spectoflow 0.21.1 → 0.22.2

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
@@ -220,7 +220,7 @@ function init() {
220
220
  console.log('');
221
221
  }
222
222
 
223
- function update() {
223
+ async function update() {
224
224
  const root = process.cwd();
225
225
  if (!fs.existsSync(path.join(root, '.spectoflow'))) {
226
226
  return console.log('No spectoflow project here. Run: spectoflow init');
@@ -252,6 +252,24 @@ function update() {
252
252
  else console.log(` ${changed ? c.g('✓ Done') : c.dim('Already up to date')}${changed ? c.dim(` · ${changed} file(s) changed`) : ''}`);
253
253
  if (r.newSidecar.length && !dryRun) console.log(` ${c.y('→')} ${c.dim(`${r.newSidecar.length} *.new file(s) to review and merge — or re-run: spectoflow update --force`)}`);
254
254
  console.log('');
255
+
256
+ // A running dashboard has the OLD framework code loaded into memory (Node caches `require()`d
257
+ // modules at process start) — new bytes on disk change nothing until it restarts. Do that
258
+ // automatically so an update always actually takes effect, instead of leaving a confusing
259
+ // half-updated dashboard (new static files, stale server logic) until someone thinks to restart.
260
+ if (!dryRun && changed) {
261
+ const lock = path.join(root, '.spectoflow', '.dashboard.lock');
262
+ let info = null;
263
+ try { info = JSON.parse(fs.readFileSync(lock, 'utf8')); } catch {}
264
+ if (info && info.port && (await probeDashboard(info.port, 2000))) {
265
+ console.log(` ${c.dim('Dashboard is running — restarting it on port ' + info.port + ' to apply the update…')}`);
266
+ // Restart on the SAME port it was already on, not resolvePort(argv)'s default — `update`
267
+ // itself was never given a --port, so a naive restartDashboard() would silently move a
268
+ // non-default-port dashboard back to 4319.
269
+ argv.push(`--port=${info.port}`);
270
+ await restartDashboard();
271
+ }
272
+ }
255
273
  }
256
274
 
257
275
  // THE launch command — routes the subcommands, then starts. Starting spawns the server DETACHED and
@@ -331,7 +349,12 @@ async function startDashboard() {
331
349
  const env = Object.assign({}, process.env, { SPECTOFLOW_PORT: String(port) });
332
350
  const child = spawn('node', [fs.existsSync(local) ? local : bundled], { detached: true, stdio: 'ignore', env });
333
351
  child.unref(); // let this CLI exit while the server keeps running
334
- console.log(`${c.g('✓')} dashboard started ${c.bold(url)} ${c.dim('(pid ' + child.pid + ')')}`);
352
+ // Confirm it actually came up (a still-releasing port from a just-stopped instance, or any other
353
+ // startup error, would otherwise print a false "started" while the detached process silently died).
354
+ let up = false;
355
+ for (let i = 0; i < 20 && !up; i++) { await new Promise((r) => setTimeout(r, 250)); up = await probeDashboard(port, 300); }
356
+ if (up) console.log(`${c.g('✓')} dashboard started → ${c.bold(url)} ${c.dim('(pid ' + child.pid + ')')}`);
357
+ else console.log(`${c.y('!')} spawned (pid ${child.pid}) but it isn't responding on ${url} yet — check ${c.g('spectoflow dashboard status')} in a moment, or its own output if something's wrong.`);
335
358
  printDashboardCommands();
336
359
  }
337
360
 
@@ -354,7 +377,11 @@ async function dashboardStatus() {
354
377
 
355
378
  async function restartDashboard() {
356
379
  await stopDashboard();
357
- await new Promise((r) => setTimeout(r, 400)); // let the port free up before rebinding
380
+ // Windows doesn't deliver real signals — process.kill() returns once the request is issued, not
381
+ // once the process (and the port it held) is actually gone. A short gap here, plus startDashboard()
382
+ // now confirming the new one actually came up, keeps a restart honest under load instead of racing
383
+ // a rebind against a socket the OS hasn't finished releasing yet.
384
+ await new Promise((r) => setTimeout(r, 1000));
358
385
  return startDashboard();
359
386
  }
360
387
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spectoflow",
3
- "version": "0.21.1",
3
+ "version": "0.22.2",
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",
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+ /*
3
+ * File Explorer backend — browse, read, write and create files/folders anywhere under the project
4
+ * root. Kept separate from server.js (own module, like runner.js/summarize.js), since it owns a
5
+ * distinct concern: safe filesystem access scoped to the whole project, not just plans/specs/agents.
6
+ *
7
+ * Trust model matches the rest of this local dashboard (POST /api/run already spawns an arbitrary
8
+ * configured agent command): this is a single-user localhost dev tool, not a hosted multi-tenant
9
+ * service. The guard here exists to stop a path like "../../etc/passwd" from a buggy or malicious
10
+ * client, not to sandbox an untrusted operator.
11
+ */
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const DENY_DIRS = new Set(['.git', 'node_modules']);
16
+ const MAX_READ_BYTES = 2 * 1024 * 1024;
17
+
18
+ // Resolves `rel` against `root`, rejecting anything that normalizes outside it (path traversal).
19
+ // `root` itself is normalized first — ROOT can arrive with mixed separators (e.g. from an env var
20
+ // built by joining a Windows base path with a forward-slash suffix), and comparing an un-normalized
21
+ // root against path.resolve()'s always-normalized output would reject even legitimate children.
22
+ function safePath(root, rel) {
23
+ const rootAbs = path.resolve(root);
24
+ const cleaned = String(rel || '').replace(/^[/\\]+/, '');
25
+ const abs = path.resolve(rootAbs, cleaned);
26
+ if (abs !== rootAbs && !abs.startsWith(rootAbs + path.sep)) return null;
27
+ return abs;
28
+ }
29
+
30
+ // Symlink guard: an existing path must REALLY resolve under root, not just syntactically.
31
+ // A path that doesn't exist yet (e.g. a file about to be created) is trusted as-is — nothing to
32
+ // resolve through.
33
+ function realUnderRoot(root, abs) {
34
+ let real;
35
+ try { real = fs.realpathSync(abs); } catch { return abs; }
36
+ const realRoot = (() => { try { return fs.realpathSync(root); } catch { return root; } })();
37
+ if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return null;
38
+ return real;
39
+ }
40
+
41
+ function isUnderGit(rel) {
42
+ const n = String(rel || '').replace(/\\/g, '/').replace(/^\/+/, '');
43
+ return n === '.git' || n.startsWith('.git/');
44
+ }
45
+
46
+ function buildTree(dir, relBase) {
47
+ let entries;
48
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
49
+ entries.sort((a, b) => (a.isDirectory() === b.isDirectory()) ? a.name.localeCompare(b.name) : (a.isDirectory() ? -1 : 1));
50
+ const out = [];
51
+ for (const e of entries) {
52
+ if (DENY_DIRS.has(e.name)) continue;
53
+ const rel = relBase ? relBase + '/' + e.name : e.name;
54
+ if (e.isDirectory()) out.push({ name: e.name, path: rel, type: 'dir', children: buildTree(path.join(dir, e.name), rel) });
55
+ else out.push({ name: e.name, path: rel, type: 'file' });
56
+ }
57
+ return out;
58
+ }
59
+
60
+ // A NUL byte anywhere in the first chunk means "binary" — cheap and reliable enough for a local
61
+ // preview tool (the same heuristic git and most editors use).
62
+ function isProbablyText(buf) {
63
+ const n = Math.min(buf.length, 8000);
64
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return false;
65
+ return true;
66
+ }
67
+
68
+ function tree(root) {
69
+ return buildTree(root, '');
70
+ }
71
+
72
+ function readFile(root, rel) {
73
+ const abs = safePath(root, rel);
74
+ if (!abs) return { error: 'Invalid path.' };
75
+ const real = realUnderRoot(root, abs);
76
+ if (!real) return { error: 'Invalid path.' };
77
+ let stat;
78
+ try { stat = fs.statSync(real); } catch { return { error: 'Not found.' }; }
79
+ if (stat.isDirectory()) return { error: 'That is a folder.' };
80
+ if (stat.size > MAX_READ_BYTES) return { error: 'File too large to open here (>2MB).' };
81
+ const buf = fs.readFileSync(real);
82
+ if (!isProbablyText(buf)) return { binary: true, size: stat.size };
83
+ return { content: buf.toString('utf8') };
84
+ }
85
+
86
+ function writeFile(root, rel, content) {
87
+ if (typeof content !== 'string') return { error: 'Missing content.' };
88
+ if (isUnderGit(rel)) return { error: 'Writes under .git are blocked.' };
89
+ const abs = safePath(root, rel);
90
+ if (!abs) return { error: 'Invalid path.' };
91
+ const parentDir = path.dirname(abs);
92
+ if (fs.existsSync(parentDir) && !realUnderRoot(root, parentDir)) return { error: 'Invalid path.' };
93
+ fs.mkdirSync(parentDir, { recursive: true });
94
+ fs.writeFileSync(abs, content, 'utf8');
95
+ return { ok: true };
96
+ }
97
+
98
+ function mkdir(root, rel) {
99
+ if (isUnderGit(rel)) return { error: 'Cannot create folders under .git.' };
100
+ const abs = safePath(root, rel);
101
+ if (!abs) return { error: 'Invalid path.' };
102
+ const parentDir = path.dirname(abs);
103
+ if (fs.existsSync(parentDir) && !realUnderRoot(root, parentDir)) return { error: 'Invalid path.' };
104
+ fs.mkdirSync(abs, { recursive: true });
105
+ return { ok: true };
106
+ }
107
+
108
+ module.exports = { tree, readFile, writeFile, mkdir };
@@ -7,6 +7,7 @@ function updateStatusLabels(){ for(const k of Object.keys(STATUS)) STATUS[k]=t('
7
7
  let P = null, openTaskId = null;
8
8
  let filter = { status: 'all', q: '' }; // board filter state — client-side only, read-only
9
9
  let boardView = (()=>{ try{ return localStorage.getItem('spf-board-view')||'list'; }catch{ return 'list'; } })(); // 'list' | 'kanban'
10
+ let sideHidden = (()=>{ try{ return localStorage.getItem('spf-side-hidden')==='1'; }catch{ return false; } })(); // right sidebar (Journal/Specs/Running) — mainly to give Kanban's own-width columns more room
10
11
  let backlogFilter = { status: 'open', q: '' }; // backlog defaults to open (not-done) tasks
11
12
  let backlogSort = { col: 'id', dir: 'asc' }; // backlog sort state — client-side only
12
13
  let backlogPage = 1; const BACKLOG_PAGE = 25; // backlog pagination — client-side only
@@ -55,19 +56,32 @@ function chatContainers(){ return [$('#chatLog'),$('#chatTabLog')].filter(Boolea
55
56
  function scrollChat(container){ container.scrollTop=container.scrollHeight; }
56
57
  function clearIdle(container){ const i=container.querySelector('.chat-idle'); if(i) i.remove(); }
57
58
  function bubble(m){
58
- if(m.role==='user'){ const d=el('div','msg you'); d.append(el('div','bubble',m.text)); return d; }
59
- const wrap=el('div','msg agentmsg k-'+(m.kind||'message'));
60
- wrap.append(el('div','msg-role', m.role + (m.agent&&m.agent!==m.role?(' · '+m.agent):'')));
61
- wrap.append(el('div','bubble',m.text));
62
- return wrap;
59
+ let node;
60
+ if(m.role==='user'){ node=el('div','msg you'); node.append(el('div','bubble',m.text)); }
61
+ else{
62
+ node=el('div','msg agentmsg k-'+(m.kind||'message'));
63
+ node.append(el('div','msg-role', m.role + (m.agent&&m.agent!==m.role?(' · '+m.agent):'')));
64
+ node.append(el('div','bubble',m.text));
65
+ }
66
+ node.dataset.id=m.id; // lets renderChatLog tell a stale bubble from a genuinely new one
67
+ return node;
63
68
  }
69
+ function idleBlock(){ const d=el('div','chat-idle'); d.innerHTML=t('chat.idle'); return d; }
64
70
  function renderChatLog(container){
65
71
  if(!container) return;
66
72
  const st=stateFor(container); const msgs=(P.runtime&&P.runtime.messages)||[];
67
- if(msgs.length) clearIdle(container);
73
+ const ids=new Set(msgs.map(m=>m.id));
74
+ // Summarize/Clear REPLACE the server-side log (a digest that leaves the old messages sitting right
75
+ // below it wouldn't condense anything) — if anything we already rendered no longer exists, the log
76
+ // was reset under us: rebuild from scratch instead of just appending, or the stale bubbles never go
77
+ // away short of a full page reload.
78
+ const stale=[...st.rendered].some(id=>!ids.has(id));
79
+ if(stale){ container.innerHTML=''; st.rendered=new Set(); st.rawBlock=null; }
80
+ if(!msgs.length){ if(!container.querySelector('.chat-idle')) container.append(idleBlock()); renderApproval(container); return; }
81
+ clearIdle(container);
68
82
  let added=false;
69
83
  for(const m of msgs){ if(st.rendered.has(m.id)) continue; st.rendered.add(m.id); container.append(bubble(m)); added=true; }
70
- if(added) scrollChat(container);
84
+ if(added||stale) scrollChat(container);
71
85
  renderApproval(container);
72
86
  }
73
87
  function renderApproval(container){
@@ -151,7 +165,7 @@ function render(){
151
165
  if(meter) meter.title=`${t('kpi.globalProgress')}: ${s.pct}% (${s.done}/${s.total} ${t('kpi.tasksLabel')})`;
152
166
  renderOverview(); renderBoard(); renderBacklog(); renderWorkflow(); renderTeam();
153
167
  renderChatLog($('#chatLog')); renderChatLog($('#chatTabLog'));
154
- renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderDocs(); renderSettings();
168
+ renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderDocs(); renderSettings(); renderFiles(); applySideHidden();
155
169
  renderCustomDashboards(); // adds/removes nav tabs + panels before applyActiveTab() below reads them
156
170
  applyActiveTab(); // re-apply the current tab so an SSE-driven re-render never resets to Board
157
171
  applyI18nStatic(); // re-translate the static markup (nav, headers, placeholders…) for this tick's language
@@ -695,6 +709,39 @@ function editAttn(it,txtNode){
695
709
  ta.addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){ e.preventDefault(); save(); } if(e.key==='Escape'){ done=true; renderAttention(); } });
696
710
  }
697
711
  async function addAttn(text){ flash(); await fetch('/api/attention',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text})}); }
712
+
713
+ // ---- Backlog "+ Add task" — a manual checkbox task, no agent involved ----
714
+ function openBacklogAddForm(){
715
+ const form=$('#backlogAddForm'); if(!form) return;
716
+ const list=$('#blPhaseList');
717
+ if(list){ list.innerHTML=''; allPhaseTitles().forEach(ti=> list.append(new Option(ti))); }
718
+ const err=$('#blAddError'); if(err) err.hidden=true;
719
+ form.hidden=false; form.classList.add('is-open');
720
+ $('#blAddTitle').focus();
721
+ }
722
+ function closeBacklogAddForm(){
723
+ const form=$('#backlogAddForm'); if(!form) return;
724
+ form.hidden=true; form.classList.remove('is-open');
725
+ ['blAddTitle','blAddPhase','blAddOwner'].forEach(id=>{ const f=$('#'+id); if(f) f.value=''; });
726
+ const lvl=$('#blAddLevel'); if(lvl) lvl.value='standard';
727
+ }
728
+ async function submitBacklogAdd(){
729
+ const err=$('#blAddError');
730
+ const title=($('#blAddTitle').value||'').trim();
731
+ if(!title){ if(err){ err.textContent=t('backlog.addTitleRequired'); err.hidden=false; } $('#blAddTitle').focus(); return; }
732
+ const phase=($('#blAddPhase').value||'').trim();
733
+ const owner=($('#blAddOwner').value||'').trim();
734
+ const level=$('#blAddLevel').value;
735
+ flash();
736
+ const r=await fetch('/api/task',{method:'POST',headers:{'Content-Type':'application/json'},
737
+ body:JSON.stringify({title, phase:phase||undefined, owner:owner||undefined, level})});
738
+ if(!r.ok){
739
+ const j=await r.json().catch(()=>({}));
740
+ if(err){ err.textContent=j.error||t('backlog.addFailed'); err.hidden=false; }
741
+ return;
742
+ }
743
+ closeBacklogAddForm();
744
+ }
698
745
  async function patchAttn(id,patch){ flash(); await fetch('/api/attention/'+encodeURIComponent(id),{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }
699
746
  async function deleteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id),{method:'DELETE'}); }
700
747
  async function promoteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id)+'/promote',{method:'POST'}); }
