smol-symphony 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/AGENTS.md +105 -38
  2. package/PRODUCT.md +2 -1
  3. package/README.md +195 -98
  4. package/SPEC.md +543 -1915
  5. package/WORKFLOW.md +654 -179
  6. package/WORKFLOW.template.md +761 -121
  7. package/dist/acp-bridge.js +324 -0
  8. package/dist/acp-bridge.js.map +1 -0
  9. package/dist/actions/cache.js +191 -0
  10. package/dist/actions/cache.js.map +1 -0
  11. package/dist/actions/effects.js +41 -0
  12. package/dist/actions/effects.js.map +1 -0
  13. package/dist/actions/executor.js +570 -0
  14. package/dist/actions/executor.js.map +1 -0
  15. package/dist/actions/index.js +13 -0
  16. package/dist/actions/index.js.map +1 -0
  17. package/dist/actions/parsing.js +273 -0
  18. package/dist/actions/parsing.js.map +1 -0
  19. package/dist/actions/predicate-env.js +27 -0
  20. package/dist/actions/predicate-env.js.map +1 -0
  21. package/dist/actions/predicates.js +49 -0
  22. package/dist/actions/predicates.js.map +1 -0
  23. package/dist/actions/templating.js +66 -0
  24. package/dist/actions/templating.js.map +1 -0
  25. package/dist/actions/types.js +15 -0
  26. package/dist/actions/types.js.map +1 -0
  27. package/dist/agent/acp.js +232 -63
  28. package/dist/agent/acp.js.map +1 -1
  29. package/dist/agent/adapter-names.js +159 -0
  30. package/dist/agent/adapter-names.js.map +1 -0
  31. package/dist/agent/adapters.js +338 -102
  32. package/dist/agent/adapters.js.map +1 -1
  33. package/dist/agent/credential-extractors.js +342 -0
  34. package/dist/agent/credential-extractors.js.map +1 -0
  35. package/dist/agent/credential-secrets.js +628 -0
  36. package/dist/agent/credential-secrets.js.map +1 -0
  37. package/dist/agent/credential-ticker.js +57 -0
  38. package/dist/agent/credential-ticker.js.map +1 -0
  39. package/dist/agent/gondolin-creds-staging.js +356 -0
  40. package/dist/agent/gondolin-creds-staging.js.map +1 -0
  41. package/dist/agent/gondolin-dispatch.js +375 -0
  42. package/dist/agent/gondolin-dispatch.js.map +1 -0
  43. package/dist/agent/gondolin.js +124 -0
  44. package/dist/agent/gondolin.js.map +1 -0
  45. package/dist/agent/runner-decisions.js +134 -0
  46. package/dist/agent/runner-decisions.js.map +1 -0
  47. package/dist/agent/runner.js +1352 -290
  48. package/dist/agent/runner.js.map +1 -1
  49. package/dist/agent/tool-call-summary.js +102 -0
  50. package/dist/agent/tool-call-summary.js.map +1 -0
  51. package/dist/agent/vm-acp-mapping.js +73 -0
  52. package/dist/agent/vm-acp-mapping.js.map +1 -0
  53. package/dist/agent/vm-guards.js +262 -0
  54. package/dist/agent/vm-guards.js.map +1 -0
  55. package/dist/agent/vm-port.js +22 -0
  56. package/dist/agent/vm-port.js.map +1 -0
  57. package/dist/agent/vm-process-registry.js +79 -0
  58. package/dist/agent/vm-process-registry.js.map +1 -0
  59. package/dist/bin/cli-args.js +105 -0
  60. package/dist/bin/cli-args.js.map +1 -0
  61. package/dist/bin/symphony.js +719 -130
  62. package/dist/bin/symphony.js.map +1 -1
  63. package/dist/errors.js +15 -0
  64. package/dist/errors.js.map +1 -0
  65. package/dist/http-disk.js +135 -0
  66. package/dist/http-disk.js.map +1 -0
  67. package/dist/http-handlers.js +180 -0
  68. package/dist/http-handlers.js.map +1 -0
  69. package/dist/http.js +1476 -764
  70. package/dist/http.js.map +1 -1
  71. package/dist/issues.js +178 -0
  72. package/dist/issues.js.map +1 -0
  73. package/dist/logging.js +163 -5
  74. package/dist/logging.js.map +1 -1
  75. package/dist/mcp.js +391 -163
  76. package/dist/mcp.js.map +1 -1
  77. package/dist/memory.js +85 -0
  78. package/dist/memory.js.map +1 -0
  79. package/dist/orchestrator-decisions.js +331 -0
  80. package/dist/orchestrator-decisions.js.map +1 -0
  81. package/dist/orchestrator.js +1189 -303
  82. package/dist/orchestrator.js.map +1 -1
  83. package/dist/prompt.js +5 -5
  84. package/dist/prompt.js.map +1 -1
  85. package/dist/reconciler/cache.js +65 -0
  86. package/dist/reconciler/cache.js.map +1 -0
  87. package/dist/reconciler/index.js +448 -0
  88. package/dist/reconciler/index.js.map +1 -0
  89. package/dist/reconciler/ledger.js +131 -0
  90. package/dist/reconciler/ledger.js.map +1 -0
  91. package/dist/reconciler/pr-adapters.js +174 -0
  92. package/dist/reconciler/pr-adapters.js.map +1 -0
  93. package/dist/reconciler/pr-decide.js +167 -0
  94. package/dist/reconciler/pr-decide.js.map +1 -0
  95. package/dist/reconciler/pr.js +422 -0
  96. package/dist/reconciler/pr.js.map +1 -0
  97. package/dist/reconciler/types.js +12 -0
  98. package/dist/reconciler/types.js.map +1 -0
  99. package/dist/reconciler/vm.js +243 -0
  100. package/dist/reconciler/vm.js.map +1 -0
  101. package/dist/reconciler/workspace-defaults.js +83 -0
  102. package/dist/reconciler/workspace-defaults.js.map +1 -0
  103. package/dist/reconciler/workspace.js +272 -0
  104. package/dist/reconciler/workspace.js.map +1 -0
  105. package/dist/runlog.js +403 -0
  106. package/dist/runlog.js.map +1 -0
  107. package/dist/scaffold.js +165 -0
  108. package/dist/scaffold.js.map +1 -0
  109. package/dist/trackers/local.js +234 -133
  110. package/dist/trackers/local.js.map +1 -1
  111. package/dist/trackers/types.js +1 -1
  112. package/dist/trackers/types.js.map +1 -1
  113. package/dist/types.js +1 -1
  114. package/dist/util/clock.js +12 -0
  115. package/dist/util/clock.js.map +1 -0
  116. package/dist/util/crypto.js +25 -0
  117. package/dist/util/crypto.js.map +1 -0
  118. package/dist/util/frontmatter.js +70 -0
  119. package/dist/util/frontmatter.js.map +1 -0
  120. package/dist/util/fs-issues.js +22 -0
  121. package/dist/util/fs-issues.js.map +1 -0
  122. package/dist/util/process.js +152 -0
  123. package/dist/util/process.js.map +1 -0
  124. package/dist/util/workspace-key.js +10 -0
  125. package/dist/util/workspace-key.js.map +1 -0
  126. package/dist/workflow-loader.js +147 -0
  127. package/dist/workflow-loader.js.map +1 -0
  128. package/dist/workflow.js +656 -219
  129. package/dist/workflow.js.map +1 -1
  130. package/dist/workspace-types.js +8 -0
  131. package/dist/workspace-types.js.map +1 -0
  132. package/dist/workspace.js +367 -120
  133. package/dist/workspace.js.map +1 -1
  134. package/package.json +14 -6
  135. package/scripts/vm-agent.mjs +211 -0
  136. package/dist/agent/codex.js +0 -439
  137. package/dist/agent/codex.js.map +0 -1
  138. package/dist/agent/smolvm.js +0 -174
  139. package/dist/agent/smolvm.js.map +0 -1
  140. package/scripts/build-vm.sh +0 -67
package/dist/http.js CHANGED
@@ -2,10 +2,12 @@
2
2
  // watching status. The UI polls `/api/v1/state` so no SSE/WebSocket infrastructure is
3
3
  // needed.
4
4
  import { createServer } from 'node:http';
5
- import { mkdir, readdir, readFile, writeFile, stat } from 'node:fs/promises';
5
+ import { readFile } from 'node:fs/promises';
6
6
  import path from 'node:path';
7
- import { sanitizeWorkspaceKey } from './workspace.js';
8
7
  import { log } from './logging.js';
