@lchase/kiln 0.1.0 → 0.4.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 CHANGED
@@ -1,10 +1,10 @@
1
1
  # Kiln
2
2
 
3
- Kiln runs a workflow against a git checkout. Register any repo, add a job from a markdown spec, a prompt, a directory of tickets, or a Jira Cloud issue, and tick. Tick launches ready jobs in yak worktrees. Status shows each job. Approve or reject answers a human gate.
3
+ Kiln runs a workflow against a git checkout. Register any repo, add a job from a markdown spec, a prompt, a directory of tickets, or a Jira Cloud issue, and tick. Tick launches ready jobs in yak worktrees. Status shows each job. Approve or reject answers a human gate. `kiln serve` opens that same derived state in a browser on this machine.
4
4
 
5
5
  A ready spec is the input. Kiln does not build or iterate requirements.
6
6
 
7
- This repository is public. Checkout paths and jobs stay in gitignored `.kiln/` or `~/.kiln/`. Do not commit them.
7
+ This repository is public. Checkout paths and jobs stay in `~/.kiln/` (or `$KILN_HOME` when that is set). Do not commit them.
8
8
 
9
9
  ## Install
10
10
 
@@ -14,6 +14,10 @@ kiln repo add web /absolute/path/to/web --test "npm test"
14
14
  kiln repo list
15
15
  ```
16
16
 
17
+ Run `kiln repo add <name> <path>` from any directory. The first run creates `~/.kiln/kiln.config.json`. Jobs, logs, and deliveries live in that same directory, whichever directory you run `kiln` from. Set `KILN_HOME` or pass `--config <file>` to use a different kiln home.
18
+
19
+ If `./.kiln/kiln.config.json` is still in the working directory and the home config does not exist, kiln stops. Move it with `mv ./.kiln ~/.kiln`, or pass `--config` / set `KILN_HOME`. Kiln does not move it for you.
20
+
17
21
  `kiln` is the command. The subcommands in the rest of this file are the same ones. Examples that say `npm run kiln --` are the in-checkout form.
18
22
 
19
23
  ## Develop from a checkout
@@ -47,12 +51,12 @@ npm run kiln -- repo add api /absolute/path/to/api --test "npm test"
47
51
  npm run kiln -- repo list
48
52
  ```
49
53
 
50
- `npm run kiln` builds first. `npm run kiln -- repo add` writes `./.kiln/kiln.config.json` (or updates `~/.kiln/kiln.config.json` when that file already exists). A relative path is stored relative to the config file's directory. `~` is expanded from the home directory. `test` is optional on the entry and required before tick can launch a job in that repo.
54
+ `npm run kiln` builds first. `kiln repo add <name> <path>` writes `~/.kiln/kiln.config.json`. When `KILN_HOME` is set it writes `$KILN_HOME/kiln.config.json`. `--config <file>` writes that file instead, and jobs live in the file's directory. A relative path is stored absolute, resolved from the working directory at add time. `~` is expanded from the home directory. `test` is optional on the entry and required before tick can launch a job in that repo.
51
55
 
52
56
  ```json
53
57
  {
54
58
  "repos": {
55
- "web": { "path": "../web", "test": "npm test" },
59
+ "web": { "path": "/absolute/path/to/web", "test": "npm test" },
56
60
  "api": { "path": "~/src/api", "test": "npm test" }
57
61
  },
58
62
  "maxConcurrent": 2,
@@ -64,7 +68,7 @@ npm run kiln -- repo list
64
68
 
65
69
  ## Add a job
66
70
 
67
- A job is one workflow run. The workflow name is a file `workflows/<name>.yaml`. `feature` is the factory loop. Bug, investigation, and security workflows are next. A later `pr-review` workflow is not in this repo yet.
71
+ A job is one workflow run. The workflow name is a file `workflows/<name>.yaml`. `feature` is the factory loop. `investigation` answers a spike and stops for a person to approve the findings. Bug and security workflows are next. A later `pr-review` workflow is not in this repo yet.
68
72
 
69
73
  ```bash
70
74
  npm run kiln -- add feature --repo web --spec spec.md --title "Show a Sunday summary"
@@ -72,7 +76,7 @@ npm run kiln -- add feature --repo api --spec api-spec.md --after <job-id>
72
76
  npm run kiln -- add feature --repo web --jira PROJ-123
73
77
  ```
74
78
 
75
- `--title` defaults to the first `#` heading in the spec, then the file name. `--after` may be repeated. Those jobs must already exist. Kiln copies the spec to `.kiln/specs/<id>.md` and writes `.kiln/jobs/<id>.json`:
79
+ `--title` defaults to the first `#` heading in the spec, then the file name. `--after` may be repeated. Those jobs must already exist. Kiln copies the spec to `~/.kiln/specs/<id>.md` and writes `~/.kiln/jobs/<id>.json`:
76
80
 
77
81
  ```json
78
82
  {
@@ -87,7 +91,7 @@ npm run kiln -- add feature --repo web --jira PROJ-123
87
91
  }
88
92
  ```
89
93
 
90
- `dependsOn` is omitted when the job has no `--after`. `stack: true` is set by `--stack` and is omitted otherwise. `source` records which intake created the job. Jobs written before intake omit `source` and still run. State is not stored. The pull request url is not stored on the job; Kiln writes it under `.kiln/deliveries/`.
94
+ `dependsOn` is omitted when the job has no `--after`. `stack: true` is set by `--stack` and is omitted otherwise. `source` records which intake created the job. Jobs written before intake omit `source` and still run. State is not stored. The pull request url is not stored on the job; Kiln writes it under `~/.kiln/deliveries/`.
91
95
 
