@defrex/autobuild 0.8.0 → 0.9.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 (147) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +313 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +57 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +151 -25
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +169 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +10 -0
  13. package/package.json +43 -23
  14. package/packages/core/src/cli/artifact.ts +130 -6
  15. package/packages/core/src/cli/binary.ts +9 -1
  16. package/packages/core/src/cli/build-control.ts +110 -13
  17. package/packages/core/src/cli/context.ts +46 -0
  18. package/packages/core/src/cli/dashboard/model.ts +57 -7
  19. package/packages/core/src/cli/dashboard/poll.ts +69 -10
  20. package/packages/core/src/cli/dashboard/render.ts +34 -5
  21. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  22. package/packages/core/src/cli/dispatch.ts +320 -46
  23. package/packages/core/src/cli/harvest.ts +122 -8
  24. package/packages/core/src/cli/help.ts +47 -8
  25. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  26. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  27. package/packages/core/src/cli/init-validation.ts +248 -222
  28. package/packages/core/src/cli/main.ts +70 -11
  29. package/packages/core/src/cli/mcp.ts +23 -1
  30. package/packages/core/src/cli/plugin.ts +7 -4
  31. package/packages/core/src/cli/remote-poll.ts +277 -0
  32. package/packages/core/src/cli/repo-state.ts +22 -0
  33. package/packages/core/src/cli/repository-status.ts +39 -1
  34. package/packages/core/src/cli/status.ts +149 -27
  35. package/packages/core/src/cli/terminals.ts +2 -1
  36. package/packages/core/src/cli/ticket-assets.ts +247 -0
  37. package/packages/core/src/cli/ticket.ts +307 -7
  38. package/packages/core/src/cli/wait.ts +147 -39
  39. package/packages/core/src/cli/watch.ts +128 -166
  40. package/packages/core/src/config/live.ts +22 -0
  41. package/packages/core/src/config/pipeline-source.ts +376 -0
  42. package/packages/core/src/config/schema.ts +230 -19
  43. package/packages/core/src/events/catalog.ts +1 -0
  44. package/packages/core/src/events/globs.ts +47 -0
  45. package/packages/core/src/events/payloads.ts +52 -2
  46. package/packages/core/src/events/repository.ts +52 -1
  47. package/packages/core/src/events/sessions.ts +69 -10
  48. package/packages/core/src/events/workspace-remote.ts +12 -0
  49. package/packages/core/src/integration/harness.ts +2 -1
  50. package/packages/core/src/kernel/auto-merge-default.ts +123 -0
  51. package/packages/core/src/kernel/phases.ts +16 -2
  52. package/packages/core/src/kernel/reducer.ts +40 -1
  53. package/packages/core/src/operator/annotations.ts +6 -0
  54. package/packages/core/src/operator/control.ts +19 -5
  55. package/packages/core/src/operator/index.ts +71 -6
  56. package/packages/core/src/operator/query.ts +208 -19
  57. package/packages/core/src/operator/registry.ts +55 -10
  58. package/packages/core/src/operator/requests.ts +9 -6
  59. package/packages/core/src/operator/sandbox-host.ts +140 -0
  60. package/packages/core/src/operator/sandbox.ts +247 -14
  61. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  62. package/packages/core/src/orchestrator/approvals.ts +115 -0
  63. package/packages/core/src/orchestrator/conversation.ts +167 -0
  64. package/packages/core/src/orchestrator/model.ts +86 -0
  65. package/packages/core/src/orchestrator/prompt.ts +61 -0
  66. package/packages/core/src/orchestrator/tools.ts +117 -0
  67. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  68. package/packages/core/src/plugin-sdk/index.ts +51 -1
  69. package/packages/core/src/plugins/load.ts +135 -7
  70. package/packages/core/src/plugins/manifest.ts +183 -10
  71. package/packages/core/src/plugins/registry.ts +73 -4
  72. package/packages/core/src/ports/forge/create.ts +1 -1
  73. package/packages/core/src/ports/forge/fake.ts +31 -0
  74. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  75. package/packages/core/src/ports/forge/github.ts +13 -2
  76. package/packages/core/src/ports/forge/local-git.ts +31 -1
  77. package/packages/core/src/ports/runner/claude.ts +2 -2
  78. package/packages/core/src/ports/runner/codex.ts +6 -2
  79. package/packages/core/src/ports/runner/fake.ts +7 -1
  80. package/packages/core/src/ports/runner/pi.ts +8 -6
  81. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  82. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  83. package/packages/core/src/ports/types.ts +17 -2
  84. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  85. package/packages/core/src/ports/workspace/create.ts +74 -39
  86. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  87. package/packages/core/src/ports/workspace/fake.ts +169 -4
  88. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  89. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  90. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  91. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  92. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  93. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  94. package/packages/core/src/processes/build-child.ts +17 -2
  95. package/packages/core/src/processes/build-execution-state.ts +37 -0
  96. package/packages/core/src/processes/build-runner.ts +54 -3
  97. package/packages/core/src/processes/dispatcher.ts +247 -41
  98. package/packages/core/src/processes/harvest-child.ts +5 -0
  99. package/packages/core/src/processes/harvest-runner.ts +66 -19
  100. package/packages/core/src/processes/harvest.ts +196 -24
  101. package/packages/core/src/processes/orchestrator-tick.ts +319 -0
  102. package/packages/core/src/processes/sandbox-state.ts +10 -0
  103. package/packages/core/src/store/adapter.ts +32 -0
  104. package/packages/core/src/store/build-scope.ts +64 -0
  105. package/packages/core/src/store/contract.ts +868 -0
  106. package/packages/core/src/store/digest.ts +49 -0
  107. package/packages/core/src/store/local/schema.ts +29 -2
  108. package/packages/core/src/store/local/store.ts +338 -4
  109. package/packages/core/src/store/memory.ts +179 -0
  110. package/packages/core/src/store/new-build.ts +17 -0
  111. package/packages/core/src/store/phase-session.ts +71 -0
  112. package/packages/core/src/store/remote/client.ts +260 -1
  113. package/packages/core/src/store/remote/index.ts +12 -2
  114. package/packages/core/src/store/remote/protocol.ts +100 -12
  115. package/packages/core/src/store/remote/version.ts +6 -2
  116. package/packages/core/src/store/repo-state-events.ts +140 -0
  117. package/packages/core/src/store/retention.ts +7 -0
  118. package/packages/core/src/store/session-handle.ts +64 -0
  119. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
  120. package/packages/core/src/store/streams/session-writer.ts +42 -2
  121. package/packages/core/src/store/streams/wait.ts +4 -5
  122. package/packages/core/src/store/ticket-assets.ts +425 -0
  123. package/packages/core/src/store/types.ts +122 -1
  124. package/packages/core/src/testing/index.ts +59 -0
  125. package/skills/code-review/SKILL.md +5 -1
  126. package/skills/guide/SKILL.md +126 -33
  127. package/skills/guide/references/operator-api.md +2 -2
  128. package/skills/guide/references/plugin-authoring.md +17 -0
  129. package/skills/guide/references/remote-store-protocol.md +169 -16
  130. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  131. package/skills/guide/references/setup.md +12 -9
  132. package/skills/implement/SKILL.md +3 -0
  133. package/skills/operate/SKILL.md +126 -0
  134. package/skills/plan/SKILL.md +4 -0
  135. package/skills/plan-review/SKILL.md +5 -1
  136. package/skills/spec/SKILL.md +46 -3
  137. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  138. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  139. package/packages/core/src/cli/testkit.ts +0 -194
  140. package/packages/core/src/markdown.ts +0 -67
  141. package/packages/core/src/operator/client.ts +0 -311
  142. package/packages/core/src/operator/server.ts +0 -616
  143. package/packages/core/src/operator/sessions.ts +0 -223
  144. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  145. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  146. package/packages/core/src/store/remote/server.ts +0 -923
  147. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -67,6 +67,7 @@ export const allowedActorKinds: Record<EventType, readonly ActorKind[]> = {
67
67
  'build.discard-requested': ['human'],
68
68
  'build.auto-merge-requested': ['human'],
69
69
  'build.auto-merge-cancelled': ['human'],
70
+ 'build.auto-merge-default-observed': ['dispatcher'],
70
71
  'build.paused': ['kernel'],
71
72
  'build.resumed': ['kernel'],
72
73
  'build.aborted': ['kernel', 'dispatcher'],
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shared event-glob compilation (§15.3, AUT-342): the anchored glob matcher
3
+ * `ab watch`, `ab wait`, and the `[orchestrator].wake` configuration use.
4
+ * `*` matches any run of characters, `?` exactly one, everything else
5
+ * verbatim (so the dots inside event type names are escaped). Every glob
6
+ * must match at least one event type in one of the given catalogs, else a
7
+ * validation error naming the glob is thrown.
8
+ */
9
+ import { EVENT_TYPES } from './payloads'
10
+ import { REPOSITORY_EVENT_TYPES } from './repository'
11
+
12
+ export interface CompileEventGlobsOptions {
13
+ /** Also accept matches in the repository-journal catalog (the `--repository`
14
+ * behavior of `ab watch`/`ab wait`). */
15
+ repository?: boolean
16
+ usage: string
17
+ }
18
+
19
+ export function compileEventGlobs(
20
+ globs: readonly string[],
21
+ opts: CompileEventGlobsOptions,
22
+ ): RegExp[] {
23
+ return globs.map((glob) => {
24
+ const source =
25
+ '^' +
26
+ [...glob]
27
+ .map((char) => {
28
+ if (char === '*') return '.*'
29
+ if (char === '?') return '.'
30
+ return char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
31
+ })
32
+ .join('') +
33
+ '$'
34
+ const regex = new RegExp(source)
35
+ const inBuildCatalog = EVENT_TYPES.some((type) => regex.test(type))
36
+ const inRepositoryCatalog =
37
+ opts.repository === true && REPOSITORY_EVENT_TYPES.some((type) => regex.test(type))
38
+ if (!inBuildCatalog && !inRepositoryCatalog) {
39
+ throw new Error(
40
+ `--event "${glob}" matches no known event type${
41
+ opts.repository ? ' in the build or repository catalogs' : ' in the build event catalog'
42
+ } — ${opts.usage}`,
43
+ )
44
+ }
45
+ return regex
46
+ })
47
+ }
@@ -36,6 +36,23 @@ import {
36
36
 
37
37
  const empty = z.strictObject({})
38
38
  const reasonOnly = z.strictObject({ reason: z.string().optional() })
39
+ /** Fan-out provenance shared by the auto-merge command facts: the repository
40
+ * seq of the `dispatcher.auto-merge-default-set` fact that caused the command,
41
+ * when one did. Per-build commands omit it. `{}` stays valid, so existing
42
+ * producers and logs replay unchanged. */
43
+ const autoMergeCommand = z.strictObject({
44
+ defaultSeq: z.number().int().positive().optional(),
45
+ })
46
+ /** A fan-out reconciliation marker: the repository seq of the newest
47
+ * `dispatcher.auto-merge-default-set` fact a build has observed when that fact
48
+ * already matched the build's requested state. It advances the build's
49
+ * provenance without appending a duplicate request/cancel command, so a later
50
+ * per-build toggle stands until a strictly newer default fact arrives
51
+ * (f_28b3fba1). Dispatcher-authored: it records a reconciliation, not a
52
+ * human command. */
53
+ const autoMergeDefaultObserved = z.strictObject({
54
+ defaultSeq: z.number().int().positive(),
55
+ })
39
56
  const round = z.number().int().positive()
40
57
  const attempt = z.number().int().positive()
41
58
  const dispatchStage = z.enum(['create', 'workspace', 'spec', 'comment', 'launch'])
@@ -175,6 +192,20 @@ const finalizeStepCompletedPayloadSchema = z.discriminatedUnion('ok', [
175
192
  }),
176
193
  ])
