spectoflow 0.21.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/spectoflow.js CHANGED
@@ -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.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",
@@ -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 };
@@ -55,19 +55,32 @@ function chatContainers(){ return [$('#chatLog'),$('#chatTabLog')].filter(Boolea
55
55
  function scrollChat(container){ container.scrollTop=container.scrollHeight; }
56
56
  function clearIdle(container){ const i=container.querySelector('.chat-idle'); if(i) i.remove(); }
57
57
  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;
58
+ let node;
59
+ if(m.role==='user'){ node=el('div','msg you'); node.append(el('div','bubble',m.text)); }
60
+ else{
61
+ node=el('div','msg agentmsg k-'+(m.kind||'message'));
62
+ node.append(el('div','msg-role', m.role + (m.agent&&m.agent!==m.role?(' · '+m.agent):'')));
63
+ node.append(el('div','bubble',m.text));
64
+ }
65
+ node.dataset.id=m.id; // lets renderChatLog tell a stale bubble from a genuinely new one
66
+ return node;
63
67
  }
68
+ function idleBlock(){ const d=el('div','chat-idle'); d.innerHTML=t('chat.idle'); return d; }
64
69
  function renderChatLog(container){
65
70
  if(!container) return;
66
71
  const st=stateFor(container); const msgs=(P.runtime&&P.runtime.messages)||[];
67
- if(msgs.length) clearIdle(container);
72
+ const ids=new Set(msgs.map(m=>m.id));
73
+ // Summarize/Clear REPLACE the server-side log (a digest that leaves the old messages sitting right
74
+ // below it wouldn't condense anything) — if anything we already rendered no longer exists, the log
75
+ // was reset under us: rebuild from scratch instead of just appending, or the stale bubbles never go
76
+ // away short of a full page reload.
77
+ const stale=[...st.rendered].some(id=>!ids.has(id));
78
+ if(stale){ container.innerHTML=''; st.rendered=new Set(); st.rawBlock=null; }
79
+ if(!msgs.length){ if(!container.querySelector('.chat-idle')) container.append(idleBlock()); renderApproval(container); return; }
80
+ clearIdle(container);
68
81
  let added=false;
69
82
  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);
83
+ if(added||stale) scrollChat(container);
71
84
  renderApproval(container);
72
85
  }
73
86
  function renderApproval(container){
@@ -151,7 +164,7 @@ function render(){
151
164
  if(meter) meter.title=`${t('kpi.globalProgress')}: ${s.pct}% (${s.done}/${s.total} ${t('kpi.tasksLabel')})`;
152
165
  renderOverview(); renderBoard(); renderBacklog(); renderWorkflow(); renderTeam();
153
166
  renderChatLog($('#chatLog')); renderChatLog($('#chatTabLog'));
154
- renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderDocs(); renderSettings();
167
+ renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderDocs(); renderSettings(); renderFiles();
155
168
  renderCustomDashboards(); // adds/removes nav tabs + panels before applyActiveTab() below reads them
156
169
  applyActiveTab(); // re-apply the current tab so an SSE-driven re-render never resets to Board
157
170
  applyI18nStatic(); // re-translate the static markup (nav, headers, placeholders…) for this tick's language
@@ -695,6 +708,39 @@ function editAttn(it,txtNode){
695
708
  ta.addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){ e.preventDefault(); save(); } if(e.key==='Escape'){ done=true; renderAttention(); } });
696
709
  }
697
710
  async function addAttn(text){ flash(); await fetch('/api/attention',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text})}); }
