@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
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: tickets
3
- description: Work this repo's local ticket tracker — create or edit a ticket, amend blockedBy dependencies, report the backlog, move work between triage/ready/doing/done, or answer "what's the status of ticket X". Use whenever the user asks about tickets, blockers, the backlog, or wants something queued for autobuild to build.
3
+ description: Work this repo's local ticket tracker — create or edit a ticket, amend blockedBy dependencies, report the backlog, triage the inbox, move work between the lifecycle directories and any repository-defined states such as the icebox, or answer "what's the status of ticket X". Use whenever the user asks about tickets, blockers, the backlog, or wants something queued for autobuild to build.
4
4
  ---
5
5
 
6
6
  # /tickets
@@ -12,7 +12,11 @@ content and blocker relationships. The default file source needs no secret.
12
12
  ## Where it lives
13
13
 
14
14
  `.autobuild/tickets/` by default (if `autobuild.toml` has a `[tickets] dir`,
15
- that directory instead), holding exactly four state directories:
15
+ that directory instead), holding the four lifecycle state directories plus any others this repo adds.
16
+ `triage/`, `ready/`, `doing/`, and `done/` are the lifecycle directories the
17
+ dispatcher uses. Any other directory is a repository-defined state: the
18
+ dispatcher never moves tickets into it and never dispatches from it unless
19
+ `autobuild.toml` names it (for example `readyState` or `proposalState`).
16
20
 
17
21
  ```
18
22
  .autobuild/tickets/
@@ -20,6 +24,7 @@ that directory instead), holding exactly four state directories:
20
24
  ready/ # groomed; file lifecycle-state gate satisfied
21
25
  doing/ # claimed; a build is running
22
26
  done/ # merged
27
+ icebox/ # example of a repo-added state, present only once created
23
28
  ```
24
29
 
25
30
  **A ticket's state is the directory it is in.** There is no `state` field
@@ -71,13 +76,17 @@ retry, and adding validates the blocker exists and is not the ticket itself.
71
76
  ## Report the backlog
72
77
 
73
78
  ```
79
+ ab ticket states # every state, lifecycle and custom: role, count, purpose
74
80
  ls .autobuild/tickets/ready # groomed candidates in the ready lifecycle state
75
81
  ls .autobuild/tickets/doing # what's building right now
76
82
  ```
77
83
 
78
84
  The filename is the id. For a title, read the file's frontmatter. To report
79
- the whole backlog, list all four directories — that listing is complete and
80
- current by construction.
85
+ the whole backlog, cover every state — lifecycle and custom — via
86
+ `ab ticket states` or by listing every directory under the tracker root, so the
87
+ report includes `icebox/` and any other category. `ab ticket list` without
88
+ filters shows only the ready state, regardless of custom states, so it is not a
89
+ backlog report.
81
90
 
82
91
  ## Groom / transition a ticket
83
92
 
@@ -113,6 +122,49 @@ bounces.
113
122
  **Don't hand-move anything out of `doing/`** — a build owns it. If you need to
114
123
  stop a build, that's `ab` (or a human), not `mv`.
115
124
 