92
96
  ## Intake
93
97
 
@@ -121,7 +125,7 @@ npm run kiln -- import ./tickets --repo web --workflow feature --context spec.md
121
125
 
122
126
  One file becomes one job. Blocked-by becomes `dependsOn` on the imported job ids. An unknown number is an error that names the ticket file, and no new job is written.
123
127
 
124
- `--workflow` defaults to `feature`. Kiln switches a ticket to `investigation` only when `workflows/investigation.yaml` exists and the ticket is a spike: front matter `type` is `spike`, `investigation`, or `research` (the word after a colon counts, as in `wayfinder:research`), the title contains spike or probe, or the text says "no product code". A body that only mentions an upstream spike stays on the requested workflow. `investigation` is not in this repo, so a spike ticket stays on the requested workflow and Kiln prints a warning.
128
+ `--workflow` defaults to `feature`. Kiln switches a ticket to `investigation` when the ticket is a spike: front matter `type` is `spike`, `investigation`, or `research` (the word after a colon counts, as in `wayfinder:research`), the title contains spike or probe, or the text says "no product code". A body that only mentions an upstream spike stays on the requested workflow.
125
129
 
126
130
  `--context` defaults to `spec.md` in the parent of the ticket directory when that file is present. The text is appended to every job spec as a `## Background` section. A context file larger than 40KB is not copied in. The spec names its path instead.
127
131
 
@@ -136,7 +140,7 @@ npm run kiln -- add feature --repo web --jira PROJ-123 --after <job-id> --dry-ru
136
140
 
137
141
  `--jira` reads one Jira Cloud issue over REST API v3 and writes the same job as the other intakes. `source.kind` is `jira`. `source.ref` is the issue key (`PROJ-123`). The title is `PROJ-123: <summary>`. The spec is that heading, a `Source:` line with the issue's browse URL, then the description converted from Atlassian document format to markdown.
138
142
 
139
- Set `JIRA_BASE_URL` (for example `https://acme.atlassian.net`), `JIRA_EMAIL`, and `JIRA_API_TOKEN`. Each value falls back to the optional `jira` block in the gitignored kiln config (`./.kiln/kiln.config.json`, or `~/.kiln/kiln.config.json` when the working directory has no config):
143
+ Set `JIRA_BASE_URL` (for example `https://acme.atlassian.net`), `JIRA_EMAIL`, and `JIRA_API_TOKEN`. Each value falls back to the optional `jira` block in the kiln config (`~/.kiln/kiln.config.json`, or `$KILN_HOME/kiln.config.json` when `KILN_HOME` is set, or the file passed with `--config`). Environment variables override the block:
140
144
 
141
145
  ```json
142
146
  "jira": {
@@ -148,10 +152,32 @@ Set `JIRA_BASE_URL` (for example `https://acme.atlassian.net`), `JIRA_EMAIL`, an
148
152
 
149
153
  Create a token at <https://id.atlassian.com/manage-profile/security/api-tokens>. Kiln sends it as Basic auth and does not write it into the job, the spec, or error text. A missing value is an error that names `JIRA_BASE_URL`, `JIRA_EMAIL`, or `JIRA_API_TOKEN`.
150
154
 
151
- A Blocks link where this issue is blocked by another key becomes `dependsOn` when a kiln job with `source` `jira` and that key already exists. Otherwise Kiln prints a warning naming the key and still creates the job. `--after` is merged with those ids. Issue type Spike or Research uses the same rule as a spike ticket: `investigation` when `workflows/investigation.yaml` exists, and a warning on the requested workflow when it does not. Running add again for the same key prints the existing job id and creates nothing. `--dry-run` prints the job and writes nothing.
155
+ A Blocks link where this issue is blocked by another key becomes `dependsOn` when a kiln job with `source` `jira` and that key already exists. Otherwise Kiln prints a warning naming the key and still creates the job. `--after` is merged with those ids. Issue type Spike or Research uses the same rule as a spike ticket and is imported as `investigation`. Running add again for the same key prints the existing job id and creates nothing. `--dry-run` prints the job and writes nothing.
152
156
 
153
157
  `examples/spec.md` is a fictional spec you can copy.
154
158
 