711
+
712
+ // ---- Backlog "+ Add task" — a manual checkbox task, no agent involved ----
713
+ function openBacklogAddForm(){
714
+ const form=$('#backlogAddForm'); if(!form) return;
715
+ const list=$('#blPhaseList');
716
+ if(list){ list.innerHTML=''; allPhaseTitles().forEach(ti=> list.append(new Option(ti))); }
717
+ const err=$('#blAddError'); if(err) err.hidden=true;
718
+ form.hidden=false; form.classList.add('is-open');
719
+ $('#blAddTitle').focus();
720
+ }
721
+ function closeBacklogAddForm(){
722
+ const form=$('#backlogAddForm'); if(!form) return;
723
+ form.hidden=true; form.classList.remove('is-open');
724
+ ['blAddTitle','blAddPhase','blAddOwner'].forEach(id=>{ const f=$('#'+id); if(f) f.value=''; });
725
+ const lvl=$('#blAddLevel'); if(lvl) lvl.value='standard';
726
+ }
727
+ async function submitBacklogAdd(){
728
+ const err=$('#blAddError');
729
+ const title=($('#blAddTitle').value||'').trim();
730
+ if(!title){ if(err){ err.textContent=t('backlog.addTitleRequired'); err.hidden=false; } $('#blAddTitle').focus(); return; }
731
+ const phase=($('#blAddPhase').value||'').trim();
732
+ const owner=($('#blAddOwner').value||'').trim();
733
+ const level=$('#blAddLevel').value;
734
+ flash();
735
+ const r=await fetch('/api/task',{method:'POST',headers:{'Content-Type':'application/json'},
736
+ body:JSON.stringify({title, phase:phase||undefined, owner:owner||undefined, level})});
737
+ if(!r.ok){
738
+ const j=await r.json().catch(()=>({}));
739
+ if(err){ err.textContent=j.error||t('backlog.addFailed'); err.hidden=false; }
740
+ return;
741
+ }
742
+ closeBacklogAddForm();
743
+ }
698
744
  async function patchAttn(id,patch){ flash(); await fetch('/api/attention/'+encodeURIComponent(id),{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }
699
745
  async function deleteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id),{method:'DELETE'}); }
700
746
  async function promoteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id)+'/promote',{method:'POST'}); }