177
194
 
195
+ /** One ticket asset revision a build froze (SPEC §6.3): the identity and
196
+ * shape of what `listTicketAssets` returned at the pinning moment. */
197
+ export const pinnedAssetSchema = z.strictObject({
198
+ kind: z.string().min(1),
199
+ name: z.string().min(1),
200
+ revision: z.number().int().nonnegative(),
201
+ layout: z.enum(['file', 'tree']),
202
+ size: z.number().int().nonnegative(),
203
+ fileCount: z.number().int().nonnegative(),
204
+ dirCount: z.number().int().nonnegative(),
205
+ })
206
+
207
+ export type PinnedAsset = z.infer<typeof pinnedAssetSchema>
208
+
178
209
  /** Shared by `plan-review.verdict` and `code-review.verdict` (symmetric by design). */
179
210
  const reviewVerdictPayload = z.strictObject({
180
211
  round,
@@ -194,12 +225,20 @@ export const eventPayloadSchemas = {
194
225
  /** Claim-time auto-merge intent retained across dispatch recovery until the
195
226
  * ordinary human-authored command fact can be materialized. */
196
227
  autoMergeRequestedBy: z.string().min(1).optional(),
228
+ /** Claim-time auto-merge intent provenance: the repository seq of the
229
+ * `dispatcher.auto-merge-default-set` fact the claim sampled. Retained for
230
+ * the same recovery window as `autoMergeRequestedBy`; omitted when the
231
+ * claim sampled no default fact. */
232
+ autoMergeDefaultSeq: z.number().int().positive().optional(),
197
233
  /** Frozen at claim time. Historical logs and disabled installs omit it. */
198
234
  pr: z
199
235
  .strictObject({
200
236
  imageHost: prImageHostSchema.optional(),
201
237
  })
202
238
  .optional(),
239
+ /** Ticket assets frozen at claim time. Omitted when the ticket had none
240
+ * and in historical logs. */
241
+ assets: z.array(pinnedAssetSchema).optional(),
203
242
  }),
204
243
  'build.completed': z.strictObject({ outcome: buildOutcomeSchema }),
205
244
  'runner.attached': z.strictObject({
@@ -223,6 +262,10 @@ export const eventPayloadSchemas = {
223
262
  path: z.string().min(1).optional(),
224
263
  /** Dispatcher-reachable path. Remote providers intentionally omit it. */
225
264
  localPath: z.string().min(1).optional(),
265
+ /** True when the provider reported a remote publication capability at
266
+ * provision time. Absent ⇒ local (historical journals). AUT-505 removes
267
+ * the legacy fallback that reads absent as the provider name. */
268
+ remote: z.boolean().optional(),
226
269
  branch: z.string().min(1),
227
270
  base: workspaceBaseSchema,
228
271
  }),
@@ -363,8 +406,9 @@ export const eventPayloadSchemas = {
363
406
  'build.resume-requested': reasonOnly,
364
407
  'build.abort-requested': reasonOnly,
365
408
  'build.discard-requested': empty,
366
- 'build.auto-merge-requested': empty,
367
- 'build.auto-merge-cancelled': empty,
409
+ 'build.auto-merge-requested': autoMergeCommand,
410
+ 'build.auto-merge-cancelled': autoMergeCommand,
411
+ 'build.auto-merge-default-observed': autoMergeDefaultObserved,
368
412
  'build.paused': empty,
369
413
  'build.resumed': empty,
370
414
  'build.aborted': empty,
@@ -382,6 +426,9 @@ export const eventPayloadSchemas = {
382
426
  artifact: artifactRefSchema,
383
427
  /** seq of the `escalation.raised` event that forced the revision. */
384
428
  escalation: z.number().int().positive(),
429
+ /** Present (possibly empty) when the revision re-pins the build to the
430
+ * ticket's then-current assets; absent leaves the pinned set unchanged. */
431
+ assets: z.array(pinnedAssetSchema).optional(),
385
432
  }),
386
433
 
387
434
  // ── Sessions (every agent run is bracketed by these) ──────────────────────
@@ -552,6 +599,9 @@ export const eventPayloadSchemas = {
552
599
  resolution: escalationResolutionSchema,
553
600
  /** Exact replacement spec authorized by a `revise-spec` answer. */
554
601
  artifact: artifactRefSchema.optional(),
602
+ /** The ticket's assets sampled with a revise-from-ticket authorization, so a
603
+ * retry re-pins the set that was authorized. Absent otherwise. */
604
+ assets: z.array(pinnedAssetSchema).optional(),
555
605
  /** Absolute per-loop/current-spec review budget selected by the operator. */
556
606
  reviewRoundCeiling: z.number().int().positive().optional(),
557
607
  }),
@@ -76,6 +76,10 @@ const tickCountersSchema = z.strictObject({
76
76
  sandboxIdleStops: z.number().int().nonnegative().optional(),
77
77
  /** Contained idle-settlement failures this tick; retried next tick. */
78
78
  sandboxSettleFailures: z.number().int().nonnegative().optional(),
79
+ /** Contained sandbox-backend construction failures this tick; re-reported
80
+ * every tick until the config is repaired. Optional so historical tick
81
+ * facts replay. */
82
+ orchestratorSandboxFailures: z.number().int().nonnegative().optional(),
79
83
  harvestStarted: z.number().int().nonnegative(),
80
84
  harvestResumed: z.number().int().nonnegative(),
81
85
  harvestCompleted: z.number().int().nonnegative(),
@@ -264,13 +268,20 @@ export const harvestEventPayloadSchemas = {
264
268
  * `processes/sandbox-state.ts`, mirroring harvest execution settlement. */
265
269
  export const orchestratorSandboxEventPayloadSchemas = {
266
270
  /** First use of one operator's environment for this repository, or a
267
- * post-reset reprovision. Carries the full environment identity. */
271
+ * post-reset reprovision. Carries the full environment identity, plus the
272
+ * base branch head the fresh provision selected — the publication
273
+ * precondition's reference point (a publish must be a descendant of it).
274
+ * Optional so historical journals replay unchanged. */
268
275
  'orchestrator.sandbox.provisioned': z.strictObject({
269
276
  operator: z.string().min(1),
270
277
  environmentId: z.string().min(1),
271
278
  provider: z.string().min(1),
272
279
  sessionId: z.string().min(1).optional(),
273
280
  workspacePath: z.string().min(1),
281
+ baseSha: z
282
+ .string()
283
+ .regex(/^[0-9a-f]{40,64}$/i)
284
+ .optional(),
274
285
  }),
275
286
  /** A stopped environment resumed for a later tool call. */
276
287
  'orchestrator.sandbox.resumed': z.strictObject({
@@ -311,6 +322,34 @@ export const orchestratorSandboxEventPayloadSchemas = {
311
322
  operator: z.string().min(1),
312
323
  environmentId: z.string().min(1),
313
324
  }),
325
+ /** One PR-only publication from an operator sandbox: the operator, the
326
+ * delegated orchestrator session when one triggered the tool, the
327
+ * deterministic branch the commit was pushed to, the published commit, and
328
+ * the opened-or-adopted PR. Written after both the push and the forge call
329
+ * succeed; the push-before-fact discipline makes a retry complete the
330
+ * journal rather than re-push. */
331
+ 'orchestrator.sandbox.published': z.strictObject({
332
+ operator: z.string().min(1),
333
+ environmentId: z.string().min(1),
334
+ branch: z.string().min(1),
335
+ sha: z.string().regex(/^[0-9a-f]{40,64}$/i),
336
+ session: z.string().min(1).optional(),
337
+ pr: z.strictObject({
338
+ number: z.number().int().positive(),
339
+ url: z.string().min(1),
340
+ headSha: z.string().min(1),
341
+ }),
342
+ }),
343
+ /** A refused or failed publication attempt. `stage` names where it stopped
344
+ * (the precondition checks, the push, or the forge PR call); `message` is
345
+ * the provider's or forge's text, redacted of credential values by the
346
+ * service. */
347
+ 'orchestrator.sandbox.publish-failed': z.strictObject({
348
+ operator: z.string().min(1),
349
+ environmentId: z.string().min(1),
350
+ stage: z.enum(['checks', 'push', 'pr']),
351
+ message: z.string().min(1),
352
+ }),
314
353
  } as const
315
354
 
316
355
  export const dispatcherStatusEventPayloadSchemas = {
@@ -421,6 +460,16 @@ export type RepositoryEventType = keyof typeof repositoryEventPayloadSchemas
421
460
  export const REPOSITORY_EVENT_TYPES = Object.keys(
422
461
  repositoryEventPayloadSchemas,
423
462
  ) as RepositoryEventType[]
463
+
464
+ /** The repository-journal attention set, followed only under `--repository`
465
+ * by `ab watch` and scanned by the orchestrator wake pass. */
466
+ export const REPOSITORY_ATTENTION_EVENTS = [
467
+ 'harvest.escalated',
468
+ 'harvest.failed',
469
+ 'dispatcher.tick-failed',
470
+ 'dispatcher.config-rejected',
471
+ 'dispatcher.harvest-runner-failed',
472
+ ] as const satisfies readonly RepositoryEventType[]
424
473
  export type RepositoryEventPayload<T extends RepositoryEventType> = z.infer<
425
474
  (typeof repositoryEventPayloadSchemas)[T]
426
475
  >
@@ -487,6 +536,8 @@ const allowedActorKinds: Record<RepositoryEventType, readonly ActorKind[]> = {
487
536
  // `harvest.execution.released` is dispatcher-authored.
488
537
  'orchestrator.sandbox.released': ['human', 'dispatcher'],
489
538
  'orchestrator.sandbox.reset': ['human'],
539
+ 'orchestrator.sandbox.published': ['human'],
540
+ 'orchestrator.sandbox.publish-failed': ['human'],
490
541
  'dispatcher.run-started': ['dispatcher'],
491
542
  'dispatcher.run-stopped': ['dispatcher'],
492
543
  'dispatcher.tick-yielded': ['dispatcher'],
@@ -24,24 +24,62 @@ const positiveInt = z.number().int().positive()
24
24
 
25
25
  const empty = z.strictObject({})
26
26
 
27
- /** The trigger that started a turn: a human message or a wake event. */
28
- const turnTriggerSchema = z.discriminatedUnion('kind', [
29
- z.strictObject({ kind: z.literal('message'), messageSeq: positiveInt }),
30
- z.strictObject({
27
+ /** The trigger that started a turn: a human message or a wake event. A wake
28
+ * trigger names exactly one source — a build (the build-log attention scan)
29
+ * or the repository journal — enforced by the schema's `.check()` below even
30
+ * though the static type (one object with optional fields) cannot express it:
31
+ * a two-member union or a nested discriminated union is not a valid
32
+ * discriminated-union option on zod 4 (both constructions throw at first
33
+ * parse), so the wake variant stays a single object and call sites branch on
34
+ * `journal === true`. */
35
+ const turnWakeTriggerSchema = z
36
+ .strictObject({
31
37
  kind: z.literal('wake'),
32
- build: z.string().min(1),
38
+ build: z.string().min(1).optional(),
39
+ journal: z.literal(true).optional(),
33
40
  seq: positiveInt,
34
41
  type: z.string().min(1),
35
- }),
42
+ })
43
+ .check((ctx) => {
44
+ const hasBuild = ctx.value.build !== undefined
45
+ const hasJournal = ctx.value.journal === true
46
+ if (hasBuild === hasJournal) {
47
+ ctx.issues.push({
48
+ code: 'custom',
49
+ input: ctx.value,
50
+ message: 'a wake trigger names exactly one of build or journal',
51
+ })
52
+ }
53
+ })
54
+
55
+ const turnTriggerSchema = z.discriminatedUnion('kind', [
56
+ z.strictObject({ kind: z.literal('message'), messageSeq: positiveInt }),
57
+ turnWakeTriggerSchema,
36
58
  ])
37
59
 
38
- /** Matches the transcript usage shape (harvest.session.ended, §15.3). */
60
+ /** Matches the transcript usage shape (harvest.session.ended, §15.3). The
61
+ * turn runner records the AI SDK's totalUsage token counts plus the step
62
+ * count. Old `turn.completed` events without `steps` still reduce. */
39
63
  const usageSchema = z.strictObject({
40
64
  inputTokens: z.number().int().nonnegative(),
41
65
  outputTokens: z.number().int().nonnegative(),
66
+ steps: z.number().int().nonnegative(),
42
67
  turns: z.number().int().nonnegative().optional(),
43
68
  })
44
69
 
70
+ /** The typed model-failure vocabulary (AUT-342): provider availability,
71
+ * exhaustion, credentials, and configuration are distinguished so a failed
72
+ * turn's class is visible in the session's reduced state and nothing is ever
73
+ * retried unboundedly. `internal` covers non-model failures — the dispatcher
74
+ * tick's crash reaper among them. */
75
+ const turnFailureKindSchema = z.enum([
76
+ 'provider-unavailable',
77
+ 'exhausted',
78
+ 'credentials',
79
+ 'configuration',
80
+ 'internal',
81
+ ])
82
+
45
83
  export const sessionEventPayloadSchemas = {
46
84
  /** The fact of creation; the session record carries the same title. */
47
85
  'session.created': z.strictObject({ title: z.string().min(1).optional() }),
@@ -52,11 +90,27 @@ export const sessionEventPayloadSchemas = {
52
90
  'session.wake-set': z.strictObject({
53
91
  globs: z.array(z.string().min(1)).max(100),
54
92
  }),
55
- /** The (later) turn runner's start of one turn on its stream. */
93
+ /** The (later turn runner's) start of one turn on its stream. A wake
94
+ * turn additionally carries the delivered input — the attention event
95
+ * record and, for a build wake, the build's reduced state, frozen at wake
96
+ * time — so a later invocation reconstructs the identical user message
97
+ * from durable state alone (live build state keeps moving; the frozen
98
+ * snapshot does not). A journal wake delivers the event record only. */
56
99
  'turn.started': z.strictObject({
57
100
  turn: z.string().min(1),
58
101
  stream: z.string().min(1),
59
102
  trigger: turnTriggerSchema,
103
+ wake: z
104
+ .strictObject({
105
+ event: z.strictObject({
106
+ seq: positiveInt,
107
+ ts: z.string().min(1),
108
+ type: z.string().min(1),
109
+ payload: z.record(z.string(), z.unknown()),
110
+ }),
111
+ buildState: z.record(z.string(), z.unknown()).optional(),
112
+ })
113
+ .optional(),
60
114
  }),
61
115
  /** A turn was suspended for budget or for an approval. */
62
116
  'turn.suspended': z.strictObject({
@@ -80,8 +134,13 @@ export const sessionEventPayloadSchemas = {
80
134
  }),
81
135
  /** One turn finished. */
82
136
  'turn.completed': z.strictObject({ turn: z.string().min(1), usage: usageSchema }),
83
- /** One turn failed. */
84
- 'turn.failed': z.strictObject({ turn: z.string().min(1), error: z.string().min(1) }),
137
+ /** One turn failed: `kind` is the typed failure class, `error` the
138
+ * human-readable message surfaced in the session's reduced state. */
139
+ 'turn.failed': z.strictObject({
140
+ turn: z.string().min(1),
141
+ kind: turnFailureKindSchema,
142
+ error: z.string().min(1),
143
+ }),
85
144
  /** Terminal. The session is read-only afterwards. */
86
145
  'session.archived': empty,
87
146
  } as const
@@ -0,0 +1,12 @@
1
+ import type { EventPayload } from './payloads'
2
+
3
+ /** Whether the workspace this payload records publishes remotely, per the
4
+ * marker recorded at provision. Journals written before the marker existed
5
+ * fall back to the legacy provider-name reading — the only name comparison
6
+ * among the remote/local decisions (removed in AUT-505). */
7
+ export function isRemoteWorkspace(
8
+ payload: Pick<EventPayload<'workspace.provisioned'>, 'provider' | 'remote'>,
9
+ ): boolean {
10
+ if (payload.remote !== undefined) return payload.remote
11
+ return payload.provider === 'vercel-sandbox'
12
+ }
@@ -347,7 +347,8 @@ export async function makeHarness(opts: {
347
347
  gatePresence: opts.gatePresence ?? 'present',
348
348
  ...(opts.pluginForge?.prAttachments === true ? { prAttachments: true } : {}),
349
349
  })
350
- let selectedForge: Forge = opts.localGitForge === true ? new LocalGitForge() : forge
350
+ let selectedForge: Forge =
351
+ opts.localGitForge === true ? new LocalGitForge({ repoRoot: origin }) : forge
351
352
  let plugins: PluginRegistry | undefined
352
353
  if (opts.pluginForge !== undefined) {
353
354
  plugins = createPluginRegistry()
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The repository auto-merge default as a bulk action over in-flight builds.
3
+ *
4
+ * The durable `dispatcher.auto-merge-default-set` fact has always been the
5
+ * claim-time seed; it now also fans out onto every current non-terminal build
6
+ * of the repository, attributed to the operator who toggled it. The fan-out is
7
+ * a dispatcher-tick reconciliation driven by the durable fact — never a
8
+ * write-time walk — so a build claimed concurrently with the toggle and a
9
+ * build observed by a second dispatcher polling the same repository converge
10
+ * on the same state.
11
+ *
12
+ * Idempotency vs. per-build overrides: each answering command records the
13
+ * repository `seq` of the default fact that caused it (`defaultSeq`). A tick
14
+ * applies the default to a build only when the newest fact's seq is strictly
15
+ * newer than the build's recorded `defaultSeq`. When the build's requested
16
+ * state already matches the fact, the tick records a provenance-only
17
+ * `build.auto-merge-default-observed` marker instead of appending a duplicate
18
+ * command, so a later per-build toggle is not mistaken for staleness and
19
+ * reverted. A global toggle therefore re-applies over a per-build choice, a
20
+ * per-build toggle in between is stable, and pressing the toggle twice appends
21
+ * no duplicate request.
22
+ *
23
+ * Eligibility is expressed against `BuildState`, not `effectiveStatus`
24
+ * (dashboard/model.ts), whose contract is "DISPLAY-ONLY — nothing consults
25
+ * this"; a durable write path must not become its first consumer — the same
26
+ * precedent `bulk-control.ts` set for the pause/resume walk.
27
+ */
28
+ import type { RepositoryEvent } from '../events/repository'
29
+ import { discardInFlight, type BuildState } from './reducer'
30
+
31
+ /** One `dispatcher.auto-merge-default-set` fact, resolved. `actor` is the
32
+ * event's actor — the human who toggled, `via` marker included — and is the
33
+ * attribution every fan-out command carries. */
34
+ export interface AutoMergeDefaultFact {
35
+ enabled: boolean
36
+ /** The repository event's seq — the fan-out's comparison marker. */
37
+ seq: number
38
+ actor: RepositoryEvent['actor']
39
+ }
40
+
41
+ /** The newest `dispatcher.auto-merge-default-set` fact in the repository
42
+ * journal, or undefined when no default was ever set (the scan runs from the
43
+ * tail because seqs are monotonic, so the first hit is the newest). With
44
+ * `enabled`, the newest fact *matching that value* — the fact a claim-time
45
+ * seed built on a stale pre-tick sample must cite, so a newer opposite fact
46
+ * stays strictly newer than the seed's provenance and the next tick's
47
+ * fan-out can reconcile it (f_b851c0e8). */
48
+ export function latestAutoMergeDefault(
49
+ events: RepositoryEvent[],
50
+ enabled?: boolean,
51
+ ): AutoMergeDefaultFact | undefined {
52
+ for (let index = events.length - 1; index >= 0; index -= 1) {
53
+ const event = events[index]
54
+ if (
55
+ event?.type === 'dispatcher.auto-merge-default-set' &&
56
+ (enabled === undefined || event.payload.enabled === enabled)
57
+ ) {
58
+ return { enabled: event.payload.enabled, seq: event.seq, actor: event.actor }
59
+ }
60
+ }
61
+ return undefined
62
+ }
63
+
64
+ /**
65
+ * A build the default may still act on: not terminal, and not on its way out.
66
+ * A pending abort (an explicit request or an accepted escalation answer) must
67
+ * never receive consent — a merge landing mid-abort is exactly the race this
68
+ * clause exists to close. The same holds for an outstanding discard request:
69
+ * `discardRequest` is present exactly while the build is non-terminal and its
70
+ * discard is unsettled, and the reducer settles it only by terminal completion
71
+ * — consent recorded inside that window can merge a build the operator already
72
+ * asked to discard (in the raced-runner-attachment case the discard is inert
73
+ * for a running build, and the recorded consent later drives the merge in
74
+ * `checkPr`). Direction-blind like the abort clause: the build terminalizes
75
+ * imminently either way. If discard ever grows a mid-flight settlement event,
76
+ * this predicate must be revisited.
77
+ */
78
+ export function autoMergeDefaultEligible(state: BuildState): boolean {
79
+ if (state.status === 'done' || state.status === 'aborted') return false
80
+ // An empty log is a record whose `build.created` has not landed yet (the
81
+ // crash or one-await window between `createBuild` and the first append):
82
+ // writing an auto-merge command ahead of the immutable facts would leave the
83
+ // log unrecoverable — dispatch recovery rejects a log that does not start
84
+ // with `build.created` (f_647d40be). `lastSeq === 0` is exactly that log.
85
+ if (state.lastSeq === 0) return false
86
+ // An outstanding discard request is the same "on its way out" shape as a
87
+ // pending abort: it settles only by terminal completion (reducer.ts), so
88
+ // consent recorded inside that window can merge a build the operator
89
+ // already asked to discard. Direction-blind, like the abort clause — an
90
+ // OFF fan-out skipping it withdraws nothing that survives the settlement.
91
+ // Shared predicate: the per-build consent guard (build-control.ts) applies
92
+ // the same exclusion to consent-recording writes. A per-build OFF withdrawal
93
+ // stays available there — revocation only shrinks the merge set — which is a
94
+ // stated, directional divergence, not a silent one.
95
+ if (discardInFlight(state)) return false
96
+ return !state.pendingCommands.some((command) => command.command === 'abort')
97
+ }
98
+
99
+ /** What one default fact asks of one build: a real command, a pure provenance
100
+ * advance, or `undefined` for "nothing". */
101
+ export type AutoMergeDefaultTarget = 'request' | 'cancel' | 'observed'
102
+
103
+ /** What one default fact asks of one build, or undefined for "nothing".
104
+ *
105
+ * Seq-first: a build whose recorded provenance is already this fact (or newer)
106
+ * has had its day in court — a per-build toggle made after the fan-out stands
107
+ * until the default moves again. A build that sampled the default at claim time
108
+ * carries the fact's seq too, so a fresh claim is not re-fanned on the next
109
+ * tick.
110
+ *
111
+ * When the requested state already matches the fact, the fact still has to be
112
+ * *recorded* as observed (`'observed'`) so the next tick does not mistake a
113
+ * later per-build command for staleness: the marker advances `defaultSeq`
114
+ * without a duplicate request/cancel (f_28b3fba1). */
115
+ export function autoMergeDefaultTarget(
116
+ state: BuildState,
117
+ fact: AutoMergeDefaultFact,
118
+ ): AutoMergeDefaultTarget | undefined {
119
+ if (!autoMergeDefaultEligible(state)) return undefined
120
+ if ((state.autoMerge.defaultSeq ?? 0) >= fact.seq) return undefined
121
+ if (state.autoMerge.requested === fact.enabled) return 'observed'
122
+ return fact.enabled ? 'request' : 'cancel'
123
+ }
@@ -52,6 +52,9 @@ export interface ContextInputs {
52
52
  * loop routing (loopOfPhase → 'other') relies on this input existing.
53
53
  */
54
54
  answeredGuidance?: boolean
55
+ /** The ticket assets the build froze at claim (SPEC §6.3), written under
56
+ * `.ab/assets/`. Every phase receives them. */
57
+ assets?: boolean
55
58
  }
56
59
 
57
60
  export interface PhaseSpec {
@@ -91,6 +94,7 @@ export const PHASE_SPECS: Record<CorePhase | 'verify', PhaseSpec> = {
91
94
  spec: true,
92
95
  priorOwnArtifacts: true,
93
96
  findings: 'current',
97
+ assets: true,
94
98
  },
95
99
  },
96
100
  'plan-review': {
@@ -102,7 +106,7 @@ export const PHASE_SPECS: Record<CorePhase | 'verify', PhaseSpec> = {
102
106
  terminalEvent: 'plan-review.verdict',
103
107
  requiredArtifact: 'plan-review',
104
108
  verdictVocabulary: ['approve', 'revise', 'escalate'],
105
- inputs: { spec: true, plan: 'latest', findings: 'all-rounds' },
109
+ inputs: { spec: true, plan: 'latest', findings: 'all-rounds', assets: true },
106
110
  },
107
111
  implement: {
108
112
  name: 'implement',
@@ -120,6 +124,7 @@ export const PHASE_SPECS: Record<CorePhase | 'verify', PhaseSpec> = {
120
124
  findings: 'current',
121
125
  verifyReports: true,
122
126
  priorOwnArtifacts: true,
127
+ assets: true,
123
128
  },
124
129
  },
125
130
  'code-review': {
@@ -137,6 +142,7 @@ export const PHASE_SPECS: Record<CorePhase | 'verify', PhaseSpec> = {
137
142
  commitRange: true,
138
143
  findings: 'all-rounds',
139
144
  implementNotes: true,
145
+ assets: true,
140
146
  },
141
147
  },
142
148
  verify: {
@@ -147,7 +153,13 @@ export const PHASE_SPECS: Record<CorePhase | 'verify', PhaseSpec> = {
147
153
  terminal: 'verdict',
148
154
  terminalEvent: 'verify.completed',
149
155
  verdictVocabulary: ['pass', 'fail', 'skip'],
150
- inputs: { spec: true, stepConfig: true, commitRange: true, currentFeedback: true },
156
+ inputs: {
157
+ spec: true,
158
+ stepConfig: true,
159
+ commitRange: true,
160
+ currentFeedback: true,
161
+ assets: true,
162
+ },
151
163
  },
152
164
  finalize: {
153
165
  name: 'finalize',
@@ -164,6 +176,7 @@ export const PHASE_SPECS: Record<CorePhase | 'verify', PhaseSpec> = {
164
176
  verifyReports: true,
165
177
  prTemplate: true,
166
178
  answeredGuidance: true,
179
+ assets: true,
167
180
  },
168
181
  },
169
182
  reconcile: {
@@ -181,6 +194,7 @@ export const PHASE_SPECS: Record<CorePhase | 'verify', PhaseSpec> = {
181
194
  implementNotes: true,
182
195
  conflict: true,
183
196
  answeredGuidance: true,
197
+ assets: true,
184
198
  },
185
199
  },
186
200
  }