125
+ ## Icebox
126
+
127
+ `icebox/` is work deliberately deferred, groomed or not, that nothing
128
+ dispatches from. The first time it is needed, create it:
129
+
130
+ ```
131
+ ab ticket state create icebox --about "Deferred on purpose; nothing dispatches from here"
132
+ ```
133
+
134
+ Move a ticket in with `ab ticket move file-3 icebox`. To revive one, move it to
135
+ `triage/` when it needs regrooming, or to `ready/` when its spec still holds.
136
+
137
+ ## Triage the backlog
138
+
139
+ To run a pass over `triage/`:
140
+
141
+ 1. Run `ab ticket states` to see which destinations exist.
142
+ 2. For each ticket in `triage/`, read it with `ab ticket show <id>` and judge
143
+ whether its body meets the
144
+ [spec standard](../ab-guide/references/spec-standard.md).
145
+ 3. Move it to `ready/` if it conforms and should be built; to `icebox/` if it
146
+ should wait; to another existing category if one fits; otherwise leave it in
147
+ `triage/` and tell the user why.
148
+ 4. Report every move to the user in the session — id, title, destination, and
149
+ reason. A move leaves no comment on the ticket, so the report is the only
150
+ record of why.
151
+
152
+ ## Inventing categories
153
+
154
+ Run `ab ticket states` before creating anything. Reuse an existing state whose
155
+ purpose fits. Create a new one only for a recurring reason that no existing
156
+ state covers, with a lowercase hyphenated noun name and a one-line `--about`
157
+ purpose. `icebox` is the first worked example; `rejected` is a second:
158
+
159
+ ```
160
+ ab ticket state create rejected --about "Considered and declined; kept for the record"
161
+ ```
162
+
163
+ Never invent a second ready, doing, or done state, never move anything out of
164
+ `doing/`, and never create states for a build's internal progress — the
165
+ lifecycle belongs to the dispatcher. Remember that `ab ticket list` without
166
+ filters still shows only the ready state, however many custom states exist.
167
+
116
168
  ## Rules
117
169
 
118
170
  - Never edit frontmatter to change state — the directory is the state.
@@ -132,7 +184,7 @@ listing under default config. That does not make `ready/` sufficient;
132
184
  unresolved blockers are an independent dispatch gate.
133
185
 
134
186
  No Linear lifecycle UI. Directory-based lifecycle operations (`ls`, `mv`, and
135
- the four state directories) are file-tracker-only. If this repo's
187
+ the state directories) are file-tracker-only. If this repo's
136
188
  `autobuild.toml` sets `[tickets] source = "linear"`, do not apply that lifecycle
137
189
  guidance: state and labels live in Linear. The source-agnostic `ab ticket block`
138
190
  and `ab ticket unblock` commands still edit blocker relationships through the
