@defrex/autobuild 0.11.0 → 0.12.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 (96) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +222 -22
  3. package/docs/architecture.md +66 -4
  4. package/docs/configuration.md +105 -16
  5. package/docs/hosted-dispatcher.md +17 -2
  6. package/docs/hosted-service.md +214 -0
  7. package/docs/mcp.md +8 -2
  8. package/docs/operator-api.md +90 -7
  9. package/docs/remote-store-protocol.md +189 -144
  10. package/docs/remote-ticket-protocol.md +107 -19
  11. package/docs/setup.md +173 -16
  12. package/docs/ticket-dependencies.md +1 -1
  13. package/package.json +7 -6
  14. package/packages/core/src/cli/attention.ts +209 -0
  15. package/packages/core/src/cli/configured-store.ts +17 -0
  16. package/packages/core/src/cli/connect.ts +261 -0
  17. package/packages/core/src/cli/dashboard/model.ts +4 -5
  18. package/packages/core/src/cli/dispatch.ts +183 -41
  19. package/packages/core/src/cli/help.ts +39 -10
  20. package/packages/core/src/cli/init-validation.ts +27 -2
  21. package/packages/core/src/cli/main.ts +41 -15
  22. package/packages/core/src/cli/mcp.ts +7 -1
  23. package/packages/core/src/cli/pr-summary-publication.ts +220 -0
  24. package/packages/core/src/cli/pr-summary.ts +2 -0
  25. package/packages/core/src/cli/project-credentials.ts +132 -0
  26. package/packages/core/src/cli/repo-state.ts +15 -2
  27. package/packages/core/src/cli/self-update.ts +10 -0
  28. package/packages/core/src/cli/store-opening.ts +22 -14
  29. package/packages/core/src/cli/terminals.ts +0 -10
  30. package/packages/core/src/cli/ticket.ts +8 -1
  31. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  32. package/packages/core/src/cli/wait.ts +84 -17
  33. package/packages/core/src/cli/watch.ts +197 -60
  34. package/packages/core/src/config/live.ts +3 -0
  35. package/packages/core/src/config/schema.ts +56 -9
  36. package/packages/core/src/connection.ts +86 -0
  37. package/packages/core/src/events/catalog.ts +10 -2
  38. package/packages/core/src/events/payloads.ts +44 -0
  39. package/packages/core/src/events/repository.ts +13 -0
  40. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +130 -83
  41. package/packages/core/src/kernel/engine.ts +24 -0
  42. package/packages/core/src/kernel/generators/build-log.ts +10 -0
  43. package/packages/core/src/kernel/log-index.ts +17 -0
  44. package/packages/core/src/kernel/pr-summary.ts +89 -0
  45. package/packages/core/src/kernel/reducer-registry.ts +8 -0
  46. package/packages/core/src/kernel/reducer.ts +8 -0
  47. package/packages/core/src/ontology.ts +1 -0
  48. package/packages/core/src/operator/index.ts +7 -0
  49. package/packages/core/src/operator/query.ts +38 -16
  50. package/packages/core/src/operator/ticket-source-backend.ts +1 -0
  51. package/packages/core/src/operator/tickets.ts +6 -8
  52. package/packages/core/src/plugin-sdk/index.ts +1 -0
  53. package/packages/core/src/plugins/manifest.ts +5 -3
  54. package/packages/core/src/plugins/runtimes.ts +3 -1
  55. package/packages/core/src/ports/forge/fake.ts +13 -1
  56. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  57. package/packages/core/src/ports/forge/github.ts +29 -5
  58. package/packages/core/src/ports/forge/local-git.ts +12 -4
  59. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  60. package/packages/core/src/ports/runner/session-env.ts +1 -0
  61. package/packages/core/src/ports/tickets/create.ts +11 -3
  62. package/packages/core/src/ports/tickets/hosted-index.ts +0 -3
  63. package/packages/core/src/ports/tickets/remote-protocol.ts +1 -0
  64. package/packages/core/src/ports/tickets/remote.ts +1 -0
  65. package/packages/core/src/ports/workspace/build-execution.ts +5 -1
  66. package/packages/core/src/ports/workspace/create.ts +11 -2
  67. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  68. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  69. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  70. package/packages/core/src/ports/workspace/vercel-sandbox.ts +47 -19
  71. package/packages/core/src/processes/build-execution-state.ts +1 -1
  72. package/packages/core/src/processes/build-runner.ts +87 -26
  73. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  74. package/packages/core/src/processes/dispatcher.ts +59 -10
  75. package/packages/core/src/processes/harvest-child.ts +1 -0
  76. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  77. package/packages/core/src/processes/harvest-runner.ts +142 -100
  78. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  79. package/packages/core/src/processes/publication-loss.ts +18 -1
  80. package/packages/core/src/store/remote/client.ts +1 -1
  81. package/packages/core/src/store/remote/compatibility.ts +90 -0
  82. package/packages/core/src/store/remote/credentials.ts +28 -0
  83. package/packages/core/src/store/remote/index.ts +14 -1
  84. package/packages/core/src/store/remote/protocol.ts +25 -0
  85. package/packages/core/src/store/remote/token.ts +74 -13
  86. package/packages/core/src/testing/index.ts +7 -0
  87. package/skills/guide/SKILL.md +144 -26
  88. package/skills/guide/references/operator-api.md +135 -13
  89. package/skills/guide/references/plugin-authoring.md +10 -2
  90. package/skills/guide/references/remote-store-protocol.md +189 -144
  91. package/skills/guide/references/remote-ticket-protocol.md +111 -18
  92. package/skills/guide/references/setup.md +173 -16
  93. package/skills/guide/references/ticket-dependencies.md +1 -1
  94. package/skills/operate/SKILL.md +126 -36
  95. package/templates/hosted-service/package.json +13 -0
  96. package/templates/hosted-service/vercel.json +7 -0
