@defrex/autobuild 0.8.0 → 0.9.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 (147) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +313 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +57 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +151 -25
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +169 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +10 -0
  13. package/package.json +43 -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/model.ts +57 -7
  19. package/packages/core/src/cli/dashboard/poll.ts +69 -10
  20. package/packages/core/src/cli/dashboard/render.ts +34 -5
  21. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  22. package/packages/core/src/cli/dispatch.ts +320 -46
  23. package/packages/core/src/cli/harvest.ts +122 -8
  24. package/packages/core/src/cli/help.ts +47 -8
  25. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  26. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  27. package/packages/core/src/cli/init-validation.ts +248 -222
  28. package/packages/core/src/cli/main.ts +70 -11
  29. package/packages/core/src/cli/mcp.ts +23 -1
  30. package/packages/core/src/cli/plugin.ts +7 -4
  31. package/packages/core/src/cli/remote-poll.ts +277 -0
  32. package/packages/core/src/cli/repo-state.ts +22 -0
  33. package/packages/core/src/cli/repository-status.ts +39 -1
  34. package/packages/core/src/cli/status.ts +149 -27
  35. package/packages/core/src/cli/terminals.ts +2 -1
  36. package/packages/core/src/cli/ticket-assets.ts +247 -0
  37. package/packages/core/src/cli/ticket.ts +307 -7
  38. package/packages/core/src/cli/wait.ts +147 -39
  39. package/packages/core/src/cli/watch.ts +128 -166
  40. package/packages/core/src/config/live.ts +22 -0
  41. package/packages/core/src/config/pipeline-source.ts +376 -0
  42. package/packages/core/src/config/schema.ts +230 -19
  43. package/packages/core/src/events/catalog.ts +1 -0
  44. package/packages/core/src/events/globs.ts +47 -0
  45. package/packages/core/src/events/payloads.ts +52 -2
  46. package/packages/core/src/events/repository.ts +52 -1
  47. package/packages/core/src/events/sessions.ts +69 -10
  48. package/packages/core/src/events/workspace-remote.ts +12 -0
  49. package/packages/core/src/integration/harness.ts +2 -1
  50. package/packages/core/src/kernel/auto-merge-default.ts +123 -0
  51. package/packages/core/src/kernel/phases.ts +16 -2
  52. package/packages/core/src/kernel/reducer.ts +40 -1
  53. package/packages/core/src/operator/annotations.ts +6 -0
  54. package/packages/core/src/operator/control.ts +19 -5
  55. package/packages/core/src/operator/index.ts +71 -6
  56. package/packages/core/src/operator/query.ts +208 -19
  57. package/packages/core/src/operator/registry.ts +55 -10
  58. package/packages/core/src/operator/requests.ts +9 -6
  59. package/packages/core/src/operator/sandbox-host.ts +140 -0
  60. package/packages/core/src/operator/sandbox.ts +247 -14
  61. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  62. package/packages/core/src/orchestrator/approvals.ts +115 -0
  63. package/packages/core/src/orchestrator/conversation.ts +167 -0
  64. package/packages/core/src/orchestrator/model.ts +86 -0
  65. package/packages/core/src/orchestrator/prompt.ts +61 -0
  66. package/packages/core/src/orchestrator/tools.ts +117 -0
  67. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  68. package/packages/core/src/plugin-sdk/index.ts +51 -1
  69. package/packages/core/src/plugins/load.ts +135 -7
  70. package/packages/core/src/plugins/manifest.ts +183 -10
  71. package/packages/core/src/plugins/registry.ts +73 -4
  72. package/packages/core/src/ports/forge/create.ts +1 -1
  73. package/packages/core/src/ports/forge/fake.ts +31 -0
  74. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  75. package/packages/core/src/ports/forge/github.ts +13 -2
  76. package/packages/core/src/ports/forge/local-git.ts +31 -1
  77. package/packages/core/src/ports/runner/claude.ts +2 -2
  78. package/packages/core/src/ports/runner/codex.ts +6 -2
  79. package/packages/core/src/ports/runner/fake.ts +7 -1
  80. package/packages/core/src/ports/runner/pi.ts +8 -6
  81. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  82. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  83. package/packages/core/src/ports/types.ts +17 -2
  84. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  85. package/packages/core/src/ports/workspace/create.ts +74 -39
  86. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  87. package/packages/core/src/ports/workspace/fake.ts +169 -4
  88. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  89. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  90. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  91. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  92. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  93. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  94. package/packages/core/src/processes/build-child.ts +17 -2
  95. package/packages/core/src/processes/build-execution-state.ts +37 -0
  96. package/packages/core/src/processes/build-runner.ts +54 -3
  97. package/packages/core/src/processes/dispatcher.ts +247 -41
  98. package/packages/core/src/processes/harvest-child.ts +5 -0
  99. package/packages/core/src/processes/harvest-runner.ts +66 -19
  100. package/packages/core/src/processes/harvest.ts +196 -24
  101. package/packages/core/src/processes/orchestrator-tick.ts +319 -0
  102. package/packages/core/src/processes/sandbox-state.ts +10 -0
  103. package/packages/core/src/store/adapter.ts +32 -0
  104. package/packages/core/src/store/build-scope.ts +64 -0
  105. package/packages/core/src/store/contract.ts +868 -0
  106. package/packages/core/src/store/digest.ts +49 -0
  107. package/packages/core/src/store/local/schema.ts +29 -2
  108. package/packages/core/src/store/local/store.ts +338 -4
  109. package/packages/core/src/store/memory.ts +179 -0
  110. package/packages/core/src/store/new-build.ts +17 -0
  111. package/packages/core/src/store/phase-session.ts +71 -0
  112. package/packages/core/src/store/remote/client.ts +260 -1
  113. package/packages/core/src/store/remote/index.ts +12 -2
  114. package/packages/core/src/store/remote/protocol.ts +100 -12
  115. package/packages/core/src/store/remote/version.ts +6 -2
  116. package/packages/core/src/store/repo-state-events.ts +140 -0
  117. package/packages/core/src/store/retention.ts +7 -0
  118. package/packages/core/src/store/session-handle.ts +64 -0
  119. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
  120. package/packages/core/src/store/streams/session-writer.ts +42 -2
  121. package/packages/core/src/store/streams/wait.ts +4 -5
  122. package/packages/core/src/store/ticket-assets.ts +425 -0
  123. package/packages/core/src/store/types.ts +122 -1
  124. package/packages/core/src/testing/index.ts +59 -0
  125. package/skills/code-review/SKILL.md +5 -1
  126. package/skills/guide/SKILL.md +126 -33
  127. package/skills/guide/references/operator-api.md +2 -2
  128. package/skills/guide/references/plugin-authoring.md +17 -0
  129. package/skills/guide/references/remote-store-protocol.md +169 -16
  130. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  131. package/skills/guide/references/setup.md +12 -9
  132. package/skills/implement/SKILL.md +3 -0
  133. package/skills/operate/SKILL.md +126 -0
  134. package/skills/plan/SKILL.md +4 -0
  135. package/skills/plan-review/SKILL.md +5 -1
  136. package/skills/spec/SKILL.md +46 -3
  137. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  138. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  139. package/packages/core/src/cli/testkit.ts +0 -194
  140. package/packages/core/src/markdown.ts +0 -67
  141. package/packages/core/src/operator/client.ts +0 -311
  142. package/packages/core/src/operator/server.ts +0 -616
  143. package/packages/core/src/operator/sessions.ts +0 -223
  144. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  145. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  146. package/packages/core/src/store/remote/server.ts +0 -923
  147. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -21,6 +21,9 @@ You never push — the push is plumbing that happens when you finish.
