spectoflow 0.33.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.
package/README.md CHANGED
@@ -340,6 +340,34 @@ drawer is served by the one read-only endpoint, `GET /api/agentfile?path=` (scop
340
340
  `.spectoflow/agents/**` + `.spectoflow/skills/**`, path-traversal-safe) — the framework's only other
341
341
  server surface is unchanged.
342
342
 
343
+ ### Isolated work on a task
344
+
345
+ In a git project, open a task and click **Work on it in isolation**. The agent works in its own copy of the
346
+ repository — a git worktree on the branch `spectoflow/<task>` — so your working tree stays untouched, and several
347
+ tasks can run at once without stepping on each other or blocking the chat.
348
+
349
+ ```
350
+ task T-012 ── Work on it in isolation ──► its own copy, branch spectoflow/T-012 (task: in progress)
351
+ │ the agent finishes
352
+ ▼
353
+ task drawer: what changed + the diff (task: to validate)
354
+ ├─ Merge into your current branch, copy removed → done
355
+ ├─ Open a PR push + gh pr create → link on the task
356
+ ├─ Send feedback the agent works again on the same copy
357
+ └─ Discard copy and branch removed — the rollback → to do
358
+ ```
359
+
360
+ - **Nothing to learn:** no command, no setting. Chat runs and orchestration keep working as before.
361
+ - **The agent's last message** shows in the drawer, so a run that changed nothing still says why.
362
+ - **Merge never half-happens:** on a conflict it is aborted, nothing changes, and the drawer shows git's reason —
363
+ send feedback to the agent, or resolve it yourself.
364
+ - **Open a PR** appears when `gh` is installed and signed in and the repository has a remote.
365
+ - The copies live in `~/.spectoflow/worktrees/`, outside your project (no tool sees a second copy of the code) and
366
+ outside `.git` (agents refuse to write there). Uncommitted changes in your working tree aren't in the copy; the
367
+ drawer tells you how many.
368
+ - **Online:** members with write access can start, review, send feedback and discard; merging and opening a pull
369
+ request stay on the owner's machine.
370
+
343
371
  ### Slash commands
344
372
 
345
373
  Type `/` in either chat surface to open an autocomplete of reusable **prompt macros** — pick one,
@@ -69,6 +69,7 @@ function createHandlers(root) {
69
69
  // so the 409 guard in orchestrate.start can't wedge forever. Not a resume — just un-wedging.
70
70
  try { orchestrator.reconcileOnBoot(root); } catch (_) {}
71
71
  try { require('./runner').reconcileRunsOnBoot(root); } catch (_) {}
72
+ try { require('./isolation').reconcileOnBoot(root); } catch (_) {}
72
73
  }
