@defrex/autobuild 0.12.0 → 0.14.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 (151) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +138 -16
  3. package/bin/ab-dispatch-kernel.ts +4 -6
  4. package/docs/architecture.md +155 -73
  5. package/docs/configuration.md +41 -9
  6. package/docs/hosted-service.md +31 -5
  7. package/docs/operator-api.md +8 -1
  8. package/docs/remote-store-protocol.md +94 -2
  9. package/docs/remote-ticket-protocol.md +151 -9
  10. package/docs/setup.md +33 -7
  11. package/docs/ticket-migration.md +64 -0
  12. package/package.json +4 -3
  13. package/packages/core/src/cli/attention.ts +18 -164
  14. package/packages/core/src/cli/build-control.ts +23 -16
  15. package/packages/core/src/cli/bulk-control.ts +3 -2
  16. package/packages/core/src/cli/connect.ts +141 -38
  17. package/packages/core/src/cli/context.ts +23 -79
  18. package/packages/core/src/cli/dashboard/model.ts +36 -22
  19. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  20. package/packages/core/src/cli/dispatch-frontend.ts +24 -67
  21. package/packages/core/src/cli/dispatch-process.ts +14 -7
  22. package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
  23. package/packages/core/src/cli/dispatch.ts +304 -82
  24. package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
  25. package/packages/core/src/cli/harvest.ts +84 -42
  26. package/packages/core/src/cli/help.ts +14 -8
  27. package/packages/core/src/cli/historical-build-state.ts +27 -0
  28. package/packages/core/src/cli/installation.ts +67 -13
  29. package/packages/core/src/cli/main.ts +23 -5
  30. package/packages/core/src/cli/mcp.ts +2 -1
  31. package/packages/core/src/cli/pr-attachments.ts +10 -13
  32. package/packages/core/src/cli/pr-summary-publication.ts +27 -60
  33. package/packages/core/src/cli/pr-summary.ts +43 -13
  34. package/packages/core/src/cli/publication-settlement.ts +10 -20
  35. package/packages/core/src/cli/repository-status.ts +38 -2
  36. package/packages/core/src/cli/self-update.ts +5 -1
  37. package/packages/core/src/cli/status.ts +62 -32
  38. package/packages/core/src/cli/terminals.ts +25 -142
  39. package/packages/core/src/cli/ticket-assets.ts +7 -2
  40. package/packages/core/src/cli/ticket-editor.ts +82 -0
  41. package/packages/core/src/cli/ticket.ts +166 -36
  42. package/packages/core/src/cli/wait.ts +91 -84
  43. package/packages/core/src/cli/watch.ts +60 -97
  44. package/packages/core/src/config/pipeline-source.ts +4 -3
  45. package/packages/core/src/config/schema.ts +35 -3
  46. package/packages/core/src/connection.ts +3 -3
  47. package/packages/core/src/events/catalog.ts +3 -2
  48. package/packages/core/src/events/payloads.ts +6 -0
  49. package/packages/core/src/events/repository.ts +7 -3
  50. package/packages/core/src/integration/harness.ts +38 -4
  51. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2444 -9
  52. package/packages/core/src/kernel/auto-merge.ts +19 -3
  53. package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
  54. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  55. package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
  56. package/packages/core/src/kernel/harvest.ts +25 -23
  57. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  58. package/packages/core/src/kernel/log-index-value.ts +38 -0
  59. package/packages/core/src/kernel/read-facts.ts +637 -0
  60. package/packages/core/src/kernel/reducer-registry.ts +139 -3
  61. package/packages/core/src/kernel/repository-attention.ts +179 -0
  62. package/packages/core/src/kernel/service-facts.ts +135 -0
  63. package/packages/core/src/kernel/terminal-guard.ts +133 -0
  64. package/packages/core/src/ontology.ts +1 -0
  65. package/packages/core/src/operator/annotations.ts +12 -0
  66. package/packages/core/src/operator/control.ts +5 -6
  67. package/packages/core/src/operator/index.ts +7 -0
  68. package/packages/core/src/operator/protocol.ts +19 -2
  69. package/packages/core/src/operator/query.ts +37 -20
  70. package/packages/core/src/operator/read-view.ts +4 -13
  71. package/packages/core/src/operator/registry.ts +61 -8
  72. package/packages/core/src/operator/requests.ts +2 -14
  73. package/packages/core/src/operator/sandbox.ts +10 -9
  74. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  75. package/packages/core/src/operator/tickets.ts +132 -22
  76. package/packages/core/src/plugin-sdk/index.ts +19 -0
  77. package/packages/core/src/plugins/runtimes.ts +5 -1
  78. package/packages/core/src/ports/runner/session-env.ts +2 -0
  79. package/packages/core/src/ports/tickets/contract.ts +66 -1
  80. package/packages/core/src/ports/tickets/create.ts +48 -12
  81. package/packages/core/src/ports/tickets/fake.ts +30 -1
  82. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  83. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  84. package/packages/core/src/ports/tickets/file.ts +114 -120
  85. package/packages/core/src/ports/tickets/grants.ts +47 -0
  86. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  87. package/packages/core/src/ports/tickets/identity.ts +20 -0
  88. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  89. package/packages/core/src/ports/tickets/linear.ts +74 -9
  90. package/packages/core/src/ports/tickets/migration.ts +336 -0
  91. package/packages/core/src/ports/tickets/operations.ts +38 -7
  92. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  93. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  94. package/packages/core/src/ports/tickets/remote.ts +60 -3
  95. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  96. package/packages/core/src/ports/tickets/revision.ts +62 -0
  97. package/packages/core/src/ports/tickets/states.ts +61 -0
  98. package/packages/core/src/ports/types.ts +40 -3
  99. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  100. package/packages/core/src/ports/workspace/create.ts +5 -1
  101. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  102. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  103. package/packages/core/src/processes/build-child.ts +6 -6
  104. package/packages/core/src/processes/build-runner.ts +62 -70
  105. package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
  106. package/packages/core/src/processes/dispatcher.ts +223 -205
  107. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  108. package/packages/core/src/processes/harvest-child.ts +30 -4
  109. package/packages/core/src/processes/harvest-execution-state.ts +15 -9
  110. package/packages/core/src/processes/harvest-runner.ts +58 -74
  111. package/packages/core/src/processes/harvest.ts +72 -29
  112. package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
  113. package/packages/core/src/processes/orchestrator-tick.ts +2 -2
  114. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  115. package/packages/core/src/processes/publication-loss.ts +35 -21
  116. package/packages/core/src/processes/repo-view.ts +229 -1099
  117. package/packages/core/src/processes/tick-publication.ts +110 -0
  118. package/packages/core/src/store/adapter.ts +23 -0
  119. package/packages/core/src/store/build-scope.ts +17 -0
  120. package/packages/core/src/store/contract.ts +283 -1
  121. package/packages/core/src/store/local/schema.ts +1 -0
  122. package/packages/core/src/store/local/store.ts +180 -55
  123. package/packages/core/src/store/memory.ts +119 -29
  124. package/packages/core/src/store/phase-session.ts +17 -0
  125. package/packages/core/src/store/reduced-reads.ts +239 -0
  126. package/packages/core/src/store/reducer-value-shapes.json +14056 -0
  127. package/packages/core/src/store/reducer-value-validation.ts +96 -0
  128. package/packages/core/src/store/reducer-values.ts +145 -0
  129. package/packages/core/src/store/remote/client.ts +41 -0
  130. package/packages/core/src/store/remote/compatibility.ts +2 -0
  131. package/packages/core/src/store/remote/index.ts +19 -0
  132. package/packages/core/src/store/remote/protocol.ts +16 -0
  133. package/packages/core/src/store/remote/version.ts +3 -1
  134. package/packages/core/src/store/repo-state-events.ts +1 -1
  135. package/packages/core/src/store/repo-tick.ts +72 -0
  136. package/packages/core/src/store/session-handle.ts +19 -0
  137. package/packages/core/src/store/snapshots.ts +10 -0
  138. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  139. package/packages/core/src/store/ticket-assets.ts +86 -8
  140. package/packages/core/src/store/types.ts +40 -3
  141. package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
  142. package/packages/core/src/testing/index.ts +8 -0
  143. package/packages/core/src/testing/reduced-read-budget.ts +462 -0
  144. package/skills/guide/SKILL.md +97 -12
  145. package/skills/guide/references/operator-api.md +8 -1
  146. package/skills/guide/references/remote-store-protocol.md +94 -2
  147. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  148. package/skills/guide/references/setup.md +33 -7
  149. package/skills/guide/references/ticket-migration.md +64 -0
  150. package/skills/tickets/SKILL.md +71 -0
  151. package/templates/hosted-service/package.json +2 -2