@@ -0,0 +1,90 @@
1
+ import semver from 'semver'
2
+ import { z } from 'zod'
3
+ import { AUTOBUILD_VERSION, REMOTE_STORE_PROTOCOL_VERSION } from './version'
4
+
5
+ /** Stable releases only: prefixes, whitespace, prereleases and build suffixes are not identities. */
6
+ export function stableVersion(value: unknown): semver.SemVer | undefined {
7
+ if (typeof value !== 'string' || !/^\d+\.\d+\.\d+$/.test(value)) return undefined
8
+ const parsed = semver.parse(value)
9
+ return parsed && parsed.version === value ? parsed : undefined
10
+ }
11
+
12
+ const stableVersionSchema = z.string().refine((value) => stableVersion(value) !== undefined)
13
+ export const supportedClientRangeSchema = z
14
+ .strictObject({
15
+ minVersion: stableVersionSchema,
16
+ maxVersionExclusive: stableVersionSchema,
17
+ })
18
+ .refine((range) => semver.lt(range.minVersion, range.maxVersionExclusive))
19
+ export type SupportedClientRange = z.infer<typeof supportedClientRangeSchema>
20
+
21
+ /** A major starts a new family: X.0 has one line, X.1 two, X.2+ three. */
22
+ export function supportedClientRange(serviceVersion = AUTOBUILD_VERSION): SupportedClientRange {
23
+ const version = stableVersion(serviceVersion)
24
+ if (!version) throw new Error('service must have a stable release version')
25
+ return {
26
+ minVersion: `${version.major}.${Math.max(0, version.minor - 2)}.0`,
27
+ maxVersionExclusive: `${version.major}.${version.minor + 1}.0`,
28
+ }
29
+ }
30
+
31
+ export function classifyClient(
32
+ version: unknown,
33
+ range: SupportedClientRange,
34
+ ): 'supported' | 'older' | 'newer' | 'invalid' {
35
+ const parsed = stableVersion(version)
36
+ if (!parsed) return 'invalid'
37
+ if (semver.lt(parsed, range.minVersion)) return 'older'
38
+ if (semver.gte(parsed, range.maxVersionExclusive)) return 'newer'
39
+ return 'supported'
40
+ }
41
+
42
+ export function formatClientRange(range: SupportedClientRange): string {
43
+ return `>=${range.minVersion} <${range.maxVersionExclusive}`
44
+ }
45
+
46
+ export function compatibilityAdvice(
47
+ client: unknown,
48
+ serviceVersion: string,
49
+ range: SupportedClientRange,
50
+ ): string | undefined {
51
+ const classification = classifyClient(client, range)
52
+ const description = `service Autobuild ${serviceVersion} supports clients ${formatClientRange(range)}`
53
+ if (classification === 'supported') return undefined
54
+ if (classification === 'newer') {
55
+ const version = stableVersion(client)!
56
+ return `${description}; upgrade the service to at least ${version.major}.${version.minor}.0 before upgrading projects (service first)`
57
+ }
58
+ if (classification === 'older')
59
+ return `${description}; upgrade the project's Autobuild into ${formatClientRange(range)}`
60
+ return `${description}; supply a stable Autobuild client version`
61
+ }
62
+
63
+ /** Retain these wire identities and handlers until their lines leave the window.
64
+ * Historical lines require explicit entries; the service's own line uses its
65
+ * current protocol identity. Unknown historical protocols fail closed. */
66
+ export const RELEASE_LINE_PROTOCOLS: Readonly<Record<string, string>> = {
67
+ '0.9': '3',
68
+ '0.10': '3',
69
+ '0.11': '3',
70
+ '0.12': '3',
71
+ }
72
+
73
+ export function clientIdentityError(
74
+ client: string | null,
75
+ protocol: string | null,
76
+ serviceVersion = AUTOBUILD_VERSION,
77
+ serviceProtocol = REMOTE_STORE_PROTOCOL_VERSION,
78
+ ): string | undefined {
79
+ const range = supportedClientRange(serviceVersion)
80
+ const service = stableVersion(serviceVersion)!
81
+ const advice = compatibilityAdvice(client, serviceVersion, range)
82
+ const parsed = stableVersion(client)
83
+ const expected =
84
+ parsed &&
85
+ (parsed.major === service.major && parsed.minor === service.minor
86
+ ? serviceProtocol
87
+ : RELEASE_LINE_PROTOCOLS[`${parsed.major}.${parsed.minor}`])
88
+ if (!advice && expected !== undefined && protocol === expected) return undefined
89
+ return `remote store version mismatch: client Autobuild ${client ?? '(missing)'} protocol ${protocol ?? '(missing)'}; server Autobuild ${serviceVersion} protocol ${serviceProtocol}; ${advice ?? `supported clients ${formatClientRange(range)}; unsupported protocol identity`}`
90
+ }
@@ -0,0 +1,28 @@
1
+ import { delegateCredentialResponseSchema, type DelegateCredentialTarget } from './protocol'
2
+ import {
3
+ AUTOBUILD_VERSION,
4
+ AUTOBUILD_VERSION_HEADER,
5
+ REMOTE_STORE_PROTOCOL_VERSION,
6
+ REMOTE_STORE_PROTOCOL_VERSION_HEADER,
7
+ } from './version'
8
+
9
+ /** Fail closed: a remote execution must never fall back to its parent token. */
10
+ export async function delegateCredential(
11
+ storeRef: string,
12
+ token: string | undefined,
13
+ target: DelegateCredentialTarget,
14
+ ): Promise<string> {
15
+ if (!token) throw new Error('credential delegation requires parent authority')
16
+ const response = await fetch(`${storeRef.replace(/\/$/, '')}/credentials/delegate`, {
17
+ method: 'POST',
18
+ headers: {
19
+ authorization: `Bearer ${token}`,
20
+ 'content-type': 'application/json',
21
+ [AUTOBUILD_VERSION_HEADER]: AUTOBUILD_VERSION,
22
+ [REMOTE_STORE_PROTOCOL_VERSION_HEADER]: REMOTE_STORE_PROTOCOL_VERSION,
23
+ },
24
+ body: JSON.stringify(target),
25
+ })
26
+ if (!response.ok) throw new Error(`credential delegation refused (${response.status})`)
27
+ return delegateCredentialResponseSchema.parse(await response.json()).token
28
+ }
@@ -4,7 +4,13 @@
4
4
  export { AuthError, RemoteBuildStore } from './client'