159
+ ## Serve
160
+
161
+ ```bash
162
+ kiln serve
163
+ kiln serve --port 4100 --tick 30
164
+ kiln serve --no-tick
165
+ ```
166
+
167
+ `kiln serve` listens on `127.0.0.1` only (port 4100 unless `--port` is set). It reads the same derived state as `kiln status` and calls the same approve, reject, and retry functions. Nothing new is stored.
168
+
169
+ `/` is the operator board, using the factory dashboard's dark charcoal layout. Columns are workflow stages taken from the job's yaml steps and the current yak step. Queued is first: jobs whose derived state is queued or blocked, with the subtitle `waiting · deps` and dimmer text. A blocked card names the jobs it is waiting on. Spec holds a job only while the spec step is the one running. Then Build (`builder · opus`), Review (`sec · ux · ui · code`), Approve (`approver · opus`), Land, PR review, Merged, and Needs human. An investigation adds Explore. Needs human stays at the right and holds open gates, dead runs, failures that are not waiting on an automatic retry, invalid job files, and rejections. A failed job with `nextRetryAt` still set stays in the column of the step it was on (Build, Review, and so on), dimmer, with one line `retrying 7:35 PM · attempt 1/3 · stalled` in the browser's local time. It is not in the needs-you count. When that retry starts, the card is a normal running card again. An empty column is a narrow strip with the name written down it. The header clock and any timestamps in the drawer use the browser's local time; stored values stay ISO UTC. The bottom toolbar switches between that and always-expanded columns; the choice is stored in `localStorage`. Repo chips across the top filter the cards. A card shows the job id, repo, title, branch, pull request, and one row per review round (security, ux, ui, code). A blocked card names what it is waiting on. Clicking a card opens a drawer. Approve, Reject, and Retry sit in the drawer header when the job is gated or failed. The drawer lists the spec, build, decision, each review, and investigation findings, and the selected file is rendered on the right. `/board` is the same page. The document title is `(N) Kiln`, where N is the needs-you count from the attention view (gates, dead runs, failures with no automatic retry left, and open pull requests). A job waiting out its backoff is not included, and it does not fire the desktop notification. The page polls every 3 seconds. After you opt in from the toolbar, a desktop notification fires when that count goes up. Kiln does not record factory quality averages, so the header does not invent round or human-fix numbers.
170
+
171
+ `GET /jobs/<id>/findings` renders the approved findings markdown as text. HTML in that file is escaped. The page is read-only.
172
+
173
+ `GET /api/jobs` and `GET /api/jobs/<id>` return that view as JSON, including the attention sections. `POST /api/jobs/<id>/approve`, `POST /api/jobs/<id>/reject`, and `POST /api/jobs/<id>/retry` call the same functions as the CLI. A browser POST from another origin is rejected. The page and the stylesheet ship inside the package (`assets/serve.css`, read from the package root).
174
+
175
+ `kiln retry <jobId>` relaunches a failed or dead job with the same launch tick uses, and clears `<kiln-dir>/attempts/<job-id>.json`. A landed job that only failed to open its pull request is delivered again instead. The board's Retry button calls that same function.
176
+
177
+ By default, serve runs `kiln tick` once when it starts and then every 30 seconds. `--tick <seconds>` changes the interval. `--no-tick` leaves the factory where it is. The tick uses the same lock as `kiln tick`, so a second tick exits with "another kiln tick is running" instead of launching twice. There is no intake form in this UI yet.
178
+
179
+ The Jira API token is not rendered. Do not commit checkout paths or job files.
180
+
155
181
  ## Tick, status, approve
156
182
 
157
183
  ```bash
@@ -169,7 +195,9 @@ while true; do npm run kiln -- tick; sleep 30; done
169
195
 
170
196
  A job is ready when its dependencies are met and the number of jobs in flight is under `maxConcurrent`. The default is after merged: every job in `dependsOn` is `done`. `--stack` is met when that one upstream has landed. A rejected gate does not unblock the next job. An ok run with no readable approval does not either.
171
197
 
172
- `npm run kiln -- status` lists jobs across repos. When a job has `source`, status prints `source <kind> <ref>`. For `needs-human` it prints `approve: kiln approve <id>` and `reject: kiln reject <id>`. Run those as `npm run kiln -- approve <id>` and `npm run kiln -- reject <id>`. For `done` and `in-review` it prints `branch yak/<run-id>`. For `deliver=pr` it also prints the pull request url once one exists. For `blocked` on a dependency id that has no job file, it prints `blocked on missing job <id>`. While an upstream pull request is open, it prints `waiting for PR #N to merge`.
198
+ `stallTimeoutMs` (default `900000`, 15 minutes) and `maxAttempts` (default `3`) live in the kiln config. `stallTimeoutMs` of `0` or below turns stall detection off. A running job with no yak journal event and no log output for that long is killed, process tree included, and marked failed with reason `stalled`. A process that is already gone when `kiln tick` or `kiln serve` starts is `orphaned`. An agent or launch crash is `crashed`. A non-zero exit that is not a gate rejection is `failed`. Those four retry on their own after `10s × 2^(n-1)`, capped at 5 minutes. A rejected gate, a cancelled run, and an ok run with no readable approval do not. After `maxAttempts` the job is `dead`. Each attempt is a file under `<kiln-dir>/attempts/<job-id>.json`, so state stays derived. Status prints `attempts`, `reason`, and `next retry` from the same view `readStatus` returns. On the board, a dead card in Needs human shows `attempt N/M` and the reason. A failure that still has a scheduled retry stays in its stage column instead, with `retrying` and the local time.
199
+
200
+ `npm run kiln -- status` lists jobs across repos. When a job has `source`, status prints `source <kind> <ref>`. For `needs-human` it prints `approve: kiln approve <id>` and `reject: kiln reject <id>`. Run those as `npm run kiln -- approve <id>` and `npm run kiln -- reject <id>`. For `done` and `in-review` it prints `branch yak/<run-id>`. For `deliver=pr` it also prints the pull request url once one exists. For an investigation whose findings file exists, it prints `findings <path>`. That path is `findingsPath` on the same status view `kiln serve` renders, and the console links it. For `blocked` on a dependency id that has no job file, it prints `blocked on missing job <id>`. While an upstream pull request is open, it prints `waiting for PR #N to merge`. `npm run kiln -- retry <id>` relaunches a failed or dead job, or delivers again when the only failure is opening the pull request, and clears the attempt ledger.
173
201
 