8
+ import { writeIssueFile } from './issues.js';
9
+ import { matchRoute, resolvePartialName, extractBearerToken, classifyContentType, checkSteeringCsrf, checkTriageCsrf, extractFormText, extractJsonText, decideCreateIssue, decideTriageTransition, } from './http-handlers.js';
10
+ import { listIssuesFromDisk, readIssueFromDisk, } from './http-disk.js';
9
11
  function jsonResponse(res, status, body) {
10
12
  const text = JSON.stringify(body);
11
13
  res.statusCode = status;
@@ -76,10 +78,12 @@ function isSameOriginRequest(req) {
76
78
  return !(req.headers['referer'] && req.headers['referer'].length > 0);
77
79
  }
78
80
  // HTMX's default response handling does not swap content on 4xx/5xx responses, so
79
- // returning a 4xx with an HTML partial silently leaves the panel stale. For HTMX
80
- // callers we therefore return 200 with the attention partial plus a one-line error
81
- // banner; the operator's textarea text survives via hx-preserve. JSON callers still
82
- // get the appropriate status code and a structured error.
81
+ // returning a 4xx with an HTML partial silently leaves the region stale. For HTMX
82
+ // callers (currently only steering-reply, which targets the #ticker region) we
83
+ // return 200 with the ticker partial plus an inline error chip; the operator's
84
+ // textarea content sits in the board row (separate hx-preserve cycle) and is
85
+ // unaffected. JSON callers still get the appropriate status code and a structured
86
+ // error.
83
87
  async function htmxOrJsonError(res, isHtmx, orch, view, jsonStatus, code, message) {
84
88
  if (isHtmx) {
85
89
  const p = await gatherPartialInputs(orch, view);
@@ -108,108 +112,6 @@ async function readTextBody(req, maxBytes = 1_000_000) {
108
112
  req.on('error', reject);
109
113
  });
110
114
  }
111
- // Used by the dashboard form. The local tracker stores issues at:
112
- // <tracker.root>/<state>/<identifier>.md
113
- // with YAML front matter. Identifier sanitization re-uses the workspace key rules so the
114
- // file name is always safe across the rest of the orchestrator.
115
- async function writeIssueFile(input) {
116
- const ident = sanitizeWorkspaceKey(input.identifier);
117
- if (!ident)
118
- throw new Error('identifier must contain at least one allowed character');
119
- const stateDir = path.join(input.trackerRoot, input.state);
120
- await mkdir(stateDir, { recursive: true });
121
- const filePath = path.join(stateDir, `${ident}.md`);
122
- try {
123
- await stat(filePath);
124
- throw new Error(`issue ${ident} already exists at ${filePath}`);
125
- }
126
- catch (err) {
127
- if (err.code !== 'ENOENT')
128
- throw err;
129
- }
130
- const now = new Date().toISOString();
131
- const fm = {
132
- id: ident,
133
- identifier: ident,
134
- title: input.title,
135
- created_at: now,
136
- updated_at: now,
137
- };
138
- if (typeof input.priority === 'number')
139
- fm.priority = input.priority;
140
- if (input.labels && input.labels.length > 0)
141
- fm.labels = input.labels;
142
- if (input.blocked_by && input.blocked_by.length > 0)
143
- fm.blocked_by = input.blocked_by;
144
- const yamlLines = ['---'];
145
- for (const [k, v] of Object.entries(fm)) {
146
- if (Array.isArray(v)) {
147
- yamlLines.push(`${k}: [${v.map((x) => JSON.stringify(x)).join(', ')}]`);
148
- }
149
- else if (typeof v === 'string') {
150
- yamlLines.push(`${k}: ${JSON.stringify(v)}`);
151
- }
152
- else {
153
- yamlLines.push(`${k}: ${String(v)}`);
154
- }
155
- }
156
- yamlLines.push('---', '');
157
- const body = (input.description ?? '').trim();
158
- const content = yamlLines.join('\n') + (body.length > 0 ? body + '\n' : '');
159
- await writeFile(filePath, content, 'utf8');
160
- return { path: filePath, identifier: ident, state: input.state };
161
- }
162
- // Browse current issues directly from disk so the UI can show items that are neither
163
- // currently running nor in the retry queue.
164
- async function listIssuesFromDisk(trackerRoot) {
165
- const out = [];
166
- let entries;
167
- try {
168
- entries = await readdir(trackerRoot);
169
- }
170
- catch {
171
- return out;
172
- }
173
- for (const stateDir of entries) {
174
- const dirPath = path.join(trackerRoot, stateDir);
175
- let st;
176
- try {
177
- st = await stat(dirPath);
178
- }
179
- catch {
180
- continue;
181
- }
182
- if (!st.isDirectory())
183
- continue;
184
- let files;
185
- try {
186
- files = await readdir(dirPath);
187
- }
188
- catch {
189
- continue;
190
- }
191
- for (const f of files) {
192
- if (!f.endsWith('.md'))
193
- continue;
194
- const filePath = path.join(dirPath, f);
195
- let text;
196
- try {
197
- text = await readFile(filePath, 'utf8');
198
- }
199
- catch {
200
- continue;
201
- }
202
- let title = f.slice(0, -3);
203
- const titleMatch = /^---[\s\S]*?\ntitle:\s*(.+)\n[\s\S]*?---/m.exec(text);
204
- if (titleMatch) {
205
- title = titleMatch[1].trim().replace(/^["'](.*)["']$/, '$1');
206
- }
207
- out.push({ identifier: f.slice(0, -3), state: stateDir, title });
208
- }
209
- }
210
- out.sort((a, b) => a.identifier.localeCompare(b.identifier));
211
- return out;
212
- }
213
115
  async function gatherPartialInputs(orch, view) {
214
116
  const trackerRoot = view.trackerRoot ?? '(unset)';
215
117
  let diskIssues = [];
@@ -225,15 +127,39 @@ async function gatherPartialInputs(orch, view) {
225
127
  workflowName: path.basename(view.workflowPath || 'workflow.md'),
226
128
  workflowPath: view.workflowPath || '',
227
129
  trackerRoot,
228
- activeStates: view.activeStates,
229
- terminalStates: view.terminalStates,
130
+ states: view.states,
230
131
  snapshot: orch.snapshot(),
231
132
  diskIssues,
232
133
  };
233
134
  }
135
+ /**
136
+ * Map a declared state name to its pill colour class. Active states pulse green
137
+ * (`running`), terminals settle into the muted "done" palette, holdings (Triage)
138
+ * use the same idle palette they always have. Unknown names — issues sitting in a
139
+ * directory that's no longer declared in `states:` — fall back to idle so the
140
+ * dashboard still renders something legible.
141
+ */
142
+ function pillClassForState(states, stateName) {
143
+ const lower = stateName.toLowerCase();
144
+ const match = states.find((s) => s.name.toLowerCase() === lower);
145
+ if (!match)
146
+ return 'idle';
147
+ switch (match.role) {
148
+ case 'active':
149
+ return 'running';
150
+ case 'terminal':
151
+ return 'done';
152
+ case 'holding':
153
+ return 'idle';
154
+ }
155
+ }
234
156
  function trackerStatus(snap) {
235
157
  const awaiting = snap.running.some((r) => r.steering_requested);
236
- if (awaiting || snap.retrying.length > 0)
158
+ // Only failure-backoff retries warrant the operator's eyes. A `continuation`
159
+ // is the calm post-transition resume of a clean handoff (e.g. Review → Todo)
160
+ // and already holds its slot, so it must not flip the whole dashboard into
161
+ // "attention" (issue 146).
162
+ if (awaiting || snap.retrying.some((r) => r.kind === 'failure'))
237
163
  return 'attention';
238
164
  if (snap.running.length > 0)
239
165
  return 'working';
@@ -257,13 +183,20 @@ function formatRuntime(seconds) {
257
183
  const rem = m % 60;
258
184
  return rem === 0 ? `${h}h` : `${h}h${rem}m`;
259
185
  }
260
- function formatTimeShort(iso) {
186
+ // `hour12: false` pins the 24-hour clock so the dashboard reads e.g. `14:30:05`
187
+ // instead of locale-default AM/PM (`02:30:05 PM`) on 12-hour locales like en-US.
188
+ export function formatTimeShort(iso) {
261
189
  if (!iso)
262
190
  return '';
263
191
  const d = new Date(iso);
264
192
  if (Number.isNaN(d.getTime()))
265
193
  return '';
266
- return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
194
+ return d.toLocaleTimeString(undefined, {
195
+ hour: '2-digit',
196
+ minute: '2-digit',
197
+ second: '2-digit',
198
+ hour12: false,
199
+ });
267
200
  }
268
201
  function truncate(text, max) {
269
202
  if (!text)
@@ -279,53 +212,301 @@ function renderHeaderPartial(p) {
279
212
  const statusLabel = status === 'attention' ? 'attention' : status === 'working' ? 'working' : 'idle';
280
213
  return `<span class="badge badge-${status}" aria-label="tracker state: ${statusLabel}">${statusLabel}</span>`;
281
214
  }
215
+ // ─── Top-of-page attention ticker ───────────────────────────────────────
216
+ // A horizontal strip listing what currently needs the operator's eyes: pending
217
+ // steering replies and stuck retries. Each identifier is an anchor link to the
218
+ // row inside the board — the operator scans the ticker, jumps to the column,
219
+ // and replies in place. The steering reply form itself now lives inline on its
220
+ // row in the board, so this ticker stays thin.
282
221
  function renderAttentionPartial(p, opts) {
283
222
  const awaiting = p.snapshot.running.filter((r) => r.steering_requested);
284
- const retrying = p.snapshot.retrying;
223
+ // Continuations are calm post-transition resumes, not failures — keep them
224
+ // off the attention ticker so a clean handoff doesn't read as a stuck retry
225
+ // (issue 146). Only failure-backoff retries surface here.
226
+ const retrying = p.snapshot.retrying.filter((r) => r.kind === 'failure');
285
227
  const errorMessage = opts?.errorMessage?.trim() ?? '';
286
- if (awaiting.length === 0 && retrying.length === 0 && !errorMessage)
287
- return '';
288
- const errorBanner = errorMessage
289
- ? `<div class="steering-error" role="alert">${escapeHtml(errorMessage)}</div>`
228
+ const segments = [];
229
+ if (awaiting.length > 0) {
230
+ const links = awaiting
231
+ .map((r) => `<a href="#row-${escapeHtml(r.issue_identifier)}" class="tick-id">${escapeHtml(r.issue_identifier)}</a>`)
232
+ .join(' ');
233
+ segments.push(`<span class="tick tick-await"><span class="tick-label">${awaiting.length} awaiting</span> ${links}</span>`);
234
+ }
235
+ if (retrying.length > 0) {
236
+ const stuck = retrying.filter((r) => r.error).length;
237
+ const links = retrying
238
+ .map((r) => `<a href="#row-${escapeHtml(r.issue_identifier)}" class="tick-id">${escapeHtml(r.issue_identifier)}</a>`)
239
+ .join(' ');
240
+ const label = stuck > 0
241
+ ? `${retrying.length} retrying · ${stuck} with error`
242
+ : `${retrying.length} retrying`;
243
+ segments.push(`<span class="tick tick-retry"><span class="tick-label">${label}</span> ${links}</span>`);
244
+ }
245
+ if (errorMessage) {
246
+ segments.push(`<span class="tick tick-err" role="alert">${escapeHtml(errorMessage)}</span>`);
247
+ }
248
+ return segments.join('');
249
+ }
250
+ // ─── Board (kanban) ─────────────────────────────────────────────────────
251
+ // One column per declared state in workflow declaration order. Each on-disk
252
+ // issue renders as a flat row in its state's column; transient orchestrator
253
+ // state (running / retrying / awaiting steering) is overlaid via a pill, a
254
+ // metadata trail, and — for awaiting — an inline question + reply form. Issues
255
+ // sitting in an undeclared state directory (e.g. after a workflow rename) get
256
+ // trailing columns so they stay visible until reconciled rather than silently
257
+ // dropping out of the dashboard.
258
+ function renderBoardPartial(p) {
259
+ const runningById = new Map();
260
+ for (const r of p.snapshot.running)
261
+ runningById.set(r.issue_identifier, r);
262
+ const retryingById = new Map();
263
+ for (const r of p.snapshot.retrying)
264
+ retryingById.set(r.issue_identifier, r);
265
+ // Group disk issues by lower-cased state name. LocalMarkdownTracker matches
266
+ // state directories case-insensitively against the declared `states:` keys
267
+ // (src/trackers/local.ts §scanAllAt: `declared.has(dirEntry.toLowerCase())`),
268
+ // so the dashboard must mirror that or a `todo/` directory under declared
269
+ // `Todo` would be misclassified as an orphan and split off into its own
270
+ // trailing column. `displayName` keeps the on-disk casing for orphan column
271
+ // headers (the only place we need to show a non-declared name).
272
+ const byStateLower = new Map();
273
+ for (const i of p.diskIssues) {
274
+ const key = i.state.toLowerCase();
275
+ const entry = byStateLower.get(key);
276
+ if (entry)
277
+ entry.items.push(i);
278
+ else
279
+ byStateLower.set(key, { displayName: i.state, items: [i] });
280
+ }
281
+ // Terminal columns (Done, Cancelled, …) are deliberately omitted from the
282
+ // board. The orchestrator's glance test — "is anything stuck, is anything
283
+ // running" — doesn't read off finished work, and a wall of archived rows
284
+ // dilutes the active surface. Terminal issues stay reachable through their
285
+ // /issues/<id> detail page; a dedicated history surface is the natural place
286
+ // to surface them again once we have something to anchor the row on (a PR
287
+ // link, a final token total, a closed-at timestamp).
288
+ const declared = p.states
289
+ .filter((state) => state.role !== 'terminal')
290
+ .map((state) => {
291
+ const items = byStateLower.get(state.name.toLowerCase())?.items ?? [];
292
+ return renderColumn(state, items, runningById, retryingById);
293
+ });
294
+ // Orphan columns: on-disk state directories whose names are not in the
295
+ // declared `states:` set. These render even when they look "terminal" because
296
+ // they're an error condition the operator needs to see (a workflow rename
297
+ // left files behind), not a clean archive.
298
+ const declaredLower = new Set(p.states.map((s) => s.name.toLowerCase()));
299
+ const orphans = [];
300
+ for (const [key, entry] of byStateLower) {
301
+ if (declaredLower.has(key))
302
+ continue;
303
+ orphans.push({ name: entry.displayName, items: entry.items });
304
+ }
305
+ orphans.sort((a, b) => a.name.localeCompare(b.name));
306
+ const orphanColumns = orphans.map((o) => renderColumn({ name: o.name, role: 'terminal' }, o.items, runningById, retryingById, { orphan: true }));
307
+ return `<div class="kanban">${declared.join('')}${orphanColumns.join('')}</div>`;
308
+ }
309
+ function renderColumn(state, items, runningById, retryingById, opts) {
310
+ const orphan = opts?.orphan === true;
311
+ const canAdd = !orphan && state.role !== 'terminal';
312
+ // Sort within a column so anything needing attention floats to the top:
313
+ // awaiting → running → retrying → idle. Ties break on identifier for stability.
314
+ const sorted = items.slice().sort((a, b) => {
315
+ const rank = (id) => {
316
+ const run = runningById.get(id);
317
+ if (run?.steering_requested)
318
+ return 0;
319
+ if (run)
320
+ return 1;
321
+ const retry = retryingById.get(id);
322
+ // A continuation reads as "running" (slot held across the handoff), so it
323
+ // ranks with the running tier; only failure-backoff retries sort below.
324
+ if (retry?.kind === 'continuation')
325
+ return 1;
326
+ if (retry)
327
+ return 2;
328
+ return 3;
329
+ };
330
+ const ar = rank(a.identifier);
331
+ const br = rank(b.identifier);
332
+ if (ar !== br)
333
+ return ar - br;
334
+ return a.identifier.localeCompare(b.identifier);
335
+ });
336
+ const rowsHtml = sorted
337
+ .map((i) => renderIssueRow(i, state, runningById.get(i.identifier), retryingById.get(i.identifier), { orphan }))
338
+ .join('');
339
+ const addBtn = canAdd
340
+ ? `<button type="button" class="col-add"
341
+ data-target-state="${escapeHtml(state.name)}"
342
+ aria-label="add issue to ${escapeHtml(state.name)}"
343
+ title="add issue to ${escapeHtml(state.name)}">+</button>`
290
344
  : '';
291
- const steeringBlocks = awaiting.map((r) => renderSteeringBlock(r)).join('');
292
- const retryBlocks = retrying.length > 0 ? renderRetryBlock(retrying) : '';
293
- return `<h2 class="attention-title">attention</h2>
294
- ${errorBanner}${steeringBlocks}${retryBlocks}`;
345
+ const emptyHint = sorted.length === 0 && state.role === 'active' && !orphan
346
+ ? `<p class="col-empty">drop into <code>${escapeHtml(state.name)}/</code></p>`
347
+ : '';
348
+ const orphanBadge = orphan
349
+ ? `<span class="col-orphan" title="state not declared in workflow.md">undeclared</span>`
350
+ : '';
351
+ return `<section class="col col-${escapeHtml(state.role)}${orphan ? ' col-orphan-wrap' : ''}" data-state="${escapeHtml(state.name)}">
352
+ <header class="col-head">
353
+ <span class="col-name">${escapeHtml(state.name)}</span>
354
+ <span class="col-count">${sorted.length}</span>
355
+ ${orphanBadge}
356
+ <span class="grow"></span>
357
+ ${addBtn}
358
+ </header>
359
+ <div class="col-body">${rowsHtml}${emptyHint}</div>
360
+ </section>`;
361
+ }
362
+ function rowFlags(state, running, retrying, orphan) {
363
+ const isAwaiting = !!running?.steering_requested;
364
+ // A `continuation` retry is the slot-holding resume of a clean handoff (e.g.
365
+ // Review → Todo): the orchestrator still holds the issue's slot and redispatches
366
+ // within ~1s, so the card stays "running" across the handoff instead of flashing
367
+ // a transient pill. Only `failure`-backoff retries are "retrying" (issues
368
+ // 146/154; the design system's Three-Pill Rule — running/retrying/idle only).
369
+ const isContinuation = retrying?.kind === 'continuation';
370
+ return {
371
+ isAwaiting,
372
+ isRunning: (!!running || isContinuation) && !isAwaiting,
373
+ isRetrying: !!retrying && !isContinuation,
374
+ isHolding: !orphan && state.role === 'holding',
375
+ orphan,
376
+ };
377
+ }
378
+ function rowPillHtml(f) {
379
+ if (f.isAwaiting)
380
+ return '<span class="pill awaiting">awaiting</span>';
381
+ if (f.isRunning)
382
+ return '<span class="pill running">running</span>';
383
+ if (f.isRetrying)
384
+ return '<span class="pill retrying">retrying</span>';
385
+ return '';
386
+ }
387
+ function rowMetaHtml(running, retrying) {
388
+ if (running) {
389
+ const tokens = formatTokens(running.tokens.total_tokens || 0);
390
+ return `<span class="row-meta">turn ${running.turn_count} · ${escapeHtml(tokens)} tok</span>`;
391
+ }
392
+ // A `continuation` carries no operator-facing meta: the card reads as "running"
393
+ // across the handoff (see rowFlags), so the brief slot-holding window stays
394
+ // silent rather than surfacing an "attempt N · due" trail. Only `failure`
395
+ // retries show the backoff schedule.
396
+ if (retrying && retrying.kind !== 'continuation') {
397
+ const dueAt = formatTimeShort(retrying.due_at) || '—';
398
+ return `<span class="row-meta">attempt ${retrying.attempt} · due ${escapeHtml(dueAt)}</span>`;
399
+ }
400
+ return '';
295
401
  }
296
- function renderSteeringBlock(r) {
402
+ function rowPeekHtml(running, isAwaiting) {
403
+ if (!running || isAwaiting)
404
+ return '';
405
+ const text = truncate(running.last_message ?? running.last_event ?? '', 110);
406
+ return text ? `<div class="row-peek dim">${escapeHtml(text)}</div>` : '';
407
+ }
408
+ function rowRetryErrHtml(retrying) {
409
+ if (!retrying?.error)
410
+ return '';
411
+ return `<div class="row-err">${escapeHtml(truncate(retrying.error, 200))}</div>`;
412
+ }
413
+ function rowTriageActionsHtml(ident, f) {
414
+ if (!(f.isHolding && !f.isRunning && !f.isAwaiting && !f.isRetrying))
415
+ return '';
416
+ const enc = encodeURIComponent(ident);
417
+ return `<div class="row-actions">
418
+ <form class="row-action"
419
+ hx-post="/api/v1/issues/${enc}/approve"
420
+ hx-target="#board" hx-swap="morph:innerHTML">
421
+ <button type="submit" class="ghost-sm" title="approve into the first active state">approve</button>
422
+ </form>
423
+ <form class="row-action"
424
+ hx-post="/api/v1/issues/${enc}/discard"
425
+ hx-target="#board" hx-swap="morph:innerHTML">
426
+ <button type="submit" class="ghost-sm danger" title="discard the proposal">discard</button>
427
+ </form>
428
+ </div>`;
429
+ }
430
+ function rowClassList(f, retryHasErr) {
431
+ return [
432
+ 'row',
433
+ f.isAwaiting ? 'row-await' : '',
434
+ f.isRunning ? 'row-run' : '',
435
+ f.isRetrying && retryHasErr ? 'row-retry-stuck' : '',
436
+ f.orphan ? 'row-orphan' : '',
437
+ ]
438
+ .filter(Boolean)
439
+ .join(' ');
440
+ }
441
+ function renderIssueRow(i, state, running, retrying, opts) {
442
+ const f = rowFlags(state, running, retrying, opts?.orphan === true);
443
+ const escIdent = escapeHtml(i.identifier);
444
+ const href = `/issues/${encodeURIComponent(i.identifier)}`;
445
+ const steering = f.isAwaiting ? renderSteeringInline(running) : '';
446
+ const classes = rowClassList(f, !!retrying?.error);
447
+ return `<article class="${classes}" id="row-${escIdent}">
448
+ <div class="row-head">
449
+ <a class="row-ident" href="${href}" title="open ${escIdent}">${escIdent}</a>
450
+ ${rowPillHtml(f)}
451
+ <span class="grow"></span>
452
+ ${rowMetaHtml(running, retrying)}
453
+ </div>
454
+ <a class="row-title" href="${href}">${escapeHtml(i.title)}</a>
455
+ ${rowPeekHtml(running, f.isAwaiting)}
456
+ ${rowRetryErrHtml(retrying)}
457
+ ${rowTriageActionsHtml(i.identifier, f)}
458
+ ${steering}
459
+ </article>`;
460
+ }
461
+ function steeringSummaryLabel(hasOriginalTask, hasContext) {
462
+ if (hasOriginalTask && hasContext)
463
+ return 'original task & agent’s context';
464
+ if (hasOriginalTask)
465
+ return 'original task';
466
+ return 'agent’s context';
467
+ }
468
+ function steeringIssueBlock(title, body) {
469
+ if (!title && !body)
470
+ return '';
471
+ const titleHtml = title ? `<h3 class="issue-title">${escapeHtml(title)}</h3>` : '';
472
+ const bodyHtml = body ? `<p class="issue-body">${escapeHtml(body)}</p>` : '';
473
+ return `<div class="steering-task-label">issue</div>
474
+ ${titleHtml}
475
+ ${bodyHtml}`;
476
+ }
477
+ function steeringContextBlock(context) {
478
+ if (!context)
479
+ return '';
480
+ return `<div class="steering-task-label">agent’s context</div>
481
+ <pre class="context">${escapeHtml(context)}</pre>`;
482
+ }
483
+ function steeringExtraPanel(title, body, context) {
484
+ const hasOriginalTask = title.length > 0 || body.length > 0;
485
+ if (!hasOriginalTask && !context)
486
+ return '';
487
+ const label = steeringSummaryLabel(hasOriginalTask, context.length > 0);
488
+ return `<details class="steering-task">
489
+ <summary>${escapeHtml(label)}</summary>
490
+ <div class="steering-task-body">
491
+ ${steeringIssueBlock(title, body)}
492
+ ${steeringContextBlock(context)}
493
+ </div>
494
+ </details>`;
495
+ }
496
+ function renderSteeringInline(r) {
297
497
  const question = (r.steering_question ?? '').trim() || '(no question text)';
298
498
  const context = (r.steering_context ?? '').trim();
299
499
  const issueTitle = (r.issue_title ?? '').trim();
300
500
  const issueBody = (r.issue_body ?? '').trim();
301
- const hasOriginalTask = issueTitle.length > 0 || issueBody.length > 0;
302
- const hasAnyExtra = hasOriginalTask || context.length > 0;
303
- // The textarea is given a stable id and hx-preserve="true" so the every-2s repoll of
304
- // the attention zone doesn't wipe the operator's in-progress reply.
501
+ // Stable textarea id + hx-preserve so the every-2s board repoll keeps the
502
+ // operator's draft reply intact across morph swaps.
305
503
  const textareaId = `reply-${r.issue_identifier}`;
306
- const summaryLabel = hasOriginalTask && context ? 'original task & agent’s context'
307
- : hasOriginalTask ? 'original task'
308
- : 'agent’s context';
309
- return `<article class="steering" data-identifier="${escapeHtml(r.issue_identifier)}">
310
- <header class="steering-head">
311
- <strong class="ident">${escapeHtml(r.issue_identifier)}</strong>
312
- <span class="pill awaiting">awaiting</span>
313
- <span class="turn"><span class="dim">turn</span> ${r.turn_count}</span>
314
- </header>
315
- <p class="question-primary">${escapeHtml(question)}</p>
316
- ${hasAnyExtra ? `<details class="steering-task">
317
- <summary>${escapeHtml(summaryLabel)}</summary>
318
- <div class="steering-task-body">
319
- ${hasOriginalTask ? `<div class="steering-task-label">issue</div>
320
- ${issueTitle ? `<h3 class="issue-title">${escapeHtml(issueTitle)}</h3>` : ''}
321
- ${issueBody ? `<p class="issue-body">${escapeHtml(issueBody)}</p>` : ''}` : ''}
322
- ${context ? `<div class="steering-task-label">agent’s context</div>
323
- <pre class="context">${escapeHtml(context)}</pre>` : ''}
324
- </div>
325
- </details>` : ''}
504
+ return `<div class="steering">
505
+ <div class="steering-q">${renderMarkdown(question)}</div>
506
+ ${steeringExtraPanel(issueTitle, issueBody, context)}
326
507
  <form class="reply"
327
508
  hx-post="/api/v1/issues/${encodeURIComponent(r.issue_identifier)}/steering-reply"
328
- hx-target="#attention" hx-swap="morph:innerHTML">
509
+ hx-target="#ticker" hx-swap="morph:innerHTML">
329
510
  <textarea id="${escapeHtml(textareaId)}" name="text" required
330
511
  placeholder="your reply…"
331
512
  aria-label="reply to ${escapeHtml(r.issue_identifier)}"
@@ -335,96 +516,53 @@ function renderSteeringBlock(r) {
335
516
  <button type="submit" class="ghost">send reply</button>
336
517
  </div>
337
518
  </form>
338
- </article>`;
339
- }
340
- function renderRetryBlock(rows) {
341
- const items = rows.map((r) => `<li>
342
- <strong class="ident">${escapeHtml(r.issue_identifier)}</strong>
343
- <span class="pill retrying">retrying</span>
344
- <span class="dim">attempt ${r.attempt}</span>
345
- <span class="dim">due ${escapeHtml(formatTimeShort(r.due_at) || '—')}</span>
346
- ${r.error ? `<div class="retry-err">${escapeHtml(truncate(r.error, 200))}</div>` : ''}
347
- </li>`).join('');
348
- return `<ul class="retry-list" aria-label="retry queue">${items}</ul>`;
349
- }
350
- function renderSessionsPartial(p) {
351
- const rows = p.snapshot.running;
352
- if (rows.length === 0) {
353
- return `<h2>sessions</h2>
354
- <p class="empty dim">no sessions running. agents wake when an issue lands in <code>${escapeHtml(p.activeStates[0] ?? 'Todo')}/</code>.</p>`;
355
- }
356
- const sessionItems = rows.map((r) => renderSessionRow(r)).join('');
357
- return `<h2>sessions <span class="count dim">(${rows.length})</span></h2>
358
- <ul class="sessions">${sessionItems}</ul>`;
359
- }
360
- function renderSessionRow(r) {
361
- const awaiting = r.steering_requested;
362
- const pill = awaiting
363
- ? '<span class="pill awaiting">awaiting</span>'
364
- : r.marked_done
365
- ? '<span class="pill done">done</span>'
366
- : '<span class="pill running">running</span>';
367
- const tokens = r.tokens.total_tokens || 0;
368
- const sessionId = r.session_id ? r.session_id.slice(0, 8) : '—';
369
- const lastMsg = truncate(r.last_message ?? r.last_event ?? '', 140);
370
- // The session row is two lines max: identifier + pill + turn + tokens + time on top,
371
- // last-message on bottom. Session id / state / last-event detail is surfaced via the
372
- // row's title attr so it's available on hover without adding a third line of noise.
373
- const rowTitle = [
374
- `session ${sessionId}`,
375
- r.state,
376
- r.last_event ?? null,
377
- `started ${formatTimeShort(r.started_at) || '—'}`,
378
- ]
379
- .filter(Boolean)
380
- .join(' · ');
381
- return `<li class="session" title="${escapeHtml(rowTitle)}">
382
- <div class="session-row">
383
- <strong class="ident">${escapeHtml(r.issue_identifier)}</strong>
384
- ${pill}
385
- <span class="turn"><span class="dim">turn</span> ${r.turn_count}</span>
386
- <span class="grow"></span>
387
- <span class="tokens dim">${formatTokens(tokens)} tok</span>
388
- </div>
389
- ${lastMsg ? `<div class="session-msg dim">${escapeHtml(lastMsg)}</div>` : ''}
390
- </li>`;
391
- }
392
- function renderDiskPartial(p) {
393
- const runIds = new Set(p.snapshot.running.map((r) => r.issue_identifier));
394
- const retryIds = new Set(p.snapshot.retrying.map((r) => r.issue_identifier));
395
- const active = new Set(p.activeStates);
396
- const filtered = p.diskIssues.filter((i) => active.has(i.state) && !runIds.has(i.identifier) && !retryIds.has(i.identifier));
397
- if (filtered.length === 0) {
398
- return `<h2>on disk</h2>
399
- <p class="empty dim">tracker is clean. drop a markdown file into <code>${escapeHtml(p.activeStates[0] ?? 'Todo')}/</code> or open <em>new issue</em> below.</p>`;
400
- }
401
- const items = filtered.map((i) => `<li>
402
- <span class="ident">${escapeHtml(i.identifier)}</span>
403
- <span class="state dim">${escapeHtml(i.state)}</span>
404
- <span class="title">${escapeHtml(i.title)}</span>
405
- </li>`).join('');
406
- return `<h2>on disk <span class="count dim">(${filtered.length})</span></h2>
407
- <ul class="disk">${items}</ul>`;
519
+ </div>`;
520
+ }
521
+ // ─── Right-side new-issue panel (the One Card) ─────────────────────────
522
+ // The dashboard's only card-shaped surface (DESIGN.md §5 One-Card Rule). Slides
523
+ // in from the right edge when the operator clicks a column's `+` button; the
524
+ // column the click came from pre-selects the state field. Non-modal: the board
525
+ // stays visible and interactive on the left. Esc closes; backdrop clicks do not
526
+ // (avoid accidental discards of in-progress drafts).
527
+ function renderNewIssuePanel(p) {
528
+ const targets = p.states.filter((s) => s.role !== 'terminal');
529
+ const firstActive = p.states.find((s) => s.role === 'active');
530
+ const defaultState = (firstActive ?? targets[0])?.name ?? '';
531
+ const options = targets
532
+ .map((s) => {
533
+ const sel = s.name === defaultState ? ' selected' : '';
534
+ return `<option value="${escapeHtml(s.name)}"${sel}>${escapeHtml(s.name)}</option>`;
535
+ })
536
+ .join('');
537
+ return `<aside id="new-panel" class="new-panel" aria-hidden="true" aria-labelledby="np-title">
538
+ <header class="np-head">
539
+ <h2 id="np-title">new issue</h2>
540
+ <button type="button" class="np-close" aria-label="close" title="close (esc)">×</button>
541
+ </header>
542
+ <form class="np-form" id="np-form">
543
+ <label for="np-state">column</label>
544
+ <select id="np-state" name="state" required>${options}</select>
545
+ <label for="np-title-input">title</label>
546
+ <input id="np-title-input" name="title" required autocomplete="off" placeholder="what needs doing?" />
547
+ <label for="np-description">description</label>
548
+ <textarea id="np-description" name="description" rows="6" placeholder="optional — context for the agent"></textarea>
549
+ <div class="np-actions">
550
+ <span class="np-msg" id="np-msg" role="status" aria-live="polite"></span>
551
+ <button type="submit" class="np-submit">create</button>
552
+ </div>
553
+ </form>
554
+ </aside>`;
408
555
  }
409
556
  function renderTotalsPartial(p) {
410
- const t = p.snapshot.codex_totals;
557
+ const t = p.snapshot.session_totals;
411
558
  if (!t || (t.input_tokens === 0 && t.output_tokens === 0 && t.seconds_running === 0))
412
559
  return '';
413
560
  return `${formatTokens(t.input_tokens)} in · ${formatTokens(t.output_tokens)} out · ${formatTokens(t.total_tokens)} total · ${formatRuntime(t.seconds_running)} runtime`;
414
561
  }
415
- function renderDashboardHtml(p) {
416
- const allStates = Array.from(new Set([...p.activeStates, ...p.terminalStates]));
417
- const stateOptions = allStates
418
- .map((s) => `<option value="${escapeHtml(s)}">${escapeHtml(s)}</option>`)
419
- .join('');
420
- const diskCount = p.diskIssues.filter((i) => p.activeStates.includes(i.state)).length;
421
- const formOpen = diskCount === 0 && p.snapshot.running.length === 0 && p.snapshot.retrying.length === 0;
422
- return `<!doctype html>
423
- <html lang="en"><head>
424
- <meta charset="utf-8">
425
- <meta name="viewport" content="width=device-width, initial-scale=1">
426
- <title>symphony · ${escapeHtml(p.workflowName)}</title>
427
- <style>
562
+ // Dashboard CSS, hoisted to a top-level constant so renderDashboardHtml stays
563
+ // within the imperative-shell max-lines budget. Pure presentation content — no
564
+ // interpolated values — so it lives outside the function.
565
+ const DASHBOARD_CSS = `
428
566
  :root {
429
567
  color-scheme: dark;
430
568
  --inset: #0c0f15; --bench: #0f1115; --raised: #161a22; --chip: #20242c;
@@ -439,30 +577,20 @@ function renderDashboardHtml(p) {
439
577
  --err: #ff7676;
440
578
  }
441
579
  * { box-sizing: border-box; }
442
- html, body { background: var(--bench); color: var(--base); }
580
+ html, body { background: var(--bench); color: var(--base); margin: 0; }
443
581
  body {
444
582
  font: 14px/1.45 ui-sans-serif, system-ui, sans-serif;
445
- margin: 0; padding: 0;
446
- display: flex; flex-direction: column; min-height: 100vh;
447
- }
448
- main {
449
- width: 100%; max-width: 1080px; margin: 0 auto;
450
- padding: 1rem 1.5rem 2rem; flex: 1;
583
+ min-height: 100vh;
584
+ display: flex; flex-direction: column;
451
585
  }
452
586
  code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.92em; color: var(--strong); }
453
587
  .dim { color: var(--dim); }
454
- .grow { flex: 1; }
455
- h2 {
456
- font-size: 1rem; font-weight: 500; margin: 1.6rem 0 0.5rem;
457
- padding-bottom: 0.35rem; border-bottom: 1px solid var(--rule-firm);
458
- letter-spacing: 0.01em;
459
- }
460
- h2:first-child { margin-top: 0.4rem; }
588
+ .grow { flex: 1 1 auto; }
461
589
 
462
590
  /* ── header strip ─────────────────────────────────────────────────────── */
463
591
  #header {
464
592
  display: flex; align-items: center; gap: 0.5rem;
465
- padding: 0.65rem 1.5rem;
593
+ padding: 0.65rem 1.25rem;
466
594
  background: var(--bench); border-bottom: 1px solid var(--rule-firm);
467
595
  font-size: 13px;
468
596
  position: sticky; top: 0; z-index: 10;
@@ -496,85 +624,204 @@ h2:first-child { margin-top: 0.4rem; }
496
624
  #header .refresh:hover { border-color: var(--muted); }
497
625
  #header .refresh:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
498
626
 
499
- /* ── pills ────────────────────────────────────────────────────────────── */
627
+ /* ── ticker ──────────────────────────────────────────────────────────── */
628
+ #ticker {
629
+ display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; align-items: center;
630
+ padding: 0 1.25rem; background: var(--bench);
631
+ border-bottom: 1px solid var(--rule-soft);
632
+ font-size: 13px;
633
+ transition: padding 200ms cubic-bezier(.22,1,.36,1),
634
+ border-bottom-color 200ms cubic-bezier(.22,1,.36,1);
635
+ }
636
+ #ticker:empty { padding-top: 0; padding-bottom: 0; border-bottom-color: transparent; }
637
+ #ticker:not(:empty) { padding-top: 0.5rem; padding-bottom: 0.5rem; }
638
+ .tick { display: inline-flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
639
+ .tick-label { color: var(--muted); font-variant-numeric: tabular-nums; }
640
+ .tick-await .tick-label { color: var(--await-fg); }
641
+ .tick-retry .tick-label { color: var(--retry-fg); }
642
+ .tick-err { color: var(--err); }
643
+ .tick-id {
644
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
645
+ color: var(--base); text-decoration: none;
646
+ border-bottom: 1px solid var(--rule-firm);
647
+ font-size: 0.92em;
648
+ padding-bottom: 1px;
649
+ }
650
+ .tick-id:hover, .tick-id:focus-visible { color: #9cc0ff; border-bottom-color: #9cc0ff; outline: none; }
651
+
652
+ /* ── board ───────────────────────────────────────────────────────────── */
653
+ #board {
654
+ flex: 1 1 auto; min-height: 0;
655
+ padding: 1rem 1.25rem 2.5rem;
656
+ overflow-x: auto;
657
+ }
658
+ .kanban {
659
+ display: grid;
660
+ grid-auto-flow: column;
661
+ grid-auto-columns: minmax(260px, 1fr);
662
+ gap: 1.5rem;
663
+ align-items: start;
664
+ min-width: min-content;
665
+ }
666
+ .col { display: flex; flex-direction: column; min-width: 0; }
667
+ .col-head {
668
+ display: flex; align-items: baseline; gap: 0.4rem;
669
+ padding: 0 0 0.35rem;
670
+ border-bottom: 1px solid var(--rule-firm);
671
+ margin-bottom: 0.5rem;
672
+ }
673
+ .col-name {
674
+ color: var(--muted); font-weight: 500; font-size: 14px;
675
+ letter-spacing: 0.01em;
676
+ }
677
+ .col-holding .col-name { color: var(--retry-fg); }
678
+ .col-terminal .col-name { color: var(--dim); }
679
+ .col-count { color: var(--dim); font-size: 0.82em; font-variant-numeric: tabular-nums; }
680
+ .col-orphan {
681
+ color: var(--err); font-size: 0.7em; letter-spacing: 0.05em;
682
+ text-transform: uppercase; margin-left: 0.3rem;
683
+ }
684
+ .col-add {
685
+ background: transparent; color: var(--muted);
686
+ border: 1px solid var(--rule-firm); border-radius: 4px;
687
+ width: 22px; height: 22px;
688
+ display: inline-flex; align-items: center; justify-content: center;
689
+ cursor: pointer; font: inherit; line-height: 1; font-size: 16px;
690
+ transition: color 160ms cubic-bezier(.22,1,.36,1), border-color 160ms cubic-bezier(.22,1,.36,1);
691
+ }
692
+ .col-add:hover { color: var(--strong); border-color: var(--muted); }
693
+ .col-add:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
694
+ .col-body { display: flex; flex-direction: column; }
695
+ .col-empty {
696
+ padding: 0.4rem 0; margin: 0;
697
+ font-size: 0.9em; color: var(--dim);
698
+ }
699
+ .col-empty code { color: var(--muted); }
700
+
701
+ /* ── row ─────────────────────────────────────────────────────────────── */
702
+ .row {
703
+ padding: 0.55rem 0.6rem; margin: 0 -0.6rem;
704
+ border-bottom: 1px solid var(--rule-soft);
705
+ display: flex; flex-direction: column; gap: 0.2rem;
706
+ border-radius: 3px;
707
+ }
708
+ .row:last-child { border-bottom: 0; }
709
+ .row-await { background: rgba(127, 181, 212, 0.07); }
710
+ .row-retry-stuck { background: rgba(240, 192, 96, 0.05); }
711
+ .row-orphan { opacity: 0.7; }
712
+ .row-head {
713
+ display: flex; align-items: center; gap: 0.5rem;
714
+ font-variant-numeric: tabular-nums;
715
+ }
716
+ .row-ident {
717
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
718
+ color: var(--strong); text-decoration: none;
719
+ font-size: 13px;
720
+ }
721
+ .row-ident:hover, .row-ident:focus-visible { color: #9cc0ff; outline: none; }
500
722
  .pill {
501
723
  display: inline-block; padding: 0.1rem 0.55rem; border-radius: 999px;
502
- font-size: 0.82em; line-height: 1.4;
724
+ font-size: 0.78em; line-height: 1.4;
503
725
  font-variant-numeric: tabular-nums;
504
726
  }
505
727
  .pill.running { background: var(--run-bg); color: var(--run-fg); }
506
728
  .pill.retrying { background: var(--retry-bg); color: var(--retry-fg); }
507
- .pill.idle { background: var(--idle-bg); color: var(--idle-fg); }
729
+ /* A "resuming" row is mid clean-handoff (issue 146): calm blue, not the amber
730
+ retry palette, so it never reads as an error. */
731
+ .pill.resuming { background: var(--await-bg); color: var(--await-fg); }
508
732
  .pill.awaiting { background: var(--await-bg); color: var(--await-fg); }
509
- .pill.done { background: var(--done-bg); color: var(--done-fg); }
510
-
511
- /* ── attention zone ───────────────────────────────────────────────────── */
512
- /* Animated open/close: max-height transitions between 0 (empty) and a generous cap
513
- (populated) so the section eases in and out rather than snapping the page up/down
514
- when steering arrives or is resolved. */
515
- #attention {
733
+ .row-meta { color: var(--muted); font-size: 0.82em; }
734
+ .row-title {
516
735
  display: block;
517
- overflow: hidden;
518
- max-height: 0;
519
- margin-bottom: 0;
520
- transition: max-height 280ms cubic-bezier(.22, 1, .36, 1),
521
- margin-bottom 280ms cubic-bezier(.22, 1, .36, 1);
522
- }
523
- #attention:not(:empty) {
524
- max-height: 1500px;
525
- margin-bottom: 0.4rem;
526
- }
527
- .attention-title { color: var(--await-fg); border-bottom-color: var(--await-bg); }
528
- .steering-error {
529
- margin: 0.4rem 0; padding: 0.5rem 0.7rem;
530
- background: var(--inset); border: 1px solid var(--rule-firm); border-radius: 4px;
531
- color: var(--err); font-size: 0.9em; line-height: 1.4;
736
+ color: var(--base); text-decoration: none;
737
+ font-size: 14px; line-height: 1.4;
738
+ overflow-wrap: anywhere;
739
+ }
740
+ .row-title:hover, .row-title:focus-visible { color: var(--strong); outline: none; }
741
+ .row-peek {
742
+ font-size: 0.85em; line-height: 1.4;
743
+ overflow-wrap: anywhere;
744
+ max-height: 2.85em; overflow: hidden;
745
+ }
746
+ .row-err {
747
+ font-size: 0.85em; line-height: 1.35; color: var(--err);
748
+ overflow-wrap: anywhere; word-break: break-word;
532
749
  }
533
- /* The steering panel: question-first layout. Selected via /impeccable live (variant 3,
534
- question-scale=1.1, density=snug). The agent's prompt sits proud and unscaled, and the
535
- original issue + agent context tuck into a disclosure so the panel reads small until
536
- the operator wants depth. */
750
+ .row-actions { display: flex; gap: 0.4rem; margin-top: 0.2rem; }
751
+ .row-action { display: inline; margin: 0; padding: 0; }
752
+ .ghost {
753
+ background: var(--chip); color: var(--base);
754
+ border: 1px solid var(--rule-firm); border-radius: 4px;
755
+ padding: 0.35rem 0.85rem; font: inherit; cursor: pointer;
756
+ transition: border-color 180ms cubic-bezier(.22,1,.36,1);
757
+ }
758
+ .ghost:hover { border-color: var(--muted); }
759
+ .ghost:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
760
+ .ghost-sm {
761
+ background: transparent; color: var(--muted);
762
+ border: 1px solid var(--rule-firm); border-radius: 3px;
763
+ padding: 0.18rem 0.6rem; font: inherit; font-size: 0.82em; cursor: pointer;
764
+ transition: color 160ms cubic-bezier(.22,1,.36,1), border-color 160ms cubic-bezier(.22,1,.36,1);
765
+ }
766
+ .ghost-sm:hover { color: var(--strong); border-color: var(--muted); }
767
+ .ghost-sm.danger:hover { color: var(--err); border-color: var(--err); }
768
+ .ghost-sm:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
769
+
770
+ /* ── inline steering on awaiting rows ────────────────────────────────── */
537
771
  .steering {
538
- background: var(--raised); padding: 0.55rem 0.8rem; margin: 0.5rem 0;
539
- border-radius: 6px;
772
+ margin-top: 0.45rem; padding-top: 0.45rem;
773
+ border-top: 1px dashed var(--rule-firm);
540
774
  display: grid; gap: 0.4rem;
541
775
  }
542
- .steering-head {
543
- display: flex; align-items: center; gap: 0.6rem;
776
+ .steering-q { color: var(--strong); font-size: 0.95em; line-height: 1.45; }
777
+ .steering-q > :first-child { margin-top: 0; }
778
+ .steering-q > :last-child { margin-bottom: 0; }
779
+ .steering-q p { margin: 0.35em 0; }
780
+ .steering-q h1, .steering-q h2, .steering-q h3,
781
+ .steering-q h4, .steering-q h5, .steering-q h6 {
782
+ margin: 0.5em 0 0.25em; font-weight: 500; font-size: 1em;
783
+ }
784
+ .steering-q ul, .steering-q ol { margin: 0.3em 0; padding-left: 1.4em; }
785
+ .steering-q li { margin: 0.15em 0; }
786
+ .steering-q code {
787
+ background: var(--inset); padding: 0.05em 0.35em; border-radius: 3px; font-size: 0.92em;
788
+ }
789
+ .steering-q pre {
790
+ margin: 0.5em 0; padding: 0.5rem 0.65rem;
791
+ background: var(--inset); border: 1px solid var(--rule-firm); border-radius: 4px;
792
+ font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
793
+ color: var(--muted); overflow: auto;
544
794
  }
545
- .steering .ident { color: var(--strong); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
546
- .steering .turn { font-size: 0.88em; }
547
- .steering .question-primary {
548
- margin: 0;
549
- font-size: calc(14px * 1.1);
550
- line-height: 1.4; color: var(--strong); font-weight: 400;
795
+ .steering-q pre code { background: transparent; padding: 0; font-size: inherit; }
796
+ .steering-q blockquote {
797
+ margin: 0.35em 0; padding: 0.1em 0.7em;
798
+ border-left: 2px solid var(--rule-firm); color: var(--muted);
551
799
  }
552
- .steering details.steering-task { font-size: 0.92em; }
553
- .steering details.steering-task > summary {
800
+ .steering-q a { color: var(--await-fg); }
801
+ .steering-task { font-size: 0.88em; }
802
+ .steering-task > summary {
554
803
  cursor: pointer; list-style: none;
555
- color: var(--muted); padding: 0.3rem 0; user-select: none;
556
- font-size: 0.88em;
804
+ color: var(--muted); padding: 0.2rem 0; user-select: none;
557
805
  }
558
- .steering details.steering-task > summary::-webkit-details-marker { display: none; }
559
- .steering details.steering-task > summary::before {
806
+ .steering-task > summary::-webkit-details-marker { display: none; }
807
+ .steering-task > summary::before {
560
808
  content: "▸"; padding-right: 0.4rem;
561
809
  transition: transform 180ms cubic-bezier(.22,1,.36,1);
562
810
  display: inline-block; color: var(--dim);
563
811
  }
564
- .steering details.steering-task[open] > summary::before { transform: rotate(90deg); }
565
- .steering .steering-task-body {
566
- display: grid; gap: 0.45rem;
567
- padding: 0.35rem 0 0 0.85rem;
568
- border-left: 1px solid var(--rule-soft);
812
+ .steering-task[open] > summary::before { transform: rotate(90deg); }
813
+ .steering-task-body {
814
+ display: grid; gap: 0.35rem;
815
+ padding: 0.3rem 0 0 0.75rem;
569
816
  }
570
- .steering .steering-task-label {
571
- font-size: 0.72em; color: var(--dim);
572
- letter-spacing: 0.09em; text-transform: uppercase;
817
+ .steering-task-label {
818
+ font-size: 0.7em; color: var(--dim);
819
+ letter-spacing: 0.08em; text-transform: uppercase;
573
820
  }
574
- .steering .steering-task-body .issue-title {
821
+ .steering-task-body .issue-title {
575
822
  margin: 0; font-size: 0.95em; font-weight: 500; color: var(--base);
576
823
  }
577
- .steering .steering-task-body .issue-body {
824
+ .steering-task-body .issue-body {
578
825
  margin: 0; color: var(--muted); font-size: 0.92em; line-height: 1.5;
579
826
  }
580
827
  .steering .context {
@@ -584,142 +831,199 @@ h2:first-child { margin-top: 0.4rem; }
584
831
  color: var(--muted); white-space: pre-wrap; word-break: break-word;
585
832
  max-height: 12em; overflow: auto;
586
833
  }
587
- .steering form.reply { display: grid; gap: 0.45rem; }
588
- .steering textarea {
834
+ form.reply { display: grid; gap: 0.4rem; margin: 0; }
835
+ form.reply textarea {
589
836
  background: var(--inset); color: var(--strong);
590
837
  border: 1px solid var(--rule-firm); border-radius: 4px;
591
- padding: 0.5rem 0.65rem;
838
+ padding: 0.45rem 0.6rem;
592
839
  font: 14px/1.5 ui-sans-serif, system-ui, sans-serif;
593
- width: 100%; min-height: 64px; resize: vertical;
594
- }
595
- .steering textarea:focus-visible { outline: 1px solid var(--accent); outline-offset: 0; border-color: var(--accent); }
596
- .steering .reply-row { display: flex; align-items: center; gap: 0.75rem; }
597
- .steering .hint { font-size: 0.82em; }
598
- .ghost {
599
- background: var(--chip); color: var(--base);
600
- border: 1px solid var(--rule-firm); border-radius: 4px;
601
- padding: 0.35rem 0.85rem; font: inherit; cursor: pointer;
602
- transition: border-color 180ms cubic-bezier(.22,1,.36,1);
603
- }
604
- .ghost:hover { border-color: var(--muted); }
605
- .ghost:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
606
- .ghost:disabled { color: var(--dim); cursor: not-allowed; }
607
-
608
- .retry-list { list-style: none; padding: 0; margin: 0.4rem 0 0; }
609
- .retry-list li {
610
- display: grid;
611
- grid-template-columns: max-content max-content max-content max-content;
612
- gap: 0.55rem; align-items: center;
613
- padding: 0.4rem 0; border-bottom: 1px solid var(--rule-soft);
614
- font-variant-numeric: tabular-nums;
615
- }
616
- .retry-list li:last-child { border-bottom: 0; }
617
- .retry-list .ident { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
618
- .retry-list .retry-err {
619
- grid-column: 1 / -1; color: var(--err); font-size: 0.88em;
620
- padding-top: 0.15rem;
621
- word-break: break-word;
622
- }
623
-
624
- /* ── sessions ─────────────────────────────────────────────────────────── */
625
- .sessions { list-style: none; padding: 0; margin: 0; }
626
- .sessions li.session {
627
- padding: 0.5rem 0; border-bottom: 1px solid var(--rule-soft);
628
- }
629
- .sessions li.session:last-child { border-bottom: 0; }
630
- .session-row {
631
- display: flex; align-items: baseline; gap: 0.65rem;
632
- font-variant-numeric: tabular-nums;
840
+ width: 100%; min-height: 60px; resize: vertical;
633
841
  }
634
- .session-row .ident { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--strong); }
635
- .session-row .turn { font-size: 0.88em; color: var(--muted); }
636
- .session-row .tokens { font-size: 0.88em; }
637
- .session-msg {
638
- margin-top: 0.2rem; padding-left: 0.05rem;
639
- font-size: 0.9em; line-height: 1.45;
640
- overflow-wrap: anywhere;
641
- max-height: 2.9em; overflow: hidden;
842
+ form.reply textarea:focus-visible {
843
+ outline: 1px solid var(--accent); outline-offset: 0; border-color: var(--accent);
642
844
  }
845
+ form.reply .reply-row { display: flex; align-items: center; gap: 0.75rem; }
846
+ form.reply .hint { font-size: 0.82em; }
643
847
 
644
- /* ── on disk ──────────────────────────────────────────────────────────── */
645
- .disk { list-style: none; padding: 0; margin: 0; }
646
- .disk li {
647
- display: grid;
648
- grid-template-columns: 10rem 5.5rem 1fr;
649
- gap: 0.5rem; align-items: baseline;
650
- padding: 0.35rem 0; border-bottom: 1px solid var(--rule-soft);
848
+ /* ── totals footer ───────────────────────────────────────────────────── */
849
+ footer.totals {
850
+ padding: 0.85rem 1.25rem;
851
+ border-top: 1px solid var(--rule-soft);
852
+ color: var(--dim); font-size: 0.85em;
651
853
  font-variant-numeric: tabular-nums;
854
+ display: flex; gap: 0.65rem; flex-wrap: wrap;
652
855
  }
653
- .disk li:last-child { border-bottom: 0; }
654
- .disk .ident { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--strong); }
655
- .disk .state { font-size: 0.85em; }
656
- .disk .title { color: var(--base); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
657
- .count { font-weight: 400; font-size: 0.88em; margin-left: 0.4rem; }
856
+ footer.totals:empty { display: none; }
658
857
 
659
- /* ── new issue (collapsed) ────────────────────────────────────────────── */
660
- details.new-issue {
661
- margin-top: 1.4rem;
662
- }
663
- details.new-issue > summary {
664
- cursor: pointer; list-style: none; padding: 0.55rem 0;
665
- border-bottom: 1px solid var(--rule-firm);
666
- font-size: 1rem; font-weight: 500;
667
- display: flex; align-items: center; gap: 0.5rem;
668
- user-select: none;
858
+ /* ── right-side new-issue panel (the One Card) ───────────────────────── */
859
+ .new-panel {
860
+ position: fixed; top: 0; right: 0;
861
+ width: 400px; max-width: 90vw;
862
+ height: 100vh;
863
+ background: var(--raised);
864
+ border-left: 1px solid var(--rule-firm);
865
+ display: flex; flex-direction: column;
866
+ transform: translateX(100%);
867
+ transition: transform 320ms cubic-bezier(.22, 1, .36, 1),
868
+ visibility 0s linear 320ms;
869
+ visibility: hidden;
870
+ z-index: 20;
871
+ }
872
+ .new-panel.open {
873
+ transform: translateX(0);
874
+ visibility: visible;
875
+ transition: transform 320ms cubic-bezier(.22, 1, .36, 1);
876
+ }
877
+ .np-head {
878
+ display: flex; align-items: center; gap: 0.75rem;
879
+ padding: 1rem 1.25rem;
880
+ border-bottom: 1px solid var(--rule-soft);
881
+ }
882
+ .np-head h2 { margin: 0; font-size: 1rem; font-weight: 500; color: var(--strong); }
883
+ .np-close {
884
+ margin-left: auto;
885
+ background: var(--chip); color: var(--muted);
886
+ border: 1px solid var(--rule-firm); border-radius: 4px;
887
+ width: 28px; height: 28px;
888
+ cursor: pointer; font: inherit; font-size: 16px; line-height: 1;
889
+ transition: color 160ms cubic-bezier(.22,1,.36,1), border-color 160ms cubic-bezier(.22,1,.36,1);
669
890
  }
670
- details.new-issue > summary::-webkit-details-marker { display: none; }
671
- details.new-issue > summary::before {
672
- content: "▸"; color: var(--dim); font-size: 0.85em;
673
- transition: transform 180ms cubic-bezier(.22,1,.36,1);
674
- display: inline-block;
891
+ .np-close:hover { color: var(--strong); border-color: var(--muted); }
892
+ .np-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
893
+ .np-form {
894
+ display: flex; flex-direction: column; gap: 0.35rem;
895
+ padding: 1rem 1.25rem; flex: 1; overflow: auto;
675
896
  }
676
- details.new-issue[open] > summary::before { transform: rotate(90deg); }
677
- details.new-issue[open] > summary { border-bottom-color: var(--rule-firm); }
678
- form.create {
679
- display: grid; grid-template-columns: max-content 1fr; gap: 0.5rem 1rem;
680
- align-items: center; padding: 1rem 0 0; margin-top: 0.4rem;
897
+ .np-form label {
898
+ color: var(--muted); font-size: 14px; margin-top: 0.5rem;
681
899
  }
682
- form.create label { color: var(--muted); }
683
- form.create input, form.create select, form.create textarea {
900
+ .np-form label:first-of-type { margin-top: 0; }
901
+ .np-form select, .np-form input, .np-form textarea {
684
902
  background: var(--inset); color: var(--strong);
685
903
  border: 1px solid var(--rule-firm); border-radius: 4px;
686
- padding: 0.4rem 0.6rem; font: inherit; width: 100%;
904
+ padding: 0.45rem 0.6rem; font: inherit; width: 100%;
687
905
  }
688
- form.create input:focus-visible, form.create select:focus-visible, form.create textarea:focus-visible {
906
+ .np-form textarea { resize: vertical; min-height: 120px; }
907
+ .np-form select:focus-visible,
908
+ .np-form input:focus-visible,
909
+ .np-form textarea:focus-visible {
689
910
  outline: 1px solid var(--accent); outline-offset: 0; border-color: var(--accent);
690
911
  }
691
- form.create textarea { min-height: 80px; resize: vertical; }
692
- form.create .submit-row {
693
- grid-column: 2; display: flex; align-items: center; gap: 0.85rem;
912
+ .np-actions {
913
+ display: flex; align-items: center; gap: 0.75rem;
914
+ margin-top: 0.9rem;
915
+ }
916
+ .np-msg {
917
+ flex: 1; color: var(--muted); font-size: 0.88em;
918
+ min-height: 1.2em; line-height: 1.2;
694
919
  }
695
- form.create button {
920
+ .np-msg.err { color: var(--err); }
921
+ .np-msg.ok { color: var(--run-fg); }
922
+ .np-submit {
696
923
  background: var(--accent); color: #f4f6fb; border: 0;
697
- padding: 0.5rem 1rem; border-radius: 4px; font: inherit; cursor: pointer;
924
+ padding: 0.55rem 1.1rem; border-radius: 4px;
925
+ font: inherit; cursor: pointer;
698
926
  transition: filter 180ms cubic-bezier(.22,1,.36,1);
699
- flex: 0 0 auto;
700
927
  }
701
- form.create button:hover { filter: brightness(1.08); }
702
- form.create button:focus-visible { outline: 2px solid var(--strong); outline-offset: 2px; }
703
- form.create .msg { min-height: 1.2em; color: var(--muted); font-size: 0.9em; line-height: 1.2; }
704
- form.create .msg.err { color: var(--err); }
705
- form.create .msg.ok { color: var(--run-fg); }
706
-
707
- /* ── totals footer ────────────────────────────────────────────────────── */
708
- footer.totals {
709
- margin-top: 2.5rem; padding-top: 0.85rem;
710
- border-top: 1px solid var(--rule-soft);
711
- color: var(--dim); font-size: 0.88em;
712
- font-variant-numeric: tabular-nums;
713
- display: flex; gap: 0.65rem; flex-wrap: wrap;
714
- }
715
- footer.totals:empty { display: none; }
716
-
717
- .empty { padding: 0.5rem 0; }
928
+ .np-submit:hover { filter: brightness(1.08); }
929
+ .np-submit:focus-visible { outline: 2px solid var(--strong); outline-offset: 2px; }
718
930
 
719
931
  /* htmx ergonomics */
720
932
  .htmx-request .refresh { opacity: 0.6; }
721
- .htmx-settling .session-msg { opacity: 0.85; }
722
- </style>
933
+ .htmx-settling .row-peek { opacity: 0.85; }
934
+ `;
935
+ // Dashboard new-issue panel controller. Stored as a top-level constant so the
936
+ // renderDashboardHtml shell stays inside the imperative-shell line budget.
937
+ const DASHBOARD_SCRIPT = `
938
+ (() => {
939
+ const $ = (id) => document.getElementById(id);
940
+ const panel = $('new-panel');
941
+ const stateSelect = $('np-state');
942
+ const titleInput = $('np-title-input');
943
+ const descTextarea = $('np-description');
944
+ const npMsg = $('np-msg');
945
+ const form = $('np-form');
946
+
947
+ const setMsg = (text, cls) => {
948
+ npMsg.className = 'np-msg' + (cls ? ' ' + cls : '');
949
+ npMsg.textContent = text;
950
+ };
951
+
952
+ const openPanel = (targetState) => {
953
+ if (targetState) {
954
+ for (const opt of stateSelect.options) {
955
+ if (opt.value === targetState) { stateSelect.value = targetState; break; }
956
+ }
957
+ }
958
+ setMsg('');
959
+ panel.classList.add('open');
960
+ panel.setAttribute('aria-hidden', 'false');
961
+ requestAnimationFrame(() => setTimeout(() => titleInput.focus(), 120));
962
+ };
963
+ const closePanel = () => {
964
+ panel.classList.remove('open');
965
+ panel.setAttribute('aria-hidden', 'true');
966
+ };
967
+
968
+ document.addEventListener('click', (ev) => {
969
+ const t = ev.target;
970
+ if (!(t instanceof Element)) return;
971
+ const addBtn = t.closest('.col-add');
972
+ if (addBtn) {
973
+ openPanel(addBtn.getAttribute('data-target-state') || '');
974
+ return;
975
+ }
976
+ if (t.closest('.np-close')) { closePanel(); return; }
977
+ });
978
+
979
+ document.addEventListener('keydown', (ev) => {
980
+ if (ev.key === 'Escape' && panel.classList.contains('open')) {
981
+ ev.preventDefault(); closePanel(); return;
982
+ }
983
+ const t = ev.target;
984
+ if (!(t instanceof HTMLTextAreaElement)) return;
985
+ if (!t.closest('form.reply')) return;
986
+ if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) {
987
+ ev.preventDefault();
988
+ t.form && t.form.requestSubmit();
989
+ }
990
+ });
991
+
992
+ form.addEventListener('submit', async (ev) => {
993
+ ev.preventDefault();
994
+ setMsg('creating…');
995
+ const body = {
996
+ state: stateSelect.value,
997
+ title: titleInput.value.trim(),
998
+ description: descTextarea.value,
999
+ };
1000
+ try {
1001
+ const res = await fetch('/api/v1/issues', {
1002
+ method: 'POST',
1003
+ headers: { 'content-type': 'application/json' },
1004
+ body: JSON.stringify(body),
1005
+ });
1006
+ const data = await res.json();
1007
+ if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status));
1008
+ setMsg('created ' + data.identifier, 'ok');
1009
+ titleInput.value = '';
1010
+ descTextarea.value = '';
1011
+ fetch('/api/v1/refresh', { method: 'POST' }).catch(() => {});
1012
+ document.body.dispatchEvent(new CustomEvent('refreshed', { bubbles: true }));
1013
+ setTimeout(() => { closePanel(); setMsg(''); }, 900);
1014
+ } catch (err) {
1015
+ setMsg(err.message, 'err');
1016
+ }
1017
+ });
1018
+ })();
1019
+ `;
1020
+ function renderDashboardHtml(p) {
1021
+ return `<!doctype html>
1022
+ <html lang="en"><head>
1023
+ <meta charset="utf-8">
1024
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1025
+ <title>symphony · ${escapeHtml(p.workflowName)}</title>
1026
+ <style>${DASHBOARD_CSS}</style>
723
1027
  <script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
724
1028
  <script src="https://unpkg.com/idiomorph@0.7.4/dist/idiomorph-ext.min.js"></script>
725
1029
  </head><body hx-ext="morph">
@@ -737,103 +1041,415 @@ footer.totals:empty { display: none; }
737
1041
  aria-label="refresh now" title="poll &amp; reconcile">⟳</button>
738
1042
  </header>
739
1043
 
740
- <main>
741
-
742
- <section id="attention"
743
- hx-get="/api/v1/partials/attention" hx-trigger="every 2s, refreshed from:body"
744
- hx-swap="morph:innerHTML">${renderAttentionPartial(p)}</section>
1044
+ <div id="ticker"
1045
+ hx-get="/api/v1/partials/attention" hx-trigger="every 2s, refreshed from:body"
1046
+ hx-swap="morph:innerHTML">${renderAttentionPartial(p)}</div>
745
1047
 
746
- <section id="sessions"
747
- hx-get="/api/v1/partials/sessions" hx-trigger="every 2s, refreshed from:body"
748
- hx-swap="morph:innerHTML">${renderSessionsPartial(p)}</section>
1048
+ <main id="board"
1049
+ hx-get="/api/v1/partials/board" hx-trigger="every 2s, refreshed from:body"
1050
+ hx-swap="morph:innerHTML">${renderBoardPartial(p)}</main>
749
1051
 
750
- <section id="disk"
751
- hx-get="/api/v1/partials/disk" hx-trigger="every 2s, refreshed from:body"
752
- hx-swap="morph:innerHTML">${renderDiskPartial(p)}</section>
753
-
754
- <details class="new-issue"${formOpen ? ' open' : ''}>
755
- <summary>new issue</summary>
756
- <form class="create" id="create-form">
757
- <label for="identifier">identifier</label>
758
- <input id="identifier" name="identifier" required placeholder="ABC-42" autocomplete="off" />
759
- <label for="title">title</label>
760
- <input id="title" name="title" required autocomplete="off" />
761
- <label for="state">state</label>
762
- <select id="state" name="state">${stateOptions}</select>
763
- <label for="priority">priority</label>
764
- <input id="priority" name="priority" type="number" min="0" max="10" placeholder="2" />
765
- <label for="labels">labels</label>
766
- <input id="labels" name="labels" placeholder="bug, urgent" autocomplete="off" />
767
- <label for="description">description</label>
768
- <textarea id="description" name="description" placeholder="what needs to be done?"></textarea>
769
- <div class="submit-row">
770
- <button type="submit">create issue</button>
771
- <span class="msg" id="create-msg" role="status" aria-live="polite"></span>
772
- </div>
773
- </form>
774
- </details>
1052
+ ${renderNewIssuePanel(p)}
775
1053
 
776
1054
  <footer class="totals"
777
1055
  hx-get="/api/v1/partials/totals" hx-trigger="every 2s, refreshed from:body"
778
1056
  hx-swap="morph:innerHTML">${renderTotalsPartial(p)}</footer>
779
1057
 
780
- </main>
781
-
782
- <script>
783
- const $ = (id) => document.getElementById(id);
784
-
785
- // Enter-to-send on steering textareas. HTMX submits the form; shift+enter still inserts a newline.
786
- document.addEventListener('keydown', (ev) => {
787
- const t = ev.target;
788
- if (!(t instanceof HTMLTextAreaElement)) return;
789
- if (!t.closest('form.reply')) return;
790
- if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) {
791
- ev.preventDefault();
792
- t.form && t.form.requestSubmit();
793
- }
794
- });
795
-
796
- // Create-issue form: stays on fetch+JSON (the create POST takes structured arrays). After
797
- // success we fire a custom event the polling regions listen to via hx-trigger.
798
- $('create-form').addEventListener('submit', async (ev) => {
799
- ev.preventDefault();
800
- const msg = $('create-msg');
801
- msg.className = 'msg'; msg.textContent = 'creating…';
802
- const labels = $('labels').value.split(',').map((s) => s.trim()).filter(Boolean);
803
- const priorityRaw = $('priority').value.trim();
804
- const body = {
805
- identifier: $('identifier').value.trim(),
806
- title: $('title').value.trim(),
807
- state: $('state').value,
808
- description: $('description').value,
809
- labels,
810
- priority: priorityRaw === '' ? null : Number(priorityRaw),
811
- };
812
- try {
813
- const res = await fetch('/api/v1/issues', {
814
- method: 'POST',
815
- headers: { 'content-type': 'application/json' },
816
- body: JSON.stringify(body),
817
- });
818
- const data = await res.json();
819
- if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status));
820
- msg.className = 'msg ok';
821
- msg.textContent = 'created ' + data.identifier;
822
- $('create-form').reset();
823
- fetch('/api/v1/refresh', { method: 'POST' }).catch(() => {});
824
- document.body.dispatchEvent(new CustomEvent('refreshed', { bubbles: true }));
825
- } catch (err) {
826
- msg.className = 'msg err';
827
- msg.textContent = err.message;
828
- }
829
- });
830
- </script>
1058
+ <script>${DASHBOARD_SCRIPT}</script>
831
1059
 
832
1060
  </body></html>`;
833
1061
  }
834
1062
  function escapeHtml(s) {
835
1063
  return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
836
1064
  }
1065
+ const FENCE_OPEN_RE = /^```([\w-]*)\s*$/;
1066
+ const FENCE_CLOSE_RE = /^```\s*$/;
1067
+ const HEADER_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
1068
+ const UL_RE = /^\s*[-*+]\s+/;
1069
+ const UL_ITEM_RE = /^\s*[-*+]\s+(.*)$/;
1070
+ const OL_RE = /^\s*\d+\.\s+/;
1071
+ const OL_ITEM_RE = /^\s*\d+\.\s+(.*)$/;
1072
+ const BLOCKQUOTE_RE = /^\s*>\s?/;
1073
+ function matchFencedCode(lines, i) {
1074
+ const fence = FENCE_OPEN_RE.exec(lines[i]);
1075
+ if (!fence)
1076
+ return null;
1077
+ const lang = fence[1] ?? '';
1078
+ const body = [];
1079
+ let j = i + 1;
1080
+ while (j < lines.length && !FENCE_CLOSE_RE.test(lines[j])) {
1081
+ body.push(lines[j]);
1082
+ j++;
1083
+ }
1084
+ if (j < lines.length)
1085
+ j++; // consume closing fence
1086
+ const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : '';
1087
+ return { html: `<pre><code${langAttr}>${escapeHtml(body.join('\n'))}</code></pre>`, nextI: j };
1088
+ }
1089
+ function matchHeader(lines, i) {
1090
+ const m = HEADER_RE.exec(lines[i]);
1091
+ if (!m)
1092
+ return null;
1093
+ const level = m[1].length;
1094
+ return { html: `<h${level}>${renderInlineMarkdown(m[2])}</h${level}>`, nextI: i + 1 };
1095
+ }
1096
+ function matchList(lines, i, marker, item, tag) {
1097
+ if (!marker.test(lines[i]))
1098
+ return null;
1099
+ const items = [];
1100
+ let j = i;
1101
+ while (j < lines.length && marker.test(lines[j])) {
1102
+ items.push(`<li>${renderInlineMarkdown(item.exec(lines[j])[1])}</li>`);
1103
+ j++;
1104
+ }
1105
+ return { html: `<${tag}>${items.join('')}</${tag}>`, nextI: j };
1106
+ }
1107
+ function matchBlockquote(lines, i) {
1108
+ if (!BLOCKQUOTE_RE.test(lines[i]))
1109
+ return null;
1110
+ const buf = [];
1111
+ let j = i;
1112
+ while (j < lines.length && BLOCKQUOTE_RE.test(lines[j])) {
1113
+ buf.push(lines[j].replace(BLOCKQUOTE_RE, ''));
1114
+ j++;
1115
+ }
1116
+ return { html: `<blockquote>${renderMarkdown(buf.join('\n'))}</blockquote>`, nextI: j };
1117
+ }
1118
+ function isBlockBoundary(line) {
1119
+ return line.trim() === ''
1120
+ || FENCE_OPEN_RE.test(line)
1121
+ || /^#{1,6}\s+/.test(line)
1122
+ || UL_RE.test(line)
1123
+ || OL_RE.test(line)
1124
+ || BLOCKQUOTE_RE.test(line);
1125
+ }
1126
+ function matchParagraph(lines, i) {
1127
+ const para = [];
1128
+ let j = i;
1129
+ while (j < lines.length && !isBlockBoundary(lines[j])) {
1130
+ para.push(lines[j]);
1131
+ j++;
1132
+ }
1133
+ return { html: `<p>${renderInlineMarkdown(para.join('\n'))}</p>`, nextI: j };
1134
+ }
1135
+ export function renderMarkdown(input) {
1136
+ const lines = input.replace(/\r\n?/g, '\n').split('\n');
1137
+ const blocks = [];
1138
+ let i = 0;
1139
+ while (i < lines.length) {
1140
+ if (lines[i].trim() === '') {
1141
+ i++;
1142
+ continue;
1143
+ }
1144
+ const m = matchFencedCode(lines, i)
1145
+ ?? matchHeader(lines, i)
1146
+ ?? matchList(lines, i, UL_RE, UL_ITEM_RE, 'ul')
1147
+ ?? matchList(lines, i, OL_RE, OL_ITEM_RE, 'ol')
1148
+ ?? matchBlockquote(lines, i)
1149
+ ?? matchParagraph(lines, i);
1150
+ blocks.push(m.html);
1151
+ i = m.nextI;
1152
+ }
1153
+ return blocks.join('\n');
1154
+ }
1155
+ function renderInlineMarkdown(input) {
1156
+ const codes = [];
1157
+ let text = input.replace(/`([^`\n]+)`/g, (_m, code) => {
1158
+ const idx = codes.length;
1159
+ codes.push(`<code>${escapeHtml(code)}</code>`);
1160
+ return `C${idx}`;
1161
+ });
1162
+ text = escapeHtml(text);
1163
+ text = text.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (m, label, url) => {
1164
+ if (!/^(https?:|mailto:)/i.test(url))
1165
+ return m;
1166
+ return `<a href="${url}" rel="noopener noreferrer">${label}</a>`;
1167
+ });
1168
+ text = text.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
1169
+ text = text.replace(/__([^_\n]+)__/g, '<strong>$1</strong>');
1170
+ text = text.replace(/(^|[^*\w])\*([^*\n]+)\*(?!\w)/g, '$1<em>$2</em>');
1171
+ text = text.replace(/(^|[^_\w])_([^_\n]+)_(?!\w)/g, '$1<em>$2</em>');
1172
+ text = text.replace(/\n/g, '<br>');
1173
+ text = text.replace(/C(\d+)/g, (_m, idx) => codes[Number(idx)]);
1174
+ return text;
1175
+ }
1176
+ // ─────────────────────────────────────────────────────────────────────────────
1177
+ // Issue-detail page. Linked from the identifier on every "on disk" and triage
1178
+ // row. Shows what the on-disk Markdown file says: the front-matter metadata
1179
+ // (labels, priority, blocked_by, provenance, timestamps) and the body rendered
1180
+ // through the same minimal Markdown engine the steering panel uses. Read-only
1181
+ // — actions live on the dashboard, not here. Follows the design system's
1182
+ // quiet-workshop rules: flat panels, tabular numerics, no shadows, status pill
1183
+ // uses the existing palette.
1184
+ // ─────────────────────────────────────────────────────────────────────────────
1185
+ function asStringList(v) {
1186
+ if (Array.isArray(v))
1187
+ return v.filter((x) => typeof x === 'string');
1188
+ return [];
1189
+ }
1190
+ function asString(v) {
1191
+ return typeof v === 'string' && v.length > 0 ? v : null;
1192
+ }
1193
+ function asNumber(v) {
1194
+ if (typeof v === 'number' && Number.isFinite(v))
1195
+ return v;
1196
+ return null;
1197
+ }
1198
+ function formatTimestamp(v) {
1199
+ if (typeof v === 'string' && v.length > 0)
1200
+ return v;
1201
+ if (v instanceof Date)
1202
+ return v.toISOString();
1203
+ return null;
1204
+ }
1205
+ function detailRow(label, value) {
1206
+ return `<div class="meta-row"><dt>${escapeHtml(label)}</dt><dd>${value}</dd></div>`;
1207
+ }
1208
+ function detailLabelsRow(labels) {
1209
+ if (labels.length === 0)
1210
+ return '';
1211
+ const chips = labels.map((l) => `<span class="label-chip">${escapeHtml(l)}</span>`).join('');
1212
+ return detailRow('labels', `<div class="label-chips">${chips}</div>`);
1213
+ }
1214
+ function detailBlockedByRow(blockedBy) {
1215
+ if (blockedBy.length === 0)
1216
+ return '';
1217
+ const items = blockedBy
1218
+ .map((b) => `<a class="blocker-link" href="/issues/${encodeURIComponent(b)}">${escapeHtml(b)}</a>`)
1219
+ .join(', ');
1220
+ return detailRow('blocked by', items);
1221
+ }
1222
+ function detailUrlRow(url) {
1223
+ if (!url)
1224
+ return '';
1225
+ const safe = /^https?:/i.test(url) ? url : '';
1226
+ const value = safe
1227
+ ? `<a href="${escapeHtml(safe)}" rel="noopener noreferrer">${escapeHtml(safe)}</a>`
1228
+ : escapeHtml(url);
1229
+ return detailRow('url', value);
1230
+ }
1231
+ function detailProposedByRow(proposedBy) {
1232
+ if (!proposedBy)
1233
+ return '';
1234
+ return detailRow('proposed by', `<a class="blocker-link" href="/issues/${encodeURIComponent(proposedBy)}">${escapeHtml(proposedBy)}</a>`);
1235
+ }
1236
+ function detailTimestampRow(label, ts) {
1237
+ if (!ts)
1238
+ return '';
1239
+ return detailRow(label, `<span class="num">${escapeHtml(ts)}</span>`);
1240
+ }
1241
+ // Pill colour follows the declared role: active → running, terminal → done,
1242
+ // holding → idle. An undeclared state (stale directory, file dropped by hand
1243
+ // into a name not in `states:`) falls back to idle so the detail page still
1244
+ // renders legibly.
1245
+ function buildDetailMetaRows(fm, stateClass, stateName) {
1246
+ const branchName = asString(fm['branch_name']);
1247
+ const priority = asNumber(fm['priority']);
1248
+ const createdAt = formatTimestamp(fm['created_at']);
1249
+ const updatedAt = formatTimestamp(fm['updated_at']);
1250
+ const showUpdated = updatedAt && updatedAt !== createdAt ? updatedAt : null;
1251
+ return [
1252
+ detailRow('state', `<span class="pill ${stateClass}">${escapeHtml(stateName)}</span>`),
1253
+ detailLabelsRow(asStringList(fm['labels'])),
1254
+ priority !== null ? detailRow('priority', `<span class="num">${escapeHtml(String(priority))}</span>`) : '',
1255
+ detailBlockedByRow(asStringList(fm['blocked_by'])),
1256
+ branchName ? detailRow('branch', `<code>${escapeHtml(branchName)}</code>`) : '',
1257
+ detailUrlRow(asString(fm['url'])),
1258
+ detailTimestampRow('created', createdAt),
1259
+ detailTimestampRow('updated', showUpdated),
1260
+ detailProposedByRow(asString(fm['proposed_by'])),
1261
+ detailTimestampRow('proposed at', formatTimestamp(fm['proposed_at'])),
1262
+ ].join('');
1263
+ }
1264
+ // Issue-detail CSS, hoisted out of renderIssueDetailPage so the function stays
1265
+ // within the imperative-shell line budget. Pure presentation content.
1266
+ const DETAIL_PAGE_CSS = `
1267
+ :root {
1268
+ color-scheme: dark;
1269
+ --inset: #0c0f15; --bench: #0f1115; --raised: #161a22; --chip: #20242c;
1270
+ --rule-soft: #1c2029; --rule-firm: #2a2e36;
1271
+ --dim: #6b7280; --muted: #9aa4b2; --base: #dfe2e7; --strong: #e6ebf2;
1272
+ --accent: #2a6df4;
1273
+ --run-bg: #1f3a26; --run-fg: #58d68d;
1274
+ --idle-bg: #20242c; --idle-fg: #9aa4b2;
1275
+ --done-bg: #1c2a1f; --done-fg: #82c896;
1276
+ --err: #ff7676;
1277
+ }
1278
+ * { box-sizing: border-box; }
1279
+ html, body { background: var(--bench); color: var(--base); }
1280
+ body {
1281
+ font: 14px/1.45 ui-sans-serif, system-ui, sans-serif;
1282
+ margin: 0; padding: 0;
1283
+ display: flex; flex-direction: column; min-height: 100vh;
1284
+ }
1285
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.92em; color: var(--strong); }
1286
+ a { color: var(--strong); text-decoration: none; }
1287
+ a:hover { color: #9cc0ff; }
1288
+ .dim { color: var(--dim); }
1289
+ .num { font-variant-numeric: tabular-nums; }
1290
+
1291
+ /* header strip mirrors the dashboard so navigation feels continuous */
1292
+ #header {
1293
+ display: flex; align-items: center; gap: 0.5rem;
1294
+ padding: 0.65rem 1.5rem;
1295
+ background: var(--bench); border-bottom: 1px solid var(--rule-firm);
1296
+ font-size: 13px;
1297
+ position: sticky; top: 0; z-index: 10;
1298
+ }
1299
+ #header .brand { font-weight: 600; color: var(--strong); letter-spacing: 0.01em; }
1300
+ #header .rule { color: var(--dim); }
1301
+ #header .crumb { color: var(--muted); }
1302
+ #header .crumb-current { color: var(--base); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
1303
+
1304
+ main {
1305
+ width: 100%; max-width: 920px; margin: 0 auto;
1306
+ padding: 1rem 1.5rem 2.5rem; flex: 1;
1307
+ }
1308
+ .back {
1309
+ display: inline-block; padding: 0.2rem 0; margin: 0.2rem 0 0.6rem;
1310
+ font-size: 0.88em; color: var(--muted);
1311
+ }
1312
+ .back:hover { color: var(--strong); }
1313
+
1314
+ .issue-head {
1315
+ display: grid; gap: 0.35rem;
1316
+ padding-bottom: 0.6rem; margin-bottom: 1.1rem;
1317
+ border-bottom: 1px solid var(--rule-firm);
1318
+ }
1319
+ .issue-head .ident {
1320
+ font: 12.5px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1321
+ color: var(--dim); letter-spacing: 0.02em;
1322
+ }
1323
+ .issue-head h1 {
1324
+ margin: 0; font-size: 1.3rem; font-weight: 500; color: var(--strong);
1325
+ line-height: 1.3;
1326
+ }
1327
+
1328
+ /* pills carry status colour, sized to read as inline metadata not content */
1329
+ .pill {
1330
+ display: inline-block; padding: 0.1rem 0.55rem; border-radius: 999px;
1331
+ font-size: 0.82em; line-height: 1.4;
1332
+ font-variant-numeric: tabular-nums;
1333
+ }
1334
+ .pill.running { background: var(--run-bg); color: var(--run-fg); }
1335
+ .pill.idle { background: var(--idle-bg); color: var(--idle-fg); }
1336
+ .pill.done { background: var(--done-bg); color: var(--done-fg); }
1337
+
1338
+ /* metadata definition list: tight two-column grid, label left, value right */
1339
+ .issue-meta {
1340
+ margin: 0 0 1.4rem;
1341
+ display: grid; grid-template-columns: 8.5rem 1fr;
1342
+ row-gap: 0.35rem; column-gap: 1rem;
1343
+ font-variant-numeric: tabular-nums;
1344
+ }
1345
+ .issue-meta .meta-row { display: contents; }
1346
+ .issue-meta dt {
1347
+ color: var(--dim); font-size: 0.85em;
1348
+ letter-spacing: 0.05em; text-transform: uppercase;
1349
+ align-self: baseline; padding-top: 0.15rem;
1350
+ }
1351
+ .issue-meta dd { margin: 0; color: var(--base); }
1352
+ .label-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; }
1353
+ .label-chip {
1354
+ display: inline-block; padding: 0.05rem 0.5rem; border-radius: 4px;
1355
+ background: var(--chip); color: var(--muted);
1356
+ font-size: 0.85em; line-height: 1.5;
1357
+ }
1358
+ .blocker-link {
1359
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1360
+ font-size: 0.92em; color: var(--muted);
1361
+ }
1362
+ .blocker-link:hover { color: var(--strong); }
1363
+
1364
+ h2.section-title {
1365
+ font-size: 1rem; font-weight: 500; margin: 0 0 0.5rem;
1366
+ padding-bottom: 0.35rem; border-bottom: 1px solid var(--rule-firm);
1367
+ letter-spacing: 0.01em;
1368
+ }
1369
+
1370
+ .issue-body { color: var(--base); font-size: 0.95em; line-height: 1.55; }
1371
+ .issue-body > :first-child { margin-top: 0; }
1372
+ .issue-body > :last-child { margin-bottom: 0; }
1373
+ .issue-body p { margin: 0.6em 0; }
1374
+ .issue-body h1, .issue-body h2, .issue-body h3,
1375
+ .issue-body h4, .issue-body h5, .issue-body h6 {
1376
+ margin: 1em 0 0.4em; font-weight: 500; color: var(--strong);
1377
+ border: 0; padding: 0;
1378
+ }
1379
+ .issue-body h1 { font-size: 1.15em; }
1380
+ .issue-body h2 { font-size: 1.05em; }
1381
+ .issue-body h3 { font-size: 1em; }
1382
+ .issue-body ul, .issue-body ol { margin: 0.5em 0; padding-left: 1.5em; }
1383
+ .issue-body li { margin: 0.15em 0; }
1384
+ .issue-body code {
1385
+ background: var(--inset); padding: 0.05em 0.35em; border-radius: 3px;
1386
+ font-size: 0.92em;
1387
+ }
1388
+ .issue-body pre {
1389
+ margin: 0.7em 0; padding: 0.6rem 0.75rem;
1390
+ background: var(--inset); border: 1px solid var(--rule-firm); border-radius: 4px;
1391
+ font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1392
+ color: var(--muted); overflow: auto;
1393
+ }
1394
+ .issue-body pre code { background: transparent; padding: 0; font-size: inherit; }
1395
+ .issue-body blockquote {
1396
+ margin: 0.5em 0; padding: 0.1em 0.8em;
1397
+ border-left: 2px solid var(--rule-firm); color: var(--muted);
1398
+ }
1399
+ .issue-body a { color: #9cc0ff; }
1400
+
1401
+ .file-path {
1402
+ margin-top: 1.4rem; padding-top: 0.7rem;
1403
+ border-top: 1px solid var(--rule-soft);
1404
+ color: var(--dim); font-size: 0.82em;
1405
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1406
+ word-break: break-all;
1407
+ }
1408
+ .empty { padding: 0.5rem 0; }
1409
+ `;
1410
+ function renderIssueDetailPage(issue, view) {
1411
+ const fm = issue.frontMatter;
1412
+ const title = asString(fm['title']) ?? issue.identifier;
1413
+ const stateClass = pillClassForState(view.states, issue.state);
1414
+ const metaRows = buildDetailMetaRows(fm, stateClass, issue.state);
1415
+ const bodyHtml = issue.body.length > 0
1416
+ ? `<div class="issue-body">${renderMarkdown(issue.body)}</div>`
1417
+ : `<p class="empty dim">no description</p>`;
1418
+ const workflowName = path.basename(view.workflowPath || 'workflow.md');
1419
+ return `<!doctype html>
1420
+ <html lang="en"><head>
1421
+ <meta charset="utf-8">
1422
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1423
+ <title>${escapeHtml(issue.identifier)} · symphony</title>
1424
+ <style>${DETAIL_PAGE_CSS}</style>
1425
+ </head><body>
1426
+
1427
+ <header id="header">
1428
+ <span class="brand">symphony</span>
1429
+ <span class="rule" aria-hidden="true">·</span>
1430
+ <a class="crumb" href="/" title="back to ${escapeHtml(workflowName)}">${escapeHtml(workflowName)}</a>
1431
+ <span class="rule" aria-hidden="true">/</span>
1432
+ <span class="crumb-current">${escapeHtml(issue.identifier)}</span>
1433
+ </header>
1434
+
1435
+ <main>
1436
+ <a class="back" href="/">← back</a>
1437
+
1438
+ <section class="issue-head">
1439
+ <span class="ident">${escapeHtml(issue.identifier)}</span>
1440
+ <h1>${escapeHtml(title)}</h1>
1441
+ </section>
1442
+
1443
+ <dl class="issue-meta">${metaRows}</dl>
1444
+
1445
+ <h2 class="section-title">description</h2>
1446
+ ${bodyHtml}
1447
+
1448
+ <p class="file-path" title="path on disk">${escapeHtml(issue.filePath)}</p>
1449
+ </main>
1450
+
1451
+ </body></html>`;
1452
+ }
837
1453
  // Resolves once the server has either bound the requested port or rejected with the bind
838
1454
  // error so CLI startup can surface EADDRINUSE / EACCES instead of an unhandled rejection.
839
1455
  // Returns the *actually bound* port (relevant for --port 0, where the kernel picks an
@@ -895,295 +1511,391 @@ async function handleRequest(req, res, orch, opts) {
895
1511
  // URL parsing inside the handler so a malformed Host header doesn't crash the listener.
896
1512
  let pathname;
897
1513
  try {
898
- const url = new URL(req.url ?? '/', 'http://symphony.local');
899
- pathname = url.pathname;
1514
+ pathname = new URL(req.url ?? '/', 'http://symphony.local').pathname;
900
1515
  }
901
1516
  catch {
902
1517
  return badRequest(res, 'invalid request URL');
903
1518
  }
904
1519
  const method = (req.method ?? 'GET').toUpperCase();
905
1520
  const view = opts.getTrackerView();
906
- if (pathname === '/') {
907
- if (method !== 'GET')
908
- return methodNotAllowed(res);
909
- const p = await gatherPartialInputs(orch, view);
910
- const html = renderDashboardHtml(p);
911
- res.statusCode = 200;
912
- res.setHeader('content-type', 'text/html; charset=utf-8');
913
- res.end(html);
1521
+ const route = matchRoute(pathname);
1522
+ const handler = ROUTE_HANDLERS[route.kind];
1523
+ await handler({ req, res, orch, opts, view, method, route });
1524
+ }
1525
+ async function handleDashboard(ctx) {
1526
+ if (ctx.method !== 'GET')
1527
+ return methodNotAllowed(ctx.res);
1528
+ const p = await gatherPartialInputs(ctx.orch, ctx.view);
1529
+ ctx.res.statusCode = 200;
1530
+ ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
1531
+ ctx.res.end(renderDashboardHtml(p));
1532
+ }
1533
+ // Static preview for impeccable live mode. The file under .impeccable/preview/ is a
1534
+ // captured snapshot of the dashboard with polling disabled, used as a variant playground.
1535
+ // Read on every request so live-wrap edits land immediately.
1536
+ async function handlePreview(ctx) {
1537
+ if (ctx.method !== 'GET')
1538
+ return methodNotAllowed(ctx.res);
1539
+ try {
1540
+ const html = await readFile('.impeccable/preview/dashboard.html', 'utf8');
1541
+ ctx.res.statusCode = 200;
1542
+ ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
1543
+ ctx.res.setHeader('cache-control', 'no-store');
1544
+ ctx.res.end(html);
1545
+ }
1546
+ catch (err) {
1547
+ notFound(ctx.res, 'preview_missing', `preview not available: ${err.message}`);
1548
+ }
1549
+ }
1550
+ // HTMX partials. Each region polls its own endpoint at 2s; this is what the dashboard
1551
+ // <section hx-get=...> elements consume. They return only the inner HTML; the outer
1552
+ // wrapper is in the dashboard shell.
1553
+ async function handlePartial(ctx) {
1554
+ if (ctx.method !== 'GET')
1555
+ return methodNotAllowed(ctx.res);
1556
+ const name = resolvePartialName(ctx.route.slug);
1557
+ if (!name) {
1558
+ return notFound(ctx.res, 'partial_not_found', `partial ${ctx.route.slug} does not exist`);
1559
+ }
1560
+ const p = await gatherPartialInputs(ctx.orch, ctx.view);
1561
+ const body = name === 'header' ? renderHeaderPartial(p)
1562
+ : name === 'attention' ? renderAttentionPartial(p)
1563
+ : name === 'board' ? renderBoardPartial(p)
1564
+ : renderTotalsPartial(p);
1565
+ ctx.res.statusCode = 200;
1566
+ ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
1567
+ ctx.res.setHeader('cache-control', 'no-store');
1568
+ ctx.res.end(body);
1569
+ }
1570
+ async function handleState(ctx) {
1571
+ if (ctx.method !== 'GET')
1572
+ return methodNotAllowed(ctx.res);
1573
+ jsonResponse(ctx.res, 200, ctx.orch.snapshot());
1574
+ }
1575
+ async function handleRefresh(ctx) {
1576
+ if (ctx.method !== 'POST')
1577
+ return methodNotAllowed(ctx.res);
1578
+ const status = ctx.orch.triggerRefresh();
1579
+ jsonResponse(ctx.res, 202, {
1580
+ ...status,
1581
+ requested_at: new Date().toISOString(),
1582
+ operations: ['poll', 'reconcile'],
1583
+ });
1584
+ }
1585
+ async function handleIssues(ctx) {
1586
+ if (ctx.method === 'GET')
1587
+ return handleListIssues(ctx);
1588
+ if (ctx.method === 'POST')
1589
+ return handleCreateIssue(ctx);
1590
+ methodNotAllowed(ctx.res);
1591
+ }
1592
+ async function handleListIssues(ctx) {
1593
+ const root = ctx.view.trackerRoot;
1594
+ if (!root)
1595
+ return jsonResponse(ctx.res, 200, { issues: [] });
1596
+ const issues = await listIssuesFromDisk(root);
1597
+ jsonResponse(ctx.res, 200, { issues });
1598
+ }
1599
+ async function handleCreateIssue(ctx) {
1600
+ const root = ctx.view.trackerRoot;
1601
+ if (!root)
1602
+ return badRequest(ctx.res, 'tracker.root not configured');
1603
+ let body;
1604
+ try {
1605
+ body = await readJsonBody(ctx.req);
1606
+ }
1607
+ catch (err) {
1608
+ return badRequest(ctx.res, err.message);
1609
+ }
1610
+ const decision = decideCreateIssue(body, ctx.view.states);
1611
+ if (!decision.ok) {
1612
+ return jsonResponse(ctx.res, decision.status, {
1613
+ error: { code: decision.code, message: decision.message },
1614
+ });
1615
+ }
1616
+ try {
1617
+ const created = await writeIssueFile({
1618
+ trackerRoot: root,
1619
+ identifier: decision.identifier,
1620
+ title: decision.title,
1621
+ state: decision.state,
1622
+ description: decision.description,
1623
+ priority: decision.priority,
1624
+ labels: decision.labels,
1625
+ blocked_by: decision.blocked_by,
1626
+ });
1627
+ log.info('issue created via http', { identifier: created.identifier, state: created.state });
1628
+ jsonResponse(ctx.res, 201, created);
1629
+ }
1630
+ catch (err) {
1631
+ jsonResponse(ctx.res, 409, {
1632
+ error: { code: 'create_failed', message: err.message },
1633
+ });
1634
+ }
1635
+ }
1636
+ // MCP JSON-RPC endpoint: agent (inside the smolvm) POSTs JSON-RPC envelopes here. The
1637
+ // URL is per-issue (the agent only knows its own /<id>/mcp), backed by a bearer token
1638
+ // generated at dispatch. Both layers are belt-and-braces against the no-auth 8787 socket.
1639
+ async function handleMcp(ctx) {
1640
+ if (ctx.method !== 'POST')
1641
+ return methodNotAllowed(ctx.res);
1642
+ const mcp = ctx.opts.mcp;
1643
+ if (!mcp)
1644
+ return notFound(ctx.res, 'mcp_disabled', 'mcp endpoint not enabled');
1645
+ const { identifier } = ctx.route;
1646
+ const auth = (ctx.req.headers['authorization'] ?? ctx.req.headers['Authorization']);
1647
+ const token = extractBearerToken(auth);
1648
+ if (!token)
1649
+ return jsonResponse(ctx.res, 401, { error: { code: 'unauthorized', message: 'bearer token required' } });
1650
+ if (!mcp.isActive(identifier, token)) {
1651
+ return jsonResponse(ctx.res, 404, {
1652
+ error: { code: 'not_found', message: 'issue not active or token mismatch' },
1653
+ });
1654
+ }
1655
+ let body;
1656
+ try {
1657
+ body = await readJsonBody(ctx.req);
1658
+ }
1659
+ catch (err) {
1660
+ return badRequest(ctx.res, err.message);
1661
+ }
1662
+ const reply = await mcp.handleJsonRpc(identifier, token, body);
1663
+ if (reply === null) {
1664
+ // JSON-RPC notification (no id) → 204 No Content
1665
+ ctx.res.statusCode = 204;
1666
+ ctx.res.end();
1667
+ return;
1668
+ }
1669
+ jsonResponse(ctx.res, 200, reply);
1670
+ }
1671
+ // Steering reply: the dashboard (or any operator with access) submits the human's
1672
+ // response to a queued request_human_steering call. The orchestrator-side runner is
1673
+ // awaiting on the registry; this POST unblocks it.
1674
+ //
1675
+ // Two callers:
1676
+ // • Dashboard via HTMX (form-encoded body, `HX-Request: true`, same-origin). We accept
1677
+ // only this combination for form bodies and reply with an HTML partial that swaps
1678
+ // into #attention.
1679
+ // • Direct API client (JSON body). Replies with a structured JSON acknowledgement.
1680
+ //
1681
+ // The form-encoded branch is gated on `HX-Request: true` and a same-origin check so a
1682
+ // simple cross-site form POST cannot inject a steering reply: form-encoded is a "simple"
1683
+ // CORS request that bypasses preflight, and the steering endpoint is unauthenticated.
1684
+ // HTMX errors land at 200 OK with an inline `.steering-error` message because HTMX's
1685
+ // default response-handling does not swap on 4xx/5xx; returning 200 keeps the operator's
1686
+ // form in sync with reality (their textarea text is preserved by hx-preserve regardless).
1687
+ async function handleSteeringReply(ctx) {
1688
+ if (ctx.method !== 'POST')
1689
+ return methodNotAllowed(ctx.res);
1690
+ const mcp = ctx.opts.mcp;
1691
+ if (!mcp)
1692
+ return notFound(ctx.res, 'mcp_disabled', 'steering endpoint not enabled');
1693
+ const { identifier } = ctx.route;
1694
+ const isHtmx = ctx.req.headers['hx-request'] === 'true';
1695
+ const ctype = classifyContentType(ctx.req.headers['content-type']);
1696
+ const csrf = checkSteeringCsrf(ctype, isHtmx, isSameOriginRequest(ctx.req));
1697
+ if (!csrf.ok) {
1698
+ return jsonResponse(ctx.res, csrf.status, {
1699
+ error: { code: csrf.code, message: csrf.message },
1700
+ });
1701
+ }
1702
+ const text = await readSteeringText(ctx, ctype);
1703
+ if (text === null)
1704
+ return;
1705
+ if (!text) {
1706
+ return htmxOrJsonError(ctx.res, isHtmx, ctx.orch, ctx.view, 400, 'bad_request', 'text is required and must be a non-empty string');
1707
+ }
1708
+ if (!mcp.submitSteeringReply(identifier, text)) {
1709
+ return htmxOrJsonError(ctx.res, isHtmx, ctx.orch, ctx.view, 409, 'no_pending_steering', 'no agent is awaiting steering for this issue');
1710
+ }
1711
+ await respondSteeringAccepted(ctx, isHtmx, identifier);
1712
+ }
1713
+ async function respondSteeringAccepted(ctx, isHtmx, identifier) {
1714
+ if (isHtmx) {
1715
+ const p = await gatherPartialInputs(ctx.orch, ctx.view);
1716
+ ctx.res.statusCode = 200;
1717
+ ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
1718
+ ctx.res.end(renderAttentionPartial(p));
914
1719
  return;
915
1720
  }
916
- // Static preview for impeccable live mode. The file under .impeccable/preview/ is a
917
- // captured snapshot of the dashboard with polling disabled, used as a variant playground.
918
- // Read on every request so live-wrap edits land immediately.
919
- if (pathname === '/preview' || pathname === '/preview/') {
920
- if (method !== 'GET')
921
- return methodNotAllowed(res);
1721
+ jsonResponse(ctx.res, 202, { identifier, accepted_at: new Date().toISOString() });
1722
+ }
1723
+ // Returns null when the body read errored and a response has already been written;
1724
+ // otherwise returns the extracted text (empty string if absent/blank, caller treats
1725
+ // that as the "no text supplied" case).
1726
+ async function readSteeringText(ctx, ctype) {
1727
+ const isHtmx = ctx.req.headers['hx-request'] === 'true';
1728
+ if (ctype.isFormBody) {
922
1729
  try {
923
- const html = await readFile('.impeccable/preview/dashboard.html', 'utf8');
924
- res.statusCode = 200;
925
- res.setHeader('content-type', 'text/html; charset=utf-8');
926
- res.setHeader('cache-control', 'no-store');
927
- res.end(html);
1730
+ return extractFormText(await readTextBody(ctx.req));
928
1731
  }
929
1732
  catch (err) {
930
- return notFound(res, 'preview_missing', `preview not available: ${err.message}`);
1733
+ await htmxOrJsonError(ctx.res, isHtmx, ctx.orch, ctx.view, 400, 'bad_request', err.message);
1734
+ return null;
931
1735
  }
932
- return;
933
1736
  }
934
- // HTMX partials. Each region polls its own endpoint at 2s; this is what the dashboard
935
- // <section hx-get=...> elements consume. They return only the inner HTML; the outer
936
- // wrapper is in the dashboard shell.
937
- if (pathname.startsWith('/api/v1/partials/')) {
938
- if (method !== 'GET')
939
- return methodNotAllowed(res);
940
- const p = await gatherPartialInputs(orch, view);
941
- const slug = pathname.slice('/api/v1/partials/'.length);
942
- let body = null;
943
- if (slug === 'header')
944
- body = renderHeaderPartial(p);
945
- else if (slug === 'attention')
946
- body = renderAttentionPartial(p);
947
- else if (slug === 'sessions')
948
- body = renderSessionsPartial(p);
949
- else if (slug === 'disk')
950
- body = renderDiskPartial(p);
951
- else if (slug === 'totals')
952
- body = renderTotalsPartial(p);
953
- if (body === null)
954
- return notFound(res, 'partial_not_found', `partial ${slug} does not exist`);
955
- res.statusCode = 200;
956
- res.setHeader('content-type', 'text/html; charset=utf-8');
957
- res.setHeader('cache-control', 'no-store');
958
- res.end(body);
959
- return;
1737
+ try {
1738
+ return extractJsonText(await readJsonBody(ctx.req));
960
1739
  }
961
- if (pathname === '/api/v1/state') {
962
- if (method !== 'GET')
963
- return methodNotAllowed(res);
964
- return jsonResponse(res, 200, orch.snapshot());
1740
+ catch (err) {
1741
+ badRequest(ctx.res, err.message);
1742
+ return null;
1743
+ }
1744
+ }
1745
+ // Triage approve / discard. The dashboard renders these as buttons inside the triage
1746
+ // section; an operator clicks one and we move the issue file out of Triage/. Approve
1747
+ // sends it to the first active state (typically Todo) where the orchestrator will pick
1748
+ // it up on the next poll; discard sends it to the first terminal state that looks like a
1749
+ // cancellation (case-insensitive "Cancelled" match preferred) so the proposal is
1750
+ // archived rather than deleted — operators can still grep for what was proposed and
1751
+ // turned down.
1752
+ //
1753
+ // CSRF posture mirrors the steering-reply endpoint: form-encoded requires HX-Request
1754
+ // + same-origin; JSON requires application/json (preflight-triggering). HTMX errors
1755
+ // come back as 200 with a partial so the section doesn't go stale.
1756
+ async function handleTriage(ctx) {
1757
+ if (ctx.method !== 'POST')
1758
+ return methodNotAllowed(ctx.res);
1759
+ const tracker = ctx.opts.tracker;
1760
+ if (!tracker || !tracker.moveIssueToState) {
1761
+ return notFound(ctx.res, 'tracker_no_state_transitions', 'tracker does not support state transitions');
965
1762
  }
966
- if (pathname === '/api/v1/refresh') {
967
- if (method !== 'POST')
968
- return methodNotAllowed(res);
969
- const status = orch.triggerRefresh();
970
- return jsonResponse(res, 202, {
971
- ...status,
972
- requested_at: new Date().toISOString(),
973
- operations: ['poll', 'reconcile'],
1763
+ const root = ctx.view.trackerRoot;
1764
+ if (!root)
1765
+ return badRequest(ctx.res, 'tracker.root not configured');
1766
+ const { identifier, action } = ctx.route;
1767
+ const ctype = classifyContentType(ctx.req.headers['content-type']);
1768
+ const csrf = checkTriageCsrf(ctype, ctx.req.headers['hx-request'] === 'true', isSameOriginRequest(ctx.req));
1769
+ if (!csrf.ok) {
1770
+ return jsonResponse(ctx.res, csrf.status, {
1771
+ error: { code: csrf.code, message: csrf.message },
974
1772
  });
975
1773
  }
976
- if (pathname === '/api/v1/issues') {
977
- if (method === 'GET') {
978
- const root = view.trackerRoot;
979
- if (!root)
980
- return jsonResponse(res, 200, { issues: [] });
981
- const issues = await listIssuesFromDisk(root);
982
- return jsonResponse(res, 200, { issues });
983
- }
984
- if (method === 'POST') {
985
- const root = view.trackerRoot;
986
- if (!root)
987
- return badRequest(res, 'tracker.root not configured');
988
- let body;
989
- try {
990
- body = await readJsonBody(req);
991
- }
992
- catch (err) {
993
- return badRequest(res, err.message);
994
- }
995
- if (!body || typeof body !== 'object' || Array.isArray(body)) {
996
- return badRequest(res, 'body must be a JSON object');
997
- }
998
- const b = body;
999
- const identifier = typeof b.identifier === 'string' ? b.identifier.trim() : '';
1000
- const title = typeof b.title === 'string' ? b.title.trim() : '';
1001
- const state = typeof b.state === 'string' ? b.state.trim() : '';
1002
- if (!identifier)
1003
- return badRequest(res, 'identifier is required');
1004
- if (!title)
1005
- return badRequest(res, 'title is required');
1006
- if (!state)
1007
- return badRequest(res, 'state is required');
1008
- // Restrict `state` to one of the configured active/terminal states. Anything else
1009
- // (or values containing path separators / `..`) is rejected so the request cannot
1010
- // escape the tracker root via `path.join`.
1011
- const allowedStates = new Set([...view.activeStates, ...view.terminalStates]);
1012
- if (!allowedStates.has(state)) {
1013
- return badRequest(res, `state must be one of: ${[...allowedStates].join(', ') || '<none configured>'}`);
1014
- }
1015
- const description = typeof b.description === 'string' ? b.description : undefined;
1016
- const priority = typeof b.priority === 'number' && Number.isFinite(b.priority) ? b.priority : null;
1017
- const labels = Array.isArray(b.labels)
1018
- ? b.labels.filter((x) => typeof x === 'string')
1019
- : [];
1020
- const blockedBy = Array.isArray(b.blocked_by)
1021
- ? b.blocked_by.filter((x) => typeof x === 'string')
1022
- : [];
1774
+ const transition = decideTriageTransition(action, ctx.view.states);
1775
+ if (!transition.ok) {
1776
+ return jsonResponse(ctx.res, transition.status, {
1777
+ error: { code: transition.code, message: transition.message },
1778
+ });
1779
+ }
1780
+ await runTriageMove(ctx, tracker, root, identifier, action, transition.toState, transition.fromState);
1781
+ }
1782
+ async function runTriageMove(ctx, tracker, root, identifier, action, toState, fromState) {
1783
+ const isHtmx = ctx.req.headers['hx-request'] === 'true';
1784
+ try {
1785
+ const result = await tracker.moveIssueToState(identifier, toState, { fromRoot: root, fromState });
1786
+ log.info('triage action', { identifier, action, from: result.fromState, to: result.toState });
1787
+ // Nudge the orchestrator to pick the freshly approved issue up immediately instead
1788
+ // of waiting for the next poll tick. Best-effort: triggerRefresh is idempotent.
1789
+ if (action === 'approve') {
1023
1790
  try {
1024
- const created = await writeIssueFile({
1025
- trackerRoot: root,
1026
- identifier,
1027
- title,
1028
- state,
1029
- description,
1030
- priority,
1031
- labels,
1032
- blocked_by: blockedBy,
1033
- });
1034
- log.info('issue created via http', { identifier: created.identifier, state: created.state });
1035
- return jsonResponse(res, 201, created);
1036
- }
1037
- catch (err) {
1038
- return jsonResponse(res, 409, {
1039
- error: { code: 'create_failed', message: err.message },
1040
- });
1791
+ ctx.orch.triggerRefresh();
1041
1792
  }
1793
+ catch { /* refresh request is fire-and-forget */ }
1042
1794
  }
1043
- return methodNotAllowed(res);
1795
+ if (isHtmx)
1796
+ return writeBoardPartial(ctx);
1797
+ jsonResponse(ctx.res, 200, {
1798
+ identifier, action, from_state: result.fromState, to_state: result.toState,
1799
+ });
1044
1800
  }
1045
- // MCP JSON-RPC endpoint: agent (inside the smolvm) POSTs JSON-RPC envelopes here. The
1046
- // URL is per-issue (the agent only knows its own /<id>/mcp), backed by a bearer token
1047
- // generated at dispatch. Both layers are belt-and-braces against the no-auth 8787 socket.
1048
- const mcpMatch = /^\/api\/v1\/issues\/([^/]+)\/mcp$/.exec(pathname);
1049
- if (mcpMatch) {
1050
- if (method !== 'POST')
1051
- return methodNotAllowed(res);
1052
- const mcp = opts.mcp;
1053
- if (!mcp)
1054
- return notFound(res, 'mcp_disabled', 'mcp endpoint not enabled');
1055
- const identifier = decodeURIComponent(mcpMatch[1]);
1056
- const auth = (req.headers['authorization'] ?? req.headers['Authorization']);
1057
- const token = auth && auth.startsWith('Bearer ') ? auth.slice('Bearer '.length).trim() : '';
1058
- if (!token) {
1059
- res.statusCode = 401;
1060
- res.setHeader('content-type', 'application/json; charset=utf-8');
1061
- res.end(JSON.stringify({ error: { code: 'unauthorized', message: 'bearer token required' } }));
1062
- return;
1063
- }
1064
- if (!mcp.isActive(identifier, token)) {
1065
- res.statusCode = 404;
1066
- res.setHeader('content-type', 'application/json; charset=utf-8');
1067
- res.end(JSON.stringify({ error: { code: 'not_found', message: 'issue not active or token mismatch' } }));
1068
- return;
1069
- }
1070
- let body;
1071
- try {
1072
- body = await readJsonBody(req);
1073
- }
1074
- catch (err) {
1075
- return badRequest(res, err.message);
1076
- }
1077
- const reply = await mcp.handleJsonRpc(identifier, token, body);
1078
- if (reply === null) {
1079
- // JSON-RPC notification (no id) → 204 No Content
1080
- res.statusCode = 204;
1081
- res.end();
1082
- return;
1083
- }
1084
- return jsonResponse(res, 200, reply);
1801
+ catch (err) {
1802
+ const code = err.code ?? 'triage_failed';
1803
+ const status = code === 'local_issue_not_found' ? 404 : 409;
1804
+ if (isHtmx)
1805
+ return writeBoardPartial(ctx);
1806
+ jsonResponse(ctx.res, status, { error: { code, message: err.message } });
1085
1807
  }
1086
- // Steering reply: the dashboard (or any operator with access) submits the human's
1087
- // response to a queued request_human_steering call. The orchestrator-side runner is
1088
- // awaiting on the registry; this POST unblocks it.
1089
- //
1090
- // Two callers:
1091
- // • Dashboard via HTMX (form-encoded body, `HX-Request: true`, same-origin). We accept
1092
- // only this combination for form bodies and reply with an HTML partial that swaps
1093
- // into #attention.
1094
- // • Direct API client (JSON body). Replies with a structured JSON acknowledgement.
1095
- //
1096
- // The form-encoded branch is gated on `HX-Request: true` and a same-origin check so a
1097
- // simple cross-site form POST cannot inject a steering reply: form-encoded is a "simple"
1098
- // CORS request that bypasses preflight, and the steering endpoint is unauthenticated.
1099
- // HTMX errors land at 200 OK with an inline `.steering-error` message because HTMX's
1100
- // default response-handling does not swap on 4xx/5xx; returning 200 keeps the operator's
1101
- // form in sync with reality (their textarea text is preserved by hx-preserve regardless).
1102
- const steeringMatch = /^\/api\/v1\/issues\/([^/]+)\/steering-reply$/.exec(pathname);
1103
- if (steeringMatch) {
1104
- if (method !== 'POST')
1105
- return methodNotAllowed(res);
1106
- const mcp = opts.mcp;
1107
- if (!mcp)
1108
- return notFound(res, 'mcp_disabled', 'steering endpoint not enabled');
1109
- const identifier = decodeURIComponent(steeringMatch[1]);
1110
- const isHtmx = req.headers['hx-request'] === 'true';
1111
- const ctype = (req.headers['content-type'] ?? '').toLowerCase();
1112
- const baseCtype = ctype.split(';', 1)[0].trim();
1113
- const isFormBody = baseCtype === 'application/x-www-form-urlencoded';
1114
- const isJsonBody = baseCtype === 'application/json';
1115
- // Content-type gates against CSRF: form-urlencoded, text/plain, and multipart/form-data
1116
- // are "simple" CORS requests and bypass preflight. Only application/json (non-simple,
1117
- // triggers preflight) is accepted on the JSON path; the form path is additionally
1118
- // gated on HX-Request + same-origin. Anything else is rejected outright.
1119
- if (!isFormBody && !isJsonBody) {
1120
- return jsonResponse(res, 415, {
1121
- error: {
1122
- code: 'unsupported_media_type',
1123
- message: 'content-type must be application/json or application/x-www-form-urlencoded',
1124
- },
1125
- });
1126
- }
1127
- if (isFormBody) {
1128
- if (!isHtmx || !isSameOriginRequest(req)) {
1129
- return jsonResponse(res, 403, {
1130
- error: {
1131
- code: 'forbidden',
1132
- message: 'form-encoded steering replies require an HTMX same-origin request',
1133
- },
1134
- });
1135
- }
1136
- }
1137
- let text = '';
1138
- if (isFormBody) {
1139
- try {
1140
- const raw = await readTextBody(req);
1141
- const params = new URLSearchParams(raw);
1142
- text = (params.get('text') ?? '').trim();
1143
- }
1144
- catch (err) {
1145
- return htmxOrJsonError(res, isHtmx, orch, view, 400, 'bad_request', err.message);
1146
- }
1147
- }
1148
- else {
1149
- let body;
1150
- try {
1151
- body = await readJsonBody(req);
1152
- }
1153
- catch (err) {
1154
- return badRequest(res, err.message);
1155
- }
1156
- text =
1157
- body && typeof body === 'object' && !Array.isArray(body) && typeof body.text === 'string'
1158
- ? body.text.trim()
1159
- : '';
1160
- }
1161
- if (!text) {
1162
- return htmxOrJsonError(res, isHtmx, orch, view, 400, 'bad_request', 'text is required and must be a non-empty string');
1163
- }
1164
- const ok = mcp.submitSteeringReply(identifier, text);
1165
- if (!ok) {
1166
- return htmxOrJsonError(res, isHtmx, orch, view, 409, 'no_pending_steering', 'no agent is awaiting steering for this issue');
1167
- }
1168
- if (isHtmx) {
1169
- const p = await gatherPartialInputs(orch, view);
1170
- res.statusCode = 200;
1171
- res.setHeader('content-type', 'text/html; charset=utf-8');
1172
- res.end(renderAttentionPartial(p));
1173
- return;
1174
- }
1175
- return jsonResponse(res, 202, { identifier, accepted_at: new Date().toISOString() });
1808
+ }
1809
+ // Re-render the whole board after a triage move. HTMX morph keeps unchanged columns
1810
+ // stable; only the row that moved (or "failed" and stayed) redraws.
1811
+ async function writeBoardPartial(ctx) {
1812
+ const p = await gatherPartialInputs(ctx.orch, ctx.view);
1813
+ ctx.res.statusCode = 200;
1814
+ ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
1815
+ ctx.res.setHeader('cache-control', 'no-store');
1816
+ ctx.res.end(renderBoardPartial(p));
1817
+ }
1818
+ // Read-only HTML view of one issue. Linked from the identifier on every "on disk" and
1819
+ // triage row; renders front-matter (labels, priority, blockers, provenance) plus the
1820
+ // Markdown body so an operator can read the full task without leaving the browser. No
1821
+ // editing surface actions stay on the dashboard. Source of truth is the on-disk .md
1822
+ // file, found by walking every state directory under tracker.root for a basename match.
1823
+ async function handleDetailHtml(ctx) {
1824
+ if (ctx.method !== 'GET')
1825
+ return methodNotAllowed(ctx.res);
1826
+ const root = ctx.view.trackerRoot;
1827
+ if (!root) {
1828
+ ctx.res.statusCode = 404;
1829
+ ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
1830
+ ctx.res.end(renderIssueNotFoundPage('(tracker root not configured)', ctx.view));
1831
+ return;
1176
1832
  }
1177
- const m = /^\/api\/v1\/([^/]+)$/.exec(pathname);
1178
- if (m) {
1179
- if (method !== 'GET')
1180
- return methodNotAllowed(res);
1181
- const identifier = decodeURIComponent(m[1]);
1182
- const detail = orch.detailByIdentifier(identifier);
1183
- if (!detail)
1184
- return notFound(res, 'issue_not_found', `issue ${identifier} is not tracked`);
1185
- return jsonResponse(res, 200, detail);
1833
+ const { identifier } = ctx.route;
1834
+ const issue = await readIssueFromDisk(root, identifier);
1835
+ if (!issue) {
1836
+ ctx.res.statusCode = 404;
1837
+ ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
1838
+ ctx.res.end(renderIssueNotFoundPage(identifier, ctx.view));
1839
+ return;
1186
1840
  }
1187
- notFound(res);
1841
+ ctx.res.statusCode = 200;
1842
+ ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
1843
+ ctx.res.setHeader('cache-control', 'no-store');
1844
+ ctx.res.end(renderIssueDetailPage(issue, ctx.view));
1845
+ }
1846
+ async function handleDetailJson(ctx) {
1847
+ if (ctx.method !== 'GET')
1848
+ return methodNotAllowed(ctx.res);
1849
+ const { identifier } = ctx.route;
1850
+ const detail = ctx.orch.detailByIdentifier(identifier);
1851
+ if (!detail)
1852
+ return notFound(ctx.res, 'issue_not_found', `issue ${identifier} is not tracked`);
1853
+ jsonResponse(ctx.res, 200, detail);
1854
+ }
1855
+ async function handleNotFoundRoute(ctx) {
1856
+ notFound(ctx.res);
1857
+ }
1858
+ const ROUTE_HANDLERS = {
1859
+ dashboard: handleDashboard,
1860
+ preview: handlePreview,
1861
+ partial: handlePartial,
1862
+ state: handleState,
1863
+ refresh: handleRefresh,
1864
+ issues: handleIssues,
1865
+ mcp: handleMcp,
1866
+ steering: handleSteeringReply,
1867
+ triage: handleTriage,
1868
+ detail_html: handleDetailHtml,
1869
+ detail_json: handleDetailJson,
1870
+ not_found: handleNotFoundRoute,
1871
+ };
1872
+ function renderIssueNotFoundPage(identifier, view) {
1873
+ const workflowName = path.basename(view.workflowPath || 'workflow.md');
1874
+ return `<!doctype html>
1875
+ <html lang="en"><head>
1876
+ <meta charset="utf-8">
1877
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1878
+ <title>not found · symphony</title>
1879
+ <style>
1880
+ :root { color-scheme: dark; }
1881
+ html, body {
1882
+ background: #0f1115; color: #dfe2e7;
1883
+ font: 14px/1.45 ui-sans-serif, system-ui, sans-serif;
1884
+ margin: 0; padding: 0;
1885
+ }
1886
+ main { max-width: 640px; margin: 0 auto; padding: 2.5rem 1.5rem; }
1887
+ h1 { font-size: 1.2rem; font-weight: 500; color: #e6ebf2; margin: 0 0 0.6rem; }
1888
+ p { color: #9aa4b2; line-height: 1.5; }
1889
+ a { color: #9cc0ff; text-decoration: none; }
1890
+ a:hover { color: #e6ebf2; }
1891
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: #e6ebf2; }
1892
+ </style>
1893
+ </head><body>
1894
+ <main>
1895
+ <h1>issue not found</h1>
1896
+ <p>no issue file matches <code>${escapeHtml(identifier)}</code> under the tracker root.</p>
1897
+ <p><a href="/">← back to ${escapeHtml(workflowName)}</a></p>
1898
+ </main>
1899
+ </body></html>`;
1188
1900
  }
1189
1901
  //# sourceMappingURL=http.js.map