@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.
- package/README.md +1 -1
- package/SPEC.md +313 -26
- package/bin/ab-build-runner.ts +8 -2
- package/docs/architecture.md +57 -14
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +151 -25
- package/docs/hosted-dispatcher.md +69 -13
- package/docs/operator-api.md +2 -2
- package/docs/remote-store-protocol.md +169 -16
- package/docs/remote-ticket-protocol.md +1 -1
- package/docs/setup.md +12 -9
- package/docs/website.md +10 -0
- package/package.json +43 -23
- package/packages/core/src/cli/artifact.ts +130 -6
- package/packages/core/src/cli/binary.ts +9 -1
- package/packages/core/src/cli/build-control.ts +110 -13
- package/packages/core/src/cli/context.ts +46 -0
- package/packages/core/src/cli/dashboard/model.ts +57 -7
- package/packages/core/src/cli/dashboard/poll.ts +69 -10
- package/packages/core/src/cli/dashboard/render.ts +34 -5
- package/packages/core/src/cli/dispatch-frontend.ts +54 -5
- package/packages/core/src/cli/dispatch.ts +320 -46
- package/packages/core/src/cli/harvest.ts +122 -8
- package/packages/core/src/cli/help.ts +47 -8
- package/packages/core/src/cli/init-readiness-remote.ts +110 -0
- package/packages/core/src/cli/init-readiness-shared.ts +72 -0
- package/packages/core/src/cli/init-validation.ts +248 -222
- package/packages/core/src/cli/main.ts +70 -11
- package/packages/core/src/cli/mcp.ts +23 -1
- package/packages/core/src/cli/plugin.ts +7 -4
- package/packages/core/src/cli/remote-poll.ts +277 -0
- package/packages/core/src/cli/repo-state.ts +22 -0
- package/packages/core/src/cli/repository-status.ts +39 -1
- package/packages/core/src/cli/status.ts +149 -27
- package/packages/core/src/cli/terminals.ts +2 -1
- package/packages/core/src/cli/ticket-assets.ts +247 -0
- package/packages/core/src/cli/ticket.ts +307 -7
- package/packages/core/src/cli/wait.ts +147 -39
- package/packages/core/src/cli/watch.ts +128 -166
- package/packages/core/src/config/live.ts +22 -0
- package/packages/core/src/config/pipeline-source.ts +376 -0
- package/packages/core/src/config/schema.ts +230 -19
- package/packages/core/src/events/catalog.ts +1 -0
- package/packages/core/src/events/globs.ts +47 -0
- package/packages/core/src/events/payloads.ts +52 -2
- package/packages/core/src/events/repository.ts +52 -1
- package/packages/core/src/events/sessions.ts +69 -10
- package/packages/core/src/events/workspace-remote.ts +12 -0
- package/packages/core/src/integration/harness.ts +2 -1
- package/packages/core/src/kernel/auto-merge-default.ts +123 -0
- package/packages/core/src/kernel/phases.ts +16 -2
- package/packages/core/src/kernel/reducer.ts +40 -1
- package/packages/core/src/operator/annotations.ts +6 -0
- package/packages/core/src/operator/control.ts +19 -5
- package/packages/core/src/operator/index.ts +71 -6
- package/packages/core/src/operator/query.ts +208 -19
- package/packages/core/src/operator/registry.ts +55 -10
- package/packages/core/src/operator/requests.ts +9 -6
- package/packages/core/src/operator/sandbox-host.ts +140 -0
- package/packages/core/src/operator/sandbox.ts +247 -14
- package/packages/core/src/operator/ticket-source-backend.ts +47 -0
- package/packages/core/src/orchestrator/approvals.ts +115 -0
- package/packages/core/src/orchestrator/conversation.ts +167 -0
- package/packages/core/src/orchestrator/model.ts +86 -0
- package/packages/core/src/orchestrator/prompt.ts +61 -0
- package/packages/core/src/orchestrator/tools.ts +117 -0
- package/packages/core/src/orchestrator/turn-runner.ts +457 -0
- package/packages/core/src/plugin-sdk/index.ts +51 -1
- package/packages/core/src/plugins/load.ts +135 -7
- package/packages/core/src/plugins/manifest.ts +183 -10
- package/packages/core/src/plugins/registry.ts +73 -4
- package/packages/core/src/ports/forge/create.ts +1 -1
- package/packages/core/src/ports/forge/fake.ts +31 -0
- package/packages/core/src/ports/forge/github-transport.ts +13 -2
- package/packages/core/src/ports/forge/github.ts +13 -2
- package/packages/core/src/ports/forge/local-git.ts +31 -1
- package/packages/core/src/ports/runner/claude.ts +2 -2
- package/packages/core/src/ports/runner/codex.ts +6 -2
- package/packages/core/src/ports/runner/fake.ts +7 -1
- package/packages/core/src/ports/runner/pi.ts +8 -6
- package/packages/core/src/ports/runner/provider-error.ts +13 -3
- package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
- package/packages/core/src/ports/types.ts +17 -2
- package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
- package/packages/core/src/ports/workspace/create.ts +74 -39
- package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
- package/packages/core/src/ports/workspace/fake.ts +169 -4
- package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
- package/packages/core/src/ports/workspace/github-origin.ts +36 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
- package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
- package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
- package/packages/core/src/processes/build-child.ts +17 -2
- package/packages/core/src/processes/build-execution-state.ts +37 -0
- package/packages/core/src/processes/build-runner.ts +54 -3
- package/packages/core/src/processes/dispatcher.ts +247 -41
- package/packages/core/src/processes/harvest-child.ts +5 -0
- package/packages/core/src/processes/harvest-runner.ts +66 -19
- package/packages/core/src/processes/harvest.ts +196 -24
- package/packages/core/src/processes/orchestrator-tick.ts +319 -0
- package/packages/core/src/processes/sandbox-state.ts +10 -0
- package/packages/core/src/store/adapter.ts +32 -0
- package/packages/core/src/store/build-scope.ts +64 -0
- package/packages/core/src/store/contract.ts +868 -0
- package/packages/core/src/store/digest.ts +49 -0
- package/packages/core/src/store/local/schema.ts +29 -2
- package/packages/core/src/store/local/store.ts +338 -4
- package/packages/core/src/store/memory.ts +179 -0
- package/packages/core/src/store/new-build.ts +17 -0
- package/packages/core/src/store/phase-session.ts +71 -0
- package/packages/core/src/store/remote/client.ts +260 -1
- package/packages/core/src/store/remote/index.ts +12 -2
- package/packages/core/src/store/remote/protocol.ts +100 -12
- package/packages/core/src/store/remote/version.ts +6 -2
- package/packages/core/src/store/repo-state-events.ts +140 -0
- package/packages/core/src/store/retention.ts +7 -0
- package/packages/core/src/store/session-handle.ts +64 -0
- package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
- package/packages/core/src/store/streams/session-writer.ts +42 -2
- package/packages/core/src/store/streams/wait.ts +4 -5
- package/packages/core/src/store/ticket-assets.ts +425 -0
- package/packages/core/src/store/types.ts +122 -1
- package/packages/core/src/testing/index.ts +59 -0
- package/skills/code-review/SKILL.md +5 -1
- package/skills/guide/SKILL.md +126 -33
- package/skills/guide/references/operator-api.md +2 -2
- package/skills/guide/references/plugin-authoring.md +17 -0
- package/skills/guide/references/remote-store-protocol.md +169 -16
- package/skills/guide/references/remote-ticket-protocol.md +1 -1
- package/skills/guide/references/setup.md +12 -9
- package/skills/implement/SKILL.md +3 -0
- package/skills/operate/SKILL.md +126 -0
- package/skills/plan/SKILL.md +4 -0
- package/skills/plan-review/SKILL.md +5 -1
- package/skills/spec/SKILL.md +46 -3
- package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
- package/packages/core/src/cli/store-opening.contract.ts +0 -289
- package/packages/core/src/cli/testkit.ts +0 -194
- package/packages/core/src/markdown.ts +0 -67
- package/packages/core/src/operator/client.ts +0 -311
- package/packages/core/src/operator/server.ts +0 -616
- package/packages/core/src/operator/sessions.ts +0 -223
- package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
- package/packages/core/src/ports/tickets/remote-server.ts +0 -185
- package/packages/core/src/store/remote/server.ts +0 -923
- 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.
|
package/skills/plan/SKILL.md
CHANGED
|
@@ -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
|
package/skills/spec/SKILL.md
CHANGED
|
@@ -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
|
|
120
|
-
|
|
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('&', '&')
|
|
250
|
-
.replaceAll('<', '<')
|
|
251
|
-
.replaceAll('>', '>')
|
|
252
|
-
.replaceAll('"', '"')
|
|
253
|
-
.replaceAll("'", ''')
|
|
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
|
-
}
|