@defrex/autobuild 0.12.0 → 0.13.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 (88) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +119 -15
  3. package/docs/architecture.md +59 -3
  4. package/docs/configuration.md +41 -9
  5. package/docs/hosted-service.md +31 -5
  6. package/docs/operator-api.md +7 -0
  7. package/docs/remote-store-protocol.md +10 -0
  8. package/docs/remote-ticket-protocol.md +151 -9
  9. package/docs/setup.md +33 -7
  10. package/docs/ticket-migration.md +64 -0
  11. package/package.json +3 -3
  12. package/packages/core/src/cli/build-control.ts +12 -1
  13. package/packages/core/src/cli/connect.ts +141 -38
  14. package/packages/core/src/cli/context.ts +7 -1
  15. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  16. package/packages/core/src/cli/dispatch.ts +156 -3
  17. package/packages/core/src/cli/harvest.ts +13 -3
  18. package/packages/core/src/cli/help.ts +14 -8
  19. package/packages/core/src/cli/installation.ts +67 -13
  20. package/packages/core/src/cli/main.ts +20 -2
  21. package/packages/core/src/cli/mcp.ts +2 -1
  22. package/packages/core/src/cli/self-update.ts +5 -1
  23. package/packages/core/src/cli/ticket-assets.ts +7 -2
  24. package/packages/core/src/cli/ticket-editor.ts +82 -0
  25. package/packages/core/src/cli/ticket.ts +164 -34
  26. package/packages/core/src/config/schema.ts +35 -3
  27. package/packages/core/src/connection.ts +3 -3
  28. package/packages/core/src/events/catalog.ts +3 -2
  29. package/packages/core/src/events/payloads.ts +6 -0
  30. package/packages/core/src/integration/harness.ts +37 -2
  31. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +15 -0
  32. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  33. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  34. package/packages/core/src/kernel/reducer-registry.ts +2 -0
  35. package/packages/core/src/ontology.ts +1 -0
  36. package/packages/core/src/operator/annotations.ts +12 -0
  37. package/packages/core/src/operator/index.ts +5 -0
  38. package/packages/core/src/operator/protocol.ts +19 -2
  39. package/packages/core/src/operator/registry.ts +61 -8
  40. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  41. package/packages/core/src/operator/tickets.ts +130 -20
  42. package/packages/core/src/plugin-sdk/index.ts +6 -0
  43. package/packages/core/src/plugins/runtimes.ts +5 -1
  44. package/packages/core/src/ports/runner/session-env.ts +2 -0
  45. package/packages/core/src/ports/tickets/contract.ts +66 -1
  46. package/packages/core/src/ports/tickets/create.ts +48 -12
  47. package/packages/core/src/ports/tickets/fake.ts +30 -1
  48. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  49. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  50. package/packages/core/src/ports/tickets/file.ts +114 -120
  51. package/packages/core/src/ports/tickets/grants.ts +47 -0
  52. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  53. package/packages/core/src/ports/tickets/identity.ts +20 -0
  54. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  55. package/packages/core/src/ports/tickets/linear.ts +74 -9
  56. package/packages/core/src/ports/tickets/migration.ts +336 -0
  57. package/packages/core/src/ports/tickets/operations.ts +38 -7
  58. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  59. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  60. package/packages/core/src/ports/tickets/remote.ts +60 -3
  61. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  62. package/packages/core/src/ports/tickets/revision.ts +62 -0
  63. package/packages/core/src/ports/tickets/states.ts +61 -0
  64. package/packages/core/src/ports/types.ts +40 -3
  65. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  66. package/packages/core/src/ports/workspace/create.ts +5 -1
  67. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  68. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  69. package/packages/core/src/processes/dispatcher.ts +110 -32
  70. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  71. package/packages/core/src/processes/harvest-child.ts +30 -4
  72. package/packages/core/src/processes/harvest-runner.ts +24 -4
  73. package/packages/core/src/processes/harvest.ts +34 -14
  74. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  75. package/packages/core/src/processes/repo-view.ts +35 -0
  76. package/packages/core/src/store/contract.ts +32 -1
  77. package/packages/core/src/store/local/store.ts +1 -1
  78. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  79. package/packages/core/src/store/ticket-assets.ts +86 -8
  80. package/packages/core/src/store/types.ts +1 -1
  81. package/skills/guide/SKILL.md +91 -11
  82. package/skills/guide/references/operator-api.md +7 -0
  83. package/skills/guide/references/remote-store-protocol.md +10 -0
  84. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  85. package/skills/guide/references/setup.md +33 -7
  86. package/skills/guide/references/ticket-migration.md +64 -0
  87. package/skills/tickets/SKILL.md +71 -0
  88. package/templates/hosted-service/package.json +2 -2
