@defrex/autobuild 0.10.1 → 0.11.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 (45) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +15 -1
  3. package/bin/ab-dispatch-kernel.ts +1 -1
  4. package/docs/configuration.md +32 -3
  5. package/docs/operator-api.md +17 -0
  6. package/docs/remote-ticket-protocol.md +5 -0
  7. package/package.json +3 -3
  8. package/packages/core/src/cli/binary.ts +3 -3
  9. package/packages/core/src/cli/dashboard/model.ts +4 -0
  10. package/packages/core/src/cli/dashboard/render.ts +37 -7
  11. package/packages/core/src/cli/dispatch-frontend.ts +16 -1
  12. package/packages/core/src/cli/dispatch-process.ts +25 -7
  13. package/packages/core/src/cli/dispatch.ts +64 -6
  14. package/packages/core/src/cli/dispatcher-liveness.ts +91 -0
  15. package/packages/core/src/cli/help.ts +8 -5
  16. package/packages/core/src/cli/repository-status.ts +45 -5
  17. package/packages/core/src/cli/watch.ts +159 -14
  18. package/packages/core/src/config/live.ts +1 -0
  19. package/packages/core/src/config/schema.ts +9 -2
  20. package/packages/core/src/events/globs.ts +8 -3
  21. package/packages/core/src/events/repository.ts +51 -2
  22. package/packages/core/src/integration/harness.ts +2 -1
  23. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +18 -0
  24. package/packages/core/src/kernel/dispatch-status.ts +9 -1
  25. package/packages/core/src/kernel/dispatcher-liveness.ts +70 -0
  26. package/packages/core/src/kernel/reducer-registry.ts +4 -0
  27. package/packages/core/src/kernel/ticket-source-health.ts +30 -0
  28. package/packages/core/src/operator/query.ts +6 -3
  29. package/packages/core/src/ports/tickets/contract.ts +25 -0
  30. package/packages/core/src/ports/tickets/fake.ts +10 -8
  31. package/packages/core/src/ports/tickets/file.ts +1 -0
  32. package/packages/core/src/ports/tickets/linear.ts +241 -49
  33. package/packages/core/src/ports/tickets/remote-protocol.ts +2 -0
  34. package/packages/core/src/ports/types.ts +17 -0
  35. package/packages/core/src/processes/dispatcher.ts +277 -73
  36. package/packages/core/src/processes/repo-view.ts +2 -1
  37. package/packages/core/src/store/adapter.ts +1 -0
  38. package/packages/core/src/store/contract.ts +109 -0
  39. package/packages/core/src/store/digest.ts +14 -2
  40. package/packages/core/src/store/local/store.ts +18 -5
  41. package/packages/core/src/store/remote/protocol.ts +1 -0
  42. package/packages/core/src/store/repo-state-events.ts +49 -59
  43. package/packages/core/src/store/types.ts +5 -1
  44. package/skills/guide/SKILL.md +31 -0
  45. package/skills/operate/SKILL.md +39 -4
@@ -18,6 +18,7 @@ import type {
18
18
  TicketSource,
19
19
  TicketUpdate,
20
20
  } from '../types'
21
+ import { TicketSourceRateLimitError } from '../types'
21
22
  import { validateTicketUpdate } from './update'
22
23
 
23
24
  export const LINEAR_API_URL = 'https://api.linear.app/graphql'
@@ -69,14 +70,36 @@ function isEntityNotFound(error: unknown): boolean {
69
70
  export type LinearFetch = (
70
71
  url: string,
71
72
  init: { method: 'POST'; headers: Record<string, string>; body: string },
72
- ) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>
73
+ ) => Promise<{
74
+ ok: boolean
75
+ status: number
76
+ headers?: { get(name: string): string | null }
77
+ json(): Promise<unknown>
78
+ }>
73
79
 
74
80
  // ── Wire shapes (small structural types per operation) ───────────────────────
75
81
 
76
82
  interface GqlIssueRelation {
77
83
  id?: string
78
84
  type: string
79
- issue: { id?: string; identifier: string } | null
85
+ issue: { id?: string; identifier: string; state?: { type: string } | null } | null
86
+ }
87
+
88
+ interface PageInfo {
89
+ hasNextPage: boolean
90
+ endCursor: string | null
91
+ }
92
+ interface RelationOwner {
93
+ inverseRelations?: { nodes: GqlIssueRelation[]; pageInfo?: PageInfo }
94
+ }
95
+ interface DependencyIssue extends RelationOwner {
96
+ id?: string
97
+ identifier: string
98
+ state: { type: string } | null
99
+ }
100
+ interface Connection<T> {
101
+ nodes: T[]
102
+ pageInfo: PageInfo
80
103
  }