73
74
  return {
74
75
  handleApi,
@@ -0,0 +1,177 @@
1
+ 'use strict';
2
+ /*
3
+ * The delivery loop (D79, docs/delivery-loop-design.md): a task worked on in isolation. One path, riding on the
4
+ * task's existing status:
5
+ *
6
+ * start → git worktree + branch spectoflow/<id>, the agent runs there task: in progress
7
+ * run ends → what it left uncommitted is committed on the branch task: to validate
8
+ * diff → files and patch, for review in the task drawer
9
+ * feedback → the comment is added to the task, the agent runs again, same worktree
10
+ * merge → merged into the working tree's branch, worktree and branch removed task: done
11
+ * pr → branch pushed, pull request opened with gh, worktree removed (link added to the task)
12
+ * discard → worktree and branch removed — the rollback task: to do
13
+ *
14
+ * State lives in runtime.json → worktrees[<id>] = { branch, base, status, runId, startedAt, endedAt,
15
+ * uncommitted, prUrl }; git itself stays the source of truth (boot drops an entry whose branch is gone).
16
+ */
17
+ const store = require('../store');
18
+ const worktree = require('../worktree');
19
+ const { startRun, stopRuns, isRunning } = require('./runner');
20
+
21
+ const group = (id) => `task:${id}`;
22
+ const OUTPUT_TAIL = 4000;
23
+ // What the agent said last (its plain output, sentinel lines left out) — shown in the drawer, so a run that
24
+ // changed nothing still says why (a question, a refusal, an error).
25
+ function tailOutput(child) {
26
+ let text = '';
27
+ const keep = (d) => { text = (text + d.toString()).slice(-OUTPUT_TAIL * 2); };
28
+ if (child.stdout) child.stdout.on('data', keep);
29
+ return () => text.split('\n').filter((l) => !/^::spectoflow\s/.test(l.trim())).join('\n').trim().slice(-OUTPUT_TAIL);
30
+ }
31
+ const fail = (status, message) => { throw new worktree.GitError(status, message); };
32
+
33
+ function findTask(root, id) {
34
+ for (const pl of store.readPlans(root)) for (const ph of pl.phases) {
35
+ const task = ph.tasks.find((t) => t.id === id);
36
+ if (task) return { task, file: pl.file };
37
+ }
38
+ return fail(404, `Task ${id} not found.`);
39
+ }
40
+ function readState(root) { return store.readRuntime(root).worktrees || {}; }
41
+ function setState(root, id, patch) {
42
+ const rt = store.readRuntime(root);
43
+ rt.worktrees = rt.worktrees || {};
44
+ if (patch === null) delete rt.worktrees[id];
45
+ else rt.worktrees[id] = { ...(rt.worktrees[id] || {}), ...patch };
46
+ store.writeRuntime(root, rt);
47
+ return rt.worktrees[id] || null;
48
+ }
49
+ const setStatus = (root, file, id, status) => { try { store.updateTaskLine(root, file, id, { status }); } catch (_) {} };
50
+
51
+ function promptFor(id, task, file, branch, feedback) {
52
+ const lines = [
53
+ `Work on task ${id}: ${task.title} (from plans/${file}).`,
54
+ `You are in an isolated git worktree of this project, on branch ${branch}. The user reviews the diff before anything reaches their own working tree.`,
55
+ "Don't edit this task's line or its status in plans/ — the dashboard tracks it. Don't push, don't switch or create branches.",
56
+ 'The user started this work from the dashboard: that is their go-ahead to do this task now. If it needs a workflow step that is disabled, enable it in .spectoflow/workflow.md and say so — the change shows in the diff they review.',
57
+ ];
58
+ const notes = (task.comments || []).filter((c) => !/^PR: /.test(c));
59
+ if (notes.length) lines.push(`Notes on the task:\n${notes.map((c) => `- ${c}`).join('\n')}`);
60
+ if (feedback) lines.push(`Review feedback on your previous change — address it:\n${feedback}`);
61
+ lines.push('When done, summarize what you changed.');
62
+ return lines.join('\n\n');
63
+ }
64
+
65
+ // Start (or restart, with feedback) the agent on a task in its worktree.
66
+ function start(root, { id, feedback }, emit, { remote } = {}) {
67
+ const { task, file } = findTask(root, id);
68
+ if (isRunning(root, group(id))) fail(409, `An agent is already working on ${id}.`);
69
+ const made = worktree.create(root, id);
70
+ const prev = readState(root)[id] || null;
71
+ const was = prev || {};
72
+ // Marked running before the agent starts: startRun's own events must never show the previous run's result.
73
+ setState(root, id, {
74
+ branch: made.branch, base: was.base || made.base, status: 'running', runId: null,
75
+ startedAt: new Date().toISOString(), endedAt: null, error: null, output: null, prUrl: was.prUrl || null,
76
+ uncommitted: made.created ? worktree.uncommitted(root, [store.resolvePlansDir(root, store.readConfig(root))]).length : (was.uncommitted || 0),
77
+ });
78
+ const run = startRun(root, {
79
+ prompt: promptFor(id, task, file, made.branch, feedback),
80
+ display: `⎇ ${id} — ${feedback ? 'feedback: ' + feedback : task.title}`,
81
+ task: id, cwd: made.cwd, learn: !remote,
82
+ }, emit);
83
+ if (run.error) {
84
+ setState(root, id, prev);
85
+ if (made.created) { try { worktree.discard(root, id); } catch (_) {} }
86
+ fail(400, run.error);
87
+ }
88
+ setState(root, id, { runId: run.runId });
89
+ setStatus(root, file, id, 'in_progress');
90
+ const output = run.child ? tailOutput(run.child) : () => '';
91
+ const finish = (code, signal) => {
92
+ let error = null;
93
+ try { worktree.commitAll(made.path, `${id}: ${task.title}`); } catch (e) { error = e.message; }
94
+ const status = signal ? 'stopped' : code === 0 && !error ? 'ready' : 'failed';
95
+ setState(root, id, { status, endedAt: new Date().toISOString(), error, output: output() || null });
96
+ setStatus(root, file, id, 'to_validate');
97
+ emit({ type: 'change' });
98
+ };
99
+ if (run.child) run.child.on('close', finish); else finish(1, null);
100
+ emit({ type: 'change' });
101
+ return { runId: run.runId, branch: made.branch, uncommitted: readState(root)[id].uncommitted };
102
+ }
103
+
104
+ function requireIdle(root, id) {
105
+ if (isRunning(root, group(id))) fail(409, `An agent is still working on ${id}: stop it first.`);
106
+ }
107
+ // Anything left uncommitted in the worktree (a stopped run, a crash, a hand edit) is part of the change.
108
+ function commitLeftovers(root, id, title) {
109
+ const wt = worktree.list(root)[id];
110
+ if (wt) worktree.commitAll(wt, `${id}: ${title}`);
111
+ }
112
+
113
+ function diff(root, { id }, { remote } = {}) {
114
+ const { task } = findTask(root, id);
115
+ const state = readState(root)[id] || {};
116
+ if (!isRunning(root, group(id))) commitLeftovers(root, id, task.title);
117
+ const d = worktree.diff(root, id, state.base);
118
+ return { ...d, running: isRunning(root, group(id)), pr: remote ? { ok: false, reason: 'local dashboard only' } : prReadyCached(root) };
119
+ }
120
+
121
+ function merge(root, { id }) {
122
+ const { task, file } = findTask(root, id);
123
+ requireIdle(root, id);
124
+ commitLeftovers(root, id, task.title);
125
+ const r = worktree.merge(root, id, `Merge ${worktree.branchOf(id)}: ${id} ${task.title}`);
126
+ setState(root, id, null);
127
+ setStatus(root, file, id, 'done');
128
+ return r;
129
+ }
130
+
131
+ function openPr(root, { id }) {
132
+ const { task, file } = findTask(root, id);
133
+ requireIdle(root, id);
134
+ commitLeftovers(root, id, task.title);
135
+ const r = worktree.openPr(root, id, { title: `${id}: ${task.title}`, body: `Task ${id} from \`plans/${file}\`, worked on with spectoflow.` });
136
+ setState(root, id, { status: 'pr', prUrl: r.url });
137
+ try { store.addTaskComment(root, file, id, `PR: ${r.url}`); } catch (_) {}
138
+ return r;
139
+ }
140
+
141
+ function discard(root, { id }) {
142
+ const { file } = findTask(root, id);
143
+ requireIdle(root, id);
144
+ const r = worktree.discard(root, id);
145
+ setState(root, id, null);
146
+ setStatus(root, file, id, 'todo');
147
+ return r;
148
+ }
149
+
150
+ const stop = (root, { id }) => ({ stopped: stopRuns(root, group(id)) });
151
+
152
+ // After a restart nothing runs: a 'running' entry was interrupted; an entry whose branch is gone is dropped.
153
+ function reconcileOnBoot(root) {
154
+ const state = readState(root);
155
+ const ids = Object.keys(state);
156
+ if (!ids.length) return;
157
+ const rt = store.readRuntime(root);
158
+ for (const id of ids) {
159
+ const exists = worktree.isRepo(root) && worktree.list(root)[id] !== undefined;
160
+ const branchKept = exists || state[id].status === 'pr';
161
+ if (!branchKept) { delete rt.worktrees[id]; continue; }
162
+ if (state[id].status === 'running') rt.worktrees[id] = { ...state[id], status: 'stopped', endedAt: new Date().toISOString() };
163
+ }
164
+ store.writeRuntime(root, rt);
165
+ }
166
+
167
+ // `gh auth status` is slow-ish: remember the answer for a minute per project.
168
+ const prCache = new Map();
169
+ function prReadyCached(root) {
170
+ const hit = prCache.get(root);
171
+ if (hit && Date.now() - hit.at < 60000) return hit.value;
172
+ const value = worktree.prReady(root);
173
+ prCache.set(root, { at: Date.now(), value });
174
+ return value;
175
+ }
176
+
177
+ module.exports = { start, diff, merge, openPr, discard, stop, reconcileOnBoot, promptFor };
@@ -23,6 +23,8 @@ const brainSetup = require('../brain-setup');
23
23
  const globalConfig = require('../global-config');
24
24
  const workflowDetect = require('../workflow-detect');
25
25
  const runnerTrust = require('../runner-trust');
26
+ const isolation = require('./isolation');
27
+ const worktree = require('../worktree');
26
28
 
27
29
  const PKG_VERSION = require('../../package.json').version;
28
30
 
@@ -143,6 +145,17 @@ function writeConfig(root, patch, detectOpts) {
143
145
  fs.writeFileSync(cp, JSON.stringify(cfg, null, 2) + '\n');
144
146
  return cfg;
145
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
+ }
146
159
  const filesResult = (r) => { if (r.error) bad(r.error); return r; };
147
160
  const changed = (ctx, result) => { ctx.emit({ type: 'change' }); return result; };
148
161
 
@@ -172,6 +185,7 @@ const ops = {
172
185
  p.todayDate = todayLocal();
173
186
  p.untrustedRunners = runnerTrust.untrusted(root, p.config);
174
187
  p.kitVersion = PKG_VERSION;
188
+ p.git = gitRepoCached(root);
175
189
  return p;
176
190
  },
177
191
  'agentfile.read': async (root, { path: rel }) => readAgentFile(root, rel),
@@ -249,6 +263,21 @@ const ops = {
249
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 });
250
264
  },
251
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 }))); },
252
281
  'chat.summarize': async (root, { agent }, ctx) => {
253
282
  const r = runSummarize(root, { agent }, ctx.emit);
254
283
  if (r.error) bad(r.error);
@@ -316,6 +316,8 @@ function connect(){
316
316
  let m; try{ m=JSON.parse(ev.data); }catch{ return; }
317
317
  if(m.type==='change'||m.type==='message') return scheduleLoad(); // messages live from runtime.messages
318
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;
319
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; }
320
322
  if(m.type==='run-line') return appendRaw(m.chunk); // raw output is ephemeral (not logged)
321
323
  };
@@ -982,6 +984,8 @@ function renderTask(t){
982
984
  c.append(tags);
983
985
  }
984
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
985
989
  if(t.owner) foot.append(el('span','owner','@'+t.owner));
986
990
  if(t.comments&&t.comments.length) foot.append(el('span','cmt-count','💬 '+t.comments.length));
987
991
  const tr=runtimeTests(t.id);
@@ -2497,7 +2501,11 @@ function openDrawer(id,keep){
2497
2501
  const task=allTasks().find(x=>x.id===id); if(!task) return;
2498
2502
  openTaskId=id;
2499
2503
  if(!keep && taskFromPath()!==id) history.pushState(null,'',projectPath('/'+activeTab+'/'+encodeURIComponent(id)));
2500
- 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='';
2501
2509
  b.append(el('div','d-id',task.id+' · '+(task.level||'standard')+' · '+task.file));
2502
2510
  b.append(el('div','d-title',task.title));
2503
2511
  const sSec=el('div','d-section'); sSec.append(el('div','d-label',t('task.status')));
@@ -2509,6 +2517,7 @@ function openDrawer(id,keep){
2509
2517
  sr.append(btn);
2510
2518
  });
2511
2519
  sSec.append(sr); b.append(sSec);
2520
+ renderIsolation(b,task);
2512
2521
 
2513
2522
  const tr=runtimeTests(id);
2514
2523
  if(tr){ const ts=el('div','d-section'); ts.append(el('div','d-label',t('task.tests')));
@@ -2529,6 +2538,129 @@ function openDrawer(id,keep){
2529
2538
  cSec.append(box); b.append(cSec);
2530
2539
  $('#drawer').setAttribute('aria-hidden','false');
2531
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);
2532
2664
  }