@@ -917,10 +963,14 @@ function czItemsFor(kind){
917
963
  function renderCustomize(){
918
964
  const root=$('#czRoot'); if(!root) return;
919
965
  const openKind=root.dataset.open||'';
966
+ // a block with its form open spans the full row (see CSS) — an auto-fit grid would otherwise
967
+ // still reserve empty trailing cells beside it for the two collapsed blocks that no longer fill
968
+ // out a row, so drop to a single column for the whole grid while any block is open.
969
+ root.classList.toggle('has-open', !!openKind);
920
970
  root.innerHTML='';
921
971
  CZ_KINDS.forEach(({kind})=>{
922
972
  const items=czItemsFor(kind);
923
- const block=el('div','cz-block');
973
+ const block=el('div','cz-block'+(openKind===kind?' is-open':''));
924
974
  const head=el('div','cz-head');
925
975
  head.append(el('h3',null,t('customize.'+kind+'s')+' ('+items.length+')'));
926
976
  const addBtn=el('button','btn cz-add',t('customize.add.'+kind));
@@ -967,11 +1017,15 @@ async function czSubmit(kind,description,agent){
967
1017
  // A custom dashboard (Customize page) gets its own tab id "custom:<id>" and its own URL shape
968
1018
  // /custom/<id> — kept out of ROUTES (a fixed list) since the set of custom ids is dynamic; recognized
969
1019
  // by a dedicated branch in tabFromPath()/navigateTab() instead.
970
- const ROUTES=['board','requests','attention','backlog','workflow','team','chat','info','docs','settings'];
1020
+ const ROUTES=['board','requests','attention','backlog','workflow','team','files','chat','info','docs','personalize'];
1021
+ // the tab used to be named/routed "settings" — old bookmarks and any localStorage value saved
1022
+ // under that name still land on the Personalize tab instead of a blank panel.
1023
+ function normalizeTab(t){ return t==='settings'?'personalize':t; }
971
1024
  function tabFromPath(){
972
1025
  const s=location.pathname.split('/').filter(Boolean);
973
1026
  if(s[0]==='custom'&&s[1]) return 'custom:'+decodeURIComponent(s[1]);
974
- return ROUTES.includes(s[0])?s[0]:null;
1027
+ const t=normalizeTab(s[0]);
1028
+ return ROUTES.includes(t)?t:null;
975
1029
  }
976
1030
  function taskFromPath(){ const s=location.pathname.split('/').filter(Boolean); return (ROUTES.includes(s[0])&&s[1])?decodeURIComponent(s[1]):null; }
977
1031
  function navigateTab(tabId,push){
@@ -986,6 +1040,7 @@ function navigateTab(tabId,push){
986
1040
  // was scrolled to last (only on an actual switch INTO the tab — applyActiveTab() alone runs on
987
1041
  // every SSE render tick too, and re-scrolling/re-focusing there would fight the user's typing).
988
1042
  if(tabId==='chat') setTimeout(()=>{ scrollChat($('#chatTabLog')); $('#tabRunPrompt').focus(); },60);
1043
+ if(tabId==='files') renderFiles(); // the tree is fetched lazily — only load it on an actual switch in
989
1044
  }
990
1045
  function closeNav(){ document.body.classList.remove('nav-open'); const nt=$('#navToggle'); if(nt) nt.setAttribute('aria-expanded','false'); }
991
1046
 
@@ -1236,6 +1291,203 @@ function renderDocs(){
1236
1291
  box.append(note);
1237
1292
  }
1238
1293
 
1294
+ // ---- Files tab: browse the project tree, view/edit any text file (Markdown rendered, HTML
1295
+ // previewed in a sandboxed iframe, everything else as a plain monospace editor). The tree is
1296
+ // fetched lazily (only while this tab is active) and never refetched mid-edit — an SSE 'change'
1297
+ // event refreshes the TREE listing but never overwrites an open file's editor buffer, so an
1298
+ // unrelated agent write elsewhere can't clobber unsaved work here. ----
1299
+ let filesTreeData=null, filesOpenPath=null, filesOpenDirty=false;
1300
+ const filesOpenDirs=new Set(); // persists which tree folders are expanded across a refresh
1301
+ async function loadFilesTree(){
1302
+ try{
1303
+ const r=await fetch('/api/files/tree'); const d=await r.json().catch(()=>({}));
1304
+ filesTreeData = (r.ok && Array.isArray(d.tree)) ? d.tree : [];
1305
+ }catch{ filesTreeData=filesTreeData||[]; }
1306
+ renderFilesTree();
1307
+ }
1308
+ function renderFiles(){
1309
+ // Only the FIRST activation fetches — render() re-runs on every SSE 'change' (a chat message, a
1310
+ // task update, anything) and a full tree rebuild on each of those would yank rows out from under
1311
+ // an in-progress click. Fresh-after-your-own-action is handled by loadFilesTree() calls at the
1312
+ // point of action (filesCreate); the toolbar's Refresh button covers everything else.
1313
+ if(activeTab!=='files' || filesTreeData!=null) return;
1314
+ loadFilesTree();
1315
+ }
1316
+ function fNode(entry){
1317
+ const row=el('div','f-row'+(entry.type==='dir'&&filesOpenDirs.has(entry.path)?' is-open':'')+(entry.path===filesOpenPath?' is-active':''));
1318
+ row.tabIndex=0;
1319
+ if(entry.type==='dir'){
1320
+ const chev=document.createElementNS('http://www.w3.org/2000/svg','svg');
1321
+ 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');
1322
+ chev.innerHTML='<path d="M6.5 4l6 5-6 5"/>';
1323
+ row.append(chev);
1324
+ } else row.append(el('span',null,''));
1325
+ row.append(el('span','f-name',entry.name));
1326
+ const wrap=el('div','f-node');
1327
+ wrap.append(row);
1328
+ if(entry.type==='dir'){
1329
+ const kids=el('div','f-children'); kids.hidden=!filesOpenDirs.has(entry.path);
1330
+ (entry.children||[]).forEach(c=> kids.append(fNode(c)));
1331
+ wrap.append(kids);
1332
+ row.addEventListener('click',()=>{
1333
+ const open=filesOpenDirs.has(entry.path);
1334
+ if(open) filesOpenDirs.delete(entry.path); else filesOpenDirs.add(entry.path);
1335
+ row.classList.toggle('is-open',!open); kids.hidden=open;
1336
+ });
1337
+ } else {
1338
+ row.addEventListener('click',()=> openFilesFile(entry.path));
1339
+ }
1340
+ return wrap;
1341
+ }
1342
+ function renderFilesTree(){
1343
+ const box=$('#filesTree'); if(!box) return;
1344
+ box.innerHTML='';
1345
+ if(!filesTreeData || !filesTreeData.length){ box.append(el('div','empty',t('files.empty'))); return; }
1346
+ filesTreeData.forEach(e=> box.append(fNode(e)));
1347
+ }
1348
+ function filesExt(p){ const m=/\.([a-z0-9]+)$/i.exec(p||''); return m?m[1].toLowerCase():''; }
1349
+ async function openFilesFile(relPath){
1350
+ // no native confirm() dialog (it blocks the whole tab, including our own SSE/automation) — a
1351
+ // dirty editor just refuses to switch until the user explicitly saves or discards.
1352
+ if(filesOpenDirty){
1353
+ const actions=$('#filesContent .files-actions');
1354
+ if(actions && !actions.querySelector('.files-error-tip')){
1355
+ const tip=el('span','files-error-tip',t('files.discardConfirm')); actions.append(tip);
1356
+ }
1357
+ return;
1358
+ }
1359
+ filesOpenPath=relPath; filesOpenDirty=false;
1360
+ renderFilesTree();
1361
+ const box=$('#filesContent'); box.innerHTML='';
1362
+ box.append(el('div','files-empty',t('drawer.loading')));
1363
+ let data;
1364
+ 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'); }
1365
+ catch(err){ box.innerHTML=''; box.append(el('div','files-empty',err.message||t('files.loadError'))); return; }
1366
+ box.innerHTML='';
1367
+ const bar=el('div','files-toolbar-row');
1368
+ bar.append(el('div','files-path',relPath));
1369
+ const actions=el('div','files-actions'); bar.append(actions);
1370
+ box.append(bar);
1371
+ if(data.binary){ box.append(el('div','files-binary',t('files.binary'))); return; }
1372
+ const ext=filesExt(relPath);
1373
+ const content=data.content||'';
1374
+ if(ext==='md'||ext==='markdown'){ renderFilesMd(box,actions,relPath,content); }
1375
+ else if(ext==='html'||ext==='htm'){ renderFilesHtml(box,actions,relPath,content); }
1376
+ else { renderFilesText(box,actions,relPath,content); }
1377
+ }
1378
+ function filesSavedTip(actions){
1379
+ const tip=el('span','files-saved-tip',t('files.saved')); actions.append(tip);
1380
+ setTimeout(()=>tip.remove(),1500);
1381
+ }
1382
+ // Explicit, non-blocking way to abandon local edits (no confirm() dialog) — re-fetches the file
1383
+ // fresh from disk and clears the dirty flag so switching tree files is unblocked again.
1384
+ function filesDiscardBtn(relPath){
1385
+ const btn=el('button',null,t('files.discard')); btn.type='button';
1386
+ btn.addEventListener('click',()=>{ filesOpenDirty=false; openFilesFile(relPath); });
1387
+ return btn;
1388
+ }
1389
+ async function filesSave(relPath,content,actions){
1390
+ try{
1391
+ const r=await fetch('/api/files/write',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:relPath,content})});
1392
+ const d=await r.json().catch(()=>({}));
1393
+ if(!r.ok) throw new Error(d.error||'error');
1394
+ filesOpenDirty=false; filesSavedTip(actions);
1395
+ }catch(err){
1396
+ const tip=el('span','files-error-tip',err.message||t('files.saveError')); actions.append(tip);
1397
+ }
1398
+ }
1399
+ // Markdown: rendered preview by default (via the same mdLite renderer the Agents & Skills drawer
1400
+ // uses), with an Edit toggle that swaps in a plain-text editor plus a tiny insert-at-cursor toolbar.
1401
+ function renderFilesMd(box,actions,relPath,content){
1402
+ let editing=false;
1403
+ const editBtn=el('button','btn',t('files.edit'));
1404
+ actions.append(editBtn);
1405
+ const body=el('div','files-body-col');
1406
+ box.append(body);
1407
+ const showPreview=()=>{ body.innerHTML=''; const md=el('div','files-view md-body'); md.innerHTML=mdLite(content); body.append(md); editBtn.textContent=t('files.edit'); };
1408
+ const showEditor=()=>{
1409
+ body.innerHTML='';
1410
+ const tb=el('div','files-md-toolbar');
1411
+ const ta=el('textarea','files-editor'); ta.value=content;
1412
+ 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; };
1413
+ [['B','**','**'],['I','_','_'],['H','## ',''],['Link','[','](url)']].forEach(([label,a,b])=>{
1414
+ const bt=el('button',null,label); bt.type='button'; bt.addEventListener('click',()=>wrapSel(a,b)); tb.append(bt);
1415
+ });
1416
+ const saveBtn=el('button',null,t('files.save')); saveBtn.type='button'; saveBtn.addEventListener('click',()=>{ content=ta.value; filesSave(relPath,content,actions); });
1417
+ tb.append(saveBtn,filesDiscardBtn(relPath));
1418
+ ta.addEventListener('input',()=>{ content=ta.value; filesOpenDirty=true; });
1419
+ body.append(tb,ta); ta.focus(); editBtn.textContent=t('files.preview');
1420
+ };
1421
+ editBtn.addEventListener('click',()=>{ editing=!editing; if(editing) showEditor(); else showPreview(); });
1422
+ showPreview();
1423
+ }
1424
+ // HTML: sandboxed iframe preview by default, plain-text editor on toggle (no WYSIWYG — the
1425
+ // dashboard stays zero-dependency, no editor library is loaded).
1426
+ function renderFilesHtml(box,actions,relPath,content){
1427
+ let editing=false;
1428
+ const editBtn=el('button','btn',t('files.edit'));
1429
+ actions.append(editBtn);
1430
+ const body=el('div','files-body-col');
1431
+ box.append(body);
1432
+ const showPreview=()=>{
1433
+ body.innerHTML='';
1434
+ const frame=document.createElement('iframe');
1435
+ frame.className='files-iframe'; frame.setAttribute('sandbox',''); frame.srcdoc=content;
1436
+ body.append(frame); editBtn.textContent=t('files.edit');
1437
+ };
1438
+ const showEditor=()=>{
1439
+ body.innerHTML='';
1440
+ const ta=el('textarea','files-editor'); ta.value=content;
1441
+ ta.addEventListener('input',()=>{ content=ta.value; filesOpenDirty=true; });
1442
+ const saveBtn=el('button','btn primary',t('files.save')); saveBtn.type='button'; saveBtn.addEventListener('click',()=>filesSave(relPath,content,actions));
1443
+ const tb=el('div','files-md-toolbar'); tb.append(saveBtn,filesDiscardBtn(relPath));
1444
+ body.append(tb,ta); ta.focus(); editBtn.textContent=t('files.preview');
1445
+ };
1446
+ editBtn.addEventListener('click',()=>{ editing=!editing; if(editing) showEditor(); else showPreview(); });
1447
+ showPreview();
1448
+ }
1449
+ // Anything else that reads as text: a plain monospace editor, editable straight away.
1450
+ function renderFilesText(box,actions,relPath,content){
1451
+ const ta=el('textarea','files-editor'); ta.value=content;
1452
+ ta.addEventListener('input',()=>{ filesOpenDirty=true; });
1453
+ const saveBtn=el('button','btn primary',t('files.save')); saveBtn.type='button';
1454
+ saveBtn.addEventListener('click',()=>filesSave(relPath,ta.value,actions));
1455
+ actions.append(saveBtn,filesDiscardBtn(relPath));
1456
+ box.append(ta); ta.focus();
1457
+ }
1458
+ // "+ File"/"+ Folder" open the same inline form (never a native prompt()/alert() — those block the
1459
+ // whole tab, including our own SSE connection, until dismissed).
1460
+ let filesCreateKind='file';
1461
+ function openFilesCreateForm(kind){
1462
+ filesCreateKind=kind;
1463
+ const form=$('#filesCreateForm'); if(!form) return;
1464
+ const input=$('#filesCreateInput');
1465
+ input.placeholder = kind==='dir' ? t('files.newFolderPrompt') : t('files.newFilePrompt');
1466
+ input.value='';
1467
+ const err=$('#filesCreateError'); if(err) err.hidden=true;
1468
+ form.hidden=false; form.classList.add('is-open');
1469
+ input.focus();
1470
+ }
1471
+ function closeFilesCreateForm(){
1472
+ const form=$('#filesCreateForm'); if(!form) return;
1473
+ form.hidden=true; form.classList.remove('is-open');
1474
+ }
1475
+ async function submitFilesCreate(){
1476
+ const input=$('#filesCreateInput'); const err=$('#filesCreateError');
1477
+ const name=(input.value||'').trim();
1478
+ if(!name){ if(err){ err.textContent=t('backlog.addTitleRequired'); err.hidden=false; } return; }
1479
+ const rel=name.replace(/^\/+/,'');
1480
+ const kind=filesCreateKind;
1481
+ const endpoint = kind==='dir' ? '/api/files/mkdir' : '/api/files/write';
1482
+ const payload = kind==='dir' ? {path:rel} : {path:rel,content:''};
1483
+ const r=await fetch(endpoint,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
1484
+ const d=await r.json().catch(()=>({}));
1485
+ if(!r.ok){ if(err){ err.textContent=d.error||t('files.saveError'); err.hidden=false; } return; }
1486
+ closeFilesCreateForm();
1487
+ await loadFilesTree();
1488
+ if(kind!=='dir') openFilesFile(rel);
1489
+ }
1490
+
1239
1491
  function openDrawer(id,keep){
1240
1492
  // named `task`, not `t` — `t` is the global translation function (see i18n.js) and this whole
1241
1493
  // function calls it repeatedly below; shadowing it with a task variable would break every call.
@@ -1283,7 +1535,7 @@ const cssv=(v)=> getComputedStyle(document.documentElement).getPropertyValue(v).
1283
1535
  // too instead of ever resetting to Board; this is what keeps a tab selected across a race with a
1284
1536
  // 'change'/'message' event that lands right after a click.
1285
1537
  // 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'; } })();
1538
+ let activeTab = tabFromPath() || (()=>{ try{ return normalizeTab(localStorage.getItem('spf-tab'))||'board'; }catch{ return 'board'; } })();
1287
1539
  openTaskId = taskFromPath(); // deep-link straight to a task drawer
1288
1540
  function applyActiveTab(){
1289
1541
  $$('#tabs .tab').forEach(t=> t.classList.toggle('is-active', t.dataset.tab===activeTab));
@@ -1308,6 +1560,9 @@ window.addEventListener('popstate',()=>{
1308
1560
  // brand logo → Board (SPA nav, no full reload)
1309
1561
  const brandLogo=$('.brand-logo'); if(brandLogo) brandLogo.addEventListener('click',e=>{ e.preventDefault(); navigateTab('board'); });
1310
1562
  applyActiveTab(); // sync to the resolved tab before the first render
1563
+ // an old bookmark/share to the pre-rename "/settings" URL: swap the address bar to the real
1564
+ // route once resolved, so the visible URL matches the "Personalize" tab it landed on.
1565
+ if(location.pathname.split('/').filter(Boolean)[0]==='settings') history.replaceState(null,'','/personalize');
1311
1566
  // filters (status chips + search) — client-side only, does not write anything
1312
1567
  $$('#statusChips .fchip').forEach(b=> b.addEventListener('click', ()=>{ filter.status=b.dataset.status; renderBoard(); }));
1313
1568
  $('#search').addEventListener('input', e=>{ filter.q=e.target.value; renderBoard(); });
@@ -1330,12 +1585,24 @@ $$('#backlogTable thead th').forEach(th=> th.addEventListener('click', ()=>{
1330
1585
  else { backlogSort.col=col; backlogSort.dir='asc'; }
1331
1586
  backlogPage=1; renderBacklog();
1332
1587
  }));
1588
+ // files tab: create a new file/folder (path relative to the project root, e.g. "notes/todo.md")
1589
+ const filesNewFileBtn=$('#filesNewFile'); if(filesNewFileBtn) filesNewFileBtn.addEventListener('click',()=>openFilesCreateForm('file'));
1590
+ const filesNewFolderBtn=$('#filesNewFolder'); if(filesNewFolderBtn) filesNewFolderBtn.addEventListener('click',()=>openFilesCreateForm('dir'));
1591
+ const filesRefreshBtn=$('#filesRefresh'); if(filesRefreshBtn) filesRefreshBtn.addEventListener('click',loadFilesTree);
1592
+ const filesCreateGoBtn=$('#filesCreateGo'); if(filesCreateGoBtn) filesCreateGoBtn.addEventListener('click',submitFilesCreate);
1593
+ const filesCreateCancelBtn=$('#filesCreateCancel'); if(filesCreateCancelBtn) filesCreateCancelBtn.addEventListener('click',closeFilesCreateForm);
1594
+ const filesCreateInputEl=$('#filesCreateInput'); if(filesCreateInputEl) filesCreateInputEl.addEventListener('keydown',e=>{ if(e.key==='Enter') submitFilesCreate(); });
1595
+ // backlog: manual "+ Add task" form (title required, phase/owner/level optional)
1596
+ $('#backlogAddBtn').addEventListener('click', ()=>{ const form=$('#backlogAddForm'); (form&&!form.hidden)?closeBacklogAddForm():openBacklogAddForm(); });
1597
+ $('#blAddCancel').addEventListener('click', closeBacklogAddForm);
1598
+ $('#blAddSubmit').addEventListener('click', submitBacklogAdd);
1599
+ $('#blAddTitle').addEventListener('keydown', e=>{ if(e.key==='Enter') submitBacklogAdd(); });
1333
1600
  // attention tab: add a note + filter chips
1334
1601
  $('#attnAddBtn').addEventListener('click',()=>{ const t=$('#attnInput'); const v=t.value.trim(); if(v){ addAttn(v); t.value=''; } });
1335
1602
  $('#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
1603
  $$('.attn-filters .fchip').forEach(b=> b.addEventListener('click',()=>{ attnFilter=b.dataset.attn; renderAttention(); }));
1337
1604
  // settings — the footer link opens the Settings tab; selects save on change
1338
- const footerSettingsBtn=$('#footerSettings'); if(footerSettingsBtn) footerSettingsBtn.addEventListener('click',()=>navigateTab('settings'));
1605
+ const footerSettingsBtn=$('#footerSettings'); if(footerSettingsBtn) footerSettingsBtn.addEventListener('click',()=>navigateTab('personalize'));
1339
1606
  const footerLogo=$('.footer-logo'); if(footerLogo) footerLogo.addEventListener('click',e=>{ e.preventDefault(); navigateTab('board'); });
1340
1607
  function onModeSelectChange(e){ setModeSelects(e.target.value); saveSettings(); }
1341
1608
  function onLangSelectChange(e){ setLangSelect(e.target.value); saveSettings(); }