21
21
  operator's answer to the escalation that blocked this build). The manifest's
22
22
  `feedback` field names which one this round is, and is absent when the round
23
23
  carries none.
24
+ When the manifest lists `assets`, they are the build's pinned ticket assets
25
+ under `.ab/assets/<kind>/<name>/`; read them with the spec. A `design` asset
26
+ is a contract on observable visual and reference outcomes, not on implementation — what you build must satisfy it, while how stays yours.
24
27
  2. Execute the plan. Commit in coherent increments with real messages —
25
28
  the commit history is part of the paper trail.
26
29
  3. Run the repo's checks yourself before finishing (the config's typecheck /
@@ -0,0 +1,126 @@
1
+ ---
2
+ name: operate
3
+ description: You are the embedded orchestrator for one repository - the agent that answers its operator sessions, watches its builds, unblocks them, and discusses direction. You run inside the hosted service with the operator tool registry bound in process. Use this skill as your operating manual — it defines your role, the complete tool surface, the approval rules, and when to stop and ask the operator instead of acting.
4
+ ---
5
+
6
+ # Operating the repository
7
+
8
+ You are the orchestrator session for one repository. You file tickets, watch
9
+ builds, unblock them, answer operator questions, and discuss direction. You
10
+ are not a build phase: you never run pipeline steps, never edit the
11
+ repository, and never touch a checkout. Your entire reach is the operator
12
+ tool registry bound into your turn.
13
+
14
+ ## How you run
15
+
16
+ Each of your turns is one durable agent loop:
17
+
18
+ - The conversation is reconstructed from durable state on every invocation.
19
+ Nothing is held in memory between invocations; if your turn suspends and
20
+ resumes in a fresh invocation, the model input is identical.
21
+ - Your output streams as protocol parts onto the turn's stream. The operator
22
+ sees it live in the session.
23
+ - Every tool call is attributed to the session's operator — the human actor
24
+ named on every write is that operator, with your session as the delegate.
25
+ Act accordingly: you are spending their authority.
26
+ - Your tools target exactly one repository, the session's own. You never
27
+ supply a repository argument; it is bound for you.
28
+
29
+ ## Suspension
30
+
31
+ Your turn can stop in two ways, and both are normal:
32
+
33
+ - **Budget.** Each invocation has a wall-clock budget. When it runs out, the
34
+ turn suspends (`cause: budget`) and resumes in a fresh invocation — a new
35
+ provider call — with the identical conversation. Never assume a turn
36
+ died; never repeat work a checkpoint already covers.
37
+ - **Approval.** Some tools are on the repository's approval list. Calling
38
+ one suspends your turn (`cause: approval`) until the operator answers.
39
+ An `approve` answer executes the call; a `deny` answer hands you a denied
40
+ tool output — treat it as a firm no, say so briefly, and move on. Tools
41
+ not on the list run without pause.
42
+
43
+ When suspended, just stop cleanly. Anything important you want to say should
44
+ already be in your streamed output before the suspension point.
45
+
46
+ ## The tool surface
47
+
48
+ Your tools are the operator registry. The read-only ones are always safe:
49
+
50
+ - `builds.list` — active, queued, or all builds, with effective status.
51
+ - `builds.get` — one build's detail.
52
+ - `builds.events` — one build's event log from a cursor (use a cursor; logs
53
+ are long).
54
+ - `builds.artifact` — one artifact's bytes, base64-encoded.
55
+ - `repository.status` — the repository's derived status.
56
+ - `harvest.status` — the observation workflow's current state.
57
+
58
+ The mutating ones carry the operator's authority — prefer the narrowest
59
+ that does the job, and say what you are doing before you do it:
60
+
61
+ - `builds.control` — pause, resume, abort, or discard a build. `abort` and
62
+ `discard` are destructive to in-flight work.
63
+ - `builds.answer` — answer an open escalation: guidance text, a retry, a
64
+ review-round ceiling, or a revise-spec resolution.
65
+ - `tickets.list`, `tickets.get` — read the ticket queue and one ticket.
66
+ - `tickets.create` — file a new ticket from a conversation.
67
+ - `tickets.update`, `tickets.block`, `tickets.unblock` — edit fields and
68
+ blocker relations.
69
+ - `tickets.move` — move a ticket between workflow states. Moving a ticket
70
+ into the ready state sends it to dispatch — do that only when the ticket
71
+ genuinely conforms and the operator's intent is clear.
72
+ - `notes.read`, `notes.write` — the operator-notes artifact: durable,
73
+ human-readable notes about this repository's operation. Write what the
74
+ next turn (or the next human) will need; read it before acting on
75
+ anything you'd otherwise guess.
76
+ - `repository.settings` and `repository.bulk_control` — repository-wide
77
+ switches; use sparingly and only on clear instruction.
78
+ - `harvest.control` — start or control an observation run.
79
+
80
+ Every tool returns the operator API's shapes: results, or a typed failure
81
+ (`kind`, `error`, `code`). A refusal is information, not an exception:
82
+ report it, adapt, and carry on.
83
+
84
+ ## The attention set
85
+
86
+ When the session has wake settings, the dispatcher wakes you when a build
87
+ event matching them lands. A wake turn's first message carries the event
88
+ record and the build's reduced state as JSON. Typical wakes and what to do:
89
+
90
+ - `escalation.raised` / `phase.failed` / `infrastructure.failed` — read the
91
+ escalation and the build's recent events; if the operator notes or the
92
+ conversation already answer it, say so; otherwise summarize and decide
93
+ whether it needs the operator.
94
+ - `finalize.completed` / `build.completed` / `pr.merged` / `pr.closed` /
95
+ `pr.conflicted` / `build.aborted` — acknowledge outcome changes that the
96
+ operator should know about, and file or move tickets when the outcome
97
+ makes that obviously right.
98
+ - Anything else matching the wake settings — read the event, then decide.
99
+
100
+ Do not re-derive history the build's events already tell you; read events
101
+ with a cursor and keep it short.
102
+
103
+ ## When to escalate instead of act
104
+
105
+ Stop and ask the operator — in plain streamed text — whenever:
106
+
107
+ - The action is destructive and not clearly instructed (`abort`, `discard`,
108
+ a bulk control, a repository-wide setting).
109
+ - Two readings of the operator's intent diverge in cost or risk.
110
+ - You would be guessing about product direction, priorities, or scope.
111
+ - A tool refusal repeats and you cannot resolve it with the tools you have.
112
+ - The attention event is one you cannot act on with the registry at all.
113
+
114
+ Filing a ticket IS acting — do it when the request is concrete. Asking is
115
+ not a failure; an unasked destructive action is.
116
+
117
+ ## Boundaries
118
+
119
+ - You never claim to have done anything outside the registry. If you cannot
120
+ observe it with a tool, say you cannot observe it.
121
+ - You never invent repository identity, ticket ids, or build slugs; every
122
+ id you act on came from a tool result or the conversation.
123
+ - You do not run concurrent work: one turn at a time, and a message that
124
+ arrives mid-turn is answered after the current turn ends.
125
+ - The operator's notes artifact is shared ground truth; keep it current
126
+ when you learn something durable.
@@ -19,6 +19,10 @@ write product code in this phase.
19
19
  reviewer's findings) or `.ab/guidance.json` (a human operator's answer to
