@defrex/autobuild 0.8.0 → 0.10.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 (182) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +323 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +166 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +175 -28
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +204 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +14 -0
  13. package/package.json +45 -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/facts.ts +215 -0
  19. package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
  20. package/packages/core/src/cli/dashboard/model.ts +141 -153
  21. package/packages/core/src/cli/dashboard/poll.ts +76 -12
  22. package/packages/core/src/cli/dashboard/render.ts +34 -5
  23. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  24. package/packages/core/src/cli/dispatch-process.ts +1 -13
  25. package/packages/core/src/cli/dispatch.ts +392 -75
  26. package/packages/core/src/cli/harvest.ts +122 -8
  27. package/packages/core/src/cli/help.ts +62 -9
  28. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  29. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  30. package/packages/core/src/cli/init-validation.ts +266 -222
  31. package/packages/core/src/cli/installation.ts +50 -8
  32. package/packages/core/src/cli/main.ts +70 -11
  33. package/packages/core/src/cli/mcp.ts +23 -1
  34. package/packages/core/src/cli/plugin.ts +7 -4
  35. package/packages/core/src/cli/remote-poll.ts +277 -0
  36. package/packages/core/src/cli/repo-state.ts +22 -0
  37. package/packages/core/src/cli/repository-status.ts +39 -1
  38. package/packages/core/src/cli/self-update.ts +77 -2
  39. package/packages/core/src/cli/status.ts +150 -28
  40. package/packages/core/src/cli/terminals.ts +2 -1
  41. package/packages/core/src/cli/ticket-assets.ts +247 -0
  42. package/packages/core/src/cli/ticket.ts +453 -8
  43. package/packages/core/src/cli/wait.ts +157 -41
  44. package/packages/core/src/cli/watch.ts +138 -168
  45. package/packages/core/src/config/live.ts +23 -0
  46. package/packages/core/src/config/pipeline-source.ts +388 -0
  47. package/packages/core/src/config/schema.ts +251 -19
  48. package/packages/core/src/events/catalog.ts +1 -0
  49. package/packages/core/src/events/globs.ts +47 -0
  50. package/packages/core/src/events/payloads.ts +52 -2
  51. package/packages/core/src/events/repository.ts +52 -1
  52. package/packages/core/src/events/sessions.ts +69 -10
  53. package/packages/core/src/events/workspace-remote.ts +12 -0
  54. package/packages/core/src/integration/harness.ts +115 -2
  55. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
  56. package/packages/core/src/kernel/auto-merge-default.ts +157 -0
  57. package/packages/core/src/kernel/auto-merge.ts +99 -22
  58. package/packages/core/src/kernel/dispatch-settings.ts +42 -17
  59. package/packages/core/src/kernel/dispatch-status.ts +23 -0
  60. package/packages/core/src/kernel/engine.ts +9 -438
  61. package/packages/core/src/kernel/generators/build-log.ts +259 -0
  62. package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
  63. package/packages/core/src/kernel/generators/session-log.ts +72 -0
  64. package/packages/core/src/kernel/harvest.ts +62 -18
  65. package/packages/core/src/kernel/incremental-contract.ts +75 -0
  66. package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
  67. package/packages/core/src/kernel/incremental.ts +53 -0
  68. package/packages/core/src/kernel/log-index.ts +620 -0
  69. package/packages/core/src/kernel/phases.ts +16 -2
  70. package/packages/core/src/kernel/pr-attachments.ts +164 -46
  71. package/packages/core/src/kernel/reducer-registry.ts +164 -0
  72. package/packages/core/src/kernel/reducer.ts +254 -73
  73. package/packages/core/src/operator/annotations.ts +6 -0
  74. package/packages/core/src/operator/control.ts +19 -5
  75. package/packages/core/src/operator/index.ts +71 -6
  76. package/packages/core/src/operator/query.ts +253 -24
  77. package/packages/core/src/operator/read-view.ts +98 -0
  78. package/packages/core/src/operator/registry.ts +55 -10
  79. package/packages/core/src/operator/requests.ts +9 -6
  80. package/packages/core/src/operator/sandbox-host.ts +140 -0
  81. package/packages/core/src/operator/sandbox.ts +247 -14
  82. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  83. package/packages/core/src/orchestrator/approvals.ts +115 -0
  84. package/packages/core/src/orchestrator/conversation.ts +167 -0
  85. package/packages/core/src/orchestrator/model.ts +86 -0
  86. package/packages/core/src/orchestrator/prompt.ts +61 -0
  87. package/packages/core/src/orchestrator/tools.ts +117 -0
  88. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  89. package/packages/core/src/plugin-sdk/index.ts +51 -1
  90. package/packages/core/src/plugins/load.ts +135 -7
  91. package/packages/core/src/plugins/manifest.ts +183 -10
  92. package/packages/core/src/plugins/registry.ts +73 -4
  93. package/packages/core/src/ports/forge/create.ts +1 -1
  94. package/packages/core/src/ports/forge/fake.ts +31 -0
  95. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  96. package/packages/core/src/ports/forge/github.ts +13 -2
  97. package/packages/core/src/ports/forge/local-git.ts +31 -1
  98. package/packages/core/src/ports/runner/claude.ts +2 -2
  99. package/packages/core/src/ports/runner/codex.ts +6 -2
  100. package/packages/core/src/ports/runner/fake.ts +7 -1
  101. package/packages/core/src/ports/runner/pi.ts +8 -6
  102. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  103. package/packages/core/src/ports/tickets/fake.ts +5 -1
  104. package/packages/core/src/ports/tickets/file.ts +169 -48
  105. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  106. package/packages/core/src/ports/tickets/linear.ts +33 -1
  107. package/packages/core/src/ports/tickets/operations.ts +1 -1
  108. package/packages/core/src/ports/tickets/remote.ts +8 -1
  109. package/packages/core/src/ports/types.ts +49 -3
  110. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  111. package/packages/core/src/ports/workspace/create.ts +74 -39
  112. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  113. package/packages/core/src/ports/workspace/fake.ts +169 -4
  114. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  115. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  116. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  117. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  118. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  119. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  120. package/packages/core/src/processes/build-child.ts +17 -2
  121. package/packages/core/src/processes/build-execution-state.ts +68 -15
  122. package/packages/core/src/processes/build-runner.ts +238 -93
  123. package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
  124. package/packages/core/src/processes/dispatcher.ts +501 -201
  125. package/packages/core/src/processes/execution-settlement.ts +72 -39
  126. package/packages/core/src/processes/harvest-child.ts +5 -0
  127. package/packages/core/src/processes/harvest-execution-state.ts +41 -29
  128. package/packages/core/src/processes/harvest-runner.ts +66 -19
  129. package/packages/core/src/processes/harvest.ts +196 -24
  130. package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
  131. package/packages/core/src/processes/orchestrator-tick.ts +360 -0
  132. package/packages/core/src/processes/publication-state.ts +164 -52
  133. package/packages/core/src/processes/repo-view.ts +1436 -0
  134. package/packages/core/src/processes/sandbox-state.ts +36 -2
  135. package/packages/core/src/processes/tick-state.ts +33 -0
  136. package/packages/core/src/store/adapter.ts +41 -0
  137. package/packages/core/src/store/build-scope.ts +90 -0
  138. package/packages/core/src/store/contract.ts +1205 -0
  139. package/packages/core/src/store/digest.ts +102 -0
  140. package/packages/core/src/store/local/schema.ts +46 -2
  141. package/packages/core/src/store/local/store.ts +445 -4
  142. package/packages/core/src/store/memory.ts +246 -0
  143. package/packages/core/src/store/new-build.ts +17 -0
  144. package/packages/core/src/store/phase-session.ts +94 -0
  145. package/packages/core/src/store/remote/client.ts +310 -2
  146. package/packages/core/src/store/remote/index.ts +14 -2
  147. package/packages/core/src/store/remote/protocol.ts +115 -12
  148. package/packages/core/src/store/remote/version.ts +6 -2
  149. package/packages/core/src/store/repo-state-events.ts +205 -0
  150. package/packages/core/src/store/retention.ts +7 -0
  151. package/packages/core/src/store/session-handle.ts +94 -0
  152. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +79 -19
  153. package/packages/core/src/store/snapshots.ts +88 -0
  154. package/packages/core/src/store/streams/session-writer.ts +42 -2
  155. package/packages/core/src/store/streams/wait.ts +4 -5
  156. package/packages/core/src/store/ticket-assets.ts +458 -0
  157. package/packages/core/src/store/types.ts +160 -1
  158. package/packages/core/src/testing/index.ts +59 -0
  159. package/skills/code-review/SKILL.md +5 -1
  160. package/skills/guide/SKILL.md +152 -40
  161. package/skills/guide/references/operator-api.md +2 -2
  162. package/skills/guide/references/plugin-authoring.md +17 -0
  163. package/skills/guide/references/remote-store-protocol.md +204 -16
  164. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  165. package/skills/guide/references/setup.md +12 -9
  166. package/skills/implement/SKILL.md +3 -0
  167. package/skills/operate/SKILL.md +126 -0
  168. package/skills/plan/SKILL.md +4 -0
  169. package/skills/plan-review/SKILL.md +5 -1
  170. package/skills/spec/SKILL.md +46 -3
  171. package/skills/tickets/SKILL.md +57 -5
  172. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  173. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  174. package/packages/core/src/cli/testkit.ts +0 -194
  175. package/packages/core/src/markdown.ts +0 -67
  176. package/packages/core/src/operator/client.ts +0 -311
  177. package/packages/core/src/operator/server.ts +0 -616
  178. package/packages/core/src/operator/sessions.ts +0 -223
  179. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  180. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  181. package/packages/core/src/store/remote/server.ts +0 -923
  182. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -19,10 +19,21 @@ import {
19
19
  ticketListCriteria,
20
20
  updateTicket,
21
21
  } from '../ports/tickets/operations'
