@defrex/autobuild 0.11.0 → 0.13.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 (136) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +335 -31
  3. package/docs/architecture.md +125 -7
  4. package/docs/configuration.md +142 -21
  5. package/docs/hosted-dispatcher.md +17 -2
  6. package/docs/hosted-service.md +240 -0
  7. package/docs/mcp.md +8 -2
  8. package/docs/operator-api.md +97 -7
  9. package/docs/remote-store-protocol.md +199 -144
  10. package/docs/remote-ticket-protocol.md +249 -19
  11. package/docs/setup.md +199 -16
  12. package/docs/ticket-dependencies.md +1 -1
  13. package/docs/ticket-migration.md +64 -0
  14. package/package.json +7 -6
  15. package/packages/core/src/cli/attention.ts +209 -0
  16. package/packages/core/src/cli/build-control.ts +12 -1
  17. package/packages/core/src/cli/configured-store.ts +17 -0
  18. package/packages/core/src/cli/connect.ts +364 -0
  19. package/packages/core/src/cli/context.ts +7 -1
  20. package/packages/core/src/cli/dashboard/model.ts +4 -5
  21. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  22. package/packages/core/src/cli/dispatch.ts +336 -41
  23. package/packages/core/src/cli/harvest.ts +13 -3
  24. package/packages/core/src/cli/help.ts +51 -16
  25. package/packages/core/src/cli/init-validation.ts +27 -2
  26. package/packages/core/src/cli/installation.ts +67 -13
  27. package/packages/core/src/cli/main.ts +60 -16
  28. package/packages/core/src/cli/mcp.ts +9 -2
  29. package/packages/core/src/cli/pr-summary-publication.ts +220 -0
  30. package/packages/core/src/cli/pr-summary.ts +2 -0
  31. package/packages/core/src/cli/project-credentials.ts +132 -0
  32. package/packages/core/src/cli/repo-state.ts +15 -2
  33. package/packages/core/src/cli/self-update.ts +15 -1
  34. package/packages/core/src/cli/store-opening.ts +22 -14
  35. package/packages/core/src/cli/terminals.ts +0 -10
  36. package/packages/core/src/cli/ticket-assets.ts +7 -2
  37. package/packages/core/src/cli/ticket-editor.ts +82 -0
  38. package/packages/core/src/cli/ticket.ts +172 -35
  39. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  40. package/packages/core/src/cli/wait.ts +84 -17
  41. package/packages/core/src/cli/watch.ts +197 -60
  42. package/packages/core/src/config/live.ts +3 -0
  43. package/packages/core/src/config/schema.ts +90 -11
  44. package/packages/core/src/connection.ts +86 -0
  45. package/packages/core/src/events/catalog.ts +11 -2
  46. package/packages/core/src/events/payloads.ts +50 -0
  47. package/packages/core/src/events/repository.ts +13 -0
  48. package/packages/core/src/integration/harness.ts +37 -2
  49. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +145 -83
  50. package/packages/core/src/kernel/engine.ts +24 -0
  51. package/packages/core/src/kernel/generators/build-log.ts +11 -1
  52. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  53. package/packages/core/src/kernel/log-index.ts +17 -0
  54. package/packages/core/src/kernel/pr-summary.ts +89 -0
  55. package/packages/core/src/kernel/reducer-registry.ts +10 -0
  56. package/packages/core/src/kernel/reducer.ts +8 -0
  57. package/packages/core/src/ontology.ts +2 -0
  58. package/packages/core/src/operator/annotations.ts +12 -0
  59. package/packages/core/src/operator/index.ts +12 -0
  60. package/packages/core/src/operator/protocol.ts +19 -2
  61. package/packages/core/src/operator/query.ts +38 -16
  62. package/packages/core/src/operator/registry.ts +61 -8
  63. package/packages/core/src/operator/ticket-source-backend.ts +17 -20
  64. package/packages/core/src/operator/tickets.ts +136 -28
  65. package/packages/core/src/plugin-sdk/index.ts +7 -0
  66. package/packages/core/src/plugins/manifest.ts +5 -3
  67. package/packages/core/src/plugins/runtimes.ts +7 -1
  68. package/packages/core/src/ports/forge/fake.ts +13 -1
  69. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  70. package/packages/core/src/ports/forge/github.ts +29 -5
  71. package/packages/core/src/ports/forge/local-git.ts +12 -4
  72. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  73. package/packages/core/src/ports/runner/session-env.ts +3 -0
  74. package/packages/core/src/ports/tickets/contract.ts +66 -1
  75. package/packages/core/src/ports/tickets/create.ts +54 -10
  76. package/packages/core/src/ports/tickets/fake.ts +30 -1
  77. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  78. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  79. package/packages/core/src/ports/tickets/file.ts +114 -120
  80. package/packages/core/src/ports/tickets/grants.ts +47 -0
  81. package/packages/core/src/ports/tickets/hosted-index.ts +17 -3
  82. package/packages/core/src/ports/tickets/identity.ts +20 -0
  83. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  84. package/packages/core/src/ports/tickets/linear.ts +74 -9
  85. package/packages/core/src/ports/tickets/migration.ts +336 -0
  86. package/packages/core/src/ports/tickets/operations.ts +38 -7
  87. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  88. package/packages/core/src/ports/tickets/remote-protocol.ts +96 -0
  89. package/packages/core/src/ports/tickets/remote.ts +61 -3
  90. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  91. package/packages/core/src/ports/tickets/revision.ts +62 -0
  92. package/packages/core/src/ports/tickets/states.ts +61 -0
  93. package/packages/core/src/ports/types.ts +40 -3
  94. package/packages/core/src/ports/workspace/build-execution.ts +6 -1
  95. package/packages/core/src/ports/workspace/create.ts +15 -2
  96. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  97. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  98. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  99. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  100. package/packages/core/src/ports/workspace/vercel-sandbox.ts +51 -19
  101. package/packages/core/src/processes/build-execution-state.ts +1 -1
  102. package/packages/core/src/processes/build-runner.ts +87 -26
  103. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  104. package/packages/core/src/processes/dispatcher.ts +168 -41
  105. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  106. package/packages/core/src/processes/harvest-child.ts +31 -4
  107. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  108. package/packages/core/src/processes/harvest-runner.ts +166 -104
  109. package/packages/core/src/processes/harvest.ts +34 -14
  110. package/packages/core/src/processes/phase-failure-budget.ts +2 -0
  111. package/packages/core/src/processes/publication-loss.ts +18 -1
  112. package/packages/core/src/processes/repo-view.ts +35 -0
  113. package/packages/core/src/store/contract.ts +32 -1
  114. package/packages/core/src/store/local/store.ts +1 -1
  115. package/packages/core/src/store/remote/client.ts +1 -1
  116. package/packages/core/src/store/remote/compatibility.ts +90 -0
  117. package/packages/core/src/store/remote/credentials.ts +28 -0
  118. package/packages/core/src/store/remote/index.ts +14 -1
  119. package/packages/core/src/store/remote/protocol.ts +25 -0
  120. package/packages/core/src/store/remote/token.ts +74 -13
  121. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  122. package/packages/core/src/store/ticket-assets.ts +86 -8
  123. package/packages/core/src/store/types.ts +1 -1
  124. package/packages/core/src/testing/index.ts +7 -0
  125. package/skills/guide/SKILL.md +232 -34
  126. package/skills/guide/references/operator-api.md +142 -13
  127. package/skills/guide/references/plugin-authoring.md +10 -2
  128. package/skills/guide/references/remote-store-protocol.md +199 -144
  129. package/skills/guide/references/remote-ticket-protocol.md +253 -18
  130. package/skills/guide/references/setup.md +199 -16
  131. package/skills/guide/references/ticket-dependencies.md +1 -1
  132. package/skills/guide/references/ticket-migration.md +64 -0
  133. package/skills/operate/SKILL.md +126 -36
  134. package/skills/tickets/SKILL.md +71 -0
  135. package/templates/hosted-service/package.json +13 -0
  136. package/templates/hosted-service/vercel.json +7 -0
