spectoflow 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,25 @@ const VERSION = require('../package.json').version;
15
16
  const argv = process.argv.slice(2);
16
17
  const cmd = argv[0] || 'help';
17
18
 
19
+ // ---- dashboard port + running-state probe ------------------------------------
20
+ // Precedence: --port=NNNN > SPECTOFLOW_PORT env > 4319 (matches templates/dashboard/server.js).
21
+ function resolvePort(args) {
22
+ const arg = (args.find((a) => a.startsWith('--port=')) || '').split('=')[1];
23
+ return Number(arg || process.env.SPECTOFLOW_PORT || 4319);
24
+ }
25
+
26
+ // Native http probe, ~500ms timeout, never throws — resolves true/false.
27
+ function probeDashboard(port, timeoutMs = 500) {
28
+ return new Promise((resolve) => {
29
+ const req = http.get({ host: 'localhost', port, path: '/api/project', timeout: timeoutMs }, (res) => {
30
+ res.resume();
31
+ resolve(res.statusCode < 500);
32
+ });
33
+ req.on('timeout', () => { req.destroy(); resolve(false); });
34
+ req.on('error', () => resolve(false));
35
+ });
36
+ }
37
+
18
38
  function copyDir(src, dst) {
19
39
  fs.mkdirSync(dst, { recursive: true });
20
40
  for (const e of fs.readdirSync(src, { withFileTypes: true })) {
@@ -26,8 +46,9 @@ function copyDir(src, dst) {
26
46
 
27
47
  // Existing project: give id-less checkbox tasks a stable id, in place.
28
48
  const ID_RE = /^[A-Za-z]{1,5}-?\d+[A-Za-z]?$/;
29
- function normalizePlans(root) {
30
- const dir = path.join(root, 'plans');
49
+ function normalizePlans(root, config) {
50
+ const dirName = store.resolvePlansDir(root, config || store.readConfig(root));
51
+ const dir = path.join(root, dirName);
31
52
  if (!fs.existsSync(dir)) return 0;
32
53
  let added = 0, seq = 1;
33
54
  for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.md'))) {
@@ -90,12 +111,18 @@ function init() {
90
111
  cfg.runners = { ...cfg.runners, ...adapters.defaultRunners(agents) };
91
112
  fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
92
113
 
93
- // artifact folders
94
- fs.mkdirSync(path.join(target, 'specs'), { recursive: true });
95
- fs.mkdirSync(path.join(target, 'plans'), { recursive: true });
114
+ // artifact folders — reuse an existing differently-named folder (e.g. a project that already
115
+ // keeps its plans in `plan/`, singular) instead of always forcing the plans/specs convention;
116
+ // mkdir is a no-op when the resolved folder already exists.
117
+ const plansDirName = store.resolvePlansDir(target, cfg);
118
+ const specsDirName = store.resolveSpecsDir(target, cfg);
119
+ fs.mkdirSync(path.join(target, specsDirName), { recursive: true });
120
+ fs.mkdirSync(path.join(target, plansDirName), { recursive: true });
121
+ if (plansDirName !== 'plans') notes.push(`Using existing '${plansDirName}/' as the plans folder (set plansDir in config.json to override).`);
122
+ if (specsDirName !== 'specs') notes.push(`Using existing '${specsDirName}/' as the specs folder (set specsDir in config.json to override).`);
96
123
 
97
124
  // existing project: id-normalize any plans already there
98
- const added = normalizePlans(target);
125
+ const added = normalizePlans(target, cfg);
99
126
  if (added) notes.push(`Normalized ${added} existing task(s) with stable ids.`);
100
127
 
101
128
  // per-agent shims
@@ -113,10 +140,11 @@ function init() {
113
140
  console.log(' specs/ plans/ markdown artifacts (your source of truth)');
114
141
  written.forEach((w) => console.log(' + ' + w));
115
142
  notes.forEach((n) => console.log(' ! ' + n));
143
+ const port = resolvePort(argv);
116
144
  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');
145
+ console.log(' 1) Open your agent here or just say what you want to build.');
146
+ console.log(' 2) spectoflow dashboard');
147
+ console.log(` → http://localhost:${port}`);
120
148
  }
121
149
 
122
150
  function update() {
@@ -142,15 +170,27 @@ function update() {
142
170
  if (dryRun) console.log('\n(dry-run — nothing was written)');
143
171
  }
144
172
 
145
- function dashboard() {
173
+ // THE launch command — prints the URL clearly and won't crash on EADDRINUSE: it probes first
174
+ // and, if a dashboard is already up on that port, just reports it instead of spawning a second one.
175
+ async function dashboard() {
176
+ const port = resolvePort(argv);
177
+ const url = `http://localhost:${port}`;
178
+ if (await probeDashboard(port)) {
179
+ console.log(`spectoflow dashboard already running → ${url}`);
180
+ return;
181
+ }
146
182
  const local = path.resolve('.spectoflow', 'dashboard', 'server.js');
147
183
  const bundled = path.join(TPL, 'dashboard', 'server.js');
148
- spawn('node', [fs.existsSync(local) ? local : bundled], { stdio: 'inherit' });
184
+ const env = Object.assign({}, process.env, { SPECTOFLOW_PORT: String(port) });
185
+ spawn('node', [fs.existsSync(local) ? local : bundled], { stdio: 'inherit', env });
186
+ console.log(`spectoflow dashboard → ${url}`);
149
187
  }
150
188
 
151
- function status() {
189
+ async function status() {
152
190
  const root = process.cwd();
153
- if (!fs.existsSync(path.join(root, 'plans')) && !fs.existsSync(path.join(root, '.spectoflow'))) {
191
+ const cfg = store.readConfig(root);
192
+ const plansDirName = store.resolvePlansDir(root, cfg);
193
+ if (!fs.existsSync(path.join(root, plansDirName)) && !fs.existsSync(path.join(root, '.spectoflow'))) {
154
194
  return console.log('No spectoflow project here. Run: spectoflow init');
155
195
  }
156
196
  const p = store.readProject(root);
@@ -159,12 +199,16 @@ function status() {
159
199
  console.log(`${(p.config && p.config.projectType) || 'project'} — mode ${p.config.mode} · lang ${p.config.language}`);
160
200
  console.log(`${done}/${tasks.length} tasks done · ${p.specs.length} spec(s) · ${p.agents.length} agents · ${p.skills.length} skills`);
161
201
  tasks.filter((t) => t.status === 'in_progress').forEach((t) => console.log(` > in progress: ${t.id} ${t.title}`));
202
+ const port = resolvePort(argv);
203
+ const running = await probeDashboard(port);
204
+ console.log(`dashboard: ${running ? `running → http://localhost:${port}` : 'not running'}`);
162
205
  }
163
206
 
164
207
  const help = () => console.log(`spectoflow — commands:
165
208
  init [dir] [--agent=claude,codex] install into a project
166
209
  update [--dry-run] refresh framework files to this kit version
167
- dashboard run the local control plane
210
+ dashboard [--port=NNNN] run the local control plane (default 4319, or $SPECTOFLOW_PORT)
168
211
  status print progress`);
169
212
 
170
- ({ init, update, dashboard, status, help })[cmd] ? ({ init, update, dashboard, status, help })[cmd]() : help();
213
+ const fns = { init, update, dashboard, status, help };
214
+ fns[cmd] ? fns[cmd]() : help();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spectoflow",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
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",
@@ -19,12 +19,30 @@
19
19
  "gemini"
20
20
  ],
21
21
  "homepage": "https://github.com/georgesmomo/spectoflow#readme",
22
- "bugs": { "url": "https://github.com/georgesmomo/spectoflow/issues" },
23
- "repository": { "type": "git", "url": "git+https://github.com/georgesmomo/spectoflow.git" },
22
+ "bugs": {
23
+ "url": "https://github.com/georgesmomo/spectoflow/issues"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/georgesmomo/spectoflow.git"
28
+ },
24
29
  "author": "Georges MOMO <georges.momo@gmail.com>",
25
30
  "license": "MIT",
26
- "bin": { "spectoflow": "bin/spectoflow.js" },
27
- "files": ["bin", "lib", "templates", "README.md", "LICENSE"],
28
- "scripts": { "status": "node bin/spectoflow.js status", "test": "node --test" },
29
- "engines": { "node": ">=18" }
31
+ "bin": {
32
+ "spectoflow": "bin/spectoflow.js"
33
+ },
34
+ "files": [
35
+ "bin",
36
+ "lib",
37
+ "templates",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "scripts": {
42
+ "status": "node bin/spectoflow.js status",
43
+ "test": "node --test"
44
+ },
45
+ "engines": {
46
+ "node": ">=18"
47
+ }
30
48
  }
@@ -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
 
@@ -41,11 +72,19 @@ const server = http.createServer(async (req,res)=>{
41
72
  if (p === '/api/agentfile' && req.method === 'GET') {
42
73
  const rel = new URL(req.url, 'http://x').searchParams.get('path') || '';
43
74
  const base = path.join(ROOT, '.spectoflow');
75
+ const aDir = path.join(base, 'agents'), sDir = path.join(base, 'skills');
44
76
  const abs = path.resolve(base, rel);
45
- const okDir = abs.startsWith(path.join(base, 'agents') + path.sep) || abs.startsWith(path.join(base, 'skills') + path.sep);
77
+ const okDir = abs.startsWith(aDir + path.sep) || abs.startsWith(sDir + path.sep);
46
78
  if (!okDir || !abs.endsWith('.md') || !fs.existsSync(abs) || fs.statSync(abs).isDirectory())
47
79
  return sendJSON(res, 400, { error: 'not an agent/skill file' });
48
- return sendJSON(res, 200, { content: fs.readFileSync(abs, 'utf8') });
80
+ // Symlink guard: the resolved real path must stay within the (real) scope dirs.
81
+ let real; try { real = fs.realpathSync(abs); } catch { real = null; }
82
+ const realA = (() => { try { return fs.realpathSync(aDir); } catch { return aDir; } })();
83
+ const realS = (() => { try { return fs.realpathSync(sDir); } catch { return sDir; } })();
84
+ const okReal = real && (real.startsWith(realA + path.sep) || real.startsWith(realS + path.sep));
85
+ if (!okReal || !real.endsWith('.md') || fs.statSync(real).isDirectory())
86
+ return sendJSON(res, 400, { error: 'not an agent/skill file' });
87
+ return sendJSON(res, 200, { content: fs.readFileSync(real, 'utf8') });
49
88
  }
50
89
 
51
90
  if(p==='/api/events'){
@@ -105,11 +144,64 @@ const server = http.createServer(async (req,res)=>{
105
144
  return sendJSON(res, ok ? 200 : 409, ok ? { ok: true } : { error: 'No pending approval.' });
106
145
  }
107
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 ----
108
190
  let file=p==='/'?'/index.html':p;
109
191
  const full=path.join(PUBLIC,path.normalize(file).replace(/^(\.\.[/\\])+/,''));
110
192
  if(!full.startsWith(PUBLIC)){ res.writeHead(403); return res.end('Forbidden'); }
111
- fs.readFile(full,(err,data)=>{ if(err){res.writeHead(404); return res.end('Not found');}
112
- 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
+ });
113
205
  }catch(e){ sendJSON(res,500,{error:String(e&&e.message||e)}); }
114
206
  });
115
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
  };