spectoflow 0.12.1 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 G. MOMO
3
+ Copyright (c) 2026 Georges MOMO
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -23,8 +23,27 @@ ceremonial command to start.
23
23
  ## Install
24
24
 
25
25
  ```bash
26
- npm install -g spectoflow # (once published)
27
- spectoflow init /path/to/project # or: node bin/spectoflow.js init .
26
+ # npm (recommended)
27
+ npm install -g spectoflow
28
+ spectoflow init /path/to/project
29
+
30
+ # or use it straight from a clone, no global install
31
+ git clone https://github.com/georgesmomo/spectoflow
32
+ node spectoflow/bin/spectoflow.js init /path/to/project
33
+ ```
34
+
35
+ Every command works both ways — `spectoflow <cmd>` when installed globally, or `node
36
+ /path/to/spectoflow/bin/spectoflow.js <cmd>` from a clone.
37
+
38
+ ### CLI
39
+
40
+ ```
41
+ spectoflow init [dir] [--agent=claude,codex] scaffold a project (auto-detects installed agents)
42
+ spectoflow update [--dry-run] refresh framework files to this kit version
43
+ spectoflow dashboard [--port=NNNN] run the local control plane (default 4319)
44
+ spectoflow status progress + whether the dashboard is running
45
+ spectoflow --version (-v) print the version
46
+ spectoflow --help (-h) show help
28
47
  ```
29
48
 
30
49
  `init` scaffolds:
@@ -90,20 +109,26 @@ agent never clobber each other.
90
109
  ## Dashboard (real-time)
91
110
 
92
111
  ```bash
93
- node .spectoflow/dashboard/server.js # → http://localhost:4319
112
+ spectoflow dashboard # → http://localhost:4319 (or --port=NNNN)
113
+ # manual (no global install): node .spectoflow/dashboard/server.js
94
114
  ```
95
115
 
96
- Zero dependencies, updates live via SSE + file watching. Seven tabs behind a dense icon-tab header
97
- (brand · subtitle · a slim global-progress meter · a sync dot · a **Run** quick-action): **Board**
98
- (the control-room Overview KPI cards, a status donut, a **scope-vs-delivered area curve**, a
116
+ `spectoflow dashboard` is the simple way it prints the URL and won't double-start (it detects a
117
+ dashboard already running on the port). `spectoflow status` tells you whether one is up. Running the
118
+ `server.js` directly still works for a manual/embedded setup. Zero dependencies, updates live via SSE +
119
+ file watching. Eight tabs behind a dense icon-tab header (brand logo · subtitle · a slim
120
+ global-progress meter · a sync dot · a **Run** quick-action · a **settings** gear): **Board** (the
121
+ control-room Overview — KPI cards, a status donut, a **scope-vs-delivered area curve**, a
99
122
  workflow-at-a-glance strip, per-phase progress bars, filter chips + search — plus the phase board),
100
- **Requests** (tasks awaiting you — `to_validate`/`to_analyze`), **Backlog** (a flat sortable/filterable
101
- table of every task across every plan), **Workflow** (the pipeline as a diagram click a step to
102
- enable/disable it, which edits `workflow.md`), **Agents & Skills** (enriched cards capability,
103
- standards, uses/inputs/outputsthat open a full-body markdown drawer), **Chat** (a full-height
104
- group-chat panel), and **Info** (a project-at-a-glance summary). Charts are zero-dep, hand-rolled SVG
105
- in `dashboard/public/charts.js` (donut/area/bars/ring, animated, `prefers-reduced-motion`-aware), and
106
- every aggregate is computed client-side.
123
+ **Requests** (tasks awaiting you — `to_validate`/`to_analyze`), **Attention** (points the agent raised
124
+ via a `::spectoflow attention msg=…` sentinel or that you notededit/resolve/delete, or **validate
125
+ task**), **Backlog** (a flat sortable/filterable, paginated table of every task, defaulting to open
126
+ work), **Workflow** (the pipeline as step cards click one to enable/disable it, which edits
127
+ `workflow.md`), **Agents & Skills** (enriched cards that open a full-body markdown drawer), **Chat** (a
128
+ full-height group-chat panel), and **Info** (a project-at-a-glance summary). URLs are real routes
129
+ (`/board`, `/backlog/T-012`, …). Charts are zero-dep, hand-rolled SVG in `dashboard/public/charts.js`
130
+ (donut/area/bars/ring, animated, `prefers-reduced-motion`-aware), and every aggregate is computed
131
+ client-side.
107
132
 
108
133
  A floating **💬 chat widget** (bottom-right, redesigned) and the **Chat** tab render the same
109
134
  `runtime.messages` log via a shared `renderChatLog()`, so they never drift. A running agent identifies
@@ -125,9 +150,14 @@ INVEST, Playwright E2E, Conventional Commits, and more — not generic one-liner
125
150
 
126
151
  ## Language
127
152
 
128
- `.spectoflow/config.json` → `language` (default `en`, incl. code comments). Switchable.
153
+ `.spectoflow/config.json` → `language` (default `en`, incl. code comments). Switchable from the CLI
154
+ (`config.json`) or the dashboard's **settings** gear (mode + language).
129
155
 
130
156
  ## Studied, not copied
131
157
 
132
158
  Structure informed by spec-kit, OpenSpec (markdown + per-agent adapters), and BMAD (agent-personas).