@@ -69,6 +69,20 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
69
69
  'locally. --validate cannot be combined with --force.',
70
70
  ]),
71
71
  },
72
+ {
73
+ name: 'connect',
74
+ audience: 'human',
75
+ summary: 'Connect this project to a hosted service with browser approval.',
76
+ detail:
77
+ 'Usage: ab connect [--tickets] [url]\nWith a URL, approve the printed URL and code in your browser. Stores the credential in user configuration and adds [store] url to autobuild.toml. Use --tickets to save [tickets] url and keep builds local. Without a URL, reports every service connection and its uses. Runs outside sessions.',
78
+ },
79
+ {
80
+ name: 'disconnect',
81
+ audience: 'human',
82
+ summary: 'Revoke and remove this project’s saved credential.',
83
+ detail:
84
+ 'Usage: ab disconnect [--tickets | --builds]\nRevokes the saved credential remotely before removing it locally. Keeps project registration, data and [store] url. Runs outside sessions.',
85
+ },
72
86
  {
73
87
  name: 'upgrade',
74
88
  audience: 'human',
@@ -159,20 +173,26 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
159
173
  detail: page([
160
174
  'Usage:',
161
175
  ' ab ticket create <title> --body <file> [--state <state>] [--labels a,b] [--blocked-by id,id] [--json]',
162
- ' ab ticket update <id> [--title <title>] [--body <file>] [--labels a,b] [--json]',
163
- ' ab ticket block <id> <blocker-id[,blocker-id...]> [--json]',
164
- ' ab ticket unblock <id> <blocker-id[,blocker-id...]> [--json]',
176
+ ' ab ticket edit <id>',
177
+ ' ab ticket export <dir> [--store <ref>]',
178
+ ' ab ticket import <dir> [--store <ref>]',
179
+ ' ab ticket update <id> [--title <title>] [--body <file>] [--labels a,b] [--expected-revision <opaque>] [--json]',
180
+ ' ab ticket block <id> <blocker-id[,blocker-id...]> [--expected-revision <opaque>] [--json]',
181
+ ' ab ticket unblock <id> <blocker-id[,blocker-id...]> [--expected-revision <opaque>] [--json]',
165
182
  ' ab ticket list [--state <state>] [--labels a,b] [--json]',
166
183
  ' ab ticket show <id> [--store <ref>] [--json]',
167
- ' ab ticket move <id> <state> [--json]',
184
+ ' ab ticket move <id> <state> [--expected-revision <opaque>] [--json]',
168
185
  ' ab ticket states [--json]',
169
186
  ' ab ticket state create <name> [--about <text>] [--json]',
170
187
  ' ab ticket attach <id> <kind> <path> [--name <name>] [--store <ref>] [--json]',
171
188
  ' ab ticket asset get <id> <kind> <name> <dest> [--rev <n>] [--store <ref>] [--json]',
172
189
  ' ab ticket asset rm <id> <kind> <name> [--store <ref>] [--json]',
173
190
  '',
174
- 'Operate on the configured [tickets] source (§8.8); all ticket commands run',
175
- 'outside sessions. create files a ticket and may establish initial blockers.',
191
+ 'Operate on the configured [tickets] source (§8.8); commands run outside sessions.',
192
+ 'Every JSON ticket read includes an opaque revision. update, move, block and',
193
+ 'unblock accept --expected-revision to refuse stale writes. Linear refuses checks.',
194
+ 'edit opens VISUAL or EDITOR, checks the opening revision, and preserves failed buffers.',
195
+ 'create files a ticket and may establish initial blockers.',
176
196
  '--state overrides [tickets].createState (or the provider default) for that',
177
197
  'create only; it is passed through unchanged and validated by the source.',
178
198
  'update partially replaces editable fields: omitted fields survive, --labels',
@@ -311,12 +331,23 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
311
331
  ' flushed as soon as it is written.',
312
332
  ' --store <ref> Override the BuildStore path or URL.',
313
333
  '',
314
- 'Default filter — the attention set. For builds: escalation.raised, phase.failed,',
315
- 'infrastructure.failed, runner.setup-failed, dispatch.failed, publication.lost,',
316
- 'finalize.completed, pr.conflicted, pr.merged, pr.closed, build.completed,',
317
- 'build.aborted. For the repository journal: harvest.escalated, harvest.failed,',
318
- 'dispatcher.tick-failed, dispatcher.config-rejected, dispatcher.harvest-runner-failed,',
319
- 'dispatcher.ticket-source-degraded, dispatcher.ticket-source-recovered.',
334
+ 'Default filter — actionable attention only. Builds: escalation.raised and',
335
+ 'build.merge-decision (finalized open PR, auto-merge off or currently deferred).',
336
+ 'Armed consent awaiting application is silent; one merge decision per build/PR.',
337
+ 'Repository: harvest.escalated, harvest.recovery-exhausted, dispatcher.config-rejected,',
338
+ 'dispatcher.config-reloaded with restartRequired, abnormal dispatcher.run-stopped,',
339
+ 'and dispatcher.lost. dispatcher.ticket-source-persistent reports degradation past',
340
+ 'resetAt after a tick started at or after reset completes or fails while still degraded,',
341
+ 'or 15 minutes without a reset; dispatcher.tick-failure-persistent reports',
342
+ 'five minutes of continuous tick failure outside source grace or cooldown. Each episode',
343
+ 'reports once; recovery ends it. Cooldown failures do not start the tick-failure clock.',
344
+ '--event replaces defaults with exactly matching catalog events (or dispatcher.lost).',
345
+ 'Routine failures, finalize and terminal outcomes require explicit --event globs.',
346
+ 'Derived records carry observation instead of event, with observedAt and PR/reason',
347
+ 'or source/cause/message/resetAt or run/firstFailure/error plus episodeSeq.',
348
+ 'Cursors suppress delivered observations, including timer-only records. Old v1',
349
+ 'cursors remain valid. Fresh watches baseline history and retain repository timers;',
350
+ 'reconcile existing build attention through status after arming.',
320
351
  '',
321
352
  'Record shape (--json): build (slug, or null for a repository event), repo (repository',
322
353
  'id, present only for repository events), event (the complete durable envelope: build,',
@@ -364,19 +395,23 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
364
395
  'Conditions (--for, repeatable): blocked, paused, running, done, aborted, terminal (done',
365
396
  'or aborted), pr=open, pr=merged, pr=closed, pr=conflicted, phase=<phase> (a core phase —',
366
397
  'plan, plan-review, implement, code-review, finalize, reconcile — or verify:<step>), and',
367
- 'attention (any event in the ab watch attention set). An unknown value is rejected before',
398
+ 'attention (an open escalation or a build waiting for a merge decision). Merge attention',
399
+ 'requires a finalized open PR with auto-merge off or a current actionable gate deferral;',
400
+ 'armed pending consent, routine outcomes and self-healing failures stay silent.',
401
+ 'An unknown condition value is rejected before',
368
402
  'any store access. Repeatable --event globs follow the ab watch rules. Multiple --for',
369
403
  'values and --event globs are alternatives: the first one satisfied ends the wait — all',
370
404
  '--for values in supply order first (each checked against the state after an event and',
371
- 'the event’s attention membership), then all --event globs in supply order.',
405
+ 'the actionable build projection), then all --event globs in supply order.',
372
406
  '',
373
407
  'A status, PR-lifecycle, or phase condition is satisfied when a targeted build’s reduction',
374
408
  'has that status, PR state, or current phase. If any targeted build already satisfies',
375
409
  'such a condition when the command starts, the command returns immediately — in the',
376
410
  'manner of kubectl wait — with that build’s latest event; --since does not suppress this.',
377
411
  'Otherwise the condition is satisfied by the first later event whose reduction produces',
378
- 'it. attention and --event conditions are satisfied only by events appended after the',
379
- 'command started (or, on a cursor-resumed backlog, after --since).',
412
+ 'it. attention also recognizes existing conditions immediately, even with --since.',
413
+ '--event conditions match only events appended after the command started, or a',
414
+ 'cursor-resumed backlog after --since; use --event to opt into other event types.',
380
415
  '',
381
416
  'Record shape (--json): the ab watch record — build, event (the complete durable',
382
417
  'envelope), state (the reduction after this event: status, phase, round, openEscalations,',
@@ -1,3 +1,5 @@
1
+ import { projectToken } from './project-credentials'
2
+ import { resolveRepoOrigin } from './repo-state'
1
3
  import { mkdtemp, readFile, rm } from 'node:fs/promises'
2
4
  import { tmpdir } from 'node:os'
3
5
  import { join, resolve } from 'node:path'
@@ -390,7 +392,31 @@ export async function validateInitReadiness(opts: {
390
392
  const configPath = join(repo, 'autobuild.toml')
391
393
  const configBytes = await readFile(configPath, 'utf8')
392
394
  const config = await loadConfig(configPath)
393
- const state = resolveRepoStatePaths({ repo, envStore: opts.env.AB_STORE })
395
+ hostPreflight(config, opts.env)
396
+ const selectedRemote = opts.env.AB_STORE?.trim() || config.store?.url
397
+ const identity =
398
+ selectedRemote && isRemoteStoreRef(selectedRemote) && !opts.env.AB_TOKEN
399
+ ? ((await resolveRepoOrigin(repo, exec)) ?? repo)
400
+ : repo
401
+ const state = resolveRepoStatePaths({
402
+ repo: identity,
403
+ checkout: repo,
404
+ envStore: opts.env.AB_STORE,
405
+ configStore: config.store?.url,
406
+ })
407
+ opts = {
408
+ ...opts,
409
+ env: {
410
+ ...opts.env,
411
+ AB_STORE: state.storeRef,
412
+ ...(isRemoteStoreRef(state.storeRef)
413
+ ? { AB_TOKEN: projectToken(state.storeRef, identity, opts.env) }
414
+ : {}),
415
+ },
416
+ }
417
+ if (config.store?.url === state.storeRef && !opts.env.AB_TOKEN) {
418
+ throw new Error('No project credential for this service; run ab connect')
419
+ }
394
420
  // Pre-registry parse and redaction come from the builtin capability table;
395
421
  // after plugin load the registration's own declarations take over (AUT-516).
396
422
  const builtinCaps = builtinWorkspaceProviderCapabilities(config.workspace.provider)
@@ -405,7 +431,6 @@ export async function validateInitReadiness(opts: {
405
431
  let caps: WorkspaceProviderCapabilities | undefined
406
432
  let assigneeLine: string | undefined
407
433
  try {
408
- hostPreflight(config, opts.env)
409
434
  // Ticket acquisition is a host responsibility for both workspace providers.
410
435
  // Exercise its read surface before allocating disposable infrastructure.
411
436
  const hostPlugins = await loadPlugins(config.plugins, repo, { packageRoot: repo })
@@ -1,5 +1,5 @@
1
1
  import { stat, readFile, realpath } from 'node:fs/promises'
2
- import { join, resolve, sep } from 'node:path'
2
+ import { dirname, join, resolve, sep } from 'node:path'
3
3
  import semver from 'semver'
4
4
  import { PLUGIN_API_VERSION } from '../plugins/manifest'
5
5
  import { defaultDistRoot } from './init'
@@ -155,6 +155,57 @@ function ownerRootForPackage(identity: DistributionIdentity): string | undefined
155
155
  return owner === '' ? sep : owner
156
156
  }
157
157
 
158
+ /** Store-internal fallback links cannot establish project ownership. */
159
+ function inBunStore(path: string): boolean {
160
+ return path
161
+ .split(sep)
162
+ .some((part, index, parts) => part === '.bun' && parts[index - 1] === 'node_modules')
163
+ }
164
+
165
+ function ancestors(path: string): string[] {
166
+ const result: string[] = []
167
+ for (let current = resolve(path); ; current = dirname(current)) {
168
+ result.push(current)
169
+ if (dirname(current) === current) return result
170
+ }
171
+ }
172
+
173
+ async function discoverOwner(
174
+ identity: DistributionIdentity,
175
+ cwd: string,
176
+ ): Promise<string | undefined> {
177
+ const direct = ownerRootForPackage(identity)
178
+ if (direct !== undefined && !inBunStore(direct)) return direct
179
+ // Reject invalid package names before constructing candidate public paths.
180
+ if (identity.packageName.split('/').some((part) => part === '' || part === '.' || part === '..'))
181
+ return undefined
182
+ const candidates = new Set([...ancestors(identity.root), ...ancestors(cwd)])
183
+ for (const candidate of candidates) {
184
+ if (inBunStore(candidate)) continue
185
+ if (!(await samePath(join(candidate, 'node_modules', identity.packageName), identity.root)))
186
+ continue
187
+ const manifestPath = join(candidate, 'package.json')
188
+ if (!(await exists(manifestPath))) continue
189
+ const manifest = await parseJson(manifestPath)
190
+ if (declaredSection(manifest, identity.packageName) !== undefined) return candidate
191
+ }
192
+ return undefined
193
+ }
194
+
195
+ async function readMetadata(path: string): Promise<string> {
196
+ try {
197
+ return await readFile(path, 'utf8')
198
+ } catch (error) {
199
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
200
+ throw new Error(`${path} does not exist`, { cause: error })
201
+ }
202
+ throw new Error(
203
+ `cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`,
204
+ { cause: error },
205
+ )
206
+ }
207
+ }
208
+
158
209
  function githubDependency(value: unknown): { owner: string; repository: string } | undefined {
159
210
  if (typeof value !== 'string') return undefined
160
211
  const match = /^github:([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:#([^\s]+))?$/.exec(value)
@@ -172,8 +223,9 @@ function registryDependency(value: unknown, installedVersion: string): boolean {
172
223
  }
173
224
 
174
225
  async function parseJson(path: string): Promise<Record<string, unknown>> {
226
+ const text = await readMetadata(path)
175
227
  try {
176
- return object(JSON.parse(await readFile(path, 'utf8'))) ?? {}
228
+ return object(JSON.parse(text)) ?? {}
177
229
  } catch (error) {
178
230
  throw new Error(
179
231
  `${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
@@ -182,8 +234,9 @@ async function parseJson(path: string): Promise<Record<string, unknown>> {
182
234
  }
183
235
 
184
236
  async function parseJsonc(path: string): Promise<Record<string, unknown>> {
237
+ const text = await readMetadata(path)
185
238
  try {
186
- const value = Bun.JSONC.parse(await readFile(path, 'utf8'))
239
+ const value = Bun.JSONC.parse(text)
187
240
  return object(value) ?? {}
188
241
  } catch (error) {
189
242
  throw new Error(
@@ -241,6 +294,7 @@ async function samePath(left: string, right: string): Promise<boolean> {
241
294
  export async function inspectInstallation(options: {
242
295
  distRoot?: string
243
296
  globalBin: string
297
+ cwd?: string
244
298
  }): Promise<InstallationInspection> {
245
299
  const identity = await readDistributionIdentity(options.distRoot)
246
300
  if (identity.sourceCheckout) {
@@ -251,18 +305,18 @@ export async function inspectInstallation(options: {
251
305
  }
252
306
  }
253
307
 
254
- const ownerRoot = ownerRootForPackage(identity)
255
- if (ownerRoot === undefined) {
256
- return {
257
- kind: 'unknown',
258
- identity,
259
- reason: 'the distribution is not a direct Bun node_modules package',
308
+ try {
309
+ const ownerRoot = await discoverOwner(identity, options.cwd ?? process.cwd())
310
+ if (ownerRoot === undefined) {
311
+ return {
312
+ kind: 'unknown',
313
+ identity,
314
+ reason: 'the distribution is not a direct Bun node_modules package',
315
+ }
260
316
  }
261
- }
262
- const ownerManifest = join(ownerRoot, 'package.json')
263
- const ownerLock = join(ownerRoot, 'bun.lock')
317
+ const ownerManifest = join(ownerRoot, 'package.json')
318
+ const ownerLock = join(ownerRoot, 'bun.lock')
264
319
 
265
- try {
266
320
  const manifest = await parseJson(ownerManifest)
267
321
  const lock = await parseJsonc(ownerLock)
268
322
  const declared = declaredSection(manifest, identity.packageName)
@@ -1,3 +1,6 @@
1
+ import { preparePinnedAssets } from '../store/ticket-assets'
2
+ import type { PinnedAsset } from '../events/payloads'
3
+ import { abConnect, abDisconnect } from './connect'
1
4
  /**
2
5
  * `ab` — the only channel between agents and the store, and the enforcement
3
6
  * point of the entire ontology (SPEC §8). This module is pure routing: every
@@ -16,7 +19,7 @@ import type { Clock } from '../store/types'
16
19
  import type { BuildStore } from '../store/types'
17
20
  import { textContent } from '../store/types'
18
21
  import type { Forge } from '../ports/types'
19
- import type { Exec } from '../ports/workspace/git-worktree'
22
+ import { spawnExec, type Exec } from '../ports/workspace/git-worktree'
20
23
  import type { IdSource } from '../ids'
21
24
  import { reduceBuild } from '../kernel/reducer'
22
25
  import { artifactDownload, artifactDownloadStream, artifactGet, artifactPut } from './artifact'
@@ -44,7 +47,6 @@ import { recognizeHelpRequest, renderCommandHelp, renderTopLevelHelp } from './h
44
47
  import { observe } from './observe'
45
48
  import { abPlugin, type PluginContractSubprocess } from './plugin'
46
49
  import { preparePrAttachments } from './pr-attachments'
47
- import { renderPrSummary } from './pr-summary'
48
50
  import { abBuildAssetGet, abBuilds, abBuildStatus } from './status'
49
51
  import { abRepositoryStatus } from './repository-status'
50
52
  import { abWatch, WATCH_USAGE } from './watch'
@@ -53,6 +55,7 @@ import { abMcp, AB_MCP_USAGE } from './mcp'
53
55
  import type { StoreOpener } from './store-opening'
54
56
  import { done, escalate, verdict } from './terminals'
55
57
  import { abTicket, openTicketSource } from './ticket'
58
+ import { checkUpgradeCompatibility, type CompatibilityFetch } from './upgrade-compatibility'
56
59
  import { abUpgrade, type ResolveConflict } from './upgrade'
57
60
  import { withUpgradeProgress } from './upgrade-progress'
58
61
  import {
@@ -95,6 +98,8 @@ import {
95
98
  */
96
99
  export const SESSIONLESS_COMMANDS = new Set([
97
100
  'init',
101
+ 'connect',
102
+ 'disconnect',
98
103
  'upgrade',
99
104
  'update',
100
105
  'ticket',
@@ -186,6 +191,7 @@ export interface SessionlessCliDeps {
186
191
  * to the running module, independent of the current repository. */
187
192
  distributionRoot?: string
188
193
  /** Self-update seams keep release/package-manager orchestration testable. */
194
+ compatibilityFetch?: CompatibilityFetch
189
195
  selfUpdateCommand?: SelfUpdateCommand
190
196
  selfUpdate?: (options: SelfUpdateOptions) => Promise<SelfUpdateResult>
191
197
  /** Production supplies the tool-free upgrade agent; tests inject a fake.
@@ -304,6 +310,7 @@ async function runBuildControl(
304
310
  action: BuildControlAction,
305
311
  storeRef?: string,
306
312
  readTicketBody?: (ref: TicketRef) => Promise<string>,
313
+ prepareTicketAssets?: (ref: TicketRef, store: BuildStore, repo: string) => Promise<PinnedAsset[]>,
307
314
  ): Promise<void> {
308
315
  if (deps.exec === undefined) {
309
316
  throw new Error(
@@ -318,6 +325,7 @@ async function runBuildControl(
318
325
  action,
319
326
  ...(storeRef !== undefined ? { storeRef } : {}),
320
327
  ...(readTicketBody !== undefined ? { readTicketBody } : {}),
328
+ ...(prepareTicketAssets ? { prepareTicketAssets } : {}),
321
329
  ...(deps.openStore !== undefined ? { openStore: deps.openStore } : {}),
322
330
  })
323
331
  deps.stdout(buildControlConfirmation(result))
@@ -444,6 +452,24 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
444
452
 
445
453
  // init and upgrade run OUTSIDE build sessions (§16.3): they operate on a
446
454
  // repo, not a build, so they route before any store/env requirement.
455
+ case 'connect':
456
+ case 'disconnect': {
457
+ const usage = command === 'connect' ? 'usage: ab connect [url]' : 'usage: ab disconnect'
458
+ const parsed = parseArgs(rest, { tickets: 'boolean', builds: 'boolean' }, usage)
459
+ if (parsed.positionals.length > (command === 'connect' ? 1 : 0)) throw new Error(usage)
460
+ const options = {
461
+ tickets: parsed.flags.get('tickets') === true,
462
+ builds: parsed.flags.get('builds') === true,
463
+ cwd: deps.workspacePath,
464
+ env: deps.processEnv ?? process.env,
465
+ exec: deps.exec ?? spawnExec,
466
+ print: stdout,
467
+ ...(deps.signal ? { signal: deps.signal } : {}),
468
+ }
469
+ if (command === 'connect') await abConnect(parsed.positionals[0], options)
470
+ else await abDisconnect(options)
471
+ return 0
472
+ }
447
473
  case 'init': {
448
474
  const usage = 'usage: ab init [target] [--force | --validate] (§16.3)'
449
475
  const removed = [
@@ -515,6 +541,14 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
515
541
  }
516
542
  const targetRepo = parsed.positionals[0] ?? deps.workspacePath
517
543
  const noCommit = parsed.flags.has('no-commit')
544
+ const compatibilityWarnings = new Set<string>()
545
+ const upgradeStderr = (line: string): void => {
546
+ if (line.startsWith('warning:')) {
547
+ if (compatibilityWarnings.has(line)) return
548
+ compatibilityWarnings.add(line)
549
+ }
550
+ stderr(line)
551
+ }
518
552
  const handoff = deps.processEnv?.[SELF_UPDATE_HANDOFF_ENV] === '1'
519
553
  let commitContext: UpgradeCommitContext | undefined
520
554
  if (!noCommit) {
@@ -543,9 +577,10 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
543
577
  if (!parsed.flags.has('no-self-update') && !handoff) {
544
578
  const update = await (deps.selfUpdate ?? selfUpdate)({
545
579
  targetRepo,
580
+ compatibilityFetch: deps.compatibilityFetch,
546
581
  stdout,
547
- stderr,
548
- env: deps.processEnv ?? {},
582
+ stderr: upgradeStderr,
583
+ env: deps.processEnv ?? process.env,
549
584
  noCommit: noCommit || commitContext === undefined,
550
585
  ...(commitContext === undefined
551
586
  ? {}
@@ -559,6 +594,13 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
559
594
  if (update.kind === 'handoff') return update.exitCode
560
595
  if (update.kind === 'failed') return 1
561
596
  }
597
+ await checkUpgradeCompatibility({
598
+ targetRepo,
599
+ distRoot: deps.distributionRoot,
600
+ env: deps.processEnv,
601
+ stderr: upgradeStderr,
602
+ fetch: deps.compatibilityFetch,
603
+ })
562
604
  const resolverFactory = deps.upgradeResolverFactory
563
605
  let resolver: ResolveConflict | undefined
564
606
  const resolveConflict: ResolveConflict | undefined =
@@ -1091,6 +1133,7 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
1091
1133
  : parsed.flags.has('revise-spec-from-ticket')
1092
1134
  ? ({ kind: 'revise-spec', body: { kind: 'ticket' } } as const)
1093
1135
  : undefined
1136
+ let revisionTicketSource: import('../ports/types').TicketSource | undefined
1094
1137
  const readTicketBody =
1095
1138
  resolve?.kind === 'revise-spec' && resolve.body.kind === 'ticket'
1096
1139
  ? async (ref: TicketRef): Promise<string> => {
@@ -1104,6 +1147,7 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
1104
1147
  },
1105
1148
  'ab answer --revise-spec-from-ticket',
1106
1149
  )
1150
+ revisionTicketSource = source
1107
1151
  if (source.name !== ref.source) {
1108
1152
  throw new Error(
1109
1153
  `build ticket source is "${ref.source}", but this repository is configured for "${source.name}"; refusing to read a same-id ticket from a different source`,
@@ -1115,7 +1159,10 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
1115
1159
  `ticket "${ref.id}" was not found in the configured ${source.name} source`,
1116
1160
  )
1117
1161
  }
1118
- if (ticket.ref.source !== ref.source || ticket.ref.id !== ref.id) {
1162
+ if (
1163
+ ticket.ref.source !== ref.source ||
1164
+ (source.name !== 'hosted' && ticket.ref.id !== ref.id)
1165
+ ) {
1119
1166
  throw new Error(
1120
1167
  `ticket adapter returned ${ticket.ref.source}:${ticket.ref.id}, expected ${ref.source}:${ref.id}`,
1121
1168
  )
@@ -1134,6 +1181,13 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
1134
1181
  },
1135
1182
  storeRef,
1136
1183
  readTicketBody,
1184
+ readTicketBody
1185
+ ? async (ref, store, repo) => {
1186
+ if (!revisionTicketSource)
1187
+ throw new Error('ticket body must be read before preparing assets')
1188
+ return preparePinnedAssets(revisionTicketSource, store, repo, ref.id)
1189
+ }
1190
+ : undefined,
1137
1191
  )
1138
1192
  return 0
1139
1193
  }
@@ -1314,7 +1368,7 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
1314
1368
  const meta = await artifactPut(session, kind, file, { attach })
1315
1369
 
1316
1370
  // A designation made after the PR exists (verify after reconcile or a
1317
- // finalize post-step) republishes the same complete projection. Every
1371
+ // finalize post-step) prepares hosted attachments. Every
1318
1372
  // external side effect is best-effort after the atomic designation.
1319
1373
  if (attach) {
1320
1374
  try {
@@ -1328,16 +1382,6 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
1328
1382
  // asset identity before throwing. Keep going so its hosting
1329
1383
  // failure cannot suppress the complete text projection.
1330
1384
  }
1331
- try {
1332
- await session.forge.commentOnPr(
1333
- session.workspacePath,
1334
- pr.number,
1335
- renderPrSummary(session.env, await session.store.getEvents(session.env.build)),
1336
- )
1337
- } catch {
1338
- // The exact artifact and designation are already durable. A
1339
- // comment refresh cannot turn the agent's deposit into failure.
1340
- }
1341
1385
  }
1342
1386
  } catch {
1343
1387
  // Reading the optional PR projection is best-effort too.
@@ -107,19 +107,21 @@ export async function buildMcpTicketBackend(
107
107
  config.tickets.readyState,
108
108
  config.tickets.claimedState,
109
109
  config.tickets.createState,
110
+ config.tickets.doneState,
110
111
  ].filter((state): state is string => state !== undefined),
111
112
  ),
112
113
  ]
113
114
  return {
114
115
  sourceFor: async (context) => {
115
- if (config.tickets.teamKey !== context.teamKey) {
116
+ if ((config.tickets.teamKey ?? '') !== context.teamKey) {
116
117
  throw new Error(
117
118
  `the repository's effective ticket config names team "${context.teamKey}", but this checkout's autobuild.toml names team "${String(config.tickets.teamKey)}"; refusing to serve tickets across that drift — run ab mcp from the checkout dispatch uses`,
118
119
  )
119
120
  }
120
121
  return source
121
122
  },
122
- statesFor: async () => states,
123
+ statesFor: async (_context, source) =>
124
+ source.listStates ? (await source.listStates()).map((s) => s.name) : states,
123
125
  }
124
126
  }
125
127
 
@@ -200,6 +202,11 @@ export async function abMcp(opts: AbMcpOpts): Promise<number> {
200
202
  try {
201
203
  const backend = await buildMcpTicketBackend({
202
204
  ...opts,
205
+ env: {
206
+ ...opts.env,
207
+ AB_STORE: context.storeRef,
208
+ ...(context.token !== undefined ? { AB_TOKEN: context.token } : {}),
209
+ },
203
210
  checkout: context.checkout,
204
211
  localStateRoot: context.localStateRoot,
205
212
  })