@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.
Files changed (251) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/SPEC.md +2269 -0
  4. package/bin/ab-build-runner.ts +47 -0
  5. package/bin/ab-dev.ts +27 -0
  6. package/bin/ab-dispatch-kernel.ts +92 -0
  7. package/bin/ab-harvest-runner.ts +29 -0
  8. package/bin/ab-init-probe.ts +19 -0
  9. package/bin/ab-process-group-reaper.ts +32 -0
  10. package/bin/ab.ts +6 -0
  11. package/bin/agent/ab +3 -0
  12. package/docs/answering-blocked-builds.md +89 -0
  13. package/docs/architecture.md +726 -0
  14. package/docs/assets/headline-wide.png +0 -0
  15. package/docs/configuration.md +1623 -0
  16. package/docs/hosted-dispatcher-cutover-validation.md +178 -0
  17. package/docs/hosted-dispatcher.md +204 -0
  18. package/docs/mcp.md +119 -0
  19. package/docs/operator-api.md +186 -0
  20. package/docs/remote-store-protocol.md +972 -0
  21. package/docs/remote-ticket-protocol.md +79 -0
  22. package/docs/setup.md +289 -0
  23. package/docs/spec-standard.md +63 -0
  24. package/docs/ticket-dependencies.md +59 -0
  25. package/docs/vercel-sandbox-rollout-probe.md +7 -0
  26. package/package.json +135 -0
  27. package/packages/core/src/cli/args.ts +74 -0
  28. package/packages/core/src/cli/artifact.ts +182 -0
  29. package/packages/core/src/cli/binary.ts +198 -0
  30. package/packages/core/src/cli/build-control.ts +789 -0
  31. package/packages/core/src/cli/build-progress.ts +55 -0
  32. package/packages/core/src/cli/bulk-control.ts +385 -0
  33. package/packages/core/src/cli/context.ts +470 -0
  34. package/packages/core/src/cli/dashboard/actions.ts +59 -0
  35. package/packages/core/src/cli/dashboard/cells.ts +92 -0
  36. package/packages/core/src/cli/dashboard/composer.ts +230 -0
  37. package/packages/core/src/cli/dashboard/detail.ts +100 -0
  38. package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
  39. package/packages/core/src/cli/dashboard/live.ts +152 -0
  40. package/packages/core/src/cli/dashboard/model.ts +1173 -0
  41. package/packages/core/src/cli/dashboard/poll.ts +196 -0
  42. package/packages/core/src/cli/dashboard/render.ts +1568 -0
  43. package/packages/core/src/cli/dashboard/selection.ts +66 -0
  44. package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
  45. package/packages/core/src/cli/dashboard/session-view.ts +506 -0
  46. package/packages/core/src/cli/dashboard/transcript.ts +108 -0
  47. package/packages/core/src/cli/dev.ts +46 -0
  48. package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
  49. package/packages/core/src/cli/dispatch-process.ts +259 -0
  50. package/packages/core/src/cli/dispatch.ts +3692 -0
  51. package/packages/core/src/cli/dotenv.ts +48 -0
  52. package/packages/core/src/cli/env.ts +206 -0
  53. package/packages/core/src/cli/harvest.ts +698 -0
  54. package/packages/core/src/cli/help.ts +713 -0
  55. package/packages/core/src/cli/init-agent.ts +106 -0
  56. package/packages/core/src/cli/init-validation.ts +583 -0
  57. package/packages/core/src/cli/init.ts +756 -0
  58. package/packages/core/src/cli/installation.ts +333 -0
  59. package/packages/core/src/cli/keyboard.ts +261 -0
  60. package/packages/core/src/cli/main.ts +1399 -0
  61. package/packages/core/src/cli/mcp.ts +279 -0
  62. package/packages/core/src/cli/models.ts +65 -0
  63. package/packages/core/src/cli/observe.ts +55 -0
  64. package/packages/core/src/cli/plugin.ts +227 -0
  65. package/packages/core/src/cli/pr-attachments.ts +138 -0
  66. package/packages/core/src/cli/pr-summary.ts +124 -0
  67. package/packages/core/src/cli/publication-settlement.ts +188 -0
  68. package/packages/core/src/cli/repo-state.ts +213 -0
  69. package/packages/core/src/cli/repository-status.ts +80 -0
  70. package/packages/core/src/cli/self-update.ts +443 -0
  71. package/packages/core/src/cli/status.ts +895 -0
  72. package/packages/core/src/cli/store-opening.contract.ts +289 -0
  73. package/packages/core/src/cli/store-opening.ts +167 -0
  74. package/packages/core/src/cli/store-ref.ts +33 -0
  75. package/packages/core/src/cli/terminal-restore.ts +174 -0
  76. package/packages/core/src/cli/terminal.ts +361 -0
  77. package/packages/core/src/cli/terminals.ts +995 -0
  78. package/packages/core/src/cli/testkit.ts +194 -0
  79. package/packages/core/src/cli/ticket.ts +484 -0
  80. package/packages/core/src/cli/upgrade-agent.ts +180 -0
  81. package/packages/core/src/cli/upgrade-commit.ts +468 -0
  82. package/packages/core/src/cli/upgrade-notice.ts +91 -0
  83. package/packages/core/src/cli/upgrade-progress.ts +110 -0
  84. package/packages/core/src/cli/upgrade.ts +888 -0
  85. package/packages/core/src/cli/wait.ts +587 -0
  86. package/packages/core/src/cli/watch.ts +932 -0
  87. package/packages/core/src/config/live.ts +255 -0
  88. package/packages/core/src/config/load.ts +98 -0
  89. package/packages/core/src/config/roles.ts +438 -0
  90. package/packages/core/src/config/schema.ts +774 -0
  91. package/packages/core/src/distribution.ts +24 -0
  92. package/packages/core/src/events/catalog.ts +201 -0
  93. package/packages/core/src/events/envelope.ts +59 -0
  94. package/packages/core/src/events/payloads.ts +618 -0
  95. package/packages/core/src/events/repository.ts +559 -0
  96. package/packages/core/src/events/sessions.ts +180 -0
  97. package/packages/core/src/harvest/schema.ts +147 -0
  98. package/packages/core/src/ids.ts +45 -0
  99. package/packages/core/src/integration/harness.ts +681 -0
  100. package/packages/core/src/kernel/auto-merge.ts +259 -0
  101. package/packages/core/src/kernel/converge.ts +117 -0
  102. package/packages/core/src/kernel/dispatch-settings.ts +60 -0
  103. package/packages/core/src/kernel/dispatch-status.ts +141 -0
  104. package/packages/core/src/kernel/engine.ts +1047 -0
  105. package/packages/core/src/kernel/harvest.ts +823 -0
  106. package/packages/core/src/kernel/origin.ts +69 -0
  107. package/packages/core/src/kernel/phases.ts +196 -0
  108. package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
  109. package/packages/core/src/kernel/pr-attachments.ts +82 -0
  110. package/packages/core/src/kernel/reducer.ts +752 -0
  111. package/packages/core/src/kernel/stall.ts +155 -0
  112. package/packages/core/src/kernel/verify-applicability.ts +52 -0
  113. package/packages/core/src/markdown.ts +67 -0
  114. package/packages/core/src/ontology.ts +288 -0
  115. package/packages/core/src/open-map.ts +203 -0
  116. package/packages/core/src/operator/annotations.ts +187 -0
  117. package/packages/core/src/operator/client.ts +311 -0
  118. package/packages/core/src/operator/control.ts +117 -0
  119. package/packages/core/src/operator/index.ts +62 -0
  120. package/packages/core/src/operator/presentation.ts +22 -0
  121. package/packages/core/src/operator/protocol.ts +98 -0
  122. package/packages/core/src/operator/query.ts +201 -0
  123. package/packages/core/src/operator/registry.ts +1043 -0
  124. package/packages/core/src/operator/requests.ts +138 -0
  125. package/packages/core/src/operator/sandbox.ts +458 -0
  126. package/packages/core/src/operator/server.ts +616 -0
  127. package/packages/core/src/operator/sessions.ts +223 -0
  128. package/packages/core/src/operator/tickets.ts +177 -0
  129. package/packages/core/src/plugin-sdk/index.ts +174 -0
  130. package/packages/core/src/plugins/contract-entry.ts +74 -0
  131. package/packages/core/src/plugins/contract-env.ts +3 -0
  132. package/packages/core/src/plugins/load.ts +259 -0
  133. package/packages/core/src/plugins/manifest.ts +236 -0
  134. package/packages/core/src/plugins/registry.ts +274 -0
  135. package/packages/core/src/plugins/runtimes.ts +75 -0
  136. package/packages/core/src/ports/forge/contract.ts +258 -0
  137. package/packages/core/src/ports/forge/create.ts +74 -0
  138. package/packages/core/src/ports/forge/fake.ts +488 -0
  139. package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
  140. package/packages/core/src/ports/forge/github-transport.ts +352 -0
  141. package/packages/core/src/ports/forge/github.ts +1162 -0
  142. package/packages/core/src/ports/forge/local-git.ts +568 -0
  143. package/packages/core/src/ports/runner/claude.ts +873 -0
  144. package/packages/core/src/ports/runner/cli-stream.ts +90 -0
  145. package/packages/core/src/ports/runner/codex.ts +873 -0
  146. package/packages/core/src/ports/runner/contract.ts +492 -0
  147. package/packages/core/src/ports/runner/fake.ts +213 -0
  148. package/packages/core/src/ports/runner/json-record.ts +2 -0
  149. package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
  150. package/packages/core/src/ports/runner/one-shot.ts +29 -0
  151. package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
  152. package/packages/core/src/ports/runner/pi-bridge.js +6 -0
  153. package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
  154. package/packages/core/src/ports/runner/pi.ts +617 -0
  155. package/packages/core/src/ports/runner/production.ts +74 -0
  156. package/packages/core/src/ports/runner/provider-error.ts +121 -0
  157. package/packages/core/src/ports/runner/routing.ts +234 -0
  158. package/packages/core/src/ports/runner/runtime.ts +214 -0
  159. package/packages/core/src/ports/runner/session-env.ts +34 -0
  160. package/packages/core/src/ports/runner/stream-parts.ts +103 -0
  161. package/packages/core/src/ports/tickets/contract.ts +570 -0
  162. package/packages/core/src/ports/tickets/create.ts +135 -0
  163. package/packages/core/src/ports/tickets/fake.ts +215 -0
  164. package/packages/core/src/ports/tickets/file.ts +559 -0
  165. package/packages/core/src/ports/tickets/linear.ts +811 -0
  166. package/packages/core/src/ports/tickets/operations.ts +161 -0
  167. package/packages/core/src/ports/tickets/remote-index.ts +13 -0
  168. package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
  169. package/packages/core/src/ports/tickets/remote-server.ts +185 -0
  170. package/packages/core/src/ports/tickets/remote.ts +130 -0
  171. package/packages/core/src/ports/tickets/update.ts +51 -0
  172. package/packages/core/src/ports/types.ts +528 -0
  173. package/packages/core/src/ports/workspace/build-execution.ts +123 -0
  174. package/packages/core/src/ports/workspace/contract.ts +136 -0
  175. package/packages/core/src/ports/workspace/create.ts +132 -0
  176. package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
  177. package/packages/core/src/ports/workspace/fake.ts +432 -0
  178. package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
  179. package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
  180. package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
  181. package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
  182. package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
  183. package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
  184. package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
  185. package/packages/core/src/processes/build-child-exit.ts +78 -0
  186. package/packages/core/src/processes/build-child.ts +119 -0
  187. package/packages/core/src/processes/build-execution-state.ts +95 -0
  188. package/packages/core/src/processes/build-parent-watch.ts +35 -0
  189. package/packages/core/src/processes/build-runner.ts +2614 -0
  190. package/packages/core/src/processes/dispatcher.ts +2825 -0
  191. package/packages/core/src/processes/execution-settlement.ts +171 -0
  192. package/packages/core/src/processes/harvest-child.ts +115 -0
  193. package/packages/core/src/processes/harvest-execution-state.ts +121 -0
  194. package/packages/core/src/processes/harvest-runner.ts +1656 -0
  195. package/packages/core/src/processes/harvest.ts +595 -0
  196. package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
  197. package/packages/core/src/processes/phase-failure-budget.ts +52 -0
  198. package/packages/core/src/processes/process-group-reaper.ts +35 -0
  199. package/packages/core/src/processes/process-group.ts +65 -0
  200. package/packages/core/src/processes/publication-loss.ts +62 -0
  201. package/packages/core/src/processes/publication-state.ts +93 -0
  202. package/packages/core/src/processes/sandbox-state.ts +101 -0
  203. package/packages/core/src/registry.ts +24 -0
  204. package/packages/core/src/sessions/reducer.ts +185 -0
  205. package/packages/core/src/skills.ts +7 -0
  206. package/packages/core/src/spec-standard.ts +47 -0
  207. package/packages/core/src/store/adapter.ts +80 -0
  208. package/packages/core/src/store/build-scope.ts +272 -0
  209. package/packages/core/src/store/contract.ts +2581 -0
  210. package/packages/core/src/store/local/blobs.ts +38 -0
  211. package/packages/core/src/store/local/schema.ts +180 -0
  212. package/packages/core/src/store/local/store.ts +1713 -0
  213. package/packages/core/src/store/memory.ts +1138 -0
  214. package/packages/core/src/store/phase-session.ts +425 -0
  215. package/packages/core/src/store/remote/client.ts +780 -0
  216. package/packages/core/src/store/remote/index.ts +12 -0
  217. package/packages/core/src/store/remote/protocol.ts +348 -0
  218. package/packages/core/src/store/remote/server.ts +923 -0
  219. package/packages/core/src/store/remote/token.ts +137 -0
  220. package/packages/core/src/store/remote/version.ts +10 -0
  221. package/packages/core/src/store/retention.ts +91 -0
  222. package/packages/core/src/store/session-handle.ts +265 -0
  223. package/packages/core/src/store/streams/assemble.ts +79 -0
  224. package/packages/core/src/store/streams/lock.ts +42 -0
  225. package/packages/core/src/store/streams/session-writer.ts +306 -0
  226. package/packages/core/src/store/streams/types.ts +171 -0
  227. package/packages/core/src/store/streams/wait.ts +127 -0
  228. package/packages/core/src/store/subscribe.ts +105 -0
  229. package/packages/core/src/store/types.ts +466 -0
  230. package/packages/core/src/testing/fixed.ts +44 -0
  231. package/packages/core/src/testing/store-failures.ts +32 -0
  232. package/packages/core/src/zod-issues.ts +91 -0
  233. package/skills/code-review/SKILL.md +100 -0
  234. package/skills/finalize/SKILL.md +44 -0
  235. package/skills/guide/SKILL.md +1781 -0
  236. package/skills/guide/references/operator-api.md +164 -0
  237. package/skills/guide/references/plugin-authoring.md +354 -0
  238. package/skills/guide/references/remote-store-protocol.md +972 -0
  239. package/skills/guide/references/remote-ticket-protocol.md +79 -0
  240. package/skills/guide/references/setup.md +289 -0
  241. package/skills/guide/references/spec-standard.md +63 -0
  242. package/skills/guide/references/ticket-dependencies.md +59 -0
  243. package/skills/harvest/SKILL.md +66 -0
  244. package/skills/harvest-review/SKILL.md +63 -0
  245. package/skills/implement/SKILL.md +82 -0
  246. package/skills/plan/SKILL.md +123 -0
  247. package/skills/plan-review/SKILL.md +121 -0
  248. package/skills/reconcile/SKILL.md +65 -0
  249. package/skills/spec/SKILL.md +132 -0
  250. package/skills/tickets/SKILL.md +139 -0
  251. package/templates/autobuild.toml +36 -0