@@ -16,6 +16,8 @@ import type { TicketSource } from '../types'
16
16
  import { DEFAULT_TICKETS_DIR, FileTicketSource } from './file'
17
17
  import { LinearTicketSource } from './linear'
18
18
  import { HostedTicketSource } from './remote'
19
+ import { ticketLifecycle } from './lifecycle'
20
+ import { normalizeServiceURL } from '../../connection'
19
21
 
20
22
  function reason(error: unknown): string {
21
23
  return error instanceof Error ? error.message : String(error)
@@ -32,26 +34,41 @@ export async function createTicketSource(
32
34
  plugins: PluginRegistry = createPluginRegistry(),
33
35
  ): Promise<TicketSource> {
34
36
  if (config.source === 'hosted') {
35
- const rawUrl = env.AB_STORE?.trim() || configuredStore(targetRepo)
37
+ const buildUrl = env.AB_STORE?.trim() || configuredStore(targetRepo)
38
+ const rawUrl = config.url ?? buildUrl
36
39
  if (!rawUrl) {
37
40
  throw new Error(
38
- 'AB_STORE is not set — required when [tickets].source = "hosted"; set it to the hosted service HTTP(S) URL',
41
+ 'Hosted tickets require [tickets] url when builds are local; alternatively set [store] url or AB_STORE to a service',
39
42
  )
40
43
  }
41
44
  let serviceUrl: URL
42
45
  try {
43
- serviceUrl = new URL(rawUrl)
46
+ serviceUrl = new URL(normalizeServiceURL(rawUrl, 'tickets.url'))
44
47
  } catch {
45
- throw new Error('AB_STORE must be a valid HTTP(S) URL when [tickets].source = "hosted"')
46
- }
47
- if (serviceUrl.protocol !== 'http:' && serviceUrl.protocol !== 'https:') {
48
- throw new Error('AB_STORE must be an HTTP(S) URL when [tickets].source = "hosted"')
48
+ throw new Error(
49
+ 'Hosted tickets require [tickets] url when [store] url / AB_STORE selects local builds',
50
+ )
49
51
  }
50
- const token = projectToken(
51
- rawUrl,
52
- (await resolveRepoOrigin(targetRepo, spawnExec)) ?? targetRepo,
53
- env,
52
+ const ticketSession = env.AB_TICKET_TOKEN !== undefined || env.AB_TICKET_URL !== undefined
53
+ if (
54
+ ticketSession &&
55
+ (!env.AB_TICKET_TOKEN ||
56
+ !env.AB_TICKET_URL ||
57
+ normalizeServiceURL(env.AB_TICKET_URL) !== normalizeServiceURL(rawUrl))
54
58
  )
59
+ throw new Error('Ticket session credential does not match the selected ticket service')
60
+ const token = ticketSession
61
+ ? env.AB_TICKET_TOKEN
62
+ : projectToken(
63
+ rawUrl,
64
+ (await resolveRepoOrigin(targetRepo, spawnExec)) ?? targetRepo,
65
+ config.url &&
66
+ (!buildUrl ||
67
+ !/^https?:/.test(buildUrl) ||
68
+ normalizeServiceURL(config.url) !== normalizeServiceURL(buildUrl))
69
+ ? { ...env, AB_TOKEN: undefined }
70
+ : env,
71
+ )
55
72
  if (!token) {
56
73
  throw new Error(
57
74
  'No project credential; run ab connect (or set AB_TOKEN) — a project credential is required when [tickets].source = "hosted"',
@@ -60,13 +77,32 @@ export async function createTicketSource(
60
77
  if (config.teamKey === undefined) {
61
78
  throw new Error('[tickets].source = "hosted" requires teamKey')
62
79
  }
63
- return new HostedTicketSource({
80
+ const source = new HostedTicketSource({
64
81
  url: serviceUrl.toString(),
65
82
  token,
66
83
  teamKey: config.teamKey,
67
84
  ...(config.claimedState !== undefined ? { claimedState: config.claimedState } : {}),
68
85
  ...(config.createState !== undefined ? { createState: config.createState } : {}),
69
86
  })
87
+ // Delegated sessions may read policy, but never redefine project authority.
88
+ if (!ticketSession && !env.AB_HARVEST && !env.AB_PHASE) {
89
+ const lifecycle = ticketLifecycle(config)
90
+ await source.synchronizePolicy({
91
+ version: 1,
92
+ tickets: {
93
+ source: 'hosted',
94
+ teamKey: config.teamKey,
95
+ readyState: lifecycle.ready,
96
+ triageState: lifecycle.triage,
97
+ claimedState: lifecycle.doing,
98
+ doneState: lifecycle.done,
99
+ createState: lifecycle.create,
100
+ proposalState: lifecycle.proposal,
101
+ ...(config.readyLabels !== undefined ? { readyLabels: config.readyLabels } : {}),
102
+ },
103
+ })
104
+ }
105
+ return source
70
106
  }
71
107
 
72
108
  if (config.source === 'linear') {
@@ -1,3 +1,5 @@
1
+ import { fileRequestedIds } from './requested-id'
2
+ import { ticketRevision, checkTicketRevision } from './revision'
1
3
  /**
2
4
  * In-memory TicketSource for seam tests (SPEC §3.2, §13). Mirrors the policy
3
5
  * shape of the real adapters: initiation (listReady/get/claim/create),
@@ -12,11 +14,13 @@ import type {
12
14
  TicketListing,
13
15
  TicketSource,
14
16
  TicketUpdate,
17
+ TicketMutation,
15
18
  } from '../types'
16
19
  import { validateTicketUpdate } from './update'
17
20
 
18
21
  function cloneTicket(ticket: Ticket, doneState: string): Ticket {
19
22
  return {
23
+ revision: ticketRevision(ticket),
20
24
  ref: { ...ticket.ref },
21
25
  ...(ticket.creationKey !== undefined ? { creationKey: ticket.creationKey } : {}),
22
26
  title: ticket.title,
@@ -29,6 +33,7 @@ function cloneTicket(ticket: Ticket, doneState: string): Ticket {
29
33
  }
30
34
 
31
35
  export class FakeTicketSource implements TicketSource {
36
+ readonly requestedIds = fileRequestedIds
32
37
  readonly name = 'fake'
33
38
 
34
39
  /** Journal of every projection sent outward (§13), in call order. */
@@ -111,6 +116,7 @@ export class FakeTicketSource implements TicketSource {
111
116
  const ticket = this.tickets.get(id)
112
117
  if (!ticket || ticket.state === this.doneState || this.claimed.has(id)) return false
113
118
  this.claimed.add(id)
119
+ ticket.state = 'Doing'
114
120
  return true
115
121
  }
116
122
 
@@ -131,8 +137,10 @@ export class FakeTicketSource implements TicketSource {
131
137
  const adopted = this.idempotency.get(opts.idempotencyKey)
132
138
  if (adopted !== undefined) return cloneTicket(this.require(adopted, 'create'), this.doneState)
133
139
  }
134
- const id = `fake-${this.nextId++}`
140
+ const id = opts.requestedId ?? `fake-${this.nextId++}`
141
+ if (this.tickets.has(id)) throw new Error(`requested ticket id already occupied: ${id}`)
135
142
  const ticket: Ticket = {
143
+ revision: 'pending',
136
144
  ref: { source: this.name, id, title: draft.title },
137
145
  ...(opts.idempotencyKey !== undefined ? { creationKey: opts.idempotencyKey } : {}),
138
146
  title: draft.title,
@@ -153,6 +161,27 @@ export class FakeTicketSource implements TicketSource {
153
161
  return cloneTicket(ticket, this.doneState)
154
162
  }
155
163
 
164
+ async mutateIfRevision(id: string, expected: string, mutation: TicketMutation): Promise<void> {
165
+ const ticket = this.require(id, 'revision-checked write')
166
+ checkTicketRevision(cloneTicket(ticket, this.doneState), expected)
167
+ if (mutation.kind === 'update') await this.update(id, mutation.patch)
168
+ else if (mutation.kind === 'transition') await this.transition(id, mutation.state)
169
+ else {
170
+ for (const blocker of mutation.ids) {
171
+ this.require(blocker, 'blocker')
172
+ if (mutation.operation === 'block' && blocker === id)
173
+ throw new Error(`ticket "${id}" cannot block itself`)
174
+ }
175
+ // No await between validation and in-memory batch application.
176
+ const blockers = new Set(ticket.blockedBy ?? [])
177
+ for (const blocker of mutation.ids) {
178
+ if (mutation.operation === 'block') blockers.add(blocker)
179
+ else blockers.delete(blocker)
180
+ }
181
+ ticket.blockedBy = [...blockers]
182
+ }
183
+ }
184
+
156
185
  async update(id: string, patch: TicketUpdate): Promise<void> {
157
186
  const validated = validateTicketUpdate(patch)
158
187
  const ticket = this.require(id, 'update')
@@ -0,0 +1,159 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import type { TicketAssetStore } from '../types'
4
+ import type { Frontmatter } from './file-codec'
5
+ import { decodePortableAsset, encodePortableAsset, type PortableAsset } from './portable-assets'
6
+ import { contentHash } from '../../store/types'
7
+ import {
8
+ DEFAULT_TICKET_ASSET_LIMITS,
9
+ summarizeTicketAsset,
10
+ validateTicketAssetInput,
11
+ type TicketAsset,
12
+ type TicketAssetInput,
13
+ } from '../../store/ticket-assets'
14
+
15
+ type History = Record<string, Array<{ asset: PortableAsset | null; createdAt: string }>>
16
+ // Native revisions occupy a separate range so legacy recorded pins remain unambiguous.
17
+ const FIRST_REVISION = 1_000_000_000
18
+ export class FileTicketAssets implements TicketAssetStore {
19
+ constructor(
20
+ private dir: string,
21
+ private load: (
22
+ id: string,
23
+ ) => Promise<{ front: Frontmatter; exists?: boolean; save(front: Frontmatter): Promise<void> }>,
24
+ ) {}
25
+ async ticketAssetLimits() {
26
+ return { ...DEFAULT_TICKET_ASSET_LIMITS }
27
+ }
28
+ private key(kind: string, name: string) {
29
+ return `${kind}/${name}`
30
+ }
31
+ private path(id: string) {
32
+ return join(this.dir, '.ab-assets', `${Buffer.from(id).toString('hex')}.json`)
33
+ }
34
+ private async history(id: string, front: Frontmatter): Promise<History> {
35
+ try {
36
+ return JSON.parse(await readFile(this.path(id), 'utf8')) as History
37
+ } catch (error) {
38
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
39
+ }
40
+ return Object.fromEntries(
41
+ (front.attachments ?? []).map((asset) => [
42
+ this.key(asset.kind, asset.name),
43
+ [{ asset, createdAt: '1970-01-01T00:00:00.000Z' }],
44
+ ]),
45
+ )
46
+ }
47
+ async hasTicketAssetHistory(_repo: string, id: string, kind: string, name: string) {
48
+ const { front } = await this.load(id)
49
+ return this.key(kind, name) in (await this.history(id, front))
50
+ }
51
+ private materialize(
52
+ repo: string,
53
+ id: string,
54
+ asset: PortableAsset,
55
+ revision: number,
56
+ createdAt: string,
57
+ ): TicketAsset {
58
+ const input = decodePortableAsset(asset)
59
+ const size = validateTicketAssetInput(input)
60
+ return {
61
+ meta: {
62
+ repo,
63
+ ticketId: id,
64
+ kind: asset.kind,
65
+ name: asset.name,
66
+ layout: asset.layout,
67
+ revision,
68
+ createdAt,
69
+ size,
70
+ entries: input.entries.map((e) =>
71
+ e.type === 'dir'
72
+ ? e
73
+ : {
74
+ type: 'file',
75
+ path: e.path,
76
+ size: e.content.byteLength,
77
+ blobRef: contentHash(e.content),
78
+ },
79
+ ),
80
+ },
81
+ entries: input.entries,
82
+ }
83
+ }
84
+ async getTicketAsset(repo: string, id: string, kind: string, name: string, revision?: number) {
85
+ const { front } = await this.load(id)
86
+ const history = (await this.history(id, front))[this.key(kind, name)]
87
+ if (!history) return null
88
+ const index = revision === undefined ? history.length - 1 : revision - FIRST_REVISION
89
+ const item = history[index]
90
+ return item?.asset
91
+ ? this.materialize(repo, id, item.asset, FIRST_REVISION + index, item.createdAt)
92
+ : null
93
+ }
94
+ async listTicketAssets(repo: string, id: string) {
95
+ const { front } = await this.load(id)
96
+ const history = await this.history(id, front)
97
+ return Object.values(history).flatMap((items) => {
98
+ const item = items.at(-1)!
99
+ return item.asset
100
+ ? [
101
+ summarizeTicketAsset(
102
+ this.materialize(
103
+ repo,
104
+ id,
105
+ item.asset,
106
+ FIRST_REVISION + items.length - 1,
107
+ item.createdAt,
108
+ ).meta,
109
+ ),
110
+ ]
111
+ : []
112
+ })
113
+ }
114
+ private async persist(
115
+ id: string,
116
+ history: History,
117
+ loaded: Awaited<ReturnType<FileTicketAssets['load']>>,
118
+ ) {
119
+ const path = this.path(id)
120
+ await mkdir(join(this.dir, '.ab-assets'), { recursive: true })
121
+ const temp = `${path}.${crypto.randomUUID()}.tmp`
122
+ await writeFile(temp, JSON.stringify(history))
123
+ await rename(temp, path)
124
+ // The journal is authoritative after interruption; repair the portable projection on retry.
125
+ loaded.front.attachments = Object.values(history).flatMap((items) =>
126
+ items.at(-1)!.asset ? [items.at(-1)!.asset!] : [],
127
+ )
128
+ await loaded.save(loaded.front)
129
+ }
130
+ async putTicketAsset(repo: string, id: string, input: TicketAssetInput) {
131
+ validateTicketAssetInput(input)
132
+ const loaded = await this.load(id)
133
+ if (loaded.exists === false)
134
+ throw new Error(`file ticket source: asset on unknown ticket "${id}"`)
135
+ const history = await this.history(id, loaded.front)
136
+ const key = this.key(input.kind, input.name)
137
+ const items = history[key] ?? []
138
+ history[key] = items
139
+ const asset = encodePortableAsset(input)
140
+ const createdAt = new Date().toISOString()
141
+ items.push({ asset, createdAt })
142
+ await this.persist(id, history, loaded)
143
+ return this.materialize(repo, id, asset, FIRST_REVISION + items.length - 1, createdAt).meta
144
+ }
145
+ async removeTicketAsset(repo: string, id: string, kind: string, name: string) {
146
+ const current = await this.getTicketAsset(repo, id, kind, name)
147
+ const loaded = await this.load(id)
148
+ if (loaded.exists === false)
149
+ throw new Error(`file ticket source: asset on unknown ticket "${id}"`)
150
+ const history = await this.history(id, loaded.front)
151
+ const key = this.key(kind, name)
152
+ if (history[key]?.at(-1)?.asset === null) return null
153
+ const items = history[key] ?? []
154
+ history[key] = items
155
+ items.push({ asset: null, createdAt: new Date().toISOString() })
156
+ await this.persist(id, history, loaded)
157
+ return current?.meta ?? null
158
+ }
159
+ }
@@ -0,0 +1,90 @@
1
+ import { portableAssetSchema } from './portable-assets'
2
+ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
3
+ import { z } from 'zod'
4
+
5
+ const frontmatterSchema = z.strictObject({
6
+ comments: z
7
+ .array(
8
+ z.strictObject({ body: z.string(), createdAt: z.string(), author: z.string().optional() }),
9
+ )
10
+ .optional(),
11
+ attachments: z
12
+ .array(portableAssetSchema)
13
+ .superRefine((assets, ctx) => {
14
+ const names = new Set<string>()
15
+ for (const asset of assets) {
16
+ const key = `${asset.kind}/${asset.name}`
17
+ if (names.has(key))
18
+ ctx.addIssue({ code: 'custom', message: `duplicate attachment: ${key}` })
19
+ names.add(key)
20
+ }
21
+ })
22
+ .optional(),
23
+ id: z.string().min(1),
24
+ title: z.string().min(1),
25
+ labels: z.array(z.string()).default([]),
26
+ /** Source-local blocker ids (§13). Absent ≡ no dependencies, which keeps
27
+ * every pre-existing ticket file valid. */
28
+ blockedBy: z.array(z.string()).optional(),
29
+ /** Stable external-create adoption key. It travels with the ticket across
30
+ * state-directory moves, so retries find it in any lifecycle state. */
31
+ idempotencyKey: z.string().min(1).optional(),
32
+ })
33
+ export type Frontmatter = z.infer<typeof frontmatterSchema>
34
+
35
+ const OPEN_FENCE = '+++\n'
36
+ const CLOSE_FENCE = '\n+++\n'
37
+
38
+ /** A malformed record that a complete tracker scan may safely exclude. This is
39
+ * deliberately narrower than ordinary Error: filesystem failures, layout
40
+ * violations, duplicate ids, and unexpected exceptions must still abort the
41
+ * listing. */
42
+ export class TicketFileValidationError extends Error {
43
+ override readonly name = 'TicketFileValidationError'
44
+ }
45
+
46
+ export function parseTicketFile(path: string, raw: string): { front: Frontmatter; body: string } {
47
+ if (!raw.startsWith(OPEN_FENCE)) {
48
+ throw new TicketFileValidationError(
49
+ `${path}: malformed ticket file — missing opening "+++" fence`,
50
+ )
51
+ }
52
+ const close = raw.indexOf(CLOSE_FENCE, OPEN_FENCE.length)
53
+ if (close === -1) {
54
+ throw new TicketFileValidationError(
55
+ `${path}: malformed ticket file — missing closing "+++" fence`,
56
+ )
57
+ }
58
+ let parsed: unknown
59
+ try {
60
+ parsed = parseToml(raw.slice(OPEN_FENCE.length, close))
61
+ } catch (error) {
62
+ const message = error instanceof Error ? error.message : String(error)
63
+ throw new TicketFileValidationError(`${path}: malformed TOML frontmatter — ${message}`)
64
+ }
65
+ const result = frontmatterSchema.safeParse(parsed)
66
+ if (!result.success) {
67
+ const issues = result.error.issues
68
+ .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
69
+ .join('; ')
70
+ throw new TicketFileValidationError(`${path}: invalid frontmatter — ${issues}`)
71
+ }
72
+ return { front: result.data, body: raw.slice(close + CLOSE_FENCE.length) }
73
+ }
74
+
75
+ export function serializeTicketFile(front: Frontmatter, body: string): string {
76
+ const data: Record<string, unknown> = { id: front.id, title: front.title }
77
+ if (front.labels.length > 0) data.labels = front.labels
78
+ // Only when nonempty: a file that never declared blockers must round-trip
79
+ // byte-identically rather than sprouting `blockedBy = []` on every write.
80
+ if (front.blockedBy !== undefined && front.blockedBy.length > 0) {
81
+ data.blockedBy = front.blockedBy
82
+ }
83
+ if (front.idempotencyKey !== undefined) {
84
+ data.idempotencyKey = front.idempotencyKey
85
+ }
86
+ if (front.comments !== undefined) data.comments = front.comments
87
+ if (front.attachments !== undefined) data.attachments = front.attachments
88
+ // smol-toml stringify ends with a newline; the closing fence follows it.
89
+ return `${OPEN_FENCE}${stringifyToml(data)}+++\n${body}`
90
+ }