174
202
  State is derived from the yak journal, the gate files, and (for `deliver=pr`) `gh pr view`:
175
203
 
@@ -181,10 +209,10 @@ State is derived from the yak journal, the gate files, and (for `deliver=pr`) `g
181
209
  | `needs-human` | The run is suspended on an open gate |
182
210
  | `approved` | The gate says approve and the run has not finished. This still holds a slot |
183
211
  | `rejected` | The run finished ok and the gate says reject |
184
- | `failed` | The run failed, the launch died before a journal, the run finished ok with no readable approval, or a pull request could not be opened |
185
- | `dead` | The journal's process is gone. Status prints the cancel command |
212
+ | `failed` | The run failed, the launch died before a journal, the run finished ok with no readable approval, a pull request could not be opened, or a transient failure is waiting to retry |
213
+ | `dead` | The attempt budget is used up. Before the next tick, a journal whose process is already gone is also `dead`. Status prints the cancel command when a process is gone, and `kiln retry <id>` once attempts are exhausted |
186
214
  | `in-review` | `deliver=pr` and the run has landed. The pull request is open, or the next tick still has to open it |
187
- | `done` | `deliver=local`: the run finished ok and the gate says approve. `deliver=pr`: that pull request has merged |
215
+ | `done` | `deliver=local`: the run finished ok and the gate says approve. `deliver=pr`: that pull request has merged. An investigation is `done` when the gate says approve, including when the repo is `deliver=pr`. It does not enter `in-review` |
188
216
 
189
217
  ## What a feature job runs
190
218
 
@@ -196,6 +224,18 @@ Verify runs the repo's `test` command with `failOn: never`. Stdout and stderr st
196
224
 
197
225
  The first launch adds `.runs/` and `.yak/` to that checkout's `.git/info/exclude`.
198
226
 
227
+ ## What an investigation job runs
228
+
229
+ A spike, probe, or research ticket runs `workflows/investigation.yaml`. One agent explores the worktree. It may run commands and scripts. It must not change product code: the step has no Write or Edit tool, and the prompt forbids creating, editing, deleting, committing, and pushing. The agent returns a markdown document with four sections: Questions, Answers (each answer includes evidence: file paths and command output), Unconfirmed, and Implications for dependent tickets.
230
+
231
+ The run then suspends on a human gate. There is no build, review, verify, or land step. `npm run kiln -- approve <job-id>` stores that markdown at `<kiln-dir>/findings/<job-id>.md` and the job is `done`. Writing the same text again does not change the file. `npm run kiln -- reject <job-id>` is `rejected` and writes no findings file. A rejected investigation does not unblock a dependent.
232
+
233
+ `deliver=pr` does not change this. Kiln does not push the investigation branch and does not run `gh pr create`.
234
+
235
+ A later job whose `dependsOn` includes that investigation receives the findings in the spec yak sees. Kiln appends a `## Findings from <title>` section for each investigation dependency. The copy under `<kiln-dir>/specs/` is left as it was. After merged still waits until the investigation is `done`. `--stack` onto an investigation is an error: there is no branch to target.
236
+
237
+ `adapter: "mock"` reads `test/fixtures/kiln-investigation/explore.json` from the checkout. The prompt is `prompts/kiln/investigation.md`. The published package includes `prompts` and `workflows`.
238
+
199
239
  ## Delivery and dependencies
200
240
 
201
241
  `deliver` is set on the repo. The default is `local`: the job is `done` when the run lands on `yak/<run-id>`, and Kiln does not push. Existing repos and tests stay on that path.
@@ -206,7 +246,7 @@ npm run kiln -- repo add web /absolute/path/to/web --test "npm test" --deliver p
206
246
 
207
247
  `deliver` is `local` or `pr`. `base` is the pull request base branch. When `base` is omitted, Kiln uses origin's default branch, from `refs/remotes/origin/HEAD` after `git fetch`.
208
248
 
209
- With `deliver=pr`, the tick after an approved land pushes `yak/<run-id>` and runs `gh pr create --draft`. The title is the job title. The body is the spec's first paragraph, the line `Kiln job: <id>`, and the security, ux, ui, code, and approver verdicts. The branch, pull request number, and url are written to `.kiln/deliveries/<job-id>.json`, not the job file. A later tick looks up an existing pull request for that branch and does not push when origin already has the same commit. Kiln does not merge, and it does not push the base branch.
249
+ With `deliver=pr`, the tick after an approved land pushes `yak/<run-id>` and runs `gh pr create --draft`. The title is the job title. The body is the spec's first paragraph, the line `Kiln job: <id>`, and the security, ux, ui, code, and approver verdicts. The branch, pull request number, and url are written to `~/.kiln/deliveries/<job-id>.json`, not the job file. A later tick looks up an existing pull request for that branch and does not push when origin already has the same commit. Kiln does not merge, and it does not push the base branch. An investigation is not delivered this way: approve stores findings and the job is `done`.
210
250
 
211
251
  If `gh` is missing or not authenticated, that job becomes `failed` and status prints what to run (`gh auth login`, then `kiln tick`). The rest of the tick still runs. The next tick tries again.
212
252
 
@@ -216,7 +256,7 @@ If `gh` is missing or not authenticated, that job becomes `failed` and status pr
216
256
 