@@ -1,30 +1,30 @@
1
+ import { readRepositoryFacts, readDispatcherRunFacts } from '../store/reduced-reads'
1
2
  import type { RepositoryEvent } from '../events/repository'
2
3
  import type { BuildStore, RepositoryRecord } from '../store/types'
3
4
  import { formatDispatcherIdentity, projectDispatcherLiveness } from '../kernel/dispatcher-liveness'
4
5
 
5
- /** Bounded first; a later yielding invocation can hide the owner's identity or
6
- * an earlier stop. Only this correlation query falls back to full history. */
6
+ /** Correlate only the latest, stopped and lease-holder runs through persisted values. */
7
7
  export async function readDispatcherLiveness(
8
8
  store: BuildStore,
9
9
  repo: string,
10
10
  now = new Date(),
11
- subset?: RepositoryEvent[],
11
+ _subset?: RepositoryEvent[],
12
12
  ) {
13
13
  const record = await store.getRepo(repo)
14
- let events = record === null ? [] : (subset ?? (await store.getRepoStateEvents(repo)))
15
- const bounded = projectDispatcherLiveness(record, events, now)
16
- if (
17
- record &&
18
- ((record.lease &&
19
- !events.some(
20
- (e) => e.type === 'dispatcher.run-started' && e.payload.run === record.lease!.holder,
21
- )) ||
22
- (bounded.state !== 'held' &&
23
- events.some((e) => e.type === 'dispatcher.run-started') &&
24
- !events.some((e) => e.type === 'dispatcher.run-stopped')))
25
- ) {
26
- events = await store.getRepoEvents(repo)
27
- }
14
+ const facts = record === null ? undefined : (await readRepositoryFacts(store, repo)).value
15
+ const runs = new Set(
16
+ [facts?.latestRun, facts?.latestStoppedRun, record?.lease?.holder].filter(
17
+ (run): run is string => run !== undefined,
18
+ ),
19
+ )
20
+ const correlations = await Promise.all(
21
+ [...runs].map(async (run) => (await readDispatcherRunFacts(store, repo, run)).value),
22
+ )
23
+ const events = correlations.flatMap((run) =>
24
+ [run.start, run.latestStart, run.stop, run.tick].filter(
25
+ (event): event is RepositoryEvent => event !== undefined,
26
+ ),
27
+ )
28
28
  return { record, events, dispatcher: projectDispatcherLiveness(record, events, now) }
29
29
  }
