spectoflow 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -207,7 +207,7 @@ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-siz
207
207
  .tflag.fail { color:var(--s-blocked); border:1px solid color-mix(in srgb,var(--s-blocked) 45%,var(--line)); }
208
208
 
209
209
  /* Requests tab (tasks awaiting review: to_validate / to_analyze) */
210
- .requests-wrap { padding:26px 28px 60px; max-width:900px; }
210
+ .requests-wrap { padding:26px 28px 60px; max-width:1400px; }
211
211
  .requests-list { display:flex; flex-direction:column; gap:7px; }
212
212
  .request-row { cursor:pointer; display:flex; align-items:center; gap:10px; background:var(--surface); border:1px solid var(--line); border-radius:10px; padding:9px 12px; font-size:13px; transition:border-color .15s; }
213
213
  .request-row:hover,.request-row:focus-visible { border-color:color-mix(in srgb,var(--signal) 40%,var(--line)); }
@@ -220,8 +220,15 @@ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-siz
220
220
  .placeholder-empty { margin-top:8px; padding:40px; border:1px dashed var(--line); border-radius:var(--radius); text-align:center; color:var(--faint); font-style:italic; }
221
221
 
222
222
  /* Backlog tab — flat, sortable, filterable table of every task */
223
- .backlog-wrap { padding:26px 28px 60px; max-width:1200px; }
223
+ .backlog-wrap { padding:26px 28px 60px; max-width:1500px; }
224
224
  .backlog-wrap .filters { padding:0 0 14px; }
225
+ .bl-add-form { display:none; align-items:center; gap:8px; flex-wrap:wrap; padding:0 0 14px; }
226
+ .bl-add-form.is-open { display:flex; }
227
+ .bl-add-input { font:inherit; font-size:12.5px; padding:6px 11px; border:1px solid var(--line); border-radius:8px; background:var(--surface); color:var(--ink); }
228
+ .bl-add-input:focus { outline:2px solid var(--signal); outline-offset:1px; }
229
+ .bl-add-title { flex:1; min-width:220px; }
230
+ .bl-add-phase,.bl-add-owner { width:150px; }
231
+ .bl-add-error { font-size:12px; color:var(--s-blocked); }
225
232
  .table-scroll { width:100%; overflow-x:auto; border:1px solid var(--line); border-radius:var(--radius); }
226
233
  .backlog-table { width:100%; min-width:720px; border-collapse:collapse; font-size:12.5px; }
227
234
  .backlog-table thead th { position:sticky; top:0; z-index:1; background:var(--surface-2); text-align:left; padding:9px 12px; font-family:var(--mono); font-size:10.5px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); white-space:nowrap; cursor:pointer; user-select:none; border-bottom:1px solid var(--line); }
@@ -243,8 +250,12 @@ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-siz
243
250
  @media (max-width:640px){ .backlog-wrap { padding:18px 14px 40px; } }
244
251
 
245
252
  /* Info tab (project overview — config, runners, counts, specs, active workflow) */
246
- .info-wrap { padding:26px 28px 60px; max-width:1000px; }
247
- .info-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:14px; margin-top:8px; }
253
+ .info-wrap { padding:26px 28px 60px; max-width:1500px; }
254
+ /* a fixed 2-column grid, not auto-fit: with 5 sections (2 + 1 full-span + 2), more columns would
255
+ leave the 2-item rows with genuinely empty trailing cells — the same dead space this was meant to
256
+ fix, just moved inside the grid. 2 columns always fill completely; the wider .info-wrap above is
257
+ what actually puts the freed width to use, by making each card roomier. */
258
+ .info-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:14px; margin-top:8px; align-items:start; }
248
259
  .info-section { background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); padding:16px 18px; }
249
260
  .info-section-head { display:flex; align-items:center; gap:8px; margin:0 0 12px; }
250
261
  .info-section-ico { width:16px; height:16px; flex-shrink:0; display:inline-flex; align-items:center; justify-content:center; color:var(--cool); }
@@ -275,6 +286,43 @@ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-siz
275
286
  /* workflow diagram */
276
287
  .wf-wrap,.team-wrap { padding:26px 28px 60px; max-width:1000px; }
