@defrex/autobuild 0.8.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 (251) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/SPEC.md +2269 -0
  4. package/bin/ab-build-runner.ts +47 -0
  5. package/bin/ab-dev.ts +27 -0
  6. package/bin/ab-dispatch-kernel.ts +92 -0
  7. package/bin/ab-harvest-runner.ts +29 -0
  8. package/bin/ab-init-probe.ts +19 -0
  9. package/bin/ab-process-group-reaper.ts +32 -0
  10. package/bin/ab.ts +6 -0
  11. package/bin/agent/ab +3 -0
  12. package/docs/answering-blocked-builds.md +89 -0
  13. package/docs/architecture.md +726 -0
  14. package/docs/assets/headline-wide.png +0 -0
  15. package/docs/configuration.md +1623 -0
  16. package/docs/hosted-dispatcher-cutover-validation.md +178 -0
  17. package/docs/hosted-dispatcher.md +204 -0
  18. package/docs/mcp.md +119 -0
  19. package/docs/operator-api.md +186 -0
  20. package/docs/remote-store-protocol.md +972 -0
  21. package/docs/remote-ticket-protocol.md +79 -0
  22. package/docs/setup.md +289 -0
  23. package/docs/spec-standard.md +63 -0
  24. package/docs/ticket-dependencies.md +59 -0
  25. package/docs/vercel-sandbox-rollout-probe.md +7 -0
  26. package/package.json +135 -0
  27. package/packages/core/src/cli/args.ts +74 -0
  28. package/packages/core/src/cli/artifact.ts +182 -0
  29. package/packages/core/src/cli/binary.ts +198 -0
  30. package/packages/core/src/cli/build-control.ts +789 -0
  31. package/packages/core/src/cli/build-progress.ts +55 -0
  32. package/packages/core/src/cli/bulk-control.ts +385 -0
  33. package/packages/core/src/cli/context.ts +470 -0
  34. package/packages/core/src/cli/dashboard/actions.ts +59 -0
  35. package/packages/core/src/cli/dashboard/cells.ts +92 -0
  36. package/packages/core/src/cli/dashboard/composer.ts +230 -0
  37. package/packages/core/src/cli/dashboard/detail.ts +100 -0
  38. package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
  39. package/packages/core/src/cli/dashboard/live.ts +152 -0
  40. package/packages/core/src/cli/dashboard/model.ts +1173 -0
  41. package/packages/core/src/cli/dashboard/poll.ts +196 -0
  42. package/packages/core/src/cli/dashboard/render.ts +1568 -0
  43. package/packages/core/src/cli/dashboard/selection.ts +66 -0
  44. package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
  45. package/packages/core/src/cli/dashboard/session-view.ts +506 -0
  46. package/packages/core/src/cli/dashboard/transcript.ts +108 -0
  47. package/packages/core/src/cli/dev.ts +46 -0
  48. package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
  49. package/packages/core/src/cli/dispatch-process.ts +259 -0
  50. package/packages/core/src/cli/dispatch.ts +3692 -0
  51. package/packages/core/src/cli/dotenv.ts +48 -0
  52. package/packages/core/src/cli/env.ts +206 -0
  53. package/packages/core/src/cli/harvest.ts +698 -0
  54. package/packages/core/src/cli/help.ts +713 -0
  55. package/packages/core/src/cli/init-agent.ts +106 -0
  56. package/packages/core/src/cli/init-validation.ts +583 -0
  57. package/packages/core/src/cli/init.ts +756 -0
  58. package/packages/core/src/cli/installation.ts +333 -0
  59. package/packages/core/src/cli/keyboard.ts +261 -0
  60. package/packages/core/src/cli/main.ts +1399 -0
  61. package/packages/core/src/cli/mcp.ts +279 -0
  62. package/packages/core/src/cli/models.ts +65 -0
  63. package/packages/core/src/cli/observe.ts +55 -0
  64. package/packages/core/src/cli/plugin.ts +227 -0
  65. package/packages/core/src/cli/pr-attachments.ts +138 -0
  66. package/packages/core/src/cli/pr-summary.ts +124 -0
  67. package/packages/core/src/cli/publication-settlement.ts +188 -0
  68. package/packages/core/src/cli/repo-state.ts +213 -0
  69. package/packages/core/src/cli/repository-status.ts +80 -0
  70. package/packages/core/src/cli/self-update.ts +443 -0
  71. package/packages/core/src/cli/status.ts +895 -0
  72. package/packages/core/src/cli/store-opening.contract.ts +289 -0
  73. package/packages/core/src/cli/store-opening.ts +167 -0
  74. package/packages/core/src/cli/store-ref.ts +33 -0
  75. package/packages/core/src/cli/terminal-restore.ts +174 -0
  76. package/packages/core/src/cli/terminal.ts +361 -0
  77. package/packages/core/src/cli/terminals.ts +995 -0
  78. package/packages/core/src/cli/testkit.ts +194 -0
  79. package/packages/core/src/cli/ticket.ts +484 -0
  80. package/packages/core/src/cli/upgrade-agent.ts +180 -0
  81. package/packages/core/src/cli/upgrade-commit.ts +468 -0
  82. package/packages/core/src/cli/upgrade-notice.ts +91 -0
  83. package/packages/core/src/cli/upgrade-progress.ts +110 -0
  84. package/packages/core/src/cli/upgrade.ts +888 -0
  85. package/packages/core/src/cli/wait.ts +587 -0
  86. package/packages/core/src/cli/watch.ts +932 -0
  87. package/packages/core/src/config/live.ts +255 -0
  88. package/packages/core/src/config/load.ts +98 -0
  89. package/packages/core/src/config/roles.ts +438 -0
  90. package/packages/core/src/config/schema.ts +774 -0
  91. package/packages/core/src/distribution.ts +24 -0
  92. package/packages/core/src/events/catalog.ts +201 -0
  93. package/packages/core/src/events/envelope.ts +59 -0
  94. package/packages/core/src/events/payloads.ts +618 -0
  95. package/packages/core/src/events/repository.ts +559 -0
  96. package/packages/core/src/events/sessions.ts +180 -0
  97. package/packages/core/src/harvest/schema.ts +147 -0
  98. package/packages/core/src/ids.ts +45 -0
  99. package/packages/core/src/integration/harness.ts +681 -0
  100. package/packages/core/src/kernel/auto-merge.ts +259 -0
  101. package/packages/core/src/kernel/converge.ts +117 -0
  102. package/packages/core/src/kernel/dispatch-settings.ts +60 -0
  103. package/packages/core/src/kernel/dispatch-status.ts +141 -0
  104. package/packages/core/src/kernel/engine.ts +1047 -0
  105. package/packages/core/src/kernel/harvest.ts +823 -0
  106. package/packages/core/src/kernel/origin.ts +69 -0
  107. package/packages/core/src/kernel/phases.ts +196 -0
  108. package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
  109. package/packages/core/src/kernel/pr-attachments.ts +82 -0
  110. package/packages/core/src/kernel/reducer.ts +752 -0
  111. package/packages/core/src/kernel/stall.ts +155 -0
  112. package/packages/core/src/kernel/verify-applicability.ts +52 -0
  113. package/packages/core/src/markdown.ts +67 -0
  114. package/packages/core/src/ontology.ts +288 -0
  115. package/packages/core/src/open-map.ts +203 -0
  116. package/packages/core/src/operator/annotations.ts +187 -0
  117. package/packages/core/src/operator/client.ts +311 -0
  118. package/packages/core/src/operator/control.ts +117 -0
  119. package/packages/core/src/operator/index.ts +62 -0
  120. package/packages/core/src/operator/presentation.ts +22 -0
  121. package/packages/core/src/operator/protocol.ts +98 -0
  122. package/packages/core/src/operator/query.ts +201 -0
  123. package/packages/core/src/operator/registry.ts +1043 -0
  124. package/packages/core/src/operator/requests.ts +138 -0
  125. package/packages/core/src/operator/sandbox.ts +458 -0
  126. package/packages/core/src/operator/server.ts +616 -0
  127. package/packages/core/src/operator/sessions.ts +223 -0
  128. package/packages/core/src/operator/tickets.ts +177 -0
  129. package/packages/core/src/plugin-sdk/index.ts +174 -0
  130. package/packages/core/src/plugins/contract-entry.ts +74 -0
  131. package/packages/core/src/plugins/contract-env.ts +3 -0
  132. package/packages/core/src/plugins/load.ts +259 -0
  133. package/packages/core/src/plugins/manifest.ts +236 -0
  134. package/packages/core/src/plugins/registry.ts +274 -0
  135. package/packages/core/src/plugins/runtimes.ts +75 -0
  136. package/packages/core/src/ports/forge/contract.ts +258 -0
  137. package/packages/core/src/ports/forge/create.ts +74 -0
  138. package/packages/core/src/ports/forge/fake.ts +488 -0
  139. package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
  140. package/packages/core/src/ports/forge/github-transport.ts +352 -0
  141. package/packages/core/src/ports/forge/github.ts +1162 -0
  142. package/packages/core/src/ports/forge/local-git.ts +568 -0
  143. package/packages/core/src/ports/runner/claude.ts +873 -0
  144. package/packages/core/src/ports/runner/cli-stream.ts +90 -0
  145. package/packages/core/src/ports/runner/codex.ts +873 -0
  146. package/packages/core/src/ports/runner/contract.ts +492 -0
  147. package/packages/core/src/ports/runner/fake.ts +213 -0
  148. package/packages/core/src/ports/runner/json-record.ts +2 -0
  149. package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
  150. package/packages/core/src/ports/runner/one-shot.ts +29 -0
  151. package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
  152. package/packages/core/src/ports/runner/pi-bridge.js +6 -0
  153. package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
  154. package/packages/core/src/ports/runner/pi.ts +617 -0
  155. package/packages/core/src/ports/runner/production.ts +74 -0
  156. package/packages/core/src/ports/runner/provider-error.ts +121 -0
  157. package/packages/core/src/ports/runner/routing.ts +234 -0
  158. package/packages/core/src/ports/runner/runtime.ts +214 -0
  159. package/packages/core/src/ports/runner/session-env.ts +34 -0
  160. package/packages/core/src/ports/runner/stream-parts.ts +103 -0
  161. package/packages/core/src/ports/tickets/contract.ts +570 -0
  162. package/packages/core/src/ports/tickets/create.ts +135 -0
  163. package/packages/core/src/ports/tickets/fake.ts +215 -0
  164. package/packages/core/src/ports/tickets/file.ts +559 -0
  165. package/packages/core/src/ports/tickets/linear.ts +811 -0
  166. package/packages/core/src/ports/tickets/operations.ts +161 -0
  167. package/packages/core/src/ports/tickets/remote-index.ts +13 -0
  168. package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
  169. package/packages/core/src/ports/tickets/remote-server.ts +185 -0
  170. package/packages/core/src/ports/tickets/remote.ts +130 -0
  171. package/packages/core/src/ports/tickets/update.ts +51 -0
  172. package/packages/core/src/ports/types.ts +528 -0
  173. package/packages/core/src/ports/workspace/build-execution.ts +123 -0
  174. package/packages/core/src/ports/workspace/contract.ts +136 -0
  175. package/packages/core/src/ports/workspace/create.ts +132 -0
  176. package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
  177. package/packages/core/src/ports/workspace/fake.ts +432 -0
  178. package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
  179. package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
  180. package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
  181. package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
  182. package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
  183. package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
  184. package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
  185. package/packages/core/src/processes/build-child-exit.ts +78 -0
  186. package/packages/core/src/processes/build-child.ts +119 -0
  187. package/packages/core/src/processes/build-execution-state.ts +95 -0
  188. package/packages/core/src/processes/build-parent-watch.ts +35 -0
  189. package/packages/core/src/processes/build-runner.ts +2614 -0
  190. package/packages/core/src/processes/dispatcher.ts +2825 -0
  191. package/packages/core/src/processes/execution-settlement.ts +171 -0
  192. package/packages/core/src/processes/harvest-child.ts +115 -0
  193. package/packages/core/src/processes/harvest-execution-state.ts +121 -0
  194. package/packages/core/src/processes/harvest-runner.ts +1656 -0
  195. package/packages/core/src/processes/harvest.ts +595 -0
  196. package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
  197. package/packages/core/src/processes/phase-failure-budget.ts +52 -0
  198. package/packages/core/src/processes/process-group-reaper.ts +35 -0
  199. package/packages/core/src/processes/process-group.ts +65 -0
  200. package/packages/core/src/processes/publication-loss.ts +62 -0
  201. package/packages/core/src/processes/publication-state.ts +93 -0
  202. package/packages/core/src/processes/sandbox-state.ts +101 -0
  203. package/packages/core/src/registry.ts +24 -0
  204. package/packages/core/src/sessions/reducer.ts +185 -0
  205. package/packages/core/src/skills.ts +7 -0
  206. package/packages/core/src/spec-standard.ts +47 -0
  207. package/packages/core/src/store/adapter.ts +80 -0
  208. package/packages/core/src/store/build-scope.ts +272 -0
  209. package/packages/core/src/store/contract.ts +2581 -0
  210. package/packages/core/src/store/local/blobs.ts +38 -0
  211. package/packages/core/src/store/local/schema.ts +180 -0
  212. package/packages/core/src/store/local/store.ts +1713 -0
  213. package/packages/core/src/store/memory.ts +1138 -0
  214. package/packages/core/src/store/phase-session.ts +425 -0
  215. package/packages/core/src/store/remote/client.ts +780 -0
  216. package/packages/core/src/store/remote/index.ts +12 -0
  217. package/packages/core/src/store/remote/protocol.ts +348 -0
  218. package/packages/core/src/store/remote/server.ts +923 -0
  219. package/packages/core/src/store/remote/token.ts +137 -0
  220. package/packages/core/src/store/remote/version.ts +10 -0
  221. package/packages/core/src/store/retention.ts +91 -0
  222. package/packages/core/src/store/session-handle.ts +265 -0
  223. package/packages/core/src/store/streams/assemble.ts +79 -0
  224. package/packages/core/src/store/streams/lock.ts +42 -0
  225. package/packages/core/src/store/streams/session-writer.ts +306 -0
  226. package/packages/core/src/store/streams/types.ts +171 -0
  227. package/packages/core/src/store/streams/wait.ts +127 -0
  228. package/packages/core/src/store/subscribe.ts +105 -0
  229. package/packages/core/src/store/types.ts +466 -0
  230. package/packages/core/src/testing/fixed.ts +44 -0
  231. package/packages/core/src/testing/store-failures.ts +32 -0
  232. package/packages/core/src/zod-issues.ts +91 -0
  233. package/skills/code-review/SKILL.md +100 -0
  234. package/skills/finalize/SKILL.md +44 -0
  235. package/skills/guide/SKILL.md +1781 -0
  236. package/skills/guide/references/operator-api.md +164 -0
  237. package/skills/guide/references/plugin-authoring.md +354 -0
  238. package/skills/guide/references/remote-store-protocol.md +972 -0
  239. package/skills/guide/references/remote-ticket-protocol.md +79 -0
  240. package/skills/guide/references/setup.md +289 -0
  241. package/skills/guide/references/spec-standard.md +63 -0
  242. package/skills/guide/references/ticket-dependencies.md +59 -0
  243. package/skills/harvest/SKILL.md +66 -0
  244. package/skills/harvest-review/SKILL.md +63 -0
  245. package/skills/implement/SKILL.md +82 -0
  246. package/skills/plan/SKILL.md +123 -0
  247. package/skills/plan-review/SKILL.md +121 -0
  248. package/skills/reconcile/SKILL.md +65 -0
  249. package/skills/spec/SKILL.md +132 -0
  250. package/skills/tickets/SKILL.md +139 -0
  251. package/templates/autobuild.toml +36 -0
