spectoflow 0.32.0 → 0.34.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.
@@ -18,10 +18,13 @@ const orchestrator = require('./orchestrator');
18
18
  const adapters = require('../adapters');
19
19
  const detect = require('../detect');
20
20
  const brain = require('../brain');
21
+ const projectMemory = require('../project-memory');
21
22
  const brainSetup = require('../brain-setup');
22
23
  const globalConfig = require('../global-config');
23
24
  const workflowDetect = require('../workflow-detect');
24
25
  const runnerTrust = require('../runner-trust');
26
+ const isolation = require('./isolation');
27
+ const worktree = require('../worktree');
25
28
 
26
29
  const PKG_VERSION = require('../../package.json').version;
27
30
 
@@ -74,6 +77,7 @@ function writeConfig(root, patch, detectOpts) {
74
77
  if (typeof patch.activeTab === 'string' && patch.activeTab.trim()) cfg.activeTab = patch.activeTab.trim();
75
78
  if (typeof patch.chatOpen === 'boolean') cfg.chatOpen = patch.chatOpen;
76
79
  if (typeof patch.workflowAutoEnable === 'boolean') cfg.workflowAutoEnable = patch.workflowAutoEnable;
80
+ if (typeof patch.memoryAutoAdd === 'boolean') cfg.memoryAutoAdd = patch.memoryAutoAdd;
77
81
  // kanbanColumns: reject the whole patch (leave the current value untouched) rather than silently
78
82
  // filtering out bad entries — an invalid/unknown status id here means the client sent something it
79
83
  // shouldn't have, and a real product-safety rule (never persist zero visible columns) applies too.
@@ -141,6 +145,17 @@ function writeConfig(root, patch, detectOpts) {
141
145
  fs.writeFileSync(cp, JSON.stringify(cfg, null, 2) + '\n');
142
146
  return cfg;
143
147
  }
148
+ const localOnly = (ctx) => { if (ctx && ctx.remote) throw new OpError(404, 'Unknown operation.'); };
149
+ const gitOp = (fn) => { try { return fn(); } catch (e) { if (e instanceof worktree.GitError) throw new OpError(e.status, e.message); throw e; } };
150
+ // project.read runs on every change: ask git whether this is a repository at most every 30s.
151
+ const gitRepoSeen = new Map();
152
+ function gitRepoCached(root) {
153
+ const hit = gitRepoSeen.get(root);
154
+ if (hit && Date.now() - hit.at < 30000) return hit.value;
155
+ const value = worktree.isRepo(root);
156
+ gitRepoSeen.set(root, { at: Date.now(), value });
157
+ return value;
158
+ }
144
159
  const filesResult = (r) => { if (r.error) bad(r.error); return r; };
145
160
  const changed = (ctx, result) => { ctx.emit({ type: 'change' }); return result; };
146
161
 
@@ -150,8 +165,12 @@ const changed = (ctx, result) => { ctx.emit({ type: 'change' }); return result;
150
165
  // watches ~/.spectoflow/brain.md and tells local tabs only, whoever wrote it (page, MCP, run line).
151
166
  function brainOp(ctx, fn) {
152
167
  if (ctx && ctx.remote) throw new OpError(404, 'Unknown operation.');
153
- try { return fn(); } catch (e) { if (e.status && !(e instanceof OpError)) throw new OpError(e.status, e.message); throw e; }
168
+ return storeOp(fn);
154
169
  }
170
+ const storeOp = (fn) => { try { return fn(); } catch (e) { if (e.status && !(e instanceof OpError)) throw new OpError(e.status, e.message); throw e; } };
171
+ // The project memory is the project's (.spectoflow/memory.md, committed): gated like any project read/write,
172
+ // online included. Only `memory.move` touches the personal brain, so only it is local.
173
+ const memOp = (root, ctx, fn) => { const r = storeOp(() => fn(projectMemory.fileFor(root))); ctx.emit({ type: 'change' }); return r; };
155
174
 
156
175
  const ops = {
157
176
  'project.read': async (root) => {
@@ -166,6 +185,7 @@ const ops = {
166
185
  p.todayDate = todayLocal();
167
186
  p.untrustedRunners = runnerTrust.untrusted(root, p.config);
168
187
  p.kitVersion = PKG_VERSION;
188
+ p.git = gitRepoCached(root);
169
189
  return p;
170
190
  },
171
191
  'agentfile.read': async (root, { path: rel }) => readAgentFile(root, rel),
@@ -243,6 +263,21 @@ const ops = {
243
263
  return changed(ctx, { fromVersion: r.fromVersion, toVersion: r.toVersion, refreshed: r.refreshed.length + r.created.length + r.forced.length + r.removed.length, review: r.newSidecar });
244
264
  },
245
265
  'run.stop': async (root, _args, ctx) => changed(ctx, { stopped: stopRuns(root) }),
266
+
267
+ // A task worked on in isolation, in its own git worktree (D79). Merge and pull request change the owner's
268
+ // branches or push: local only, absent from the relay.
269
+ 'worktree.start': async (root, { id }, ctx) => gitOp(() => isolation.start(root, { id }, ctx.emit, { remote: !!ctx.remote })),
270
+ 'worktree.feedback': async (root, { id, text: body }, ctx) => gitOp(() => {
271
+ const msg = text(body, 'Empty feedback.');
272
+ const file = findPlanFileForTask(root, id); if (!file) notFound(`Task ${id} not found.`);
273
+ store.addTaskComment(root, file, id, msg, 'me');
274
+ return isolation.start(root, { id, feedback: msg }, ctx.emit, { remote: !!ctx.remote });
275
+ }),
276
+ 'worktree.diff': async (root, { id }, ctx) => gitOp(() => isolation.diff(root, { id }, { remote: !!ctx.remote })),
277
+ 'worktree.stop': async (root, { id }, ctx) => changed(ctx, isolation.stop(root, { id })),
278
+ 'worktree.discard': async (root, { id }, ctx) => changed(ctx, gitOp(() => isolation.discard(root, { id }))),
279
+ 'worktree.merge': async (root, { id }, ctx) => { localOnly(ctx); return changed(ctx, gitOp(() => isolation.merge(root, { id }))); },
280
+ 'worktree.pr': async (root, { id }, ctx) => { localOnly(ctx); return changed(ctx, gitOp(() => isolation.openPr(root, { id }))); },
246
281
  'chat.summarize': async (root, { agent }, ctx) => {
247
282
  const r = runSummarize(root, { agent }, ctx.emit);
248
283
  if (r.error) bad(r.error);
@@ -307,6 +342,26 @@ const ops = {
307
342
  return { autoAdd: globalConfig.set('brain.autoAdd', autoAdd) };
308
343
  }),
309
344
 
345
+ 'memory.read': async (root) => storeOp(() => ({ ...projectMemory.read(projectMemory.fileFor(root)), path: projectMemory.REL })),
346
+ 'memory.add': async (root, { category, text: body }, ctx) => memOp(root, ctx, (f) => projectMemory.add({ category, text: body, by: 'user' }, f)),
347
+ 'memory.update': async (root, { id, patch }, ctx) => memOp(root, ctx, (f) => ({ entry: projectMemory.update(id, patch || {}, f) })),
348
+ 'memory.remove': async (root, { id }, ctx) => memOp(root, ctx, (f) => projectMemory.remove(id, f)),
349
+ 'memory.confirm': async (root, { id }, ctx) => memOp(root, ctx, (f) => ({ entry: projectMemory.confirm(id, f) })),
350
+ // The agent picked the wrong memory: move one entry to the other, under the category the user chose. Added
351
+ // to the target first, removed from the source after — a failure never loses the fact. Local only: one of
352
+ // the two files is always the user's personal brain.
353
+ 'memory.move': async (root, { from, id, category }, ctx) => brainOp(ctx, () => {
354
+ if (from !== 'user' && from !== 'project') bad('from must be "user" or "project".');
355
+ const pf = projectMemory.fileFor(root);
356
+ const [src, srcFile, dst, dstFile] = from === 'user' ? [brain, undefined, projectMemory, pf] : [projectMemory, pf, brain, undefined];
357
+ const entry = src.get(id, srcFile);
358
+ if (!dst.CATEGORIES.includes(category)) bad(`Unknown category: ${category}.`);
359
+ const added = dst.add({ category, text: entry.text, by: entry.by }, dstFile);
360
+ src.remove(id, srcFile);
361
+ ctx.emit({ type: 'change' });
362
+ return added;
363
+ }),
364
+
310
365
  'attention.remove': async (root, { id }, ctx) => {
311
366
  const rt = store.readRuntime(root); rt.attention = (rt.attention || []).filter((x) => x.id !== id); store.writeRuntime(root, rt);
312
367
  return changed(ctx, { ok: true });
@@ -73,7 +73,7 @@ window.fetch=(url,opts)=>{
73
73
  return _fetch(url,opts);
74
74
  };
75
75
  // Served by the online relay? Its project.read always carries `online`; the local hub's never does.
76
- // Online, the Second brain tab is hidden: the brain is the machine owner's, never a project's.
76
+ // Online, the Second brain tab shows only the project memory: the personal brain is the machine owner's.
77
77
  let REMOTE=false;
78
78
  function setOffline(p){
79
79
  REMOTE=typeof p.online==='boolean';
@@ -161,14 +161,14 @@ function applyNavTabs() {
161
161
  list.forEach((entry) => {
162
162
  const btn = nav.querySelector('.tab[data-tab="' + entry.id + '"]');
163
163
  if (!btn) return;
164
- btn.hidden = !entry.enabled || (entry.id === 'brain' && REMOTE);
164
+ btn.hidden = !entry.enabled;
165
165
  nav.insertBefore(btn, anchor);
166
166
  });
167
167
  // If the currently active NATIVE tab just became disabled (e.g. the user disabled the tab they're
168
168
  // viewing, or another viewer did and this tab just reloaded), navigate to a sensible fallback
169
169
  // instead of leaving a hidden/disabled panel showing.
170
170
  const activeEntry = list.find((e) => e.id === activeTab);
171
- if (activeEntry && (!activeEntry.enabled || (activeEntry.id === 'brain' && REMOTE))) {
171
+ if (activeEntry && !activeEntry.enabled) {
172
172
  const fallback = list.find((e) => e.id === 'board' && e.enabled) || list.find((e) => e.enabled);
173
173
  if (fallback) navigateTab(fallback.id);
174
174
  }
@@ -299,9 +299,10 @@ const runtimeTests=(id)=> (P.runtime&&P.runtime.tests&&P.runtime.tests[id])||nul
299
299
  async function load(){
300
300
  const r = await fetch(withProject('/api/project')); P = await r.json();
301
301
  syncSettingsFromServer();
302
- REMOTE=typeof P.online==='boolean'; // known before the first paint: the brain tab is hidden online
302
+ REMOTE=typeof P.online==='boolean'; // known before the first paint: the personal brain is hidden online
303
303
  render(); setOffline(P); renderUpdateBar(); notifyOrchestration();
304
- if(!REMOTE && !brainData) loadBrain();
304
+ if(!REMOTE && !MEMORIES.user.data) loadBrain();
305
+ loadMemory('project'); // .spectoflow/memory.md may be what just changed
305
306
  if(openTaskId) openDrawer(openTaskId,true);
306
307
  }
307
308
  // Coalesce bursts of SSE 'change'/'message' events into one reload so the board doesn't
@@ -315,6 +316,8 @@ function connect(){
315
316
  let m; try{ m=JSON.parse(ev.data); }catch{ return; }
316
317
  if(m.type==='change'||m.type==='message') return scheduleLoad(); // messages live from runtime.messages
317
318
  if(m.type==='brain') return loadBrain(); // ~/.spectoflow/brain.md changed (page, MCP, run line)
319
+ if((m.type==='run-start'||m.type==='run-end') && m.task) return scheduleLoad(); // isolated work: the chat stays free
320
+ if(m.type==='run-line' && m.task) return;
318
321
  if(m.type==='run-start'||m.type==='run-end') { chatState.forEach(st=>{ st.rawBlock=null; }); sseBusy=(m.type==='run-start'); updateChatBusyUI(); if(m.type==='run-end') notify(t(m.code===0?'notify.done':'notify.failed',{project:P&&P.projectName||'spectoflow'}), ''); return; }
319
322
  if(m.type==='run-line') return appendRaw(m.chunk); // raw output is ephemeral (not logged)
320
323
  };
@@ -981,6 +984,8 @@ function renderTask(t){
981
984
  c.append(tags);
982
985
  }
983
986
  const foot=el('div','task-foot');
987
+ const iso=isoState(t.id);
988
+ if(iso) foot.append(el('span','iso-chip is-'+iso.status,'⎇ '+window.t('iso.chip.'+iso.status))); // `t` is the task here
984
989
  if(t.owner) foot.append(el('span','owner','@'+t.owner));
985
990
  if(t.comments&&t.comments.length) foot.append(el('span','cmt-count','💬 '+t.comments.length));
986
991
  const tr=runtimeTests(t.id);
@@ -1581,7 +1586,7 @@ function navigateTab(tabId,push){
1581
1586
  // was scrolled to last (only on an actual switch INTO the tab — applyActiveTab() alone runs on
1582
1587
  // every SSE render tick too, and re-scrolling/re-focusing there would fight the user's typing).
1583
1588
  if(tabId==='chat') setTimeout(()=>{ scrollChat($('#chatTabLog')); $('#tabRunPrompt').focus(); },60);
1584
- if(tabId==='brain'){ renderBrain(); if(!brainData) loadBrain(); }
1589
+ if(tabId==='brain'){ renderBrain(); if(!MEMORIES.user.data) loadBrain(); if(!MEMORIES.project.data) loadMemory('project'); }
1585
1590
  if(tabId==='files') renderFiles(); // the tree is fetched lazily — only load it on an actual switch in
1586
1591
  }
1587
1592
  function closeNav(){ document.body.classList.remove('nav-open'); const nt=$('#navToggle'); if(nt) nt.setAttribute('aria-expanded','false'); }
@@ -2159,60 +2164,79 @@ function notesSetStatus(state){
2159
2164
  }
2160
2165
 
2161
2166
  // ---- Second brain ------------------------------------------------------------------------------
2162
- // What spectoflow has learned about the user: ~/.spectoflow/brain.md, shared by all their projects —
2163
- // NOT this project's, so it is fetched on its own (/api/brain), never part of /api/project. The hub
2164
- // pushes a 'brain' SSE event whenever the file changes, whoever wrote it (this page, an agent through
2165
- // `spectoflow mcp`, or a `::spectoflow learn` run line).
2166
- const BRAIN_CATEGORIES=['profile','preferences','workflow','avoid'];
2167
+ // Two memories on one page, one renderer. "user": what spectoflow has learned about the user —
2168
+ // ~/.spectoflow/brain.md, shared by all their projects, NOT this project's, so it is fetched on its own
2169
+ // (/api/brain) and hidden online; the hub pushes a 'brain' SSE event whenever that file changes, whoever
2170
+ // wrote it (this page, an agent through `spectoflow mcp`, a `::spectoflow learn` run line). "project":
2171
+ // facts about this project — .spectoflow/memory.md, committed; a write to it (page, agent, git) is an
2172
+ // ordinary 'change' event, and load() refetches it.
2167
2173
  const BRAIN_SOFT_LIMIT=60;
2168
- let brainData=null, brainError=null, brainActionError=null;
2169
- async function loadBrain(){
2170
- if(REMOTE) return;
2174
+ const MEMORIES={
2175
+ user:{ api:'/api/brain', cats:['profile','preferences','workflow','avoid'], other:'project', empty:'brain.empty', dup:'brain.duplicate',
2176
+ path:(d)=>t('brain.path',{path:d.path||'~/.spectoflow/brain.md'}),
2177
+ setAuto:(v)=>memCall('POST','/api/brain/settings',{autoAdd:v}) },
2178
+ project:{ api:'/api/memory', cats:['conventions','pitfalls','glossary','constraints'], other:'user', empty:'brain.projectEmpty', dup:'brain.projectDuplicate',
2179
+ path:(d)=>t('brain.projectPath',{path:d.path||'.spectoflow/memory.md'}),
2180
+ setAuto:(v)=>{ if(P&&P.config) P.config.memoryAutoAdd=v; return memCall('POST','/api/settings',{memoryAutoAdd:v}); } },
2181
+ };
2182
+ Object.values(MEMORIES).forEach(m=>{ m.data=null; m.error=null; m.actionError=null; });
2183
+ const memSection=(key)=>document.querySelector(`[data-memory="${key}"]`);
2184
+ const memUrl=(key,id,suffix)=>`${MEMORIES[key].api}/${encodeURIComponent(id)}${suffix||''}`;
2185
+ async function loadMemory(key){
2186
+ const m=MEMORIES[key];
2187
+ if(key==='user' && REMOTE) return;
2171
2188
  try{
2172
- const r=await fetch(withProject('/api/brain'));
2189
+ const r=await fetch(withProject(m.api));
2173
2190
  const d=await r.json().catch(()=>({}));
2174
2191
  if(!r.ok) throw new Error(d.error||'error');
2175
- brainData=d; brainError=null;
2176
- }catch(err){ brainError=err.message||'error'; }
2192
+ m.data=d; m.error=null;
2193
+ }catch(err){ m.error=err.message||'error'; }
2177
2194
  renderBrain();
2178
2195
  }
2179
- async function brainCall(method,url,body){
2196
+ function loadBrain(){ return loadMemory('user'); }
2197
+ async function memCall(method,url,body){
2180
2198
  flash();
2181
2199
  const r=await fetch(withProject(url),{method,headers:{'Content-Type':'application/json'},body:body===undefined?undefined:JSON.stringify(body)});
2182
2200
  const d=await r.json().catch(()=>({}));
2183
2201
  if(!r.ok) throw new Error(d.error||t('brain.error'));
2184
2202
  return d;
2185
2203
  }
2186
- // A re-render (SSE tick, another tab's write) must never wipe what the user is typing.
2204
+ // A re-render (SSE tick, another tab's write) must never wipe what the user is typing or picking.
2187
2205
  function brainIsEditing(){
2188
2206
  const a=document.activeElement;
2189
- return !!(a && (a.tagName==='INPUT'||a.tagName==='TEXTAREA') && a.closest('#brainGrid, #brainPending'));
2207
+ return !!(a && ['INPUT','TEXTAREA','SELECT'].includes(a.tagName) && a.closest('.mem-grid, .mem-pending'));
2190
2208
  }
2191
2209
  function renderBrain(){
2192
2210
  const badge=$('#brainBadge');
2193
- const pendingN=brainData ? brainData.pending.length : 0;
2211
+ const pendingN=Object.entries(MEMORIES).reduce((n,[k,m])=> n+((m.data && !(k==='user'&&REMOTE)) ? m.data.pending.length : 0),0);
2194
2212
  if(badge){ badge.textContent=pendingN; badge.hidden=pendingN===0; }
2195
2213
  if(activeTab!=='brain' || brainIsEditing()) return;
2196
- const grid=$('#brainGrid'); if(!grid) return;
2197
- if(brainError && !brainData){ grid.innerHTML=''; grid.append(el('div','empty',t('brain.error'))); return; }
2198
- if(!brainData){ grid.innerHTML=''; grid.append(el('div','empty',t('drawer.loading'))); return; }
2199
- const d=brainData;
2200
- $('#brainCount').textContent=d.entries.length;
2201
- const auto=$('#brainAutoAdd'); auto.checked=!!d.autoAdd;
2202
- $('#brainAutoHint').textContent=d.autoAdd ? t('brain.autoAddOn') : t('brain.autoAddOff');
2203
- renderBrainAgents(d.agents||[]);
2204
- const notice=$('#brainNotice');
2205
- const noticeText=brainActionError || (d.entries.length>BRAIN_SOFT_LIMIT ? t('brain.tooMany',{n:d.entries.length}) : '');
2214
+ Object.keys(MEMORIES).forEach(renderMemory);
2215
+ }
2216
+ function renderMemory(key){
2217
+ const m=MEMORIES[key], sec=memSection(key); if(!sec) return;
2218
+ sec.hidden = key==='user' && REMOTE;
2219
+ if(sec.hidden) return;
2220
+ const grid=sec.querySelector('.mem-grid');
2221
+ if(m.error && !m.data){ grid.innerHTML=''; grid.append(el('div','empty',t('brain.error'))); return; }
2222
+ if(!m.data){ grid.innerHTML=''; grid.append(el('div','empty',t('drawer.loading'))); return; }
2223
+ const d=m.data;
2224
+ sec.querySelector('.mem-count').textContent=d.entries.length;
2225
+ sec.querySelector('.mem-auto').checked=!!d.autoAdd;
2226
+ sec.querySelector('.mem-auto-hint').textContent=d.autoAdd ? t('brain.autoAddOn') : t('brain.autoAddOff');
2227
+ const agents=sec.querySelector('.mem-agents'); if(agents) renderBrainAgents(agents, d.agents||[]);
2228
+ const notice=sec.querySelector('.mem-notice');
2229
+ const noticeText=m.actionError || (d.entries.length>BRAIN_SOFT_LIMIT ? t('brain.tooMany',{n:d.entries.length}) : '');
2206
2230
  notice.hidden=!noticeText; notice.textContent=noticeText;
2207
- notice.classList.toggle('is-error',!!brainActionError);
2208
- renderBrainPending(d.pending);
2231
+ notice.classList.toggle('is-error',!!m.actionError);
2232
+ renderMemoryPending(key, sec.querySelector('.mem-pending'), d.pending);
2209
2233
  grid.innerHTML='';
2210
- if(!d.entries.length && !d.pending.length) grid.append(el('div','brain-empty',t('brain.empty')));
2211
- BRAIN_CATEGORIES.forEach(cat=> grid.append(brainCard(cat, d.entries.filter(e=>e.category===cat))));
2212
- $('#brainPath').textContent=t('brain.path',{path:d.path||'~/.spectoflow/brain.md'});
2234
+ if(!d.entries.length && !d.pending.length) grid.append(el('div','brain-empty',t(m.empty)));
2235
+ m.cats.forEach(cat=> grid.append(memoryCard(key, cat, d.entries.filter(e=>e.category===cat))));
2236
+ sec.querySelector('.mem-path').textContent=m.path(d);
2213
2237
  }
2214
- function renderBrainAgents(agents){
2215
- const box=$('#brainAgents'); box.innerHTML='';
2238
+ function renderBrainAgents(box, agents){
2239
+ box.innerHTML='';
2216
2240
  if(!agents.length){ box.append(el('span','brain-agents-none',t('brain.noAgents'))); return; }
2217
2241
  box.append(el('span','brain-agents-label',t('brain.agents')));
2218
2242
  agents.forEach(a=>{
@@ -2230,13 +2254,26 @@ function brainMeta(e){
2230
2254
  const who=e.by==='agent' ? t('brain.byAgent') : t('brain.byYou');
2231
2255
  return e.at ? `${who} · ${e.at}` : who;
2232
2256
  }
2233
- function renderBrainPending(pending){
2234
- const box=$('#brainPending'); box.innerHTML='';
2257
+ // "Move to…": the agent picked the wrong memory. Lists the other memory's categories; local only (one of the
2258
+ // two files is the user's personal brain), so never offered online.
2259
+ function memoryMoveSelect(key, e){
2260
+ if(REMOTE) return null;
2261
+ const target=MEMORIES[key].other;
2262
+ const sel=el('select','brain-move'); sel.title=t('brain.moveHint');
2263
+ const ph=el('option',null,t('brain.moveTo')); ph.value=''; ph.selected=true; ph.disabled=true; sel.append(ph);
2264
+ const group=document.createElement('optgroup'); group.label=t(target==='user'?'brain.you':'brain.project');
2265
+ MEMORIES[target].cats.forEach(c=>{ const o=el('option',null,t('brain.cat.'+c)); o.value=c; group.append(o); });
2266
+ sel.append(group);
2267
+ sel.addEventListener('change',()=>{ const category=sel.value; sel.blur(); memAct(key,()=>memCall('POST','/api/memory/move',{from:key,id:e.id,category}),true); });
2268
+ return sel;
2269
+ }
2270
+ function renderMemoryPending(key, box, pending){
2271
+ box.innerHTML='';
2235
2272
  box.hidden=!pending.length; if(!pending.length) return;
2236
2273
  const head=el('div','brain-pending-head');
2237
2274
  head.append(el('h3','brain-pending-title',t('brain.toConfirm')));
2238
2275
  const all=el('button','btn primary',t('brain.confirmAll'));
2239
- all.addEventListener('click',async()=>{ all.disabled=true; try{ for(const e of pending) await brainCall('POST',`/api/brain/${encodeURIComponent(e.id)}/confirm`,{}); }catch(err){} loadBrain(); });
2276
+ all.addEventListener('click',async()=>{ all.disabled=true; try{ for(const e of pending) await memCall('POST',memUrl(key,e.id,'/confirm'),{}); }catch(err){} loadMemory(key); });
2240
2277
  head.append(all); box.append(head);
2241
2278
  pending.forEach(e=>{
2242
2279
  const row=el('div','brain-row is-pending');
@@ -2244,14 +2281,17 @@ function renderBrainPending(pending){
2244
2281
  const txt=el('div','brain-text',e.text); row.append(txt);
2245
2282
  row.append(el('div','brain-meta',brainMeta(e)));
2246
2283
  const acts=el('div','brain-actions');
2247
- const ok=el('button','btn primary',t('brain.confirm')); ok.addEventListener('click',()=>brainAct(()=>brainCall('POST',`/api/brain/${encodeURIComponent(e.id)}/confirm`,{})));
2248
- const ed=el('button','btn',t('action.edit')); ed.addEventListener('click',()=>brainEdit(e,txt));
2249
- const no=el('button','btn danger',t('brain.reject')); no.addEventListener('click',()=>brainAct(()=>brainCall('DELETE',`/api/brain/${encodeURIComponent(e.id)}`)));
2250
- acts.append(ok,ed,no); row.append(acts);
2284
+ const ok=el('button','btn primary',t('brain.confirm')); ok.addEventListener('click',()=>memAct(key,()=>memCall('POST',memUrl(key,e.id,'/confirm'),{})));
2285
+ const ed=el('button','btn',t('action.edit')); ed.addEventListener('click',()=>memEdit(key,e,txt));
2286
+ const no=el('button','btn danger',t('brain.reject')); no.addEventListener('click',()=>memAct(key,()=>memCall('DELETE',memUrl(key,e.id))));
2287
+ acts.append(ok,ed,no);
2288
+ const mv=memoryMoveSelect(key,e); if(mv) acts.append(mv);
2289
+ row.append(acts);
2251
2290
  box.append(row);
2252
2291
  });
2253
2292
  }
2254
- function brainCard(cat, entries){
2293
+ function memoryCard(key, cat, entries){
2294
+ const m=MEMORIES[key];
2255
2295
  const card=el('div','brain-card');
2256
2296
  const head=el('div','brain-card-head');
2257
2297
  head.append(el('h3','brain-card-title',t('brain.cat.'+cat)), el('span','count',String(entries.length)));
@@ -2264,9 +2304,11 @@ function brainCard(cat, entries){
2264
2304
  const foot=el('div','brain-row-foot');
2265
2305
  foot.append(el('span','brain-meta',brainMeta(e)));
2266
2306
  const acts=el('span','brain-actions');
2267
- const ed=el('button','btn btn-xs',t('action.edit')); ed.addEventListener('click',()=>brainEdit(e,txt));
2268
- const del=el('button','btn btn-xs danger',t('action.delete')); del.addEventListener('click',()=>brainAct(()=>brainCall('DELETE',`/api/brain/${encodeURIComponent(e.id)}`)));
2269
- acts.append(ed,del); foot.append(acts); row.append(foot);
2307
+ const ed=el('button','btn btn-xs',t('action.edit')); ed.addEventListener('click',()=>memEdit(key,e,txt));
2308
+ const del=el('button','btn btn-xs danger',t('action.delete')); del.addEventListener('click',()=>memAct(key,()=>memCall('DELETE',memUrl(key,e.id))));
2309
+ acts.append(ed,del);
2310
+ const mv=memoryMoveSelect(key,e); if(mv) acts.append(mv);
2311
+ foot.append(acts); row.append(foot);
2270
2312
  list.append(row);
2271
2313
  });
2272
2314
  card.append(list);
@@ -2280,28 +2322,28 @@ function brainCard(cat, entries){
2280
2322
  const text=input.value.trim(); if(!text){ input.focus(); return; }
2281
2323
  btn.disabled=true; err.hidden=true;
2282
2324
  try{
2283
- const r=await brainCall('POST','/api/brain',{category:cat,text});
2325
+ const r=await memCall('POST',m.api,{category:cat,text});
2284
2326
  input.value=''; input.blur();
2285
- if(r.duplicate){ err.textContent=t('brain.duplicate'); err.hidden=false; }
2327
+ if(r.duplicate){ err.textContent=t(m.dup); err.hidden=false; }
2286
2328
  }catch(e){ err.textContent=e.message; err.hidden=false; }
2287
- btn.disabled=false; loadBrain();
2329
+ btn.disabled=false; loadMemory(key);
2288
2330
  });
2289
2331
  card.append(add);
2290
2332
  return card;
2291
2333
  }
2292
- async function brainAct(fn){
2293
- try{ await fn(); brainActionError=null; }catch(err){ brainActionError=err.message; }
2294
- loadBrain();
2334
+ async function memAct(key, fn, both){
2335
+ try{ await fn(); MEMORIES[key].actionError=null; }catch(err){ MEMORIES[key].actionError=err.message; }
2336
+ loadMemory(key); if(both) loadMemory(MEMORIES[key].other);
2295
2337
  }
2296
2338
  // Inline edit, same interaction as the Attention tab: blur or Ctrl/Cmd+Enter saves, Escape cancels.
2297
- function brainEdit(e, txtNode){
2339
+ function memEdit(key, e, txtNode){
2298
2340
  const ta=el('textarea','brain-edit'); ta.value=e.text; ta.maxLength=500; txtNode.replaceWith(ta); ta.focus();
2299
2341
  let done=false;
2300
2342
  const finish=async(save)=>{
2301
2343
  if(done) return; done=true;
2302
2344
  const v=ta.value.trim();
2303
2345
  ta.blur();
2304
- if(save && v && v!==e.text) await brainAct(()=>brainCall('PATCH',`/api/brain/${encodeURIComponent(e.id)}`,{text:v}));
2346
+ if(save && v && v!==e.text) await memAct(key,()=>memCall('PATCH',memUrl(key,e.id),{text:v}));
2305
2347
  else renderBrain();
2306
2348
  };
2307
2349
  ta.addEventListener('blur',()=>finish(true));
@@ -2459,7 +2501,11 @@ function openDrawer(id,keep){
2459
2501
  const task=allTasks().find(x=>x.id===id); if(!task) return;
2460
2502
  openTaskId=id;
2461
2503
  if(!keep && taskFromPath()!==id) history.pushState(null,'',projectPath('/'+activeTab+'/'+encodeURIComponent(id)));
2462
- const b=$('#drawerBody'); const prev=keep?$('.drawer-panel').scrollTop:0; b.innerHTML='';
2504
+ const b=$('#drawerBody'); const prev=keep?$('.drawer-panel').scrollTop:0;
2505
+ // A re-render (any SSE tick) must not steal the feedback being typed: remember focus and caret.
2506
+ const focused=document.activeElement&&document.activeElement.closest&&document.activeElement.closest('.iso-feedback') ? document.activeElement : null;
2507
+ const caret=focused?[focused.selectionStart,focused.selectionEnd]:null;
2508
+ b.innerHTML='';
2463
2509
  b.append(el('div','d-id',task.id+' · '+(task.level||'standard')+' · '+task.file));
2464
2510
  b.append(el('div','d-title',task.title));
2465
2511
  const sSec=el('div','d-section'); sSec.append(el('div','d-label',t('task.status')));
@@ -2471,6 +2517,7 @@ function openDrawer(id,keep){
2471
2517
  sr.append(btn);
2472
2518
  });
2473
2519
  sSec.append(sr); b.append(sSec);
2520
+ renderIsolation(b,task);
2474
2521
 
2475
2522
  const tr=runtimeTests(id);
2476
2523
  if(tr){ const ts=el('div','d-section'); ts.append(el('div','d-label',t('task.tests')));
@@ -2491,6 +2538,129 @@ function openDrawer(id,keep){
2491
2538
  cSec.append(box); b.append(cSec);
2492
2539
  $('#drawer').setAttribute('aria-hidden','false');
2493
2540
  if(keep) $('.drawer-panel').scrollTop=prev;
2541
+ if(caret){ const ta=b.querySelector('.iso-feedback textarea'); if(ta){ ta.focus(); ta.setSelectionRange(caret[0],caret[1]); } }
2542
+ }
2543
+ // ---- Isolated work (D79) -------------------------------------------------------------------------
2544
+ // A task worked on in its own git worktree: the agent's change stays off the user's working tree until
2545
+ // they review the diff and merge it, open a pull request, send feedback, or discard it. State comes from
2546
+ // runtime.worktrees (P.runtime); the diff is fetched on demand and kept until the run changes. Merge and pull
2547
+ // request are local only (the relay refuses them), so they aren't offered online.
2548
+ const isoDiffs={}, isoDraft={}, isoErrors={};
2549
+ let isoOpenPatch=null, isoArmed=null;
2550
+ const isoState=(id)=> (P&&P.runtime&&P.runtime.worktrees||{})[id]||null;
2551
+ const isoKey=(w)=> w ? `${w.runId}|${w.status}|${w.endedAt||''}` : '';
2552
+ async function isoCall(id,action,body,method){
2553
+ flash();
2554
+ const r=await fetch(withProject('/api/task/'+encodeURIComponent(id)+'/isolate'+(action?'/'+action:'')),{method:method||'POST',headers:{'Content-Type':'application/json'},body:method==='GET'?undefined:JSON.stringify(body||{})});
2555
+ const d=await r.json().catch(()=>({}));
2556
+ if(!r.ok) throw new Error(d.error||t('iso.error'));
2557
+ return d;
2558
+ }
2559
+ // The outcome of an action shows after the next render: success arrives as a 'change' event; a refusal
2560
+ // (a merge conflict, gh missing…) is kept per task until the next action.
2561
+ function isoAct(id,btn,fn){
2562
+ btn.disabled=true; delete isoErrors[id];
2563
+ return fn().then(()=>{ delete isoDiffs[id]; },(err)=>{ isoErrors[id]=err.message; delete isoDiffs[id]; if(openTaskId===id) openDrawer(id,true); });
2564
+ }
2565
+ async function loadIsoDiff(id){
2566
+ const w=isoState(id), key=isoKey(w);
2567
+ try{ isoDiffs[id]={key, data:await isoCall(id,'diff',null,'GET')}; }
2568
+ catch(err){ isoDiffs[id]={key, error:err.message}; }
2569
+ if(openTaskId===id) openDrawer(id,true);
2570
+ }
2571
+ function renderIsoDiff(sec,id){
2572
+ const cached=isoDiffs[id];
2573
+ if(!cached){ sec.append(el('div','empty',t('drawer.loading'))); return null; }
2574
+ if(cached.error){ sec.append(el('div','iso-error',cached.error)); return null; }
2575
+ const d=cached.data;
2576
+ if(!d.files.length){ sec.append(el('div','empty',t('iso.noChanges'))); return d; }
2577
+ const files=el('div','iso-files');
2578
+ d.files.forEach(f=>{
2579
+ const row=el('div','iso-file');
2580
+ row.append(el('span','iso-path',f.path));
2581
+ const n=el('span','iso-count');
2582
+ if(f.added===null) n.textContent=t('iso.binary');
2583
+ else n.append(el('span','iso-plus','+'+f.added),' ',el('span','iso-minus','−'+f.removed));
2584
+ row.append(n); files.append(row);
2585
+ });
2586
+ sec.append(files);
2587
+ const open=isoOpenPatch===id;
2588
+ const toggle=el('button','btn btn-xs',open?t('iso.hideDiff'):t('iso.showDiff'));
2589
+ toggle.addEventListener('click',()=>{ isoOpenPatch=open?null:id; openDrawer(id,true); });
2590
+ sec.append(toggle);
2591
+ if(open){
2592
+ const wrap=el('div','iso-patch'), pre=el('pre');
2593
+ d.patch.split('\n').forEach(line=>{
2594
+ const cls=/^(\+\+\+|---)/.test(line)?'iso-l-meta':line[0]==='+'?'iso-l-add':line[0]==='-'?'iso-l-del':/^@@/.test(line)?'iso-l-hunk':/^diff --git/.test(line)?'iso-l-file':'';
2595
+ pre.append(el('span',cls,line+'\n'));
2596
+ });
2597
+ wrap.append(pre); sec.append(wrap);
2598
+ if(d.truncated) sec.append(el('div','empty',t('iso.truncated')));
2599
+ }
2600
+ return d;
2601
+ }
2602
+ function renderIsolation(b,task){
2603
+ if(!P.git) return;
2604
+ const id=task.id, w=isoState(id);
2605
+ const sec=el('div','d-section iso'); sec.append(el('div','d-label',t('iso.title')));
2606
+ b.append(sec);
2607
+ if(!w){
2608
+ sec.append(el('p','iso-hint',t('iso.hint')));
2609
+ if(isoErrors[id]) sec.append(el('div','iso-error',isoErrors[id]));
2610
+ const go=el('button','btn primary',t('iso.start'));
2611
+ go.addEventListener('click',()=>isoAct(id,go,()=>isoCall(id,'')));
2612
+ sec.append(go);
2613
+ return;
2614
+ }
2615
+ const head=el('div','iso-head');
2616
+ head.append(el('span','iso-status is-'+w.status,t('iso.status.'+w.status)), el('code','iso-branch',w.branch));
2617
+ sec.append(head);
2618
+ if(w.status==='running'){
2619
+ const recent=((P.runtime.messages)||[]).filter(m=>m.runId&&m.runId===w.runId&&m.kind!=='status').slice(-4);
2620
+ if(recent.length){ const log=el('div','iso-log'); recent.forEach(m=>log.append(el('div','iso-log-line',m.text))); sec.append(log); }
2621
+ if(isoErrors[id]) sec.append(el('div','iso-error',isoErrors[id]));
2622
+ const stop=el('button','btn danger',t('chat.stop'));
2623
+ stop.addEventListener('click',()=>isoAct(id,stop,()=>isoCall(id,'stop')));
2624
+ sec.append(stop);
2625
+ return;
2626
+ }
2627
+ if(w.uncommitted) sec.append(el('p','iso-hint',t('iso.uncommitted',{n:w.uncommitted})));
2628
+ if(w.error) sec.append(el('div','iso-error',w.error));
2629
+ if(w.output){ const said=el('div','iso-said'); said.append(el('div','iso-said-label',t('iso.said')), el('div','iso-said-text',w.output)); sec.append(said); }
2630
+ if(w.prUrl){ const a=el('a','iso-pr',t('iso.prOpened')); a.href=w.prUrl; a.target='_blank'; a.rel='noopener noreferrer'; sec.append(a); }
2631
+ if(!isoDiffs[id] || isoDiffs[id].key!==isoKey(w)){ if(!isoDiffs[id] || !isoDiffs[id].loading){ isoDiffs[id]={key:isoKey(w),loading:true}; loadIsoDiff(id); } }
2632
+ const d=isoDiffs[id]&&!isoDiffs[id].loading ? renderIsoDiff(sec,id) : (sec.append(el('div','empty',t('drawer.loading'))), null);
2633
+ const acts=el('div','iso-actions');
2634
+ const hasChanges=!!(d&&d.files.length);
2635
+ if(!REMOTE && hasChanges){
2636
+ const merge=el('button','btn primary',t('iso.merge')); merge.title=t('iso.mergeHint');
2637
+ merge.addEventListener('click',()=>isoAct(id,merge,()=>isoCall(id,'merge')));
2638
+ acts.append(merge);
2639
+ if(d.pr&&d.pr.ok&&w.status!=='pr'){
2640
+ const pr=el('button','btn',t('iso.pr'));
2641
+ pr.addEventListener('click',()=>isoAct(id,pr,()=>isoCall(id,'pr').then(r=>{ if(r.url) window.open(r.url,'_blank','noopener'); })));
2642
+ acts.append(pr);
2643
+ }
2644
+ }
2645
+ // Discard asks for a second click within 4s; the armed state outlives re-renders.
2646
+ const armed=isoArmed&&isoArmed.id===id&&Date.now()<isoArmed.until;
2647
+ const discard=el('button','btn danger',armed?t('iso.discardConfirm'):t('iso.discard')); discard.title=t('iso.discardHint');
2648
+ discard.addEventListener('click',()=>{
2649
+ if(isoArmed&&isoArmed.id===id&&Date.now()<isoArmed.until){ isoArmed=null; isoAct(id,discard,()=>isoCall(id,'discard')); return; }
2650
+ isoArmed={id,until:Date.now()+4000}; discard.textContent=t('iso.discardConfirm');
2651
+ setTimeout(()=>{ if(isoArmed&&isoArmed.id===id&&Date.now()>=isoArmed.until){ isoArmed=null; if(openTaskId===id) openDrawer(id,true); } },4100);
2652
+ });
2653
+ acts.append(discard);
2654
+ sec.append(acts);
2655
+ if(!REMOTE && hasChanges && d.pr && !d.pr.ok && w.status!=='pr') sec.append(el('p','iso-hint',t('iso.prUnavailable',{reason:d.pr.reason})));
2656
+ if(isoErrors[id]) sec.append(el('div','iso-error',isoErrors[id]));
2657
+ const fb=el('div','c-box iso-feedback');
2658
+ const ta=el('textarea'); ta.placeholder=t('iso.feedbackPlaceholder'); ta.value=isoDraft[id]||'';
2659
+ ta.addEventListener('input',()=>{ isoDraft[id]=ta.value; });
2660
+ const send=el('button','btn',t('iso.feedback'));
2661
+ send.addEventListener('click',()=>{ const v=ta.value.trim(); if(!v){ ta.focus(); return; } isoAct(id,send,()=>isoCall(id,'feedback',{text:v}).then(()=>{ delete isoDraft[id]; })); });
2662
+ const row=el('div','c-actions'); row.append(send);
2663
+ fb.append(ta,row); sec.append(fb);
2494
2664
  }
2495
2665
  function closeDrawer(){ if(taskFromPath()) history.pushState(null,'',projectPath('/'+activeTab)); openTaskId=null; $('#drawer').setAttribute('aria-hidden','true'); }
2496
2666
  const cssv=(v)=> getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#888';
@@ -2685,7 +2855,7 @@ $$('.chat-stop').forEach(b=>b.addEventListener('click',async()=>{ b.disabled=tru
2685
2855
  $('#wfAnalyzeBtn').addEventListener('click',analyzeWorkflow);
2686
2856
  $('#updateBtn').addEventListener('click',updateProject);
2687
2857
  $('#setWorkflowAuto').addEventListener('change',(e)=>{ if(P&&P.config) P.config.workflowAutoEnable=e.target.checked; saveSetting({workflowAutoEnable:e.target.checked}); });
2688
- $('#brainAutoAdd').addEventListener('change',(e)=>brainAct(()=>brainCall('POST','/api/brain/settings',{autoAdd:e.target.checked})));
2858
+ $$('[data-memory]').forEach(sec=>{ const key=sec.dataset.memory; sec.querySelector('.mem-auto').addEventListener('change',(e)=>memAct(key,()=>MEMORIES[key].setAuto(e.target.checked))); });
2689
2859
  $('#attnAddBtn').addEventListener('click',()=>{ const t=$('#attnInput'); const v=t.value.trim(); if(v){ addAttn(v); t.value=''; } });
2690
2860
  $('#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=''; } } });
2691
2861
  $$('.attn-filters .fchip').forEach(b=> b.addEventListener('click',()=>{ attnFilter=b.dataset.attn; renderAttention(); }));