5
5
  export type { RemoteBuildStoreOptions, RemoteStoreIdentity } from './client'
6
6
  export { mintToken, tokenResource, verifyToken } from './token'
7
- export type { OperatorTokenScope, TokenScope } from './token'
7
+ export type {
8
+ OperatorTokenScope,
9
+ InternalOperatorTokenScope,
10
+ AdminTokenScope,
11
+ TokenScope,
12
+ DelegatedTokenScope,
13
+ } from './token'
8
14
  export {
9
15
  AUTOBUILD_VERSION,
10
16
  AUTOBUILD_VERSION_HEADER,
@@ -22,3 +28,10 @@ export type { SessionEvent, SessionEventWrite } from '../../events/sessions'
22
28
  export type { RepositoryEvent, RepositoryEventWrite } from '../../events/repository'
23
29
  export { SNAPSHOT_REDUCER_NAME } from '../snapshots'
24
30
  export type { ReducerSnapshot, SnapshotScope } from '../snapshots'
31
+
32
+ export * from './compatibility'
33
+
34
+ export { delegateCredential } from './credentials'
35
+
36
+ export { reduceBuild } from '../../kernel/reducer'
37
+ export { reduceHarvest } from '../../kernel/harvest'
@@ -450,3 +450,28 @@ export function substitutePlaceholderRefs(
450
450
  }
451
451
  return value
452
452
  }
453
+
454
+ /** Issuance is an authentication operation, separate from the BuildStore port. */
455
+ export const delegateCredentialBodySchema = z.discriminatedUnion('kind', [
456
+ z.strictObject({ kind: z.literal('build-runner'), id: z.string().min(1) }),
457
+ z.strictObject({
458
+ kind: z.literal('build-phase'),
459
+ id: z.string().min(1),
460
+ session: z.string().min(1),
461
+ }),
462
+ z.strictObject({
463
+ kind: z.literal('harvest-runner'),
464
+ execution: z.string().min(1),
465
+ repo: z.string().min(1),
466
+ id: z.string().min(1),
467
+ leaseHolder: z.string().min(1),
468
+ }),
469
+ z.strictObject({
470
+ kind: z.literal('harvest-phase'),
471
+ repo: z.string().min(1),
472
+ id: z.string().min(1),
473
+ session: z.string().min(1),
474
+ }),
475
+ ])
476
+ export type DelegateCredentialTarget = z.infer<typeof delegateCredentialBodySchema>
477
+ export const delegateCredentialResponseSchema = z.strictObject({ token: z.string().min(1) })
@@ -1,13 +1,7 @@
1
- /**
2
- * HMAC-scoped store tokens (SPEC §8.1 [D8]): "the token is scoped to this
3
- * build and session — an agent physically cannot append to another build's
4
- * log or read another build's artifacts". Least privilege comes from the
5
- * runner minting a narrow token, not from prompt instructions.
6
- *
7
- * A token is `base64url(JSON scope) + '.' + base64url(hmac-sha256(secret,
8
- * payload))`. The scope IS the token — the server keeps no session state;
9
- * it verifies the signature (timing-safe) and the expiry, then trusts the
10
- * embedded scope.
1
+ /** Signed lifecycle-bound execution grants, internal owner delegation and admin.
2
+ * Historical resource/deployment/operator scopes remain decodable for tests
3
+ * and migration diagnostics; hosted machine gates reject those retired forms.
4
+ * Project credentials are opaque stored secrets, not these stateless tokens.
11
5
  */
12
6
  import { createHmac, timingSafeEqual } from 'node:crypto'
13
7
  import { z } from 'zod'
@@ -52,8 +46,35 @@ export interface DeploymentTokenScope {
52
46
  exp: number
53
47
  }
54
48
 
55
- /** Legacy scopes remain valid on their existing routes. */
49
+ /** Includes retired wire shapes for recognition, never server authorization. */
50
+ export interface InternalOperatorTokenScope {
51
+ version: 1
52
+ internalOperator: { user: string; repo: string }
53
+ exp: number
54
+ via?: Via
55
+ }
56
+ export interface AdminTokenScope {
57
+ version: 1
58
+ ownerAdmin: { user: string }
59
+ exp: number
60
+ }
61
+ export interface DelegatedTokenScope {
62
+ version: 2
63
+ delegation: {
64
+ repo: string
65
+ kind: 'build-runner' | 'build-phase' | 'harvest-runner' | 'harvest-phase'
66
+ id: string
67
+ anchor: number
68
+ session?: string
69
+ execution?: string
70
+ leaseHolder?: string
71
+ }
72
+ }
73
+
56
74
  export type TokenScope =
75
+ | DelegatedTokenScope
76
+ | InternalOperatorTokenScope
77
+ | AdminTokenScope
57
78
  | LegacyBuildTokenScope
58
79
  | ResourceTokenScope
59
80
  | OperatorTokenScope
@@ -63,7 +84,38 @@ const commonScope = {
63
84
  session: z.string().min(1),
64
85
  exp: z.number().int(),
65
86
  }