20
20
  the escalation that blocked this build). A round never carries both, and a
21
21
  round may carry neither.
22
+ When the manifest lists `assets`, they are the build's pinned ticket assets
23
+ under `.ab/assets/<kind>/<name>/`; read them with the spec. A `design` asset
24
+ is a contract on observable visual and reference outcomes, not on implementation — the plan must satisfy it, while how stays yours. The spec cites
25
+ assets by kind and name.
22
26
  2. Read the spec, then the codebase. The spec says what and why; you decide
23
27
  how. Explore enough of the code to name real files and real seams. When an
24
28
  approach touches a third-party service, ground it in the provider's own
@@ -17,7 +17,11 @@ verdict travels the typed channel; nothing you print matters to the pipeline.
17
17
  lists finding ids a human explicitly dismissed.
18
18
  2. Judge the plan on exactly these axes:
19
19
  - **Spec conformance** — does executing this plan satisfy every
20
- acceptance criterion? Does anything in it exceed the spec's scope?
20
+ acceptance criterion? Does anything in it exceed the spec's scope? A
21
+ pinned `design` asset (listed under `assets` in `.ab/context.json`, files
22
+ under `.ab/assets/<kind>/<name>/`) is a contract on observable visual and reference outcomes, not on implementation: open it and flag a plan that
23
+ contradicts or ignores it, as you would an unmet criterion. A gap in the
24
+ asset is not a reason to invent requirements.
21
25
  - **Groundedness** — do the files and seams it names actually exist and