@@ -1,373 +0,0 @@
1
- import { readdirSync } from 'node:fs'
2
- import { createRequire } from 'node:module'
3
- import { dirname, join } from 'node:path'
4
- import { Resvg } from '@resvg/resvg-js'
5
- import { distributionPath } from '../../distribution'
6
- import { graphemes } from './cells'
7
-
8
- /**
9
- * Deterministic dashboard-frame rendering.
10
- *
11
- * The dashboard deliberately emits a tiny terminal vocabulary: SGR reset,
12
- * bold/dim and six named foreground colours, plus OSC 8 hyperlinks. This
13
- * adapter accepts exactly that vocabulary. Unknown control traffic is an
14
- * error rather than evidence that merely looks plausible after bytes were
15
- * dropped.
16
- */
17
-
18
- const ESC = '\x1b'
19
- const BEL = '\x07'
20
- const FONT_FAMILY = 'DejaVu Sans Mono'
21
- const FONT_FAMILIES = [
22
- FONT_FAMILY,
23
- 'Dashboard CJK 113',
24
- 'Dashboard CJK 117',
25
- 'Dashboard CJK 118',
26
- 'Dashboard Emoji Flags',
27
- 'Dashboard Emoji Food',
28
- 'Dashboard Emoji People',
29
- ].join(', ')
30
- const FONT_SIZE = 16
31
- const CELL_WIDTH = 10
32
- const LINE_HEIGHT = 20
33
- const PADDING_X = 12
34
- const PADDING_Y = 10
35
- const BASELINE = 16
36
- const EMOJI_CLUSTER = /\p{Extended_Pictographic}|\p{Regional_Indicator}/u
37
-
38
- const PALETTE = {
39
- background: '#0d1117',
40
- foreground: '#c9d1d9',
41
- red: '#ff7b72',
42
- green: '#3fb950',
43
- yellow: '#d29922',
44
- blue: '#58a6ff',
45
- cyan: '#39c5cf',
46
- } as const
47
-
48
- type Foreground = Exclude<keyof typeof PALETTE, 'background'>
49
-
50
- interface Style {
51
- foreground: Foreground
52
- bold: boolean
53
- dim: boolean
54
- href?: string
55
- }
56
-
57
- interface TextRun {
58
- column: number
59
- cells: number
60
- text: string
61
- style: Style
62
- }
63
-
64
- interface ParsedLine {
65
- text: string
66
- cells: number
67
- runs: TextRun[]
68
- }
69
-
70
- export interface FrameImageOptions {
71
- /** Declared terminal width. Every parsed line must fit this grid. */
72
- columns: number
73
- }
74
-
75
- export interface RenderedFrameImage {
76
- /** ANSI/OSC-free text from the exact parsed cells, with a final newline. */
77
- text: string
78
- svg: string
79
- png: Uint8Array
80
- width: number
81
- height: number
82
- rows: number
83
- columns: number
84
- }
85
-
86
- function cloneStyle(style: Style): Style {
87
- return {
88
- foreground: style.foreground,
89
- bold: style.bold,
90
- dim: style.dim,
91
- ...(style.href !== undefined ? { href: style.href } : {}),
92
- }
93
- }
94
-
95
- function sameStyle(left: Style, right: Style): boolean {
96
- return (
97
- left.foreground === right.foreground &&
98
- left.bold === right.bold &&
99
- left.dim === right.dim &&
100
- left.href === right.href
101
- )
102
- }
103
-
104
- function applySgr(style: Style, raw: string, line: number): void {
105
- const codes = raw === '' ? [0] : raw.split(';').map((part) => Number(part))
106
- if (codes.some((code) => !Number.isInteger(code))) {
107
- throw new Error(`dashboard frame line ${line}: malformed SGR sequence ESC[${raw}m`)
108
- }
109
- for (const code of codes) {
110
- switch (code) {
111
- case 0:
112
- style.foreground = 'foreground'
113
- style.bold = false
114
- style.dim = false
115
- break
116
- case 1:
117
- style.bold = true
118
- break
119
- case 2:
120
- style.dim = true
121
- break
122
- case 22:
123
- style.bold = false
124
- style.dim = false
125
- break
126
- case 31:
127
- style.foreground = 'red'
128
- break
129
- case 32:
130
- style.foreground = 'green'
131
- break
132
- case 33:
133
- style.foreground = 'yellow'
134
- break
135
- case 34:
136
- style.foreground = 'blue'
137
- break
138
- case 36:
139
- style.foreground = 'cyan'
140
- break
141
- case 39:
142
- style.foreground = 'foreground'
143
- break
144
- default:
145
- throw new Error(`dashboard frame line ${line}: unsupported SGR code ${code} in ESC[${raw}m`)
146
- }
147
- }
148
- }
149
-
150
- function parseLine(value: string, lineNumber: number): ParsedLine {
151
- const style: Style = {
152
- foreground: 'foreground',
153
- bold: false,
154
- dim: false,
155
- }
156
- const runs: TextRun[] = []
157
- let text = ''
158
- let cells = 0
159
-
160
- const append = (cluster: string, width: number): void => {
161
- const previous = runs.at(-1)
162
- // ASCII runs may coalesce. Unicode clusters stay independently pinned to
163
- // their terminal cell columns so font fallback/advance cannot shift the
164
- // run that follows a wide glyph.
165
- if (
166
- previous !== undefined &&
167
- previous.column + previous.cells === cells &&
168
- /^[\x20-\x7e]*$/.test(previous.text) &&
169
- /^[\x20-\x7e]$/.test(cluster) &&
170
- sameStyle(previous.style, style)
171
- ) {
172
- previous.text += cluster
173
- previous.cells += width
174
- } else {
175
- runs.push({ column: cells, cells: width, text: cluster, style: cloneStyle(style) })
176
- }
177
- text += cluster
178
- cells += width
179
- }
180
-
181
- for (let index = 0; index < value.length; ) {
182
- if (value[index] === ESC) {
183
- const family = value[index + 1]
184
- if (family === '[') {
185
- const rest = value.slice(index + 2)
186
- const match = /^([0-9;]*)m/.exec(rest)
187
- if (match === null) {
188
- throw new Error(
189
- `dashboard frame line ${lineNumber}: unsupported or unterminated CSI sequence`,
190
- )
191
- }
192
- applySgr(style, match[1]!, lineNumber)
193
- index += 2 + match[0].length
194
- continue
195
- }
196
- if (family === ']') {
197
- const end = value.indexOf(BEL, index + 2)
198
- if (end === -1) {
199
- throw new Error(`dashboard frame line ${lineNumber}: unterminated OSC sequence`)
200
- }
201
- const payload = value.slice(index + 2, end)
202
- const match = /^8;;(.*)$/.exec(payload)
203
- if (match === null) {
204
- throw new Error(
205
- `dashboard frame line ${lineNumber}: unsupported OSC sequence ${JSON.stringify(payload)}`,
206
- )
207
- }
208
- const href = match[1]!
209
- style.href = href === '' ? undefined : href
210
- index = end + 1
211
- continue
212
- }
213
- throw new Error(
214
- `dashboard frame line ${lineNumber}: unsupported escape family ${JSON.stringify(family ?? '')}`,
215
- )
216
- }
217
-
218
- const nextEscape = value.indexOf(ESC, index)
219
- const stop = nextEscape === -1 ? value.length : nextEscape
220
- const plain = value.slice(index, stop)
221
- for (const character of plain) {
222
- const code = character.codePointAt(0)!
223
- if (code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) {
224
- throw new Error(
225
- `dashboard frame line ${lineNumber}: unsupported control U+${code
226
- .toString(16)
227
- .toUpperCase()
228
- .padStart(4, '0')}`,
229
- )
230
- }
231
- }
232
- for (const cluster of graphemes(plain)) append(cluster.text, cluster.width)
233
- index = stop
234
- }
235
-
236
- if (style.href !== undefined) {
237
- throw new Error(`dashboard frame line ${lineNumber}: OSC 8 hyperlink was not closed`)
238
- }
239
- if (style.foreground !== 'foreground' || style.bold || style.dim) {
240
- throw new Error(
241
- `dashboard frame line ${lineNumber}: SGR style was not reset before end of line`,
242
- )
243
- }
244
- return { text, cells, runs }
245
- }
246
-
247
- function xml(value: string): string {
248
- return value
249
- .replaceAll('&', '&amp;')
250
- .replaceAll('<', '&lt;')
251
- .replaceAll('>', '&gt;')
252
- .replaceAll('"', '&quot;')
253
- .replaceAll("'", '&apos;')
254
- }
255
-
256
- function svgFor(
257
- parsed: ParsedLine[],
258
- columns: number,
259
- ): {
260
- svg: string
261
- width: number
262
- height: number
263
- } {
264
- const width = PADDING_X * 2 + columns * CELL_WIDTH
265
- const height = PADDING_Y * 2 + Math.max(1, parsed.length) * LINE_HEIGHT
266
- const content: string[] = []
267
-
268
- for (const [row, line] of parsed.entries()) {
269
- for (const run of line.runs) {
270
- const x = PADDING_X + run.column * CELL_WIDTH
271
- const y = PADDING_Y + BASELINE + row * LINE_HEIGHT
272
- const opacity = run.style.dim ? ' fill-opacity="0.58"' : ''
273
- const weight = run.style.bold ? ' font-weight="700"' : ' font-weight="400"'
274
- // DejaVu's native advance is slightly narrower than the 10 px evidence
275
- // grid. Fit coalesced ASCII runs to their exact terminal cells so the
276
- // error cannot accumulate into a visible gap before a pinned Unicode
277
- // cluster. Emoji get the same exact fit at a slightly larger size: the
278
- // monochrome fallback remains readable without crossing its cell range.
279
- const emoji = EMOJI_CLUSTER.test(run.text)
280
- const geometry =
281
- /^[\x20-\x7e]+$/.test(run.text) || emoji
282
- ? ` textLength="${run.cells * CELL_WIDTH}" lengthAdjust="spacingAndGlyphs"`
283
- : ''
284
- const size = emoji ? ' font-size="18"' : ''
285
- const node =
286
- `<text x="${x}" y="${y}" fill="${PALETTE[run.style.foreground]}"` +
287
- `${weight}${opacity}${geometry}${size}>${xml(run.text)}</text>`
288
- content.push(
289
- run.style.href === undefined ? node : `<a href="${xml(run.style.href)}">${node}</a>`,
290
- )
291
- }
292
- }
293
-
294
- return {
295
- width,
296
- height,
297
- svg: [
298
- `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
299
- `<rect width="${width}" height="${height}" fill="${PALETTE.background}"/>`,
300
- `<g font-family="${FONT_FAMILIES}" font-size="${FONT_SIZE}" xml:space="preserve" text-rendering="geometricPrecision">`,
301
- ...content,
302
- '</g>',
303
- '</svg>',
304
- ].join(''),
305
- }
306
- }
307
-
308
- function fontFiles(): string[] {
309
- const require = createRequire(import.meta.url)
310
- const root = dirname(require.resolve('dejavu-fonts-ttf/package.json'))
311
- const fallbacks = distributionPath('tools', 'fonts')
312
- return [
313
- join(root, 'ttf', 'DejaVuSansMono.ttf'),
314
- join(root, 'ttf', 'DejaVuSansMono-Bold.ttf'),
315
- ...readdirSync(fallbacks)
316
- .filter((name) => name.endsWith('.ttf'))
317
- .sort()
318
- .map((name) => join(fallbacks, name)),
319
- ]
320
- }
321
-
322
- /** Parse once, then derive both evidence forms from the same exact cells. */
323
- export function renderDashboardFrameImage(
324
- lines: readonly string[],
325
- options: FrameImageOptions,
326
- ): RenderedFrameImage {
327
- if (!Number.isInteger(options.columns) || options.columns <= 0) {
328
- throw new Error(`dashboard frame columns must be a positive integer, got ${options.columns}`)
329
- }
330
- if (lines.length === 0) {
331
- throw new Error('dashboard frame is empty')
332
- }
333
- const parsed = lines.map((line, index) => parseLine(line, index + 1))
334
- for (const [index, line] of parsed.entries()) {
335
- if (line.cells > options.columns) {
336
- throw new Error(
337
- `dashboard frame line ${index + 1} is ${line.cells} cells wide, exceeding declared terminal width ${options.columns}`,
338
- )
339
- }
340
- }
341
-
342
- const { svg, width, height } = svgFor(parsed, options.columns)
343
- const rendered = new Resvg(svg, {
344
- fitTo: { mode: 'original' },
345
- background: PALETTE.background,
346
- font: {
347
- fontFiles: fontFiles(),
348
- loadSystemFonts: false,
349
- defaultFontFamily: FONT_FAMILY,
350
- monospaceFamily: FONT_FAMILY,
351
- defaultFontSize: FONT_SIZE,
352
- },
353
- shapeRendering: 2,
354
- textRendering: 2,
355
- imageRendering: 0,
356
- logLevel: 'off',
357
- }).render()
358
- if (rendered.width !== width || rendered.height !== height) {
359
- throw new Error(
360
- `dashboard PNG dimensions ${rendered.width}x${rendered.height} did not match SVG ${width}x${height}`,
361
- )
362
- }
363
-
364
- return {
365
- text: `${parsed.map((line) => line.text).join('\n')}\n`,
366
- svg,
367
- png: new Uint8Array(rendered.asPng()),
368
- width,
369
- height,
370
- rows: lines.length,
371
- columns: options.columns,
372
- }
373
- }