277
288
  .wf-wrap { max-width:1340px; } /* wider so the horizontal pipeline fits its 9 steps on one line */
289
+ .team-wrap { max-width:1500px; } /* the agent/skill .cards grid fills more columns instead of sitting capped */
290
+
291
+ /* Files tab — a two-pane explorer: a collapsible tree, and the selected file's preview/editor */
292
+ .files-wrap { padding:26px 28px 60px; max-width:1600px; height:calc(100vh - 130px); display:flex; flex-direction:column; }
293
+ .files-body { flex:1; min-height:0; display:flex; gap:16px; margin-top:8px; }
294
+ .files-tree-col { width:280px; flex-shrink:0; display:flex; flex-direction:column; gap:8px; min-height:0; }
295
+ .files-toolbar { display:flex; gap:8px; }
296
+ .files-toolbar .btn { font-size:12px; padding:6px 11px; }
297
+ .files-create-form { display:none; flex-direction:column; gap:6px; padding:10px; border:1px solid var(--line); border-radius:var(--radius); background:var(--surface); }
298
+ .files-create-form.is-open { display:flex; }
299
+ .files-create-form .bl-add-input { width:100%; }
300
+ .files-create-actions { display:flex; gap:8px; }
301
+ .files-create-actions .btn { font-size:12px; padding:6px 11px; }
302
+ .files-tree { flex:1; min-height:0; overflow:auto; border:1px solid var(--line); border-radius:var(--radius); padding:8px; background:var(--surface); }
303
+ .files-content-col { flex:1; min-width:0; display:flex; flex-direction:column; min-height:0; border:1px solid var(--line); border-radius:var(--radius); background:var(--surface); overflow:hidden; }
304
+ .files-empty,.files-binary { padding:40px; text-align:center; color:var(--faint); font-style:italic; }
305
+ .f-row { display:flex; align-items:center; gap:6px; padding:4px 6px; border-radius:6px; cursor:pointer; font-size:12.5px; white-space:nowrap; color:var(--muted); }
306
+ .f-row:hover { background:var(--surface-2); color:var(--ink); }
307
+ .f-row.is-active { background:var(--surface-2); color:var(--signal); font-weight:600; }
308
+ .f-row .f-chevron { width:11px; height:11px; flex-shrink:0; transition:transform .15s; color:var(--faint); }
309
+ .f-row.is-open .f-chevron { transform:rotate(90deg); }
310
+ .f-row .f-name { overflow:hidden; text-overflow:ellipsis; }
311
+ .f-children { margin-left:13px; border-left:1px solid var(--line); padding-left:6px; }
312
+ .f-children[hidden] { display:none; }
313
+ .files-toolbar-row { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:10px 14px; border-bottom:1px solid var(--line); }
314
+ .files-path { font-family:var(--mono); font-size:12px; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
315
+ .files-actions { display:flex; gap:8px; flex-shrink:0; }
316
+ .files-actions .btn { font-size:12px; padding:6px 11px; }
317
+ .files-body-col { flex:1; min-height:0; display:flex; flex-direction:column; }
318
+ .files-view { flex:1; min-height:0; overflow:auto; padding:16px 20px; }
319
+ .files-editor { flex:1; min-height:0; width:100%; border:0; resize:none; padding:16px 20px; font-family:var(--mono); font-size:12.5px; line-height:1.6; background:var(--surface); color:var(--ink); outline:none; }
320
+ .files-iframe { flex:1; min-height:0; width:100%; border:0; background:#fff; }
321
+ .files-md-toolbar { display:flex; gap:6px; padding:8px 14px; border-bottom:1px solid var(--line); }
322
+ .files-md-toolbar button { font-family:var(--mono); font-size:12px; padding:4px 9px; border:1px solid var(--line); border-radius:6px; background:var(--surface-2); color:var(--ink); cursor:pointer; }
323
+ .files-saved-tip { font-size:11.5px; color:var(--s-done); font-family:var(--mono); }
324
+ .files-error-tip { font-size:11.5px; color:var(--s-blocked); }
325
+ @media (max-width:820px){ .files-body { flex-direction:column; } .files-tree-col { width:auto; max-height:220px; } .files-wrap { height:auto; } }
278
326
  .wf-diagram { display:block; margin-top:8px; }
279
327
  .wf-step { display:flex; align-items:center; }
280
328
  .wf-node { display:flex; align-items:center; gap:8px; padding:11px 15px; border:1px solid var(--line); border-radius:var(--radius); background:var(--surface); cursor:pointer; transition:border-color .18s,transform .15s,box-shadow .15s; user-select:none; }
@@ -373,7 +421,7 @@ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-siz
373
421
  .chat-fab.is-open { box-shadow:inset 0 0 0 2px var(--signal); }
374
422
  .chat { position:fixed; right:20px; bottom:88px; z-index:18; width:min(420px,94vw); height:min(600px,78vh); background:var(--surface); border:1px solid var(--line); border-radius:calc(var(--radius) + 2px); box-shadow:var(--shadow); display:flex; flex-direction:column; overflow:hidden; transform-origin:bottom right; transition:opacity .16s,transform .16s; }
375
423
  .chat[aria-hidden="true"] { opacity:0; transform:scale(.96) translateY(8px); pointer-events:none; }
376
- .chat-head { display:flex; align-items:center; gap:10px; padding:14px 16px; border-bottom:1px solid var(--line); background:linear-gradient(180deg,color-mix(in srgb,var(--surface-2) 75%,transparent),transparent); }
424
+ .chat-head { display:flex; align-items:center; gap:10px; padding:14px 16px; border-bottom:1px solid var(--line); background:color-mix(in srgb,var(--surface-2) 40%,transparent); }
377
425
  .chat-head-text { display:flex; flex-direction:column; gap:1px; min-width:0; }
378
426
  .chat-title { font-weight:700; font-size:15px; }
379
427
  .chat-sub { font-size:10px; color:var(--faint); text-transform:uppercase; letter-spacing:.04em; }
@@ -548,8 +596,10 @@ body.booting .ring-svg circle:last-of-type { transform-origin:center; animation:
548
596
  .kpi.has-accent { border-left-width:3px; border-left-style:solid; }
549
597
 
550
598
  /* Settings tab */
551
- .settings-wrap { max-width:720px; margin:0 auto; padding:20px 22px; }
552
- .settings-card { background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); padding:18px; display:flex; flex-direction:column; gap:16px; margin:14px 0; }
599
+ .settings-wrap { max-width:1120px; margin:0 auto; padding:20px 22px; }
600
+ .settings-groups { display:grid; grid-template-columns:repeat(auto-fit,minmax(320px,1fr)); gap:16px; margin:14px 0; }
601
+ .settings-card { background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); padding:18px; display:flex; flex-direction:column; gap:16px; margin:0; }
602
+ .settings-card-title { margin:0; font-size:12px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); }
553
603
  .settings-field { display:flex; flex-direction:column; gap:6px; }