81
104
 
82
105
  interface GqlIssue {
@@ -91,7 +114,7 @@ interface GqlIssue {
91
114
  * `{issue: A, relatedIssue: B, type: "blocks"}` reads "A blocks B", so an
92
115
  * issue's blockers are its inverse `blocks` relations, and the blocker is
93
116
  * each relation's `issue`. Relation ids are required by the delete API. */
94
- inverseRelations?: { nodes: GqlIssueRelation[] }
117
+ inverseRelations?: { nodes: GqlIssueRelation[]; pageInfo?: PageInfo }
95
118
  }
96
119
 
97
120
  /**
@@ -105,14 +128,14 @@ interface GqlIssue {
105
128
  const RESOLVED_STATE_TYPES = new Set(['completed', 'canceled'])
106
129
 
107
130
  /** Native inverse `blocks` relations for one blocked issue. */
108
- function blockerRelationsOf(issue: GqlIssue): GqlIssueRelation[] {
131
+ function blockerRelationsOf(issue: RelationOwner): GqlIssueRelation[] {
109
132
  return (issue.inverseRelations?.nodes ?? []).filter(
110
133
  (relation) => relation.type === 'blocks' && relation.issue !== null,
111
134
  )
112
135
  }
113
136
 
114
137
  /** The identifiers blocking `issue`; unrelated relation kinds are ignored. */
115
- function blockersOf(issue: GqlIssue): string[] {
138
+ function blockersOf(issue: RelationOwner): string[] {
116
139
  return [...new Set(blockerRelationsOf(issue).map((relation) => relation.issue!.identifier))]
117
140
  }
118
141
 
@@ -175,16 +198,45 @@ interface LinearTeamInfo {
175
198
  labelIds: Map<string, string>
176
199
  }
177
200
 
178
- /** Autobuild blocker sets are intentionally small, but Linear's implicit
179
- * connection default is only 50. Make the practical bound explicit so reads,
180
- * idempotency preflight, and deletion all see the same relation window. */
201
+ /** Direct issue reads and grooming retain the existing 250-relation window.
202
+ * Dependency graph reads accumulate that window in smaller pages; ready
203
+ * listings exhaust relations so they can safely report blocker completion. */
181
204
  export const LINEAR_RELATION_PAGE_SIZE = 250
182
205
 
183
206
  const ISSUE_FIELDS =
184
207
  'id identifier title description url state { name type } labels { nodes { name } } ' +
185
208
  `inverseRelations(first: ${LINEAR_RELATION_PAGE_SIZE}) { nodes { id type issue { id identifier } } }`
186
209
 
187
- const LIST_READY_QUERY = `query ListReady($filter: IssueFilter!) { issues(filter: $filter) { nodes { ${ISSUE_FIELDS} } } }`
210
+ // Linear computes complexity from requested bounds, not returned counts:
211
+ // https://linear.app/developers/rate-limiting. Scalars cost .1, objects 1.
212
+ // Dependency: 25 * (1.2 + 1.1 + 100 * 2.2 + 1.2) + wrappers < 5,600.
213
+ // Ready: 10 * (10 + 50 * 1.1 + 100 * 3.5 + 1.2) + wrappers < 4,200.
214
+ // Continuations: <=25 * (2 + 100 * 2.2 + 1.2) < 5,600, or
215
+ // <=10 * (2 + 100 * 3.5 + 1.2) < 3,600. Every shape stays below 8,000.
216
+ const DEPENDENCY_PAGE_SIZE = 25
217
+ const READY_PAGE_SIZE = 10
218
+ const RELATION_PAGE_SIZE = 100
219
+ const READY_LABEL_PAGE_SIZE = 50
220
+ const PAGE_INFO = 'pageInfo { hasNextPage endCursor }'
221
+ const DEPENDENCY_RELATIONS = 'type issue { identifier }'
222
+ const READY_RELATIONS = 'id type issue { id identifier state { type } }'
223
+ const relationFields = (ready: boolean) => (ready ? READY_RELATIONS : DEPENDENCY_RELATIONS)
224
+ const dependencyFields = `id identifier state { type } inverseRelations(first: ${RELATION_PAGE_SIZE}) { nodes { ${DEPENDENCY_RELATIONS} } ${PAGE_INFO} }`
225
+ const DEPENDENCY_QUERY = `query Dependencies($filter: IssueFilter!, $first: Int!, $after: String) { issues(filter: $filter, first: $first, after: $after, includeArchived: true) { nodes { ${dependencyFields} } ${PAGE_INFO} } }`
226
+ const DEPENDENCY_FALLBACK_QUERY = `query Dependency($id: String!) { issue(id: $id) { ${dependencyFields} } }`
227
+ const READY_FIELDS = `id identifier title description url state { name type } labels(first: ${READY_LABEL_PAGE_SIZE}) { nodes { name } } inverseRelations(first: ${RELATION_PAGE_SIZE}) { nodes { ${READY_RELATIONS} } ${PAGE_INFO} }`
228
+ const LIST_READY_QUERY = `query ListReady($filter: IssueFilter!, $first: Int!, $after: String) { issues(filter: $filter, first: $first, after: $after) { nodes { ${READY_FIELDS} } ${PAGE_INFO} } }`
229
+
230
+ /** Refuse partial success and cursor loops on every connection independently. */
231
+ function nextCursor(info: PageInfo | undefined, seen: Set<string>): string | null {
232
+ if (!info || typeof info.hasNextPage !== 'boolean') throw new Error('linear: missing pageInfo')
233
+ if (!info.hasNextPage) return null
234
+ const cursor = info.endCursor
235
+ if (!cursor || seen.has(cursor)) throw new Error('linear: invalid or repeated pagination cursor')
236
+ seen.add(cursor)
237
+ return cursor
238
+ }
239
+
188
240
  const GET_ISSUE_QUERY = `query GetIssue($id: String!) { issue(id: $id) { ${ISSUE_FIELDS} } }`
189
241
  const RESOLVE_ISSUE_QUERY = `query ResolveIssue($id: String!) { issue(id: $id) { id } }`
190
242
  const ISSUE_STATE_QUERY = `query IssueState($id: String!) { issue(id: $id) { id state { name type } } }`
@@ -206,6 +258,8 @@ export class LinearTicketSource implements TicketSource {
206
258
  private readonly teamKey: string
207
259
  private readonly claimedState: string
208
260
  private readonly createState: string | undefined
261
+ private readonly now: () => number
262
+ private cooldown?: TicketSourceRateLimitError
209
263
  private readonly fetchFn: LinearFetch
210
264
 
211
265
  /** identifier → Linear UUID, cached per instance. */
@@ -220,12 +274,14 @@ export class LinearTicketSource implements TicketSource {
220
274
  apiKey: string
221
275
  teamKey: string
222
276
  fetchFn?: LinearFetch
277
+ now?: () => number
223
278
  /** Workflow state claim() moves the issue to (claim-before-launch, §12). */
224
279
  claimedState?: string
225
280
  /** Workflow state create() files new issues into; absent = Linear's team
226
281
  * default (whatever the team's default state is, e.g. Backlog). */
227
282
  createState?: string
228
283
  }) {
284
+ this.now = opts.now ?? Date.now
229
285
  this.apiKey = opts.apiKey
230
286
  this.teamKey = opts.teamKey
231
287
  this.claimedState = opts.claimedState ?? 'In Progress'
@@ -283,13 +339,31 @@ export class LinearTicketSource implements TicketSource {
283
339
  labels: { some: { name: { eq: label } } },
284
340
  }))
285
341
  }
286
- const data = await this.gql<{ issues: { nodes: GqlIssue[] } }>('listReady', LIST_READY_QUERY, {
287
- filter,
288
- })
289
- return {
290
- tickets: data.issues.nodes.map((issue) => this.toTicket(issue)),
291
- diagnostics: [],
292
- }
342
+ const tickets: Ticket[] = []
343
+ const seen = new Set<string>()
344
+ let after: string | null = null
345
+ do {
346
+ const data: { issues: Connection<GqlIssue> } = await this.gql('listReady', LIST_READY_QUERY, {
347
+ filter,
348
+ first: READY_PAGE_SIZE,
349
+ after,
350
+ })
351
+ await this.completeRelations(data.issues.nodes, true)
352
+ for (const issue of data.issues.nodes) {
353
+ const relations = issue.inverseRelations!.nodes
354
+ tickets.push({
355
+ ...this.toTicket(issue),
356
+ blockersResolved: relations.every(
357
+ (relation) =>
358
+ relation.type !== 'blocks' ||
359
+ (!!relation.issue?.identifier &&
360
+ RESOLVED_STATE_TYPES.has(relation.issue.state?.type ?? '')),
361
+ ),
362
+ })
363
+ }
364
+ after = nextCursor(data.issues.pageInfo, seen)
365
+ } while (after !== null)
366
+ return { tickets, diagnostics: [] }
293
367
  }
