@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
@@ -213,8 +213,14 @@ export function createGitHubFetchTransport(opts: {
213
213
  // a private repository is otherwise indistinguishable from a bad path,
214
214
  // and the operator's fix (export a token, or `gh auth login`) is the
215
215
  // point of the message.
216
+ // `in` rather than relying on the `token === undefined` discriminant:
217
+ // the plugin-sdk typecheck fixture compiles this chain without
218
+ // strictNullChecks, where `undefined` is assignable to `string` and
219
+ // the union stops discriminating (AUT-517).
216
220
  const anonymous =
217
- credential.token === undefined ? ` (request was unauthenticated: ${credential.reason})` : ''
221
+ credential.token === undefined && 'reason' in credential
222
+ ? ` (request was unauthenticated: ${credential.reason})`
223
+ : ''
218
224
  throw new GitHubApiError(
219
225
  response.status,
220
226
  `${githubErrorMessage(response.status, json, text)}${anonymous}`,
@@ -335,9 +341,14 @@ export async function resolveGitHubToken(
335
341
  if (fromEnv !== undefined) return { token: fromEnv }
336
342
  const probe = await githubTokenFromGhCli(exec)
337
343
  if (probe.token !== undefined) return probe
344
+ // `in` rather than relying on the `token !== undefined` discriminant: the
345
+ // plugin-sdk typecheck fixture compiles this chain without
346
+ // strictNullChecks, where `undefined` is assignable to `string` and the
347
+ // union stops discriminating (AUT-517). A miss always carries its reason.
348
+ const missReason = 'reason' in probe ? probe.reason : 'no credential source succeeded'
338
349
  return {
339
350
  token: undefined,
340
- reason: `GITHUB_TOKEN and GH_TOKEN are unset and ${probe.reason}`,
351
+ reason: `GITHUB_TOKEN and GH_TOKEN are unset and ${missReason}`,
341
352
  }
342
353
  }
343
354
 
@@ -470,6 +470,16 @@ function restMergeState(raw: string): MergeStateStatus {
470
470
 
471
471
  const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
472
472
 
473
+ /** Bounded UNKNOWN re-query schedule: 7 re-queries, ~30 s of nondecreasing
474
+ * backoff capped at 6 s steps. Sized after AUT-392 — the prior
475
+ * two-re-query/2 s budget (`[500, 1500]`) was exhausted by a live occurrence
476
+ * (build `give-repo-scoped`, PR #352) while GitHub was still computing
477
+ * mergeability after required checks completed. Single-occurrence evidence,
478
+ * so this is a deliberate sizing bet, not a measured latency distribution;
479
+ * the unchanged pendingAutoMerge tick backstop remains behind it for waits a
480
+ * single tick cannot absorb. */
481
+ export const MERGEABILITY_RETRY_DELAYS_MS = [1000, 2000, 4000, 5000, 6000, 6000, 6000]
482
+
473
483
  /** Hard cap on the janitor poll's per-PR ETag cache. One small entry per PR
474
484
  * ever polled in a dispatcher process lifetime makes hitting this in practice
475
485
  * implausible; the cap is hygiene, not a load-bearing bound. */
@@ -514,7 +524,8 @@ export class GitHubForge implements Forge {
514
524
  env?: Readonly<Record<string, string | undefined>>
515
525
  /** Bounded waits between mergeability re-queries when GitHub reports
516
526
  * `mergeable_state: 'unknown'` (mergeability still being computed).
517
- * Two retries default; tests inject `[0, 0]` so no test sleeps. */
527
+ * Defaults to `MERGEABILITY_RETRY_DELAYS_MS` (7 re-queries, ~30 s);
528
+ * tests inject `[0, 0]` or all-zero schedules so no test sleeps. */
518
529
  mergeabilityRetryDelaysMs?: number[]
519
530
  } = {},
520
531
  ) {
@@ -535,7 +546,7 @@ export class GitHubForge implements Forge {
535
546
  this.explicitRepository = this.env.AB_REPOSITORY
536
547
  }
537
548
  this.repoRoot = opts.repoRoot
538
- this.mergeabilityRetryDelaysMs = opts.mergeabilityRetryDelaysMs ?? [500, 1500]
549
+ this.mergeabilityRetryDelaysMs = opts.mergeabilityRetryDelaysMs ?? MERGEABILITY_RETRY_DELAYS_MS
539
550
  this.prAttachments = new GitHubPrAttachmentHosting({ transport: this.transport })
540
551
  }
541
552
 
@@ -40,9 +40,39 @@ function detail(error: unknown): string {
40
40
  export class LocalGitForge implements Forge {
41
41
  readonly name = 'local-git'
42
42
  private readonly exec: Exec
43
+ /** The repository the optional checkout-less read seam reads from. The
44
+ * factory passes the served checkout; without it the capability is absent
45
+ * and callers retain their checkout/git fallbacks. */
46
+ private readonly repoRoot?: string
43
47
 
44
- constructor(opts: { exec?: Exec } = {}) {
48
+ constructor(opts: { exec?: Exec; repoRoot?: string } = {}) {
45
49
  this.exec = opts.exec ?? bunExec
50
+ this.repoRoot = opts.repoRoot
51
+ }
52
+
53
+ /** Current tip of a local branch in the served repository. Throws when the
54
+ * branch does not exist — the same not-found contract as remote adapters. */
55
+ async remoteBranchSha(branch: string): Promise<string> {
56
+ if (this.repoRoot === undefined) {
57
+ throw new Error('local-git forge has no repository root for remote branch reads')
58
+ }
59
+ const result = await this.command(
60
+ ['rev-parse', '--verify', `refs/heads/${branch}`],
61
+ this.repoRoot,
62
+ true,
63
+ )
64
+ if (result.exitCode !== 0) throw new Error(`local-git: unknown remote branch ${branch}`)
65
+ return result.stdout.trim()
66
+ }
67
+
68
+ /** File bytes from the served repository. Ref omitted → the checkout's HEAD.
69
+ * Throws when the path does not exist at that ref. */
70
+ async readFile(path: string, ref?: string): Promise<string> {
71
+ if (this.repoRoot === undefined) {
72
+ throw new Error('local-git forge has no repository root for file reads')
73
+ }
74
+ const result = await this.command(['show', `${ref ?? 'HEAD'}:${path}`], this.repoRoot)
75
+ return result.stdout
46
76
  }
47
77
 
48
78
  private async command(args: string[], cwd: string, allowFailure = false) {
@@ -38,7 +38,7 @@ import {
38
38
  toolInputPart,
39
39
  toolOutputPart,
40
40
  } from './stream-parts'
41
- import { classifyProviderError, configurationFailure } from './provider-error'
41
+ import { classifyProviderError, missingExecutableFailure } from './provider-error'
42
42
  import { sessionEnv } from './session-env'
43
43
  import type { OneShotCompletion, OneShotCompletionInput, OneShotCompletionResult } from './one-shot'
44
44
  import type { RuntimeUsabilityInput, RuntimeUsabilityResult } from './runtime'
@@ -652,7 +652,7 @@ function launchFailure(error: unknown): ClaudeTurn {
652
652
  return {
653
653
  text: '',
654
654
  usage: { inputTokens: 0, outputTokens: 0 },
655
- failure: missing ? configurationFailure(message) : classifyProviderError(message),
655
+ failure: missing ? missingExecutableFailure(message) : classifyProviderError(message),
656
656
  cli: {
657
657
  stdout: '',
658
658
  stderr: errorText(error),
@@ -17,7 +17,11 @@ import {
17
17
  type Transcript,
18
18
  } from '../types'
19
19
  import type { OneShotCompletion, OneShotCompletionInput, OneShotCompletionResult } from './one-shot'
20
- import { classifyProviderError, configurationFailure, credentialFailure } from './provider-error'
20
+ import {
21
+ classifyProviderError,
22
+ credentialFailure,
23
+ missingExecutableFailure,
24
+ } from './provider-error'
21
25
  import {
22
26
  abortPart,
23
27
  errorPart,
@@ -694,7 +698,7 @@ function launchFailure(error: unknown): CodexTurn {
694
698
  return {
695
699
  text: '',
696
700
  usage: { inputTokens: 0, outputTokens: 0 },
697
- failure: missing ? configurationFailure(message) : classifyProviderError(message),
701
+ failure: missing ? missingExecutableFailure(message) : classifyProviderError(message),
698
702
  cli: { stdout: '', stderr: errorText(error), exitCode: -1 },
699
703
  events: [],
700
704
  malformedLines: [],
@@ -49,12 +49,18 @@ export function failedTurnResult(
49
49
  permanent: boolean,
50
50
  text = '',
51
51
  cause?: AgentFailureCause,
52
+ missingExecutable?: true,
52
53
  ): AgentTurnResult {
53
54
  return {
54
55
  kind: 'failed',
55
56
  text,
56
57
  usage: { inputTokens: 0, outputTokens: 0, turns: 1 },
57
- failure: { message, permanent, ...(cause !== undefined ? { cause } : {}) },
58
+ failure: {
59
+ message,
60
+ permanent,
61
+ ...(cause !== undefined ? { cause } : {}),
62
+ ...(missingExecutable === true ? { missingExecutable } : {}),
63
+ },
58
64
  }
59
65
  }
60
66
 
@@ -13,7 +13,12 @@ import {
13
13
  type Transcript,
14
14
  } from '../types'
15
15
  import type { OneShotCompletion, OneShotCompletionInput, OneShotCompletionResult } from './one-shot'
16
- import { classifyProviderError, configurationFailure, credentialFailure } from './provider-error'
16
+ import {
17
+ classifyProviderError,
18
+ configurationFailure,
19
+ credentialFailure,
20
+ missingExecutableFailure,
21
+ } from './provider-error'
17
22
  import { createPiRpcSession, PI_MODEL_ARG, PI_RPC_MODE_ARG } from './pi-rpc'
18
23
  import {
19
24
  abortPart,
@@ -586,11 +591,8 @@ export class PiAgentRunner implements AgentRunner, OneShotCompletion {
586
591
 
587
592
  function localPiFailure(error: unknown): AgentTurnFailure {
588
593
  const message = errorText(error)
589
- if (
590
- /executable "pi" was not found|requires Pi .* or newer|could not parse detected Pi version/.test(
591
- message,
592
- )
593
- ) {
594
+ if (/executable "pi" was not found/.test(message)) return missingExecutableFailure(message)
595
+ if (/requires Pi .* or newer|could not parse detected Pi version/.test(message)) {
594
596
  return configurationFailure(message)
595
597
  }
596
598
  if (/not ready|authentication|logged in|credentials/i.test(message))
@@ -77,9 +77,12 @@ export function classifyProviderError(
77
77
  /** Whether another declared target may be tried inside this phase attempt.
78
78
  * Legacy plugins have no cause: preserve their old safe split. */
79
79
  export function isAlternateEligible(failure: AgentTurnFailure): boolean {
80
- return failure.cause === undefined
81
- ? !failure.permanent
82
- : failure.cause === 'availability' || failure.cause === 'exhaustion'
80
+ if (failure.cause === undefined) return !failure.permanent
81
+ return (
82
+ failure.cause === 'availability' ||
83
+ failure.cause === 'exhaustion' ||
84
+ (failure.cause === 'configuration' && failure.missingExecutable === true)
85
+ )
83
86
  }
84
87
 
85
88
  /** Final-failure retry behavior remains governed by the compatibility bit. */
@@ -92,6 +95,13 @@ export function configurationFailure(message: string): AgentTurnFailure {
92
95
  return { message, permanent: true, cause: 'configuration' }
93
96
  }
94
97
 
98
+ /** The runtime's CLI executable is not installed. Still a permanent
99
+ * `configuration` failure (never retried), but a later declared target whose
100
+ * CLI is installed may serve the phase. */
101
+ export function missingExecutableFailure(message: string): AgentTurnFailure {
102
+ return { message, permanent: true, cause: 'configuration', missingExecutable: true }
103
+ }
104
+
95
105
  /** Explicit credential failures bypass both alternates and phase retries. */
96
106
  export function credentialFailure(message: string): AgentTurnFailure {
97
107
  return { message, permanent: true, cause: 'credentials' }
@@ -79,7 +79,11 @@ export class FakeTicketSource implements TicketSource {
79
79
  this.tickets.set(ticket.ref.id, cloneTicket(ticket))
80
80
  }
81
81
 
82
- async listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing> {
82
+ async listReady(criteria: {
83
+ labels?: string[]
84
+ state?: string
85
+ assignee?: string
86
+ }): Promise<TicketListing> {
83
87
  const labels = criteria.labels ?? []
84
88
  return {
85
89
  tickets: [...this.tickets.values()]
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * File-based TicketSource (SPEC §3.2, §13): the default tracker. A directory
3
- * of state directories — `triage/ ready/ doing/ done/` — each holding
3
+ * of state directories — the lifecycle four `triage/ ready/ doing/ done/`
4
+ * plus any the repository adds, such as `icebox/` — each holding
4
5
  * `<id>.md` files: TOML frontmatter between `+++` fences, then the ticket
5
6
  * body, where the spec lives pre-build (§6.3). This adapter is the policy's
6
7
  * proof: a source with nowhere to put blobs must be fully workable, because
@@ -19,6 +20,7 @@
19
20
  * human edits are explicitly last-write-wins. State renames never cross a
20
21
  * filesystem: `<dir>/ready` → `<dir>/doing` is one mount by construction.
21
22
  */
23
+ import { readdirSync, statSync } from 'node:fs'
22
24
  import { mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
23
25
  import { join } from 'node:path'
24
26
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
@@ -31,40 +33,64 @@ import type {
31
33
  TicketDraft,
32
34
  TicketListing,
33
35
  TicketSource,
36
+ TicketStateInfo,
34
37
  TicketUpdate,
35
38
  } from '../types'
36
39
  import { validateTicketUpdate } from './update'
37
40
 
41
+ /** Repo-relative fallback when no selected local-state root is supplied. */
42
+ export const DEFAULT_TICKETS_DIR = '.autobuild/tickets'
43
+
38
44
  /**
39
- * The canonical states, in workflow order. This set is closed: the states are
40
- * directories, and a tracker whose states are discoverable by `ls` cannot also
41
- * let config invent new ones.
45
+ * The directories the dispatcher's roles (create, ready, claim, triage, done)
46
+ * are played by. Always valid state names, because `ensureLayout` creates them.
42
47
  */
43
- export const TICKET_STATES = ['Triage', 'Ready', 'Doing', 'Done'] as const
44
- export type TicketState = (typeof TICKET_STATES)[number]
48
+ const LIFECYCLE_DIRS = ['triage', 'ready', 'doing', 'done'] as const
45
49
 
46
- /** Repo-relative fallback when no selected local-state root is supplied. */
47
- export const DEFAULT_TICKETS_DIR = '.autobuild/tickets'
50
+ /** Dotfile inside a state directory holding its one-line purpose. Dot entries
51
+ * and non-`.md` files are never tickets, so it can never be mistaken for one. */
52
+ const ABOUT_FILE = '.about'
53
+
54
+ const STATE_NAME_RULE =
55
+ 'a state name is one to forty characters of lowercase letters, digits, and hyphens, starting with a letter'
56
+ const STATE_NAME_RE = /^[a-z][a-z0-9-]{0,39}$/
57
+
58
+ /** Throws, stating the rule, unless `name` may be used to create a state. */
59
+ export function validateStateName(name: string): void {
60
+ if (!STATE_NAME_RE.test(name)) {
61
+ throw new Error(`file ticket source: invalid state name "${name}" — ${STATE_NAME_RULE}`)
62
+ }
63
+ }
64
+
65
+ /** The state names a root listing yields: the lifecycle four, then every other
66
+ * non-dot directory alphabetically. Dot entries and files are never states. */
67
+ function statesIn(directories: string[]): string[] {
68
+ const lifecycle: string[] = [...LIFECYCLE_DIRS]
69
+ const extra = directories
70
+ .filter((name) => !name.startsWith('.') && !lifecycle.includes(name))
71
+ .sort()
72
+ return [...lifecycle, ...extra]
73
+ }
48
74
 
49
75
  /**
50
- * Canonicalize a state name to its directory. Case-insensitive in (a config's
51
- * `readyState = "ready"` means the `ready/` directory), canonical out — and
52
- * anything else is a loud error, because a typo'd state must not silently
53
- * create a fifth directory no `ls` of the four would ever show.
76
+ * Resolve a state name to its directory. Case-insensitive in (a config's
77
+ * `readyState = "ready"` means the `ready/` directory), the directory's real
78
+ * name out — and a name matching no directory is a loud error, because a
79
+ * typo'd state must not silently create a directory no `ls` would show.
54
80
  */
55
- export function stateDir(state: string): TicketState {
56
- const match = TICKET_STATES.find((s) => s.toLowerCase() === state.toLowerCase())
57
- if (match === undefined) {
81
+ function resolveState(name: string, existing: string[]): string {
82
+ if (existing.includes(name)) return name
83
+ const matches = existing.filter((s) => s.toLowerCase() === name.toLowerCase())
84
+ if (matches.length === 1) return matches[0] as string
85
+ if (matches.length > 1) {
58
86
  throw new Error(
59
- `file ticket source: unknown state "${state}" — this tracker's states are ` +
60
- `the directories: ${TICKET_STATES.join(', ')}`,
87
+ `file ticket source: ambiguous state "${name}" — it matches the directories ${matches.join(', ')}`,
61
88
  )
62
89
  }
63
- return match
64
- }
65
-
66
- function dirName(state: TicketState): string {
67
- return state.toLowerCase()
90
+ throw new Error(
91
+ `file ticket source: unknown state "${name}" — this tracker's states are ` +
92
+ `the directories: ${existing.join(', ')}`,
93
+ )
68
94
  }
69
95
 
70
96
  const frontmatterSchema = z.strictObject({
@@ -138,7 +164,7 @@ function serializeTicketFile(front: Frontmatter, body: string): string {
138
164
  /** A ticket file found on disk: its id, its state (= its directory), its path. */
139
165
  interface Located {
140
166
  id: string
141
- state: TicketState
167
+ state: string
142
168
  path: string
143
169
  }
144
170
 
@@ -147,8 +173,8 @@ export class FileTicketSource implements TicketSource {
147
173
 
148
174
  private readonly dir: string
149
175
  private readonly clock: Clock
150
- private readonly createState: TicketState
151
- private readonly doneState: TicketState
176
+ private readonly createState: string
177
+ private readonly doneState: string
152
178
  private readonly selfIgnore: boolean
153
179
 
154
180
  constructor(opts: {
@@ -174,14 +200,20 @@ export class FileTicketSource implements TicketSource {
174
200
  }) {
175
201
  this.dir = opts.dir
176
202
  this.clock = opts.clock ?? systemClock
177
- this.createState = stateDir(opts.createState ?? 'Triage')
178
- this.doneState = stateDir(opts.doneState ?? 'Done')
203
+ const existing = this.statesSync()
204
+ this.createState = resolveState(opts.createState ?? 'Triage', existing)
205
+ this.doneState = resolveState(opts.doneState ?? 'Done', existing)
179
206
  this.selfIgnore = opts.selfIgnore ?? false
180
207
  }
181
208
 
182
- async listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing> {
209
+ async listReady(criteria: {
210
+ labels?: string[]
211
+ state?: string
212
+ assignee?: string
213
+ }): Promise<TicketListing> {
183
214
  const labels = criteria.labels ?? []
184
- const state = criteria.state === undefined ? undefined : stateDir(criteria.state)
215
+ const state =
216
+ criteria.state === undefined ? undefined : resolveState(criteria.state, await this.states())
185
217
  // Through listAll, not a single readdir: scan completes its layout and
186
218
  // duplicate-id checks before any per-record validation is contained. A
187
219
  // malformed record can never weaken the invariant preventing a `cp`
@@ -209,8 +241,8 @@ export class FileTicketSource implements TicketSource {
209
241
  * the claim record — no `claimedBy` field, which is what makes "claiming
210
242
  * visibly removes it from ready/" true.
211
243
  *
212
- * The guard refuses a ticket already in Doing or in this source's configured
213
- * terminal state rather than requiring one in Ready. That inversion is
244
+ * The guard refuses a ticket already in doing or in this source's configured
245
+ * terminal state rather than requiring one in ready. That inversion is
214
246
  * deliberate: a legal `[tickets] readyState = "Triage"` would otherwise
215
247
  * stall forever. Other nonterminal states remain claimable after an explicit
216
248
  * handback, while terminal work can never be reopened by claim.
@@ -224,8 +256,8 @@ export class FileTicketSource implements TicketSource {
224
256
  // this closes the list→claim window where a human could corrupt a record
225
257
  // after it was listed but before the dispatcher claims it.
226
258
  await this.loadAt(found)
227
- if (found.state === 'Doing' || found.state === this.doneState) return false
228
- await rename(found.path, this.pathIn('Doing', id))
259
+ if (found.state === 'doing' || found.state === this.doneState) return false
260
+ await rename(found.path, this.pathIn('doing', id))
229
261
  return true
230
262
  }
231
263
 
@@ -250,7 +282,7 @@ export class FileTicketSource implements TicketSource {
250
282
  /** A move, never a rewrite — which is why the body survives byte-exactly. */
251
283
  async transition(id: string, state: string): Promise<void> {
252
284
  await this.ensureLayout()
253
- const target = stateDir(state)
285
+ const target = resolveState(state, await this.states())
254
286
  const found = await this.locate(id)
255
287
  if (found === null) {
256
288
  throw new Error(`file ticket source: transition on unknown ticket "${id}"`)
@@ -270,9 +302,16 @@ export class FileTicketSource implements TicketSource {
270
302
  * or adopts the ticket carrying the same idempotency key. */
271
303
  async create(draft: TicketDraft, opts: TicketCreateOptions = {}): Promise<Ticket> {
272
304
  await this.ensureLayout()
305
+ // Resolve before anything is read or written: an unknown state touches no file.
306
+ const targetState =
307
+ opts.state === undefined ? this.createState : resolveState(opts.state, await this.states())
273
308
  const located = await this.scan()
274
309
  if (opts.idempotencyKey !== undefined) {
275
- const matches: Array<{ found: Located; front: Frontmatter; body: string }> = []
310
+ const matches: Array<{
311
+ found: Located
312
+ front: Frontmatter
313
+ body: string
314
+ }> = []
276
315
  for (const found of located) {
277
316
  const loaded = await this.loadAt(found)
278
317
  if (loaded.front.idempotencyKey === opts.idempotencyKey) {
@@ -301,7 +340,6 @@ export class FileTicketSource implements TicketSource {
301
340
  : {}),
302
341
  ...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}),
303
342
  }
304
- const targetState = opts.state === undefined ? this.createState : stateDir(opts.state)
305
343
  await writeFile(this.pathIn(targetState, front.id), serializeTicketFile(front, draft.body))
306
344
  return this.toTicket(front, draft.body, targetState)
307
345
  }
@@ -397,22 +435,105 @@ export class FileTicketSource implements TicketSource {
397
435
  return states
398
436
  }
399
437
 
438
+ /** Every state with its ticket count and purpose. Reads directories only —
439
+ * never parses a ticket — so a malformed record cannot hide the states. */
440
+ async listStates(): Promise<TicketStateInfo[]> {
441
+ const infos: TicketStateInfo[] = []
442
+ for (const name of await this.states()) infos.push(await this.stateInfo(name))
443
+ return infos
444
+ }
445
+
446
+ async addState(
447
+ name: string,
448
+ opts: { about?: string } = {},
449
+ ): Promise<{ state: TicketStateInfo; created: boolean }> {
450
+ validateStateName(name)
451
+ const about = opts.about === undefined ? undefined : opts.about.trim()
452
+ if (about !== undefined && (about === '' || /[\r\n]/.test(about))) {
453
+ throw new Error('file ticket source: a state purpose must be one nonblank line')
454
+ }
455
+ const existing = await this.states()
456
+ const clash = existing.find((s) => s !== name && s.toLowerCase() === name.toLowerCase())
457
+ if (clash !== undefined) {
458
+ throw new Error(
459
+ `file ticket source: state "${name}" collides with the existing state "${clash}" — ${STATE_NAME_RULE}, ` +
460
+ 'and state names are compared case-insensitively',
461
+ )
462
+ }
463
+ const created = !existing.includes(name)
464
+ await this.ensureLayout()
465
+ if (created) await mkdir(join(this.dir, name))
466
+ if (about !== undefined) await writeFile(join(this.dir, name, ABOUT_FILE), `${about}\n`)
467
+ return { state: await this.stateInfo(name), created }
468
+ }
469
+
470
+ private async stateInfo(name: string): Promise<TicketStateInfo> {
471
+ const tickets = (await this.readMarkdown(join(this.dir, name))).length
472
+ let about: string | undefined
473
+ try {
474
+ about = (await readFile(join(this.dir, name, ABOUT_FILE), 'utf8')).trim()
475
+ } catch (error) {
476
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
477
+ }
478
+ return { name, tickets, ...(about ? { about } : {}) }
479
+ }
480
+
400
481
  /**
401
- * The four state dirs, plus — for the defaulted backlog only — a
482
+ * The four lifecycle dirs (never a custom state: `addState` is the
483
+ * explicit act), plus — for the defaulted backlog only — a
402
484
  * self-excluding `.gitignore`. Same move `src/cli/context.ts` makes for
403
485
  * `.ab/`: the dir hides itself from git in ANY repo, so the local backlog is
404
486
  * never staged, without mutating the repo's own tracked `.gitignore` as an
405
487
  * unreviewed side effect. Idempotent; called at the head of every write.
406
488
  */
407
489
  private async ensureLayout(): Promise<void> {
408
- for (const state of TICKET_STATES) {
409
- await mkdir(join(this.dir, dirName(state)), { recursive: true })
490
+ for (const state of LIFECYCLE_DIRS) {
491
+ await mkdir(join(this.dir, state), { recursive: true })
410
492
  }
411
493
  if (this.selfIgnore) await writeFile(join(this.dir, '.gitignore'), '*\n')
412
494
  }
413
495
 
414
- private pathIn(state: TicketState, id: string): string {
415
- return join(this.dir, dirName(state), `${this.safeId(id)}.md`)
496
+ /** The state names that exist now. Re-read per operation so a directory
497
+ * created while a long-running dispatcher is up is seen. */
498
+ private async states(): Promise<string[]> {
499
+ try {
500
+ const entries = await readdir(this.dir, { withFileTypes: true })
501
+ const directories: string[] = []
502
+ for (const entry of entries) {
503
+ if (entry.isDirectory()) directories.push(entry.name)
504
+ else if (entry.isSymbolicLink()) {
505
+ const target = await stat(join(this.dir, entry.name)).catch(() => null)
506
+ if (target?.isDirectory()) directories.push(entry.name)
507
+ }
508
+ }
509
+ return statesIn(directories)
510
+ } catch (error) {
511
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return statesIn([])
512
+ throw error
513
+ }
514
+ }
515
+
516
+ /** Constructor-time twin of `states()`: the only sync IO in this adapter. */
517
+ private statesSync(): string[] {
518
+ try {
519
+ const entries = readdirSync(this.dir, { withFileTypes: true })
520
+ const directories = entries
521
+ .filter(
522
+ (e) =>
523
+ e.isDirectory() ||
524
+ (e.isSymbolicLink() &&
525
+ statSync(join(this.dir, e.name), { throwIfNoEntry: false })?.isDirectory()),
526
+ )
527
+ .map((e) => e.name)
528
+ return statesIn(directories)
529
+ } catch (error) {
530
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return statesIn([])
531
+ throw error
532
+ }
533
+ }
534
+
535
+ private pathIn(state: string, id: string): string {
536
+ return join(this.dir, state, `${this.safeId(id)}.md`)
416
537
  }
417
538
 
418
539
  private safeId(id: string): string {
@@ -426,7 +547,7 @@ export class FileTicketSource implements TicketSource {
426
547
  private async locate(id: string): Promise<Located | null> {
427
548
  this.safeId(id)
428
549
  const hits: Located[] = []
429
- for (const state of TICKET_STATES) {
550
+ for (const state of await this.states()) {
430
551
  const path = this.pathIn(state, id)
431
552
  try {
432
553
  await stat(path)
@@ -443,14 +564,14 @@ export class FileTicketSource implements TicketSource {
443
564
  return hits[0] ?? null
444
565
  }
445
566
 
446
- /** Every ticket file across the four state dirs, sorted, duplicates rejected. */
567
+ /** Every ticket file across every state dir, sorted, duplicates rejected. */
447
568
  private async scan(): Promise<Located[]> {
448
569
  await this.assertNoLooseTickets()
449
570
  const byId = new Map<string, Located[]>()
450
- for (const state of TICKET_STATES) {
451
- for (const entry of await this.readMarkdown(join(this.dir, dirName(state)))) {
571
+ for (const state of await this.states()) {
572
+ for (const entry of await this.readMarkdown(join(this.dir, state))) {
452
573
  const id = entry.slice(0, -'.md'.length)
453
- const located = { id, state, path: join(this.dir, dirName(state), entry) }
574
+ const located = { id, state, path: join(this.dir, state, entry) }
454
575
  byId.set(id, [...(byId.get(id) ?? []), located])
455
576
  }
456
577
  }
@@ -473,7 +594,7 @@ export class FileTicketSource implements TicketSource {
473
594
  throw new Error(
474
595
  `${join(this.dir, entry)}: ticket file outside a state directory — a ticket's ` +
475
596
  `state is the directory it sits in; move it into one of: ` +
476
- `${TICKET_STATES.map(dirName).join('/, ')}/`,
597
+ `${(await this.states()).join('/, ')}/`,
477
598
  )
478
599
  }
479
600
  }
@@ -535,7 +656,7 @@ export class FileTicketSource implements TicketSource {
535
656
  return { tickets, diagnostics }
536
657
  }
537
658
 
538
- private toTicket(front: Frontmatter, body: string, state: TicketState): Ticket {
659
+ private toTicket(front: Frontmatter, body: string, state: string): Ticket {
539
660
  return {
540
661
  ref: { source: this.name, id: front.id, title: front.title },
541
662
  ...(front.idempotencyKey !== undefined ? { creationKey: front.idempotencyKey } : {}),
@@ -1,7 +1,8 @@
1
+ // Client half of the hosted ticket source (SPEC §13): the TicketSource a
2
+ // local CLI drives against a hosted deployment. The HTTP server half ships
3
+ // in `@defrex/autobuild-hosted-store-service`.
1
4
  export { HostedTicketSource } from './remote'
2
5
  export type { HostedTicketFetch, HostedTicketSourceOptions } from './remote'
3
- export { createTicketServer } from './remote-server'
4
- export type { TicketServerOptions } from './remote-server'
5
6
  export {
6
7
  HOSTED_TICKET_OPERATIONS,
7
8
  hostedTicketContextSchema,
@@ -238,10 +238,42 @@ export class LinearTicketSource implements TicketSource {
238
238
  return [...(await this.getTeamInfo('workflow states')).stateIds.keys()]
239
239
  }
240
240
 
241
- async listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing> {
241
+ /** The Linear user a `readyAssignee` value names: `me` is the API key's
242
+ * owner, anything else is an email. Null when no user matches. */
243
+ async resolveAssignee(
244
+ assignee: string,
245
+ ): Promise<{ id: string; name: string; email: string } | null> {
246
+ if (assignee === 'me') {
247
+ const data = await this.gql<{ viewer: { id: string; name: string; email: string } | null }>(
248
+ 'resolveAssignee',
249
+ 'query Viewer { viewer { id name email } }',
250
+ {},
251
+ )
252
+ return data.viewer ?? null
253
+ }
254
+ const data = await this.gql<{
255
+ users: { nodes: { id: string; name: string; email: string }[] }
256
+ }>(
257
+ 'resolveAssignee',
258
+ 'query UserByEmail($email: String!) { users(filter: { email: { eq: $email } }) { nodes { id name email } } }',
259
+ { email: assignee },
260
+ )
261
+ return data.users.nodes[0] ?? null
262
+ }
263
+
264
+ async listReady(criteria: {
265
+ labels?: string[]
266
+ state?: string
267
+ assignee?: string
268
+ }): Promise<TicketListing> {
242
269
  const filter: Record<string, unknown> = {
243
270
  team: { key: { eq: this.teamKey } },
244
271
  }
272
+ if (criteria.assignee !== undefined) {
273
+ // An unassigned issue matches neither predicate, so it is never ready.
274
+ filter.assignee =
275
+ criteria.assignee === 'me' ? { isMe: { eq: true } } : { email: { eq: criteria.assignee } }
276
+ }
245
277
  if (criteria.state !== undefined) {
246
278
  filter.state = { name: { eq: criteria.state } }
247
279
  }