554
604
  .settings-field-label { font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); }
555
605
  .settings-select { font-family:inherit; font-size:14px; padding:9px 11px; border:1px solid var(--line); border-radius:9px; background:var(--surface-2); color:var(--ink); cursor:pointer; }
@@ -575,7 +625,9 @@ body.booting .ring-svg circle:last-of-type { transform-origin:center; animation:
575
625
  /* ---- Customize (Settings → dashboards/skills/agents) — reuses .card/.chat-ta/.btn styling so it
576
626
  reads as part of the same system, not a bolted-on sub-app. ---- */
577
627
  .cz-wrap { margin-top:28px; }
578
- .cz-block { background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); padding:14px 16px; margin-top:12px; }
628
+ #czRoot { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:12px; margin-top:12px; }
629
+ #czRoot.has-open { grid-template-columns:1fr; } /* one block's form open — stack instead of leaving empty cells beside the other two */
630
+ .cz-block { background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); padding:14px 16px; margin-top:0; }
579
631
  .cz-head { display:flex; align-items:center; justify-content:space-between; gap:12px; }
580
632
  .cz-head h3 { margin:0; font-size:13.5px; font-weight:700; }
581
633
  .cz-add { font-size:12.5px; padding:6px 13px; }
@@ -790,7 +842,7 @@ body.booting .wf-step2 { opacity:0; animation:rise .4s cubic-bezier(.2,.8,.2,1)
790
842
  /* Obsidian Ops fonts: JetBrains Mono (mono + display) + IBM Plex Sans (body) */
791
843
  :root[data-design="obsidian"] { --sans:'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif; --mono:'JetBrains Mono', ui-monospace, monospace; --display:'JetBrains Mono', ui-monospace, monospace; }
792
844
 
793
- /* --- Neon Command: glassmorphism, aurora violet + cyan, control-room --- */
845
+ /* --- Neon Command: violet + cyan control-room, flat surfaces (no gradients/aurora glow) --- */
794
846
  :root[data-design="neon-command"] {
795
847
  --bg:#0d0b1a; --surface:#171233; --surface-2:#1f1940; --line:#2e2857;
796
848
  --ink:#eae6ff; --muted:#a99fd6; --faint:#6f689e;
@@ -799,13 +851,7 @@ body.booting .wf-step2 { opacity:0; animation:rise .4s cubic-bezier(.2,.8,.2,1)
799
851
  --radius:16px; --shadow:0 18px 50px rgba(0,0,0,.55);
800
852
  --sans:'Sora', system-ui, sans-serif; --display:'Space Grotesk', system-ui, sans-serif;
801
853
  }
802
- :root[data-design="neon-command"] body {
803
- background:
804
- radial-gradient(1200px 600px at 15% -5%, rgba(124,77,255,.22), transparent 60%),
805
- radial-gradient(1000px 520px at 100% 8%, rgba(0,229,255,.14), transparent 55%),
806
- var(--bg);
807
- background-attachment:fixed;
808
- }
854
+ :root[data-design="neon-command"] body { background:var(--bg); }
809
855
  :root[data-design="neon-command"] .kpi,
810
856
  :root[data-design="neon-command"] .ocard,
811
857
  :root[data-design="neon-command"] .card,
@@ -814,14 +860,13 @@ body.booting .wf-step2 { opacity:0; animation:rise .4s cubic-bezier(.2,.8,.2,1)
814
860
  :root[data-design="neon-command"] .settings-readonly,
815
861
  :root[data-design="neon-command"] .wf-pop,
816
862
  :root[data-design="neon-command"] .approval {
817
- background:linear-gradient(160deg, color-mix(in srgb,var(--surface) 80%,transparent), color-mix(in srgb,var(--surface-2) 50%,transparent));
863
+ background:var(--surface);
818
864
  border-color:color-mix(in srgb,var(--signal) 22%,var(--line));
819
- backdrop-filter:blur(10px);
820
865
  }
821
- :root[data-design="neon-command"] .topbar { background:color-mix(in srgb,var(--bg) 68%,transparent); border-bottom-color:color-mix(in srgb,var(--signal) 22%,var(--line)); }
866
+ :root[data-design="neon-command"] .topbar { background:color-mix(in srgb,var(--bg) 92%,transparent); border-bottom-color:color-mix(in srgb,var(--signal) 22%,var(--line)); }
822
867
  :root[data-design="neon-command"] .btn.primary,
823
- :root[data-design="neon-command"] .run-btn { background:linear-gradient(120deg,var(--signal),var(--cool)); color:#fff; border-color:transparent; box-shadow:0 6px 20px color-mix(in srgb,var(--signal) 45%,transparent); }
824
- :root[data-design="neon-command"] .brand-name { background:linear-gradient(120deg,var(--signal),var(--cool)); -webkit-background-clip:text; background-clip:text; color:transparent; }
868
+ :root[data-design="neon-command"] .run-btn { background:var(--signal); color:#fff; border-color:transparent; }
869
+ :root[data-design="neon-command"] .brand-name { color:var(--signal); }
825
870
  :root[data-design="neon-command"] .tab.is-active { background:color-mix(in srgb,var(--signal) 20%,transparent); box-shadow:inset 0 -2px 0 var(--cool); }
826
871
  :root[data-design="neon-command"] .wf-circle { backdrop-filter:blur(6px); }
827
872
  :root[data-design="neon-command"][data-theme="light"] {
@@ -13,6 +13,7 @@ const { startRun } = require('./runner');
13
13
  const { runSummarize } = require('./summarize');
14
14
  const orchestrator = require('./orchestrator');
15
15
  const agentsRegistry = require('../lib/agents-registry');
16
+ const files = require('./files');
16
17
 
17
18
  const PORT = process.env.SPECTOFLOW_PORT ? Number(process.env.SPECTOFLOW_PORT) : 4319;
18
19
  const PUBLIC = path.join(__dirname, 'public');
@@ -71,26 +72,9 @@ function writeConfig(patch){
71
72
  fs.writeFileSync(cp, JSON.stringify(cfg, null, 2) + '\n');
72
73
  return cfg;
73
74
  }
74
- // Next free T-### id across every plan file (absolute paths from store.readPlans).
75
- function nextTaskId(){
76
- let max = 0;
77
- for (const pl of store.readPlans(ROOT)) {
78
- try { const t = fs.readFileSync(pl.file, 'utf8'); const re = /\bT-(\d+)/g; let m; while ((m = re.exec(t))) max = Math.max(max, Number(m[1])); } catch {}
79
- }
80
- return 'T-' + String(max + 1).padStart(3, '0');
81
- }
82
75
  // Promote an attention item into a real checkbox task under an `## Attention` phase.
83
76
  function promoteAttention(item){
84
- const plans = store.readPlans(ROOT);
85
- let file = plans[0] && plans[0].file;
86
- if (!file) { file = path.join(ROOT, 'plans', 'inbox.md'); fs.mkdirSync(path.dirname(file), { recursive: true }); if (!fs.existsSync(file)) fs.writeFileSync(file, '# Inbox\n'); }
87
- const id = nextTaskId();
88
- let text = fs.readFileSync(file, 'utf8');
89
- const line = `- [ ] ${id} ${String(item.text).replace(/\s+/g, ' ').trim()} @user ~standard`;
90
- if (/^##\s+Attention\s*$/m.test(text)) text = text.replace(/^(##\s+Attention\s*)$/m, `$1\n${line}`);
91
- else { if (!text.endsWith('\n')) text += '\n'; text += `\n## Attention\n${line}\n`; }
92
- fs.writeFileSync(file, text);
93
- return { id, file };
77
+ return store.addTask(ROOT, { phase: 'Attention', title: item.text, owner: 'user' });
94
78
  }
95
79
 
96
80
  function watch(dir){ try{ fs.watch(dir,{recursive:false},()=>emit({type:'change'})); }catch(_){} }
@@ -131,12 +115,39 @@ const server = http.createServer(async (req,res)=>{
131
115
  return sendJSON(res, 200, { content: fs.readFileSync(real, 'utf8') });
132
116
  }
133
117
 
118
+ // ---- File Explorer: browse/read/write/create anywhere under the project root ----
119
+ if (p === '/api/files/tree' && req.method === 'GET') return sendJSON(res, 200, { tree: files.tree(ROOT) });
120
+ if (p === '/api/files/read' && req.method === 'GET') {
121
+ const rel = new URL(req.url, 'http://x').searchParams.get('path') || '';
122
+ const r = files.readFile(ROOT, rel);
123
+ return sendJSON(res, r.error ? 400 : 200, r);
124
+ }
125
+ if (p === '/api/files/write' && req.method === 'POST') {
126
+ const { path: rel, content } = await body(req);
127
+ const r = files.writeFile(ROOT, rel, content);
128
+ if (r.error) return sendJSON(res, 400, r);
129
+ emit({ type: 'change' }); return sendJSON(res, 200, r);
130
+ }
131
+ if (p === '/api/files/mkdir' && req.method === 'POST') {
132
+ const { path: rel } = await body(req);
133
+ const r = files.mkdir(ROOT, rel);
134
+ if (r.error) return sendJSON(res, 400, r);
135
+ emit({ type: 'change' }); return sendJSON(res, 200, r);
136
+ }
137
+
134
138
  if(p==='/api/events'){
135
139
  res.writeHead(200,{'Content-Type':'text/event-stream','Cache-Control':'no-cache',Connection:'keep-alive'});
136
140
  res.write('data: '+JSON.stringify({type:'hello'})+'\n\n');
137
141
  clients.add(res); req.on('close',()=>clients.delete(res)); return;
138
142
  }
139
143
 
144
+ // ---- manual task creation: add a checkbox task straight to a plan, no agent involved ----
145
+ if(p==='/api/task'&&req.method==='POST'){
146
+ const { title, phase, file, owner, level } = await body(req);
147
+ if(!title||!String(title).trim()) return sendJSON(res,400,{error:'A title is required.'});
148
+ const t = store.addTask(ROOT,{ title:String(title).trim(), phase, file, owner, level });
149
+ emit({type:'change'}); return sendJSON(res,200,{task:t});
150
+ }
140
151
  if(p.startsWith('/api/task/')&&req.method==='PATCH'){
141
152
  const id=decodeURIComponent(p.split('/')[3]||''); const patch=await body(req);
142
153
  const file=findPlanFileForTask(id); if(!file) return sendJSON(res,404,{error:`Task ${id} not found.`});
@@ -1,8 +1,10 @@
1
1
  'use strict';
2
2
  /*
3
- * Chat "Summarize" — condenses the recent group-chat log into one digest message, via the same
3
+ * Chat "Summarize" — condenses the group-chat log down to one digest message, via the same
4
4
  * configured agent runner (kept separate from runner.js since it captures the child's raw stdout as
5
5
  * one summary, not sentinel-parsed lines; unit-testable without an HTTP server, same as runner.js).
6
+ * Deliberately REPLACES runtime.messages rather than appending: a digest that leaves the full history
7
+ * sitting right below it doesn't condense anything — it just adds noise on top of noise.
6
8
  */
7
9
  const { spawn } = require('child_process');
8
10
  const store = require('../lib/store');
@@ -16,8 +18,9 @@ function formatLog(messages, limit = DEFAULT_LIMIT) {
16
18
  }
17
19
 
18
20
  // Summarizes runtime.messages (excluding prior summaries, so re-summarizing doesn't compound) into
19
- // one new message of kind 'summary'. Returns { child } on success or { error } — mirrors startRun's
20
- // shape, without the sentinel-line streaming runner.js does (this is a one-shot digest, not a task run).
21
+ // one new message of kind 'summary', REPLACING the log. Returns { child } on success or { error } —
22
+ // mirrors startRun's shape, without the sentinel-line streaming runner.js does (this is a one-shot
23
+ // digest, not a task run).
21
24
  function runSummarize(root, { agent } = {}, emit) {
22
25
  const cfg = store.readConfig(root);
23
26
  const which = agent || cfg.agent || 'claude';
@@ -27,9 +30,11 @@ function runSummarize(root, { agent } = {}, emit) {
27
30
  const rt = store.readRuntime(root);
28
31
  const messages = (rt.messages || []).filter((m) => m.kind !== 'summary');
29
32
  if (!messages.length) return { error: 'Nothing to summarize yet.' };
33
+ const summarizedIds = new Set(messages.map((m) => m.id));
30
34
 
31
35
  const prompt = 'Summarize this project\'s recent activity log in 3-6 concise bullet points — '
32
- + 'what was built, decided, or is blocked. Reply with the summary only, no preamble or sentinel lines.'
36
+ + 'what was built, decided, or is blocked. Reply with the summary text only: no preamble, no '
37
+ + 'sentinel lines, and do not read, search or edit any files — answer using only the log below.'
33
38
  + '\n\n' + formatLog(messages);
34
39
 
35
40
  const parts = cmdStr.split(/\s+/).filter(Boolean);
@@ -43,8 +48,22 @@ function runSummarize(root, { agent } = {}, emit) {
43
48
  child.stderr && child.stderr.on('data', (d) => { out += d.toString(); });
44
49
  child.on('close', (code) => {
45
50
  const text = out.trim() || (code === 0 ? '(no output)' : `summarize failed (exit ${code})`);
46
- const m = store.appendMessage(root, { role: which, kind: 'summary', text, agent: which });
47
- if (emit) { emit({ type: 'message', message: m }); emit({ type: 'change' }); }
51
+ const summary = {
52
+ id: 'm' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
53
+ at: new Date().toISOString(),
54
+ kind: 'summary',
55
+ role: which,
56
+ agent: which,
57
+ text,
58
+ };
59
+ // Re-read fresh (not the earlier snapshot): drop exactly the messages that went INTO this
60
+ // summary, keep anything that arrived while the agent was running — condensing the log must
61
+ // never silently lose a message someone sent in the meantime.
62
+ const fresh = store.readRuntime(root);
63
+ fresh.messages = (fresh.messages || []).filter((m) => !summarizedIds.has(m.id));
64
+ fresh.messages.push(summary);
65
+ store.writeRuntime(root, fresh);
66
+ if (emit) { emit({ type: 'message', message: summary }); emit({ type: 'change' }); }
48
67
  });
49
68
  return { child };
50
69
  }
@@ -139,6 +139,48 @@ function addTaskComment(projectRoot, file, id, text, author) {
139
139
  return false;
140
140
  }
141
141
 
142
+ // Next free T-### id across every plan file — scans for the highest existing T-<n> and returns n+1.
143
+ function nextTaskId(projectRoot) {
144
+ let max = 0;
145
+ for (const pl of readPlans(projectRoot)) {
146
+ for (const ph of pl.phases) {
147
+ for (const t of ph.tasks) {
148
+ const m = /^T-(\d+)$/.exec(t.id);
149
+ if (m) max = Math.max(max, Number(m[1]));
150
+ }
151
+ }
152
+ }
153
+ return 'T-' + String(max + 1).padStart(3, '0');
154
+ }
155
+
156
+ // Add a new checkbox task, generating its id. `file` (a basename from readPlans, e.g. "0001-x.md")
157
+ // picks which plan; when omitted, the first existing plan file is used, or `plans/inbox.md` is
158
+ // created if the project has no plans yet. `phase` is the "## " heading the task is filed under —
159
+ // created at the end of the file if it doesn't already exist. Returns { id, file }.
160
+ function addTask(projectRoot, { file, phase, title, owner, level, status } = {}) {
161
+ const dirName = resolvePlansDir(projectRoot, readConfig(projectRoot));
162
+ const dir = path.join(projectRoot, dirName);
163
+ const plans = readPlans(projectRoot);
164
+ let fp;
165
+ if (file && plans.some((pl) => pl.file === file)) fp = path.join(dir, file);
166
+ else if (plans[0]) fp = path.join(dir, plans[0].file);
167
+ else { fs.mkdirSync(dir, { recursive: true }); fp = path.join(dir, 'inbox.md'); if (!fs.existsSync(fp)) writeAtomic(fp, '# Inbox\n'); }
168
+
169
+ const id = nextTaskId(projectRoot);
170
+ const phaseName = (phase && String(phase).trim()) || 'Backlog';
171
+ const line = buildTaskLine({
172
+ indent: '', id, title: String(title).replace(/\s+/g, ' ').trim(),
173
+ owner: (owner && String(owner).trim()) || null, level: level || 'standard', status: status || 'todo',
174
+ });
175
+
176
+ let text = fs.readFileSync(fp, 'utf8');
177
+ const headingRe = new RegExp(`^##\\s+${phaseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'm');
178
+ if (headingRe.test(text)) text = text.replace(headingRe, (m) => `${m}\n${line}`);
179
+ else { if (!text.endsWith('\n')) text += '\n'; text += `\n## ${phaseName}\n${line}\n`; }
180
+ writeAtomic(fp, text);
181
+ return { id, file: path.basename(fp) };
182
+ }
183
+
142
184
  function writeAtomic(fp, content) {
143
185
  const tmp = fp + '.tmp';
144
186
  fs.writeFileSync(tmp, content, 'utf8');
@@ -331,6 +373,7 @@ function readSkills(projectRoot) {
331
373
 
332
374
  module.exports = {
333
375
  parseTaskLine, buildTaskLine, parsePlan, readPlans, readSpecs, updateTaskLine, addTaskComment,
376
+ nextTaskId, addTask,
334
377
  readRuntime, writeRuntime, parseAgentLine, appendMessage, readConfig, readWorkflow, readProject,
335
378
  readAgents, readSkills, readCustomDashboards, recordSnapshot, resolvePlansDir, resolveSpecsDir,
336
379
  };