294
368
 
295
369
  async get(id: string): Promise<Ticket | null> {
@@ -506,43 +580,132 @@ export class LinearTicketSource implements TicketSource {
506
580
  }
507
581
  }
508
582
 
509
- /**
510
- * Dependency nodes (§13). One query per id — Linear's `IssueFilter` has no
511
- * identifier-`in` filter, and the id sets here are small (the ready set's
512
- * blockers). Deliberately uncached: a blocker completing between ticks must
513
- * be visible on the very next pass, so only the identifier→UUID map (which
514
- * never changes) is cached.
515
- */
583
+ /** One request per team/UUID group per issue page, plus batched relation
584
+ * continuation pages. No cache: workflow changes are visible next tick.
585
+ * Filter schema: https://github.com/linear/linear/blob/master/packages/sdk/src/schema.graphql */
516
586
  async dependencyStates(ids: string[]): Promise<DependencyState[]> {
517
- const states: DependencyState[] = []
587
+ const groups = new Map<
588
+ string,
589
+ { filter: Record<string, unknown>; values: Set<string | number> }
590
+ >()
591
+ const lookups = new Map<string, string>()
592
+ const fallback = new Set<string>()
518
593
  for (const id of ids) {
519
- let data: { issue: GqlIssue | null }
594
+ const match = /^([A-Z][A-Z0-9-]*)-([1-9][0-9]*)$/.exec(id)
595
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
596
+ const number = match ? Number(match[2]) : 0
597
+ if (!uuid && !(match && Number.isSafeInteger(number))) {
598
+ fallback.add(id)
599
+ lookups.set(id, id)
600
+ continue
601
+ }
602
+ const key = uuid ? 'uuid' : match![1]!
603
+ const group = groups.get(key) ?? {
604
+ filter: uuid ? {} : { team: { key: { eq: key } } },
605
+ values: new Set<string | number>(),
606
+ }
607
+ group.values.add(uuid ? id.toLowerCase() : number)
608
+ groups.set(key, group)
609
+ lookups.set(id, uuid ? id.toLowerCase() : id)
610
+ }
611
+ const found = new Map<string, DependencyIssue>()
612
+ for (const [key, group] of groups) {
613
+ const filter = {
614
+ ...group.filter,
615
+ [key === 'uuid' ? 'id' : 'number']: { in: [...group.values] },
616
+ }
617
+ const seen = new Set<string>()
618
+ let after: string | null = null
619
+ do {
620
+ const data: { issues: Connection<DependencyIssue> } = await this.gql(
621
+ 'dependencyStates',
622
+ DEPENDENCY_QUERY,
623
+ {
624
+ filter,
625
+ first: DEPENDENCY_PAGE_SIZE,
626
+ after,
627
+ },
628
+ )
629
+ await this.completeRelations(data.issues.nodes, false)
630
+ for (const issue of data.issues.nodes) {
631
+ found.set(issue.identifier, issue)
632
+ if (issue.id) found.set(issue.id.toLowerCase(), issue)
633
+ }
634
+ after = nextCursor(data.issues.pageInfo, seen)
635
+ } while (after !== null)
636
+ }
637
+ for (const id of fallback) {
638
+ let data: { issue: DependencyIssue | null }
520
639
  try {
521
- data = await this.gql<{ issue: GqlIssue | null }>('dependencyStates', GET_ISSUE_QUERY, {
522
- id,
523
- })
640
+ data = await this.gql('dependencyStates', DEPENDENCY_FALLBACK_QUERY, { id })
524
641
  } catch (error) {
525
- // An unknown identifier is a MISSING dependency, not a failed check:
526
- // the dispatcher must be able to say "AUT-99 does not exist" rather
527
- // than bailing out of the ticket's whole dependency evaluation.
528
- if (isEntityNotFound(error)) {
529
- states.push({ id, exists: false, resolved: false, blockedBy: [] })
530
- continue
531
- }
642
+ if (isEntityNotFound(error)) continue
532
643
  throw error
533
644
  }
534
- if (!data.issue) {
535
- states.push({ id, exists: false, resolved: false, blockedBy: [] })
536
- continue
645
+ if (data.issue) {
646
+ await this.completeRelations([data.issue], false)
647
+ found.set(id, data.issue)
537
648
  }
538
- states.push({
649
+ }
650
+ return ids.map((id) => {
651
+ const issue = found.get(lookups.get(id)!)
652
+ return {
539
653
  id,
540
- exists: true,
541
- resolved: RESOLVED_STATE_TYPES.has(data.issue.state?.type ?? ''),
542
- blockedBy: blockersOf(data.issue),
654
+ exists: !!issue,
655
+ resolved: !!issue && RESOLVED_STATE_TYPES.has(issue.state?.type ?? ''),
656
+ blockedBy: issue ? blockersOf(issue) : [],
657
+ }
658
+ })
659
+ }
660
+
661
+ /** Each alias owns a cursor. Graph reads retain the historic first 250
662
+ * relations; listing reads exhaust relations, including blockers after 250. */
663
+ private async completeRelations(issues: DependencyIssue[], ready: boolean): Promise<void> {
664
+ const pending = issues.map((issue) => ({
665
+ issue,
666
+ seen: new Set<string>(),
667
+ after: null as string | null,
668
+ }))
669
+ for (const item of pending)
670
+ item.after = nextCursor(item.issue.inverseRelations?.pageInfo, item.seen)
671
+ for (;;) {
672
+ const batch = pending.filter(
673
+ (item) =>
674
+ item.after !== null &&
675
+ (ready || item.issue.inverseRelations!.nodes.length < LINEAR_RELATION_PAGE_SIZE),
676
+ )
677
+ if (batch.length === 0) return
678
+ const definitions: string[] = []
679
+ const selections: string[] = []
680
+ const variables: Record<string, unknown> = {}
681
+ batch.forEach((item, n) => {
682
+ if (!item.issue.id) throw new Error('linear: relation continuation requires issue id')
683
+ definitions.push(`$id${n}: String!, $first${n}: Int!, $after${n}: String!`)
684
+ selections.push(
685
+ `r${n}: issue(id: $id${n}) { inverseRelations(first: $first${n}, after: $after${n}) { nodes { ${relationFields(ready)} } ${PAGE_INFO} } }`,
686
+ )
687
+ variables[`id${n}`] = item.issue.id
688
+ variables[`first${n}`] = ready
689
+ ? RELATION_PAGE_SIZE
690
+ : Math.min(
691
+ RELATION_PAGE_SIZE,
692
+ LINEAR_RELATION_PAGE_SIZE - item.issue.inverseRelations!.nodes.length,
693
+ )
694
+ variables[`after${n}`] = item.after
695
+ })
696
+ const data = await this.gql<Record<string, RelationOwner | null>>(
697
+ 'relations',
698
+ `query Relations(${definitions.join(', ')}) { ${selections.join(' ')} }`,
699
+ variables,
700
+ )
701
+ batch.forEach((item, n) => {
702
+ const connection = data[`r${n}`]?.inverseRelations
703
+ const after = nextCursor(connection?.pageInfo, item.seen)
704
+ if (!connection) throw new Error('linear: missing relation continuation')
705
+ item.issue.inverseRelations!.nodes.push(...connection.nodes)
706
+ item.after = after
543
707
  })
544
708
  }
545
- return states
546
709
  }
547
710
 
548
711
  // ── Plumbing ───────────────────────────────────────────────────────────────
@@ -654,6 +817,7 @@ export class LinearTicketSource implements TicketSource {
654
817
  query: string,
655
818
  variables: Record<string, unknown>,
656
819
  ): Promise<T> {
820
+ if (this.cooldown && this.now() < this.cooldown.resetAt) throw this.cooldown
657
821
  const response = await this.fetchFn(LINEAR_API_URL, {
658
822
  method: 'POST',
659
823
  headers: {
@@ -662,20 +826,47 @@ export class LinearTicketSource implements TicketSource {
662
826
  },
663
827
  body: JSON.stringify({ query, variables }),
664
828
  })
665
- if (!response.ok) {
666
- throw new Error(`linear ${operation}: HTTP ${response.status}`)
829
+ const http = `linear ${operation}: HTTP ${response.status}`
830
+ let raw: unknown
831
+ try {
832
+ raw = await response.json()
833
+ } catch (error) {
834
+ if (!response.ok) throw new Error(http)
835
+ throw error
667
836
  }
668
- const payload = (await response.json()) as {
837
+ const payload = (raw !== null && typeof raw === 'object' ? raw : {}) as {
669
838
  data?: T | null
670
839
  errors?: GqlError[]
671
840
  }
672
- if (payload.errors && payload.errors.length > 0) {
673
- const messages = payload.errors.map((e) => e.message).join('; ')
674
- throw new LinearGqlError(`linear ${operation}: GraphQL errors — ${messages}`, payload.errors)
841
+ const errors = Array.isArray(payload.errors)
842
+ ? payload.errors.filter((e) => e !== null && typeof e === 'object')
843
+ : []
844
+ if (errors.some((e) => e.extensions?.code === 'RATELIMITED')) {
845
+ const now = this.now()
846
+ const reset = Number(response.headers?.get('x-ratelimit-requests-reset'))
847
+ // Missing, expired or unrepresentable provider dates get a bounded fallback.
848
+ const resetAt =
849
+ Number.isFinite(reset) && reset > now && reset > 0 && reset <= 8.64e15
850
+ ? reset
851
+ : now + 60_000
852
+ this.cooldown = new TicketSourceRateLimitError(this.name, resetAt)
853
+ throw this.cooldown
854
+ }
855
+ if (errors.length > 0) {
856
+ const messages = errors
857
+ .map((e) => `${e.extensions?.code ? `${e.extensions.code}: ` : ''}${e.message}`)
858
+ .join('; ')
859
+ throw new LinearGqlError(
860
+ `${response.ok ? `linear ${operation}` : http}: GraphQL errors — ${messages}`,
861
+ errors,
862
+ )
675
863
  }
864
+ if (!response.ok) throw new Error(http)
676
865
  if (payload.data === undefined || payload.data === null) {
677
866
  throw new Error(`linear ${operation}: response has no data`)
678
867
  }
868
+ // An older in-flight success must not cancel a still-active cooldown.
869
+ if (this.cooldown && this.now() >= this.cooldown.resetAt) this.cooldown = undefined
679
870
  return payload.data
680
871
  }
681
872
 
@@ -836,6 +1027,7 @@ export class LinearTicketSource implements TicketSource {
836
1027
  title: issue.title,
837
1028
  body: issue.description ?? '',
838
1029
  state: issue.state?.name,
1030
+ resolved: RESOLVED_STATE_TYPES.has(issue.state?.type ?? ''),
839
1031
  labels: issue.labels.nodes.map((label) => label.name),
840
1032
  ...(blockedBy.length > 0 ? { blockedBy } : {}),
841
1033
  }
@@ -15,7 +15,9 @@ export const ticketWireSchema = z.strictObject({
15
15
  title: z.string(),
16
16
  body: z.string(),
17
17
  state: z.string().optional(),
18
+ resolved: z.boolean(),
18
19
  labels: z.array(z.string()),
20
+ blockersResolved: z.boolean().optional(),
19
21
  blockedBy: z.array(z.string()).optional(),
20
22
  })
21
23
  export const ticketListingWireSchema = z.strictObject({
@@ -59,7 +59,12 @@ export interface Ticket {
59
59
  /** The ticket body — pre-build, the spec lives here (§6.3). */
60
60
  body: string
61
61
  state?: string
62
+ /** Native completion fact, identical to DependencyState.resolved. */
63
+ resolved: boolean
62
64
  labels: string[]
65
+ /** Listing-time source fact that all declared blockers are resolved. Only
66
+ * true permits skipping dependency reads; consumers fall back when absent. */
67
+ blockersResolved?: boolean
63
68
  /** Source-local ids of tickets declared as blockers. Absent ≡ none. */
64
69
  blockedBy?: string[]
65
70
  }
@@ -131,6 +136,18 @@ export interface TicketStateInfo {
131
136
  about?: string
132
137
  }
133
138
 
139
+ /** Source methods may reject with this failure; it is not a dependency verdict. */
140
+ export class TicketSourceRateLimitError extends Error {
141
+ constructor(
142
+ readonly source: string,
143
+ /** UTC epoch milliseconds at which a caller may try again. */
144
+ readonly resetAt: number,
145
+ ) {
146
+ super(`${source}: rate limited until ${new Date(resetAt).toISOString()}`)
147
+ this.name = 'TicketSourceRateLimitError'
148
+ }
149
+ }
150
+
134
151
  export interface TicketSource {
135
152
  readonly name: string
136
153
  /** Ready tickets matching the dispatch criteria (label/state — §3.3), plus