@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
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A dispatcher ticket source as the registry's ticket backend (AUT-342):
3
+ * the tick's turn runner takes an `OperatorToolRegistry`, and
4
+ * `buildRegistry`'s ticket tools require an `OperatorTicketBackend` — but
5
+ * the dispatcher's wiring holds a `TicketSource`, not a backend, and
6
+ * `buildMcpTicketBackend` reads a checkout's autobuild.toml, which cannot
7
+ * run in origin mode. This adapter hands the dispatcher's
8
+ * already-constructed source straight through (it was built by
9
+ * `createTicketSource` from the same effective `[tickets]` config the
10
+ * `OperatorTicketContext` would derive, for the same repository) and derives
11
+ * the workflow-state list from the effective config's lifecycle names.
12
+ */
13
+ import type { Config } from '../config/schema'
14
+ import type { OperatorTicketBackend, OperatorTicketContext } from './tickets'
15
+ import type { TicketSource } from '../ports/types'
16
+
17
+ /** The dispatcher's resolved triage state (the same default
18
+ * `processes/dispatcher.ts` applies); inlined rather than imported so this
19
+ * operator-seam module does not pull the dispatcher's module graph. */
20
+ function defaultTriageState(config: Config): string {
21
+ return (
22
+ config.tickets.triageState ??
23
+ (config.tickets.source === 'linear' || config.tickets.source === 'hosted'
24
+ ? 'Backlog'
25
+ : 'Triage')
26
+ )
27
+ }
28
+
29
+ export function ticketBackendFromSource(options: {
30
+ source: TicketSource
31
+ config: Config
32
+ }): OperatorTicketBackend {
33
+ return {
34
+ sourceFor: (_context: OperatorTicketContext) => options.source,
35
+ statesFor: (_context: OperatorTicketContext, _source: TicketSource): string[] => {
36
+ const tickets = options.config.tickets
37
+ const states = [
38
+ tickets.triageState ?? defaultTriageState(options.config),
39
+ tickets.readyState,
40
+ tickets.createState,
41
+ tickets.claimedState,
42
+ tickets.proposalState ?? defaultTriageState(options.config),
43
+ ].filter((state): state is string => typeof state === 'string' && state.trim().length > 0)
44
+ return [...new Set(states)]
45
+ },
46
+ }
47
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The turn approval list (AUT-342): which registry tool calls suspend a turn
3
+ * until the operator answers. Configured as `[orchestrator].approvals` — a
4
+ * list of registry tool names, optionally qualified by the accepted value of
5
+ * the tool's discriminator field (`tool:qualifier`).
6
+ *
7
+ * Semantics:
8
+ * - A bare entry (`builds.control`) suspends every call to that tool.
9
+ * - A qualified entry (`tickets.move:ready`) suspends only calls whose
10
+ * discriminator equals the qualifier; other values of the same tool run
11
+ * without pause.
12
+ * - An entry naming an absent or unknown tool is inert by design, never an
13
+ * error — the default list is stable while the registry grows.
14
+ * - A qualified entry for a tool without a declared discriminator never
15
+ * matches (there is nothing to compare).
16
+ *
17
+ * Nothing here encodes this repository's specifics.
18
+ */
19
+ import type { ToolApprovalStatus } from 'ai'
20
+
21
+ /** A per-tool approval configuration value: a static status or a function
22
+ * deciding from the call's input. (The SDK's per-tool map accepts both.) */
23
+ export type ApprovalDecision = ToolApprovalStatus | ((input: unknown) => ToolApprovalStatus)
24
+
25
+ /** The registry tools' discriminator fields, for qualified entries. A tool
26
+ * absent from this map has no discriminator; only bare entries match it. */
27
+ const DISCRIMINATOR_FIELDS: Record<string, string> = {
28
+ 'builds.control': 'action',
29
+ 'builds.answer': 'resolution',
30
+ 'tickets.move': 'state',
31
+ }
32
+
33
+ export interface ParsedApprovalEntry {
34
+ tool: string
35
+ qualifier?: string
36
+ }
37
+
38
+ /** Parse one configured entry into its tool and optional qualifier. The
39
+ * config schema already enforces the entry shape syntactically. */
40
+ export function parseApprovalEntry(entry: string): ParsedApprovalEntry {
41
+ const separator = entry.indexOf(':')
42
+ if (separator === -1) return { tool: entry }
43
+ return { tool: entry.slice(0, separator), qualifier: entry.slice(separator + 1) }
44
+ }
45
+
46
+ /** Parse the whole configured list. Duplicate entries collapse harmlessly:
47
+ * the resulting matcher is a predicate, not a table. */
48
+ export function parseApprovalEntries(entries: readonly string[]): ParsedApprovalEntry[] {
49
+ return entries.map(parseApprovalEntry)
50
+ }
51
+
52
+ /**
53
+ * The AI SDK `toolApproval` per-tool configuration for the parsed list: a
54
+ * bare entry maps the whole tool to `user-approval`; a qualified entry maps
55
+ * to a function that returns `user-approval` when the discriminator equals
56
+ * one of the tool's configured qualifiers, else `not-applicable`. Tools with
57
+ * no matching entry get no key at all — the SDK treats an absent key as
58
+ * not-applicable.
59
+ */
60
+ export function approvalConfiguration(
61
+ entries: readonly ParsedApprovalEntry[],
62
+ ): Record<string, ApprovalDecision> {
63
+ const bare = new Set<string>()
64
+ const qualified = new Map<string, Set<string>>()
65
+ for (const entry of entries) {
66
+ if (entry.qualifier === undefined) {
67
+ bare.add(entry.tool)
68
+ continue
69
+ }
70
+ if (bare.has(entry.tool)) continue
71
+ const set = qualified.get(entry.tool) ?? new Set<string>()
72
+ set.add(entry.qualifier)
73
+ qualified.set(entry.tool, set)
74
+ }
75
+ const configuration: Record<string, ApprovalDecision> = {}
76
+ for (const tool of bare) configuration[tool] = 'user-approval'
77
+ for (const [tool, qualifiers] of qualified) {
78
+ if (bare.has(tool)) continue
79
+ const field = DISCRIMINATOR_FIELDS[tool]
80
+ configuration[tool] = (input: unknown) => {
81
+ // A qualified entry for a tool without a declared discriminator never
82
+ // matches — there is nothing to compare.
83
+ if (field === undefined) return 'not-applicable'
84
+ const discriminator =
85
+ typeof input === 'object' && input !== null
86
+ ? (input as Record<string, unknown>)[field]
87
+ : undefined
88
+ return typeof discriminator === 'string' && qualifiers.has(discriminator)
89
+ ? 'user-approval'
90
+ : 'not-applicable'
91
+ }
92
+ }
93
+ return configuration
94
+ }
95
+
96
+ /** Whether a specific call would be suspended — the mirror of
97
+ * `approvalConfiguration` for tests and for the runner's own diagnostics. */
98
+ export function callNeedsApproval(
99
+ entries: readonly ParsedApprovalEntry[],
100
+ tool: string,
101
+ input: unknown,
102
+ ): boolean {
103
+ for (const entry of entries) {
104
+ if (entry.tool !== tool) continue
105
+ if (entry.qualifier === undefined) return true
106
+ const field = DISCRIMINATOR_FIELDS[tool]
107
+ if (field === undefined) continue
108
+ const discriminator =
109
+ typeof input === 'object' && input !== null
110
+ ? (input as Record<string, unknown>)[field]
111
+ : undefined
112
+ if (discriminator === entry.qualifier) return true
113
+ }
114
+ return false
115
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Durable conversation reconstruction (AUT-342): a turn runner holds no
3
+ * process memory. On every invocation the whole conversation is rebuilt from
4
+ * the session's durable state alone — the finalized artifacts of earlier
5
+ * turns, the live stream of the open turn, and the session events.
6
+ *
7
+ * Ordering rule: a turn's assembled messages attach AT ITS `turn.started`
8
+ * event, not at its terminal fact. Attaching at the terminal fact would
9
+ * place a message posted while the turn was suspended BEFORE that turn's
10
+ * already-generated output, interleaving the new message into the middle of
11
+ * the checkpoint the original invocation produced — breaking the identical-
12
+ * input guarantee for a resumed turn in exactly the reachable case (a
13
+ * message landing during a suspension). Attached at `turn.started`, every
14
+ * step the original invocation completed appears verbatim and in the
15
+ * position the model generated it from, and a mid-suspension message reads
16
+ * as a plain next user message.
17
+ *
18
+ * Message-triggered turns contribute no input message of their own (the
19
+ * `message.posted` fact is the input); wake-triggered turns carry their
20
+ * input — the event record and the build's reduced state, frozen at wake
21
+ * time — in the `turn.started` payload, so reconstruction is byte-identical
22
+ * across invocations even though live build state keeps moving.
23
+ */
24
+ import { assembleUIMessageDocument } from '../store/streams/assemble'
25
+ import type { StreamPart, StreamRead } from '../store/streams/types'
26
+ import type { UIMessage } from 'ai'
27
+ import type { BuildStore } from '../store/types'
28
+ import type { SessionEvent } from '../events/sessions'
29
+ import type { StreamRecord } from '../store/streams/types'
30
+
31
+ const STREAM_ARTIFACT_PREFIX = 'stream:'
32
+
33
+ interface TurnStreamSource {
34
+ /** The turn's stream id, from its `turn.started` fact. */
35
+ stream: string
36
+ /** Durable status of the stream at read time. */
37
+ status: 'open' | 'closed'
38
+ }
39
+
40
+ async function readTurnDocument(
41
+ store: BuildStore,
42
+ sessionId: string,
43
+ source: TurnStreamSource,
44
+ ): Promise<UIMessage[]> {
45
+ if (source.status === 'open') {
46
+ const read: StreamRead = await store.readStream(source.stream)
47
+ const parts: StreamPart[] = read.chunks.flatMap((chunk) => chunk.parts)
48
+ const { document } = await assembleUIMessageDocument(parts)
49
+ return document
50
+ }
51
+ // Closed streams finalize into the `stream:<id>` session artifact; the
52
+ // chunks themselves are retention-pruned, so the artifact is the durable
53
+ // form a later invocation must read.
54
+ const artifact = await store.getSessionArtifact(
55
+ sessionId,
56
+ `${STREAM_ARTIFACT_PREFIX}${source.stream}`,
57
+ )
58
+ if (artifact === null) return []
59
+ let parsed: unknown
60
+ try {
61
+ parsed = JSON.parse(new TextDecoder().decode(artifact.content))
62
+ } catch {
63
+ return []
64
+ }
65
+ return Array.isArray(parsed) ? (parsed as UIMessage[]) : []
66
+ }
67
+
68
+ export interface ReconstructedConversation {
69
+ messages: UIMessage[]
70
+ /** Per turn, in start order: the turn id and how many messages its
71
+ * document contributed. Diagnostics, not contract. */
72
+ turns: { turn: string; messages: number }[]
73
+ }
74
+
75
+ /**
76
+ * Reconstruct the conversation for a session from durable state alone.
77
+ * `events` must be the complete session event log (seq 1 upward).
78
+ */
79
+ export async function reconstructConversation(
80
+ store: BuildStore,
81
+ sessionId: string,
82
+ events: SessionEvent[],
83
+ ): Promise<ReconstructedConversation> {
84
+ const messages: UIMessage[] = []
85
+ const turns: ReconstructedConversation['turns'] = []
86
+
87
+ // Resolve each turn's stream status up front so the walk stays ordered:
88
+ // the open turn reads its live stream, closed turns read their artifacts.
89
+ const sources = new Map<string, TurnStreamSource>()
90
+ for (const event of events) {
91
+ if (event.type !== 'turn.started') continue
92
+ const record: StreamRecord | null = await store.getStream(event.payload.stream)
93
+ sources.set(event.payload.turn, {
94
+ stream: event.payload.stream,
95
+ status: record?.status === 'open' ? 'open' : 'closed',
96
+ })
97
+ }
98
+
99
+ for (const event of events) {
100
+ if (event.type === 'message.posted') {
101
+ messages.push({
102
+ id: `msg_${event.seq}`,
103
+ role: 'user',
104
+ parts: [{ type: 'text', text: event.payload.text }],
105
+ })
106
+ } else if (event.type === 'turn.started') {
107
+ const source = sources.get(event.payload.turn)
108
+ if (source === undefined) continue
109
+ // A wake turn's input is the frozen snapshot carried by its own
110
+ // `turn.started` fact — recorded before the fact, so every invocation
111
+ // reconstructs the identical user message.
112
+ if (event.payload.trigger.kind === 'wake' && event.payload.wake !== undefined) {
113
+ messages.push(
114
+ wakeTriggerInputMessage({
115
+ build: event.payload.trigger.build,
116
+ journal: event.payload.trigger.journal === true,
117
+ type: event.payload.trigger.type,
118
+ event: event.payload.wake.event,
119
+ buildState: event.payload.wake.buildState,
120
+ }),
121
+ )
122
+ }
123
+ const document = await readTurnDocument(store, sessionId, source)
124
+ turns.push({ turn: event.payload.turn, messages: document.length })
125
+ messages.push(...document)
126
+ }
127
+ }
128
+
129
+ return { messages, turns }
130
+ }
131
+
132
+ /**
133
+ * The user input message a wake trigger delivers to its turn: the attention
134
+ * event record and — for a build wake — the build's reduced state, as JSON.
135
+ * A journal wake names the repository journal instead of a build and carries
136
+ * no build-state section (there is no build; the turn's operator registry
137
+ * exposes bounded repository reads). The runner embeds the same object in
138
+ * the `turn.started` payload, so this function and the reconstruction agree
139
+ * by construction.
140
+ */
141
+ export function wakeTriggerInputMessage(input: {
142
+ build?: string
143
+ journal?: boolean
144
+ type: string
145
+ event: { seq: number; ts: string; type: string; payload: unknown }
146
+ buildState?: unknown
147
+ }): UIMessage {
148
+ const journal = input.journal === true || input.build === undefined
149
+ return {
150
+ id: journal ? `wake_journal_${input.event.seq}` : `wake_${input.build}_${input.event.seq}`,
151
+ role: 'user',
152
+ parts: [
153
+ {
154
+ type: 'text',
155
+ text: journal
156
+ ? `Repository-journal attention event: ${input.type} (seq ${input.event.seq}).\n` +
157
+ 'Event record:\n' +
158
+ JSON.stringify(input.event, null, 2)
159
+ : `Attention event on build ${input.build}: ${input.type} (seq ${input.event.seq}).\n` +
160
+ 'Event record:\n' +
161
+ JSON.stringify(input.event, null, 2) +
162
+ '\nBuild state:\n' +
163
+ JSON.stringify(input.buildState, null, 2),
164
+ },
165
+ ],
166
+ }
167
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Model resolution and typed failure classification (AUT-342): the
3
+ * orchestrator's model comes from `[orchestrator].model`, a provider-
4
+ * qualified string in the same vocabulary as `[roles]`, resolved through the
5
+ * deployment's gateway credential. Model failures are typed on
6
+ * `turn.failed` — provider availability, exhaustion, credentials, and
7
+ * configuration are distinguished so nothing is ever retried unboundedly and
8
+ * the class is visible in the session's reduced state.
9
+ */
10
+ import { LoadAPIKeyError, createGateway } from 'ai'
11
+ import {
12
+ GatewayAuthenticationError,
13
+ GatewayInternalServerError,
14
+ GatewayModelNotFoundError,
15
+ GatewayRateLimitError,
16
+ } from '@ai-sdk/gateway'
17
+
18
+ /** Strip the optional `vercel-ai-gateway/` prefix so the value is written
19
+ * exactly as `[roles]` spells it while the gateway provider receives the
20
+ * bare model id. */
21
+ export function resolveGatewayModelId(model: string): string {
22
+ const prefix = 'vercel-ai-gateway/'
23
+ return model.startsWith(prefix) ? model.slice(prefix.length) : model
24
+ }
25
+
26
+ /** Resolve the configured model string through the deployment's gateway
27
+ * credential (`AI_GATEWAY_API_KEY`, read by `createGateway`). Missing or
28
+ * rejected credentials surface at call time and classify as `credentials`. */
29
+ export function resolveGatewayModel(model: string): ReturnType<ReturnType<typeof createGateway>> {
30
+ return createGateway()(resolveGatewayModelId(model))
31
+ }
32
+
33
+ /** The typed model-failure vocabulary, mirroring `turn.failed`'s `kind`. */
34
+ export type TurnFailureKind =
35
+ | 'provider-unavailable'
36
+ | 'exhausted'
37
+ | 'credentials'
38
+ | 'configuration'
39
+ | 'internal'
40
+
41
+ /**
42
+ * Classify one model error. The gateway's own error classes are the primary
43
+ * vocabulary: `GatewayModelNotFoundError` (`type: 'model_not_found'`, thrown
44
+ * at call time — the gateway constructs a model for any id, so unknown-model
45
+ * is never `NoSuchModelError`) is `configuration`;
46
+ * `GatewayAuthenticationError` or `LoadAPIKeyError` (which the gateway does
47
+ * not export; `ai` re-exports it from `@ai-sdk/provider`) is `credentials`;
48
+ * `GatewayRateLimitError` or any 429 is `exhausted`;
49
+ * `GatewayInternalServerError` and other 5xx/network/timeout errors are
50
+ * `provider-unavailable`. Remaining errors classify by their `statusCode`
51
+ * when present, else `internal`.
52
+ */
53
+ export function classifyModelError(error: unknown): TurnFailureKind {
54
+ // `ai` rewrites gateway authentication errors flowing through a stream
55
+ // into a plain Error (or AISDKError in production) that keeps only the
56
+ // class NAME — classify those by name before the instanceof checks.
57
+ const name =
58
+ typeof (error as { name?: unknown })?.name === 'string'
59
+ ? ((error as { name?: string }).name ?? '')
60
+ : ''
61
+ if (name === 'GatewayAuthenticationError' || name === 'GatewayError') return 'credentials'
62
+ if (error instanceof GatewayModelNotFoundError) return 'configuration'
63
+ if (error instanceof GatewayAuthenticationError || error instanceof LoadAPIKeyError) {
64
+ return 'credentials'
65
+ }
66
+ if (error instanceof GatewayRateLimitError) return 'exhausted'
67
+ if (error instanceof GatewayInternalServerError) return 'provider-unavailable'
68
+
69
+ // Non-gateway shapes: classify by what they carry.
70
+ const candidate = error as { statusCode?: unknown; name?: unknown; message?: unknown }
71
+ if (typeof candidate?.statusCode === 'number') {
72
+ if (candidate.statusCode === 401 || candidate.statusCode === 403) return 'credentials'
73
+ if (candidate.statusCode === 404) return 'configuration'
74
+ if (candidate.statusCode === 429) return 'exhausted'
75
+ if (candidate.statusCode >= 500) return 'provider-unavailable'
76
+ }
77
+ const text = `${candidate?.name ?? ''} ${candidate?.message ?? ''}`.toLowerCase()
78
+ if (text.includes('api key') || text.includes('apikey') || text.includes('unauthorized')) {
79
+ return 'credentials'
80
+ }
81
+ if (text.includes('rate limit') || text.includes('too many requests')) return 'exhausted'
82
+ if (text.includes('timeout') || text.includes('econnrefused') || text.includes('enotfound')) {
83
+ return 'provider-unavailable'
84
+ }
85
+ return 'internal'
86
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The turn's system prompt (AUT-342): the canonical `ab-operate` skill body,
3
+ * plus the current `operator-notes` artifact, plus the session's wake
4
+ * settings, plus the session's repository identity.
5
+ *
6
+ * The skill is read from the distribution's `skills/operate/SKILL.md` — the
7
+ * same tree `ab init` vendors from — through `distributionPath`, and is
8
+ * shipped into every hosted function that executes a turn (see
9
+ * packages/hosted-dispatcher/src/ship-packed-distribution.ts). It is
10
+ * self-contained per the vendored-skill rule and teaches the tool surface,
11
+ * the attention set, and when to escalate rather than act; nothing
12
+ * repository-specific may be inlined here.
13
+ */
14
+ import { readFile } from 'node:fs/promises'
15
+ import { distributionPath } from '../distribution'
16
+ import { OPERATOR_NOTES_REPO_KIND } from '../store/retention'
17
+ import type { BuildStore } from '../store/types'
18
+ import type { OrchestratorConfig } from '../config/schema'
19
+
20
+ /** Where the canonical skill lives in the distribution tree. */
21
+ export const OPERATE_SKILL_PATH = 'skills/operate/SKILL.md'
22
+
23
+ /** Read the canonical skill body from the distribution tree. Throws when
24
+ * the file is missing — a deployment that cannot load the canonical skill
25
+ * must not run turns with a silently empty system prompt. */
26
+ export async function readOperateSkill(): Promise<string> {
27
+ return readFile(distributionPath(...OPERATE_SKILL_PATH.split('/')), 'utf8')
28
+ }
29
+
30
+ export interface TurnPromptInput {
31
+ store: BuildStore
32
+ repo: string
33
+ /** The session's wake globs (already resolved — empty means message-only). */
34
+ wakeGlobs: readonly string[]
35
+ config: OrchestratorConfig
36
+ }
37
+
38
+ /** Assemble the system prompt. The operator-notes artifact is empty when
39
+ * absent; the skill body is always present (missing file throws). */
40
+ export async function buildTurnSystemPrompt(input: TurnPromptInput): Promise<string> {
41
+ const skill = await readOperateSkill()
42
+ const notes = await input.store.getRepoArtifact(input.repo, OPERATOR_NOTES_REPO_KIND)
43
+ const notesText = notes === null ? '' : new TextDecoder().decode(notes.content).trim()
44
+ return [
45
+ skill,
46
+ '',
47
+ '## Repository',
48
+ '',
49
+ `You operate the repository ${JSON.stringify(input.repo)}. Every tool call is bound to this repository; you never supply a repo argument yourself.`,
50
+ '',
51
+ '## Operator notes',
52
+ '',
53
+ notesText === '' ? '(none recorded)' : notesText,
54
+ '',
55
+ '## Wake settings',
56
+ '',
57
+ input.wakeGlobs.length === 0
58
+ ? 'This session wakes only on operator messages (no attention-event wake sources).'
59
+ : `This session wakes on these attention-event globs: ${input.wakeGlobs.map((glob) => JSON.stringify(glob)).join(', ')}.`,
60
+ ].join('\n')
61
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Registry entries as AI SDK tools (AUT-342): the turn runner binds the
3
+ * in-process operator registry, so the agent's tools ARE the operator
4
+ * surface — never a reimplementation.
5
+ *
6
+ * Two rules shape the binding:
7
+ *
8
+ * 1. **Attribution.** Every call carries the session operator as the human
9
+ * actor with `via: {kind: 'session', id}` — the operator's session did
10
+ * it, exactly as an MCP client's calls carry `via: {kind: 'mcp', …}`.
11
+ *
12
+ * 2. **The model never supplies `repo`.** Every registry entry requires a
13
+ * `repo` field and `call()` validates it before the handler runs, but
14
+ * nothing in the turn's durable state teaches the model a repository
15
+ * identity it could type: message-triggered turns carry no repo fact, the
16
+ * canonical skill must stay generic, and the registry has no
17
+ * repositories.list tool. The model-facing schema therefore strips `repo`
18
+ * (the mirror of the registry's own `extendUnion`), and `execute` injects
19
+ * the session's repository into the validated input before
20
+ * `registry.call` re-validates it — the session's repository is the only
21
+ * value a tool can ever target. The registry's `allowedRepo` guard stays
22
+ * bound as defense in depth.
23
+ *
24
+ * A thrown `RegistryError` is caught and returned as the operator-API
25
+ * failure body (`kind`/`error`/`code`) so the model can react to a refusal
26
+ * instead of the step dying; the registry's JSON-value invariant makes every
27
+ * output wire-safe.
28
+ */
29
+ import { tool, type ToolSet } from 'ai'
30
+ import { z } from 'zod'
31
+ import { RegistryError, type OperatorToolRegistry } from '../operator/registry'
32
+ import type { Via } from '../events/envelope'
33
+ import type { ApprovalDecision } from './approvals'
34
+
35
+ /** The model-facing stand-in for a stripped `repo` on schemas Zod 4 refuses
36
+ * to `.omit()` — an object carrying refinements (`.refine` locks the shape).
37
+ * `safeExtend` preserves the refinements while replacing the field with an
38
+ * optional `never`: absent is fine, any supplied value fails validation, so
39
+ * the model still cannot point a tool anywhere but the session's
40
+ * repository. (Checked against the installed zod; `.omit` and `.extend`
41
+ * both throw on refined objects.) */
42
+ const unsatisfiableRepo = z.optional(z.never())
43
+
44
+ /** Strip `repo` from a tool's model-facing schema: plain `ZodObject` entries
45
+ * omit the field; union entries (the `extendUnion` shape) strip each option
46
+ * and rebuild the union — Zod 4 unions expose no `withOptions`, so the
47
+ * original union must never be handed through unchanged (its members still
48
+ * require `repo`, which the model cannot know). Refined objects neutralize
49
+ * the field instead of omitting it. Returns undefined only for a schema the
50
+ * entry table never uses. */
51
+ function stripRepoField(schema: z.ZodType): z.ZodType | undefined {
52
+ const options = (schema as unknown as { options?: readonly z.ZodType[] }).options
53
+ if (Array.isArray(options) && options.length > 0) {
54
+ const stripped = options.map((option) => stripRepoField(option) ?? option)
55
+ return z.union(stripped)
56
+ }
57
+ const object = schema as unknown as {
58
+ omit?: (mask: Record<string, boolean>) => unknown
59
+ safeExtend?: (patch: Record<string, z.ZodType>) => unknown
60
+ }
61
+ if (typeof object.omit === 'function') {
62
+ try {
63
+ return object.omit({ repo: true }) as z.ZodType
64
+ } catch {
65
+ // A refined object: neutralize `repo` instead of omitting it.
66
+ if (typeof object.safeExtend === 'function') {
67
+ return object.safeExtend({ repo: unsatisfiableRepo }) as z.ZodType
68
+ }
69
+ }
70
+ }
71
+ return undefined
72
+ }
73
+
74
+ export interface RegistryToolsInput {
75
+ registry: OperatorToolRegistry
76
+ /** The session's repository — the only value any tool can target. */
77
+ repo: string
78
+ /** The session operator: the human actor every attributed write names. */
79
+ operator: string
80
+ /** The session-scoped delegate marker. */
81
+ via: Via
82
+ /** Optional per-tool approval configuration (from `approvals.ts`), keyed
83
+ * by registry tool name; absent keys never suspend. (Kept for signature
84
+ * symmetry; the agent takes the configuration directly.) */
85
+ toolApproval?: Record<string, ApprovalDecision>
86
+ }
87
+
88
+ /** Build the AI SDK tool set from the registry's closed table. */
89
+ export function registryTools(input: RegistryToolsInput): ToolSet {
90
+ const tools: ToolSet = {}
91
+ for (const entry of input.registry.entries) {
92
+ const modelSchema = stripRepoField(entry.inputSchema)
93
+ if (modelSchema === undefined) continue
94
+ tools[entry.name] = tool({
95
+ description: `${entry.description}${entry.outputDescription ? `\n\nReturns: ${entry.outputDescription}` : ''}`,
96
+ inputSchema: modelSchema,
97
+ execute: async (modelInput: unknown) => {
98
+ // Re-inject the session's repository: the registry re-validates the
99
+ // whole input (with `repo`) before the handler runs.
100
+ const fullInput =
101
+ typeof modelInput === 'object' && modelInput !== null
102
+ ? { ...(modelInput as Record<string, unknown>), repo: input.repo }
103
+ : { repo: input.repo }
104
+ try {
105
+ return await input.registry.call(entry.name, fullInput, {
106
+ identity: input.operator,
107
+ via: input.via,
108
+ })
109
+ } catch (error) {
110
+ if (error instanceof RegistryError) return error.body
111
+ throw error
112
+ }
113
+ },
114
+ })
115
+ }
116
+ return tools
117
+ }