22
- import type { Ticket, TicketSource, TicketUpdate } from '../ports/types'
23
- import type { Exec } from '../ports/workspace/git-worktree'
22
+ import type { Ticket, TicketSource, TicketStateInfo, TicketUpdate } from '../ports/types'
23
+ import { reduceBuild } from '../kernel/reducer'
24
+ import type { BuildStore } from '../store/types'
25
+ import { spawnExec, type Exec } from '../ports/workspace/git-worktree'
26
+ import type { TicketAssetSummary } from '../store/ticket-assets'
24
27
  import { parseArgs, stringFlag } from './args'
25
28
  import { resolveMainRepo, resolveRepoStatePaths } from './repo-state'
29
+ import { withSessionlessStore, type StoreOpener } from './store-opening'
30
+ import {
31
+ buildAssetInput,
32
+ formatAssetSize,
33
+ ticketAssetNote,
34
+ walkAssetPath,
35
+ writeAssetTo,
36
+ } from './ticket-assets'
26
37
 
27
38
  export type TicketSourceFactory = (
28
39
  config: TicketsConfig,
@@ -41,6 +52,11 @@ export interface TicketCommandOpts {
41
52
  stdout: (line: string) => void
42
53
  /** Injectable for tests; defaults to the real adapter factory. */
43
54
  sourceFactory?: TicketSourceFactory
55
+ /** Store seam for the ticket-asset commands and `show`'s asset list. */
56
+ openStore?: StoreOpener
57
+ /** Explicit `--store` for the store-backed commands; precedence is applied
58
+ * by repo-state.ts. */
59
+ storeRef?: string
44
60
  }
45
61
 
46
62
  export interface TicketCreateOpts extends TicketCommandOpts {
@@ -88,6 +104,35 @@ export interface TicketShowOpts extends TicketCommandOpts {
88
104
  json?: boolean
89
105
  }
90
106
 
107
+ export interface TicketAttachOpts extends TicketCommandOpts {
108
+ id: string
109
+ kind: string
110
+ path: string
111
+ /** Defaults to the path's basename. */
112
+ name?: string
113
+ json?: boolean
114
+ /** Warning sink (active-build notice); stdout stays the command's result. */
115
+ stderr?: (line: string) => void
116
+ }
117
+
118
+ export interface TicketAssetGetOpts extends TicketCommandOpts {
119
+ id: string
120
+ kind: string
121
+ name: string
122
+ dest: string
123
+ rev?: number
124
+ json?: boolean
125
+ }
126
+
127
+ export interface TicketAssetRmOpts extends TicketCommandOpts {
128
+ id: string
129
+ kind: string
130
+ name: string
131
+ json?: boolean
132
+ /** Warning sink (active-build notice); stdout stays the command's result. */
133
+ stderr?: (line: string) => void
134
+ }
135
+
91
136
  export interface TicketMoveOpts extends TicketCommandOpts {
92
137
  id: string
93
138
  /** Source-local workflow state; validation belongs to the adapter. */
@@ -95,7 +140,30 @@ export interface TicketMoveOpts extends TicketCommandOpts {
95
140
  json?: boolean
96
141
  }
97
142
 
98
- type TicketCommandName = 'create' | 'update' | 'block' | 'unblock' | 'list' | 'show' | 'move'
143
+ export interface TicketStatesOpts extends TicketCommandOpts {
144
+ json?: boolean
145
+ }
146
+
147
+ export interface TicketStateCreateOpts extends TicketCommandOpts {
148
+ name: string
149
+ /** One-line purpose; absent leaves an existing purpose untouched. */
150
+ about?: string
151
+ json?: boolean
152
+ }
153
+
154
+ type TicketCommandName =
155
+ | 'states'
156
+ | 'state create'
157
+ | 'create'
158
+ | 'update'
159
+ | 'block'
160
+ | 'unblock'
161
+ | 'list'
162
+ | 'show'
163
+ | 'move'
164
+ | 'attach'
165
+ | 'asset get'
166
+ | 'asset rm'
99
167
 
100
168
  export interface ResolvedTicketCommand {
101
169
  config: Config
@@ -173,7 +241,11 @@ function ticketSummary(ticket: Ticket): string {
173
241
  return fields.join(' ')
174
242
  }
175
243
 
176
- function ticketDetail(ticket: Ticket): string[] {
244
+ function assetLine(asset: TicketAssetSummary): string {
245
+ return ` ${asset.kind}/${asset.name} rev ${asset.revision} ${formatAssetSize(asset.size)}`
246
+ }
247
+
248
+ function ticketDetail(ticket: Ticket, assets: TicketAssetSummary[] = []): string[] {
177
249
  const lines = [
178
250
  `ticket ${ticket.ref.source}:${ticket.ref.id}`,
179
251
  ` title: ${ticket.title}`,
@@ -184,6 +256,7 @@ function ticketDetail(ticket: Ticket): string[] {
184
256
  lines.push(` blocked by: ${ticket.blockedBy.join(', ')}`)
185
257
  }
186
258
  if (ticket.ref.url !== undefined) lines.push(` url: ${ticket.ref.url}`)
259
+ if (assets.length > 0) lines.push(' assets:', ...assets.map(assetLine))
187
260
  lines.push(' body:')
188
261
  return lines
189
262
  }
@@ -291,16 +364,169 @@ export async function abTicketList(opts: TicketListOpts): Promise<void> {
291
364
  export async function abTicketShow(opts: TicketShowOpts): Promise<void> {
292
365
  const { source } = await resolveTicketCommand(opts, 'show')
293
366
  const ticket = await requireTicket(source, opts.id)
367
+ // An asset-listing failure fails the command rather than hiding the assets.
368
+ const assets = await withTicketStore(opts, ({ store, repo }) =>
369
+ store.listTicketAssets(repo, opts.id),
370
+ )
294
371
  if (opts.json === true) {
295
- opts.stdout(JSON.stringify(ticket, null, 2))
372
+ opts.stdout(
373
+ JSON.stringify(
374
+ {
375
+ ...ticket,
376
+ assets: assets.map(({ kind, name, revision, size, layout, fileCount }) => ({
377
+ kind,
378
+ name,
379
+ revision,
380
+ size,
381
+ layout,
382
+ fileCount,
383
+ })),
384
+ },
385
+ null,
386
+ 2,
387
+ ),
388
+ )
296
389
  return
297
390
  }
298
- for (const line of ticketDetail(ticket)) opts.stdout(line)
391
+ for (const line of ticketDetail(ticket, assets)) opts.stdout(line)
299
392
  // Keep the adapter-provided body untouched. It may itself be multiline and
300
393
  // may intentionally end (or not end) with a newline.
301
394
  opts.stdout(ticket.body)
302
395
  }
303
396
 
397
+ function withTicketStore<T>(
398
+ opts: TicketCommandOpts,
399
+ use: (context: { store: BuildStore; repo: string }) => Promise<T>,
400
+ ): Promise<T> {
401
+ return withSessionlessStore(
402
+ {
403
+ targetRepo: opts.targetRepo,
404
+ env: opts.env,
405
+ exec: opts.exec ?? spawnExec,
406
+ ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
407
+ ...(opts.openStore !== undefined ? { openStore: opts.openStore } : {}),
408
+ },
409
+ use,
410
+ )
411
+ }
412
+
413
+ /** Slugs of this repo's non-terminal builds for the ticket. A build freezes its
414
+ * ticket assets at the claim (SPEC §6.3), so a change to them does not reach
415
+ * it. The notice is advisory: a failed lookup yields none and never blocks. */
416
+ async function activeBuildsFor(store: BuildStore, repo: string, id: string): Promise<string[]> {
417
+ try {
418
+ const active: string[] = []
419
+ for (const record of await store.listBuilds()) {
420
+ if (record.repo !== repo || record.ticket?.id !== id) continue
421
+ const { status } = reduceBuild(await store.getEvents(record.slug))
422
+ if (status !== 'done' && status !== 'aborted') active.push(record.slug)
423
+ }
424
+ return active
425
+ } catch {
426
+ return []
427
+ }
428
+ }
429
+
430
+ function warnActiveBuilds(
431
+ opts: { stderr?: (line: string) => void; id: string },
432
+ slugs: string[],
433
+ ): void {
434
+ for (const slug of slugs) {
435
+ opts.stderr?.(
436
+ `warning: build ${slug} is active for ticket ${opts.id}; it keeps the assets it froze at ` +
437
+ `the claim unless its spec is revised from the ticket (ab answer ${slug} --revise-spec-from-ticket)`,
438
+ )
439
+ }
440
+ }
441
+
442
+ /** `ab ticket attach <id> <kind> <path>` — store a file or folder as a ticket
443
+ * asset, then leave a note on the ticket in its source. Order matters: the
444
+ * source is asked first, so an unknown id stores nothing; the note follows the
445
+ * store write, so it never points at nothing. */
446
+ export async function abTicketAttach(opts: TicketAttachOpts): Promise<void> {
447
+ const { source } = await resolveTicketCommand(opts, 'attach')
448
+ await requireTicket(source, opts.id)
449
+ const walk = await walkAssetPath(opts.path)
450
+ const name = opts.name ?? walk.basename
451
+ const stored = await withTicketStore(opts, async ({ store, repo }) => {
452
+ const limits = await store.ticketAssetLimits(repo)
453
+ const input = await buildAssetInput(walk, { kind: opts.kind, name }, limits)
454
+ const live = await store.listTicketAssets(repo, opts.id)
455
+ const replacing = live.some((asset) => asset.kind === opts.kind && asset.name === name)
456
+ const meta = await store.putTicketAsset(repo, opts.id, input)
457
+ return { meta, replacing, active: await activeBuildsFor(store, repo, opts.id) }
458
+ })
459
+ const { meta } = stored
460
+ warnActiveBuilds(opts, stored.active)
461
+ try {
462
+ await source.comment(opts.id, ticketAssetNote(stored.replacing ? 'replaced' : 'attached', meta))
463
+ } catch (error) {
464
+ throw new Error(
465
+ `ticket asset ${meta.kind}/${meta.name} was stored as revision ${meta.revision}, but the note on ` +
466
+ `${source.name}:${opts.id} failed: ${error instanceof Error ? error.message : String(error)} ` +
467
+ '(re-running attach stores another revision)',
468
+ )
469
+ }
470
+ if (opts.json === true) {
471
+ opts.stdout(JSON.stringify(meta, null, 2))
472
+ return
473
+ }
474
+ opts.stdout(
475
+ `ticket asset attached: ${source.name}:${opts.id} ${meta.kind}/${meta.name} revision ${meta.revision} ` +
476
+ `(${formatAssetSize(meta.size)})`,
477
+ )
478
+ }
479
+
480
+ /** `ab ticket asset get <id> <kind> <name> <dest>` — exact bytes at the
481
+ * latest or a named revision. */
482
+ export async function abTicketAssetGet(opts: TicketAssetGetOpts): Promise<void> {
483
+ const asset = await withTicketStore(opts, ({ store, repo }) =>
484
+ store.getTicketAsset(repo, opts.id, opts.kind, opts.name, opts.rev),
485
+ )
486
+ if (asset === null) {
487
+ const revision = opts.rev === undefined ? '' : ` revision ${opts.rev}`
488
+ throw new Error(
489
+ `ticket ${opts.id} has no asset ${opts.kind}/${opts.name}${revision}` +
490
+ (opts.rev === undefined ? ' (it may have been removed — try --rev <n>)' : ''),
491
+ )
492
+ }
493
+ const written = await writeAssetTo(asset, opts.dest)
494
+ if (opts.json === true) {
495
+ opts.stdout(JSON.stringify({ ...asset.meta, path: written }, null, 2))
496
+ return
497
+ }
498
+ opts.stdout(
499
+ `ticket asset downloaded: ${opts.kind}/${opts.name} revision ${asset.meta.revision} → ${written}`,
500
+ )
501
+ }
502
+
503
+ /** `ab ticket asset rm <id> <kind> <name>` — remove from the current assets
504
+ * (earlier revisions stay retrievable) and note the removal on the ticket. */
505
+ export async function abTicketAssetRm(opts: TicketAssetRmOpts): Promise<void> {
506
+ const { source } = await resolveTicketCommand(opts, 'asset rm')
507
+ const { removed, active } = await withTicketStore(opts, async ({ store, repo }) => ({
508
+ removed: await store.removeTicketAsset(repo, opts.id, opts.kind, opts.name),
509
+ active: await activeBuildsFor(store, repo, opts.id),
510
+ }))
511
+ if (removed === null) {
512
+ throw new Error(`ticket ${opts.id} has no current asset ${opts.kind}/${opts.name} to remove`)
513
+ }
514
+ warnActiveBuilds(opts, active)
515
+ try {
516
+ await source.comment(opts.id, ticketAssetNote('removed', removed))
517
+ } catch (error) {
518
+ throw new Error(
519
+ `ticket asset ${opts.kind}/${opts.name} was removed, but the note on ${source.name}:${opts.id} ` +
520
+ `failed: ${error instanceof Error ? error.message : String(error)}`,
521
+ )
522
+ }
523
+ if (opts.json === true) {
524
+ opts.stdout(JSON.stringify(removed, null, 2))
525
+ return
526
+ }
527
+ opts.stdout(`ticket asset removed: ${source.name}:${opts.id} ${opts.kind}/${opts.name}`)
528
+ }
529
+
304
530
  /** `ab ticket move <id> <state>` — adapter-owned state validation and move. */
305
531
  export async function abTicketMove(opts: TicketMoveOpts): Promise<void> {
306
532
  const { source } = await resolveTicketCommand(opts, 'move')
@@ -312,6 +538,108 @@ export async function abTicketMove(opts: TicketMoveOpts): Promise<void> {
312
538
  opts.stdout(`ticket moved: ${ticketSummary(moved)}`)
313
539
  }
314
540
 
541
+ export type TicketStateRole = 'create' | 'ready' | 'claimed' | 'triage' | 'proposal' | 'done'
542
+
543
+ export interface TicketStateRow extends TicketStateInfo {
544
+ roles: TicketStateRole[]
545
+ }
546
+
547
+ /**
548
+ * The lifecycle roles each state plays under `config`, for a source whose
549
+ * states are directories (names match case-insensitively, as `ab ticket move`
550
+ * does). The file adapter always claims into `doing`, so that is shown as
551
+ * claimed whatever `[tickets].claimedState` says. Roles naming no existing
552
+ * state are omitted.
553
+ */
554
+ export function stateRoles(
555
+ tickets: TicketsConfig,
556
+ names: string[],
557
+ ): Map<string, TicketStateRole[]> {
558
+ const triage = tickets.triageState ?? 'triage'
559
+ const named: Array<[TicketStateRole, string]> = [
560
+ ['create', tickets.createState ?? 'triage'],
561
+ ['ready', tickets.readyState],
562
+ ['claimed', 'doing'],
563
+ ['triage', triage],
564
+ ['proposal', tickets.proposalState ?? triage],
565
+ ['done', 'done'],
566
+ ]
567
+ const roles = new Map<string, TicketStateRole[]>(names.map((name) => [name, []]))
568
+ for (const [role, target] of named) {
569
+ const match =
570
+ names.find((name) => name === target) ??
571
+ names.find((name) => name.toLowerCase() === target.toLowerCase())
572
+ if (match !== undefined) roles.get(match)?.push(role)
573
+ }
574
+ return roles
575
+ }
576
+
577
+ function stateRow(info: TicketStateInfo, roles: Map<string, TicketStateRole[]>): TicketStateRow {
578
+ return {
579
+ name: info.name,
580
+ roles: roles.get(info.name) ?? [],
581
+ tickets: info.tickets,
582
+ ...(info.about !== undefined ? { about: info.about } : {}),
583
+ }
584
+ }
585
+
586
+ function unsupported(source: TicketSource, operation: string): Error {
587
+ return new Error(`ticket source "${source.name}" does not support ${operation}`)
588
+ }
589
+
590
+ /** `ab ticket states` — every state of the source, its roles, counts, purpose. */
591
+ export async function abTicketStates(opts: TicketStatesOpts): Promise<void> {
592
+ const { config, source } = await resolveTicketCommand(opts, 'states')
593
+ if (typeof source.listStates !== 'function') {
594
+ throw unsupported(source, 'state discovery (ab ticket states)')
595
+ }
596
+ const infos = await source.listStates()
597
+ const roles = stateRoles(
598
+ config.tickets,
599
+ infos.map((info) => info.name),
600
+ )
601
+ const rows = infos.map((info) => stateRow(info, roles))
602
+ if (opts.json === true) {
603
+ opts.stdout(JSON.stringify(rows, null, 2))
604
+ return
605
+ }
606
+ const nameWidth = Math.max(...rows.map((row) => row.name.length))
607
+ const roleText = rows.map((row) => (row.roles.length > 0 ? row.roles.join(',') : 'none'))
608
+ const roleWidth = Math.max(...roleText.map((text) => text.length))
609
+ rows.forEach((row, index) => {
610
+ const count = `${row.tickets} ${row.tickets === 1 ? 'ticket' : 'tickets'}`
611
+ const line = `${row.name.padEnd(nameWidth)} ${(roleText[index] as string).padEnd(roleWidth)} ${count}`
612
+ opts.stdout(row.about === undefined ? line : `${line} ${row.about}`)
613
+ })
614
+ }
615
+
616
+ /** `ab ticket state create <name> [--about <text>]` — idempotent. */
617
+ export async function abTicketStateCreate(opts: TicketStateCreateOpts): Promise<void> {
618
+ const { config, source } = await resolveTicketCommand(opts, 'state create')
619
+ if (typeof source.addState !== 'function') {
620
+ throw unsupported(source, 'state creation (ab ticket state create)')
621
+ }
622
+ const aboutBefore =
623
+ opts.about === undefined
624
+ ? undefined
625
+ : (await source.listStates?.())?.find((info) => info.name === opts.name)?.about
626
+ const { state, created } = await source.addState(opts.name, {
627
+ ...(opts.about !== undefined ? { about: opts.about } : {}),
628
+ })
629
+ const names = (await source.listStates?.())?.map((info) => info.name) ?? [state.name]
630
+ const row = stateRow(state, stateRoles(config.tickets, names))
631
+ if (opts.json === true) {
632
+ opts.stdout(JSON.stringify({ ...row, created }, null, 2))
633
+ return
634
+ }
635
+ const purposeChanged = !created && opts.about !== undefined && state.about !== aboutBefore
636
+ opts.stdout(
637
+ created
638
+ ? `ticket state created: ${state.name}`
639
+ : `ticket state already exists: ${state.name}${purposeChanged ? ' (purpose updated)' : ''}`,
640
+ )
641
+ }
642
+
315
643
  const CREATE_USAGE =
316
644
  'usage: ab ticket create <title> --body <file> [--state <state>] [--labels a,b] [--blocked-by id,id] [--json] (§8.8)'
317
645
  const UPDATE_USAGE =
@@ -319,8 +647,16 @@ const UPDATE_USAGE =
319
647
  const BLOCK_USAGE = 'usage: ab ticket block <id> <blocker-id[,blocker-id...]> [--json] (§8.8)'
320
648
  const UNBLOCK_USAGE = 'usage: ab ticket unblock <id> <blocker-id[,blocker-id...]> [--json] (§8.8)'
321
649
  const LIST_USAGE = 'usage: ab ticket list [--state <state>] [--labels a,b] [--json] (§8.8)'
322
- const SHOW_USAGE = 'usage: ab ticket show <id> [--json] (§8.8)'
650
+ const SHOW_USAGE = 'usage: ab ticket show <id> [--store <ref>] [--json] (§8.8)'
651
+ const ATTACH_USAGE =
652
+ 'usage: ab ticket attach <id> <kind> <path> [--name <name>] [--store <ref>] [--json] (§8.8)'
653
+ const ASSET_GET_USAGE =
654
+ 'usage: ab ticket asset get <id> <kind> <name> <dest> [--rev <n>] [--store <ref>] [--json] (§8.8)'
655
+ const ASSET_RM_USAGE =
656
+ 'usage: ab ticket asset rm <id> <kind> <name> [--store <ref>] [--json] (§8.8)'
323
657
  const MOVE_USAGE = 'usage: ab ticket move <id> <state> [--json] (§8.8)'
658
+ const STATES_USAGE = 'usage: ab ticket states [--json] (§8.8)'
659
+ const STATE_CREATE_USAGE = 'usage: ab ticket state create <name> [--about <text>] [--json] (§8.8)'
324
660
  export const TICKET_USAGE = [
325
661
  CREATE_USAGE,
326
662
  UPDATE_USAGE,
@@ -329,6 +665,11 @@ export const TICKET_USAGE = [
329
665
  LIST_USAGE,
330
666
  SHOW_USAGE,
331
667
  MOVE_USAGE,
668
+ STATES_USAGE,
669
+ STATE_CREATE_USAGE,
670
+ ATTACH_USAGE,
671
+ ASSET_GET_USAGE,
672
+ ASSET_RM_USAGE,
332
673
  ].join('\n')
333
674
 
334
675
  function commaList(value: string): string[] {
@@ -444,14 +785,118 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
444
785
  }
445
786
 
446
787
  case 'show': {
447
- const parsed = parseArgs(args, { json: 'boolean' }, TICKET_USAGE)
788
+ const parsed = parseArgs(args, { json: 'boolean', store: 'value' }, TICKET_USAGE)
448
789
  const [id, ...extra] = parsed.positionals
449
790
  if (id === undefined || id.trim() === '' || extra.length > 0) {
450
791
  throw new Error(TICKET_USAGE)
451
792
  }
793
+ const store = stringFlag(parsed, 'store')
452
794
  await abTicketShow({
453
795
  ...opts,
796
+ ...(store !== undefined ? { storeRef: store } : {}),
797
+ id,
798
+ json: parsed.flags.has('json'),
799
+ })
800
+ return
801
+ }
802
+
803
+ case 'attach': {
804
+ const parsed = parseArgs(
805
+ args,
806
+ { name: 'value', store: 'value', json: 'boolean' },
807
+ TICKET_USAGE,
808
+ )
809
+ const [id, kind, path, ...extra] = parsed.positionals
810
+ if (!id?.trim() || !kind?.trim() || !path?.trim() || extra.length > 0) {
811
+ throw new Error(TICKET_USAGE)
812
+ }
813
+ const name = stringFlag(parsed, 'name')
814
+ if (name !== undefined && name.trim() === '') throw new Error(TICKET_USAGE)
815
+ const store = stringFlag(parsed, 'store')
816
+ await abTicketAttach({
817
+ ...opts,
818
+ ...(store !== undefined ? { storeRef: store } : {}),
454
819
  id,
820
+ kind,
821
+ path,
822
+ ...(name !== undefined ? { name } : {}),
823
+ json: parsed.flags.has('json'),
824
+ })
825
+ return
826
+ }
827
+
828
+ case 'asset': {
829
+ const [action, ...assetArgs] = args
830
+ if (action === 'get') {
831
+ const parsed = parseArgs(
832
+ assetArgs,
833
+ { rev: 'value', store: 'value', json: 'boolean' },
834
+ TICKET_USAGE,
835
+ )
836
+ const [id, kind, name, dest, ...extra] = parsed.positionals
837
+ if (!id?.trim() || !kind?.trim() || !name?.trim() || !dest?.trim() || extra.length > 0) {
838
+ throw new Error(TICKET_USAGE)
839
+ }
840
+ const rawRev = stringFlag(parsed, 'rev')
841
+ if (rawRev !== undefined && !/^\d+$/.test(rawRev)) {
842
+ throw new Error(
843
+ `--rev must be a nonnegative integer, got "${rawRev}" — ${ASSET_GET_USAGE}`,
844
+ )
845
+ }
846
+ const store = stringFlag(parsed, 'store')
847
+ await abTicketAssetGet({
848
+ ...opts,
849
+ ...(store !== undefined ? { storeRef: store } : {}),
850
+ id,
851
+ kind,
852
+ name,
853
+ dest,
854
+ ...(rawRev !== undefined ? { rev: Number(rawRev) } : {}),
855
+ json: parsed.flags.has('json'),
856
+ })
857
+ return
858
+ }
859
+ if (action === 'rm') {
860
+ const parsed = parseArgs(assetArgs, { store: 'value', json: 'boolean' }, TICKET_USAGE)
861
+ const [id, kind, name, ...extra] = parsed.positionals
862
+ if (!id?.trim() || !kind?.trim() || !name?.trim() || extra.length > 0) {
863
+ throw new Error(TICKET_USAGE)
864
+ }
865
+ const store = stringFlag(parsed, 'store')
866
+ await abTicketAssetRm({
867
+ ...opts,
868
+ ...(store !== undefined ? { storeRef: store } : {}),
869
+ id,
870
+ kind,
871
+ name,
872
+ json: parsed.flags.has('json'),
873
+ })
874
+ return
875
+ }
876
+ throw new Error(TICKET_USAGE)
877
+ }
878
+
879
+ case 'states': {
880
+ const parsed = parseArgs(args, { json: 'boolean' }, TICKET_USAGE)
881
+ if (parsed.positionals.length !== 0) throw new Error(STATES_USAGE)
882
+ await abTicketStates({ ...opts, json: parsed.flags.has('json') })
883
+ return
884
+ }
885
+
886
+ case 'state': {
887
+ const [action, ...stateArgs] = args
888
+ if (action !== 'create') throw new Error(STATE_CREATE_USAGE)
889
+ const parsed = parseArgs(stateArgs, { about: 'value', json: 'boolean' }, TICKET_USAGE)
890
+ const [name, ...extra] = parsed.positionals
891
+ if (name === undefined || name.trim() === '' || extra.length > 0) {
892
+ throw new Error(STATE_CREATE_USAGE)
893
+ }
894
+ const about = stringFlag(parsed, 'about')
895
+ if (about !== undefined && about.trim() === '') throw new Error(STATE_CREATE_USAGE)
896
+ await abTicketStateCreate({
897
+ ...opts,
898
+ name,
899
+ ...(about !== undefined ? { about } : {}),
455
900
  json: parsed.flags.has('json'),
456
901
  })
457
902
  return