30
30
 
@@ -39,6 +39,10 @@ export class StandbyIdentityResolver {
39
39
  constructor(
40
40
  private readonly store: BuildStore,
41
41
  private readonly repo: string,
42
+ private readonly history?: {
43
+ allowLookup: () => boolean
44
+ remember: (events: RepositoryEvent[], complete: boolean) => void
45
+ },
42
46
  ) {}
43
47
 
44
48
  ingest(events: readonly RepositoryEvent[]): void {
@@ -51,11 +55,14 @@ export class StandbyIdentityResolver {
51
55
  const pending = this.pending.get(holder)
52
56
  if (pending !== undefined) return pending
53
57
  if (this.starts.has(holder) || this.attempted.has(holder)) return
58
+ if (this.history && !this.history.allowLookup()) return
54
59
  this.attempted.add(holder)
55
60
  const lookup = (async () => {
56
61
  try {
57
- this.ingest(await this.store.getRepoStateEvents(this.repo))
58
- if (!this.starts.has(holder)) this.ingest(await this.store.getRepoEvents(this.repo, 0))
62
+ const facts = (await readDispatcherRunFacts(this.store, this.repo, holder)).value
63
+ this.ingest(facts.start ? [facts.start] : [])
64
+ const complete = false // This lookup correlates one holder, not the complete journal.
65
+ this.history?.remember([...this.starts.values()], complete)
59
66
  } catch {
60
67
  // Identity is best effort; the sampled holder id remains sufficient.
61
68
  }
@@ -1,3 +1,4 @@
1
+ import { readRepositoryFacts, type RepositoryReadFacts } from '../store/reduced-reads'
1
2
  /** Typed agent/operator surface for repository-scoped harvest workflows. Agent
2
3
  * stdout is never interpreted: proposal sets and review verdicts land through
3
4
  * this module as validated repository events and versioned artifacts. */
@@ -67,8 +68,8 @@ async function writeJson(path: string, value: unknown): Promise<void> {
67
68
  await writeFile(path, `${JSON.stringify(value, null, 2)}\n`)
68
69
  }
69
70
 
70
- function runForEnv(events: RepositoryEvent[], env: HarvestCliEnv): HarvestRunState {
71
- const run = reduceHarvest(events).runs.find((candidate) => candidate.run === env.run)
71
+ function runForEnv(events: RepositoryReadFacts, env: HarvestCliEnv): HarvestRunState {
72
+ const run = events.harvest.runs.find((candidate) => candidate.run === env.run)
72
73
  if (!run) throw new Error(`unknown harvest run "${env.run}" in repo "${env.repo}"`)
73
74
  if (run.status !== 'running') {
74
75
  throw new Error(
@@ -78,34 +79,44 @@ function runForEnv(events: RepositoryEvent[], env: HarvestCliEnv): HarvestRunSta
78
79
  return run
79
80
  }
80
81
 
81
- function assertNoSessionTerminal(events: RepositoryEvent[], env: HarvestCliEnv): void {
82
- for (const event of events) {
83
- if (
84
- event.type === 'harvest.proposals.submitted' &&
85
- env.phase === 'synthesize' &&
86
- event.payload.run === env.run &&
87
- event.payload.round === env.round
88
- ) {
89
- throw new Error(
90
- `second harvest terminal rejected: proposals for ${env.run} synthesize@${env.round} already landed at repo seq ${event.seq}`,
91
- )
92
- }
93
- if (
94
- event.type === 'harvest.review.verdict' &&
95
- env.phase === 'review' &&
96
- event.payload.run === env.run &&
97
- event.payload.round === env.round
98
- ) {
99
- throw new Error(
100
- `second harvest terminal rejected: verdict for ${env.run} review@${env.round} already landed at repo seq ${event.seq}`,
101
- )
102
- }
103
- if (event.type === 'harvest.session.ended' && event.payload.session === env.session) {
104
- throw new Error(
105
- `harvest terminal rejected: session "${env.session}" already ended at repo seq ${event.seq}`,
106
- )
107
- }
108
- }
82
+ function assertNoSessionTerminal(events: RepositoryReadFacts, env: HarvestCliEnv): void {
83
+ const run = events.harvest.runs.find((run) => run.run === env.run)
84
+ const proposal =
85
+ env.phase === 'synthesize'
86
+ ? run?.proposals.find((proposal) => proposal.round === env.round)
87
+ : undefined
88
+ const review =
89
+ env.phase === 'review'
90
+ ? events.harvestVerdicts[JSON.stringify([env.run, env.round])]?.first
91
+ : undefined
92
+ const ended = events.harvestSessionEndings[JSON.stringify(env.session)]
93
+ const errors = [
94
+ ...(proposal
95
+ ? [
96
+ {
97
+ seq: proposal.seq,
98
+ message: `second harvest terminal rejected: proposals for ${env.run} synthesize@${env.round} already landed at repo seq ${proposal.seq}`,
99
+ },
100
+ ]
101
+ : []),
102
+ ...(review
103
+ ? [
104
+ {
105
+ seq: review.seq,
106
+ message: `second harvest terminal rejected: verdict for ${env.run} review@${env.round} already landed at repo seq ${review.seq}`,
107
+ },
108
+ ]
109
+ : []),
110
+ ...(ended
111
+ ? [
112
+ {
113
+ seq: ended,
114
+ message: `harvest terminal rejected: session "${env.session}" already ended at repo seq ${ended}`,
115
+ },
116
+ ]
117
+ : []),
118
+ ].sort((a, b) => a.seq - b.seq)
119
+ if (errors[0]) throw new Error(errors[0].message)
109
120
  }
110
121
 
111
122
  async function readArtifactJson(
@@ -122,7 +133,7 @@ async function readArtifactJson(
122
133
  export async function buildHarvestContext(deps: HarvestCliDeps): Promise<HarvestContextManifest> {
123
134
  const { store, env, workspacePath } = deps
124
135
  // Bounded read (AUT-489): runForEnv reduces harvest facts only.
125
- const events = await store.getRepoStateEvents(env.repo)
136
+ const events = (await readRepositoryFacts(store, env.repo)).value
126
137
  const run = runForEnv(events, env)
127
138
  const packet = await loadScanPacket(store, env.repo, run.scan)
128
139
  const abDir = join(workspacePath, '.ab')
@@ -184,10 +195,20 @@ export async function buildHarvestContext(deps: HarvestCliDeps): Promise<Harvest
184
195
  }
185
196
 
186
197
  async function readJsonFile(path: string): Promise<unknown> {
187
- const file = Bun.file(path)
188
- if (!(await file.exists())) throw new Error(`file not found: ${path}`)
198
+ let text: string
189
199
  try {
190
- return JSON.parse(await file.text())
200
+ text = await Bun.file(path).text()
201
+ } catch (error) {
202
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
203
+ throw new Error(`file not found: ${path}`, { cause: error })
204
+ }
205
+ throw new Error(
206
+ `cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`,
207
+ { cause: error },
208
+ )
209
+ }
210
+ try {
211
+ return JSON.parse(text)
191
212
  } catch (error) {
192
213
  throw new Error(
193
214
  `${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
@@ -220,7 +241,7 @@ export async function submitHarvestProposals(
220
241
  }
221
242
  // Bounded read (AUT-489): runForEnv and assertNoSessionTerminal consume
222
243
  // harvest facts only.
223
- const events = await store.getRepoStateEvents(env.repo)
244
+ const events = (await readRepositoryFacts(store, env.repo)).value
224
245
  const run = runForEnv(events, env)
225
246
  assertNoSessionTerminal(events, env)
226
247
 
@@ -312,7 +333,7 @@ export async function submitHarvestVerdict(
312
333
  }
313
334
  // Bounded read (AUT-489): runForEnv and assertNoSessionTerminal consume
314
335
  // harvest facts only.
315
- const events = await store.getRepoStateEvents(env.repo)
336
+ const events = (await readRepositoryFacts(store, env.repo)).value
316
337
  const run = runForEnv(events, env)
317
338
  assertNoSessionTerminal(events, env)
318
339
  if (proposalArtifactForRound(run, env.round) === undefined) {
@@ -592,19 +613,35 @@ function projectHarvestRunStatus(run: HarvestRunState): Omit<HarvestRunStatusVie
592
613
 
593
614
  export function projectHarvestStatus(
594
615
  repo: string,
595
- events: RepositoryEvent[],
616
+ events: RepositoryEvent[] | RepositoryReadFacts,
596
617
  newestEvents?: number,
597
618
  streams?: readonly StreamRecord[],
598
619
  ): HarvestStatusView {
599
- const state = reduceHarvest(events)
620
+ const state = Array.isArray(events) ? reduceHarvest(events) : events.harvest
600
621
  const history =
601
- newestEvents === undefined ? undefined : events.filter(isHarvestEvent).slice(-newestEvents)
622
+ newestEvents === undefined || !Array.isArray(events)
623
+ ? undefined
624
+ : events.filter(isHarvestEvent).slice(-newestEvents)
602
625
  const unresolved = [...parkedHarvestRuns(state), ...unresolvedHarvestAttentionRuns(state)]
603
626
  const open = openHarvestRuns(state)
604
627
  const included = new Set(
605
628
  [...unresolved, ...open, ...(state.latest ? [state.latest] : [])].map((run) => run.run),
606
629
  )
607
- const sessionsByRun = projectHarvestSessions(events, streams)
630
+ const sessionsByRun = Array.isArray(events)
631
+ ? projectHarvestSessions(events, streams)
632
+ : new Map<string, HarvestSessionStatusView[]>()
633
+ if (!Array.isArray(events))
634
+ for (const { run, ...session } of events.harvestSessions) {
635
+ const record = streams?.find(
636
+ (record) => record.scope.kind === 'repo' && record.label === `session:${session.session}`,
637
+ )
638
+ const list = sessionsByRun.get(run) ?? []
639
+ list.push({
640
+ ...session,
641
+ ...(record ? { stream: record.id, streamStatus: record.status } : {}),
642
+ })
643
+ sessionsByRun.set(run, list)
644
+ }
608
645
  const runs = state.runs
609
646
  .filter((run) => included.has(run.run))
610
647
  .map((run) => ({
@@ -792,7 +829,7 @@ export async function abHarvestStatus(opts: HarvestStatusOpts): Promise<void> {
792
829
  async ({ store, repo }) => {
793
830
  const record = await store.getRepo(repo)
794
831
  // Bounded read (AUT-489): projectHarvestStatus reduces harvest facts only.
795
- const events = record === null ? [] : await store.getRepoStateEvents(repo)
832
+ const events = (await readRepositoryFacts(store, repo)).value
796
833
  // Best-effort stream enrichment for the session rows, mirroring the
797
834
  // degrade-gracefully pattern of 'ab build status': a store that cannot
798
835
  // list streams keeps the payload-derived projection instead of failing.
@@ -804,7 +841,12 @@ export async function abHarvestStatus(opts: HarvestStatusOpts): Promise<void> {
804
841
  streams = undefined
805
842
  }
806
843
  }
807
- const view = projectHarvestStatus(repo, events, opts.events, streams)
844
+ const view = projectHarvestStatus(repo, events, undefined, streams)
845
+ if (opts.events !== undefined)
846
+ view.events =
847
+ record === null
848
+ ? []
849
+ : (await store.getRepoEvents(repo)).filter(isHarvestEvent).slice(-opts.events)
808
850
  if (opts.json === true) opts.stdout(JSON.stringify(view, null, 2))
809
851
  else for (const line of renderHarvestStatus(view)) opts.stdout(line)
810
852
  },
@@ -74,14 +74,14 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
74
74
  audience: 'human',
75
75
  summary: 'Connect this project to a hosted service with browser approval.',
76
76
  detail:
77
- 'Usage: ab connect [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. Without a URL, reports the saved credential status. Runs outside sessions.',
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
78
  },
79
79
  {
80
80
  name: 'disconnect',
81
81
  audience: 'human',
82
82
  summary: 'Revoke and remove this project’s saved credential.',
83
83
  detail:
84
- 'Usage: ab disconnect\nRevokes the saved credential remotely before removing it locally. Keeps project registration, data and [store] url. Runs outside sessions.',
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
85
  },
86
86
  {
87
87
  name: 'upgrade',
@@ -173,20 +173,26 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
173
173
  detail: page([
174
174
  'Usage:',
175
175
  ' ab ticket create <title> --body <file> [--state <state>] [--labels a,b] [--blocked-by id,id] [--json]',
176
- ' ab ticket update <id> [--title <title>] [--body <file>] [--labels a,b] [--json]',
177
- ' ab ticket block <id> <blocker-id[,blocker-id...]> [--json]',
178
- ' 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]',
179
182
  ' ab ticket list [--state <state>] [--labels a,b] [--json]',
180
183
  ' ab ticket show <id> [--store <ref>] [--json]',
181
- ' ab ticket move <id> <state> [--json]',
184
+ ' ab ticket move <id> <state> [--expected-revision <opaque>] [--json]',
182
185
  ' ab ticket states [--json]',
183
186
  ' ab ticket state create <name> [--about <text>] [--json]',
184
187
  ' ab ticket attach <id> <kind> <path> [--name <name>] [--store <ref>] [--json]',
185
188
  ' ab ticket asset get <id> <kind> <name> <dest> [--rev <n>] [--store <ref>] [--json]',
186
189
  ' ab ticket asset rm <id> <kind> <name> [--store <ref>] [--json]',
187
190
  '',
188
- 'Operate on the configured [tickets] source (§8.8); all ticket commands run',
189
- '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.',
190
196
  '--state overrides [tickets].createState (or the provider default) for that',
191
197
  'create only; it is passed through unchanged and validated by the source.',
192
198
  'update partially replaces editable fields: omitted fields survive, --labels',
@@ -0,0 +1,27 @@
1
+ import type { BuildStore } from '../store/types'
2
+ import { buildStreamSeedReducer, type BuildStreamSeed } from '../kernel/read-facts'
3
+
4
+ /** Only explicit old --since cursors need historical reconstruction. Ordinary
5
+ * registration and the first event batch must use the persisted seed. */
6
+ export async function historicalBuildState(
7
+ store: BuildStore,
8
+ slug: string,
9
+ requested: number,
10
+ current: { cursor: number; value: BuildStreamSeed },
11
+ ) {
12
+ if (requested >= current.cursor) return current.value
13
+ const events = await store.getEvents(slug)
14
+ return buildStreamSeedReducer.reduce(events.filter((event) => event.seq <= requested))
15
+ }
16
+
17
+ import { repositoryAttentionReducer } from '../kernel/repository-attention'
18
+ export async function historicalRepositoryAttention(
19
+ store: BuildStore,
20
+ repo: string,
21
+ requested: number,
22
+ current: { cursor: number; value: ReturnType<typeof repositoryAttentionReducer.finish> },
23
+ ) {
24
+ if (requested >= current.cursor) return current.value
25
+ const events = await store.getRepoEvents(repo)
26
+ return repositoryAttentionReducer.reduce(events.filter((event) => event.seq <= requested))
27
+ }
@@ -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 { readBuildFacts } from '../store/reduced-reads'
2
+ import { preparePinnedAssets } from '../store/ticket-assets'
3
+ import type { PinnedAsset } from '../events/payloads'
1
4
  import { abConnect, abDisconnect } from './connect'
2
5
  /**
3
6
  * `ab` — the only channel between agents and the store, and the enforcement
@@ -19,7 +22,6 @@ import { textContent } from '../store/types'
19
22
  import type { Forge } from '../ports/types'
20
23
  import { spawnExec, type Exec } from '../ports/workspace/git-worktree'
21
24
  import type { IdSource } from '../ids'
22
- import { reduceBuild } from '../kernel/reducer'
23
25
  import { artifactDownload, artifactDownloadStream, artifactGet, artifactPut } from './artifact'
24
26
  import { multiFlag, parseArgs, stringFlag, type ParsedArgs } from './args'
25
27
  import { abBuildControl, type BuildControlAction, type BuildControlResult } from './build-control'
@@ -308,6 +310,7 @@ async function runBuildControl(
308
310
  action: BuildControlAction,
309
311
  storeRef?: string,
310
312
  readTicketBody?: (ref: TicketRef) => Promise<string>,
313
+ prepareTicketAssets?: (ref: TicketRef, store: BuildStore, repo: string) => Promise<PinnedAsset[]>,
311
314
  ): Promise<void> {
312
315
  if (deps.exec === undefined) {
313
316
  throw new Error(
@@ -322,6 +325,7 @@ async function runBuildControl(
322
325
  action,
323
326
  ...(storeRef !== undefined ? { storeRef } : {}),
324
327
  ...(readTicketBody !== undefined ? { readTicketBody } : {}),
328
+ ...(prepareTicketAssets ? { prepareTicketAssets } : {}),
325
329
  ...(deps.openStore !== undefined ? { openStore: deps.openStore } : {}),
326
330
  })
327
331
  deps.stdout(buildControlConfirmation(result))
@@ -451,9 +455,11 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
451
455
  case 'connect':
452
456
  case 'disconnect': {
453
457
  const usage = command === 'connect' ? 'usage: ab connect [url]' : 'usage: ab disconnect'
454
- const parsed = parseArgs(rest, {}, usage)
458
+ const parsed = parseArgs(rest, { tickets: 'boolean', builds: 'boolean' }, usage)
455
459
  if (parsed.positionals.length > (command === 'connect' ? 1 : 0)) throw new Error(usage)
456
460
  const options = {
461
+ tickets: parsed.flags.get('tickets') === true,
462
+ builds: parsed.flags.get('builds') === true,
457
463
  cwd: deps.workspacePath,
458
464
  env: deps.processEnv ?? process.env,
459
465
  exec: deps.exec ?? spawnExec,
@@ -1127,6 +1133,7 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
1127
1133
  : parsed.flags.has('revise-spec-from-ticket')
1128
1134
  ? ({ kind: 'revise-spec', body: { kind: 'ticket' } } as const)
1129
1135
  : undefined
1136
+ let revisionTicketSource: import('../ports/types').TicketSource | undefined
1130
1137
  const readTicketBody =
1131
1138
  resolve?.kind === 'revise-spec' && resolve.body.kind === 'ticket'
1132
1139
  ? async (ref: TicketRef): Promise<string> => {
@@ -1140,6 +1147,7 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
1140
1147
  },
1141
1148
  'ab answer --revise-spec-from-ticket',
1142
1149
  )
1150
+ revisionTicketSource = source
1143
1151
  if (source.name !== ref.source) {
1144
1152
  throw new Error(
1145
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`,
@@ -1151,7 +1159,10 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
1151
1159
  `ticket "${ref.id}" was not found in the configured ${source.name} source`,
1152
1160
  )
1153
1161
  }
1154
- 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
+ ) {
1155
1166
  throw new Error(
1156
1167
  `ticket adapter returned ${ticket.ref.source}:${ticket.ref.id}, expected ${ref.source}:${ref.id}`,
1157
1168
  )
@@ -1170,6 +1181,13 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
1170
1181
  },
1171
1182
  storeRef,
1172
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,
1173
1191
  )
1174
1192
  return 0
1175
1193
  }
@@ -1354,8 +1372,8 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
1354
1372
  // external side effect is best-effort after the atomic designation.
1355
1373
  if (attach) {
1356
1374
  try {
1357
- const events = await session.store.getEvents(session.env.build)
1358
- const pr = reduceBuild(events).pr
1375
+ const events = (await readBuildFacts(session.store, session.env.build)).value
1376
+ const pr = events.state.pr
1359
1377
  if (pr !== undefined) {
1360
1378
  try {
1361
1379
  await preparePrAttachments(session, events, pr.url)
@@ -120,7 +120,8 @@ export async function buildMcpTicketBackend(
120
120
  }
121
121
  return source
122
122
  },
123
- statesFor: async () => states,
123
+ statesFor: async (_context, source) =>
124
+ source.listStates ? (await source.listStates()).map((s) => s.name) : states,
124
125
  }
125
126
  }
126
127
 
@@ -1,11 +1,8 @@
1
- import type { AbEvent, EventEnvelope } from '../events/catalog'
1
+ import type { CurrentBuildFacts } from '../store/reduced-reads'
2
+ import type { EventEnvelope } from '../events/catalog'
2
3
  import { KERNEL } from '../events/envelope'
3
4
  import type { IdSource } from '../ids'
4
- import {
5
- currentPrAttachments,
6
- frozenPrImageHost,
7
- hostedPrAttachments,
8
- } from '../kernel/pr-attachments'
5
+ import type { currentPrAttachments } from '../kernel/pr-attachments'
9
6
  import { hostedPrAttachmentAssetSchema, type HostedPrAttachmentAsset } from '../ontology'
10
7
  import type { Forge } from '../ports/types'
11
8
  import type { BuildStore } from '../store/types'
@@ -25,7 +22,7 @@ function errorMessage(error: unknown): string {
25
22
 
26
23
  async function recordHostingFailure(
27
24
  deps: PrAttachmentDeps,
28
- events: AbEvent[],
25
+ events: CurrentBuildFacts,
29
26
  designation: ReturnType<typeof currentPrAttachments>[number],
30
27
  error: unknown,
31
28
  extraRefs: string[] = [],
@@ -44,7 +41,7 @@ async function recordHostingFailure(
44
41
  refs: [`${artifact.kind}@${artifact.rev}`, ...extraRefs],
45
42
  },
46
43
  })
47
- events.push(observed)
44
+ events.observations.push(observed.payload)
48
45
  } catch {
49
46
  // Hosting is a best-effort review affordance. A secondary failure to
50
47
  // record its follow-up must not hide the complete text projection or turn
@@ -61,14 +58,14 @@ async function recordHostingFailure(
61
58
  */
62
59
  export async function preparePrAttachments(
63
60
  deps: PrAttachmentDeps,
64
- events: AbEvent[],
61
+ events: CurrentBuildFacts,
65
62
  prUrl: string,
66
63
  ): Promise<void> {
67
- const target = frozenPrImageHost(events)
64
+ const target = events.imageHost
68
65
  if (target === undefined) return
69
66
 
70
- const designations = currentPrAttachments(events)
71
- const hosted = hostedPrAttachments(events, designations)
67
+ const designations = events.designations
68
+ const hosted = new Map(events.hostedImages)
72
69
  for (const designation of designations) {
73
70
  if (!designation.payload.mediaType.startsWith('image/')) continue
74
71
  if (hosted.has(designation.seq)) continue
@@ -132,7 +129,7 @@ export async function preparePrAttachments(
132
129
  )
133
130
  throw error
134
131
  }
135
- events.push(event)
132
+ events.hostedImages.push([designation.seq, event])
136
133
  hosted.set(designation.seq, event)
137
134
  }
138
135
  }