133
- spectoflow keeps the good ideas, removes the ceremony, and adds a real-time control plane. MIT.
159
+ spectoflow keeps the good ideas, removes the ceremony, and adds a real-time control plane.
160
+
161
+ ## License & author
162
+
163
+ MIT © 2026 [Georges MOMO](https://github.com/georgesmomo).
package/bin/spectoflow.js CHANGED
@@ -2,6 +2,7 @@
2
2
  'use strict';
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const http = require('http');
5
6
  const { spawn } = require('child_process');
6
7
  const store = require('../templates/lib/store');
7
8
  const adapters = require('../lib/adapters');
@@ -15,6 +16,30 @@ const VERSION = require('../package.json').version;
15
16
  const argv = process.argv.slice(2);
16
17
  const cmd = argv[0] || 'help';
17
18
 
19
+ // Tiny ANSI colouriser — no dependency; disabled when not a TTY or NO_COLOR is set.
20
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
21
+ const paint = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s));
22
+ const c = { g: paint('32'), cy: paint('36'), b: paint('34'), y: paint('33'), dim: paint('2'), bold: paint('1'), amber: paint('38;5;179') };
23
+
24
+ // ---- dashboard port + running-state probe ------------------------------------
25
+ // Precedence: --port=NNNN > SPECTOFLOW_PORT env > 4319 (matches templates/dashboard/server.js).
26
+ function resolvePort(args) {
27
+ const arg = (args.find((a) => a.startsWith('--port=')) || '').split('=')[1];
28
+ return Number(arg || process.env.SPECTOFLOW_PORT || 4319);
29
+ }
30
+
31
+ // Native http probe, ~500ms timeout, never throws — resolves true/false.
32
+ function probeDashboard(port, timeoutMs = 500) {
33
+ return new Promise((resolve) => {
34
+ const req = http.get({ host: 'localhost', port, path: '/api/project', timeout: timeoutMs }, (res) => {
35
+ res.resume();
36
+ resolve(res.statusCode < 500);
37
+ });
38
+ req.on('timeout', () => { req.destroy(); resolve(false); });
39
+ req.on('error', () => resolve(false));
40
+ });
41
+ }
42
+
18
43
  function copyDir(src, dst) {
19
44
  fs.mkdirSync(dst, { recursive: true });
20
45
  for (const e of fs.readdirSync(src, { withFileTypes: true })) {
@@ -26,8 +51,9 @@ function copyDir(src, dst) {
26
51
 
27
52
  // Existing project: give id-less checkbox tasks a stable id, in place.
28
53
  const ID_RE = /^[A-Za-z]{1,5}-?\d+[A-Za-z]?$/;
29
- function normalizePlans(root) {
30
- const dir = path.join(root, 'plans');
54
+ function normalizePlans(root, config) {
55
+ const dirName = store.resolvePlansDir(root, config || store.readConfig(root));
56
+ const dir = path.join(root, dirName);
31
57
  if (!fs.existsSync(dir)) return 0;
32
58
  let added = 0, seq = 1;
33
59
  for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.md'))) {
@@ -90,12 +116,18 @@ function init() {
90
116
  cfg.runners = { ...cfg.runners, ...adapters.defaultRunners(agents) };
91
117
  fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
92
118
 
93
- // artifact folders
94
- fs.mkdirSync(path.join(target, 'specs'), { recursive: true });
95
- fs.mkdirSync(path.join(target, 'plans'), { recursive: true });
119
+ // artifact folders — reuse an existing differently-named folder (e.g. a project that already
120
+ // keeps its plans in `plan/`, singular) instead of always forcing the plans/specs convention;
121
+ // mkdir is a no-op when the resolved folder already exists.
122
+ const plansDirName = store.resolvePlansDir(target, cfg);
123
+ const specsDirName = store.resolveSpecsDir(target, cfg);
124
+ fs.mkdirSync(path.join(target, specsDirName), { recursive: true });
125
+ fs.mkdirSync(path.join(target, plansDirName), { recursive: true });
126
+ if (plansDirName !== 'plans') notes.push(`Using existing '${plansDirName}/' as the plans folder (set plansDir in config.json to override).`);
127
+ if (specsDirName !== 'specs') notes.push(`Using existing '${specsDirName}/' as the specs folder (set specsDir in config.json to override).`);
96
128
 
97
129
  // existing project: id-normalize any plans already there
98
- const added = normalizePlans(target);
130
+ const added = normalizePlans(target, cfg);
99
131
  if (added) notes.push(`Normalized ${added} existing task(s) with stable ids.`);
100
132
 
101
133
  // per-agent shims
@@ -113,10 +145,11 @@ function init() {
113
145
  console.log(' specs/ plans/ markdown artifacts (your source of truth)');
114
146
  written.forEach((w) => console.log(' + ' + w));
115
147
  notes.forEach((n) => console.log(' ! ' + n));
148
+ const port = resolvePort(argv);
116
149
  console.log('\nNext:');
117
- console.log(' 1) Open your agent here (Claude Code loads CLAUDE.md; Codex loads AGENTS.md).');
118
- console.log(' 2) Run /spectoflow init — or just say what you want to build.');
119
- console.log(' 3) Watch: node .spectoflow/dashboard/server.js → http://localhost:4319');
150
+ console.log(' 1) Open your agent here or just say what you want to build.');
151
+ console.log(' 2) spectoflow dashboard');
152
+ console.log(` → http://localhost:${port}`);
120
153
  }
121
154
 
122
155
  function update() {
@@ -128,29 +161,50 @@ function update() {
128
161
  const r = require('../lib/update').runUpdate({ projectRoot: root, templatesDir: TPL, version: VERSION, dryRun });
129
162
 
130
163
  const from = r.fromVersion || 'unknown';
131
- console.log(`spectoflow update ${from} ${r.toVersion}${dryRun ? ' (dry-run)' : ''}`);
132
- const line = (label, list) => list.length && console.log(` ${label.padEnd(10)} ${String(list.length).padStart(2)} ${list.join(', ')}`);
133
- line('refreshed', r.refreshed);
134
- line('created', r.created);
135
- line('adopted', r.adopted);
136
- line('.new', r.newSidecar);
137
- if (r.unchanged.length) console.log(` ${'unchanged'.padEnd(10)} ${String(r.unchanged.length).padStart(2)}`);
138
- console.log(` ${'preserved'.padEnd(10)} config.json, workflow.md, specs/, plans/, your custom agents & skills`);
139
- if (r.newSidecar.length) {
140
- console.log(`\n${r.newSidecar.length} file(s) you edited have a new version alongside as *.new — compare and merge by hand.`);
141
- }
142
- if (dryRun) console.log('\n(dry-run nothing was written)');
164
+ const changed = r.refreshed.length + r.created.length + r.adopted.length + r.newSidecar.length;
165
+ const row = (sym, label, list, painter, note) => {
166
+ if (!list.length) return;
167
+ const n = c.dim(String(list.length).padStart(2));
168
+ const detail = note ? c.dim(note) : c.dim(list.slice(0, 6).join(', ') + (list.length > 6 ? ` +${list.length - 6} more` : ''));
169
+ console.log(` ${sym} ${painter(label.padEnd(9))} ${n} ${detail}`);
170
+ };
171
+ console.log('');
172
+ console.log(` ${c.bold('spectoflow update')} ${c.dim(from)} ${c.amber('→')} ${c.bold(r.toVersion)}${dryRun ? c.dim(' (dry-run)') : ''}`);
173
+ console.log('');
174
+ row(c.g('✓'), 'refreshed', r.refreshed, c.g);
175
+ row(c.cy('+'), 'created', r.created, c.cy);
176
+ row(c.b('~'), 'adopted', r.adopted, c.b);
177
+ row(c.y('!'), '.new', r.newSidecar, c.y, 'you edited these — new version saved as *.new, merge by hand');
178
+ if (r.unchanged.length) console.log(` ${c.dim('·')} ${c.dim('unchanged'.padEnd(9))} ${c.dim(String(r.unchanged.length).padStart(2))}`);
179
+ console.log(` ${c.dim('=')} ${c.dim('preserved'.padEnd(9))} ${c.dim('config.json · workflow.md · specs/ · plans/ · your custom agents & skills')}`);
180
+ console.log('');
181
+ if (dryRun) console.log(` ${c.dim('(dry-run — nothing was written)')}`);
182
+ else console.log(` ${changed ? c.g('✓ Done') : c.dim('Already up to date')}${changed ? c.dim(` · ${changed} file(s) changed`) : ''}`);
183
+ if (r.newSidecar.length && !dryRun) console.log(` ${c.y('→')} ${c.dim(`${r.newSidecar.length} *.new file(s) to review and merge`)}`);
184
+ console.log('');
143
185
  }
144
186
 
145
- function dashboard() {
187
+ // THE launch command — prints the URL clearly and won't crash on EADDRINUSE: it probes first
188
+ // and, if a dashboard is already up on that port, just reports it instead of spawning a second one.
189
+ async function dashboard() {
190
+ const port = resolvePort(argv);
191
+ const url = `http://localhost:${port}`;
192
+ if (await probeDashboard(port)) {
193
+ console.log(`spectoflow dashboard already running → ${url}`);
194
+ return;
195
+ }
146
196
  const local = path.resolve('.spectoflow', 'dashboard', 'server.js');
147
197
  const bundled = path.join(TPL, 'dashboard', 'server.js');
148
- spawn('node', [fs.existsSync(local) ? local : bundled], { stdio: 'inherit' });
198
+ const env = Object.assign({}, process.env, { SPECTOFLOW_PORT: String(port) });
199
+ spawn('node', [fs.existsSync(local) ? local : bundled], { stdio: 'inherit', env });
200
+ console.log(`spectoflow dashboard → ${url}`);
149
201
  }
150
202
 
151
- function status() {
203
+ async function status() {
152
204
  const root = process.cwd();
153
- if (!fs.existsSync(path.join(root, 'plans')) && !fs.existsSync(path.join(root, '.spectoflow'))) {
205
+ const cfg = store.readConfig(root);
206
+ const plansDirName = store.resolvePlansDir(root, cfg);
207
+ if (!fs.existsSync(path.join(root, plansDirName)) && !fs.existsSync(path.join(root, '.spectoflow'))) {
154
208
  return console.log('No spectoflow project here. Run: spectoflow init');
155
209
  }
156
210
  const p = store.readProject(root);
@@ -159,12 +213,31 @@ function status() {
159
213
  console.log(`${(p.config && p.config.projectType) || 'project'} — mode ${p.config.mode} · lang ${p.config.language}`);
160
214
  console.log(`${done}/${tasks.length} tasks done · ${p.specs.length} spec(s) · ${p.agents.length} agents · ${p.skills.length} skills`);
161
215
  tasks.filter((t) => t.status === 'in_progress').forEach((t) => console.log(` > in progress: ${t.id} ${t.title}`));
216
+ const port = resolvePort(argv);
217
+ const running = await probeDashboard(port);
218
+ console.log(`dashboard: ${running ? `running → http://localhost:${port}` : 'not running'}`);
162
219
  }
163
220
 
164
- const help = () => console.log(`spectoflow commands:
165
- init [dir] [--agent=claude,codex] install into a project
166
- update [--dry-run] refresh framework files to this kit version
167
- dashboard run the local control plane
168
- status print progress`);
221
+ function version() { console.log(`spectoflow v${VERSION}`); }
222
+
223
+ const help = () => console.log(`${c.bold('spectoflow')} ${c.amber('v' + VERSION)} — agent-agnostic spec-driven development
224
+
225
+ ${c.dim('Usage:')} spectoflow <command> [options]
226
+
227
+ ${c.bold('Commands:')}
228
+ ${c.g('init')} [dir] [--agent=claude,codex] scaffold a project (auto-detects installed agents)
229
+ ${c.g('update')} [--dry-run] refresh framework files to this kit version
230
+ ${c.g('dashboard')} [--port=NNNN] run the local control plane (default 4319, or $SPECTOFLOW_PORT)
231
+ ${c.g('status')} print progress + whether the dashboard is running
232
+
233
+ ${c.bold('Options:')}
234
+ -v, --version print the version
235
+ -h, --help show this help
236
+
237
+ ${c.dim('Docs:')} https://github.com/georgesmomo/spectoflow`);
169
238
 
170
- ({ init, update, dashboard, status, help })[cmd] ? ({ init, update, dashboard, status, help })[cmd]() : help();
239
+ const fns = { init, update, dashboard, status, help, version };
240
+ if (['-v', '-V', '--version', 'version'].includes(cmd)) version();
241
+ else if (['-h', '--help'].includes(cmd)) help();
242
+ else if (fns[cmd]) fns[cmd]();
243
+ else help();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spectoflow",
3
- "version": "0.12.1",
3
+ "version": "0.13.1",
4
4
  "description": "Agent-agnostic spec-driven development framework + real-time local control plane. Markdown artifacts, intent router, workflow-by-scope.",
5
5
  "keywords": [
6
6
  "spec-driven-development",
@@ -17,6 +17,12 @@ specs, plans, comments, and **code comments**. English is the default standard.
17
17
 
18
18
  - **Artifacts (markdown, versioned, source of truth):** `specs/*.md` (specifications), `plans/*.md`
19
19
  (plans whose tasks are checkbox lines). These are what humans read and git tracks.
20
+ - **Broaden the search before concluding "no plans exist".** The plans/specs folder name is
21
+ configurable: check `.spectoflow/config.json` → `plansDir`/`specsDir` first (if set, that folder
22
+ is authoritative); otherwise look for `plans/` then the singular `plan/` (same for `specs/`/`spec/`).
23
+ If you find tasks sitting in a differently-named folder (e.g. `plan/`), use them — and tell the
24
+ user they can pin it permanently by setting `plansDir` (or `specsDir`) in `.spectoflow/config.json`,
25
+ or by just telling you the folder name. Only treat the project as empty once you've checked both.
20
26
  - **Task line convention** in `plans/*.md`:
21
27
  `- [ ] T-012 Add login form @owner ~level %status`
22
28
  `[x]` = done · `~level` = quick|standard|major · `%status` = in_progress|to_validate|to_analyze|blocked
@@ -43,9 +49,14 @@ whole file. This lets the dashboard and you co-edit without clobbering. Reflect
43
49
 
44
50
  ## New / empty project → Intake
45
51
 
46
- If `plans/` and `specs/` are empty: greet the user, state the mode, and **ask what they want to build**.
47
- Then run **brainstorm → analysis → spec → plan** (write `specs/*.md`, then `plans/*.md` with tasks)
48
- before any implementation.
52
+ If `plans/` and `specs/` are empty (after broadening the search above): greet the user, state the
53
+ mode, and **ask what they want to build**. Then run **brainstorm → analysis → spec → plan** (write
54
+ `specs/*.md`, then `plans/*.md` with tasks) before any implementation.
55
+
56
+ **Right after `init`, or on your very first reply in a fresh project, give a short next-steps hint —
57
+ don't leave the user unsure what to do.** Keep it to a few lines:
58
+ 1. Say what you want to build (plain language — no ceremonial command needed).
59
+ 2. The dashboard: tell them it's at its URL (see Dashboard below), and whether it's already running.
49
60
 
50
61
  ## Workflow, capabilities, agents, skills
51
62
 
@@ -63,4 +74,12 @@ destructive migration, security). Mode sets routine friction; policy is non-nego
63
74
 
64
75
  ## Dashboard
65
76
 
66
- `node .spectoflow/dashboard/server.js` http://localhost:4319 (zero deps, live via SSE).
77
+ Launch it with `spectoflow dashboard` (default http://localhost:4319, or `SPECTOFLOW_PORT` /
78
+ `--port=NNNN`; falls back to `node .spectoflow/dashboard/server.js` if the CLI isn't on PATH). Zero
79
+ deps, live via SSE.
80
+
81
+ **At the end of `init`, and on the first request in a session,** check whether the dashboard is
82
+ running — UNLESS the user said they don't want it, or `.spectoflow/config.json` →
83
+ `dashboard.autostart` is `false`. If it's not running, start it **detached** (spawn `spectoflow
84
+ dashboard`, or `node .spectoflow/dashboard/server.js`, unref'd/backgrounded so it doesn't block you),
85
+ then share the URL. Always be able to answer "is the dashboard running?" — check, don't assume.
@@ -3,6 +3,9 @@
3
3
  "language": "en",
4
4
  "agent": "claude",
5
5
  "projectType": "app",
6
+ "plansDir": null,
7
+ "specsDir": null,
8
+ "dashboard": { "autostart": true },
6
9
  "runners": {
7
10
  "claude": "claude -p --permission-mode acceptEdits",
8
11
  "codex": "codex exec"
@@ -87,7 +87,9 @@ function defaultRunStep({ root, step, agent, skill, request }, emit) {
87
87
  return new Promise((resolve) => {
88
88
  const prompt = buildPrompt({ step, agent, skill, request });
89
89
  const tool = store.readConfig(root).agent;
90
- const r = startRun(root, { prompt, agent: tool }, (e) => { emit(e); if (e.type === 'run-end') resolve(e.code); });
90
+ // logPrompt:false the orchestrator already posts a clean "→ step (agent)" line; the raw
91
+ // priming prompt would otherwise show as a noisy user bubble.
92
+ const r = startRun(root, { prompt, agent: tool, logPrompt: false }, (e) => { emit(e); if (e.type === 'run-end') resolve(e.code); });
91
93
  if (r.error) { emit({ type: 'message', message: { role: 'orchestrator', kind: 'status', text: r.error } }); resolve(1); }
92
94
  });
93
95
  }
@@ -2,8 +2,10 @@
2
2
  const STATUS = { todo:'To do', in_progress:'In progress', to_validate:'To validate', to_analyze:'To analyze', done:'Done', blocked:'Blocked' };
3
3
  let P = null, openTaskId = null;
4
4
  let filter = { status: 'all', q: '' }; // board filter state — client-side only, read-only
5
- let backlogFilter = { status: 'all', q: '' }; // backlog filter state independent from the board's
5
+ let backlogFilter = { status: 'open', q: '' }; // backlog defaults to open (not-done) tasks
6
6
  let backlogSort = { col: 'id', dir: 'asc' }; // backlog sort state — client-side only
7
+ let backlogPage = 1; const BACKLOG_PAGE = 25; // backlog pagination — client-side only
8
+ let attnFilter = 'open'; // attention tab filter — client-side only
7
9
 
8
10
  const $ = (s,r=document)=>r.querySelector(s);
9
11
  const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
@@ -15,12 +17,16 @@ async function load(){
15
17
  const r = await fetch('/api/project'); P = await r.json(); render();
16
18
  if(openTaskId) openDrawer(openTaskId,true);
17
19
  }
20
+ // Coalesce bursts of SSE 'change'/'message' events into one reload so the board doesn't
21
+ // re-render (and flash) several times for a single agent action.
22
+ let loadTimer=null;
23
+ function scheduleLoad(){ clearTimeout(loadTimer); loadTimer=setTimeout(load,180); }
18
24
  function connect(){
19
25
  const es = new EventSource('/api/events');
20
26
  es.onopen = ()=>{ $('#sync').classList.remove('offline'); $('#syncLabel').textContent='live'; };
21
27
  es.onmessage = (ev)=>{
22
28
  let m; try{ m=JSON.parse(ev.data); }catch{ return; }
23
- if(m.type==='change'||m.type==='message') return load(); // messages live from runtime.messages
29
+ if(m.type==='change'||m.type==='message') return scheduleLoad(); // messages live from runtime.messages
24
30
  if(m.type==='run-start'||m.type==='run-end') { chatState.forEach(st=>{ st.rawBlock=null; }); return; }
25
31
  if(m.type==='run-line') return appendRaw(m.chunk); // raw output is ephemeral (not logged)
26
32
  };
@@ -127,7 +133,7 @@ function render(){
127
133
  if(meter) meter.title=`Global progress: ${s.pct}% (${s.done}/${s.total} tasks)`;
128
134
  renderOverview(); renderBoard(); renderBacklog(); renderWorkflow(); renderTeam();
129
135
  renderChatLog($('#chatLog')); renderChatLog($('#chatTabLog'));
130
- renderSidebar(); renderRequests(); renderInfo();
136
+ renderSidebar(); renderRequests(); renderAttention(); renderInfo();
131
137
  applyActiveTab(); // re-apply the current tab so an SSE-driven re-render never resets to Board
132
138
  }
133
139
 
@@ -332,7 +338,8 @@ function backlogRows(){
332
338
  return rows;
333
339
  }
334
340
  function backlogMatches(r){
335
- if(backlogFilter.status!=='all' && r.status!==backlogFilter.status) return false;
341
+ if(backlogFilter.status==='open'){ if(r.status==='done') return false; } // "open" = every not-done task
342
+ else if(backlogFilter.status!=='all' && r.status!==backlogFilter.status) return false;
336
343
  const q=backlogFilter.q.trim().toLowerCase();
337
344
  if(q && !((r.id+' '+r.title).toLowerCase().includes(q))) return false;
338
345
  return true;
@@ -365,10 +372,25 @@ function renderBacklog(){
365
372
  const all=backlogRows();
366
373
  const cnt=$('#backlogCount'); if(cnt) cnt.textContent=all.length;
367
374
  const filtered=sortBacklogRows(all.filter(backlogMatches));
375
+ const pages=Math.max(1,Math.ceil(filtered.length/BACKLOG_PAGE));
376
+ if(backlogPage>pages) backlogPage=pages;
377
+ if(backlogPage<1) backlogPage=1;
368
378
  body.innerHTML='';
369
- if(!all.length){ body.append(backlogEmptyRow('No plans yet. Ask your agent to build something — it will run Intake and write plans/*.md.')); return; }
370
- if(!filtered.length){ body.append(backlogEmptyRow('No tasks match this filter.')); return; }
371
- filtered.forEach(r=> body.append(backlogRow(r)));
379
+ if(!all.length){ body.append(backlogEmptyRow('No plans yet. Ask your agent to build something — it will run Intake and write plans/*.md.')); return renderBacklogPager(0,1); }
380
+ if(!filtered.length){ body.append(backlogEmptyRow('No tasks match this filter.')); return renderBacklogPager(0,1); }
381
+ const start=(backlogPage-1)*BACKLOG_PAGE;
382
+ filtered.slice(start,start+BACKLOG_PAGE).forEach(r=> body.append(backlogRow(r)));
383
+ renderBacklogPager(filtered.length,pages);
384
+ }
385
+ function renderBacklogPager(total,pages){
386
+ const pager=$('#backlogPager'); if(!pager) return; pager.innerHTML='';
387
+ if(total<=BACKLOG_PAGE){ if(total) pager.append(el('span','pager-info',`${total} task${total>1?'s':''}`)); return; }
388
+ const prev=el('button','pager-btn','‹ Prev'); prev.disabled=backlogPage<=1;
389
+ prev.addEventListener('click',()=>{ backlogPage--; renderBacklog(); });
390
+ const next=el('button','pager-btn','Next ›'); next.disabled=backlogPage>=pages;
391
+ next.addEventListener('click',()=>{ backlogPage++; renderBacklog(); });
392
+ const from=(backlogPage-1)*BACKLOG_PAGE+1, to=Math.min(total,backlogPage*BACKLOG_PAGE);
393
+ pager.append(prev, el('span','pager-info',`${from}–${to} of ${total} · page ${backlogPage}/${pages}`), next);
372
394
  }
373
395
  function backlogEmptyRow(txt){
374
396
  const tr=el('tr','backlog-empty-row'); const td=el('td',null,txt); td.colSpan=7; tr.append(td); return tr;
@@ -450,18 +472,108 @@ function renderTask(t){
450
472
  function renderWorkflow(){
451
473
  const box=$('#wfDiagram'); box.innerHTML='';
452
474
  const steps=P.workflow||[];
475
+ if(!steps.length){ box.append(el('div','empty','No workflow defined.')); return; }
476
+ const enabledCount=steps.filter(s=>s.enabled).length;
477
+ const legend=el('div','wf-legend');
478
+ legend.append(el('span','wf-legend-txt',`${enabledCount}/${steps.length} steps enabled`));
479
+ box.append(legend);
480
+ const track=el('div','wf-track');
453
481
  steps.forEach((s,i)=>{
454
- const step=el('div','wf-step');
455
- const node=el('div','wf-node'+(s.enabled?'':' off'));
456
- node.append(el('span','dot'));
457
- node.append(el('span','nm',s.name));
458
- if(s.optional) node.append(el('span','opt','opt'));
459
- node.addEventListener('click',()=>toggleStep(s.name));
460
- step.append(node);
461
- if(i<steps.length-1){ const a=el('div','wf-arrow'+(s.enabled&&steps[i+1].enabled?'':' off')); step.append(a); }
462
- box.append(step);
482
+ const node=el('div','wf-card'+(s.enabled?'':' off')); node.tabIndex=0; node.setAttribute('role','button');
483
+ node.setAttribute('aria-pressed',String(!!s.enabled));
484
+ const head=el('div','wf-card-head');
485
+ head.append(el('span','wf-num',String(i+1)));
486
+ head.append(el('span','wf-card-name',s.name));
487
+ if(s.optional) head.append(el('span','wf-opt','optional'));
488
+ node.append(head);
489
+ if(s.cap||s.skill){
490
+ const meta=el('div','wf-card-meta');
491
+ if(s.cap) meta.append(el('span','wf-cap',s.cap));
492
+ if(s.skill) meta.append(el('span','wf-skill',s.skill));
493
+ node.append(meta);
494
+ }
495
+ const toggle=el('div','wf-toggle');
496
+ toggle.append(el('span','wf-toggle-dot'));
497
+ toggle.append(el('span','wf-toggle-label',s.enabled?'enabled':'disabled'));
498
+ node.append(toggle);
499
+ const act=()=>toggleStep(s.name);
500
+ node.addEventListener('click',act);
501
+ node.addEventListener('keydown',e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); act(); } });
502
+ track.append(node);
503
+ if(i<steps.length-1) track.append(el('div','wf-conn'+(s.enabled&&steps[i+1].enabled?'':' off')));
463
504
  });
464
- if(!steps.length) box.append(el('div','empty','No workflow defined.'));
505
+ box.append(track);
506
+ }
507
+
508
+ // ---- Attention tab: agent/user-raised points; validate → real task ----------
509
+ function attnItems(){ return (P.runtime&&P.runtime.attention)||[]; }
510
+ function renderAttention(){
511
+ const list=$('#attnList'); if(!list) return;
512
+ const items=attnItems();
513
+ const openN=items.filter(i=>i.status!=='resolved').length;
514
+ const badge=$('#attnBadge'); if(badge){ badge.textContent=openN; badge.hidden=openN===0; }
515
+ const count=$('#attnCount'); if(count) count.textContent=items.length;
516
+ $$('.attn-filters .fchip').forEach(b=> b.classList.toggle('active', b.dataset.attn===attnFilter));
517
+ const shown=items.filter(i=> attnFilter==='all' ? true : attnFilter==='resolved' ? i.status==='resolved' : i.status!=='resolved');
518
+ list.innerHTML='';
519
+ if(!shown.length){ list.append(el('div','empty', attnFilter==='resolved'?'Nothing resolved yet.':'No points of attention. The agent surfaces them here as it works — or add your own note above.')); return; }
520
+ shown.forEach(it=> list.append(attnRow(it)));
521
+ }
522
+ function attnRow(it){
523
+ const row=el('div','attn-row'+(it.status==='resolved'?' is-resolved':'')+(it.source==='agent'?' from-agent':''));
524
+ const head=el('div','attn-head');
525
+ head.append(el('span','attn-src '+(it.source==='agent'?'is-agent':'is-user'), it.source==='agent'?('⚑ '+(it.by||'agent')):'✎ you'));
526
+ if(it.at) head.append(el('span','attn-time',(String(it.at).replace('T',' ')).slice(0,16)));
527
+ if(it.status==='resolved') head.append(el('span','chip s-done', it.promotedTo?('→ '+it.promotedTo):'resolved'));
528
+ row.append(head);
529
+ const txt=el('div','attn-text', it.text); row.append(txt);
530
+ const acts=el('div','attn-actions');
531
+ if(it.status!=='resolved'){
532
+ const val=el('button','btn primary','Validate → task'); val.addEventListener('click',()=>promoteAttn(it.id));
533
+ const res=el('button','btn','Resolve'); res.addEventListener('click',()=>patchAttn(it.id,{status:'resolved'}));
534
+ const edit=el('button','btn','Edit'); edit.addEventListener('click',()=>editAttn(it,txt));
535
+ acts.append(val,res,edit);
536
+ }else{
537
+ const re=el('button','btn','Reopen'); re.addEventListener('click',()=>patchAttn(it.id,{status:'open'})); acts.append(re);
538
+ }
539
+ const del=el('button','btn danger','Delete'); del.addEventListener('click',()=>deleteAttn(it.id));
540
+ acts.append(del); row.append(acts);
541
+ return row;
542
+ }
543
+ function editAttn(it,txtNode){
544
+ const ta=el('textarea','attn-edit'); ta.value=it.text; txtNode.replaceWith(ta); ta.focus();
545
+ let done=false;
546
+ const save=()=>{ if(done) return; done=true; const v=ta.value.trim(); if(v&&v!==it.text) patchAttn(it.id,{text:v}); else renderAttention(); };
547
+ ta.addEventListener('blur',save);
548
+ ta.addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){ e.preventDefault(); save(); } if(e.key==='Escape'){ done=true; renderAttention(); } });
549
+ }
550
+ async function addAttn(text){ flash(); await fetch('/api/attention',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text})}); }
551
+ async function patchAttn(id,patch){ flash(); await fetch('/api/attention/'+encodeURIComponent(id),{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }
552
+ async function deleteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id),{method:'DELETE'}); }
553
+ async function promoteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id)+'/promote',{method:'POST'}); }
554
+
555
+ // ---- Settings popover: change autonomy mode + output language (writes config.json) ----
556
+ function openSettings(open){
557
+ const pop=$('#settingsPop'); if(!pop) return;
558
+ if(open){ const c=P&&P.config||{}; if($('#setMode')) $('#setMode').value=c.mode||'semi'; setLangSelect(c.language||'en'); }
559
+ pop.hidden=!open;
560
+ }
561
+ function setLangSelect(lang){
562
+ const sel=$('#setLang'); if(!sel) return;
563
+ if(![...sel.options].some(o=>o.value===lang)){ const o=document.createElement('option'); o.value=lang; o.textContent=lang; sel.append(o); }
564
+ sel.value=lang;
565
+ }
566
+ async function saveSettings(){ flash(); const mode=$('#setMode').value, language=$('#setLang').value;
567
+ await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode,language})}); }
568
+
569
+ // ---- client-side routing: /<tab>[/<taskId>] via the History API ------------
570
+ const ROUTES=['board','requests','attention','backlog','workflow','team','chat','info'];
571
+ function tabFromPath(){ const s=location.pathname.split('/').filter(Boolean); return ROUTES.includes(s[0])?s[0]:null; }
572
+ function taskFromPath(){ const s=location.pathname.split('/').filter(Boolean); return (ROUTES.includes(s[0])&&s[1])?decodeURIComponent(s[1]):null; }
573
+ function navigateTab(t,push){
574
+ activeTab=t; try{ localStorage.setItem('spf-tab',t); }catch{}
575
+ if(push!==false) history.pushState(null,'','/'+t);
576
+ applyActiveTab();
465
577
  }
466
578
 
467
579
  // chip row: a small uppercase kicker label followed by one chip per item — used for
@@ -643,6 +755,7 @@ function renderInfo(){
643
755
  function openDrawer(id,keep){
644
756
  const t=allTasks().find(x=>x.id===id); if(!t) return;
645
757
  openTaskId=id;
758
+ if(!keep && taskFromPath()!==id) history.pushState(null,'','/'+activeTab+'/'+encodeURIComponent(id));
646
759
  const b=$('#drawerBody'); const prev=keep?$('.drawer-panel').scrollTop:0; b.innerHTML='';
647
760
  b.append(el('div','d-id',t.id+' · '+(t.level||'standard')+' · '+t.file));
648
761
  b.append(el('div','d-title',t.title));
@@ -676,36 +789,50 @@ function openDrawer(id,keep){
676
789
  $('#drawer').setAttribute('aria-hidden','false');
677
790
  if(keep) $('.drawer-panel').scrollTop=prev;
678
791
  }
679
- function closeDrawer(){ openTaskId=null; $('#drawer').setAttribute('aria-hidden','true'); }
792
+ function closeDrawer(){ if(taskFromPath()) history.pushState(null,'','/'+activeTab); openTaskId=null; $('#drawer').setAttribute('aria-hidden','true'); }
680
793
  const cssv=(v)=> getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#888';
681
794
 
682
795
  // tabs — activeTab is the single source of truth (persisted), so a click sets it and applies it,
683
796
  // and render()'s SSE-driven re-render (triggered by the snapshot write / polling) re-applies it
684
797
  // too instead of ever resetting to Board; this is what keeps a tab selected across a race with a
685
798
  // 'change'/'message' event that lands right after a click.
686
- let activeTab = (()=>{ try{ return localStorage.getItem('spf-tab')||'board'; }catch{ return 'board'; } })();
799
+ // initial tab: the URL path wins (deep-link / refresh), else the persisted tab, else board
800
+ let activeTab = tabFromPath() || (()=>{ try{ return localStorage.getItem('spf-tab')||'board'; }catch{ return 'board'; } })();
801
+ openTaskId = taskFromPath(); // deep-link straight to a task drawer
687
802
  function applyActiveTab(){
688
803
  $$('#tabs .tab').forEach(t=> t.classList.toggle('is-active', t.dataset.tab===activeTab));
689
804
  $$('.panel').forEach(p=> p.classList.toggle('is-active', p.dataset.panel===activeTab));
690
805
  }
691
- $$('#tabs .tab').forEach(tab=> tab.addEventListener('click',()=>{
692
- activeTab=tab.dataset.tab;
693
- try{ localStorage.setItem('spf-tab',activeTab); }catch{}
694
- applyActiveTab();
695
- }));
696
- applyActiveTab(); // sync to any persisted tab before the first render
806
+ $$('#tabs .tab').forEach(tab=> tab.addEventListener('click',()=> navigateTab(tab.dataset.tab)));
807
+ // keep the URL and the path in sync when the user uses the browser back/forward buttons
808
+ window.addEventListener('popstate',()=>{
809
+ activeTab = tabFromPath() || 'board'; applyActiveTab();
810
+ const id=taskFromPath(); if(id) openDrawer(id); else closeDrawer();
811
+ });
812
+ // brand logo → Board (SPA nav, no full reload)
813
+ const brandLogo=$('.brand-logo'); if(brandLogo) brandLogo.addEventListener('click',e=>{ e.preventDefault(); navigateTab('board'); });
814
+ applyActiveTab(); // sync to the resolved tab before the first render
697
815
  // filters (status chips + search) — client-side only, does not write anything
698
816
  $$('#statusChips .fchip').forEach(b=> b.addEventListener('click', ()=>{ filter.status=b.dataset.status; renderBoard(); }));
699
817
  $('#search').addEventListener('input', e=>{ filter.q=e.target.value; renderBoard(); });
700
- // backlog: independent filters + sortable column headers — client-side only
701
- $$('#backlogStatusChips .fchip').forEach(b=> b.addEventListener('click', ()=>{ backlogFilter.status=b.dataset.status; renderBacklog(); }));
702
- $('#backlogSearch').addEventListener('input', e=>{ backlogFilter.q=e.target.value; renderBacklog(); });
818
+ // backlog: independent filters + sortable column headers — client-side only (reset to page 1 on change)
819
+ $$('#backlogStatusChips .fchip').forEach(b=> b.addEventListener('click', ()=>{ backlogFilter.status=b.dataset.status; backlogPage=1; renderBacklog(); }));
820
+ $('#backlogSearch').addEventListener('input', e=>{ backlogFilter.q=e.target.value; backlogPage=1; renderBacklog(); });
703
821
  $$('#backlogTable thead th').forEach(th=> th.addEventListener('click', ()=>{
704
822
  const col=th.dataset.col;
705
823
  if(backlogSort.col===col) backlogSort.dir = backlogSort.dir==='asc'?'desc':'asc';
706
824
  else { backlogSort.col=col; backlogSort.dir='asc'; }
707
- renderBacklog();
825
+ backlogPage=1; renderBacklog();
708
826
  }));
827
+ // attention tab: add a note + filter chips
828
+ $('#attnAddBtn').addEventListener('click',()=>{ const t=$('#attnInput'); const v=t.value.trim(); if(v){ addAttn(v); t.value=''; } });
829
+ $('#attnInput').addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){ const v=e.target.value.trim(); if(v){ addAttn(v); e.target.value=''; } } });
830
+ $$('.attn-filters .fchip').forEach(b=> b.addEventListener('click',()=>{ attnFilter=b.dataset.attn; renderAttention(); }));
831
+ // settings popover
832
+ $('#settingsBtn').addEventListener('click',e=>{ e.stopPropagation(); openSettings($('#settingsPop').hidden); });
833
+ $('#setMode').addEventListener('change',saveSettings);
834
+ $('#setLang').addEventListener('change',saveSettings);
835
+ document.addEventListener('click',e=>{ const pop=$('#settingsPop'); if(!pop||pop.hidden) return; if(!pop.contains(e.target) && !e.target.closest('#settingsBtn')) openSettings(false); });
709
836
  // theme
710
837
  (function(){ const s=localStorage.getItem('spf-theme'); if(s)document.documentElement.setAttribute('data-theme',s);
711
838
  $('#themeToggle').addEventListener('click',()=>{ const c=document.documentElement.getAttribute('data-theme'); const n=c==='dark'?'light':'dark'; document.documentElement.setAttribute('data-theme',n); localStorage.setItem('spf-theme',n); }); })();
@@ -744,4 +871,8 @@ $('#tabRunPrompt').addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.
744
871
  $('#drawerClose').addEventListener('click',closeDrawer);
745
872
  $('#drawerScrim').addEventListener('click',closeDrawer);
746
873
  document.addEventListener('keydown',e=>{ if(e.key==='Escape')closeDrawer(); });
874
+ // entry animations play only during the initial boot window; after that, live SSE re-renders
875
+ // don't replay them (kills the flicker). CSS scopes @keyframes to body.booting.
876
+ document.body.classList.add('booting');
877
+ setTimeout(()=>document.body.classList.remove('booting'),1400);
747
878
  load(); connect();
@@ -21,6 +21,10 @@
21
21
  info: wrap('<circle cx="9" cy="9" r="6.4"/><line x1="9" y1="8.3" x2="9" y2="12.2"/><circle cx="9" cy="5.7" r="0.9" fill="currentColor" stroke="none"/>'),
22
22
  // play triangle — run
23
23
  run: wrap('<path d="M5.4 3.6v10.8l9-5.4z" fill="currentColor" stroke="none"/>'),
24
+ // flag — points needing attention
25
+ attention: wrap('<line x1="4.5" y1="2.4" x2="4.5" y2="15.6"/><path d="M4.5 3.4h8.2l-1.7 2.6 1.7 2.6H4.5z"/>'),
26
+ // gear — settings
27
+ settings: wrap('<circle cx="9" cy="9" r="2.3"/><path d="M9 1.8v2M9 14.2v2M16.2 9h-2M3.8 9h-2M14.1 3.9l-1.4 1.4M5.3 12.7l-1.4 1.4M14.1 14.1l-1.4-1.4M5.3 5.3 3.9 3.9"/>'),
24
28
  };
25
29
 
26
30
  if (typeof module !== 'undefined' && module.exports) module.exports = ICON;
@@ -4,12 +4,16 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>spectoflow · control</title>
7
+ <link rel="icon" type="image/png" href="logo-white.png" />
7
8
  <link rel="stylesheet" href="styles.css" />
8
9
  </head>
9
10
  <body>
10
11
  <header class="topbar">
11
12
  <div class="brand">
12
- <span class="brand-mark" aria-hidden="true"></span>
13
+ <a class="brand-logo" href="/board" data-route="board" aria-label="spectoflow home">
14
+ <img class="brand-logo-img is-dark" src="logo-white.png" alt="spectoflow" />
15
+ <img class="brand-logo-img is-light" src="logo-dark.png" alt="spectoflow" />
16
+ </a>
13
17
  <div class="brand-text">
14
18
  <div class="brand-line">
15
19
  <span class="brand-name">spectoflow</span>
@@ -25,6 +29,7 @@
25
29
  <nav class="tabs" id="tabs">
26
30
  <button class="tab is-active" data-tab="board"><span class="tab-ico" data-icon="board"></span><span class="tab-label">Board</span></button>
27
31
  <button class="tab" data-tab="requests"><span class="tab-ico" data-icon="requests"></span><span class="tab-label">Requests</span></button>
32
+ <button class="tab" data-tab="attention"><span class="tab-ico" data-icon="attention"></span><span class="tab-label">Attention</span><span class="tab-badge" id="attnBadge" hidden>0</span></button>
28
33
  <button class="tab" data-tab="backlog"><span class="tab-ico" data-icon="backlog"></span><span class="tab-label">Backlog</span></button>
29
34
  <button class="tab" data-tab="workflow"><span class="tab-ico" data-icon="workflow"></span><span class="tab-label">Workflow</span></button>
30
35
  <button class="tab" data-tab="team"><span class="tab-ico" data-icon="agents"></span><span class="tab-label">Agents &amp; Skills</span></button>
@@ -37,8 +42,32 @@
37
42
  <span class="mode-chip" id="modeChip" title="Autonomy mode">semi</span>
38
43
  <span class="sync" id="sync"><span class="sync-dot"></span><span id="syncLabel">live</span></span>
39
44
  <button class="btn primary run-btn" id="runQuickBtn" title="Open the run chat"><span class="tab-ico" data-icon="run"></span><span>Run</span></button>
45
+ <button class="icon-btn" id="settingsBtn" aria-label="Settings" title="Settings"><span class="tab-ico" data-icon="settings"></span></button>
40
46
  <button class="theme-toggle" id="themeToggle" aria-label="Toggle theme"><span class="theme-ico"></span></button>
41
47
  </div>
48
+
49
+ <!-- settings popover (mode + language) -->
50
+ <div class="settings-pop" id="settingsPop" hidden role="dialog" aria-label="Settings">
51
+ <div class="settings-head">Settings</div>
52
+ <label class="settings-row"><span>Autonomy mode</span>
53
+ <select id="setMode">
54
+ <option value="autopilot">autopilot</option>
55
+ <option value="semi">semi</option>
56
+ <option value="manual">manual</option>
57
+ </select>
58
+ </label>
59
+ <label class="settings-row"><span>Output language</span>
60
+ <select id="setLang">
61
+ <option value="en">en · English</option>
62
+ <option value="fr">fr · Français</option>
63
+ <option value="es">es · Español</option>
64
+ <option value="de">de · Deutsch</option>
65
+ <option value="pt">pt · Português</option>
66
+ <option value="it">it · Italiano</option>
67
+ </select>
68
+ </label>
69
+ <p class="settings-note">Writes <code>.spectoflow/config.json</code> — the agent picks it up on its next run.</p>
70
+ </div>
42
71
  </header>
43
72
 
44
73
  <main class="stage">
@@ -85,6 +114,24 @@
85
114
  </div>
86
115
  </section>
87
116
 
117
+ <!-- ATTENTION (points raised by the agent or by you; validate → task) -->
118
+ <section class="panel" data-panel="attention">
119
+ <div class="attn-wrap">
120
+ <h2 class="panel-title">Points of attention <span class="count" id="attnCount">0</span></h2>
121
+ <p class="panel-sub">Things the agent flagged during work — or you noted — that deserve a look. <em>Validate</em> one to turn it into a task.</p>
122
+ <div class="attn-add">
123
+ <textarea id="attnInput" class="chat-ta" placeholder="Note something to keep an eye on…"></textarea>
124
+ <button id="attnAddBtn" class="btn primary">Add note</button>
125
+ </div>
126
+ <div class="attn-filters">
127
+ <button class="fchip active" data-attn="open">Open</button>
128
+ <button class="fchip" data-attn="all">All</button>
129
+ <button class="fchip" data-attn="resolved">Resolved</button>
130
+ </div>
131
+ <div class="attn-list" id="attnList"></div>
132
+ </div>
133
+ </section>
134
+
88
135
  <!-- BACKLOG (flat, sortable, filterable table of every task) -->
89
136
  <section class="panel" data-panel="backlog">
90
137
  <div class="backlog-wrap">
@@ -92,7 +139,8 @@
92
139
  <p class="panel-sub">Every task, across every plan, in one flat table.</p>
93
140
  <div class="filters" id="backlogFilters">
94
141
  <div class="chip-list" id="backlogStatusChips">
95
- <button class="fchip active" data-status="all">All</button>
142
+ <button class="fchip active" data-status="open">Open</button>
143
+ <button class="fchip" data-status="all">All</button>
96
144
  <button class="fchip" data-status="todo">To do</button>
97
145
  <button class="fchip" data-status="in_progress">In progress</button>
98
146
  <button class="fchip" data-status="to_validate">To validate</button>
@@ -118,6 +166,7 @@
118
166
  <tbody id="backlogBody"></tbody>
119
167
  </table>
120
168
  </div>
169
+ <div class="pager" id="backlogPager"></div>
121
170
  </div>
122
171
  </section>
123
172
 
@@ -390,29 +390,32 @@ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-siz
390
390
  @media (max-width:640px){ .chat-tab-input { flex-direction:column; } .chat-tab-actions { flex-direction:row; } }
391
391
 
392
392
  /* ---- chart & panel motion --------------------------------------------- */
393
+ /* Entry animations are scoped to body.booting so they play once on initial load; live SSE
394
+ re-renders (which rebuild these nodes) then don't replay them — this is what stops the
395
+ dashboard from flickering on every agent action. */
393
396
  /* cards/panels rise in on render */
394
397
  @keyframes rise { from{ opacity:0; transform:translateY(8px);} to{ opacity:1; transform:translateY(0);} }
395
- .kpi,.ocard,.card { animation:rise .4s cubic-bezier(.2,.8,.2,1) both; }
396
- .kpi-row .kpi:nth-child(1){ animation-delay:.02s; } .kpi-row .kpi:nth-child(2){ animation-delay:.06s; }
397
- .kpi-row .kpi:nth-child(3){ animation-delay:.1s; } .kpi-row .kpi:nth-child(4){ animation-delay:.14s; }
398
+ body.booting .kpi, body.booting .ocard, body.booting .card { animation:rise .4s cubic-bezier(.2,.8,.2,1) both; }
399
+ body.booting .kpi-row .kpi:nth-child(1){ animation-delay:.02s; } body.booting .kpi-row .kpi:nth-child(2){ animation-delay:.06s; }
400
+ body.booting .kpi-row .kpi:nth-child(3){ animation-delay:.1s; } body.booting .kpi-row .kpi:nth-child(4){ animation-delay:.14s; }
398
401
 
399
402
  /* area curve draws itself — pathLength="1" makes stroke-dash* fraction-based */
400
403
  @keyframes draw { to{ stroke-dashoffset:0; } }
401
- .area-line { stroke-dasharray:1; stroke-dashoffset:1; animation:draw 1.1s cubic-bezier(.2,.8,.2,1) forwards; animation-delay:calc(var(--i,0)*.12s); }
402
- .area-fill { opacity:0; animation:fadein .6s ease forwards; animation-delay:calc(.4s + var(--i,0)*.12s); }
403
- .area-dot,.area-value { opacity:0; animation:fadein .3s ease forwards; animation-delay:calc(1s + var(--i,0)*.12s); }
404
+ body.booting .area-line { stroke-dasharray:1; stroke-dashoffset:1; animation:draw 1.1s cubic-bezier(.2,.8,.2,1) forwards; animation-delay:calc(var(--i,0)*.12s); }
405
+ body.booting .area-fill { opacity:0; animation:fadein .6s ease forwards; animation-delay:calc(.4s + var(--i,0)*.12s); }
406
+ body.booting .area-dot, body.booting .area-value { opacity:0; animation:fadein .3s ease forwards; animation-delay:calc(1s + var(--i,0)*.12s); }
404
407
  @keyframes fadein { to{ opacity:1; } }
405
408
 
406
409
  /* donut arcs pop in, staggered by --i */
407
410
  @keyframes pop { from{ opacity:0; transform:scale(.85); } to{ opacity:1; transform:scale(1); } }
408
- .seg-anim { transform-origin:center; transform-box:fill-box; opacity:0; animation:pop .45s cubic-bezier(.2,.8,.2,1) forwards; animation-delay:calc(var(--i,0)*.08s); }
411
+ body.booting .seg-anim { transform-origin:center; transform-box:fill-box; opacity:0; animation:pop .45s cubic-bezier(.2,.8,.2,1) forwards; animation-delay:calc(var(--i,0)*.08s); }
409
412
 
410
413
  /* phase bars grow + the count-up badge is driven from JS (countUp in app.js) */
411
- .bar-fill { animation:grow .6s cubic-bezier(.2,.8,.2,1) both; animation-delay:calc(var(--i,0)*.07s); }
414
+ body.booting .bar-fill { animation:grow .6s cubic-bezier(.2,.8,.2,1) both; animation-delay:calc(var(--i,0)*.07s); }
412
415
  @keyframes grow { from{ width:0; } }
413
416
 
414
417
  /* gradient ring (stroke="url(#grad)") fades/scales in */
415
- .ring-svg circle:last-of-type { transform-origin:center; animation:pop .5s cubic-bezier(.2,.8,.2,1) both; }
418
+ body.booting .ring-svg circle:last-of-type { transform-origin:center; animation:pop .5s cubic-bezier(.2,.8,.2,1) both; }
416
419
 
417
420
  @media (prefers-reduced-motion: reduce){
418
421
  *{ animation:none!important; transition:none!important; }
@@ -424,3 +427,82 @@ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-siz
424
427
  .tooltip { position:fixed; z-index:30; pointer-events:none; background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:6px 10px; font-size:11.5px; line-height:1.4; color:var(--ink); box-shadow:var(--shadow); max-width:220px; }
425
428
  .tooltip[hidden] { display:none; }
426
429
  .tooltip b { color:var(--ink); }
430
+
431
+ /* ================= v0.13 additions ================= */
432
+ .topbar { position:relative; }
433
+
434
+ /* brand logo (replaces the conic mark) — theme-swapped image */
435
+ .brand-logo { display:inline-flex; align-items:center; height:26px; flex-shrink:0; text-decoration:none; }
436
+ .brand-logo-img { height:26px; width:auto; display:block; }
437
+ .brand-logo-img.is-light { display:none; }
438
+ .brand-logo-img.is-dark { display:block; }
439
+ :root[data-theme="light"] .brand-logo-img.is-dark { display:none; }
440
+ :root[data-theme="light"] .brand-logo-img.is-light { display:block; }
441
+
442
+ /* header icon button (settings gear) */
443
+ .icon-btn { width:30px; height:30px; border:1px solid var(--line); border-radius:8px; background:var(--surface-2); color:var(--muted); cursor:pointer; display:grid; place-items:center; flex-shrink:0; }
444
+ .icon-btn:hover { color:var(--ink); border-color:var(--cool); }
445
+
446
+ /* settings popover */
447
+ .settings-pop { position:absolute; top:58px; right:16px; z-index:60; width:250px; background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:14px; display:flex; flex-direction:column; gap:12px; }
448
+ .settings-pop[hidden] { display:none; }
449
+ .settings-head { font-weight:700; font-size:13px; }
450
+ .settings-row { display:flex; flex-direction:column; gap:5px; font-size:10.5px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); }
451
+ .settings-row select { font-family:inherit; font-size:13px; text-transform:none; letter-spacing:0; padding:6px 8px; border:1px solid var(--line); border-radius:8px; background:var(--surface-2); color:var(--ink); cursor:pointer; }
452
+ .settings-note { font-size:10.5px; color:var(--faint); margin:0; line-height:1.4; }
453
+
454
+ /* tab badge (open attention count) */
455
+ .tab-badge { display:inline-grid; place-items:center; min-width:16px; height:16px; padding:0 4px; margin-left:5px; border-radius:999px; background:var(--signal); color:#241705; font-size:10px; font-weight:700; }
456
+ .tab-badge[hidden] { display:none; }
457
+
458
+ /* attention tab */
459
+ .attn-wrap { max-width:860px; margin:0 auto; padding:20px 22px; }
460
+ .attn-add { display:flex; gap:8px; margin:14px 0 10px; align-items:flex-start; }
461
+ .attn-add textarea { flex:1; min-height:44px; resize:vertical; }
462
+ .attn-filters { display:flex; gap:8px; margin-bottom:16px; }
463
+ .attn-list { display:flex; flex-direction:column; gap:10px; }
464
+ .attn-row { border:1px solid var(--line); border-left:3px solid var(--faint); border-radius:var(--radius); background:var(--surface); padding:12px 14px; }
465
+ .attn-row.from-agent { border-left-color:var(--signal); }
466
+ .attn-row.is-resolved { opacity:.62; }
467
+ .attn-head { display:flex; align-items:center; gap:10px; margin-bottom:6px; }
468
+ .attn-src { font-family:var(--mono); font-size:10.5px; text-transform:uppercase; letter-spacing:.05em; }
469
+ .attn-src.is-agent { color:var(--signal); }
470
+ .attn-src.is-user { color:var(--cool); }
471
+ .attn-time { font-family:var(--mono); font-size:10.5px; color:var(--faint); }
472
+ .attn-text { font-size:14px; line-height:1.5; white-space:pre-wrap; }
473
+ .attn-edit { width:100%; min-height:64px; font-family:inherit; font-size:14px; padding:8px; border:1px solid var(--cool); border-radius:8px; background:var(--surface-2); color:var(--ink); box-sizing:border-box; }
474
+ .attn-actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:11px; }
475
+ .btn.danger { color:var(--s-blocked); border-color:color-mix(in srgb,var(--s-blocked) 40%,var(--line)); }
476
+ .btn.danger:hover { border-color:var(--s-blocked); }
477
+
478
+ /* backlog pager */
479
+ .pager { display:flex; align-items:center; justify-content:center; gap:12px; padding:14px 0 4px; }
480
+ .pager-info { font-family:var(--mono); font-size:11.5px; color:var(--muted); }
481
+ .pager-btn { font-family:var(--mono); font-size:12px; padding:5px 12px; border:1px solid var(--line); border-radius:8px; background:var(--surface); color:var(--ink); cursor:pointer; }
482
+ .pager-btn:hover:not(:disabled) { border-color:var(--cool); }
483
+ .pager-btn:disabled { opacity:.4; cursor:default; }
484
+
485
+ /* workflow redesign — a wrapping pipeline of step cards with connectors */
486
+ .wf-legend { margin-bottom:14px; }
487
+ .wf-legend-txt { font-family:var(--mono); font-size:11.5px; color:var(--muted); }
488
+ .wf-track { display:flex; flex-wrap:wrap; align-items:stretch; gap:0; }
489
+ .wf-card { position:relative; min-width:152px; flex:0 0 auto; border:1px solid var(--line); border-radius:var(--radius); background:var(--surface); padding:12px 14px; cursor:pointer; transition:border-color .15s, transform .15s, box-shadow .15s; display:flex; flex-direction:column; gap:9px; }
490
+ .wf-card:hover { border-color:var(--cool); transform:translateY(-1px); box-shadow:var(--shadow); }
491
+ .wf-card:focus-visible { outline:2px solid var(--cool); outline-offset:2px; }
492
+ .wf-card.off { opacity:.5; }
493
+ .wf-card-head { display:flex; align-items:center; gap:8px; }
494
+ .wf-num { display:grid; place-items:center; width:20px; height:20px; border-radius:999px; background:var(--surface-2); color:var(--muted); font-family:var(--mono); font-size:11px; flex-shrink:0; }
495
+ .wf-card:not(.off) .wf-num { background:var(--signal); color:#241705; }
496
+ .wf-card-name { font-weight:600; font-size:13.5px; }
497
+ .wf-opt { font-family:var(--mono); font-size:9px; text-transform:uppercase; letter-spacing:.05em; color:var(--faint); border:1px solid var(--line); border-radius:4px; padding:1px 4px; }
498
+ .wf-card-meta { display:flex; flex-wrap:wrap; gap:6px; }
499
+ .wf-cap,.wf-skill { font-family:var(--mono); font-size:10px; border-radius:999px; padding:2px 8px; }
500
+ .wf-cap { color:var(--cool); border:1px solid color-mix(in srgb,var(--cool) 40%,var(--line)); }
501
+ .wf-skill { color:var(--muted); border:1px solid var(--line); }
502
+ .wf-toggle { display:flex; align-items:center; gap:6px; }
503
+ .wf-toggle-dot { width:8px; height:8px; border-radius:50%; background:var(--faint); }
504
+ .wf-card:not(.off) .wf-toggle-dot { background:var(--s-done); }
505
+ .wf-toggle-label { font-family:var(--mono); font-size:10px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); }
506
+ .wf-conn { flex:0 0 auto; align-self:center; width:26px; height:2px; background:linear-gradient(90deg,var(--cool),var(--signal)); margin:0 2px; border-radius:2px; }
507
+ .wf-conn.off { background:var(--line); }
508
+ @media (max-width:720px){ .wf-track { flex-direction:column; align-items:stretch; } .wf-conn { width:2px; height:18px; margin:2px 0 2px 19px; } }
@@ -27,8 +27,27 @@ function makeFeeder(onLine) {
27
27
  };
28
28
  }
29
29
 
30
+ // Detect an attention sentinel: `::spectoflow attention msg=<text>` (kind=… optional).
31
+ // Agents raise points that deserve the user's eye; they surface in the Attention tab.
32
+ function parseAttentionLine(line) {
33
+ const m = /^::spectoflow\s+attention\b(.*)$/.exec(String(line).trim());
34
+ if (!m) return null;
35
+ const rest = m[1];
36
+ const msg = (/\bmsg=([\s\S]+)$/.exec(rest) || [])[1];
37
+ if (!msg || !msg.trim()) return null;
38
+ return msg.trim();
39
+ }
40
+ function pushAttention(root, text, by) {
41
+ const rt = store.readRuntime(root); rt.attention = rt.attention || [];
42
+ const item = { id: 'att' + Date.now().toString(36) + Math.floor(Math.random() * 1e3).toString(36), at: new Date().toISOString(), by: by || 'agent', source: 'agent', status: 'open', text };
43
+ rt.attention.unshift(item); store.writeRuntime(root, rt);
44
+ return item;
45
+ }
46
+
30
47
  // Start an agent run. Returns { runId, child } or { error } if no runner is configured.
31
- function startRun(root, { prompt, agent }, emit) {
48
+ // logPrompt:false suppresses echoing the prompt as a user bubble — used by the orchestrator,
49
+ // whose priming prompt ("You are the …") is machinery the user shouldn't have to read.
50
+ function startRun(root, { prompt, agent, logPrompt = true }, emit) {
32
51
  const cfg = store.readConfig(root);
33
52
  const which = agent || cfg.agent || 'claude';
34
53
  const cmdStr = cfg.runners && cfg.runners[which];
@@ -37,8 +56,10 @@ function startRun(root, { prompt, agent }, emit) {
37
56
  const runId = 'r' + Date.now().toString(36);
38
57
  const p = String(prompt).trim();
39
58
 
40
- const um = store.appendMessage(root, { role: 'user', kind: 'message', text: p, agent: which, runId });
41
- emit({ type: 'message', message: um });
59
+ if (logPrompt) {
60
+ const um = store.appendMessage(root, { role: 'user', kind: 'message', text: p, agent: which, runId });
61
+ emit({ type: 'message', message: um });
62
+ }
42
63
 
43
64
  const run = { id: runId, tool: which, prompt: p, status: 'running', startedAt: new Date().toISOString() };
44
65
  runStart(root, run); emit({ type: 'run-start', run }); emit({ type: 'change' });
@@ -56,6 +77,8 @@ function startRun(root, { prompt, agent }, emit) {
56
77
  try { child.stdin && child.stdin.end(); } catch {}
57
78
 
58
79
  const onLine = (line) => {
80
+ const att = parseAttentionLine(line);
81
+ if (att) { pushAttention(root, att, which); emit({ type: 'change' }); return; }
59
82
  const m = store.parseAgentLine(line);
60
83
  if (m) { const full = store.appendMessage(root, { ...m, agent: which, runId }); emit({ type: 'message', message: full }); }
61
84
  else emit({ type: 'run-line', runId, chunk: line + '\n' });
@@ -15,7 +15,7 @@ const orchestrator = require('./orchestrator');
15
15
  const PORT = process.env.SPECTOFLOW_PORT ? Number(process.env.SPECTOFLOW_PORT) : 4319;
16
16
  const PUBLIC = path.join(__dirname, 'public');
17
17
  const ROOT = process.env.SPECTOFLOW_ROOT || path.resolve(__dirname, '..', '..');
18
- const MIME = { '.html':'text/html; charset=utf-8', '.css':'text/css; charset=utf-8', '.js':'application/javascript; charset=utf-8' };
18
+ const MIME = { '.html':'text/html; charset=utf-8', '.css':'text/css; charset=utf-8', '.js':'application/javascript; charset=utf-8', '.png':'image/png', '.svg':'image/svg+xml', '.ico':'image/x-icon' };
19
19
  const clients = new Set();
20
20
 
21
21
  function project(){ return store.readProject(ROOT); }
@@ -24,6 +24,37 @@ function body(req){ return new Promise(r=>{ let b=''; req.on('data',c=>b+=c); re
24
24
  function emit(obj){ const line='data: '+JSON.stringify(obj)+'\n\n'; for(const res of clients) res.write(line); }
25
25
  function findPlanFileForTask(id){ for(const pl of store.readPlans(ROOT)) for(const ph of pl.phases) if(ph.tasks.find(t=>t.id===id)) return pl.file; return null; }
26
26
 
27
+ // ---- helpers for settings + attention points -----------------------------
28
+ const configPath = () => path.join(ROOT, '.spectoflow', 'config.json');
29
+ function writeConfig(patch){
30
+ const cp = configPath(); const cfg = JSON.parse(fs.readFileSync(cp, 'utf8'));
31
+ if (patch.mode && ['autopilot','semi','manual'].includes(patch.mode)) cfg.mode = patch.mode;
32
+ if (typeof patch.language === 'string' && patch.language.trim()) cfg.language = patch.language.trim();
33
+ fs.writeFileSync(cp, JSON.stringify(cfg, null, 2) + '\n');
34
+ return cfg;
35
+ }
36
+ // Next free T-### id across every plan file (absolute paths from store.readPlans).
37
+ function nextTaskId(){
38
+ let max = 0;
39
+ for (const pl of store.readPlans(ROOT)) {
40
+ try { const t = fs.readFileSync(pl.file, 'utf8'); const re = /\bT-(\d+)/g; let m; while ((m = re.exec(t))) max = Math.max(max, Number(m[1])); } catch {}
41
+ }
42
+ return 'T-' + String(max + 1).padStart(3, '0');
43
+ }
44
+ // Promote an attention item into a real checkbox task under an `## Attention` phase.
45
+ function promoteAttention(item){
46
+ const plans = store.readPlans(ROOT);
47
+ let file = plans[0] && plans[0].file;
48
+ if (!file) { file = path.join(ROOT, 'plans', 'inbox.md'); fs.mkdirSync(path.dirname(file), { recursive: true }); if (!fs.existsSync(file)) fs.writeFileSync(file, '# Inbox\n'); }
49
+ const id = nextTaskId();
50
+ let text = fs.readFileSync(file, 'utf8');
51
+ const line = `- [ ] ${id} ${String(item.text).replace(/\s+/g, ' ').trim()} @user ~standard`;
52
+ if (/^##\s+Attention\s*$/m.test(text)) text = text.replace(/^(##\s+Attention\s*)$/m, `$1\n${line}`);
53
+ else { if (!text.endsWith('\n')) text += '\n'; text += `\n## Attention\n${line}\n`; }
54
+ fs.writeFileSync(file, text);
55
+ return { id, file };
56
+ }
57
+
27
58
  function watch(dir){ try{ fs.watch(dir,{recursive:false},()=>emit({type:'change'})); }catch(_){} }
28
59
  ['plans','specs','.spectoflow'].forEach(d=>{ const p=path.join(ROOT,d); if(fs.existsSync(p)) watch(p); });
29
60
 
@@ -113,11 +144,64 @@ const server = http.createServer(async (req,res)=>{
113
144
  return sendJSON(res, ok ? 200 : 409, ok ? { ok: true } : { error: 'No pending approval.' });
114
145
  }
115
146
 
147
+ // ---- settings: change autonomy mode + output language (writes config.json) ----
148
+ if (p === '/api/settings' && req.method === 'POST') {
149
+ const patch = await body(req);
150
+ try { const cfg = writeConfig(patch); emit({ type: 'change' }); return sendJSON(res, 200, { config: cfg }); }
151
+ catch (e) { return sendJSON(res, 400, { error: String(e && e.message || e) }); }
152
+ }
153
+
154
+ // ---- attention points: agent- or user-raised notes; validate → real task ----
155
+ if (p === '/api/attention' && req.method === 'POST') {
156
+ const { text } = await body(req);
157
+ if (!text || !String(text).trim()) return sendJSON(res, 400, { error: 'Empty note.' });
158
+ const rt = store.readRuntime(ROOT); rt.attention = rt.attention || [];
159
+ const item = { id: 'att' + Date.now().toString(36), at: new Date().toISOString(), by: 'me', source: 'user', status: 'open', text: String(text).trim() };
160
+ rt.attention.unshift(item); store.writeRuntime(ROOT, rt); emit({ type: 'change' });
161
+ return sendJSON(res, 200, { item });
162
+ }
163
+ if (/^\/api\/attention\/[^/]+\/promote$/.test(p) && req.method === 'POST') {
164
+ const id = decodeURIComponent(p.split('/')[3] || '');
165
+ const rt = store.readRuntime(ROOT); const it = (rt.attention || []).find((x) => x.id === id);
166
+ if (!it) return sendJSON(res, 404, { error: 'Note not found.' });
167
+ const t = promoteAttention(it); it.status = 'resolved'; it.promotedTo = t.id;
168
+ store.writeRuntime(ROOT, rt); emit({ type: 'change' });
169
+ return sendJSON(res, 200, { task: t });
170
+ }
171
+ if (/^\/api\/attention\/[^/]+$/.test(p) && req.method === 'PATCH') {
172
+ const id = decodeURIComponent(p.split('/')[3] || '');
173
+ const patch = await body(req);
174
+ const rt = store.readRuntime(ROOT); const it = (rt.attention || []).find((x) => x.id === id);
175
+ if (!it) return sendJSON(res, 404, { error: 'Note not found.' });
176
+ if (typeof patch.text === 'string' && patch.text.trim()) it.text = patch.text.trim();
177
+ if (patch.status && ['open', 'resolved'].includes(patch.status)) it.status = patch.status;
178
+ store.writeRuntime(ROOT, rt); emit({ type: 'change' });
179
+ return sendJSON(res, 200, { item: it });
180
+ }
181
+ if (/^\/api\/attention\/[^/]+$/.test(p) && req.method === 'DELETE') {
182
+ const id = decodeURIComponent(p.split('/')[3] || '');
183
+ const rt = store.readRuntime(ROOT); rt.attention = (rt.attention || []).filter((x) => x.id !== id);
184
+ store.writeRuntime(ROOT, rt); emit({ type: 'change' });
185
+ return sendJSON(res, 200, { ok: true });
186
+ }
187
+
188
+ // ---- static files, with SPA fallback: a route like /backlog (no file extension)
189
+ // that isn't a real asset serves index.html so client-side routing can take over ----
116
190
  let file=p==='/'?'/index.html':p;
117
191
  const full=path.join(PUBLIC,path.normalize(file).replace(/^(\.\.[/\\])+/,''));
118
192
  if(!full.startsWith(PUBLIC)){ res.writeHead(403); return res.end('Forbidden'); }
119
- fs.readFile(full,(err,data)=>{ if(err){res.writeHead(404); return res.end('Not found');}
120
- res.writeHead(200,{'Content-Type':MIME[path.extname(full)]||'application/octet-stream'}); res.end(data); });
193
+ fs.readFile(full,(err,data)=>{
194
+ if(err){
195
+ if(req.method==='GET' && !path.extname(p) && !p.startsWith('/api/')){
196
+ return fs.readFile(path.join(PUBLIC,'index.html'),(e2,d2)=>{
197
+ if(e2){ res.writeHead(404); return res.end('Not found'); }
198
+ res.writeHead(200,{'Content-Type':MIME['.html']}); res.end(d2);
199
+ });
200
+ }
201
+ res.writeHead(404); return res.end('Not found');
202
+ }
203
+ res.writeHead(200,{'Content-Type':MIME[path.extname(full)]||'application/octet-stream'}); res.end(data);
204
+ });
121
205
  }catch(e){ sendJSON(res,500,{error:String(e&&e.message||e)}); }
122
206
  });
123
207
  server.listen(PORT,()=>{ console.log(`spectoflow · dashboard → http://localhost:${PORT}`); console.log(`project root: ${ROOT}`); });
@@ -63,8 +63,26 @@ function parsePlan(text) {
63
63
  return phases;
64
64
  }
65
65
 
66
+ // ---- directory resolution (plans/specs may live under a differently-named folder) --------------
67
+ // Pure: no writes, cheap fs.existsSync probes only. Returns a directory NAME (not a full path):
68
+ // an explicit config override wins if that folder actually exists under root; otherwise the first
69
+ // existing candidate wins (so a project using the singular `plan/` just works); otherwise the
70
+ // conventional default (candidates[0]) is returned even if it doesn't exist yet — callers that
71
+ // need existence keep checking it themselves, same as before.
72
+ function resolveDir(root, config, key, candidates) {
73
+ const override = config && config[key];
74
+ if (override && fs.existsSync(path.join(root, override))) return override;
75
+ for (const c of candidates) {
76
+ if (fs.existsSync(path.join(root, c))) return c;
77
+ }
78
+ return candidates[0];
79
+ }
80
+ function resolvePlansDir(root, config) { return resolveDir(root, config || {}, 'plansDir', ['plans', 'plan']); }
81
+ function resolveSpecsDir(root, config) { return resolveDir(root, config || {}, 'specsDir', ['specs', 'spec']); }
82
+
66
83
  function readPlans(projectRoot) {
67
- const dir = path.join(projectRoot, 'plans');
84
+ const dirName = resolvePlansDir(projectRoot, readConfig(projectRoot));
85
+ const dir = path.join(projectRoot, dirName);
68
86
  if (!fs.existsSync(dir)) return [];
69
87
  const out = [];
70
88
  for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.md')).sort()) {
@@ -74,10 +92,17 @@ function readPlans(projectRoot) {
74
92
  return out;
75
93
  }
76
94
 
95
+ function readSpecs(projectRoot) {
96
+ const dirName = resolveSpecsDir(projectRoot, readConfig(projectRoot));
97
+ const d = path.join(projectRoot, dirName);
98
+ return fs.existsSync(d) ? fs.readdirSync(d).filter((x) => x.endsWith('.md')) : [];
99
+ }
100
+
77
101
  // ---- granular writes ---------------------------------------------------------
78
102
  // Rewrite only the line whose task id matches, preserving everything else.
79
103
  function updateTaskLine(projectRoot, file, id, patch) {
80
- const fp = path.join(projectRoot, 'plans', file);
104
+ const dirName = resolvePlansDir(projectRoot, readConfig(projectRoot));
105
+ const fp = path.join(projectRoot, dirName, file);
81
106
  const lines = fs.readFileSync(fp, 'utf8').split('\n');
82
107
  let changed = false;
83
108
  for (let i = 0; i < lines.length; i++) {
@@ -94,7 +119,8 @@ function updateTaskLine(projectRoot, file, id, patch) {
94
119
  }
95
120
 
96
121
  function addTaskComment(projectRoot, file, id, text, author) {
97
- const fp = path.join(projectRoot, 'plans', file);
122
+ const dirName = resolvePlansDir(projectRoot, readConfig(projectRoot));
123
+ const fp = path.join(projectRoot, dirName, file);
98
124
  const lines = fs.readFileSync(fp, 'utf8').split('\n');
99
125
  for (let i = 0; i < lines.length; i++) {
100
126
  const t = parseTaskLine(lines[i]);
@@ -206,10 +232,7 @@ function readProject(projectRoot) {
206
232
  const plans = readPlans(projectRoot);
207
233
  let runtime = readRuntime(projectRoot);
208
234
  const workflow = readWorkflow(projectRoot);
209
- const specs = (() => {
210
- const d = path.join(projectRoot, 'specs');
211
- return fs.existsSync(d) ? fs.readdirSync(d).filter((x) => x.endsWith('.md')) : [];
212
- })();
235
+ const specs = readSpecs(projectRoot);
213
236
  const agents = listMd(path.join(projectRoot, '.spectoflow', 'agents'));
214
237
  const skills = listSkills(path.join(projectRoot, '.spectoflow', 'skills'));
215
238
 
@@ -283,7 +306,7 @@ function readSkills(projectRoot) {
283
306
  }
284
307
 
285
308
  module.exports = {
286
- parseTaskLine, buildTaskLine, parsePlan, readPlans, updateTaskLine, addTaskComment,
309
+ parseTaskLine, buildTaskLine, parsePlan, readPlans, readSpecs, updateTaskLine, addTaskComment,
287
310
  readRuntime, writeRuntime, parseAgentLine, appendMessage, readConfig, readWorkflow, readProject,
288
- readAgents, readSkills, recordSnapshot,
311
+ readAgents, readSkills, recordSnapshot, resolvePlansDir, resolveSpecsDir,
289
312
  };