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 +59 -15
- package/package.json +25 -7
- package/templates/AGENTS.md +23 -4
- package/templates/config.json +3 -0
- package/templates/dashboard/orchestrator.js +3 -1
- package/templates/dashboard/public/app.js +160 -29
- package/templates/dashboard/public/icons.js +4 -0
- package/templates/dashboard/public/index.html +51 -2
- package/templates/dashboard/public/logo-dark.png +0 -0
- package/templates/dashboard/public/logo-white.png +0 -0
- package/templates/dashboard/public/styles.css +91 -9
- package/templates/dashboard/runner.js +26 -3
- package/templates/dashboard/server.js +97 -5
- package/templates/lib/store.js +32 -9
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
|
|
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
|
-
|
|
95
|
-
|
|
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
|
|
118
|
-
console.log(' 2)
|
|
119
|
-
console.log(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
210
|
+
dashboard [--port=NNNN] run the local control plane (default 4319, or $SPECTOFLOW_PORT)
|
|
168
211
|
status print progress`);
|
|
169
212
|
|
|
170
|
-
|
|
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.
|
|
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": {
|
|
23
|
-
|
|
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": {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"
|
|
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
|
}
|
package/templates/AGENTS.md
CHANGED
|
@@ -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
|
|
47
|
-
Then run **brainstorm → analysis → spec → plan** (write
|
|
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
|
-
`
|
|
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.
|
package/templates/config.json
CHANGED
|
@@ -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
|
-
|
|
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: '
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
if(
|
|
462
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
applyActiveTab();
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
<
|
|
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 & 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="
|
|
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
|
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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(
|
|
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
|
-
|
|
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)=>{
|
|
112
|
-
|
|
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}`); });
|
package/templates/lib/store.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
};
|