2533
2665
  function closeDrawer(){ if(taskFromPath()) history.pushState(null,'',projectPath('/'+activeTab)); openTaskId=null; $('#drawer').setAttribute('aria-hidden','true'); }
2534
2666
  const cssv=(v)=> getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#888';
@@ -118,7 +118,7 @@ en: {
118
118
  'settings.saved':'✓ saved','settings.group.execution':'Agent & automation','settings.group.appearance':'Appearance & language','settings.group.navTabs':'Navigation tabs','settings.navTabs.locked':'Always on — this is where you manage this setting','settings.navTabs.moveUp':'Move up','settings.navTabs.moveDown':'Move down','field.plansFolder':'Plans folder','field.specsFolder':'Specs folder',
119
119
  'settings.commands.title':'Commands','settings.commands.note':'Slash commands you can type in the chat. Use {{input}} in the instruction to place your text where you want it.','settings.commands.add':'+ Add command','settings.commands.restore':'Restore defaults','settings.commands.ph.trigger':'Trigger (e.g. rapport_jour)','settings.commands.ph.desc':'Short description (shown in the menu)','settings.commands.ph.instruction':'Instruction sent to the agent (use {{input}} for your text)','settings.commands.err.invalidTrigger':'Invalid trigger: lowercase letters, digits, - and _ only (max 40).','settings.commands.err.duplicateTrigger':'A command with this trigger already exists.','settings.commands.err.emptyInstruction':'The instruction cannot be empty.',
120
120
  'field.frameworkVersion':'Framework version',
121
- 'task.status':'Status','task.tests':'Tests','task.failing':'{n} failing','task.passing':'{n} passing',
121
+ 'task.status':'Status','task.tests':'Tests','task.failing':'{n} failing','task.passing':'{n} passing','iso.title':'Isolated work','iso.hint':'The agent works on this task in its own copy of the repository (a git worktree): your working tree stays untouched until you review the change and merge it.','iso.start':'Work on it in isolation','iso.error':'Something went wrong.','iso.status.running':'Agent working…','iso.status.ready':'Ready for review','iso.status.stopped':'Stopped — review what was done','iso.status.failed':'The agent ended with an error — review what was done','iso.status.pr':'Pull request opened','iso.chip.running':'working','iso.chip.ready':'to review','iso.chip.stopped':'stopped','iso.chip.failed':'failed','iso.chip.pr':'PR','iso.noChanges':'No change yet.','iso.binary':'binary','iso.showDiff':'Show the changes','iso.hideDiff':'Hide the changes','iso.truncated':'The diff is too long to show in full.','iso.uncommitted':'{n} uncommitted change(s) in your working tree are not in this copy.','iso.prOpened':'Open the pull request ↗','iso.merge':'Merge','iso.mergeHint':'Merge into your current branch, then remove the isolated copy','iso.pr':'Open a pull request','iso.prUnavailable':'Pull request unavailable: {reason}.','iso.discard':'Discard','iso.discardHint':'Delete the isolated copy and its branch','iso.discardConfirm':'Click again to discard','iso.feedback':'Send feedback to the agent','iso.feedbackPlaceholder':'What should the agent change?','iso.said':'The agent\'s last words',
122
122
  'task.failingPassing':'{f} failing, {p} passing','task.comments':'Comments','task.noComments':'No comments.',
123
123
  'task.addCommentPlaceholder':'Add a comment, a remark, feedback…',
124
124
  'task.toAnalyzeHint':'"To analyze" moves the task back so the agent picks it up next round.',
@@ -243,7 +243,7 @@ fr: {
243
243
  'settings.saved':'✓ enregistré','settings.group.execution':'Agent et automatisation','settings.group.appearance':'Apparence et langue','settings.group.navTabs':'Onglets de navigation','settings.navTabs.locked':'Toujours activé — c’est ici que vous gérez ce réglage','settings.navTabs.moveUp':'Monter','settings.navTabs.moveDown':'Descendre','field.plansFolder':'Dossier des plans','field.specsFolder':'Dossier des specs',
244
244
  'settings.commands.title':'Commandes','settings.commands.note':'Des commandes slash à taper dans le chat. Utilisez {{input}} dans l\'instruction pour placer votre texte où vous voulez.','settings.commands.add':'+ Ajouter une commande','settings.commands.restore':'Restaurer les commandes par défaut','settings.commands.ph.trigger':'Déclencheur (ex. rapport_jour)','settings.commands.ph.desc':'Courte description (affichée dans le menu)','settings.commands.ph.instruction':'Instruction envoyée à l\'agent (utilisez {{input}} pour votre texte)','settings.commands.err.invalidTrigger':'Déclencheur invalide : minuscules, chiffres, - et _ uniquement (40 max).','settings.commands.err.duplicateTrigger':'Une commande avec ce déclencheur existe déjà.','settings.commands.err.emptyInstruction':'L\'instruction ne peut pas être vide.',
245
245
  'field.frameworkVersion':'Version du framework',
246
- 'task.status':'Statut','task.tests':'Tests','task.failing':'{n} en échec','task.passing':'{n} réussi(s)',
246
+ 'task.status':'Statut','task.tests':'Tests','task.failing':'{n} en échec','task.passing':'{n} réussi(s)','iso.title':'Travail isolé','iso.hint':'L’agent travaille sur cette tâche dans sa propre copie du dépôt (un worktree git) : votre dossier de travail reste intact jusqu’à ce que vous relisiez le changement et le fusionniez.','iso.start':'Travailler en isolation','iso.error':'Une erreur est survenue.','iso.status.running':'L’agent travaille…','iso.status.ready':'Prêt à relire','iso.status.stopped':'Arrêté — relisez ce qui a été fait','iso.status.failed':'L’agent a terminé en erreur — relisez ce qui a été fait','iso.status.pr':'Pull request ouverte','iso.chip.running':'en cours','iso.chip.ready':'à relire','iso.chip.stopped':'arrêté','iso.chip.failed':'échec','iso.chip.pr':'PR','iso.noChanges':'Aucun changement pour l’instant.','iso.binary':'binaire','iso.showDiff':'Voir les changements','iso.hideDiff':'Masquer les changements','iso.truncated':'Le diff est trop long pour être affiché en entier.','iso.uncommitted':'{n} changement(s) non commité(s) de votre dossier de travail ne sont pas dans cette copie.','iso.prOpened':'Ouvrir la pull request ↗','iso.merge':'Fusionner','iso.mergeHint':'Fusionner dans votre branche actuelle, puis supprimer la copie isolée','iso.pr':'Ouvrir une pull request','iso.prUnavailable':'Pull request indisponible : {reason}.','iso.discard':'Abandonner','iso.discardHint':'Supprimer la copie isolée et sa branche','iso.discardConfirm':'Cliquez encore pour abandonner','iso.feedback':'Renvoyer un commentaire à l’agent','iso.feedbackPlaceholder':'Que doit changer l’agent ?','iso.said':'Dernier message de l’agent',
247
247
  'task.failingPassing':'{f} en échec, {p} réussi(s)','task.comments':'Commentaires','task.noComments':'Aucun commentaire.',
248
248
  'task.addCommentPlaceholder':'Ajouter un commentaire, une remarque, un retour…',
249
249
  'task.toAnalyzeHint':'« À analyser » renvoie la tâche pour que l’agent la reprenne au tour suivant.',
@@ -368,7 +368,7 @@ es: {
368
368
  'settings.saved':'✓ guardado','settings.group.execution':'Agente y automatización','settings.group.appearance':'Apariencia e idioma','settings.group.navTabs':'Pestañas de navegación','settings.navTabs.locked':'Siempre activo — aquí es donde gestionas este ajuste','settings.navTabs.moveUp':'Subir','settings.navTabs.moveDown':'Bajar','field.plansFolder':'Carpeta de planes','field.specsFolder':'Carpeta de specs',
369
369
  'settings.commands.title':'Comandos','settings.commands.note':'Comandos slash que puedes escribir en el chat. Usa {{input}} en la instrucción para colocar tu texto donde quieras.','settings.commands.add':'+ Añadir comando','settings.commands.restore':'Restaurar valores predeterminados','settings.commands.ph.trigger':'Activador (p. ej. rapport_jour)','settings.commands.ph.desc':'Descripción breve (se muestra en el menú)','settings.commands.ph.instruction':'Instrucción enviada al agente (usa {{input}} para tu texto)','settings.commands.err.invalidTrigger':'Activador inválido: solo minúsculas, dígitos, - y _ (máx. 40).','settings.commands.err.duplicateTrigger':'Ya existe un comando con este activador.','settings.commands.err.emptyInstruction':'La instrucción no puede estar vacía.',
370
370
  'field.frameworkVersion':'Versión del framework',
371
- 'task.status':'Estado','task.tests':'Pruebas','task.failing':'{n} fallando','task.passing':'{n} superadas',
371
+ 'task.status':'Estado','task.tests':'Pruebas','task.failing':'{n} fallando','task.passing':'{n} superadas','iso.title':'Trabajo aislado','iso.hint':'El agente trabaja en esta tarea en su propia copia del repositorio (un worktree de git): tu directorio de trabajo no se toca hasta que revises el cambio y lo fusiones.','iso.start':'Trabajar en aislamiento','iso.error':'Algo salió mal.','iso.status.running':'El agente está trabajando…','iso.status.ready':'Listo para revisar','iso.status.stopped':'Detenido — revisa lo hecho','iso.status.failed':'El agente terminó con un error — revisa lo hecho','iso.status.pr':'Pull request abierta','iso.chip.running':'en curso','iso.chip.ready':'a revisar','iso.chip.stopped':'detenido','iso.chip.failed':'error','iso.chip.pr':'PR','iso.noChanges':'Aún no hay cambios.','iso.binary':'binario','iso.showDiff':'Ver los cambios','iso.hideDiff':'Ocultar los cambios','iso.truncated':'El diff es demasiado largo para mostrarlo entero.','iso.uncommitted':'{n} cambio(s) sin confirmar de tu directorio de trabajo no están en esta copia.','iso.prOpened':'Abrir la pull request ↗','iso.merge':'Fusionar','iso.mergeHint':'Fusionar en tu rama actual y luego eliminar la copia aislada','iso.pr':'Abrir una pull request','iso.prUnavailable':'Pull request no disponible: {reason}.','iso.discard':'Descartar','iso.discardHint':'Eliminar la copia aislada y su rama','iso.discardConfirm':'Haz clic otra vez para descartar','iso.feedback':'Enviar comentarios al agente','iso.feedbackPlaceholder':'¿Qué debe cambiar el agente?','iso.said':'Último mensaje del agente',
372
372
  'task.failingPassing':'{f} fallando, {p} superadas','task.comments':'Comentarios','task.noComments':'Sin comentarios.',
373
373
  'task.addCommentPlaceholder':'Añade un comentario, una observación, feedback…',
374
374
  'task.toAnalyzeHint':'«Por analizar» devuelve la tarea para que el agente la retome en la siguiente ronda.',
@@ -493,7 +493,7 @@ de: {
493
493
  'settings.saved':'✓ gespeichert','settings.group.execution':'Agent & Automatisierung','settings.group.appearance':'Erscheinungsbild & Sprache','settings.group.navTabs':'Navigations-Tabs','settings.navTabs.locked':'Immer aktiv — hier verwaltest du diese Einstellung','settings.navTabs.moveUp':'Nach oben','settings.navTabs.moveDown':'Nach unten','field.plansFolder':'Plan-Ordner','field.specsFolder':'Spec-Ordner',
494
494
  'settings.commands.title':'Befehle','settings.commands.note':'Slash-Befehle, die du im Chat eingeben kannst. Verwende {{input}} in der Anweisung, um deinen Text an der gewünschten Stelle einzufügen.','settings.commands.add':'+ Befehl hinzufügen','settings.commands.restore':'Standardwerte wiederherstellen','settings.commands.ph.trigger':'Auslöser (z. B. rapport_jour)','settings.commands.ph.desc':'Kurze Beschreibung (im Menü angezeigt)','settings.commands.ph.instruction':'An den Agenten gesendete Anweisung (verwende {{input}} für deinen Text)','settings.commands.err.invalidTrigger':'Ungültiger Auslöser: nur Kleinbuchstaben, Ziffern, - und _ (max. 40).','settings.commands.err.duplicateTrigger':'Ein Befehl mit diesem Auslöser existiert bereits.','settings.commands.err.emptyInstruction':'Die Anweisung darf nicht leer sein.',
495
495
  'field.frameworkVersion':'Framework-Version',
496
- 'task.status':'Status','task.tests':'Tests','task.failing':'{n} fehlgeschlagen','task.passing':'{n} bestanden',
496
+ 'task.status':'Status','task.tests':'Tests','task.failing':'{n} fehlgeschlagen','task.passing':'{n} bestanden','iso.title':'Isolierte Arbeit','iso.hint':'Der Agent arbeitet an dieser Aufgabe in seiner eigenen Kopie des Repositorys (einem git-Worktree): Dein Arbeitsverzeichnis bleibt unberührt, bis du die Änderung prüfst und zusammenführst.','iso.start':'Isoliert daran arbeiten','iso.error':'Etwas ist schiefgelaufen.','iso.status.running':'Der Agent arbeitet…','iso.status.ready':'Bereit zur Prüfung','iso.status.stopped':'Gestoppt — prüfe, was gemacht wurde','iso.status.failed':'Der Agent endete mit einem Fehler — prüfe, was gemacht wurde','iso.status.pr':'Pull Request geöffnet','iso.chip.running':'läuft','iso.chip.ready':'zu prüfen','iso.chip.stopped':'gestoppt','iso.chip.failed':'Fehler','iso.chip.pr':'PR','iso.noChanges':'Noch keine Änderung.','iso.binary':'binär','iso.showDiff':'Änderungen anzeigen','iso.hideDiff':'Änderungen ausblenden','iso.truncated':'Der Diff ist zu lang, um ihn vollständig anzuzeigen.','iso.uncommitted':'{n} nicht committete Änderung(en) in deinem Arbeitsverzeichnis sind nicht in dieser Kopie.','iso.prOpened':'Pull Request öffnen ↗','iso.merge':'Zusammenführen','iso.mergeHint':'In deinen aktuellen Branch zusammenführen, dann die isolierte Kopie entfernen','iso.pr':'Pull Request öffnen','iso.prUnavailable':'Pull Request nicht verfügbar: {reason}.','iso.discard':'Verwerfen','iso.discardHint':'Die isolierte Kopie und ihren Branch löschen','iso.discardConfirm':'Zum Verwerfen erneut klicken','iso.feedback':'Feedback an den Agenten senden','iso.feedbackPlaceholder':'Was soll der Agent ändern?','iso.said':'Letzte Nachricht des Agenten',
497
497
  'task.failingPassing':'{f} fehlgeschlagen, {p} bestanden','task.comments':'Kommentare','task.noComments':'Keine Kommentare.',
498
498
  'task.addCommentPlaceholder':'Kommentar, Anmerkung oder Feedback hinzufügen…',
499
499
  'task.toAnalyzeHint':'„Zu analysieren“ gibt die Aufgabe zurück, damit der Agent sie in der nächsten Runde aufgreift.',
@@ -618,7 +618,7 @@ pt: {
618
618
  'settings.saved':'✓ guardado','settings.group.execution':'Agente e automação','settings.group.appearance':'Aparência e idioma','settings.group.navTabs':'Abas de navegação','settings.navTabs.locked':'Sempre ativo — é aqui que você gerencia esta configuração','settings.navTabs.moveUp':'Mover para cima','settings.navTabs.moveDown':'Mover para baixo','field.plansFolder':'Pasta de planos','field.specsFolder':'Pasta de specs',
619
619
  'settings.commands.title':'Comandos','settings.commands.note':'Comandos slash que você pode digitar no chat. Use {{input}} na instrução para posicionar seu texto onde quiser.','settings.commands.add':'+ Adicionar comando','settings.commands.restore':'Restaurar padrões','settings.commands.ph.trigger':'Gatilho (ex.: rapport_jour)','settings.commands.ph.desc':'Descrição curta (exibida no menu)','settings.commands.ph.instruction':'Instrução enviada ao agente (use {{input}} para o seu texto)','settings.commands.err.invalidTrigger':'Gatilho inválido: apenas minúsculas, dígitos, - e _ (máx. 40).','settings.commands.err.duplicateTrigger':'Já existe um comando com este gatilho.','settings.commands.err.emptyInstruction':'A instrução não pode ficar vazia.',
620
620
  'field.frameworkVersion':'Versão do framework',
621
- 'task.status':'Estado','task.tests':'Testes','task.failing':'{n} a falhar','task.passing':'{n} bem-sucedido(s)',
621
+ 'task.status':'Estado','task.tests':'Testes','task.failing':'{n} a falhar','task.passing':'{n} bem-sucedido(s)','iso.title':'Trabalho isolado','iso.hint':'O agente trabalha nesta tarefa na sua própria cópia do repositório (um worktree git): a sua pasta de trabalho fica intacta até rever a alteração e a integrar.','iso.start':'Trabalhar em isolamento','iso.error':'Algo correu mal.','iso.status.running':'O agente está a trabalhar…','iso.status.ready':'Pronto para rever','iso.status.stopped':'Parado — reveja o que foi feito','iso.status.failed':'O agente terminou com um erro — reveja o que foi feito','iso.status.pr':'Pull request aberta','iso.chip.running':'em curso','iso.chip.ready':'a rever','iso.chip.stopped':'parado','iso.chip.failed':'erro','iso.chip.pr':'PR','iso.noChanges':'Ainda sem alterações.','iso.binary':'binário','iso.showDiff':'Ver as alterações','iso.hideDiff':'Ocultar as alterações','iso.truncated':'O diff é demasiado longo para mostrar por inteiro.','iso.uncommitted':'{n} alteração(ões) não confirmada(s) da sua pasta de trabalho não estão nesta cópia.','iso.prOpened':'Abrir a pull request ↗','iso.merge':'Integrar','iso.mergeHint':'Integrar no seu ramo atual e depois remover a cópia isolada','iso.pr':'Abrir uma pull request','iso.prUnavailable':'Pull request indisponível: {reason}.','iso.discard':'Descartar','iso.discardHint':'Apagar a cópia isolada e o seu ramo','iso.discardConfirm':'Clique outra vez para descartar','iso.feedback':'Enviar comentário ao agente','iso.feedbackPlaceholder':'O que deve o agente mudar?','iso.said':'Última mensagem do agente',
622
622
  'task.failingPassing':'{f} a falhar, {p} bem-sucedido(s)','task.comments':'Comentários','task.noComments':'Sem comentários.',
623
623
  'task.addCommentPlaceholder':'Adicione um comentário, uma observação, feedback…',
624
624
  'task.toAnalyzeHint':'«Por analisar» devolve a tarefa para o agente a retomar na ronda seguinte.',
@@ -743,7 +743,7 @@ it: {
743
743
  'settings.saved':'✓ salvato','settings.group.execution':'Agente e automazione','settings.group.appearance':'Aspetto e lingua','settings.group.navTabs':'Schede di navigazione','settings.navTabs.locked':'Sempre attivo — è qui che gestisci questa impostazione','settings.navTabs.moveUp':'Sposta su','settings.navTabs.moveDown':'Sposta giù','field.plansFolder':'Cartella dei piani','field.specsFolder':'Cartella delle specs',
744
744
  'settings.commands.title':'Comandi','settings.commands.note':'Comandi slash che puoi digitare in chat. Usa {{input}} nell\'istruzione per posizionare il tuo testo dove vuoi.','settings.commands.add':'+ Aggiungi comando','settings.commands.restore':'Ripristina predefiniti','settings.commands.ph.trigger':'Trigger (es. rapport_jour)','settings.commands.ph.desc':'Breve descrizione (mostrata nel menu)','settings.commands.ph.instruction':'Istruzione inviata all\'agente (usa {{input}} per il tuo testo)','settings.commands.err.invalidTrigger':'Trigger non valido: solo minuscole, cifre, - e _ (max 40).','settings.commands.err.duplicateTrigger':'Esiste già un comando con questo trigger.','settings.commands.err.emptyInstruction':'L\'istruzione non può essere vuota.',
745
745
  'field.frameworkVersion':'Versione del framework',
746
- 'task.status':'Stato','task.tests':'Test','task.failing':'{n} falliti','task.passing':'{n} superati',
746
+ 'task.status':'Stato','task.tests':'Test','task.failing':'{n} falliti','task.passing':'{n} superati','iso.title':'Lavoro isolato','iso.hint':'L’agente lavora a questa attività nella propria copia del repository (un worktree git): la tua cartella di lavoro resta intatta finché non rivedi la modifica e la unisci.','iso.start':'Lavora in isolamento','iso.error':'Qualcosa è andato storto.','iso.status.running':'L’agente sta lavorando…','iso.status.ready':'Pronto da rivedere','iso.status.stopped':'Fermato — rivedi ciò che è stato fatto','iso.status.failed':'L’agente è terminato con un errore — rivedi ciò che è stato fatto','iso.status.pr':'Pull request aperta','iso.chip.running':'in corso','iso.chip.ready':'da rivedere','iso.chip.stopped':'fermato','iso.chip.failed':'errore','iso.chip.pr':'PR','iso.noChanges':'Ancora nessuna modifica.','iso.binary':'binario','iso.showDiff':'Mostra le modifiche','iso.hideDiff':'Nascondi le modifiche','iso.truncated':'Il diff è troppo lungo per essere mostrato per intero.','iso.uncommitted':'{n} modifica/e non committata/e della tua cartella di lavoro non sono in questa copia.','iso.prOpened':'Apri la pull request ↗','iso.merge':'Unisci','iso.mergeHint':'Unisci nel tuo branch attuale, poi rimuovi la copia isolata','iso.pr':'Apri una pull request','iso.prUnavailable':'Pull request non disponibile: {reason}.','iso.discard':'Scarta','iso.discardHint':'Elimina la copia isolata e il suo branch','iso.discardConfirm':'Clicca di nuovo per scartare','iso.feedback':'Invia un commento all’agente','iso.feedbackPlaceholder':'Cosa deve cambiare l’agente?','iso.said':'Ultimo messaggio dell’agente',
747
747
  'task.failingPassing':'{f} falliti, {p} superati','task.comments':'Commenti','task.noComments':'Nessun commento.',
748
748
  'task.addCommentPlaceholder':'Aggiungi un commento, un’osservazione, un feedback…',
749
749
  'task.toAnalyzeHint':'«Da analizzare» rimanda l’attività così l’agente la riprende al giro successivo.',
@@ -698,6 +698,37 @@ body.booting .ring-svg circle:last-of-type { transform-origin:center; animation:
698
698
  .settings-check-label { font-size:13px; font-weight:600; }
699
699
  .settings-check-hint { font-size:12px; color:var(--muted); }
700
700
 
701
+ /* ---- Isolated work (task drawer) ---- */
702
+ .iso-hint { color:var(--muted); font-size:12.5px; line-height:1.5; margin:0 0 8px; }
703
+ .iso-head { display:flex; flex-wrap:wrap; align-items:center; gap:6px 10px; margin-bottom:8px; }
704
+ .iso-status { font-size:12.5px; font-weight:700; color:var(--ink); }
705
+ .iso-status.is-running { color:var(--cool); } .iso-status.is-failed { color:var(--s-blocked); } .iso-status.is-ready, .iso-status.is-pr { color:var(--signal); }
706
+ .iso-branch { font-family:var(--mono); font-size:11px; color:var(--muted); background:var(--surface-2); border:1px solid var(--line); border-radius:5px; padding:1px 6px; overflow-wrap:anywhere; }
707
+ .iso-log { display:flex; flex-direction:column; gap:3px; margin:0 0 8px; }
708
+ .iso-log-line { font-size:12.5px; color:var(--muted); border-left:2px solid var(--cool); padding-left:8px; overflow-wrap:anywhere; }
709
+ .iso-files { display:flex; flex-direction:column; gap:3px; margin:0 0 8px; }
710
+ .iso-file { display:flex; justify-content:space-between; gap:10px; font-family:var(--mono); font-size:11.5px; min-width:0; }
711
+ .iso-path { overflow-wrap:anywhere; min-width:0; }
712
+ .iso-count { white-space:nowrap; color:var(--faint); }
713
+ .iso-plus { color:var(--s-done); } .iso-minus { color:var(--s-blocked); }
714
+ .iso-patch { margin:8px 0 0; max-height:420px; overflow:auto; border:1px solid var(--line); border-radius:8px; background:var(--surface-2); }
715
+ .iso-patch pre { margin:0; padding:8px 10px; font-family:var(--mono); font-size:11px; line-height:1.5; font-variant-ligatures:none; }
716
+ .iso-patch pre span { display:block; white-space:pre; }
717
+ .iso-l-add { color:var(--s-done); background:color-mix(in srgb,var(--s-done) 10%,transparent); }
718
+ .iso-l-del { color:var(--s-blocked); background:color-mix(in srgb,var(--s-blocked) 10%,transparent); }
719
+ .iso-l-hunk { color:var(--cool); } .iso-l-file, .iso-l-meta { color:var(--faint); font-weight:700; }
720
+ .iso-said { border:1px solid var(--line); border-left:3px solid var(--cool); border-radius:8px; background:var(--surface-2); padding:8px 10px; margin:0 0 10px; max-height:260px; overflow:auto; }
721
+ .iso-said-label { font-size:10.5px; text-transform:uppercase; letter-spacing:.08em; color:var(--faint); font-weight:700; margin-bottom:4px; }
722
+ .iso-said-text { font-size:12.5px; line-height:1.5; white-space:pre-wrap; overflow-wrap:anywhere; }
723
+ .iso-actions { display:flex; flex-wrap:wrap; gap:6px; margin:12px 0 4px; }
724
+ .iso-error { white-space:pre-wrap; font-size:12px; color:var(--s-blocked); border:1px solid color-mix(in srgb,var(--s-blocked) 45%,var(--line)); border-radius:8px; padding:7px 10px; margin:8px 0; overflow-wrap:anywhere; }
725
+ .iso-pr { display:inline-block; font-size:12.5px; color:var(--signal); margin:0 0 8px; }
726
+ .iso-feedback { margin-top:10px; }
727
+ .iso-chip { font-family:var(--mono); font-size:10.5px; color:var(--muted); border:1px solid var(--line); border-radius:999px; padding:1px 7px; }
728
+ .iso-chip.is-running { color:var(--cool); border-color:color-mix(in srgb,var(--cool) 45%,var(--line)); }
729
+ .iso-chip.is-ready, .iso-chip.is-pr { color:var(--signal); border-color:color-mix(in srgb,var(--signal) 45%,var(--line)); }
730
+ .iso-chip.is-failed { color:var(--s-blocked); }
731
+
701
732
  /* ---- Second brain ---- */
702
733
  .brain-wrap { max-width:1080px; margin:0 auto; padding:20px 22px 32px; }
703
734
  .brain-section { margin-top:22px; }
@@ -24,6 +24,14 @@ const ROUTES = [
24
24
  ['POST', '/api/workflow/apply', 'workflow.apply', (_u, b) => b],
25
25
  ['POST', '/api/run', 'run.start', (_u, b) => b],
26
26
  ['POST', '/api/run/stop', 'run.stop', () => ({})],
27
+ // A task worked on in isolation (D79). merge and pr are local only — absent from server/src/relay.js.
28
+ ['POST', /^\/api\/task\/[^/]+\/isolate$/, 'worktree.start', (_u, _b, p) => ({ id: seg(p, 3) })],
29
+ ['POST', /^\/api\/task\/[^/]+\/isolate\/feedback$/, 'worktree.feedback', (_u, b, p) => ({ id: seg(p, 3), text: b.text })],
30
+ ['GET', /^\/api\/task\/[^/]+\/isolate\/diff$/, 'worktree.diff', (_u, _b, p) => ({ id: seg(p, 3) })],
31
+ ['POST', /^\/api\/task\/[^/]+\/isolate\/stop$/, 'worktree.stop', (_u, _b, p) => ({ id: seg(p, 3) })],
32
+ ['POST', /^\/api\/task\/[^/]+\/isolate\/discard$/, 'worktree.discard', (_u, _b, p) => ({ id: seg(p, 3) })],
33
+ ['POST', /^\/api\/task\/[^/]+\/isolate\/merge$/, 'worktree.merge', (_u, _b, p) => ({ id: seg(p, 3) })],
34
+ ['POST', /^\/api\/task\/[^/]+\/isolate\/pr$/, 'worktree.pr', (_u, _b, p) => ({ id: seg(p, 3) })],
27
35
  ['POST', '/api/chat/summarize', 'chat.summarize', (_u, b) => b],
28
36
  ['POST', '/api/meeting/generate', 'meeting.generate', (_u, b) => b],
29
37
  ['POST', '/api/chat/clear', 'chat.clear', () => ({})],
@@ -26,22 +26,25 @@ function resolveRunnerCommand(root, cfg, which, opts) {
26
26
  return null;
27
27
  }
28
28
 
29
- // Agent processes in flight, per project — so a stuck one can be stopped from the dashboard. Runs, summaries
30
- // and meeting notes all register here.
29
+ // Agent processes in flight, per project and group — so a stuck one can be stopped from the dashboard. Runs,
30
+ // summaries and meeting notes share the 'chat' group; a task worked on in isolation (D79) has its own,
31
+ // 'task:<id>', so stopping the chat never stops it, and the other way round.
31
32
  const inFlight = new Map();
32
- function trackChild(root, child) {
33
- const key = path.resolve(root);
33
+ const flightKey = (root, group) => `${path.resolve(root)}\n${group || 'chat'}`;
34
+ function trackChild(root, child, group) {
35
+ const key = flightKey(root, group);
34
36
  if (!inFlight.has(key)) inFlight.set(key, new Set());
35
37
  inFlight.get(key).add(child);
36
38
  child.on('close', () => { const set = inFlight.get(key); if (set) set.delete(child); });
37
39
  }
38
- // Stop every agent process running for this project → how many were signalled.
39
- function stopRuns(root) {
40
- const set = inFlight.get(path.resolve(root));
40
+ // Stop every agent process of this project's group → how many were signalled.
41
+ function stopRuns(root, group) {
42
+ const set = inFlight.get(flightKey(root, group));
41
43
  if (!set || !set.size) return 0;
42
44
  for (const child of set) { try { child.kill(); } catch (_) {} }
43
45
  return set.size;
44
46
  }
47
+ const isRunning = (root, group) => { const set = inFlight.get(flightKey(root, group)); return !!(set && set.size); };
45
48
  // After a crash or restart, runs recorded as running can't be: mark them interrupted.
46
49
  function reconcileRunsOnBoot(root) {
47
50
  const rt = store.readRuntime(root);
@@ -104,7 +107,9 @@ function parseLearnLine(line) {
104
107
  // logPrompt:false suppresses echoing the prompt as a user bubble — used by the orchestrator,
105
108
  // whose priming prompt ("You are the …") is machinery the user shouldn't have to read.
106
109
  // display: when a non-empty string, the chat bubble shows this while the child still receives prompt.
107
- function startRun(root, { prompt, agent, logPrompt = true, display, learn = true }, emit) {
110
+ // task + cwd: a task worked on in isolation (D79) — the agent runs in the task's worktree, its run and events
111
+ // carry the task id (the chat stays free), and it is stopped with its own group.
112
+ function startRun(root, { prompt, agent, logPrompt = true, display, learn = true, task, cwd }, emit) {
108
113
  const cfg = store.readConfig(root);
109
114
  const which = agent || cfg.agent || 'claude';
110
115
  const cmdStr = resolveRunnerCommand(root, cfg, which);
@@ -124,24 +129,25 @@ function startRun(root, { prompt, agent, logPrompt = true, display, learn = true
124
129
  emit({ type: 'message', message: um });
125
130
  }
126
131
 
127
- const run = { id: runId, tool: which, prompt: p, status: 'running', startedAt: new Date().toISOString() };
128
- runStart(root, run); emit({ type: 'run-start', run }); emit({ type: 'change' });
132
+ const run = { id: runId, tool: which, prompt: p, status: 'running', startedAt: new Date().toISOString(), ...(task ? { task } : {}) };
133
+ const tag = task ? { task } : {};
134
+ runStart(root, run); emit({ type: 'run-start', run, ...tag }); emit({ type: 'change' });
129
135
 
130
136
  let child;
131
137
  // windowsHide: without it, spawning a .cmd-shimmed CLI (e.g. a global npm install of `claude` on
132
138
  // Windows) pops up a real, empty console window on top of the browser — jarring, and pointless
133
139
  // since stdout/stderr are already piped and captured below, never read from that window anyway.
134
- try { child = spawn(parts[0], [...parts.slice(1), p], { cwd: root, env: process.env, windowsHide: true }); }
140
+ try { child = spawn(parts[0], [...parts.slice(1), p], { cwd: cwd || root, env: process.env, windowsHide: true }); }
135
141
  catch (e) {
136
142
  runEnd(root, runId, 1);
137
- emit({ type: 'run-line', runId, chunk: 'spawn error: ' + e.message + '\n' });
138
- emit({ type: 'run-end', runId, code: 1 }); emit({ type: 'change' });
139
- return { runId };
143
+ emit({ type: 'run-line', runId, chunk: 'spawn error: ' + e.message + '\n', ...tag });
144
+ emit({ type: 'run-end', runId, code: 1, ...tag }); emit({ type: 'change' });
145
+ return { runId, spawnError: e.message };
140
146
  }
141
147
  // End the child's stdin immediately: a child that reads stdin (or a Windows pipe that
142
148
  // otherwise keeps 'close' from firing) can't stall the run waiting on input that never comes.
143
149
  try { child.stdin && child.stdin.end(); } catch {}
144
- trackChild(root, child);
150
+ trackChild(root, child, task ? `task:${task}` : 'chat');
145
151
 
146
152
  const onLine = (line) => {
147
153
  // A learn line is swallowed either way. When recorded, it ALWAYS waits in "To confirm", whatever
@@ -158,20 +164,20 @@ function startRun(root, { prompt, agent, logPrompt = true, display, learn = true
158
164
  if (att) { pushAttention(root, att, which); emit({ type: 'change' }); return; }
159
165
  const m = store.parseAgentLine(line);
160
166
  if (m) { const full = store.appendMessage(root, { ...m, agent: which, runId }); emit({ type: 'message', message: full }); }
161
- else emit({ type: 'run-line', runId, chunk: line + '\n' });
167
+ else emit({ type: 'run-line', runId, chunk: line + '\n', ...tag });
162
168
  };
163
169
  const out = makeFeeder(onLine), err = makeFeeder(onLine);
164
170
  child.stdout && child.stdout.on('data', (d) => out.feed(d));
165
171
  child.stderr && child.stderr.on('data', (d) => err.feed(d));
166
- child.on('error', (e) => emit({ type: 'run-line', runId, chunk: 'error: ' + e.message + '\n' }));
172
+ child.on('error', (e) => emit({ type: 'run-line', runId, chunk: 'error: ' + e.message + '\n', ...tag }));
167
173
  child.on('close', (code, signal) => {
168
174
  out.flush(); err.flush();
169
175
  runEnd(root, runId, code, signal);
170
176
  const sm = store.appendMessage(root, { role: which, kind: 'status', text: signal ? 'stopped' : `finished (exit ${code})`, agent: which, runId });
171
177
  emit({ type: 'message', message: sm });
172
- emit({ type: 'run-end', runId, code }); emit({ type: 'change' });
178
+ emit({ type: 'run-end', runId, code, ...tag }); emit({ type: 'change' });
173
179
  });
174
180
  return { runId, child };
175
181
  }
176
182
 
177
- module.exports = { startRun, resolveRunnerCommand, parseLearnLine, trackChild, stopRuns, reconcileRunsOnBoot };
183
+ module.exports = { startRun, resolveRunnerCommand, parseLearnLine, trackChild, stopRuns, isRunning, reconcileRunsOnBoot };
@@ -0,0 +1,193 @@
1
+ 'use strict';
2
+ /*
3
+ * The delivery loop's git side (D79): one git worktree per task, so an agent works on a task without touching
4
+ * the user's working tree, then the change is reviewed as a diff and merged, turned into a pull request, sent
5
+ * back with feedback, or discarded. Zero dependency: `git` and `gh` are run with execFileSync — never a shell,
6
+ * so nothing in a task id, a title or a comment is ever interpreted.
7
+ *
8
+ * worktree ~/.spectoflow/worktrees/<repo>-<hash>/<task-id> outside the project, so no watcher, search, test
9
+ * runner or build sees a second copy of the code — and outside .git, where agents refuse to write
10
+ * (Claude Code protects .git/ — found with a real run)
11
+ * branch spectoflow/<task-id>, created from the working tree's HEAD
12
+ *
13
+ * The project may sit in a sub-folder of the repository: the agent then works in the same sub-folder of the
14
+ * worktree.
15
+ */
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const crypto = require('crypto');
19
+ const { execFileSync } = require('child_process');
20
+ const globalConfig = require('./global-config');
21
+
22
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
23
+ const MAX_PATCH = 400 * 1024;
24
+ const BRANCH_PREFIX = 'spectoflow/';
25
+
26
+ class GitError extends Error {
27
+ constructor(status, message) { super(message); this.status = status; }
28
+ }
29
+
30
+ function run(cmd, args, cwd, { input, allowFail } = {}) {
31
+ try {
32
+ return execFileSync(cmd, args, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], input, maxBuffer: 64 * 1024 * 1024, windowsHide: true, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } });
33
+ } catch (e) {
34
+ if (allowFail) return null;
35
+ const msg = String((e.stderr || '') + (e.stdout || '')).trim() || e.message;
36
+ throw new GitError(409, msg.split('\n').slice(-6).join('\n'));
37
+ }
38
+ }
39
+ const git = (cwd, args, opts) => run('git', args, cwd, opts);
40
+ const gitOk = (cwd, args) => git(cwd, args, { allowFail: true });
41
+
42
+ const checkId = (id) => { if (!ID_RE.test(String(id || ''))) throw new GitError(400, `Invalid task id: ${id}`); return String(id); };
43
+ const branchOf = (id) => BRANCH_PREFIX + checkId(id);
44
+
45
+ // → { top, commonDir, rel } for a project inside a git work tree, else null.
46
+ function repoInfo(root) {
47
+ const top = gitOk(root, ['rev-parse', '--show-toplevel']);
48
+ if (top === null) return null;
49
+ const common = git(root, ['rev-parse', '--git-common-dir']).trim();
50
+ const topDir = top.trim();
51
+ let rel = path.relative(fs.realpathSync(topDir), fs.realpathSync(root));
52
+ if (rel.startsWith('..')) rel = '';
53
+ return { top: topDir, commonDir: path.resolve(root, common), rel };
54
+ }
55
+ function requireRepo(root) {
56
+ const info = repoInfo(root);
57
+ if (!info) throw new GitError(400, 'This project is not a git repository: isolated work needs git.');
58
+ if (!gitOk(root, ['rev-parse', '--verify', 'HEAD'])) throw new GitError(400, 'This repository has no commit yet: make a first commit, then try again.');
59
+ return info;
60
+ }
61
+ const isRepo = (root) => !!repoInfo(root);
62
+
63
+ // One folder per repository, named so a person can tell which is which, and unique per repository.
64
+ function worktreePath(info, id) {
65
+ const key = crypto.createHash('sha1').update(fs.realpathSync(info.commonDir)).digest('hex').slice(0, 8);
66
+ const name = path.basename(info.top).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 40) || 'repo';
67
+ return path.join(globalConfig.homeDir(), 'worktrees', `${name}-${key}`, checkId(id));
68
+ }
69
+ const branchExists = (root, branch) => gitOk(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]) !== null;
70
+
71
+ // The worktrees git knows about for spectoflow branches → { <task-id>: path }.
72
+ function list(root) {
73
+ const out = gitOk(root, ['worktree', 'list', '--porcelain']);
74
+ const found = {};
75
+ if (!out) return found;
76
+ let current = null;
77
+ for (const line of out.split('\n')) {
78
+ if (line.startsWith('worktree ')) current = line.slice(9);
79
+ else if (line.startsWith(`branch refs/heads/${BRANCH_PREFIX}`) && current) found[line.slice(`branch refs/heads/${BRANCH_PREFIX}`.length)] = current;
80
+ }
81
+ return found;
82
+ }
83
+
84
+ // Create the task's worktree (or reuse the one that exists) → { branch, path, cwd, base }.
85
+ function create(root, id) {
86
+ const info = requireRepo(root);
87
+ const branch = branchOf(id);
88
+ const wt = worktreePath(info, id);
89
+ const existing = list(root)[id];
90
+ const base = git(root, ['rev-parse', 'HEAD']).trim();
91
+ if (existing) return { branch, path: existing, cwd: path.join(existing, info.rel), base: mergeBase(root, branch) || base, created: false };
92
+ fs.mkdirSync(path.dirname(wt), { recursive: true });
93
+ if (branchExists(root, branch)) git(root, ['worktree', 'add', wt, branch]);
94
+ else git(root, ['worktree', 'add', '-b', branch, wt, 'HEAD']);
95
+ return { branch, path: wt, cwd: path.join(wt, info.rel), base, created: true };
96
+ }
97
+ const mergeBase = (root, branch) => { const r = gitOk(root, ['merge-base', 'HEAD', branch]); return r && r.trim(); };
98
+
99
+ // Uncommitted changes in the user's working tree (they are not in the worktree) → paths. Left out: what the
100
+ // dashboard writes itself — preferences in config.json, task status lines in the plans folder (`ignore`, paths
101
+ // relative to the project) — the agent is given its task in the prompt.
102
+ function uncommitted(root, ignore = []) {
103
+ const out = gitOk(root, ['status', '--porcelain', '--untracked-files=normal', '--', '.']) || '';
104
+ const info = repoInfo(root);
105
+ const skip = ['.spectoflow/config.json', ...ignore].map((p) => path.posix.join(info && info.rel ? info.rel.split(path.sep).join('/') : '', p));
106
+ return out.split('\n').filter(Boolean).map((l) => l.slice(3).replace(/^"|"$/g, ''))
107
+ .filter((p) => !skip.some((s) => p === s || p.startsWith(s.replace(/\/?$/, '/'))));
108
+ }
109
+
110
+ // Commit whatever the agent left uncommitted in the worktree → true when a commit was made. No hooks: the
111
+ // worktree has no installed dependencies for them to run with, and the change is reviewed before it lands.
112
+ function commitAll(wtPath, message) {
113
+ git(wtPath, ['add', '-A']);
114
+ if (gitOk(wtPath, ['diff', '--cached', '--quiet']) !== null) return false;
115
+ const ident = gitOk(wtPath, ['config', 'user.email']) ? [] : ['-c', 'user.name=spectoflow', '-c', 'user.email=spectoflow@localhost'];
116
+ git(wtPath, [...ident, 'commit', '--no-verify', '-q', '-m', message]);
117
+ return true;
118
+ }
119
+
120
+ // What the branch changes compared to where it started → { files: [{ path, added, removed }], patch, truncated }.
121
+ function diff(root, id, base) {
122
+ requireRepo(root);
123
+ const branch = branchOf(id);
124
+ if (!branchExists(root, branch)) throw new GitError(404, `No isolated work for ${id}.`);
125
+ const from = base || mergeBase(root, branch);
126
+ const range = [`${from}`, branch];
127
+ const files = git(root, ['diff', '--numstat', '--no-color', ...range]).split('\n').filter(Boolean).map((l) => {
128
+ const [a, r, ...p] = l.split('\t');
129
+ return { path: p.join('\t'), added: a === '-' ? null : Number(a), removed: r === '-' ? null : Number(r) };
130
+ });
131
+ let patch = git(root, ['diff', '--no-color', '--no-ext-diff', ...range]);
132
+ const truncated = patch.length > MAX_PATCH;
133
+ if (truncated) patch = patch.slice(0, MAX_PATCH);
134
+ return { branch, files, patch, truncated };
135
+ }
136
+
137
+ function removeWorktree(root, id) {
138
+ const wt = list(root)[id];
139
+ if (wt) git(root, ['worktree', 'remove', '--force', wt]);
140
+ gitOk(root, ['worktree', 'prune']);
141
+ if (wt) { try { fs.rmdirSync(path.dirname(wt)); } catch (_) {} } // the repository's folder, once empty
142
+ }
143
+
144
+ // Merge the branch into the working tree's current branch. On any failure (conflict, local changes git would
145
+ // overwrite) the merge is aborted and nothing has changed → throws with git's own explanation.
146
+ function merge(root, id, message) {
147
+ requireRepo(root);
148
+ const branch = branchOf(id);
149
+ if (!branchExists(root, branch)) throw new GitError(404, `No isolated work for ${id}.`);
150
+ const ident = gitOk(root, ['config', 'user.email']) ? [] : ['-c', 'user.name=spectoflow', '-c', 'user.email=spectoflow@localhost'];
151
+ try {
152
+ git(root, [...ident, 'merge', '--no-ff', '--no-verify', '-m', message || `Merge ${branch}`, branch]);
153
+ } catch (e) {
154
+ if (gitOk(root, ['rev-parse', '--verify', '--quiet', 'MERGE_HEAD']) !== null) gitOk(root, ['merge', '--abort']);
155
+ throw new GitError(409, `The merge could not be done, nothing was changed. Git said:\n${e.message}`);
156
+ }
157
+ removeWorktree(root, id);
158
+ gitOk(root, ['branch', '-D', branch]);
159
+ return { branch };
160
+ }
161
+
162
+ function discard(root, id) {
163
+ requireRepo(root);
164
+ const branch = branchOf(id);
165
+ removeWorktree(root, id);
166
+ gitOk(root, ['branch', '-D', branch]);
167
+ return { branch };
168
+ }
169
+
170
+ // Can a pull request be opened from here? → { ok, reason }. `gh` installed and signed in, and a remote.
171
+ function prReady(root) {
172
+ if (!isRepo(root)) return { ok: false, reason: 'not a git repository' };
173
+ const remotes = (gitOk(root, ['remote']) || '').split('\n').filter(Boolean);
174
+ if (!remotes.length) return { ok: false, reason: 'no git remote' };
175
+ if (run('gh', ['--version'], root, { allowFail: true }) === null) return { ok: false, reason: 'gh is not installed' };
176
+ if (run('gh', ['auth', 'status'], root, { allowFail: true }) === null) return { ok: false, reason: 'gh is not signed in (run: gh auth login)' };
177
+ return { ok: true, remote: remotes.includes('origin') ? 'origin' : remotes[0] };
178
+ }
179
+
180
+ // Push the branch and open a pull request with `gh` → { url }. The branch stays; the worktree goes.
181
+ function openPr(root, id, { title, body } = {}) {
182
+ const ready = prReady(root);
183
+ if (!ready.ok) throw new GitError(400, `Can't open a pull request: ${ready.reason}.`);
184
+ const branch = branchOf(id);
185
+ if (!branchExists(root, branch)) throw new GitError(404, `No isolated work for ${id}.`);
186
+ git(root, ['push', '-u', ready.remote, branch]);
187
+ const out = run('gh', ['pr', 'create', '--head', branch, '--title', title || branch, '--body', body || ''], root);
188
+ const url = (out.match(/https?:\/\/\S+/) || [])[0] || out.trim();
189
+ removeWorktree(root, id);
190
+ return { url, branch };
191
+ }
192
+
193
+ module.exports = { GitError, isRepo, repoInfo, list, create, uncommitted, commitAll, diff, merge, discard, prReady, openPr, branchOf, BRANCH_PREFIX };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spectoflow",
3
- "version": "0.33.0",
3
+ "version": "0.34.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",