217
257
  The default is after merged. A job stays `blocked` until every upstream is `done`. Status says `waiting for PR #N to merge` while an upstream pull request is open. A `deliver=pr` worktree is cut from `origin/<base>` after `git fetch`, not from checkout `HEAD`.
218
258
 
219
- `--stack` on `kiln add` or `kiln import` waits for one upstream to be approved and landed, then cuts the worktree from that upstream branch (`yak/<run-id>`). The pull request targets that branch. `stack` with any count other than one dependency is an error at add time, and the upstream job has to be in the same repo. Stacked work does not wait for the upstream pull request to merge.
259
+ `--stack` on `kiln add` or `kiln import` waits for one upstream to be approved and landed, then cuts the worktree from that upstream branch (`yak/<run-id>`). The pull request targets that branch. `stack` with any count other than one dependency is an error at add time, and the upstream job has to be in the same repo. Stacking onto an investigation is an error, because that job does not land a branch. Stacked work does not wait for the upstream pull request to merge.
220
260
 
221
261
  ```bash
222
262
  npm run kiln -- add feature --repo web --spec spec.md --after <job-id> --stack
@@ -225,4 +265,4 @@ npm run kiln -- import ./tickets --repo web --stack
225
265
 
226
266
  ## Not included
227
267
 
228
- No requirement refinement. Bug, investigation, security, and pr-review workflows are not in this repo yet. Kiln does not merge pull requests and does not push the base branch.
268
+ No requirement refinement. Bug, security, and pr-review workflows are not in this repo yet. Kiln does not merge pull requests and does not push the base branch.
@@ -0,0 +1,452 @@
1
+ /* kiln-serve */
2
+ /* Tokens and layout from the factory dashboard (plugins/factory/dashboard). Dark only. */
3
+ :root {
4
+ --bg: #1c2023;
5
+ --panel: #24292d;
6
+ --card: #24292d;
7
+ --card-hover: #2d3338;
8
+ --line: #2d3338;
9
+ --line-strong: #4a5259;
10
+ --ink: #fcfcfd;
11
+ --muted: #b7bcc1;
12
+ --faint: #888e92;
13
+ --live: #4bc4c9;
14
+ --pass: #90cea7;
15
+ --changes: #ffcc61;
16
+ --human: #ff6300;
17
+ --merged: #a794ff;
18
+ --must: #f7a89c;
19
+ --primary: #5405bd;
20
+ --code: #beb3ff;
21
+ --code-bg: #2d3338;
22
+ --shadow: 0 4px 8px -2px #00000066, 0 8px 16px 0 #00000059;
23
+ --mono: "Roboto Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
24
+ }
25
+ * { box-sizing: border-box; }
26
+ [hidden] { display: none !important; }
27
+ html, body { margin: 0; }
28
+ html { background: var(--bg); }
29
+ body {
30
+ min-height: 100vh;
31
+ display: flex;
32
+ flex-direction: column;
33
+ background: var(--bg);
34
+ color: var(--ink);
35
+ font: 14px/20px Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
36
+ -webkit-font-smoothing: antialiased;
37
+ padding-bottom: 80px;
38
+ }
39
+ button { font: inherit; color: inherit; }
40
+ a { color: var(--code); }
41
+
42
+ header {
43
+ display: flex;
44
+ align-items: center;
45
+ justify-content: space-between;
46
+ gap: 16px;
47
+ flex-wrap: wrap;
48
+ min-height: 56px;
49
+ padding: 0 24px;
50
+ border-bottom: 1px solid var(--line);
51
+ }
52
+ .brand { display: flex; align-items: center; gap: 12px; }
53
+ .logo {
54
+ width: 28px;
55
+ height: 28px;
56
+ border-radius: 50%;
57
+ background: var(--primary);
58
+ display: inline-flex;
59
+ align-items: center;
60
+ justify-content: center;
61
+ }
62
+ .ver { font-size: 12px; line-height: 16px; color: var(--faint); align-self: flex-end; margin: 0 0 4px -4px; }
63
+ h1 { margin: 0; font-size: 18px; line-height: 24px; font-weight: 500; }
64
+ .status { margin-left: auto; font-family: var(--mono); font-size: 12px; line-height: 16px; color: var(--muted); }
65
+ .status::before {
66
+ content: "";
67
+ display: inline-block;
68
+ width: 8px;
69
+ height: 8px;
70
+ border-radius: 50%;
71
+ background: var(--live);
72
+ margin-right: 8px;
73
+ vertical-align: 0;
74
+ animation: pulse 1.6s ease-in-out infinite;
75
+ }
76
+ .status.err { color: var(--human); }
77
+ .status.err::before { background: var(--human); animation: none; }
78
+
79
+ .repos { display: flex; gap: 8px; flex-wrap: wrap; padding: 12px 24px 0; }
80
+ .repo-f {
81
+ display: inline-flex;
82
+ align-items: center;
83
+ gap: 6px;
84
+ height: 28px;
85
+ padding: 0 12px;
86
+ border: 1px solid var(--line-strong);
87
+ border-radius: 4px;
88
+ background: none;
89
+ color: var(--muted);
90
+ font: 12px/16px var(--mono);
91
+ cursor: pointer;
92
+ }
93
+ .repo-f:hover { background: var(--card-hover); color: var(--ink); }
94
+ .repo-f[aria-pressed="true"] {
95
+ border-color: transparent;
96
+ background: color-mix(in srgb, var(--merged) 22%, transparent);
97
+ color: var(--ink);
98
+ }
99
+ .repo-f .n { color: var(--faint); }
100
+
101
+ .board {
102
+ flex: 1;
103
+ display: grid;
104
+ grid-template-columns: var(--cols, repeat(8, minmax(0, 1fr)));
105
+ gap: 16px;
106
+ padding: 24px;
107
+ align-items: start;
108
+ }
109
+ .col { min-width: 0; }
110
+ .col-head {
111
+ display: flex;
112
+ align-items: baseline;
113
+ gap: 8px;
114
+ padding: 0 0 8px;
115
+ border-bottom: 1px solid var(--line);
116
+ margin-bottom: 12px;
117
+ }
118
+ .col-head h2 { margin: 0; font-size: 14px; line-height: 20px; font-weight: 500; white-space: nowrap; }
119
+ .col-head .who {
120
+ font-family: var(--mono);
121
+ font-size: 12px;
122
+ line-height: 16px;
123
+ color: var(--faint);
124
+ white-space: nowrap;
125
+ overflow: hidden;
126
+ text-overflow: ellipsis;
127
+ }
128
+ .col-head .count { margin-left: auto; font-family: var(--mono); font-size: 12px; color: var(--muted); }
129
+ .col.human h2 { color: var(--human); }
130
+ .col.merged h2 { color: var(--merged); }
131
+ .col.queued h2 { color: var(--muted); }
132
+ .col.queued .who,
133
+ .col.queued .count { color: var(--faint); }
134
+ .col:not(.slim) .slim-face { display: none; }
135
+ .col.slim {
136
+ align-self: stretch;
137
+ min-height: 320px;
138
+ display: flex;
139
+ flex-direction: column;
140
+ align-items: center;
141
+ gap: 12px;
142
+ padding: 12px 0;
143
+ border: 1px solid var(--line);
144
+ border-radius: 4px;
145
+ }
146
+ .col.slim .full { display: none; }
147
+ .col.slim .slim-face { display: flex; flex-direction: column; align-items: center; gap: 12px; }
148
+ .col.slim .v { writing-mode: vertical-rl; display: flex; align-items: center; gap: 8px; }
149
+ .col.slim h2 { margin: 0; font-size: 14px; line-height: 20px; font-weight: 500; white-space: nowrap; }
150
+ .col.slim .who { font-family: var(--mono); font-size: 12px; line-height: 16px; color: var(--faint); white-space: nowrap; }
151
+ .col.slim .count { font-family: var(--mono); font-size: 12px; color: var(--muted); }
152
+ .empty {
153
+ color: var(--faint);
154
+ font-size: 12px;
155
+ line-height: 16px;
156
+ padding: 20px 12px;
157
+ border: 1px dashed var(--line);
158
+ border-radius: 4px;
159
+ text-align: center;
160
+ }
161
+
162
+ .card {
163
+ display: block;
164
+ width: 100%;
165
+ text-align: left;
166
+ cursor: pointer;
167
+ background: var(--card);
168
+ border: 1px solid var(--line-strong);
169
+ border-radius: 4px;
170
+ padding: 12px;
171
+ margin-bottom: 12px;
172
+ }
173
+ .card:hover { border-color: var(--faint); box-shadow: var(--shadow); }
174
+ .card:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--merged); }
175
+ .card.human { border: 2px solid var(--human); padding: 11px; }
176
+ .card.merged { background: var(--bg); border-color: var(--line); }
177
+ .card.merged .title { color: var(--muted); }
178
+ .card.queued,
179
+ .card.retrying { background: var(--bg); border-color: var(--line); }
180
+ .card.queued .title,
181
+ .card.retrying .title { color: var(--muted); font-weight: 400; }
182
+ .card.queued .num,
183
+ .card.queued .branch,
184
+ .card.queued .note,
185
+ .card.queued .deps,
186
+ .card.retrying .num,
187
+ .card.retrying .branch,
188
+ .card.retrying .note,
189
+ .card.retrying .deps,
190
+ .card.retrying .meta { color: var(--faint); }
191
+ .card.queued:hover,
192
+ .card.retrying:hover { border-color: var(--line-strong); }
193
+ .card-top { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
194
+ .num {
195
+ font-family: var(--mono);
196
+ font-size: 12px;
197
+ line-height: 16px;
198
+ color: var(--muted);
199
+ white-space: nowrap;
200
+ overflow: hidden;
201
+ text-overflow: ellipsis;
202
+ }
203
+ .card-top .num { flex: 1; min-width: 0; }
204
+ .pulse {
205
+ width: 8px;
206
+ height: 8px;
207
+ border-radius: 50%;
208
+ background: var(--live);
209
+ flex: none;
210
+ animation: pulse 1.6s ease-in-out infinite;
211
+ }
212
+ .flag {
213
+ margin-left: auto;
214
+ padding: 0 6px;
215
+ border-radius: 4px;
216
+ background: color-mix(in srgb, var(--human) 18%, transparent);
217
+ color: var(--human);
218
+ font-size: 11px;
219
+ font-weight: 500;
220
+ letter-spacing: .02em;
221
+ text-transform: uppercase;
222
+ white-space: nowrap;
223
+ }
224
+ .repo { margin: -2px 0 6px; }
225
+ .title { font-size: 14px; line-height: 20px; margin: 0 0 6px; font-weight: 500; }
226
+ .branch {
227
+ font-family: var(--mono);
228
+ font-size: 12px;
229
+ line-height: 16px;
230
+ color: var(--faint);
231
+ white-space: nowrap;
232
+ overflow: hidden;
233
+ text-overflow: ellipsis;
234
+ }
235
+ .rounds { margin-top: 12px; display: grid; gap: 4px; }
236
+ .round { display: grid; grid-template-columns: 20px repeat(4, minmax(0, 1fr)); gap: 4px; align-items: center; }
237
+ .round .r { font-family: var(--mono); font-size: 11px; color: var(--faint); }
238
+ .chip {
239
+ text-align: center;
240
+ font-family: var(--mono);
241
+ font-size: 11px;
242
+ line-height: 16px;
243
+ padding: 1px 0;
244
+ border-radius: 4px;
245
+ border: 1px dashed var(--line-strong);
246
+ color: var(--muted);
247
+ }
248
+ .chip.wait { animation: pulse 1.6s ease-in-out infinite; }
249
+ .chip.PASS { border-color: transparent; color: var(--pass); background: color-mix(in srgb, var(--pass) 18%, transparent); }
250
+ .chip.CHANGES { border-color: transparent; color: var(--changes); background: color-mix(in srgb, var(--changes) 18%, transparent); }
251
+ .note {
252
+ margin-top: 8px;
253
+ padding-top: 8px;
254
+ border-top: 1px solid var(--line-strong);
255
+ font-size: 12px;
256
+ line-height: 16px;
257
+ color: var(--muted);
258
+ overflow-wrap: anywhere;
259
+ }
260
+ .deps { margin: 8px 0 0; font-family: var(--mono); font-size: 12px; line-height: 16px; color: var(--muted); overflow-wrap: anywhere; }
261
+ .meta { margin-top: 8px; font-family: var(--mono); font-size: 12px; line-height: 16px; color: var(--muted); overflow-wrap: anywhere; }
262
+ .meta time { font-family: inherit; }
263
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .3; } }
264
+ @media (prefers-reduced-motion: reduce) {
265
+ .pulse, .chip.wait, .status::before { animation: none; }
266
+ }
267
+
268
+ .toolbar {
269
+ position: fixed;
270
+ left: 50%;
271
+ bottom: 16px;
272
+ transform: translateX(-50%);
273
+ display: flex;
274
+ gap: 4px;
275
+ padding: 6px;
276
+ background: var(--panel);
277
+ border: 1px solid var(--line-strong);
278
+ border-radius: 12px;
279
+ box-shadow: var(--shadow);
280
+ z-index: 2;
281
+ }
282
+ .tool {
283
+ position: relative;
284
+ width: 40px;
285
+ height: 40px;
286
+ display: inline-flex;
287
+ align-items: center;
288
+ justify-content: center;
289
+ border: 0;
290
+ border-radius: 8px;
291
+ background: none;
292
+ color: var(--muted);
293
+ cursor: pointer;
294
+ }
295
+ .tool:hover, .tool:focus-visible { background: var(--card-hover); color: var(--ink); }
296
+ .tool svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
297
+ .tool::after {
298
+ content: attr(data-tip);
299
+ position: absolute;
300
+ bottom: calc(100% + 12px);
301
+ left: 50%;
302
+ transform: translateX(-50%);
303
+ padding: 4px 8px;
304
+ border-radius: 4px;
305
+ background: var(--ink);
306
+ color: var(--bg);
307
+ font: 12px/16px var(--mono);
308
+ white-space: nowrap;
309
+ opacity: 0;
310
+ pointer-events: none;
311
+ }
312
+ .tool:hover::after, .tool:focus-visible::after { opacity: 1; }
313
+ .toolbar .sep { width: 1px; margin: 6px 4px; background: var(--line-strong); }
314
+
315
+ .scrim { position: fixed; inset: 0; background: rgba(0, 0, 0, .5); opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 3; }
316
+ .drawer {
317
+ position: fixed;
318
+ top: 0;
319
+ right: 0;
320
+ bottom: 0;
321
+ width: min(900px, 100vw);
322
+ background: var(--panel);
323
+ border-left: 1px solid var(--line-strong);
324
+ box-shadow: 0 12px 16px -4px #00000080, 0 24px 32px 0 #00000080;
325
+ transform: translateX(100%);
326
+ transition: transform .22s ease;
327
+ z-index: 4;
328
+ }
329
+ body.open .scrim { opacity: 1; pointer-events: auto; }
330
+ body.open .drawer { transform: none; }
331
+ .sheet { height: 100%; display: grid; grid-template-rows: auto minmax(0, 1fr); }
332
+ .d-head { padding: 20px 24px 16px; border-bottom: 1px solid var(--line-strong); position: relative; }
333
+ .d-head h3 { margin: 4px 48px 4px 0; font-size: 22px; line-height: 28px; font-weight: 500; }
334
+ .d-head .num { white-space: normal; overflow: visible; text-overflow: unset; }
335
+ .close {
336
+ position: absolute;
337
+ top: 12px;
338
+ right: 16px;
339
+ width: 44px;
340
+ height: 44px;
341
+ border-radius: 50%;
342
+ border: 0;
343
+ background: none;
344
+ cursor: pointer;
345
+ color: var(--ink);
346
+ font-size: 24px;
347
+ line-height: 1;
348
+ }
349
+ .close:hover { background: var(--card-hover); }
350
+ .d-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
351
+ .d-actions form { margin: 0; }
352
+ .d-actions button {
353
+ height: 28px;
354
+ padding: 0 12px;
355
+ border-radius: 4px;
356
+ border: 1px solid var(--line-strong);
357
+ background: transparent;
358
+ color: var(--ink);
359
+ font: 12px/16px var(--mono);
360
+ cursor: pointer;
361
+ }
362
+ .d-actions button:hover { background: var(--card-hover); }
363
+ .d-actions .approve button { color: var(--pass); border-color: transparent; background: color-mix(in srgb, var(--pass) 18%, transparent); }
364
+ .d-actions .reject button { color: var(--human); border-color: transparent; background: color-mix(in srgb, var(--human) 18%, transparent); }
365
+ .d-body { display: grid; grid-template-columns: 200px minmax(0, 1fr); min-height: 0; }
366
+ .files { border-right: 1px solid var(--line-strong); padding: 16px 12px; overflow-y: auto; }
367
+ .group {
368
+ font-size: 12px;
369
+ line-height: 16px;
370
+ font-weight: 500;
371
+ text-transform: uppercase;
372
+ letter-spacing: .02em;
373
+ color: var(--faint);
374
+ margin: 16px 0 4px;
375
+ padding: 0 12px;
376
+ }
377
+ .group:first-child { margin-top: 0; }
378
+ .file {
379
+ display: flex;
380
+ align-items: center;
381
+ gap: 10px;
382
+ width: 100%;
383
+ text-align: left;
384
+ text-decoration: none;
385
+ background: none;
386
+ border: 0;
387
+ border-radius: 4px;
388
+ padding: 6px 12px;
389
+ cursor: pointer;
390
+ font-family: var(--mono);
391
+ font-size: 13px;
392
+ line-height: 20px;
393
+ color: var(--ink);
394
+ }
395
+ .file:hover { background: var(--card-hover); }
396
+ .file.on { background: var(--code-bg); }
397
+ .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: none; }
398
+ .dot.good { background: var(--pass); }
399
+ .dot.warn { background: var(--changes); }
400
+ .dot.stop { background: var(--human); }
401
+ .doc { overflow-y: auto; padding: 24px 32px 64px; min-width: 0; }
402
+ .src { display: none; }
403
+
404
+ .md { max-width: 660px; line-height: 20px; }
405
+ .md h1, .md h2, .md h3 { line-height: 1.3; margin: 1.5em 0 .5em; font-weight: 500; }
406
+ .md h1 { font-size: 18px; }
407
+ .md h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .02em; color: var(--muted); }
408
+ .md h3 { font-size: 13px; color: var(--muted); }
409
+ .md > :first-child { margin-top: 0; }
410
+ .md p, .md ul, .md ol, .md pre, .md table { margin: 0 0 1em; }
411
+ .md ul, .md ol { padding-left: 1.3em; }
412
+ .md li { margin: .25em 0; }
413
+ .md code { font-family: var(--mono); font-size: 13px; background: var(--code-bg); color: var(--code); padding: 1px 6px; border-radius: 4px; }
414
+ .md pre { background: var(--bg); border: 1px solid var(--line-strong); border-radius: 4px; padding: 12px 16px; overflow-x: auto; }
415
+ .md pre code { background: none; color: var(--ink); padding: 0; }
416
+ .md table { border-collapse: collapse; font-size: 13px; }
417
+ .md th, .md td { border: 1px solid var(--line-strong); padding: 6px 10px; text-align: left; }
418
+ .verdict {
419
+ display: inline-flex;
420
+ align-items: center;
421
+ height: 24px;
422
+ font-family: var(--mono);
423
+ font-size: 12px;
424
+ font-weight: 500;
425
+ letter-spacing: .02em;
426
+ padding: 0 10px;
427
+ border-radius: 4px;
428
+ margin-bottom: 16px;
429
+ }
430
+ .verdict.good { color: var(--pass); background: color-mix(in srgb, var(--pass) 18%, transparent); }
431
+ .verdict.warn { color: var(--changes); background: color-mix(in srgb, var(--changes) 18%, transparent); }
432
+ .verdict.stop { color: var(--human); background: color-mix(in srgb, var(--human) 18%, transparent); }
433
+
434
+ .back { display: inline-block; margin: 24px 24px 0; }
435
+ .page { padding: 0 24px 48px; }
436
+ .text { font-family: var(--mono); font-size: 13px; white-space: pre-wrap; overflow-wrap: anywhere; }
437
+
438
+ @media (max-width: 1100px) {
439
+ .board { grid-template-columns: repeat(3, minmax(0, 1fr)); row-gap: 24px; }
440
+ .col.slim { align-self: start; min-height: 0; flex-direction: row; padding: 8px 12px; }
441
+ .col.slim .slim-face { flex-direction: row; }
442
+ .col.slim .v { writing-mode: horizontal-tb; }
443
+ }
444
+ @media (max-width: 640px) {
445
+ header, .repos { padding-left: 16px; padding-right: 16px; }
446
+ .board { grid-template-columns: 1fr; padding: 16px; }
447
+ .d-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
448
+ .files { border-right: 0; border-bottom: 1px solid var(--line-strong); display: flex; gap: 4px; overflow-x: auto; padding: 8px; }
449
+ .group { display: none; }
450
+ .file { width: auto; white-space: nowrap; }
451
+ .doc { padding: 16px; }
452
+ }