gcf-board 0.2.13 → 0.2.17

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
@@ -42,7 +42,8 @@ It puts **GCF Board** on the desktop (Windows: a shortcut whose server console s
42
42
  - `run_mode: worktree`: one worktree and one agent session per item, all at once, then the integrator;
43
43
  - `run_mode: main`: one item at a time in the project folder. A run asked for meanwhile waits in the board's **queue**; **remove from queue** takes it out.
44
44
 
45
- A run asks first: it spends the subscription. **Stop** on a running item ends its session; the item stays ready and its work is kept. A queued main run that the next one would repeat (the harness did not start, or another run works the checkout) pauses the queue: the header shows **queue paused — resume**; fix the cause, then resume. A card marked **interrupted** had a run that stopped (a timeout, a stop, a crash, the usage limit): **resume** continues its agent session with all its context, **start fresh** drops it. After the usage limit the card reads **paused: limit** and the board resumes it by itself at the reset time; the header shows **resumes HH:MM** with **cancel**. When a run ends, a toast lists each item's result. The queue lives in the board's memory: a board restart forgets it.
45
+ A run asks first: it spends the subscription. **Stop** on a running item ends its session; the item stays ready and its work is kept. A queued main run that the next one would repeat (the harness did not start, or another run works the checkout) pauses the queue: the header shows **queue paused — resume**; fix the cause, then resume. A card marked **interrupted** had a run that stopped (a timeout, a stop, a crash, the usage limit): **resume** continues its agent session with all its context, **start fresh** drops it. After the usage limit the card reads **paused: limit** and the board resumes it by itself at the reset time; the header shows **resumes HH:MM** with **cancel**, and a queue the limit paused resumes only by that clock until the operator cancels it; a pause after a failure keeps its **resume** button. When a run ends, a report card lists each item's result and stays until closed, across page reloads. **What the board did by itself** lists its automatic acts with their times: a usage limit, a context handoff, the queue paused or started, a resume run or cancelled. A run confirmation names what each interrupted item picks up. The queue, the reports and that list live in the board's memory: a board restart forgets them.
46
+ - **Who runs it** (core 0.7.13): a running card tells a console run and **your session** (a `gcf claim` mark) from the board's own run; a session offers **release mark** instead of **stop**. A ready card whose dependencies are not built yet shows **waits for <dep>**; the drawer shows when an item began to wait (`since_at`).
46
47
  - **Run next N independent**: the framework's gate picks the ready items that can run together now, by priority, and the board runs them after asking.
47
48
  - **Priority**: drop a ready card on another to put it before; the column's ranks are committed (`gcf prioritize`).
48
49
  - **Questions**: each D1 shows its question and proposed answer; **Answer** writes yours into its record (`gcf question answer`), and the next agent on its units puts it in place.
@@ -14,7 +14,7 @@ import { followLog, readShown, shownShape } from './files.js';
14
14
  import { HttpError } from './http-error.js';
15
15
  import { moveArgs, runIds } from './moves.js';
16
16
  import { addProject, listProjects, projectOf, removeProject } from './registry.js';
17
- import { cancelResume, forgetProject, resumeQueue, runsOf, startRun, unqueue } from './runs.js';
17
+ import { cancelResume, closeReport, forgetProject, resumeQueue, runsOf, startRun, unqueue, } from './runs.js';
18
18
  const send = (res, status, body) => {
19
19
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
20
20
  res.end(JSON.stringify(body));
@@ -90,8 +90,13 @@ async function sendPage(res, web, pathname) {
90
90
  }
91
91
  export function createApp(o) {
92
92
  const watchers = new Map();
93
+ let closed = false;
93
94
  const watchAll = async () => {
94
- for (const p of await listProjects())
95
+ const projects = await listProjects();
96
+ // A watcher set up after the server closed would keep the process alive.
97
+ if (closed)
98
+ return;
99
+ for (const p of projects)
95
100
  if (!watchers.has(p.name))
96
101
  watchers.set(p.name, watchProject(p));
97
102
  };
@@ -140,10 +145,15 @@ export function createApp(o) {
140
145
  // count of plan writes, which tells the page an open plan's text changed.
141
146
  const own = { runs: runsOf(p.name), plans_rev: planRev(p.name) };
142
147
  const board = await boardOf(p);
143
- // A running item's last log write tells a working session from a stuck one.
144
- for (const i of board.items)
148
+ // A running item's last log write tells a working session from a stuck one; a run's
149
+ // lock is this board's run or a console's.
150
+ const mine = new Set(own.runs.active.flatMap((r) => r.ids));
151
+ for (const i of board.items) {
145
152
  if (i.running)
146
153
  i.active_at = i.log ? activeAt(path.join(p.path, i.log)) : null;
154
+ if (i.running_kind === 'run')
155
+ i.running_kind = mine.has(i.id) ? 'board' : 'console';
156
+ }
147
157
  send(res, 200, { ...board, ...own });
148
158
  },
149
159
  ],
@@ -249,6 +259,14 @@ export function createApp(o) {
249
259
  send(res, 200, { ok: true });
250
260
  },
251
261
  ],
262
+ [
263
+ 'DELETE',
264
+ /^\/api\/p\/([^/]+)\/reports\/(r\d+)$/,
265
+ async (_req, res, m) => {
266
+ closeReport((await projectOf(m[1])).name, m[2]);
267
+ send(res, 200, { ok: true });
268
+ },
269
+ ],
252
270
  [
253
271
  'DELETE',
254
272
  /^\/api\/p\/([^/]+)\/queue\/([a-z0-9][a-z0-9-]*)$/,
@@ -290,15 +308,18 @@ export function createApp(o) {
290
308
  const server = createServer((req, res) => {
291
309
  handle(req, res).catch((e) => {
292
310
  if (res.headersSent)
293
- return res.end();
294
- send(res, e instanceof HttpError ? e.status : 500, { error: e.message });
311
+ res.end();
312
+ else
313
+ send(res, e instanceof HttpError ? e.status : 500, { error: e.message });
295
314
  });
296
315
  });
297
316
  // A broken projects.json must not take the server down: the page shows the error instead.
298
317
  server.on('listening', () => watchAll().catch((e) => console.error(`gcf-board: ${e.message}`)));
299
318
  server.on('close', () => {
319
+ closed = true;
300
320
  for (const stop of watchers.values())
301
321
  stop();
322
+ watchers.clear();
302
323
  });
303
324
  return server;
304
325
  }
@@ -1,7 +1,8 @@
1
1
  // The board starts agents that commit and push: whoever talks to it runs code on this machine.
2
- // It listens on 127.0.0.1 only, and every request needs the token printed at start (kept in a
3
- // cookie after the first visit). Host and Origin are checked too: a page in another tab can
4
- // reach 127.0.0.1 (DNS rebinding, a cross-site POST), but it cannot fake those headers.
2
+ // It listens on 127.0.0.1 only. The URL printed at start carries the token; that visit gets a
3
+ // session cookie (one per server process, never the token itself), and every request needs it.
4
+ // Host and Origin are checked too: a page in another tab can reach 127.0.0.1 (DNS rebinding, a
5
+ // cross-site POST), but it cannot fake those headers.
5
6
  import { randomBytes, timingSafeEqual } from 'node:crypto';
6
7
  import { existsSync } from 'node:fs';
7
8
  import { mkdir, readFile } from 'node:fs/promises';
@@ -71,6 +71,8 @@ export function runCli(entry, cwd, args, timeoutMs = 120_000) {
71
71
  spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
72
72
  else
73
73
  child.kill();
74
+ // A grandchild holding the pipes keeps close from firing: the answer does not wait for it.
75
+ setTimeout(() => resolve({ ok: false, code: null, out, err }), 5_000).unref();
74
76
  }, timeoutMs);
75
77
  child.on('error', (e) => {
76
78
  clearTimeout(timer);
@@ -60,8 +60,8 @@ const NEW_ITEM_OPTIONS = [
60
60
  'effort',
61
61
  ];
62
62
  const MOVES = {
63
- submit: (b) => ['submit', id(b)],
64
- close: (b) => ['close', id(b), ...many(b, 'constraint')],
63
+ submit: (b) => ['submit', id(b), ...opt(b, 'unverified')],
64
+ close: (b) => ['close', id(b), ...many(b, 'constraint'), ...opt(b, 'unverified')],
65
65
  verdict: (b) => [
66
66
  'verdict',
67
67
  id(b),
@@ -80,6 +80,8 @@ const MOVES = {
80
80
  ],
81
81
  block: (b) => ['block', id(b), `--reason=${required(b, 'reason')}`],
82
82
  unblock: (b) => ['unblock', id(b), ...opt(b, 'note')],
83
+ // A session's mark taken off: the item no longer shows running (Board-PRD §3.2, kind c).
84
+ release: (b) => ['release', id(b)],
83
85
  drop: (b) => [
84
86
  'drop',
85
87
  id(b),
@@ -19,6 +19,22 @@ const scheduled = new Map();
19
19
  const limitPaused = new Set();
20
20
  // Runs going on for a project the operator removed: their end touches no project state.
21
21
  const forgotten = new WeakSet();
22
+ // shortcut: reports and acts live in memory, like the queue: a board restart forgets them;
23
+ // persist them with the queue if restarts become common.
24
+ const reports = new Map();
25
+ const acts = new Map();
26
+ const KEEP_REPORTS = 20;
27
+ const KEEP_ACTS = 50;
28
+ let reportSeq = 0;
29
+ function trace(project, act, text) {
30
+ const list = [...(acts.get(project) ?? []), { at: new Date().toISOString(), act, text }];
31
+ acts.set(project, list.slice(-KEEP_ACTS));
32
+ }
33
+ /** The operator closed a run report. */
34
+ export function closeReport(project, id) {
35
+ reports.set(project, (reports.get(project) ?? []).filter((r) => r.id !== id));
36
+ broadcast({ type: 'board', project });
37
+ }
22
38
  export const activeRuns = (project) => [...active].filter((r) => r.project === project);
23
39
  /** What runs and what waits, for a project; a paused queue waits for `resumeQueue`. */
24
40
  export function runsOf(project) {
@@ -27,8 +43,12 @@ export function runsOf(project) {
27
43
  active: activeRuns(project),
28
44
  queued: (queues.get(project) ?? []).flatMap((q) => q.ids),
29
45
  paused: paused.has(project),
46
+ // Why it waits: the usage limit (its reset may lift it) or a failure (only the operator).
47
+ paused_by: paused.has(project) ? (limitPaused.has(project) ? 'limit' : 'failure') : null,
30
48
  // When the items a usage limit stopped run again by themselves, and the queue with them.
31
49
  resume: s ? { at: s.at.toISOString(), ids: s.run.ids } : null,
50
+ reports: reports.get(project) ?? [],
51
+ acts: acts.get(project) ?? [],
32
52
  };
33
53
  }
34
54
  /**
@@ -59,7 +79,10 @@ export function resetAt(text, now = new Date()) {
59
79
  }
60
80
  /** Drop the scheduled resume; the queue stays as it is. */
61
81
  export function cancelResume(project) {
62
- clearTimeout(scheduled.get(project)?.timer);
82
+ const s = scheduled.get(project);
83
+ if (s?.run.ids.length)
84
+ trace(project, 'resume-cancelled', `the resume of ${s.run.ids.join(', ')} is cancelled`);
85
+ clearTimeout(s?.timer);
63
86
  scheduled.delete(project);
64
87
  broadcast({ type: 'board', project });
65
88
  }
@@ -77,6 +100,8 @@ export function forgetProject(project) {
77
100
  queues.delete(project);
78
101
  paused.delete(project);
79
102
  limitPaused.delete(project);
103
+ reports.delete(project);
104
+ acts.delete(project);
80
105
  }
81
106
  /**
82
107
  * At the reset: run the stopped items again (the harness resumes their sessions), then the queue.
@@ -93,11 +118,18 @@ function schedule(run, at) {
93
118
  scheduled.delete(project);
94
119
  liftLimitPause(project);
95
120
  try {
96
- if (s)
97
- startRun(s.run.entry, s.run.p, s.run.ids, s.run.mode);
121
+ if (s) {
122
+ const r = startRun(s.run.entry, s.run.p, s.run.ids, s.run.mode);
123
+ const how = 'queued' in r ? 'wait in the queue' : 'run again';
124
+ trace(project, 'resume', `the usage limit reset: ${s.run.ids.join(', ')} ${how}`);
125
+ }
98
126
  }
99
- catch {
100
- // Started from the board meanwhile: the queue still goes on after it.
127
+ catch (e) {
128
+ // The board's own run of an item takes it out of the schedule first, so this is rare;
129
+ // the queue still goes on.
130
+ const why = e instanceof Error ? e.message : String(e);
131
+ if (s)
132
+ trace(project, 'resume-failed', `${s.run.ids.join(', ')}: ${why}`);
101
133
  advance(project);
102
134
  }
103
135
  broadcast({ type: 'board', project });
@@ -108,12 +140,10 @@ function schedule(run, at) {
108
140
  /**
109
141
  * A run some of whose items hit the usage limit: the main queue would hit it too, so it pauses,
110
142
  * and those items run again at the reset when the message names a time. The reason, for its
111
- * `run` event.
143
+ * report.
112
144
  */
113
145
  function limited(run, result) {
114
- const items = result
115
- ?.items;
116
- const hit = (items ?? []).filter((i) => i.limit_resets !== undefined);
146
+ const hit = (result?.items ?? []).filter((i) => i.limit_resets !== undefined);
117
147
  if (!hit.length)
118
148
  return '';
119
149
  const project = run.p.name;
@@ -127,18 +157,28 @@ function limited(run, result) {
127
157
  schedule({ ...run, ids: hit.map((i) => i.id), fresh: false }, at);
128
158
  const when = at ? `they resume at ${at.toTimeString().slice(0, 5)}` : 'run them after the reset';
129
159
  const queue = run.mode === 'main' ? ', the queue is paused' : '';
130
- return `usage limit${resets ? `, resets ${resets}` : ''}${queue}: ${when}`;
160
+ const reason = `usage limit${resets ? `, resets ${resets}` : ''}${queue}: ${when}`;
161
+ trace(project, 'limit', `${hit.map((i) => i.id).join(', ')}: ${reason}`);
162
+ return reason;
131
163
  }
132
164
  /** Start the next queued batch when the checkout is free: after a run, or on the operator's resume. */
133
165
  function advance(project) {
134
166
  if (paused.has(project) || activeRuns(project).some((r) => r.mode === 'main'))
135
167
  return;
136
168
  const next = queues.get(project)?.shift();
137
- if (next)
138
- spawnRun(next, next.mode);
169
+ if (!next)
170
+ return;
171
+ trace(project, 'queue-run', `the queue starts ${next.ids.join(', ')}`);
172
+ spawnRun(next, next.mode);
139
173
  }
140
174
  /** The operator resumes a paused queue (after fixing what stopped it). */
141
175
  export function resumeQueue(project) {
176
+ // The limit still holds until its reset: the next run would hit it again.
177
+ const s = scheduled.get(project);
178
+ if (s && limitPaused.has(project))
179
+ throw new HttpError(409, `the queue resumes by itself at ${s.at.toTimeString().slice(0, 5)}, after the usage limit; cancel that first to resume now`);
180
+ if (paused.has(project))
181
+ trace(project, 'queue-resumed', 'the operator resumed the queue');
142
182
  paused.delete(project);
143
183
  limitPaused.delete(project);
144
184
  advance(project);
@@ -163,6 +203,34 @@ export function stalled(ok, result) {
163
203
  const items = result?.items ?? [];
164
204
  return (items.length > 0 && items.every((i) => /another run works in the main/.test(i.error ?? '')));
165
205
  }
206
+ /**
207
+ * The harness report a run printed, or null for none. A report of another shape counts as none:
208
+ * the close listener that reads it would throw, and take the server down with it.
209
+ */
210
+ export function harnessReport(out) {
211
+ let r;
212
+ try {
213
+ r = JSON.parse(out);
214
+ }
215
+ catch {
216
+ // No report: the error says why.
217
+ return null;
218
+ }
219
+ if (typeof r !== 'object' || r === null || Array.isArray(r))
220
+ return null;
221
+ const { gate, items, orphans } = r;
222
+ const list = (v) => v === undefined || (Array.isArray(v) && v.every(isItem));
223
+ return (gate === undefined || typeof gate === 'string') && list(items) && list(orphans)
224
+ ? r
225
+ : null;
226
+ }
227
+ // The fields read as more than text: the id, and the reset a resume is scheduled at.
228
+ function isItem(i) {
229
+ if (typeof i !== 'object' || i === null)
230
+ return false;
231
+ const { id, limit_resets: resets } = i;
232
+ return typeof id === 'string' && (resets == null || typeof resets === 'string');
233
+ }
166
234
  function spawnRun(q, mode) {
167
235
  const { entry, p, ids } = q;
168
236
  const run = { project: p.name, ids, started: new Date().toISOString(), mode };
@@ -183,13 +251,7 @@ function spawnRun(q, mode) {
183
251
  // A spawn that fails fires both error and close: one end only.
184
252
  if (!active.delete(run))
185
253
  return;
186
- let result = null;
187
- try {
188
- result = JSON.parse(out);
189
- }
190
- catch {
191
- // No report: the error says why.
192
- }
254
+ const result = harnessReport(out);
193
255
  // A run of a project removed meanwhile only reports: it pauses and schedules nothing.
194
256
  const kept = !forgotten.has(run);
195
257
  const stop = kept && mode === 'main' && queues.get(p.name)?.length && stalled(ok, result);
@@ -201,16 +263,24 @@ function spawnRun(q, mode) {
201
263
  ? ''
202
264
  : limited(q, result) ||
203
265
  (stop ? 'the queue is paused: fix the cause, then resume it on the board' : '');
266
+ if (stop)
267
+ trace(p.name, 'queue-paused', `after ${ids.join(', ')}: ${note}`);
268
+ for (const i of result?.items ?? [])
269
+ if (kept && i.handoffs)
270
+ trace(p.name, 'handoff', `${i.id}: ${i.handoffs} context handoff(s) to a fresh session`);
204
271
  const brief = err && briefError(err);
205
- broadcast({
206
- type: 'run',
207
- project: p.name,
272
+ const report = {
273
+ id: `r${++reportSeq}`,
274
+ at: new Date().toISOString(),
208
275
  ids,
209
276
  ok,
210
277
  result,
211
278
  err: (brief + message).trim(),
212
279
  note,
213
- });
280
+ };
281
+ // A project removed meanwhile has no board to show it on.
282
+ if (kept)
283
+ reports.set(p.name, [...(reports.get(p.name) ?? []), report].slice(-KEEP_REPORTS));
214
284
  // The checkout is free now, for whatever queue the name has: a project added back meanwhile.
215
285
  if (mode === 'main')
216
286
  advance(p.name);
@@ -222,7 +292,7 @@ function spawnRun(q, mode) {
222
292
  }
223
293
  /**
224
294
  * Start a batch in the project's run mode, or queue it behind the main run going on. Its end
225
- * is a `run` event with the harness report, or what went wrong. `fresh` starts new agent
295
+ * is a run report with the harness report, or what went wrong. `fresh` starts new agent
226
296
  * sessions where the harness would resume the interrupted ones.
227
297
  */
228
298
  export function startRun(entry, p, ids, mode = 'worktree', fresh = false) {
@@ -235,6 +305,8 @@ export function startRun(entry, p, ids, mode = 'worktree', fresh = false) {
235
305
  // now, and the limit's pause with it; the rest of the schedule stays.
236
306
  const s = scheduled.get(p.name);
237
307
  if (s?.run.ids.some((id) => ids.includes(id))) {
308
+ const early = s.run.ids.filter((id) => ids.includes(id));
309
+ trace(p.name, 'resume-cancelled', `${early.join(', ')}: the operator runs them before the reset`);
238
310
  s.run = { ...s.run, ids: s.run.ids.filter((id) => !ids.includes(id)) };
239
311
  if (!s.run.ids.length)
240
312
  cancelResume(p.name);
@@ -1 +1 @@
1
- :root{--bg:#14161a;--panel:#1d2026;--card:#262a32;--line:#343a45;--text:#e6e8ec;--muted:#8b93a1;--accent:#5b9cff;--ok:#46c08a;--warn:#e8a33d;--bad:#e5534b;--verdict:#b48cff;--auto:#46c08a;--decision:#e8a33d;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;font:14px/1.4 system-ui,-apple-system,Segoe UI,sans-serif}*{box-sizing:border-box}body{background:var(--bg);color:var(--text);margin:0}button,input,select,textarea{font:inherit;color:inherit;background:var(--card);border:1px solid var(--line);border-radius:6px;padding:6px 10px}button{cursor:pointer}button:disabled{opacity:.5;cursor:default}button.primary{background:var(--accent);border-color:var(--accent);color:#fff}button.danger{border-color:var(--bad);color:var(--bad)}code,pre{font-family:ui-monospace,Cascadia Code,Consolas,monospace}.muted{color:var(--muted)}.error,.warn{color:var(--bad)}.ok{color:var(--ok)}.badge{background:var(--line);border-radius:4px;padding:1px 6px;font-size:12px;display:inline-block}.topbar{background:var(--panel);border-bottom:1px solid var(--line);z-index:5;align-items:center;gap:12px;padding:8px 16px;display:flex;position:sticky;top:0}.projects-menu{position:relative}.projects-menu>:not(summary){margin-top:8px}.projects-menu[open]{background:var(--panel);border:1px solid var(--line);z-index:20;border-radius:8px;width:min(520px,92vw);padding:10px;position:absolute;top:8px;right:16px}.add-project{flex-wrap:wrap;gap:6px;display:flex}.add-project input:first-child{flex:260px}.empty{padding:32px}.project{padding:12px 16px 48px}.banner{border:1px solid var(--bad);border-radius:6px;margin-bottom:8px;padding:8px 12px}.header{flex-wrap:wrap;align-items:center;gap:12px;margin-bottom:8px;display:flex}.phase{flex-wrap:wrap;flex:1;align-items:center;gap:8px;display:flex}.criterion{color:var(--muted);font-size:12px}.criterion.met-true{color:var(--ok)}.criterion.met-false{color:var(--warn)}.wip.over{color:var(--bad);font-weight:600}.tabs{gap:4px;margin:8px 0;display:flex;overflow-x:auto}.tabs button{background:0 0;border-color:#0000}.tabs button.active{border-color:var(--line);background:var(--panel)}.board-actions{align-items:center;gap:12px;margin-bottom:8px;display:flex}.graveyard-zone{border:1px dashed var(--line);color:var(--muted);text-align:center;border-radius:6px;flex:1;padding:6px 10px}.graveyard-zone.over{border-color:var(--bad);color:var(--bad)}.columns{scroll-snap-type:x mandatory;grid-auto-columns:minmax(230px,1fr);grid-auto-flow:column;gap:10px;padding-bottom:8px;display:grid;overflow-x:auto}.column{background:var(--panel);border:1px solid var(--line);scroll-snap-align:start;border-radius:8px;min-height:200px;padding:8px}.column.over{border-color:var(--accent)}.column h3{text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:0 0 8px;font-size:13px}.count{color:var(--text)}.card{background:var(--card);border:1px solid var(--line);border-left:3px solid var(--line);cursor:pointer;touch-action:manipulation;border-radius:6px;margin-bottom:8px;padding:8px}.card.gate-verdict{border-left-color:var(--verdict)}.card.gate-auto{border-left-color:var(--auto)}.card.gate-decision{border-left-color:var(--decision)}.card.dragging{opacity:.7;z-index:10;position:relative}.card-top,.card-bottom{flex-wrap:wrap;align-items:center;gap:6px;font-size:12px;display:flex}.card .id{font-size:13px;font-weight:600}.card .type,.card .model,.card .tune{color:var(--muted)}.card .lessons{color:var(--warn)}button.link{cursor:pointer;background:0 0;border:none;padding:0;text-decoration:underline}.drawer .lessons{margin:8px 0}.gate{margin-left:auto;font-size:11px}.gate-verdict.gate{color:var(--verdict)}.gate-auto.gate{color:var(--auto)}.gate-decision.gate{color:var(--decision)}.title{margin:4px 0}.plan{background:var(--line);border-radius:4px;padding:0 5px}.verified{color:var(--ok)}.queued{color:var(--warn)}.activity{color:var(--muted)}.quiet{color:var(--warn)}.rank{color:var(--muted);font-size:11px}.card.drop-before{box-shadow:0 -3px 0 var(--accent)}.run-next{color:var(--muted);align-items:center;gap:6px;display:inline-flex}.run-next input{width:56px}.sev-critical,.sev-high{color:var(--bad)}.blocked-why{color:var(--warn);white-space:pre-wrap;margin-top:4px;font-size:12px}.drawer{background:var(--panel);border-left:1px solid var(--line);z-index:30;flex-direction:column;width:min(640px,100vw);padding:12px 16px;display:flex;position:fixed;top:0;bottom:0;right:0;box-shadow:-8px 0 24px #0006}.drawer>header{justify-content:space-between;align-items:center;display:flex}.drawer h2{margin:0}.facts{grid-template-columns:max-content 1fr;gap:2px 12px;margin:8px 0;display:grid}.facts dt{color:var(--muted)}.facts dd{margin:0}.moves{flex-wrap:wrap;align-items:center;gap:6px;display:flex}.moves details{gap:6px;display:inline-flex}.moves details[open]{flex-wrap:wrap;gap:6px;display:flex}.drawer-body{border-top:1px solid var(--line);flex:1;padding-top:8px;overflow:auto}.frontmatter{color:var(--muted);background:var(--card);white-space:pre-wrap;border-radius:6px;padding:8px;font-size:12px}.log{white-space:pre-wrap;word-break:break-word;margin:0;font-size:12px}.modal{z-index:40;background:#0000008c;place-items:center;padding:12px;display:grid;position:fixed;inset:0}.dialog{background:var(--panel);border:1px solid var(--line);border-radius:10px;flex-direction:column;gap:10px;width:min(480px,100%);max-height:90vh;padding:16px;display:flex;overflow:auto}.dialog h2{margin:0;font-size:16px}.dialog label{color:var(--muted);flex-direction:column;gap:4px;font-size:13px;display:flex}.dialog label>*{color:var(--text)}.dialog-actions{justify-content:flex-end;gap:8px;display:flex}.list-view ul{padding:0;list-style:none}.list-view li{border-bottom:1px solid var(--line);align-items:center;gap:8px;padding:6px 0;display:flex}.row-actions{gap:6px;margin-left:auto;display:flex}.inline-form{gap:6px;margin:8px 0;display:flex}.inline-form input{flex:1}.playtest .build{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.check{background:var(--card);border:1px solid var(--line);border-radius:8px;margin-bottom:8px;padding:10px}.check p{margin:4px 0}.ended{grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px;display:grid}.ended table{border-collapse:collapse;width:100%}.ended td{border-bottom:1px solid var(--line);vertical-align:top;padding:4px 6px}.toasts{z-index:50;flex-direction:column;gap:6px;max-width:min(480px,92vw);display:flex;position:fixed;bottom:12px;right:12px}.toast{background:var(--card);border:1px solid var(--ok);white-space:pre-wrap;border-radius:6px;padding:8px 12px}.toast-close{float:right;background:0 0;border:none;margin:-4px -6px 0 8px;padding:0 6px}.toast.error{border-color:var(--bad)}@media (width<=700px){.project{padding:8px}.columns{grid-auto-columns:85vw}}
1
+ :root{--bg:#14161a;--panel:#1d2026;--card:#262a32;--line:#343a45;--text:#e6e8ec;--muted:#8b93a1;--accent:#5b9cff;--ok:#46c08a;--warn:#e8a33d;--bad:#e5534b;--verdict:#b48cff;--auto:#46c08a;--decision:#e8a33d;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;font:14px/1.4 system-ui,-apple-system,Segoe UI,sans-serif}*{box-sizing:border-box}body{background:var(--bg);color:var(--text);margin:0}button,input,select,textarea{font:inherit;color:inherit;background:var(--card);border:1px solid var(--line);border-radius:6px;padding:6px 10px}button{cursor:pointer}button:disabled{opacity:.5;cursor:default}button.primary{background:var(--accent);border-color:var(--accent);color:#fff}button.danger{border-color:var(--bad);color:var(--bad)}code,pre{font-family:ui-monospace,Cascadia Code,Consolas,monospace}.muted{color:var(--muted)}.error,.warn{color:var(--bad)}.ok{color:var(--ok)}.badge{background:var(--line);border-radius:4px;padding:1px 6px;font-size:12px;display:inline-block}.topbar{background:var(--panel);border-bottom:1px solid var(--line);z-index:5;align-items:center;gap:12px;padding:8px 16px;display:flex;position:sticky;top:0}.projects-menu{position:relative}.projects-menu>:not(summary){margin-top:8px}.projects-menu[open]{background:var(--panel);border:1px solid var(--line);z-index:20;border-radius:8px;width:min(520px,92vw);padding:10px;position:absolute;top:8px;right:16px}.add-project{flex-wrap:wrap;gap:6px;display:flex}.add-project input:first-child{flex:260px}.empty{padding:32px}.project{padding:12px 16px 48px}.banner{border:1px solid var(--bad);border-radius:6px;margin-bottom:8px;padding:8px 12px}.header{flex-wrap:wrap;align-items:center;gap:12px;margin-bottom:8px;display:flex}.phase{flex-wrap:wrap;flex:1;align-items:center;gap:8px;display:flex}.criterion{color:var(--muted);font-size:12px}.criterion.met-true{color:var(--ok)}.criterion.met-false{color:var(--warn)}.wip.over{color:var(--bad);font-weight:600}.tabs{gap:4px;margin:8px 0;display:flex;overflow-x:auto}.tabs button{background:0 0;border-color:#0000}.tabs button.active{border-color:var(--line);background:var(--panel)}.board-actions{align-items:center;gap:12px;margin-bottom:8px;display:flex}.graveyard-zone{border:1px dashed var(--line);color:var(--muted);text-align:center;border-radius:6px;flex:1;padding:6px 10px}.graveyard-zone.over{border-color:var(--bad);color:var(--bad)}.columns{scroll-snap-type:x mandatory;grid-auto-columns:minmax(230px,1fr);grid-auto-flow:column;gap:10px;padding-bottom:8px;display:grid;overflow-x:auto}.column{background:var(--panel);border:1px solid var(--line);scroll-snap-align:start;border-radius:8px;min-height:200px;padding:8px}.column.over{border-color:var(--accent)}.column h3{text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:0 0 8px;font-size:13px}.count{color:var(--text)}.card{background:var(--card);border:1px solid var(--line);border-left:3px solid var(--line);cursor:pointer;touch-action:manipulation;border-radius:6px;margin-bottom:8px;padding:8px}.card.gate-verdict{border-left-color:var(--verdict)}.card.gate-auto{border-left-color:var(--auto)}.card.gate-decision{border-left-color:var(--decision)}.card.dragging{opacity:.7;z-index:10;position:relative}.card-top,.card-bottom{flex-wrap:wrap;align-items:center;gap:6px;font-size:12px;display:flex}.card .id{font-size:13px;font-weight:600}.card .type,.card .model,.card .tune{color:var(--muted)}.card .lessons{color:var(--warn)}button.link{cursor:pointer;background:0 0;border:none;padding:0;text-decoration:underline}.drawer .lessons{margin:8px 0}.gate{margin-left:auto;font-size:11px}.gate-verdict.gate{color:var(--verdict)}.gate-auto.gate{color:var(--auto)}.gate-decision.gate{color:var(--decision)}.title{margin:4px 0}.plan{background:var(--line);border-radius:4px;padding:0 5px}.verified{color:var(--ok)}.queued{color:var(--warn)}.activity{color:var(--muted)}.quiet{color:var(--warn)}.rank{color:var(--muted);font-size:11px}.card.drop-before{box-shadow:0 -3px 0 var(--accent)}.run-next{color:var(--muted);align-items:center;gap:6px;display:inline-flex}.run-next input{width:56px}.sev-critical,.sev-high{color:var(--bad)}.blocked-why{color:var(--warn);white-space:pre-wrap;margin-top:4px;font-size:12px}.drawer{background:var(--panel);border-left:1px solid var(--line);z-index:30;flex-direction:column;width:min(640px,100vw);padding:12px 16px;display:flex;position:fixed;top:0;bottom:0;right:0;box-shadow:-8px 0 24px #0006}.drawer>header{justify-content:space-between;align-items:center;display:flex}.drawer h2{margin:0}.facts{grid-template-columns:max-content 1fr;gap:2px 12px;margin:8px 0;display:grid}.facts dt{color:var(--muted)}.facts dd{margin:0}.moves{flex-wrap:wrap;align-items:center;gap:6px;display:flex}.moves details{gap:6px;display:inline-flex}.moves details[open]{flex-wrap:wrap;gap:6px;display:flex}.drawer-body{border-top:1px solid var(--line);flex:1;padding-top:8px;overflow:auto}.frontmatter{color:var(--muted);background:var(--card);white-space:pre-wrap;border-radius:6px;padding:8px;font-size:12px}.log{white-space:pre-wrap;word-break:break-word;margin:0;font-size:12px}.modal{z-index:40;background:#0000008c;place-items:center;padding:12px;display:grid;position:fixed;inset:0}.dialog{background:var(--panel);border:1px solid var(--line);border-radius:10px;flex-direction:column;gap:10px;width:min(480px,100%);max-height:90vh;padding:16px;display:flex;overflow:auto}.dialog h2{margin:0;font-size:16px}.dialog label{color:var(--muted);flex-direction:column;gap:4px;font-size:13px;display:flex}.dialog label>*{color:var(--text)}.dialog-actions{justify-content:flex-end;gap:8px;display:flex}.list-view ul{padding:0;list-style:none}.list-view li{border-bottom:1px solid var(--line);align-items:center;gap:8px;padding:6px 0;display:flex}.row-actions{gap:6px;margin-left:auto;display:flex}.inline-form{gap:6px;margin:8px 0;display:flex}.inline-form input{flex:1}.playtest .build{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.check{background:var(--card);border:1px solid var(--line);border-radius:8px;margin-bottom:8px;padding:10px}.check p{margin:4px 0}.ended{grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px;display:grid}.ended table{border-collapse:collapse;width:100%}.ended td{border-bottom:1px solid var(--line);vertical-align:top;padding:4px 6px}.toasts{z-index:50;flex-direction:column;gap:6px;max-width:min(480px,92vw);display:flex;position:fixed;bottom:12px;right:12px}.toast{background:var(--card);border:1px solid var(--ok);white-space:pre-wrap;border-radius:6px;padding:8px 12px}.toast-close{float:right;background:0 0;border:none;margin:-4px -6px 0 8px;padding:0 6px}.toast.error{border-color:var(--bad)}.run-reports{flex-direction:column;gap:6px;margin:6px 0;display:flex}.acts ul{margin:4px 0;padding-left:18px}@media (width<=700px){.project{padding:8px}.columns{grid-auto-columns:85vw}}