gcf-board 0.2.13 → 0.2.16

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));
@@ -140,10 +140,15 @@ export function createApp(o) {
140
140
  // count of plan writes, which tells the page an open plan's text changed.
141
141
  const own = { runs: runsOf(p.name), plans_rev: planRev(p.name) };
142
142
  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)
143
+ // A running item's last log write tells a working session from a stuck one; a run's
144
+ // lock is this board's run or a console's.
145
+ const mine = new Set(own.runs.active.flatMap((r) => r.ids));
146
+ for (const i of board.items) {
145
147
  if (i.running)
146
148
  i.active_at = i.log ? activeAt(path.join(p.path, i.log)) : null;
149
+ if (i.running_kind === 'run')
150
+ i.running_kind = mine.has(i.id) ? 'board' : 'console';
151
+ }
147
152
  send(res, 200, { ...board, ...own });
148
153
  },
149
154
  ],
@@ -249,6 +254,14 @@ export function createApp(o) {
249
254
  send(res, 200, { ok: true });
250
255
  },
251
256
  ],
257
+ [
258
+ 'DELETE',
259
+ /^\/api\/p\/([^/]+)\/reports\/(r\d+)$/,
260
+ async (_req, res, m) => {
261
+ closeReport((await projectOf(m[1])).name, m[2]);
262
+ send(res, 200, { ok: true });
263
+ },
264
+ ],
252
265
  [
253
266
  'DELETE',
254
267
  /^\/api\/p\/([^/]+)\/queue\/([a-z0-9][a-z0-9-]*)$/,
@@ -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,17 @@ 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
+ if (s)
131
+ trace(project, 'resume-failed', `${s.run.ids.join(', ')}: ${e.message}`);
101
132
  advance(project);
102
133
  }
103
134
  broadcast({ type: 'board', project });
@@ -108,7 +139,7 @@ function schedule(run, at) {
108
139
  /**
109
140
  * A run some of whose items hit the usage limit: the main queue would hit it too, so it pauses,
110
141
  * and those items run again at the reset when the message names a time. The reason, for its
111
- * `run` event.
142
+ * report.
112
143
  */
113
144
  function limited(run, result) {
114
145
  const items = result
@@ -127,18 +158,28 @@ function limited(run, result) {
127
158
  schedule({ ...run, ids: hit.map((i) => i.id), fresh: false }, at);
128
159
  const when = at ? `they resume at ${at.toTimeString().slice(0, 5)}` : 'run them after the reset';
129
160
  const queue = run.mode === 'main' ? ', the queue is paused' : '';
130
- return `usage limit${resets ? `, resets ${resets}` : ''}${queue}: ${when}`;
161
+ const reason = `usage limit${resets ? `, resets ${resets}` : ''}${queue}: ${when}`;
162
+ trace(project, 'limit', `${hit.map((i) => i.id).join(', ')}: ${reason}`);
163
+ return reason;
131
164
  }
132
165
  /** Start the next queued batch when the checkout is free: after a run, or on the operator's resume. */
133
166
  function advance(project) {
134
167
  if (paused.has(project) || activeRuns(project).some((r) => r.mode === 'main'))
135
168
  return;
136
169
  const next = queues.get(project)?.shift();
137
- if (next)
138
- spawnRun(next, next.mode);
170
+ if (!next)
171
+ return;
172
+ trace(project, 'queue-run', `the queue starts ${next.ids.join(', ')}`);
173
+ spawnRun(next, next.mode);
139
174
  }
140
175
  /** The operator resumes a paused queue (after fixing what stopped it). */
141
176
  export function resumeQueue(project) {
177
+ // The limit still holds until its reset: the next run would hit it again.
178
+ const s = scheduled.get(project);
179
+ if (s && limitPaused.has(project))
180
+ 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`);
181
+ if (paused.has(project))
182
+ trace(project, 'queue-resumed', 'the operator resumed the queue');
142
183
  paused.delete(project);
143
184
  limitPaused.delete(project);
144
185
  advance(project);
@@ -201,16 +242,25 @@ function spawnRun(q, mode) {
201
242
  ? ''
202
243
  : limited(q, result) ||
203
244
  (stop ? 'the queue is paused: fix the cause, then resume it on the board' : '');
245
+ if (stop)
246
+ trace(p.name, 'queue-paused', `after ${ids.join(', ')}: ${note}`);
247
+ const handed = result?.items;
248
+ for (const i of handed ?? [])
249
+ if (kept && i.handoffs)
250
+ trace(p.name, 'handoff', `${i.id}: ${i.handoffs} context handoff(s) to a fresh session`);
204
251
  const brief = err && briefError(err);
205
- broadcast({
206
- type: 'run',
207
- project: p.name,
252
+ const report = {
253
+ id: `r${++reportSeq}`,
254
+ at: new Date().toISOString(),
208
255
  ids,
209
256
  ok,
210
257
  result,
211
258
  err: (brief + message).trim(),
212
259
  note,
213
- });
260
+ };
261
+ // A project removed meanwhile has no board to show it on.
262
+ if (kept)
263
+ reports.set(p.name, [...(reports.get(p.name) ?? []), report].slice(-KEEP_REPORTS));
214
264
  // The checkout is free now, for whatever queue the name has: a project added back meanwhile.
215
265
  if (mode === 'main')
216
266
  advance(p.name);
@@ -222,7 +272,7 @@ function spawnRun(q, mode) {
222
272
  }
223
273
  /**
224
274
  * 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
275
+ * is a run report with the harness report, or what went wrong. `fresh` starts new agent
226
276
  * sessions where the harness would resume the interrupted ones.
227
277
  */
228
278
  export function startRun(entry, p, ids, mode = 'worktree', fresh = false) {
@@ -235,6 +285,8 @@ export function startRun(entry, p, ids, mode = 'worktree', fresh = false) {
235
285
  // now, and the limit's pause with it; the rest of the schedule stays.
236
286
  const s = scheduled.get(p.name);
237
287
  if (s?.run.ids.some((id) => ids.includes(id))) {
288
+ const early = s.run.ids.filter((id) => ids.includes(id));
289
+ trace(p.name, 'resume-cancelled', `${early.join(', ')}: the operator runs them before the reset`);
238
290
  s.run = { ...s.run, ids: s.run.ids.filter((id) => !ids.includes(id)) };
239
291
  if (!s.run.ids.length)
240
292
  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}}