87
+ const exactIdentity = z
88
+ .string()
89
+ .min(1)
90
+ .refine((value) => value !== '*')
91
+ const delegatedClaims = z
92
+ .strictObject({
93
+ repo: exactIdentity,
94
+ kind: z.enum(['build-runner', 'build-phase', 'harvest-runner', 'harvest-phase']),
95
+ id: exactIdentity,
96
+ anchor: z.number().int().positive(),
97
+ session: exactIdentity.optional(),
98
+ execution: exactIdentity.optional(),
99
+ leaseHolder: exactIdentity.optional(),
100
+ })
101
+ .refine(
102
+ (claims) =>
103
+ claims.kind.endsWith('-phase') === (claims.session !== undefined) &&
104
+ claims.kind.startsWith('harvest') === (claims.execution !== undefined),
105
+ )
66
106
  const tokenScopeSchema = z.union([
107
+ z.strictObject({ version: z.literal(2), delegation: delegatedClaims }),
108
+ z.strictObject({
109
+ version: z.literal(1),
110
+ internalOperator: z.strictObject({ user: z.string().trim().min(1), repo: z.string().min(1) }),
111
+ exp: z.number().int(),
112
+ via: viaSchema.optional(),
113
+ }),
114
+ z.strictObject({
115
+ version: z.literal(1),
116
+ ownerAdmin: z.strictObject({ user: z.string().trim().min(1) }),
117
+ exp: z.number().int(),
118
+ }),
67
119
  z.strictObject({ build: z.string().min(1), ...commonScope }),
68
120
  z.strictObject({
69
121
  resource: z.strictObject({
@@ -85,6 +137,13 @@ export function tokenResource(scope: TokenScope): {
85
137
  kind: 'build' | 'repo' | 'session' | 'admin' | 'operator' | 'deployment'
86
138
  id: string
87
139
  } {
140
+ if ('delegation' in scope)
141
+ return {
142
+ kind: scope.delegation.kind.startsWith('build') ? 'build' : 'repo',
143
+ id: scope.delegation.kind.startsWith('build') ? scope.delegation.id : scope.delegation.repo,
144
+ }
145
+ if ('ownerAdmin' in scope) return { kind: 'admin', id: '*' }
146
+ if ('internalOperator' in scope) return { kind: 'operator', id: scope.internalOperator.user }
88
147
  if ('operator' in scope) {
89
148
  return scope.operator === true
90
149
  ? { kind: 'deployment', id: '*' }
@@ -108,7 +167,9 @@ export function mintToken(secret: string, scope: TokenScope): string {
108
167
  /**
109
168
  * Bad signature, malformed token, unparseable scope, or expired → null —
110
169
  * verification never explains itself to the caller (the server maps null to
111
- * 401). The signature compare is timing-safe via `crypto.timingSafeEqual`.
170
+ * 401). Version-2 grants deliberately have no fixed expiration: the service
171
+ * must additionally validate their registered project and durable lifecycle.
172
+ * The signature compare is timing-safe via `crypto.timingSafeEqual`.
112
173
  */
113
174
  export function verifyToken(secret: string, token: string, now: Date): TokenScope | null {
114
175
  const parts = token.split('.')
@@ -132,6 +193,6 @@ export function verifyToken(secret: string, token: string, now: Date): TokenScop
132
193
  }
133
194
  const scope = tokenScopeSchema.safeParse(raw)
134
195
  if (!scope.success) return null
135
- if (scope.data.exp <= now.getTime()) return null
196
+ if ('exp' in scope.data && scope.data.exp <= now.getTime()) return null
136
197
  return scope.data
137
198
  }
@@ -57,3 +57,10 @@ export {
57
57
  type E2eHarness,
58
58
  type SkillHandlers,
59
59
  } from '../integration/harness'
60
+
61
+ // Harvest lifecycle and CLI seams for delegated-credential integration tests.
62
+ export { HarvestRunner } from '../processes/harvest-runner'
63
+ export { resolveHarvestCliEnv } from '../cli/env'
64
+ export { buildHarvestContext, submitHarvestProposals, submitHarvestVerdict } from '../cli/harvest'
65
+ export { abConnect, abDisconnect } from '../cli/connect'
66
+ export { ProjectCredentials } from '../cli/project-credentials'
@@ -283,6 +283,19 @@ through the strict versioned manifest and environment-only secrets, to the
283
283
  unchanged shared contract suite and optional npm publication—read
284
284
  [`references/plugin-authoring.md`](references/plugin-authoring.md).
285
285
 
286
+ ### `[store]`
287
+
288
+ | Key | Required | Values / purpose |
289
+ |---|---|---|
290
+ | `url` | When `[store]` exists | Absolute HTTPS service base URL; loopback HTTP is allowed for development. No credentials, query or fragment. Trailing slashes are normalized. |
291
+
292
+ Selection is explicit `--store` > nonblank `AB_STORE` > `[store] url` > the main
293
+ checkout's `.autobuild/`. A configured service without a user credential fails
294
+ with `ab connect` guidance. `AB_TOKEN` overrides the saved service/project
295
+ credential. See [repository setup](references/setup.md#connect-a-project).
296
+ Changing the store requires restarting dispatch.
297
+
298
+
286
299
  ### `[pr]`
287
300
 
288
301
  Optional PR-projection settings. Omission leaves artifact attachments text-only.
@@ -408,6 +421,28 @@ manager.
408
421
 
409
422
  ### `[verify]`
410
423
 
424
+ Post-reconcile verification defaults to enabled: `[verify].afterReconcile = true`.
425
+ Set it to `false` to retain pre-reconcile verification and skip all steps after
426
+ reconciliation. An optional boolean `[verify.<step>].afterReconcile` overrides
427
+ the repository setting in either direction; overrides require a configured step.
428
+ For example, set `afterReconcile = false` in `[verify]` and
429
+ `afterReconcile = true` in `[verify.types]` to keep only that step re-running.
430
+
431
+ The kernel freezes the resolved ordered policy in
432
+ `verify.reconcile-policy-captured` at the next engine action after reconcile
433
+ completion. Live configuration changes apply at that boundary; a cycle already
434
+ started keeps its policy through recovery. Historical cycles already started
435
+ without a captured policy retain the rerun-all behavior. Initial verification
436
+ and verification after subsequent code review always use ordinary applicability.
437
+ Policy exclusion precedes plan selection and paths, including `always = true`.
438
+ Enabled steps still follow plan selection and path rules. Excluded steps record
439
+ `verify.completed` with outcome `skipped`, `skipCause = "post-reconcile-policy"`,
440
+ and a reason naming the setting. Dashboards and PR summaries call this
441
+ "skipped by post-reconcile policy"; it is never a pass. Forge-side required
442
+ checks and branch protections still gate merge. For excluded steps, merged code
443
+ is not re-verified, so breakage that only exists in the combination can land.
444
+
445
+
411
446
  `steps` orders the verify phases; each entry `"<step>"` becomes a
412
447
  `verify:<step>` phase and needs its own `[verify.<step>]` subtable. Those
413
448
  subtables are part of this section — their fields are listed here.
@@ -419,6 +454,7 @@ subtables are part of this section — their fields are listed here.
419
454
  | `command` | — | **required when `kind = "check"`**, nonempty string | Ref into `[commands]` — the key, not a shell string. Pass/fail is the command's exit status. |
420
455
  | `skill` | — | **required when `kind = "agent"`**, nonempty string | Installed skill name to run (e.g. a repository-authored `"verify-app-e2e"`). |
421
456
  | `paths` | — | optional nonempty array of positive repository-relative globs | Makes either step kind apply when any changed path matches any selector. Omitted means unconditional. |
457
+ | `afterReconcile` | repository: `true`; step: inherited | boolean | Step override wins over `[verify].afterReconcile`; false skips this step after reconciliation only. |
422
458
  | `always` | — | optional boolean | `true` makes the step unconditional and mandatory (a plan cannot deselect it); `false` is equivalent to omission. |
423
459
 
424
460
  A plan may begin with strict TOML front matter selecting the complete set of
@@ -488,7 +524,7 @@ A selected step's `paths` are evaluated immediately before the step against
488
524
  current `HEAD`, relative to the initial branch-cut SHA or the refreshed base
489
525
  from the latest completed reconcile. It uses a NUL-delimited, no-rename Git diff, so
490
526
  adds/modifications/deletions and both rename sides participate. A reconcile
491
- starts a fresh cycle and evaluation repeats; upstream-only merged paths are
527
+ starts a fresh cycle and evaluation repeats for policy-enabled steps; upstream-only merged paths are
492
528
  excluded while build-owned resolutions remain visible. A miss starts no
493
529
  command, agent, or session and records `skipped` with
494
530
  `excluded by [verify.<step>].paths: no changed path matched <JSON paths>`.
@@ -688,6 +724,7 @@ nonnegative integer so zero can disable that trigger.
688
724
  | `sessionBudgetSeconds` | `3600` | positive integer | Wall-clock bound for each build agent session unless its logical role overrides it. |
689
725
  | `stallRounds` | `3` | positive integer | The same finding surviving this many review rounds auto-escalates to a human — the anti-loop guard. |
690
726
  | `maxVerifyAttempts` | `3` | positive integer | Caps the `verify → implement → verify` cycle before escalation. |
727
+ | `maxDispatchAttempts` | `3` | positive integer | Caps consecutive dispatch failures before a setup-targeted `dispatch-failure-limit` escalation. |
691
728
  | `maxSetupAttempts` | `3` | positive integer | Caps consecutive `[commands].setup` failures before a setup-targeted human escalation. |
692
729
  | `maxInfrastructureAttempts` | `3` | positive integer | Caps consecutive remote provider lifecycle failures before a setup-targeted human escalation; a `retry` answer re-arms the budget. |
693
730
  | `maxReconcileAttempts` | `3` | positive integer | Caps completed reconciles that leave the PR conflicted against an unchanged authoritative base. Moving-base races do not consume the budget. |
@@ -695,6 +732,11 @@ nonnegative integer so zero can disable that trigger.
695
732
  | `harvestThreshold` | `5` | positive integer | Newly unclaimed `observation.recorded` occurrences required to start one repository harvest run. |
696
733
  | `harvestMaxDrift` | `3` | nonnegative integer | Other builds merged after the oldest unclaimed observation required to start a run; `0` disables drift. |
697
734
 
735
+ Dispatch failures share one budget across stages until runner attachment. At
736
+ exhaustion dispatch parks durably; bare `ab answer <slug>` and text guidance
737
+ both grant a fresh budget, while abort terminates without retrying. Workspace
738
+ failures owned by provider infrastructure recovery use only its existing budget.
739
+
698
740
  For each repeat conflict, Autobuild compares the base merged by the latest
699
741
  completed reconcile with a fresh authoritative base snapshot. An advanced base
700
742
  proves the attempt lost a race and permits another reconcile regardless of the
@@ -712,7 +754,7 @@ roundless no-progress answer. An already exhausted no-progress budget raises its
712
754
  matching cause separately before another reconcile. Cause-less historical
713
755
  raises retain their former round-shaped reset behavior, and roundless
714
756
  policy/reconcile raises retain their former no-progress meaning, on replay
715
- without migration. Post-reconcile verification still runs in full and remains
757
+ without migration. Post-reconcile verification follows the captured policy and remains
716
758
  independently bounded by `maxVerifyAttempts`.
717
759
 
718
760
  `stallRounds` counts *persistence chains*, which reviewers mark and the kernel
@@ -748,10 +790,11 @@ state eligible.
748
790
  | `readyLabels` | — (source-aware) | optional; array of nonempty strings | A ticket must carry **every** listed label to be dispatchable. `[]` = **no label gate**. Absent uses the source default below. |
749
791
  | `readyState` | — | **required**, non-blank string | The one workflow state a ticket must sit in to be dispatchable. Linear matches exactly and case-sensitively; file matches it case-insensitively to a state directory (`ready` → `ready/`). There is no any-state mode. |
750
792
  | `readyAssignee` | — | optional; `"me"` or an email; `linear` only | Also require the ticket to be assigned to that Linear user (`me` = owner of `LINEAR_API_KEY`); unassigned tickets are never ready. |
751
- | `teamKey` | — | required by `linear`, forbidden by `file`, allowed for plugins | The Linear team key (e.g. `"ENG"`) or an existing plugin configuration field. |
793
+ | `teamKey` | — | required by `linear` and `hosted`, forbidden by `file`, allowed for plugins | The Linear team key (e.g. `"ENG"`) or an existing plugin configuration field. |
752
794
  | `claimedState` | — | optional nonempty string; forbidden by `file`, allowed for plugins | Workflow state `claim()` moves a ticket to when a build starts. |
795
+ | `doneState` | hosted: `"Done"` | optional nonblank string | Project completion state and merged-ticket transition; hosted tickets only. |
753
796
  | `createState` | — | optional, nonempty string | Default state for new tickets when a create does not name one. Absent = the provider's default (Linear: the team's default, e.g. Backlog; file: Triage). |
754
- | `triageState` | — | optional, nonempty string | State the dispatcher hands tickets back to for human triage — spec-gate bounces, aborted builds, closed-unmerged PRs. Absent = Linear: Backlog; file/plugin: Triage. Must name a state the tracker actually has — a Linear team only has "Triage" when its triage feature is enabled. |
797
+ | `triageState` | — | optional, nonempty string | State the dispatcher hands tickets back to for human triage — spec-gate bounces, aborted builds, closed-unmerged PRs. Absent = Linear/hosted: Backlog; file/plugin: Triage. Must name a state the tracker actually has — a Linear team only has "Triage" when its triage feature is enabled. |
755
798
  | `proposalState` | — | optional, nonempty string | State observation harvest files approved proposals into. Absent = the resolved `triageState`, which keeps the human grooming gate. Setting it to `readyState` waives that gate: harvested proposals dispatch without being read. Keep it distinct from `triageState` — see below. |
756
799
  | `dir` | file: `.autobuild/tickets`; plugin: — | optional nonempty string; forbidden by `linear`, allowed for plugins | Root holding file state directories, or an existing plugin configuration field. |
757
800
 
@@ -829,7 +872,7 @@ changing it requires a dispatcher restart.
829
872
  | `model` | — (required when `enabled = true`) | nonblank provider-qualified string, same vocabulary as `[roles]` | The turn runner's model, resolved through the deployment's gateway credential; a leading `vercel-ai-gateway/` prefix is accepted and stripped. |
830
873
  | `invocationBudgetSeconds` | `240` | positive integer, clamped to 300 | Wall-clock budget for one turn invocation. A larger value is clamped — not rejected — to the 300-second hosted function limit the runner executes under. |
831
874
  | `approvals` | `["builds.control:abort", "builds.control:discard", "builds.answer:revise-spec", "sandbox.publish", "tickets.move:ready"]` | array of `tool` or `tool:qualifier` strings | Registry tool calls that suspend a turn until the operator answers. Empty (`[]`) means none — autonomous operation. An entry naming an absent or unknown tool is **inert by design**, never an error. A qualified entry applies to one discriminator value only (`tickets.move:ready` approves moving a ticket to `ready`); for a tool without a discriminator, only the bare form matches. |
832
- | `wake` | inherits the default | array of event-type globs | The attention events a **new** session wakes for. Absent inherits the default attention set (the events `ab watch` filters on by default); an explicit `[]` means never wake — message-only. Each glob must match at least one build event type. Existing sessions never retroactively inherit; an explicit wake update always wins. |
875
+ | `wake` | inherits the default | array of event-type globs | The attention events a **new** session wakes for. Absent inherits the legacy hosted wake event set, independent of CLI watch defaults; an explicit `[]` means never wake — message-only. Each glob must match at least one build event type. Existing sessions never retroactively inherit; an explicit wake update always wins. |
833
876
  | `sandbox` | — | strict subtable | The sandbox behavior knobs; absence reads as the defaults below. |
834
877
 
835
878
  `[orchestrator.sandbox]` fields:
@@ -1049,7 +1092,7 @@ There is no home-directory fallback: a repository with no state tree starts
1049
1092
  empty.
1050
1093
 
1051
1094
  Every command uses the same precedence: explicit `--store <ref>` > nonempty
1052
- `AB_STORE` > `<main-repo>/.autobuild`. Relative local overrides are normalized
1095
+ `AB_STORE` > `[store] url` > `<main-repo>/.autobuild`. Relative local overrides are normalized
1053
1096
  against the main checkout. A local override relocates the whole state tree,
1054
1097
  including `worktrees/` and default file tickets; an explicit `[tickets].dir`
1055
1098
  remains repo-relative. An HTTP(S) URL still selects the remote store unchanged,
@@ -1057,6 +1100,46 @@ while worktrees and default file tickets remain under the repository-default
1057
1100
  `.autobuild/` directory. The dispatcher passes its normalized selection to
1058
1101
  every agent session as `AB_STORE`.
1059
1102
 
1103
+ ## Connect a project
1104
+
1105
+ From a checkout with an origin remote, run `ab connect https://YOUR_ORIGIN`.
1106
+ The CLI prints a verification URL and short code and attempts to open a browser.
1107
+ Sign in as an allowed owner, check the full repository identity and code, and
1108
+ approve or deny. Another device can open the printed URL, or visit `/connect`
1109
+ and enter the code. Approval requires the web-enabled service origin; a
1110
+ standalone machine-only listener cannot present browser consent. Requests
1111
+ expire after ten minutes. Denial and timeout leave local configuration unchanged.
1112
+
1113
+ On approval the service registers the project and issues a project credential.
1114
+ The CLI keeps it in `$XDG_CONFIG_HOME/autobuild/credentials` (or
1115
+ `~/.config/autobuild/credentials`), keyed by normalized service and origin,
1116
+ with directory mode 0700 and file mode 0600. Credentials never belong in the
1117
+ repository. It adds the non-secret `[store] url` to `autobuild.toml` when
1118
+ absent; review and commit that change yourself. An existing different URL
1119
+ must be changed explicitly before connecting. Reconnecting replaces the saved
1120
+ credential only after approval and revokes the previous credential.
1121
+
1122
+ All worktrees share the main checkout's configuration and the user's saved
1123
+ credential. A service manager running as that user uses the same HOME and
1124
+ XDG_CONFIG_HOME, with no AB_STORE or AB_TOKEN exports. A fresh clone with
1125
+ committed store configuration but no saved credential fails with `ab connect`
1126
+ guidance; it never silently creates local state. Run connect on each host/user.
1127
+
1128
+ `ab connect` without a URL reports service, project, and credential status:
1129
+ accepted, rejected, missing, or unreachable. `ab disconnect` revokes the saved
1130
+ credential remotely before removing it locally, keeping the project registered,
1131
+ its data, and store configuration. A network/server failure retains the local
1132
+ entry for retry; an already revoked credential can be removed. If saving a new
1133
+ connection fails, the CLI cancels/revokes it and retains the previous entry.
1134
+ If remote cleanup cannot be confirmed, check the printed credential id with
1135
+ the owner's administrative tools before retrying.
1136
+
1137
+ Store selection is `--store` > nonblank `AB_STORE` > `[store] url` > the
1138
+ main checkout's `.autobuild/`. `AB_TOKEN` overrides the exact service/project's
1139
+ saved credential for CI and other non-interactive hosts. A service override
1140
+ never borrows another service's credential. Build and harvest sessions keep
1141
+ their explicit scoped authority.
1142
+
1060
1143
  ## Source-agnostic ticket operations
1061
1144
 
1062
1145
  The sessionless `ab ticket` namespace constructs whichever TicketSource the
@@ -1111,7 +1194,7 @@ to a ticket that has already been claimed into a build does not stop that build.
1111
1194
  For dependent tickets, follow the
1112
1195
  [safe blocker-filing workflow](references/ticket-dependencies.md): determine the
1113
1196
  intended final destination, stage outside ready, verify all blocker relationships,
1114
- and move to ready last. Direct and hosted Linear create the issue and its blockers
1197
+ and move to ready last. Direct Linear creates the issue and its blockers
1115
1198
  in separate requests; one `create --blocked-by` call is not atomic publication.
1116
1199
  Harvest's creation reservations cover Harvest only, not interactive CLI creates.
1117
1200
  Dependency order and JSON id extraction are still required, but do not alone
@@ -1173,7 +1256,7 @@ judge against it.
1173
1256
  is the read-only binary retrieval form. Operators need no session identity: it
1174
1257
  works after a build is terminal, writes exact bytes (creating parent
1175
1258
  directories), verifies that the build belongs to this repository, and uses the
1176
- standard explicit `--store` > nonblank `AB_STORE` > repository-local selection.
1259
+ standard explicit `--store` > nonblank `AB_STORE` > `[store] url` > repository-local selection.
1177
1260
  Inside a phase, a complete ambient tuple permits only the ambient build; a
1178
1261
  foreign build or malformed/partial identity is rejected. Use the exact pinned
1179
1262
  `@rev` from a PR attachment command.
@@ -1507,7 +1590,7 @@ Every command requires the target to exist in this repository and be active
1507
1590
  `ab answer` additionally requires an open escalation. A stale, missing, done,
1508
1591
  aborted, or otherwise inapplicable target is an error and gets no new event. Attribution uses `USER`, then `USERNAME`, then the
1509
1592
  same stable fallback on both surfaces. All five commands run sessionless and
1510
- accept `--store <ref>` with the usual explicit flag > `AB_STORE` > repository
1593
+ accept `--store <ref>` with the usual explicit flag > `AB_STORE` > `[store] url` > repository
1511
1594
  local precedence. If nonblank `AB_SESSION` and matching `AB_BUILD` identify the
1512
1595
  caller's own phase build, the command refuses it; a phase cannot pause, resume,
1513
1596
  auto-merge, answer, or abort itself. `ab pause --all` and `ab resume --all`
@@ -1553,7 +1636,7 @@ repository-wide pause (`paused`, default `false`), and the claim-time auto-merge
1553
1636
  default (`defaultAutoMerge`, default `false`). Those defaults also describe a
1554
1637
  repository with no journal row; the read does not create one. Human output is
1555
1638
  the default. `--json` emits one bare object with those exact fields plus the
1556
- canonical `repo`, and `--store` follows the usual explicit flag > `AB_STORE` >
1639
+ canonical `repo`, and `--store` follows the usual explicit flag > `AB_STORE` > `[store] url` >
1557
1640
  repository-local precedence. The command uses the same reducer as the
1558
1641
  dispatcher and dashboard, writes no state, claims no ticket, attaches no runner,
1559
1642
  and starts no dispatcher work.
@@ -1636,19 +1719,51 @@ streams matching events as they land instead of answering once. With no slug it
1636
1719
  follows every nonterminal build in the repository — a build created while the
1637
1720
  watch runs joins it without a restart — and naming one or more slugs watches
1638
1721
  exactly those builds, ending the watch when all of them reach a terminal status.
1639
- `--repository` additionally follows the repository journal; without it,
1640
- repository events are never emitted.
1641
-
1642
- The default filter is the attention set — the events something must wake up
1643
- for. For builds that is exactly: `escalation.raised`, `phase.failed`,
1644
- `infrastructure.failed`, `runner.setup-failed`, `dispatch.failed`,
1645
- `publication.lost`, `finalize.completed`, `pr.conflicted`, `pr.merged`,
1646
- `pr.closed`, `build.completed`, `build.aborted`. For the repository journal:
1647
- `harvest.escalated`, `harvest.failed`, `dispatcher.tick-failed`,
1648
- `dispatcher.config-rejected`, `dispatcher.harvest-runner-failed`. A repeatable
1649
- `--event <glob>` (`escalation.*`, `pr.*`, `*` — `*` matches any run of
1650
- characters, `?` one) replaces that filter, and every glob must match at least
1651
- one known event type or the command fails before reading the store.
1722
+ With no slugs the repository journal is included; named watches add it with
1723
+ `--repository`.
1724
+
1725
+ The default watch reports actionable conditions only:
1726
+
1727
+ - Build `escalation.raised`, or `build.merge-decision`: a finalized open PR
1728
+ with auto-merge off or a current actionable merge-gate deferral. Armed consent
1729
+ awaiting application is silent. One merge decision is reported per build/PR.
1730
+ - Repository `harvest.escalated`, `harvest.recovery-exhausted`,
1731
+ `dispatcher.config-rejected`, `dispatcher.config-reloaded` with nonempty
1732
+ `restartRequired`, abnormal `dispatcher.run-stopped`, and `dispatcher.lost`.
1733
+ - `dispatcher.ticket-source-persistent`: degradation past its advertised
1734
+ reset time, once a tick started at or after reset has completed or failed
1735
+ with the episode still open; crossing reset alone is silent. Without a reset,
1736
+ the deadline is 15 minutes after onset. Repeated evidence never postpones
1737
+ the deadline; recovery ends the episode.
1738
+ - `dispatcher.tick-failure-persistent`: continuously failing completed ticks
1739
+ for five minutes in one run. A successful tick, new run, or matching stop
1740
+ ends the episode. Source grace and cooldown-driven failures do not start
1741
+ this clock; after grace, the first qualifying failure starts a new five-minute
1742
+ interval. Each persistence episode is reported once.
1743
+
1744
+ Repository conditions are included with no slugs or with `--repository`.
1745
+ Routine outcomes and self-healing failures are silent. Repeatable
1746
+ `--event <glob>` replaces the default and selects exactly matching catalog
1747
+ events (plus the existing `dispatcher.lost` observation); use explicit globs
1748
+ to opt into failures, finalize, merge, or completion events.
1749
+
1750
+ Derived records use `observation` instead of `event`, without inventing a
1751
+ durable sequence or actor. Merge observations carry `type`, `pr`, `reason`,
1752
+ and `observedAt`, plus the build slug and normal reduced state. Persistence
1753
+ observations carry source/cause/message/resetAt when advertised (or
1754
+ run/firstFailure/error), `episodeSeq`, and `observedAt`; repository records
1755
+ have `repo` and null build/state. Source observations also report
1756
+ `dependencyLimited`.
1757
+
1758
+ Every record carries a cursor, including timer-only records. Resuming suppresses
1759
+ delivered merge decisions and persistence episodes even when stream positions
1760
+ did not advance. Old v1 cursors remain valid: historical eligible merge
1761
+ decisions are treated as consumed; active overdue repository episodes may
1762
+ report because old watches had no persistence notification. Fresh attachment
1763
+ silently baselines historical direct events and merge decisions, but retains
1764
+ active repository timers. Reconcile existing conditions through status after
1765
+ arming the watch. Named watches still end when every named build is terminal;
1766
+ learn their outcomes from status reconciliation at exit.
1652
1767
 
1653
1768
  With `--json`, stdout is newline-delimited JSON — exactly one object per
1654
1769
  matching event, flushed as soon as it is written — with fields `build` (slug,
@@ -1745,7 +1860,7 @@ vocabulary is closed: `blocked`, `paused`, `running`, `done`, `aborted`,
1745
1860
  `terminal` (done or aborted), `pr=open`, `pr=merged`, `pr=closed`,
1746
1861
  `pr=conflicted`, `phase=<phase>` (a core phase — `plan`, `plan-review`,
1747
1862
  `implement`, `code-review`, `finalize`, `reconcile` — or `verify:<step>`), and
1748
- `attention` (any event in the `ab watch` attention set). Repeatable `--event`
1863
+ `attention` (an open escalation or a build waiting for a merge decision). Repeatable `--event`
1749
1864
  globs follow the `ab watch` rules. Multiple values are alternatives: the first
1750
1865
  one satisfied ends the wait — all `--for` values in supply order, then all
1751
1866
  globs in supply order — and the record names what matched.
@@ -1756,8 +1871,11 @@ means an open escalation exactly as everywhere else. In the manner of `kubectl
1756
1871
  wait`, a condition that already holds when the command starts returns
1757
1872
  immediately with that build's latest event (`--since` does not suppress this);
1758
1873
  otherwise the first later event whose reduction produces it ends the wait.
1759
- `attention` and `--event` conditions fire only on events appended after the
1760
- command started, or on a cursor-resumed backlog after `--since`.
1874
+ `attention` is also a current-state condition: an already open escalation or
1875
+ merge decision returns immediately, including with `--since`. Merge matches
1876
+ use the same observation as watch. During replay, only new escalation events
1877
+ or undelivered eligible merge decisions satisfy attention. `--event` conditions
1878
+ fire only on new events or a cursor-resumed backlog.
1761
1879
 
1762
1880
  The record is the `ab watch` record plus a `condition` field naming the
1763
1881
  satisfied `--for` value or `--event` glob. Unlike `ab watch`, there is no