@@ -917,10 +964,14 @@ function czItemsFor(kind){
917
964
  function renderCustomize(){
918
965
  const root=$('#czRoot'); if(!root) return;
919
966
  const openKind=root.dataset.open||'';
967
+ // a block with its form open spans the full row (see CSS) — an auto-fit grid would otherwise
968
+ // still reserve empty trailing cells beside it for the two collapsed blocks that no longer fill
969
+ // out a row, so drop to a single column for the whole grid while any block is open.
970
+ root.classList.toggle('has-open', !!openKind);
920
971
  root.innerHTML='';
921
972
  CZ_KINDS.forEach(({kind})=>{
922
973
  const items=czItemsFor(kind);
923
- const block=el('div','cz-block');
974
+ const block=el('div','cz-block'+(openKind===kind?' is-open':''));
924
975
  const head=el('div','cz-head');
925
976
  head.append(el('h3',null,t('customize.'+kind+'s')+' ('+items.length+')'));
926
977
  const addBtn=el('button','btn cz-add',t('customize.add.'+kind));
@@ -967,11 +1018,15 @@ async function czSubmit(kind,description,agent){
967
1018
  // A custom dashboard (Customize page) gets its own tab id "custom:<id>" and its own URL shape
968
1019
  // /custom/<id> — kept out of ROUTES (a fixed list) since the set of custom ids is dynamic; recognized
969
1020
  // by a dedicated branch in tabFromPath()/navigateTab() instead.
970
- const ROUTES=['board','requests','attention','backlog','workflow','team','chat','info','docs','settings'];
1021
+ const ROUTES=['board','requests','attention','backlog','workflow','team','files','chat','info','docs','personalize'];
1022
+ // the tab used to be named/routed "settings" — old bookmarks and any localStorage value saved
1023
+ // under that name still land on the Personalize tab instead of a blank panel.
1024
+ function normalizeTab(t){ return t==='settings'?'personalize':t; }
971
1025
  function tabFromPath(){
972
1026
  const s=location.pathname.split('/').filter(Boolean);
973
1027
  if(s[0]==='custom'&&s[1]) return 'custom:'+decodeURIComponent(s[1]);
974
- return ROUTES.includes(s[0])?s[0]:null;
1028
+ const t=normalizeTab(s[0]);
1029
+ return ROUTES.includes(t)?t:null;
975
1030
  }
976
1031
  function taskFromPath(){ const s=location.pathname.split('/').filter(Boolean); return (ROUTES.includes(s[0])&&s[1])?decodeURIComponent(s[1]):null; }
977
1032
  function navigateTab(tabId,push){
@@ -986,6 +1041,7 @@ function navigateTab(tabId,push){
986
1041
  // was scrolled to last (only on an actual switch INTO the tab — applyActiveTab() alone runs on
987
1042
  // every SSE render tick too, and re-scrolling/re-focusing there would fight the user's typing).
988
1043
  if(tabId==='chat') setTimeout(()=>{ scrollChat($('#chatTabLog')); $('#tabRunPrompt').focus(); },60);
1044
+ if(tabId==='files') renderFiles(); // the tree is fetched lazily — only load it on an actual switch in
989
1045
  }
990
1046
  function closeNav(){ document.body.classList.remove('nav-open'); const nt=$('#navToggle'); if(nt) nt.setAttribute('aria-expanded','false'); }
991
1047
 
@@ -1236,6 +1292,203 @@ function renderDocs(){
1236
1292
  box.append(note);
1237
1293
  }
1238
1294
 
1295
+ // ---- Files tab: browse the project tree, view/edit any text file (Markdown rendered, HTML
1296
+ // previewed in a sandboxed iframe, everything else as a plain monospace editor). The tree is
1297
+ // fetched lazily (only while this tab is active) and never refetched mid-edit — an SSE 'change'
1298
+ // event refreshes the TREE listing but never overwrites an open file's editor buffer, so an
1299
+ // unrelated agent write elsewhere can't clobber unsaved work here. ----
1300
+ let filesTreeData=null, filesOpenPath=null, filesOpenDirty=false;
1301
+ const filesOpenDirs=new Set(); // persists which tree folders are expanded across a refresh
1302
+ async function loadFilesTree(){
1303
+ try{
1304
+ const r=await fetch('/api/files/tree'); const d=await r.json().catch(()=>({}));
1305
+ filesTreeData = (r.ok && Array.isArray(d.tree)) ? d.tree : [];
1306
+ }catch{ filesTreeData=filesTreeData||[]; }
1307
+ renderFilesTree();
1308
+ }
1309
+ function renderFiles(){
1310
+ // Only the FIRST activation fetches — render() re-runs on every SSE 'change' (a chat message, a
1311
+ // task update, anything) and a full tree rebuild on each of those would yank rows out from under
1312
+ // an in-progress click. Fresh-after-your-own-action is handled by loadFilesTree() calls at the
1313
+ // point of action (filesCreate); the toolbar's Refresh button covers everything else.
1314
+ if(activeTab!=='files' || filesTreeData!=null) return;
1315
+ loadFilesTree();
1316
+ }
1317
+ function fNode(entry){
1318
+ const row=el('div','f-row'+(entry.type==='dir'&&filesOpenDirs.has(entry.path)?' is-open':'')+(entry.path===filesOpenPath?' is-active':''));
1319
+ row.tabIndex=0;
1320
+ if(entry.type==='dir'){
1321
+ const chev=document.createElementNS('http://www.w3.org/2000/svg','svg');
1322
+ chev.setAttribute('viewBox','0 0 18 18'); chev.setAttribute('class','f-chevron'); chev.setAttribute('fill','none'); chev.setAttribute('stroke','currentColor'); chev.setAttribute('stroke-width','1.8');
1323
+ chev.innerHTML='<path d="M6.5 4l6 5-6 5"/>';
1324
+ row.append(chev);
1325
+ } else row.append(el('span',null,''));
1326
+ row.append(el('span','f-name',entry.name));
1327
+ const wrap=el('div','f-node');
1328
+ wrap.append(row);
1329
+ if(entry.type==='dir'){
1330
+ const kids=el('div','f-children'); kids.hidden=!filesOpenDirs.has(entry.path);
1331
+ (entry.children||[]).forEach(c=> kids.append(fNode(c)));
1332
+ wrap.append(kids);
1333
+ row.addEventListener('click',()=>{
1334
+ const open=filesOpenDirs.has(entry.path);
1335
+ if(open) filesOpenDirs.delete(entry.path); else filesOpenDirs.add(entry.path);
1336
+ row.classList.toggle('is-open',!open); kids.hidden=open;
1337
+ });
1338
+ } else {
1339
+ row.addEventListener('click',()=> openFilesFile(entry.path));
1340
+ }
1341
+ return wrap;
1342
+ }
1343
+ function renderFilesTree(){
1344
+ const box=$('#filesTree'); if(!box) return;
1345
+ box.innerHTML='';
1346
+ if(!filesTreeData || !filesTreeData.length){ box.append(el('div','empty',t('files.empty'))); return; }
1347
+ filesTreeData.forEach(e=> box.append(fNode(e)));
1348
+ }
1349
+ function filesExt(p){ const m=/\.([a-z0-9]+)$/i.exec(p||''); return m?m[1].toLowerCase():''; }
1350
+ async function openFilesFile(relPath){
1351
+ // no native confirm() dialog (it blocks the whole tab, including our own SSE/automation) — a
1352
+ // dirty editor just refuses to switch until the user explicitly saves or discards.
1353
+ if(filesOpenDirty){
1354
+ const actions=$('#filesContent .files-actions');
1355
+ if(actions && !actions.querySelector('.files-error-tip')){
1356
+ const tip=el('span','files-error-tip',t('files.discardConfirm')); actions.append(tip);
1357
+ }
1358
+ return;
1359
+ }
1360
+ filesOpenPath=relPath; filesOpenDirty=false;
1361
+ renderFilesTree();
1362
+ const box=$('#filesContent'); box.innerHTML='';
1363
+ box.append(el('div','files-empty',t('drawer.loading')));
1364
+ let data;
1365
+ try{ const r=await fetch('/api/files/read?'+new URLSearchParams({path:relPath})); data=await r.json().catch(()=>({})); if(!r.ok) throw new Error(data.error||'error'); }
1366
+ catch(err){ box.innerHTML=''; box.append(el('div','files-empty',err.message||t('files.loadError'))); return; }
1367
+ box.innerHTML='';
1368
+ const bar=el('div','files-toolbar-row');
1369
+ bar.append(el('div','files-path',relPath));
1370
+ const actions=el('div','files-actions'); bar.append(actions);
1371
+ box.append(bar);
1372
+ if(data.binary){ box.append(el('div','files-binary',t('files.binary'))); return; }
1373
+ const ext=filesExt(relPath);
1374
+ const content=data.content||'';
1375
+ if(ext==='md'||ext==='markdown'){ renderFilesMd(box,actions,relPath,content); }
1376
+ else if(ext==='html'||ext==='htm'){ renderFilesHtml(box,actions,relPath,content); }
1377
+ else { renderFilesText(box,actions,relPath,content); }
1378
+ }
1379
+ function filesSavedTip(actions){
1380
+ const tip=el('span','files-saved-tip',t('files.saved')); actions.append(tip);
1381
+ setTimeout(()=>tip.remove(),1500);
1382
+ }
1383
+ // Explicit, non-blocking way to abandon local edits (no confirm() dialog) — re-fetches the file
1384
+ // fresh from disk and clears the dirty flag so switching tree files is unblocked again.
1385
+ function filesDiscardBtn(relPath){
1386
+ const btn=el('button',null,t('files.discard')); btn.type='button';
1387
+ btn.addEventListener('click',()=>{ filesOpenDirty=false; openFilesFile(relPath); });
1388
+ return btn;
1389
+ }
1390
+ async function filesSave(relPath,content,actions){
1391
+ try{
1392
+ const r=await fetch('/api/files/write',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:relPath,content})});
1393
+ const d=await r.json().catch(()=>({}));
1394
+ if(!r.ok) throw new Error(d.error||'error');
1395
+ filesOpenDirty=false; filesSavedTip(actions);
1396
+ }catch(err){
1397
+ const tip=el('span','files-error-tip',err.message||t('files.saveError')); actions.append(tip);
1398
+ }
1399
+ }
1400
+ // Markdown: rendered preview by default (via the same mdLite renderer the Agents & Skills drawer
1401
+ // uses), with an Edit toggle that swaps in a plain-text editor plus a tiny insert-at-cursor toolbar.
1402
+ function renderFilesMd(box,actions,relPath,content){
1403
+ let editing=false;
1404
+ const editBtn=el('button','btn',t('files.edit'));
1405
+ actions.append(editBtn);
1406
+ const body=el('div','files-body-col');
1407
+ box.append(body);
1408
+ const showPreview=()=>{ body.innerHTML=''; const md=el('div','files-view md-body'); md.innerHTML=mdLite(content); body.append(md); editBtn.textContent=t('files.edit'); };
1409
+ const showEditor=()=>{
1410
+ body.innerHTML='';
1411
+ const tb=el('div','files-md-toolbar');
1412
+ const ta=el('textarea','files-editor'); ta.value=content;
1413
+ const wrapSel=(before,after)=>{ const s=ta.selectionStart,e=ta.selectionEnd; const v=ta.value; ta.value=v.slice(0,s)+before+v.slice(s,e)+after+v.slice(e); ta.focus(); ta.selectionStart=s+before.length; ta.selectionEnd=e+before.length; content=ta.value; filesOpenDirty=true; };
1414
+ [['B','**','**'],['I','_','_'],['H','## ',''],['Link','[','](url)']].forEach(([label,a,b])=>{
1415
+ const bt=el('button',null,label); bt.type='button'; bt.addEventListener('click',()=>wrapSel(a,b)); tb.append(bt);
1416
+ });
1417
+ const saveBtn=el('button',null,t('files.save')); saveBtn.type='button'; saveBtn.addEventListener('click',()=>{ content=ta.value; filesSave(relPath,content,actions); });
1418
+ tb.append(saveBtn,filesDiscardBtn(relPath));
1419
+ ta.addEventListener('input',()=>{ content=ta.value; filesOpenDirty=true; });
1420
+ body.append(tb,ta); ta.focus(); editBtn.textContent=t('files.preview');
1421
+ };
1422
+ editBtn.addEventListener('click',()=>{ editing=!editing; if(editing) showEditor(); else showPreview(); });
1423
+ showPreview();
1424
+ }
1425
+ // HTML: sandboxed iframe preview by default, plain-text editor on toggle (no WYSIWYG — the
1426
+ // dashboard stays zero-dependency, no editor library is loaded).
1427
+ function renderFilesHtml(box,actions,relPath,content){
1428
+ let editing=false;
1429
+ const editBtn=el('button','btn',t('files.edit'));
1430
+ actions.append(editBtn);
1431
+ const body=el('div','files-body-col');
1432
+ box.append(body);
1433
+ const showPreview=()=>{
1434
+ body.innerHTML='';
1435
+ const frame=document.createElement('iframe');
1436
+ frame.className='files-iframe'; frame.setAttribute('sandbox',''); frame.srcdoc=content;
1437
+ body.append(frame); editBtn.textContent=t('files.edit');
1438
+ };
1439
+ const showEditor=()=>{
1440
+ body.innerHTML='';
1441
+ const ta=el('textarea','files-editor'); ta.value=content;
1442
+ ta.addEventListener('input',()=>{ content=ta.value; filesOpenDirty=true; });
1443
+ const saveBtn=el('button','btn primary',t('files.save')); saveBtn.type='button'; saveBtn.addEventListener('click',()=>filesSave(relPath,content,actions));
1444
+ const tb=el('div','files-md-toolbar'); tb.append(saveBtn,filesDiscardBtn(relPath));
1445
+ body.append(tb,ta); ta.focus(); editBtn.textContent=t('files.preview');
1446
+ };
1447
+ editBtn.addEventListener('click',()=>{ editing=!editing; if(editing) showEditor(); else showPreview(); });
1448
+ showPreview();
1449
+ }
1450
+ // Anything else that reads as text: a plain monospace editor, editable straight away.
1451
+ function renderFilesText(box,actions,relPath,content){
1452
+ const ta=el('textarea','files-editor'); ta.value=content;
1453
+ ta.addEventListener('input',()=>{ filesOpenDirty=true; });
1454
+ const saveBtn=el('button','btn primary',t('files.save')); saveBtn.type='button';
1455
+ saveBtn.addEventListener('click',()=>filesSave(relPath,ta.value,actions));
1456
+ actions.append(saveBtn,filesDiscardBtn(relPath));
1457
+ box.append(ta); ta.focus();
1458
+ }
1459
+ // "+ File"/"+ Folder" open the same inline form (never a native prompt()/alert() — those block the
1460
+ // whole tab, including our own SSE connection, until dismissed).
1461
+ let filesCreateKind='file';
1462
+ function openFilesCreateForm(kind){
1463
+ filesCreateKind=kind;
1464
+ const form=$('#filesCreateForm'); if(!form) return;
1465
+ const input=$('#filesCreateInput');
1466
+ input.placeholder = kind==='dir' ? t('files.newFolderPrompt') : t('files.newFilePrompt');
1467
+ input.value='';
1468
+ const err=$('#filesCreateError'); if(err) err.hidden=true;
1469
+ form.hidden=false; form.classList.add('is-open');
1470
+ input.focus();
1471
+ }
1472
+ function closeFilesCreateForm(){
1473
+ const form=$('#filesCreateForm'); if(!form) return;
1474
+ form.hidden=true; form.classList.remove('is-open');
1475
+ }
1476
+ async function submitFilesCreate(){
1477
+ const input=$('#filesCreateInput'); const err=$('#filesCreateError');
1478
+ const name=(input.value||'').trim();
1479
+ if(!name){ if(err){ err.textContent=t('backlog.addTitleRequired'); err.hidden=false; } return; }
1480
+ const rel=name.replace(/^\/+/,'');
1481
+ const kind=filesCreateKind;
1482
+ const endpoint = kind==='dir' ? '/api/files/mkdir' : '/api/files/write';
1483
+ const payload = kind==='dir' ? {path:rel} : {path:rel,content:''};
1484
+ const r=await fetch(endpoint,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
1485
+ const d=await r.json().catch(()=>({}));
1486
+ if(!r.ok){ if(err){ err.textContent=d.error||t('files.saveError'); err.hidden=false; } return; }
1487
+ closeFilesCreateForm();
1488
+ await loadFilesTree();
1489
+ if(kind!=='dir') openFilesFile(rel);
1490
+ }
1491
+
1239
1492
  function openDrawer(id,keep){
1240
1493
  // named `task`, not `t` — `t` is the global translation function (see i18n.js) and this whole
1241
1494
  // function calls it repeatedly below; shadowing it with a task variable would break every call.
@@ -1283,12 +1536,27 @@ const cssv=(v)=> getComputedStyle(document.documentElement).getPropertyValue(v).
1283
1536
  // too instead of ever resetting to Board; this is what keeps a tab selected across a race with a
1284
1537
  // 'change'/'message' event that lands right after a click.
1285
1538
  // initial tab: the URL path wins (deep-link / refresh), else the persisted tab, else board
1286
- let activeTab = tabFromPath() || (()=>{ try{ return localStorage.getItem('spf-tab')||'board'; }catch{ return 'board'; } })();
1539
+ let activeTab = tabFromPath() || (()=>{ try{ return normalizeTab(localStorage.getItem('spf-tab'))||'board'; }catch{ return 'board'; } })();
1287
1540
  openTaskId = taskFromPath(); // deep-link straight to a task drawer
1288
1541
  function applyActiveTab(){
1289
1542
  $$('#tabs .tab').forEach(t=> t.classList.toggle('is-active', t.dataset.tab===activeTab));
1290
1543
  $$('.panel').forEach(p=> p.classList.toggle('is-active', p.dataset.panel===activeTab));
1291
- }
1544
+ fitTabs();
1545
+ }
1546
+ // Horizontal top-nav designs (Console's vertical rail and Orbit's hidden/radial nav manage their own
1547
+ // layout independently) can run out of room as tabs are added over time — a single px breakpoint
1548
+ // tuned for whatever tab count existed back then goes stale the moment a tab is added or removed
1549
+ // (exactly what happened when the Files tab pushed the row from 10 to 11 items: some tabs, including
1550
+ // Personalize, silently overflowed with no visible way to reach them). Measure the row's REAL
1551
+ // overflow instead of guessing from viewport width alone, so it's correct at any tab count.
1552
+ function fitTabs(){
1553
+ const tabsEl=$('#tabs'); if(!tabsEl) return;
1554
+ const design=document.documentElement.getAttribute('data-design');
1555
+ if(design==='console'||design==='orbit'){ tabsEl.classList.remove('tabs-compact'); return; }
1556
+ tabsEl.classList.remove('tabs-compact'); // measure at full (labelled) size first
1557
+ if(tabsEl.scrollWidth>tabsEl.clientWidth+1) tabsEl.classList.add('tabs-compact');
1558
+ }
1559
+ window.addEventListener('resize', (()=>{ let t=null; return ()=>{ clearTimeout(t); t=setTimeout(fitTabs,120); }; })());
1292
1560
  $$('#tabs .tab').forEach(tab=> tab.addEventListener('click',()=> navigateTab(tab.dataset.tab)));
1293
1561
  // mobile hamburger — toggles the tab dropdown (body.nav-open); closes on tab pick / outside / Esc
1294
1562
  const navToggle=$('#navToggle');
@@ -1308,11 +1576,24 @@ window.addEventListener('popstate',()=>{
1308
1576
  // brand logo → Board (SPA nav, no full reload)
1309
1577
  const brandLogo=$('.brand-logo'); if(brandLogo) brandLogo.addEventListener('click',e=>{ e.preventDefault(); navigateTab('board'); });
1310
1578
  applyActiveTab(); // sync to the resolved tab before the first render
1579
+ // an old bookmark/share to the pre-rename "/settings" URL: swap the address bar to the real
1580
+ // route once resolved, so the visible URL matches the "Personalize" tab it landed on.
1581
+ if(location.pathname.split('/').filter(Boolean)[0]==='settings') history.replaceState(null,'','/personalize');
1311
1582
  // filters (status chips + search) — client-side only, does not write anything
1312
1583
  $$('#statusChips .fchip').forEach(b=> b.addEventListener('click', ()=>{ filter.status=b.dataset.status; renderBoard(); }));
1313
1584
  $('#search').addEventListener('input', e=>{ filter.q=e.target.value; renderBoard(); });
1314
1585
  // board view switch — List (phase-grouped) vs Kanban (columns by status), persisted per viewer
1315
1586
  $$('#boardViewToggle .vt-btn').forEach(b=> b.addEventListener('click', ()=>{ boardView=b.dataset.view; try{ localStorage.setItem('spf-board-view',boardView); }catch{} renderBoard(); }));
1587
+ // right sidebar hide/show — Kanban's own-width columns need the room, and the toggle stays
1588
+ // persisted per viewer like every other layout preference here
1589
+ function applySideHidden(){
1590
+ const panel=$('.panel[data-panel="board"]'); const btn=$('#sideToggle');
1591
+ if(panel) panel.classList.toggle('side-hidden', sideHidden);
1592
+ if(btn){ btn.setAttribute('aria-pressed', String(sideHidden)); btn.title=t(sideHidden?'board.showSidebar':'board.hideSidebar'); }
1593
+ }
1594
+ const sideToggleBtn=$('#sideToggle');
1595
+ if(sideToggleBtn) sideToggleBtn.addEventListener('click', ()=>{ sideHidden=!sideHidden; try{ localStorage.setItem('spf-side-hidden', sideHidden?'1':'0'); }catch{} applySideHidden(); });
1596
+ applySideHidden();
1316
1597
  // expand / collapse all phases (List view) — keeps a big board compact by default
1317
1598
  const phaseToggleAllBtn=$('#phaseToggleAll');
1318
1599
  if(phaseToggleAllBtn) phaseToggleAllBtn.addEventListener('click', ()=>{
@@ -1330,12 +1611,24 @@ $$('#backlogTable thead th').forEach(th=> th.addEventListener('click', ()=>{
1330
1611
  else { backlogSort.col=col; backlogSort.dir='asc'; }
1331
1612
  backlogPage=1; renderBacklog();
1332
1613
  }));
1614
+ // files tab: create a new file/folder (path relative to the project root, e.g. "notes/todo.md")
1615
+ const filesNewFileBtn=$('#filesNewFile'); if(filesNewFileBtn) filesNewFileBtn.addEventListener('click',()=>openFilesCreateForm('file'));
1616
+ const filesNewFolderBtn=$('#filesNewFolder'); if(filesNewFolderBtn) filesNewFolderBtn.addEventListener('click',()=>openFilesCreateForm('dir'));
1617
+ const filesRefreshBtn=$('#filesRefresh'); if(filesRefreshBtn) filesRefreshBtn.addEventListener('click',loadFilesTree);
1618
+ const filesCreateGoBtn=$('#filesCreateGo'); if(filesCreateGoBtn) filesCreateGoBtn.addEventListener('click',submitFilesCreate);
1619
+ const filesCreateCancelBtn=$('#filesCreateCancel'); if(filesCreateCancelBtn) filesCreateCancelBtn.addEventListener('click',closeFilesCreateForm);
1620
+ const filesCreateInputEl=$('#filesCreateInput'); if(filesCreateInputEl) filesCreateInputEl.addEventListener('keydown',e=>{ if(e.key==='Enter') submitFilesCreate(); });
1621
+ // backlog: manual "+ Add task" form (title required, phase/owner/level optional)
1622
+ $('#backlogAddBtn').addEventListener('click', ()=>{ const form=$('#backlogAddForm'); (form&&!form.hidden)?closeBacklogAddForm():openBacklogAddForm(); });
1623
+ $('#blAddCancel').addEventListener('click', closeBacklogAddForm);
1624
+ $('#blAddSubmit').addEventListener('click', submitBacklogAdd);
1625
+ $('#blAddTitle').addEventListener('keydown', e=>{ if(e.key==='Enter') submitBacklogAdd(); });
1333
1626
  // attention tab: add a note + filter chips
1334
1627
  $('#attnAddBtn').addEventListener('click',()=>{ const t=$('#attnInput'); const v=t.value.trim(); if(v){ addAttn(v); t.value=''; } });
1335
1628
  $('#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=''; } } });
1336
1629
  $$('.attn-filters .fchip').forEach(b=> b.addEventListener('click',()=>{ attnFilter=b.dataset.attn; renderAttention(); }));
1337
1630
  // settings — the footer link opens the Settings tab; selects save on change
1338
- const footerSettingsBtn=$('#footerSettings'); if(footerSettingsBtn) footerSettingsBtn.addEventListener('click',()=>navigateTab('settings'));
1631
+ const footerSettingsBtn=$('#footerSettings'); if(footerSettingsBtn) footerSettingsBtn.addEventListener('click',()=>navigateTab('personalize'));
1339
1632
  const footerLogo=$('.footer-logo'); if(footerLogo) footerLogo.addEventListener('click',e=>{ e.preventDefault(); navigateTab('board'); });
1340
1633
  function onModeSelectChange(e){ setModeSelects(e.target.value); saveSettings(); }
1341
1634
  function onLangSelectChange(e){ setLangSelect(e.target.value); saveSettings(); }