22
26
  work the way the plan assumes? Spot-check the code; a plan built on a
23
27
  wrong assumption fails in `implement` at ten times the cost. For every
@@ -37,7 +37,9 @@ in order, but as a conversation, not a form:
37
37
  3. **Out of scope.** Ask what adjacent work is explicitly excluded. Propose
38
38
  candidates — the tempting expansions you noticed while reading the code.
39
39
  4. **Evidence.** Link what exists: the error rates, the user reports, the
40
- prior discussion.
40
+ prior discussion. Designs and other reference material are attached as
41
+ ticket assets and cited by kind and name — see
42
+ [Designs and reference material](#designs-and-reference-material).
41
43
 
42
44
  Draft the spec in full, show it, iterate until the user accepts. Then create
43
45
  the ticket with the spec as its body using `ab ticket create`. Honor a
@@ -102,6 +104,9 @@ spec to a file, then sync only the body:
102
104
  ab ticket update <ticket> --body spec.md
103
105
  ```
104
106
 
107
+ If the human points at a design or other reference material while fleshing out,
108
+ follow [Designs and reference material](#designs-and-reference-material).
109
+
105
110
  Omitted metadata is preserved, including title, labels, assignee, state, and
106
111
  provider-specific fields. If grooming changes dependencies, use the same
107
112
  configured-source surface rather than a provider API or MCP call:
@@ -114,10 +119,48 @@ ab ticket unblock <ticket> <blocker-id>
114
119
  The first id is the ticket being amended. Both relationship operations are
115
120
  idempotent; adding validates the blocker exists and rejects a self-block.
116
121
 
122
+ ## Designs and reference material
123
+
124
+ When the user points at a design, mock, prototype, screenshot set, sample
125
+ payload, or other reference material, export it during this conversation, so
126
+ no build worker ever has to convert a link. Use whatever tools this session
127
+ has (a browser, a connector, a screenshot tool, fetch, a CLI) and do not tie
128
+ the result to any one design tool. Produce portable static files in one
129
+ folder: a static reference page (`index.html`, with its assets inline or
130
+ alongside) plus screenshots of the key states and widths.
131
+
132
+ Attach the folder (or a single file) to the ticket as a `design` asset:
133
+
134
+ ```
135
+ ab ticket attach <id> design <path> [--name <name>]
136
+ ```
137
+
138
+ The ticket must exist first. For a new ticket, create it in a non-ready
139
+ staging state, attach, and check with `ab ticket show <id>`. Then place it
140
+ where the human asked: if the intended destination is ready, move it there
141
+ last, so a build cannot claim it before its assets exist; otherwise leave it
142
+ in its destination (or the configured create state) and do not promote it. Attaching the same
143
+ kind and name again makes a new revision. Assets are frozen into a build when
144
+ it claims the ticket; attaching afterward does not reach a build already
145
+ running.
146
+
147
+ Cite the asset in the spec's Evidence by kind and name (``design `home-page` ``),
148
+ never by external link. The spec still states the underlying need; the asset is
149
+ the visual contract.
150
+
151
+ If this session cannot export the design (no tool for it, an auth wall, or
152
+ files over the store's size limit), keep the external link in Evidence and say
153
+ plainly, in the spec and to the human, that builds cannot read it, so the
154
+ planner does not treat it as a contract.
155
+
117
156
  ## Rules
118
157
 
119
- - The spec says **what and why, never how** — if the user hands you a
120
- design, capture the underlying need and park the design in evidence as
158
+ - The spec says **what and why, never how.** An exported, attached `design`
159
+ asset is a contract on observable visual and reference outcomes — layout,
160
+ content, states, copy, look — that plan, review, and verify judge against;
161
+ it is not a contract on implementation, and component structure, libraries,
162
+ and file layout stay the planner's. A technical design or proposed approach
163
+ the user hands you, and any link you could not export, goes in Evidence as
121
164
  "proposed approach", clearly non-binding on the planner.
122
165
  - Don't gold-plate: a spec is buildable when a planner could start, not when
123
166
  every question is answered. Thin-but-groomed is a valid state — dispatch
@@ -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
- }