@@ -0,0 +1,559 @@
1
+ /**
2
+ * File-based TicketSource (SPEC §3.2, §13): the default tracker. A directory
3
+ * of state directories — `triage/ ready/ doing/ done/` — each holding
4
+ * `<id>.md` files: TOML frontmatter between `+++` fences, then the ticket
5
+ * body, where the spec lives pre-build (§6.3). This adapter is the policy's
6
+ * proof: a source with nowhere to put blobs must be fully workable, because
7
+ * the tracker initiates and receives projections only — never consulted
8
+ * mid-build, never artifact storage.
9
+ *
10
+ * A ticket's state IS the directory it sits in. There is no `state` field to
11
+ * disagree with the filesystem, and `transition` and `claim` are rename(2).
12
+ * Moving a valid record from `triage/` to `ready/` is the complete
13
+ * lifecycle-state grooming UX; listing validates every candidate before it can
14
+ * become eligible.
15
+ *
16
+ * Concurrency: writes locate and validate immediately before a same-path
17
+ * rewrite or same-filesystem rename; there is no filesystem lock. The
18
+ * dispatcher remains the lifecycle single writer (SPEC §12), and concurrent
19
+ * human edits are explicitly last-write-wins. State renames never cross a
20
+ * filesystem: `<dir>/ready` → `<dir>/doing` is one mount by construction.
21
+ */
22
+ import { mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
23
+ import { join } from 'node:path'
24
+ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
25
+ import { z } from 'zod'
26
+ import { systemClock, type Clock } from '../../store/types'
27
+ import type {
28
+ DependencyState,
29
+ Ticket,
30
+ TicketCreateOptions,
31
+ TicketDraft,
32
+ TicketListing,
33
+ TicketSource,
34
+ TicketUpdate,
35
+ } from '../types'
36
+ import { validateTicketUpdate } from './update'
37
+
38
+ /**
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.
42
+ */
43
+ export const TICKET_STATES = ['Triage', 'Ready', 'Doing', 'Done'] as const
44
+ export type TicketState = (typeof TICKET_STATES)[number]
45
+
46
+ /** Repo-relative fallback when no selected local-state root is supplied. */
47
+ export const DEFAULT_TICKETS_DIR = '.autobuild/tickets'
48
+
49
+ /**
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.
54
+ */
55
+ export function stateDir(state: string): TicketState {
56
+ const match = TICKET_STATES.find((s) => s.toLowerCase() === state.toLowerCase())
57
+ if (match === undefined) {
58
+ throw new Error(
59
+ `file ticket source: unknown state "${state}" — this tracker's states are ` +
60
+ `the directories: ${TICKET_STATES.join(', ')}`,
61
+ )
62
+ }
63
+ return match
64
+ }
65
+
66
+ function dirName(state: TicketState): string {
67
+ return state.toLowerCase()
68
+ }
69
+
70
+ const frontmatterSchema = z.strictObject({
71
+ id: z.string().min(1),
72
+ title: z.string().min(1),
73
+ labels: z.array(z.string()).default([]),
74
+ /** Source-local blocker ids (§13). Absent ≡ no dependencies, which keeps
75
+ * every pre-existing ticket file valid. */
76
+ blockedBy: z.array(z.string()).optional(),
77
+ /** Stable external-create adoption key. It travels with the ticket across
78
+ * state-directory moves, so retries find it in any lifecycle state. */
79
+ idempotencyKey: z.string().min(1).optional(),
80
+ })
81
+ type Frontmatter = z.infer<typeof frontmatterSchema>
82
+
83
+ const OPEN_FENCE = '+++\n'
84
+ const CLOSE_FENCE = '\n+++\n'
85
+
86
+ /** A malformed record that a complete tracker scan may safely exclude. This is
87
+ * deliberately narrower than ordinary Error: filesystem failures, layout
88
+ * violations, duplicate ids, and unexpected exceptions must still abort the
89
+ * listing. */
90
+ export class TicketFileValidationError extends Error {
91
+ override readonly name = 'TicketFileValidationError'
92
+ }
93
+
94
+ function parseTicketFile(path: string, raw: string): { front: Frontmatter; body: string } {
95
+ if (!raw.startsWith(OPEN_FENCE)) {
96
+ throw new TicketFileValidationError(
97
+ `${path}: malformed ticket file — missing opening "+++" fence`,
98
+ )
99
+ }
100
+ const close = raw.indexOf(CLOSE_FENCE, OPEN_FENCE.length)
101
+ if (close === -1) {
102
+ throw new TicketFileValidationError(
103
+ `${path}: malformed ticket file — missing closing "+++" fence`,
104
+ )
105
+ }
106
+ let parsed: unknown
107
+ try {
108
+ parsed = parseToml(raw.slice(OPEN_FENCE.length, close))
109
+ } catch (error) {
110
+ const message = error instanceof Error ? error.message : String(error)
111
+ throw new TicketFileValidationError(`${path}: malformed TOML frontmatter — ${message}`)
112
+ }
113
+ const result = frontmatterSchema.safeParse(parsed)
114
+ if (!result.success) {
115
+ const issues = result.error.issues
116
+ .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
117
+ .join('; ')
118
+ throw new TicketFileValidationError(`${path}: invalid frontmatter — ${issues}`)
119
+ }
120
+ return { front: result.data, body: raw.slice(close + CLOSE_FENCE.length) }
121
+ }
122
+
123
+ function serializeTicketFile(front: Frontmatter, body: string): string {
124
+ const data: Record<string, unknown> = { id: front.id, title: front.title }
125
+ if (front.labels.length > 0) data.labels = front.labels
126
+ // Only when nonempty: a file that never declared blockers must round-trip
127
+ // byte-identically rather than sprouting `blockedBy = []` on every write.
128
+ if (front.blockedBy !== undefined && front.blockedBy.length > 0) {
129
+ data.blockedBy = front.blockedBy
130
+ }
131
+ if (front.idempotencyKey !== undefined) {
132
+ data.idempotencyKey = front.idempotencyKey
133
+ }
134
+ // smol-toml stringify ends with a newline; the closing fence follows it.
135
+ return `${OPEN_FENCE}${stringifyToml(data)}+++\n${body}`
136
+ }
137
+
138
+ /** A ticket file found on disk: its id, its state (= its directory), its path. */
139
+ interface Located {
140
+ id: string
141
+ state: TicketState
142
+ path: string
143
+ }
144
+
145
+ export class FileTicketSource implements TicketSource {
146
+ readonly name = 'file'
147
+
148
+ private readonly dir: string
149
+ private readonly clock: Clock
150
+ private readonly createState: TicketState
151
+ private readonly doneState: TicketState
152
+ private readonly selfIgnore: boolean
153
+
154
+ constructor(opts: {
155
+ dir: string
156
+ /** Injectable for deterministic comment timestamps. */
157
+ clock?: Clock
158
+ /** State assigned by create — proposals land in Triage (SPEC §12). */
159
+ createState?: string
160
+ /**
161
+ * The state this source considers complete, for dependency resolution
162
+ * (§13: this adapter's native lifecycle). Default 'Done' — and, like every
163
+ * state here, it names a directory, so it is canonicalized rather than
164
+ * compared raw.
165
+ */
166
+ doneState?: string
167
+ /**
168
+ * Write a self-excluding `<dir>/.gitignore`. Set ONLY for the defaulted
169
+ * local-state `tickets/` backlog (see createTicketSource): an explicitly
170
+ * configured dir is the user's directory, and ab does not decide git's
171
+ * view of it.
172
+ */
173
+ selfIgnore?: boolean
174
+ }) {
175
+ this.dir = opts.dir
176
+ this.clock = opts.clock ?? systemClock
177
+ this.createState = stateDir(opts.createState ?? 'Triage')
178
+ this.doneState = stateDir(opts.doneState ?? 'Done')
179
+ this.selfIgnore = opts.selfIgnore ?? false
180
+ }
181
+
182
+ async listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing> {
183
+ const labels = criteria.labels ?? []
184
+ const state = criteria.state === undefined ? undefined : stateDir(criteria.state)
185
+ // Through listAll, not a single readdir: scan completes its layout and
186
+ // duplicate-id checks before any per-record validation is contained. A
187
+ // malformed record can never weaken the invariant preventing a `cp`
188
+ // between state dirs from becoming a silent double-dispatch.
189
+ const listing = await this.listAll()
190
+ return {
191
+ tickets: listing.tickets.filter(
192
+ (ticket) =>
193
+ (state === undefined || ticket.state === state) &&
194
+ labels.every((label) => ticket.labels.includes(label)),
195
+ ),
196
+ diagnostics: listing.diagnostics,
197
+ }
198
+ }
199
+
200
+ async get(id: string): Promise<Ticket | null> {
201
+ const found = await this.locate(id)
202
+ if (found === null) return null
203
+ const loaded = await this.loadAt(found)
204
+ return this.toTicket(loaded.front, loaded.body, found.state)
205
+ }
206
+
207
+ /**
208
+ * Claim-before-launch (SPEC §12): rename into `doing/`. The relocation IS
209
+ * the claim record — no `claimedBy` field, which is what makes "claiming
210
+ * visibly removes it from ready/" true.
211
+ *
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
214
+ * deliberate: a legal `[tickets] readyState = "Triage"` would otherwise
215
+ * stall forever. Other nonterminal states remain claimable after an explicit
216
+ * handback, while terminal work can never be reopened by claim.
217
+ */
218
+ async claim(id: string): Promise<boolean> {
219
+ await this.ensureLayout()
220
+ const found = await this.locate(id)
221
+ if (found === null) return false
222
+ // Revalidate at the targeted operation boundary, even when lifecycle state
223
+ // will make the claim a no-op. Besides keeping malformed records loud,
224
+ // this closes the list→claim window where a human could corrupt a record
225
+ // after it was listed but before the dispatcher claims it.
226
+ await this.loadAt(found)
227
+ if (found.state === 'Doing' || found.state === this.doneState) return false
228
+ await rename(found.path, this.pathIn('Doing', id))
229
+ return true
230
+ }
231
+
232
+ /**
233
+ * Projections flow outward only (SPEC §13): the comment is appended below
234
+ * the existing content, which is preserved byte-exactly — the spec in the
235
+ * body (§6.3) is never rewritten.
236
+ */
237
+ async comment(id: string, body: string): Promise<void> {
238
+ await this.ensureLayout()
239
+ const found = await this.locate(id)
240
+ if (found === null) {
241
+ throw new Error(`file ticket source: comment on unknown ticket "${id}"`)
242
+ }
243
+ const raw = await readFile(found.path, 'utf8')
244
+ parseTicketFile(found.path, raw) // surface malformed files before appending
245
+ const separator = raw.endsWith('\n') ? '\n' : '\n\n'
246
+ const stamp = this.clock().toISOString()
247
+ await writeFile(found.path, `${raw}${separator}## Comment (${stamp})\n\n${body}\n`)
248
+ }
249
+
250
+ /** A move, never a rewrite — which is why the body survives byte-exactly. */
251
+ async transition(id: string, state: string): Promise<void> {
252
+ await this.ensureLayout()
253
+ const target = stateDir(state)
254
+ const found = await this.locate(id)
255
+ if (found === null) {
256
+ throw new Error(`file ticket source: transition on unknown ticket "${id}"`)
257
+ }
258
+ // Targeted writes remain loud for malformed records, including an
259
+ // otherwise-idempotent transition to the current state.
260
+ await this.loadAt(found)
261
+ // Idempotent by construction — the dispatcher retries transitions after a
262
+ // crash (file.test.ts explains the window). rename(2) would no-op here
263
+ // anyway, but resting idempotency on that POSIX subtlety would make it a
264
+ // property of the syscall rather than of this adapter.
265
+ if (found.state === target) return
266
+ await rename(found.path, this.pathIn(target, id))
267
+ }
268
+
269
+ /** Writes `<createState>/file-<n>.md` with the next free n (gaps reused),
270
+ * or adopts the ticket carrying the same idempotency key. */
271
+ async create(draft: TicketDraft, opts: TicketCreateOptions = {}): Promise<Ticket> {
272
+ await this.ensureLayout()
273
+ const located = await this.scan()
274
+ if (opts.idempotencyKey !== undefined) {
275
+ const matches: Array<{ found: Located; front: Frontmatter; body: string }> = []
276
+ for (const found of located) {
277
+ const loaded = await this.loadAt(found)
278
+ if (loaded.front.idempotencyKey === opts.idempotencyKey) {
279
+ matches.push({ found, ...loaded })
280
+ }
281
+ }
282
+ if (matches.length > 1) {
283
+ throw new Error(
284
+ `file ticket source: idempotency key "${opts.idempotencyKey}" exists on multiple tickets: ${matches.map((match) => match.found.id).join(', ')}`,
285
+ )
286
+ }
287
+ const adopted = matches[0]
288
+ if (adopted !== undefined) {
289
+ return this.toTicket(adopted.front, adopted.body, adopted.found.state)
290
+ }
291
+ }
292
+ const taken = new Set(located.map((f) => f.id))
293
+ let n = 1
294
+ while (taken.has(`file-${n}`)) n += 1
295
+ const front: Frontmatter = {
296
+ id: `file-${n}`,
297
+ title: draft.title,
298
+ labels: [...(draft.labels ?? [])],
299
+ ...(draft.blockedBy !== undefined && draft.blockedBy.length > 0
300
+ ? { blockedBy: [...draft.blockedBy] }
301
+ : {}),
302
+ ...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}),
303
+ }
304
+ const targetState = opts.state === undefined ? this.createState : stateDir(opts.state)
305
+ await writeFile(this.pathIn(targetState, front.id), serializeTicketFile(front, draft.body))
306
+ return this.toTicket(front, draft.body, targetState)
307
+ }
308
+
309
+ async update(id: string, patch: TicketUpdate): Promise<void> {
310
+ const validated = validateTicketUpdate(patch)
311
+ await this.ensureLayout()
312
+ const loaded = await this.requireForWrite(id, 'update')
313
+ const front: Frontmatter = {
314
+ ...loaded.front,
315
+ ...(validated.title !== undefined ? { title: validated.title } : {}),
316
+ ...(validated.labels !== undefined ? { labels: [...validated.labels] } : {}),
317
+ }
318
+ await this.rewriteAt(loaded.found, front, validated.body ?? loaded.body)
319
+ }
320
+
321
+ async addBlocker(id: string, blockerId: string): Promise<void> {
322
+ await this.ensureLayout()
323
+ const loaded = await this.requireForWrite(id, 'addBlocker')
324
+ if (id === blockerId) {
325
+ throw new Error(`file ticket source: ticket "${id}" cannot block itself`)
326
+ }
327
+
328
+ const blocker = await this.locate(blockerId)
329
+ if (blocker === null) {
330
+ throw new Error(
331
+ `file ticket source: addBlocker on ticket "${id}" names unknown blocker "${blockerId}"`,
332
+ )
333
+ }
334
+ await this.loadAt(blocker) // Existence means a valid ticket, not only a path.
335
+
336
+ if (loaded.front.blockedBy?.includes(blockerId)) return
337
+ await this.rewriteAt(
338
+ loaded.found,
339
+ {
340
+ ...loaded.front,
341
+ blockedBy: [...(loaded.front.blockedBy ?? []), blockerId],
342
+ },
343
+ loaded.body,
344
+ )
345
+ }
346
+
347
+ async removeBlocker(id: string, blockerId: string): Promise<void> {
348
+ await this.ensureLayout()
349
+ const loaded = await this.requireForWrite(id, 'removeBlocker')
350
+ const remaining = (loaded.front.blockedBy ?? []).filter((candidate) => candidate !== blockerId)
351
+ if (remaining.length === (loaded.front.blockedBy ?? []).length) return
352
+
353
+ const front: Frontmatter = { ...loaded.front }
354
+ if (remaining.length === 0) delete front.blockedBy
355
+ else front.blockedBy = remaining
356
+ await this.rewriteAt(loaded.found, front, loaded.body)
357
+ }
358
+
359
+ /**
360
+ * Dependency nodes (§13). Resolution is this source's own lifecycle, and
361
+ * this source's lifecycle is the filesystem: a blocker is complete when its
362
+ * file sits in the done directory. Nothing is read from the frontmatter to
363
+ * decide it — there is no `state` field to disagree with the directory.
364
+ *
365
+ * An id that cannot name a file (path traversal) is reported as
366
+ * `exists: false` rather than thrown: a bad reference is a missing
367
+ * dependency, and one ticket's typo must not abort the whole tick. A
368
+ * *malformed* file still throws — that is operator error about a real
369
+ * ticket, and the dispatcher confines the blast radius to the one ticket
370
+ * that referenced it.
371
+ */
372
+ async dependencyStates(ids: string[]): Promise<DependencyState[]> {
373
+ const states: DependencyState[] = []
374
+ for (const id of ids) {
375
+ let found: Located | null
376
+ try {
377
+ found = await this.locate(id)
378
+ } catch (error) {
379
+ if (error instanceof Error && error.message.includes('invalid ticket id')) {
380
+ states.push({ id, exists: false, resolved: false, blockedBy: [] })
381
+ continue
382
+ }
383
+ throw error
384
+ }
385
+ if (found === null) {
386
+ states.push({ id, exists: false, resolved: false, blockedBy: [] })
387
+ continue
388
+ }
389
+ const loaded = await this.loadAt(found)
390
+ states.push({
391
+ id,
392
+ exists: true,
393
+ resolved: found.state === this.doneState,
394
+ blockedBy: [...(loaded.front.blockedBy ?? [])],
395
+ })
396
+ }
397
+ return states
398
+ }
399
+
400
+ /**
401
+ * The four state dirs, plus — for the defaulted backlog only — a
402
+ * self-excluding `.gitignore`. Same move `src/cli/context.ts` makes for
403
+ * `.ab/`: the dir hides itself from git in ANY repo, so the local backlog is
404
+ * never staged, without mutating the repo's own tracked `.gitignore` as an
405
+ * unreviewed side effect. Idempotent; called at the head of every write.
406
+ */
407
+ private async ensureLayout(): Promise<void> {
408
+ for (const state of TICKET_STATES) {
409
+ await mkdir(join(this.dir, dirName(state)), { recursive: true })
410
+ }
411
+ if (this.selfIgnore) await writeFile(join(this.dir, '.gitignore'), '*\n')
412
+ }
413
+
414
+ private pathIn(state: TicketState, id: string): string {
415
+ return join(this.dir, dirName(state), `${this.safeId(id)}.md`)
416
+ }
417
+
418
+ private safeId(id: string): string {
419
+ if (id.includes('/') || id.includes('\\') || id.includes('..')) {
420
+ throw new Error(`file ticket source: invalid ticket id "${id}"`)
421
+ }
422
+ return id
423
+ }
424
+
425
+ /** Find the one state dir holding `<id>.md`; null when absent. */
426
+ private async locate(id: string): Promise<Located | null> {
427
+ this.safeId(id)
428
+ const hits: Located[] = []
429
+ for (const state of TICKET_STATES) {
430
+ const path = this.pathIn(state, id)
431
+ try {
432
+ await stat(path)
433
+ hits.push({ id, state, path })
434
+ } catch (error) {
435
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
436
+ }
437
+ }
438
+ if (hits.length > 1)
439
+ throw duplicateError(
440
+ id,
441
+ hits.map((h) => h.path),
442
+ )
443
+ return hits[0] ?? null
444
+ }
445
+
446
+ /** Every ticket file across the four state dirs, sorted, duplicates rejected. */
447
+ private async scan(): Promise<Located[]> {
448
+ await this.assertNoLooseTickets()
449
+ 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)))) {
452
+ const id = entry.slice(0, -'.md'.length)
453
+ const located = { id, state, path: join(this.dir, dirName(state), entry) }
454
+ byId.set(id, [...(byId.get(id) ?? []), located])
455
+ }
456
+ }
457
+ for (const [id, hits] of byId) {
458
+ if (hits.length > 1)
459
+ throw duplicateError(
460
+ id,
461
+ hits.map((h) => h.path),
462
+ )
463
+ }
464
+ return [...byId.values()].flat().sort((a, b) => a.id.localeCompare(b.id))
465
+ }
466
+
467
+ /**
468
+ * A `.md` at the tracker root has no state — it is invisible to every scan
469
+ * and would never dispatch. Name it rather than ignore it.
470
+ */
471
+ private async assertNoLooseTickets(): Promise<void> {
472
+ for (const entry of await this.readMarkdown(this.dir)) {
473
+ throw new Error(
474
+ `${join(this.dir, entry)}: ticket file outside a state directory — a ticket's ` +
475
+ `state is the directory it sits in; move it into one of: ` +
476
+ `${TICKET_STATES.map(dirName).join('/, ')}/`,
477
+ )
478
+ }
479
+ }
480
+
481
+ private async readMarkdown(path: string): Promise<string[]> {
482
+ try {
483
+ return (await readdir(path)).filter((e) => e.endsWith('.md')).sort()
484
+ } catch (error) {
485
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
486
+ throw error
487
+ }
488
+ }
489
+
490
+ private async loadAt(found: Located): Promise<{ front: Frontmatter; body: string }> {
491
+ const loaded = parseTicketFile(found.path, await readFile(found.path, 'utf8'))
492
+ if (loaded.front.id !== found.id) {
493
+ throw new TicketFileValidationError(
494
+ `${found.path}: frontmatter id "${loaded.front.id}" does not match filename`,
495
+ )
496
+ }
497
+ return loaded
498
+ }
499
+
500
+ private async requireForWrite(
501
+ id: string,
502
+ operation: string,
503
+ ): Promise<{ found: Located; front: Frontmatter; body: string }> {
504
+ const found = await this.locate(id)
505
+ if (found === null) {
506
+ throw new Error(`file ticket source: ${operation} on unknown ticket "${id}"`)
507
+ }
508
+ return { found, ...(await this.loadAt(found)) }
509
+ }
510
+
511
+ /** Rewrite only the located file: editable writes can never move lifecycle
512
+ * state because the existing state-directory path remains authoritative. */
513
+ private async rewriteAt(found: Located, front: Frontmatter, body: string): Promise<void> {
514
+ await writeFile(found.path, serializeTicketFile(front, body))
515
+ }
516
+
517
+ private async listAll(): Promise<TicketListing> {
518
+ const tickets: Ticket[] = []
519
+ const diagnostics: string[] = []
520
+ // Complete discovery first. Structural failures (including duplicate ids)
521
+ // therefore win even when one of the discovered records is also malformed.
522
+ const located = await this.scan()
523
+ for (const found of located) {
524
+ try {
525
+ const loaded = await this.loadAt(found)
526
+ tickets.push(this.toTicket(loaded.front, loaded.body, found.state))
527
+ } catch (error) {
528
+ if (error instanceof TicketFileValidationError) {
529
+ diagnostics.push(error.message)
530
+ continue
531
+ }
532
+ throw error
533
+ }
534
+ }
535
+ return { tickets, diagnostics }
536
+ }
537
+
538
+ private toTicket(front: Frontmatter, body: string, state: TicketState): Ticket {
539
+ return {
540
+ ref: { source: this.name, id: front.id, title: front.title },
541
+ ...(front.idempotencyKey !== undefined ? { creationKey: front.idempotencyKey } : {}),
542
+ title: front.title,
543
+ body,
544
+ state,
545
+ labels: [...front.labels],
546
+ ...(front.blockedBy !== undefined && front.blockedBy.length > 0
547
+ ? { blockedBy: [...front.blockedBy] }
548
+ : {}),
549
+ }
550
+ }
551
+ }
552
+
553
+ function duplicateError(id: string, paths: string[]): Error {
554
+ return new Error(
555
+ `file ticket source: ticket "${id}" exists in more than one state directory: ` +
556
+ `${paths.join(', ')} — a ticket's state is the directory it sits in; keep ` +
557
+ `exactly one copy (use mv, not cp)`,
558
+ )
559
+ }