@@ -0,0 +1,470 @@
1
+ /**
2
+ * The `ab context` materializer (SPEC §8.3): a phase-scoped hydration of the
3
+ * gitignored `.ab/` scratch dir. Everything derives from
4
+ * `PHASE_SPECS[phase].inputs` — the table drives, so the CLI, the manifest,
5
+ * and the engine all validate against the same data (no per-phase switch
6
+ * sprawl). Scoping is deliberate: what a phase *can't* see is part of its
7
+ * design (the planner never sees code-review rounds; the reviewer sees prior
8
+ * findings but not the producer's session).
9
+ *
10
+ * Disposable `.ab/` content is wiped and recreated on every run — stale
11
+ * context is worse than no context — while legacy tracked entries are restored
12
+ * byte-for-byte from the index. Git's local exclude keeps generated scratch
13
+ * ignored without mutating the repository's own `.gitignore`.
14
+ */
15
+ import { mkdir, rm, writeFile } from 'node:fs/promises'
16
+ import { dirname, join } from 'node:path'
17
+ import { parseConfig } from '../config/load'
18
+ import type { VerifyStepConfig } from '../config/schema'
19
+ import type { AbEvent } from '../events/catalog'
20
+ import {
21
+ allowedTerminals,
22
+ phaseSpecFor,
23
+ type PhaseSpec,
24
+ type TerminalCommand,
25
+ } from '../kernel/phases'
26
+ import { reduceBuild, type BuildState } from '../kernel/reducer'
27
+ import {
28
+ isVerifyPhase,
29
+ verifyReportKind,
30
+ verifyStep,
31
+ type ArtifactRef,
32
+ type CommitRange,
33
+ type Feedback,
34
+ type Finding,
35
+ type Phase,
36
+ } from '../ontology'
37
+ import { KERNEL } from '../events/envelope'
38
+ import type { IdSource } from '../ids'
39
+ import {
40
+ allocateScratchPath,
41
+ ensureScratchExcluded,
42
+ restoreTrackedScratch,
43
+ trackedScratchPaths,
44
+ } from '../ports/workspace/phase-scratch'
45
+ import type { Exec } from '../ports/workspace/git-worktree'
46
+ import type { BuildStore } from '../store/types'
47
+ import type { CliEnv } from './env'
48
+
49
+ export interface ContextDeps {
50
+ store: BuildStore
51
+ env: CliEnv
52
+ /** Root of the working copy; `.ab/` is created directly under it. */
53
+ workspacePath: string
54
+ /** Present in the wired CLI; optional for non-Git materializer unit tests. */
55
+ exec?: Exec
56
+ ids?: IdSource
57
+ }
58
+
59
+ /** Paths are relative to `.ab/`, forward-slash separated. */
60
+ export type MaterializedEntry = ArtifactRef | 'derived'
61
+
62
+ /**
63
+ * The manifest (§8.3): the agent's contract — `required` deposits and
64
+ * `allowedTerminals` — sourced from the same PHASE_SPECS table the terminal
65
+ * commands validate with, so skills are self-checking.
66
+ */
67
+ export interface ContextManifest {
68
+ build: string
69
+ phase: Phase
70
+ round: number
71
+ /**
72
+ * Artifact kinds the phase's terminal will validate (D5). For agent-verify
73
+ * phases this is `verify-report:<step>`, required only on a `fail` verdict
74
+ * (§8.2) — listed so the agent knows the kind name up front.
75
+ */
76
+ required: string[]
77
+ allowedTerminals: TerminalCommand[]
78
+ /** Workspace-relative path where this manifest was written. */
79
+ manifestPath: string
80
+ /** Safe workspace-relative producer notes path (implement/reconcile only). */
81
+ notesPath?: string
82
+ /** relPath (under .ab/) → source artifact ref, or 'derived'. */
83
+ materialized: Record<string, MaterializedEntry>
84
+ /** From the latest `implement.completed` (§8.3 code-review/verify inputs). */
85
+ commitRange?: CommitRange
86
+ /** Reconcile: `{baseSha}` freshly recorded by this attempt's phase start (§15.7). */
87
+ conflict?: { baseSha: string }
88
+ /** Agent-verify: the step's config from the workspace's autobuild.toml (§16.1). */
89
+ step?: { name: string; config: VerifyStepConfig }
90
+ /** The feedback this phase occurrence was started with (§15.3, §10). */
91
+ feedback?: Feedback
92
+ /** Chains a human resolved via dismiss-finding — the reviewer is told so (§15.6-B). */
93
+ dismissedFindingIds?: string[]
94
+ }
95
+
96
+ function requiredKinds(spec: PhaseSpec, phase: Phase): string[] {
97
+ if (spec.requiredArtifact !== undefined) return [spec.requiredArtifact]
98
+ // Agent-verify: the per-step report kind, required only on fail (§8.2).
99
+ if (isVerifyPhase(phase)) return [verifyReportKind(verifyStep(phase))]
100
+ return []
101
+ }
102
+
103
+ /** All finding objects ever deposited, by id — verdict payloads carry them in
104
+ * full (§15.3), so feedback-by-id resolves without touching artifacts. */
105
+ function findingsById(events: AbEvent[]): Map<string, Finding> {
106
+ const byId = new Map<string, Finding>()
107
+ for (const event of events) {
108
+ if (event.type === 'plan-review.verdict' || event.type === 'code-review.verdict') {
109
+ for (const finding of event.payload.findings) byId.set(finding.id, finding)
110
+ }
111
+ }
112
+ return byId
113
+ }
114
+
115
+ /**
116
+ * The feedback carried by this occurrence's `*.started` event (§15.3).
117
+ * `implement.started`, `plan.started`, and agent `verify.started` may carry
118
+ * feedback — the carrier that gets answered guidance to a fresh session after
119
+ * a parked build re-attaches (§15.6-B). A `plan.started` without one (logs
120
+ * predating the field) falls
121
+ * back to deriving round-N feedback from the round-(N-1)
122
+ * `plan-review.verdict` — the plan loop's verdicts ARE its findings channel
123
+ * (§8.3: "prior plan rev + findings").
124
+ */
125
+ function currentFeedback(events: AbEvent[], phase: Phase, round: number): Feedback | null {
126
+ if (isVerifyPhase(phase)) {
127
+ const step = verifyStep(phase)
128
+ for (let i = events.length - 1; i >= 0; i -= 1) {
129
+ const event = events[i]!
130
+ if (
131
+ event.type === 'verify.started' &&
132
+ event.payload.step === step &&
133
+ event.payload.attempt === round
134
+ ) {
135
+ return event.payload.feedback ?? null
136
+ }
137
+ }
138
+ return null
139
+ }
140
+ if (phase === 'implement') {
141
+ for (let i = events.length - 1; i >= 0; i -= 1) {
142
+ const event = events[i]!
143
+ if (event.type === 'implement.started' && event.payload.round === round) {
144
+ return event.payload.feedback ?? null
145
+ }
146
+ }
147
+ return null
148
+ }
149
+ if (phase === 'plan') {
150
+ for (let i = events.length - 1; i >= 0; i -= 1) {
151
+ const event = events[i]!
152
+ if (event.type === 'plan.started' && event.payload.round === round) {
153
+ if (event.payload.feedback !== undefined) return event.payload.feedback
154
+ break // legacy start without the field — derive from the prior verdict
155
+ }
156
+ }
157
+ if (round <= 1) return null
158
+ for (let i = events.length - 1; i >= 0; i -= 1) {
159
+ const event = events[i]!
160
+ if (event.type === 'plan-review.verdict' && event.payload.round === round - 1) {
161
+ return event.payload.verdict === 'revise'
162
+ ? { findings: event.payload.findings.map((finding) => finding.id) }
163
+ : null
164
+ }
165
+ }
166
+ }
167
+ return null
168
+ }
169
+
170
+ /** Empty and recreate `.ab/`; callers restore any tracked legacy entries. */
171
+ async function wipeAbDir(abDir: string): Promise<void> {
172
+ await rm(abDir, { recursive: true, force: true })
173
+ await mkdir(abDir, { recursive: true })
174
+ }
175
+
176
+ function dismissedIds(state: BuildState, events: AbEvent[]): string[] {
177
+ const known = findingsById(events)
178
+ const ids = new Set<string>()
179
+ for (const escalation of state.answeredEscalations) {
180
+ if (escalation.resolution !== 'dismiss-finding') continue
181
+ for (const ref of escalation.refs ?? []) {
182
+ if (known.has(ref)) ids.add(ref)
183
+ }
184
+ }
185
+ return [...ids]
186
+ }
187
+
188
+ /**
189
+ * Materialize the phase's `.ab/` tree and return the manifest (also written
190
+ * as `.ab/context.json`).
191
+ */
192
+ export async function buildContext(deps: ContextDeps): Promise<ContextManifest> {
193
+ const { store, env, workspacePath } = deps
194
+ const { build, phase, round } = env
195
+ const spec = phaseSpecFor(phase)
196
+ const inputs = spec.inputs
197
+ const events = await store.getEvents(build)
198
+ const state = reduceBuild(events)
199
+
200
+ // Preserve legacy tracked scratch exactly while replacing disposable files.
201
+ // Some adapter contract fixtures intentionally use a non-Git workspace;
202
+ // context hydration still works there, while publication remains Git-only.
203
+ const abDir = join(workspacePath, '.ab')
204
+ const gitWorkspace =
205
+ deps.exec !== undefined &&
206
+ (await deps.exec(['git', 'rev-parse', '--is-inside-work-tree'], { cwd: workspacePath }))
207
+ .exitCode === 0
208
+ const tracked = gitWorkspace ? await trackedScratchPaths(deps.exec!, workspacePath) : []
209
+ await wipeAbDir(abDir)
210
+ if (gitWorkspace) {
211
+ await restoreTrackedScratch(deps.exec!, workspacePath, tracked)
212
+ await ensureScratchExcluded(deps.exec!, workspacePath)
213
+ }
214
+ if (!tracked.includes('.ab/.gitignore')) await writeFile(join(abDir, '.gitignore'), '*\n')
215
+
216
+ const trackedSet = new Set(tracked)
217
+ const allocated = new Set<string>()
218
+ const locations = new Map<string, string>()
219
+ const locate = (desired: string): string => {
220
+ const prior = locations.get(desired)
221
+ if (prior !== undefined) return prior
222
+ const path = allocateScratchPath(desired, trackedSet, allocated)
223
+ locations.set(desired, path)
224
+ return path
225
+ }
226
+ const manifestRelPath = locate('context.json')
227
+ const notesRelPath =
228
+ phase === 'implement' || phase === 'reconcile' ? locate(`${phase}-notes.md`) : undefined
229
+ const materialized: Record<string, MaterializedEntry> = {}
230
+
231
+ async function writeDerived(desiredPath: string, content: string): Promise<void> {
232
+ const relPath = locate(desiredPath)
233
+ const target = join(abDir, relPath)
234
+ await mkdir(dirname(target), { recursive: true })
235
+ await writeFile(target, content)
236
+ materialized[relPath] = 'derived'
237
+ }
238
+
239
+ /** Latest rev when `rev` omitted; silently skips absent kinds — the tree is
240
+ * a projection of what exists, and the manifest shows what materialized. */
241
+ async function writeArtifact(desiredPath: string, kind: string, rev?: number): Promise<void> {
242
+ const artifact = await store.getArtifact(build, kind, rev)
243
+ if (artifact === null) return
244
+ const relPath = locate(desiredPath)
245
+ const target = join(abDir, relPath)
246
+ await mkdir(dirname(target), { recursive: true })
247
+ await writeFile(target, artifact.content)
248
+ materialized[relPath] = { kind: artifact.meta.kind, rev: artifact.meta.revision }
249
+ }
250
+
251
+ const manifest: ContextManifest = {
252
+ build,
253
+ phase,
254
+ round,
255
+ required: requiredKinds(spec, phase),
256
+ allowedTerminals: allowedTerminals(phase),
257
+ manifestPath: `.ab/${manifestRelPath}`,
258
+ ...(notesRelPath !== undefined ? { notesPath: `.ab/${notesRelPath}` } : {}),
259
+ materialized,
260
+ }
261
+
262
+ if (inputs.ticket === true) {
263
+ const created = events.find((event) => event.type === 'build.created')
264
+ if (created !== undefined && created.type === 'build.created') {
265
+ const ticket = created.payload.ticket
266
+ const lines = [
267
+ `# Ticket: ${ticket.title ?? ticket.id}`,
268
+ '',
269
+ `- source: ${ticket.source}`,
270
+ `- id: ${ticket.id}`,
271
+ ...(ticket.url !== undefined ? [`- url: ${ticket.url}`] : []),
272
+ ...(ticket.title !== undefined ? [`- title: ${ticket.title}`] : []),
273
+ '',
274
+ ]
275
+ await writeDerived('ticket.md', lines.join('\n'))
276
+ }
277
+ }
278
+
279
+ if (inputs.spec === true) {
280
+ // §6.3 immutability: the spec a phase sees is the rev the spec.* events
281
+ // anchor (spec.imported/authored/revised → state.specRev), never the
282
+ // newest deposit — so a stray spec rev with no sanctioning event can
283
+ // never swap the contract downstream reviewers approve conformance to.
284
+ await writeArtifact('spec.md', 'spec', state.specRev)
285
+ }
286
+
287
+ // The plan a phase sees is the rev cited by the latest `plan.completed`
288
+ // (D5: the terminal validates and cites this round's deposit). For
289
+ // 'approved' inputs the engine only advances past the plan loop on an
290
+ // approve verdict (§10), so the cited rev IS the approved one; for
291
+ // plan-review's 'latest' it is the rev under review. Pinning to the
292
+ // event-derived rev — never the newest deposit — keeps an out-of-loop
293
+ // `ab artifact put plan` from swapping in a never-reviewed plan (§6.3's
294
+ // drift argument, applied to the plan).
295
+ if (inputs.plan !== undefined) {
296
+ await writeArtifact('plan.md', 'plan', state.plan.artifactRev)
297
+ }
298
+
299
+ // Producer's own prior-round artifacts (§8.3: plan sees its prior rev,
300
+ // implement its own prior notes) — the phase's required kind at latest rev;
301
+ // absent on round 1 by construction.
302
+ if (inputs.priorOwnArtifacts === true && spec.requiredArtifact !== undefined) {
303
+ await writeArtifact(`${spec.requiredArtifact}.md`, spec.requiredArtifact)
304
+ }
305
+
306
+ if (inputs.implementNotes === true) {
307
+ await writeArtifact('implement-notes.md', 'implement-notes')
308
+ }
309
+
310
+ // Verify reports at latest rev, one file per step (§8.3: routed-back
311
+ // failure reports for implement; the full set for finalize).
312
+ if (inputs.verifyReports === true) {
313
+ const metas = await store.listArtifacts(build)
314
+ const kinds = new Set(
315
+ metas.map((meta) => meta.kind).filter((kind) => kind.startsWith('verify-report:')),
316
+ )
317
+ for (const kind of kinds) {
318
+ const step = kind.slice('verify-report:'.length)
319
+ await writeArtifact(`verify/${step}.md`, kind)
320
+ }
321
+ }
322
+
323
+ if (inputs.findings === 'current' || inputs.currentFeedback === true) {
324
+ const feedback = currentFeedback(events, phase, round)
325
+ if (feedback !== null) {
326
+ manifest.feedback = feedback
327
+ if ('findings' in feedback) {
328
+ // Ids → full Finding objects from the verdict that produced them.
329
+ const byId = findingsById(events)
330
+ const resolved = feedback.findings
331
+ .map((id) => byId.get(id))
332
+ .filter((finding): finding is Finding => finding !== undefined)
333
+ await writeDerived('findings.json', `${JSON.stringify(resolved, null, 2)}\n`)
334
+ } else if ('verify' in feedback) {
335
+ // The verify failure report routed back into the code loop (§5) — the
336
+ // exact rev the feedback cites, not just the latest.
337
+ await writeArtifact(
338
+ `verify/${feedback.verify.step}.md`,
339
+ feedback.verify.report.kind,
340
+ feedback.verify.report.rev,
341
+ )
342
+ } else {
343
+ // A human escalation answer fed in as authoritative feedback (§15.6-B).
344
+ await writeDerived('guidance.json', `${JSON.stringify(feedback.guidance, null, 2)}\n`)
345
+ }
346
+ }
347
+ }
348
+
349
+ if (inputs.findings === 'all-rounds') {
350
+ // Every prior round's findings, for `persists` marking (§15.4), plus the
351
+ // human-dismissed ids — the reviewer is told so (§15.6-B).
352
+ const loop =
353
+ phase === 'plan-review' ? state.reviewFindings.planReview : state.reviewFindings.codeReview
354
+ for (let r = 1; r < round && r <= loop.length; r += 1) {
355
+ await writeDerived(`history/findings-r${r}.json`, `${JSON.stringify(loop[r - 1], null, 2)}\n`)
356
+ }
357
+ const dismissed = dismissedIds(state, events)
358
+ manifest.dismissedFindingIds = dismissed
359
+ if (dismissed.length > 0) {
360
+ await writeDerived('dismissed-findings.json', `${JSON.stringify(dismissed, null, 2)}\n`)
361
+ }
362
+ }
363
+
364
+ // Answered guidance addressed to THIS phase (§15.6-B, §11): finalize and
365
+ // reconcile have no producer round for the engine to feed the answer into,
366
+ // so `ab context` IS their delivery channel — without this the answer would
367
+ // be recorded but never reach any agent. Latest answer wins, matching the
368
+ // engine's loop-feedback rule; the file shape matches the loop producers'
369
+ // guidance.json so skills read one format.
370
+ if (inputs.answeredGuidance === true) {
371
+ const answered = state.answeredEscalations.filter(
372
+ (escalation) => escalation.resolution === 'guidance' && escalation.phase === phase,
373
+ )
374
+ const latest = answered.at(-1) // answeredEscalations is in answer order
375
+ if (latest !== undefined) {
376
+ await writeDerived(
377
+ 'guidance.json',
378
+ `${JSON.stringify({ escalation: latest.id, answer: latest.answer }, null, 2)}\n`,
379
+ )
380
+ }
381
+ }
382
+
383
+ if (inputs.commitRange === true && state.implement.commits !== undefined) {
384
+ manifest.commitRange = state.implement.commits
385
+ }
386
+
387
+ if (inputs.conflict === true) {
388
+ // `pr.conflicted.baseSha` is the janitor's detection-time evidence. The
389
+ // runner refreshes the remote base immediately before each actual run and
390
+ // records the execution input in reconcile.started. A crashed same-attempt
391
+ // rerun can therefore have multiple starts; the newest matching AB_PHASE
392
+ // attempt is authoritative.
393
+ for (let i = events.length - 1; i >= 0; i -= 1) {
394
+ const event = events[i]!
395
+ if (event.type === 'reconcile.started' && event.payload.attempt === round) {
396
+ manifest.conflict = { baseSha: event.payload.baseSha }
397
+ break
398
+ }
399
+ }
400
+ if (manifest.conflict === undefined) {
401
+ throw new Error(
402
+ `reconcile@${round} context requires a matching reconcile.started event ` +
403
+ 'with the freshly resolved base SHA — the runner must record the phase start before launching the session',
404
+ )
405
+ }
406
+ }
407
+
408
+ if (inputs.stepConfig === true && isVerifyPhase(phase)) {
409
+ const step = verifyStep(phase)
410
+ const tomlPath = join(workspacePath, 'autobuild.toml')
411
+ const file = Bun.file(tomlPath)
412
+ if (!(await file.exists())) {
413
+ throw new Error(
414
+ `verify:${step} needs its step config, but ${tomlPath} does not exist — ` +
415
+ 'autobuild.toml declares [verify.<step>] tables (SPEC §16.1)',
416
+ )
417
+ }
418
+ const config = parseConfig(await file.text(), tomlPath)
419
+ const stepConfig = config.verify.stepConfigs[step]
420
+ if (stepConfig === undefined) {
421
+ const known = Object.keys(config.verify.stepConfigs)
422
+ throw new Error(
423
+ `verify step "${step}" is not configured in autobuild.toml — ` +
424
+ `configured steps: ${known.join(', ') || '(none)'} (SPEC §16.1)`,
425
+ )
426
+ }
427
+ manifest.step = { name: step, config: stepConfig }
428
+ }
429
+
430
+ // inputs.prTemplate (§8.3 finalize): autobuild.toml declares no PR-template
431
+ // table in §16.1, so there is nothing to materialize yet — the input stays
432
+ // in the table for when config grows one.
433
+
434
+ await writeFile(join(abDir, manifestRelPath), `${JSON.stringify(manifest, null, 2)}\n`)
435
+
436
+ // Legacy tracked scratch is repository debt, not content Autobuild rewrites.
437
+ // Record it once per build from the provisioned base so harvest can groom it.
438
+ if (gitWorkspace && deps.ids !== undefined) {
439
+ const provisioned = events.find((event) => event.type === 'workspace.provisioned')
440
+ const marker = 'phase-scratch:tracked-at-provisioned-base'
441
+ const alreadyRecorded = events.some(
442
+ (event) => event.type === 'observation.recorded' && event.payload.refs?.includes(marker),
443
+ )
444
+ if (
445
+ provisioned !== undefined &&
446
+ provisioned.type === 'workspace.provisioned' &&
447
+ !alreadyRecorded
448
+ ) {
449
+ const baseTracked = await trackedScratchPaths(
450
+ deps.exec!,
451
+ workspacePath,
452
+ provisioned.payload.base.sha,
453
+ )
454
+ if (baseTracked.length > 0) {
455
+ await store.append(build, {
456
+ actor: KERNEL,
457
+ type: 'observation.recorded',
458
+ payload: {
459
+ id: deps.ids('obs'),
460
+ kind: 'followup',
461
+ summary: `repository base tracks Autobuild phase scratch: ${baseTracked.join(', ')}`,
462
+ files: baseTracked,
463
+ refs: [marker],
464
+ },
465
+ })
466
+ }
467
+ }
468
+ }
469
+ return manifest
470
+ }
@@ -0,0 +1,59 @@
1
+ import type { DashboardBuild, DashboardHarvest, DashboardModel, EffectiveStatus } from './model'
2
+
3
+ export type DashboardBuildControl =
4
+ | { key: 'p'; action: 'pause'; label: 'pause' }
5
+ | { key: 'p'; action: 'cancel-pause'; label: 'cancel pause' }
6
+ | { key: 'r'; action: 'resume'; label: 'resume' }
7
+
8
+ /** Authoritative effective-status mapping consumed by every dashboard frontend. */
9
+ export function dashboardBuildControl(status: EffectiveStatus): DashboardBuildControl | undefined {
10
+ switch (status) {
11
+ case 'running':
12
+ return { key: 'p', action: 'pause', label: 'pause' }
13
+ case 'pausing':
14
+ return { key: 'p', action: 'cancel-pause', label: 'cancel pause' }
15
+ case 'paused':
16
+ case 'blocked':
17
+ return { key: 'r', action: 'resume', label: 'resume' }
18
+ case 'queued':
19
+ case 'resuming':
20
+ case 'aborting':
21
+ case 'cleaning':
22
+ return undefined
23
+ }
24
+ }
25
+
26
+ export interface DashboardActionAvailability {
27
+ primary?: DashboardBuildControl['action']
28
+ abort: boolean
29
+ discard: boolean
30
+ autoMerge: boolean
31
+ answer: boolean
32
+ }
33
+
34
+ export function buildActionAvailability(build: DashboardBuild): DashboardActionAvailability {
35
+ return {
36
+ ...(dashboardBuildControl(build.status)
37
+ ? { primary: dashboardBuildControl(build.status)!.action }
38
+ : {}),
39
+ abort: ['queued', 'running', 'pausing', 'paused', 'resuming', 'blocked'].includes(build.status),
40
+ discard: build.status === 'queued',
41
+ autoMerge: !['aborting', 'cleaning'].includes(build.status),
42
+ answer: build.blockers.length > 0,
43
+ }
44
+ }
45
+
46
+ export function repositoryActionAvailability(model: DashboardModel) {
47
+ return {
48
+ intake: true,
49
+ defaultAutoMerge: true,
50
+ bulkPause: model.builds.some((build) => build.status === 'running'),
51
+ bulkResume: model.builds.some((build) => build.status === 'paused'),
52
+ harvestToggle: true,
53
+ harvestRun: model.harvestPaused === false,
54
+ }
55
+ }
56
+
57
+ export function harvestActionAvailability(harvest: DashboardHarvest | undefined) {
58
+ return { resume: harvest?.action === 'resume', acknowledge: harvest?.action === 'acknowledge' }
59
+ }
@@ -0,0 +1,92 @@
1
+ /** Unicode text geometry shared by every dashboard compositor. */
2
+
3
+ const SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
4
+
5
+ export interface Grapheme {
6
+ /** Exact source bytes represented by this cluster. */
7
+ text: string
8
+ /** UTF-16 boundaries, suitable for String.prototype.slice. */
9
+ start: number
10
+ end: number
11
+ /** Terminal display-cell width. */
12
+ width: number
13
+ }
14
+
15
+ /** Segment a string into indivisible extended grapheme clusters. */
16
+ export function graphemes(value: string): Grapheme[] {
17
+ return [...SEGMENTER.segment(value)].map(({ segment, index }) => ({
18
+ text: segment,
19
+ start: index,
20
+ end: index + segment.length,
21
+ width: Bun.stringWidth(segment),
22
+ }))
23
+ }
24
+
25
+ function isTerminalControl(code: number): boolean {
26
+ return code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)
27
+ }
28
+
29
+ /**
30
+ * Preserve readable Unicode while making terminal control characters visible.
31
+ * This is display-only: callers retain and submit the original value.
32
+ */
33
+ export function displayText(value: string): string {
34
+ let displayed = ''
35
+ for (const character of value) {
36
+ const code = character.codePointAt(0)!
37
+ displayed += isTerminalControl(code) ? `\\u{${code.toString(16)}}` : character
38
+ }
39
+ return displayed
40
+ }
41
+
42
+ /** Display-safe clusters, with widths measured after controls are escaped. */
43
+ export function displayGraphemes(value: string): Grapheme[] {
44
+ return graphemes(displayText(value))
45
+ }
46
+
47
+ /** Width in terminal display cells. Input must not contain ANSI sequences. */
48
+ export function cellWidth(value: string): number {
49
+ return graphemes(value).reduce((sum, cluster) => sum + cluster.width, 0)
50
+ }
51
+
52
+ /** Pad to a terminal-cell width without trimming an over-wide value. */
53
+ export function padEndCells(value: string, width: number): string {
54
+ return `${value}${' '.repeat(Math.max(0, width - cellWidth(value)))}`
55
+ }
56
+
57
+ /**
58
+ * Split into cell-bounded rows without splitting a grapheme cluster. A cluster
59
+ * wider than the complete row budget is omitted, and later clusters resume.
60
+ */
61
+ export function splitCells(value: string, width: number): string[] {
62
+ if (width <= 0) return []
63
+ const rows: string[] = []
64
+ let row = ''
65
+ let used = 0
66
+ for (const cluster of graphemes(value)) {
67
+ // At an impossible width, omission is safer than a partial cluster or an
68
+ // overflowing physical terminal row.
69
+ if (cluster.width > width) continue
70
+ if (cluster.width > 0 && used + cluster.width > width) {
71
+ rows.push(row)
72
+ row = ''
73
+ used = 0
74
+ }
75
+ row += cluster.text
76
+ used += cluster.width
77
+ }
78
+ if (row !== '' || rows.length === 0) rows.push(row)
79
+ return rows
80
+ }
81
+
82
+ /** Normalize an arbitrary UTF-16 offset to the preceding grapheme boundary. */
83
+ export function graphemeBoundary(value: string, offset: number): number {
84
+ if (!Number.isFinite(offset)) return value.length
85
+ const wanted = Math.max(0, Math.min(value.length, Math.floor(offset)))
86
+ let boundary = 0
87
+ for (const cluster of graphemes(value)) {
88
+ if (cluster.end > wanted) return cluster.start
89
+ boundary = cluster.end
90
+ }
91
+ return boundary
92
+ }