spectoflow 0.31.1 → 0.33.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
@@ -24,6 +24,17 @@ An **agent-agnostic** spec-driven development framework with a **real-time local
24
24
  You speak in plain language; the framework classifies your intent and runs the right workflow. No
25
25
  ceremonial command to start.
26
26
 
27
+ ## Quick start
28
+
29
+ ```bash
30
+ npm install -g spectoflow
31
+ cd my-project && spectoflow init # fits the workflow to your project, detects your agents
32
+ spectoflow dashboard # → http://localhost:4319
33
+ ```
34
+
35
+ Then just tell your coding agent what you want to build. Optional, once per machine:
36
+ `spectoflow brain setup` so your agents remember you across projects.
37
+
27
38
  **Works with whichever coding agent you have.** `init` auto-detects what's installed; the dashboard's
28
39
  topbar always shows the **active agent**, front and center, with a switcher — pick another and it's
29
40
  verified as genuinely installed before activating (a red **"No agent found"** if none is), never
@@ -296,8 +307,9 @@ off by default):
296
307
  - **Info** — a project-at-a-glance summary.
297
308
  - **Documentation** — the live supported-agents table (your own install status + links) plus the CLI
298
309
  command reference.
299
- - **Second brain** — what spectoflow has learned about you, shared by all your projects: read, add, fix,
300
- confirm (see [Second brain](#second-brain)). Local only.
310
+ - **Second brain** — two sections: **You** (what spectoflow has learned about you, shared by all your projects,
311
+ local only) and **This project** (the project memory, committed). Read, add, fix, confirm, move a fact to the
312
+ other one (see [Second brain](#second-brain)).
301
313
  - **Personalize** — autonomy mode, language, design, the active agent, **navigation tabs**, **slash
302
314
  commands**, and **Extend spectoflow** (see *Customize* below).
303
315
 
@@ -401,11 +413,37 @@ agents then read and grow your second brain through that server: nothing is copi
401
413
  `::spectoflow learn category=… msg=…` line instead. Those always wait in *To confirm*, whatever the setting:
402
414
  that output also carries command output and file contents, so a line hidden in a repository can't slip
403
415
  in unseen.
404
- - **Private:** the Second brain tab and its API answer this machine only — not the online dashboard
416
+ - **Private:** the *You* section and its API answer this machine only — not the online dashboard
405
417
  (`server/` refuses them for everyone, the project owner included), and not other machines or websites.
406
418
  A run started from either of those can't write into it, and a learned fact never goes into a project's chat
407
419
  log.
408
420
 
421
+ ### Project memory
422
+
423
+ Some facts aren't about you but about **one project**: its conventions, what breaks, its vocabulary, its
424
+ constraints. Those go in `.spectoflow/memory.md`, **committed with the code**, so your team and their agents
425
+ share them — and your other projects don't inherit them.
426
+
427
+ ```markdown
428
+ # Project memory
429
+
430
+ ## Conventions
431
+ - Tests run with `npm test -- --runInBand` (shared DB fixtures)
432
+
433
+ ## Glossary
434
+ - "Ticket" means a customer support case, never a Jira issue
435
+ ```
436
+
437
+ - **Four categories:** Conventions, Pitfalls, Glossary, Constraints.
438
+ - **The agent reads and writes the file directly** — no setup. About you → second brain; about the project →
439
+ this file. Never anything personal in it.
440
+ - **Facts the agent adds land directly by default.** To review them first, untick *Add what the agent learns
441
+ directly* in the *This project* section: that sets `memoryAutoAdd: false` in `.spectoflow/config.json`, for
442
+ the whole team.
443
+ - **Wrong memory?** *Move to…* on any fact sends it to the other one, in the category you pick (local dashboard
444
+ only).
445
+ - Created on the first fact; `spectoflow update` never touches it.
446
+
409
447
  ## A workflow that fits the project
410
448
 
411
449
  The workflow (`.spectoflow/workflow.md`) isn't one-size-fits-all anymore.
package/bin/spectoflow.js CHANGED
@@ -517,6 +517,11 @@ async function startDashboard() {
517
517
  const boardUrl = (p) => (entry ? `http://localhost:${p}/p/${entry.id}/board` : `http://localhost:${p}/`);
518
518
  const info = workspace.readLock();
519
519
  if (info && info.port && await probeDashboard(info.port)) {
520
+ // A hub started by an older spectoflow keeps running that old code: replace it (D77).
521
+ if (info.version !== VERSION) {
522
+ console.log(`${c.cy('↻')} the running hub is ${info.version ? 'spectoflow v' + info.version : 'an older spectoflow'} — restarting it on v${VERSION}`);
523
+ return restartDashboard();
524
+ }
520
525
  console.log(`${c.g('●')} hub already running → ${c.bold(boardUrl(info.port))}`);
521
526
  await printOnlineLine(info.port, true);
522
527
  return printDashboardCommands();
package/lib/adapters.js CHANGED
@@ -30,6 +30,11 @@ learn one (a stated preference, a correction of how you work, their role), recor
30
30
  \`::spectoflow learn category=<profile|preferences|workflow|avoid> msg=<fact>\` if that tool is unavailable. Never
31
31
  secrets or sensitive data.
32
32
 
33
+ **Project memory.** Facts about *this project* (conventions, pitfalls, domain vocabulary, constraints) live in
34
+ \`.spectoflow/memory.md\`, committed — read it at session start and apply it. When you learn one, add a line under
35
+ its section (or under \`## To confirm\` as \`- [category] fact\` when \`.spectoflow/config.json\` → \`memoryAutoAdd\` is
36
+ \`false\`). About the user → second brain; about the project → this file. Never anything personal there.
37
+
33
38
  **Workflow.** If a request needs a step that is disabled in \`.spectoflow/workflow.md\` (e.g. writing code while
34
39
  *Develop* is off): enable it yourself and say so when \`.spectoflow/config.json\` → \`workflowAutoEnable\` is \`true\`;
35
40
  otherwise ask first. Never disable a step without the user.
@@ -53,6 +58,11 @@ learn one (a stated preference, a correction of how you work, their role), recor
53
58
  \`::spectoflow learn category=<profile|preferences|workflow|avoid> msg=<fact>\` if that tool is unavailable. Never
54
59
  secrets or sensitive data.
55
60
 
61
+ **Project memory.** Facts about *this project* (conventions, pitfalls, domain vocabulary, constraints) live in
62
+ \`.spectoflow/memory.md\`, committed — read it at session start and apply it. When you learn one, add a line under
63
+ its section (or under \`## To confirm\` as \`- [category] fact\` when \`.spectoflow/config.json\` → \`memoryAutoAdd\` is
64
+ \`false\`). About the user → second brain; about the project → this file. Never anything personal there.
65
+
56
66
  **Workflow.** If a request needs a step that is disabled in \`.spectoflow/workflow.md\` (e.g. writing code while
57
67
  *Develop* is off): enable it yourself and say so when \`.spectoflow/config.json\` → \`workflowAutoEnable\` is \`true\`;
58
68
  otherwise ask first. Never disable a step without the user.
@@ -73,6 +83,11 @@ learn one (a stated preference, a correction of how you work, their role), recor
73
83
  \`::spectoflow learn category=<profile|preferences|workflow|avoid> msg=<fact>\` if that tool is unavailable. Never
74
84
  secrets or sensitive data.
75
85
 
86
+ **Project memory.** Facts about *this project* (conventions, pitfalls, domain vocabulary, constraints) live in
87
+ \`.spectoflow/memory.md\`, committed — read it at session start and apply it. When you learn one, add a line under
88
+ its section (or under \`## To confirm\` as \`- [category] fact\` when \`.spectoflow/config.json\` → \`memoryAutoAdd\` is
89
+ \`false\`). About the user → second brain; about the project → this file. Never anything personal there.
90
+
76
91
  **Workflow.** If a request needs a step that is disabled in \`.spectoflow/workflow.md\` (e.g. writing code while
77
92
  *Develop* is off): enable it yourself and say so when \`.spectoflow/config.json\` → \`workflowAutoEnable\` is \`true\`;
78
93
  otherwise ask first. Never disable a step without the user.
package/lib/brain.js CHANGED
@@ -2,256 +2,25 @@
2
2
  /*
3
3
  * The second brain — what spectoflow has learned about the user, shared by all their projects.
4
4
  * One markdown file, ~/.spectoflow/brain.md (never inside a project, never copied), read and written
5
- * by the dashboard page, the `spectoflow mcp` server and the `::spectoflow learn` run line. Zero
6
- * dependency.
7
- *
8
- * # Second brain
9
- * ## Profile
10
- * - Scrum master and full-stack developer <!-- id:b7k2 by:agent at:2026-09-16 -->
11
- * ## To confirm
12
- * - [preferences] Prefers pnpm over npm <!-- id:b7k6 by:agent at:2026-09-16 -->
13
- *
14
- * The file is the user's too: every line spectoflow doesn't change is written back byte for byte —
15
- * titles, blank lines, comments, code blocks, unknown sections and their order, hand-written entries.
16
- * Writers in different processes (hub, MCP servers, runs) take a lock file, then write-then-rename.
5
+ * by the dashboard page, the `spectoflow mcp` server and the `::spectoflow learn` run line. Facts about
6
+ * one project go to the project memory instead (lib/project-memory.js). The file format, its fidelity
7
+ * and its lock live in lib/memory-store.js.
17
8
  */
18
- const fs = require('fs');
19
9
  const path = require('path');
20
- const crypto = require('crypto');
21
10
  const globalConfig = require('./global-config');
22
-
23
- const CATEGORIES = ['profile', 'preferences', 'workflow', 'avoid'];
24
- const HEADINGS = { profile: 'Profile', preferences: 'Preferences', workflow: 'Working style', avoid: 'Avoid' };
25
- const PENDING = 'To confirm';
26
- const MAX_TEXT = 500;
27
- const FALLBACK_CATEGORY = 'preferences';
11
+ const { createMemoryStore } = require('./memory-store');
28
12
 
29
13
  function brainPath() { return path.join(globalConfig.homeDir(), 'brain.md'); }
30
14
 
31
- const normCategory = (c) => (CATEGORIES.includes(String(c || '').trim().toLowerCase()) ? String(c).trim().toLowerCase() : FALLBACK_CATEGORY);
32
- // One line, no HTML-comment delimiters (they would break the metadata), capped.
33
- function cleanText(t) {
34
- return String(t == null ? '' : t).replace(/<!--|-->/g, '').replace(/\s+/g, ' ').trim().slice(0, MAX_TEXT);
35
- }
36
- const newId = () => 'b' + Date.now().toString(36) + crypto.randomBytes(3).toString('hex');
37
- // A hand-written line has no id: derive a stable one. `n` tells identical lines of a category apart.
38
- const derivedId = (category, text, n) => 'h' + crypto.createHash('sha1').update(`${category}\n${text}\n${n}`).digest('hex').slice(0, 10);
39
- const today = () => new Date().toISOString().slice(0, 10);
40
- const isBlank = (it) => it.raw !== undefined && !it.raw.trim();
41
-
42
- function headingKind(title) {
43
- const t = title.trim().toLowerCase();
44
- if (t === PENDING.toLowerCase()) return { kind: 'pending' };
45
- const cat = CATEGORIES.find((c) => c === t || HEADINGS[c].toLowerCase() === t);
46
- return cat ? { kind: 'category', id: cat } : { kind: 'unknown' };
47
- }
48
-
49
- const ENTRY_RE = /^-\s+(?:\[([\w-]+)\]\s+)?(.*?)\s*(?:<!--\s*(.*?)\s*-->)?\s*$/;
50
- function parseMeta(s) {
51
- const out = {};
52
- for (const m of String(s || '').matchAll(/(\w+):(\S+)/g)) out[m[1]] = m[2];
53
- return out;
54
- }
55
-
56
- // → { title, blocks: [{ kind: preamble|category|pending|unknown, id?, heading?, items: [...] }] }
57
- // item = { raw } (a line kept verbatim) or an entry { id, category, text, by, at, status, line }.
58
- function parse(text) {
59
- const lines = String(text || '').split(/\r?\n/);
60
- while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
61
- const model = { title: null, eol: /\r\n/.test(String(text || '')) ? '\r\n' : '\n', blocks: [{ kind: 'preamble', items: [] }] };
62
- let block = model.blocks[0], inFence = false;
63
- const seen = new Map();
64
- for (const line of lines) {
65
- if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; block.items.push({ raw: line }); continue; }
66
- if (inFence) { block.items.push({ raw: line }); continue; }
67
- if (model.title === null && model.blocks.length === 1 && block.items.every(isBlank) && /^#\s+/.test(line)) { model.title = line; continue; }
68
- const h = line.match(/^##\s+(.*)$/);
69
- if (h) {
70
- block = { ...headingKind(h[1]), heading: line, items: [] };
71
- model.blocks.push(block);
72
- continue;
73
- }
74
- const m = (block.kind === 'category' || block.kind === 'pending') && line.match(ENTRY_RE);
75
- const meta = m ? parseMeta(m[3]) : null;
76
- // A trailing comment is never part of the fact (a user's own comment stays hidden: the line itself is
77
- // written back verbatim while the entry is untouched; editing the entry replaces the line).
78
- const body = m ? m[2] : '';
79
- if (m && cleanText(body)) {
80
- const pending = block.kind === 'pending';
81
- const category = pending ? normCategory(m[1]) : block.id;
82
- const entryText = cleanText(pending || !m[1] ? body : `[${m[1]}] ${body}`);
83
- let id = meta.id;
84
- if (!id) { const k = `${category}\n${entryText}`; const n = seen.get(k) || 0; seen.set(k, n + 1); id = derivedId(category, entryText, n); }
85
- block.items.push({ id, category, text: entryText, by: meta.by || 'user', at: meta.at || null, status: pending ? 'pending' : 'confirmed', line });
86
- } else {
87
- block.items.push({ raw: line });
88
- }
89
- }
90
- return model;
91
- }
92
-
93
- function entryLine(e) {
94
- const meta = `id:${e.id} by:${e.by}${e.at ? ' at:' + e.at : ''}`;
95
- return `- ${e.status === 'pending' ? `[${e.category}] ` : ''}${e.text} <!-- ${meta} -->`;
96
- }
97
- function serialize(model) {
98
- const out = [model.title || '# Second brain'];
99
- for (const b of model.blocks) {
100
- if (b.kind === 'pending' && !b.items.some((it) => it.raw === undefined) && b.items.every(isBlank)) continue;
101
- if (b.kind !== 'preamble') {
102
- if (out[out.length - 1].trim()) out.push(''); // a section always follows a blank line
103
- out.push(b.heading || `## ${b.kind === 'pending' ? PENDING : HEADINGS[b.id]}`);
104
- }
105
- for (const it of b.items) out.push(it.raw !== undefined ? it.raw : (it.line && !it.dirty ? it.line : entryLine(it)));
106
- }
107
- while (out.length > 1 && !out[out.length - 1].trim()) out.pop();
108
- const eol = model.eol || '\n';
109
- return out.join(eol) + eol;
110
- }
111
-
112
- function emptyModel() {
113
- return { title: null, blocks: [{ kind: 'preamble', items: [] }, ...CATEGORIES.map((id) => ({ kind: 'category', id, heading: null, items: [] }))] };
114
- }
115
- function load(file = brainPath()) {
116
- let text = '';
117
- try { text = fs.readFileSync(file, 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
118
- return text.trim() ? parse(text) : emptyModel();
119
- }
120
- function save(model, file = brainPath()) {
121
- fs.mkdirSync(path.dirname(file), { recursive: true });
122
- const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
123
- fs.writeFileSync(tmp, serialize(model));
124
- fs.renameSync(tmp, file);
125
- }
126
-
127
- // Cross-process lock: the hub, any number of `spectoflow mcp` processes and runs may write in the
128
- // same instant. A lock older than 10s is a crashed writer's and is taken over.
129
- const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
130
- function withLock(file, fn) {
131
- fs.mkdirSync(path.dirname(file), { recursive: true });
132
- const lock = `${file}.lock`, deadline = Date.now() + 3000;
133
- for (;;) {
134
- try { fs.writeFileSync(lock, String(process.pid), { flag: 'wx' }); break; }
135
- catch (e) {
136
- if (e.code !== 'EEXIST') throw e;
137
- try { if (Date.now() - fs.statSync(lock).mtimeMs > 10000) { fs.unlinkSync(lock); continue; } } catch (_) {}
138
- if (Date.now() > deadline) throw Object.assign(new Error('The second brain is busy, try again.'), { status: 503 });
139
- sleepSync(15);
140
- }
141
- }
142
- try { return fn(); } finally { try { fs.unlinkSync(lock); } catch (_) {} }
143
- }
144
- // Read, change, save — under the lock. `fn` returns { result, changed }.
145
- function mutate(file, fn) {
146
- return withLock(file, () => {
147
- const model = load(file);
148
- const { result, changed } = fn(model);
149
- if (changed) save(model, file);
150
- return result;
151
- });
152
- }
153
-
154
- const entriesOf = (model) => model.blocks.flatMap((b) => b.items.filter((it) => it.raw === undefined));
155
- function find(model, id) {
156
- for (const b of model.blocks) {
157
- const i = b.items.findIndex((it) => it.raw === undefined && it.id === id);
158
- if (i >= 0) return { block: b, i, entry: b.items[i] };
159
- }
160
- return null;
161
- }
162
- // Append before the block's trailing blank lines, so the spacing before the next heading stays.
163
- function append(block, entry) {
164
- let i = block.items.length;
165
- while (i > 0 && isBlank(block.items[i - 1])) i--;
166
- block.items.splice(i, 0, entry);
167
- }
168
- function blockFor(model, status, category) {
169
- const want = status === 'pending' ? (b) => b.kind === 'pending' : (b) => b.kind === 'category' && b.id === category;
170
- let b = model.blocks.find(want);
171
- if (!b) {
172
- b = status === 'pending' ? { kind: 'pending', heading: null, items: [] } : { kind: 'category', id: category, heading: null, items: [] };
173
- const at = status === 'pending' ? -1 : model.blocks.findIndex((x) => x.kind === 'pending');
174
- if (at < 0) model.blocks.push(b); else model.blocks.splice(at, 0, b);
175
- }
176
- return b;
177
- }
178
- const notFound = () => Object.assign(new Error('Entry not found.'), { status: 404 });
179
- const emptyText = () => Object.assign(new Error('Text is required.'), { status: 400 });
180
-
181
- // { entries (confirmed, in category order), pending, autoAdd }
182
- function read(file = brainPath()) {
183
- const all = entriesOf(load(file));
184
- return {
185
- entries: CATEGORIES.flatMap((c) => all.filter((e) => e.status === 'confirmed' && e.category === c)),
186
- pending: all.filter((e) => e.status === 'pending'),
187
- autoAdd: autoAdd(),
188
- };
189
- }
190
-
191
- // Adds one fact. status 'confirmed' or 'pending'. A case-insensitive duplicate of any entry is not
192
- // added again: → { duplicate: true, entry: <the existing one> }.
193
- function add({ category, text, by = 'user', status = 'confirmed' }, file = brainPath()) {
194
- const t = cleanText(text);
195
- if (!t) throw emptyText();
196
- return mutate(file, (model) => {
197
- const existing = entriesOf(model).find((e) => e.text.toLowerCase() === t.toLowerCase());
198
- if (existing) return { result: { duplicate: true, entry: existing }, changed: false };
199
- const entry = { id: newId(), category: normCategory(category), text: t, by, at: today(), status: status === 'pending' ? 'pending' : 'confirmed' };
200
- append(blockFor(model, entry.status, entry.category), entry);
201
- return { result: { duplicate: false, entry }, changed: true };
202
- });
203
- }
204
-
205
- // What the agent learned through MCP: confirmed right away, or "to confirm", per brain.autoAdd.
206
- function learn({ category, text }, file = brainPath()) {
207
- return add({ category, text, by: 'agent', status: autoAdd() ? 'confirmed' : 'pending' }, file);
208
- }
209
-
210
- function update(id, { text, category } = {}, file = brainPath()) {
211
- const t = text === undefined ? undefined : cleanText(text);
212
- if (text !== undefined && !t) throw emptyText();
213
- return mutate(file, (model) => {
214
- const hit = find(model, id); if (!hit) throw notFound();
215
- const e = hit.entry;
216
- if (t !== undefined) e.text = t;
217
- if (category !== undefined) {
218
- const c = normCategory(category);
219
- if (c !== e.category && e.status === 'confirmed') { hit.block.items.splice(hit.i, 1); append(blockFor(model, 'confirmed', c), e); }
220
- e.category = c;
221
- }
222
- e.dirty = true;
223
- return { result: e, changed: true };
224
- });
225
- }
226
-
227
- function remove(id, file = brainPath()) {
228
- return mutate(file, (model) => {
229
- const hit = find(model, id); if (!hit) throw notFound();
230
- hit.block.items.splice(hit.i, 1);
231
- return { result: { ok: true }, changed: true };
232
- });
233
- }
234
-
235
- function confirm(id, file = brainPath()) {
236
- return mutate(file, (model) => {
237
- const hit = find(model, id); if (!hit) throw notFound();
238
- if (hit.entry.status !== 'pending') return { result: hit.entry, changed: false };
239
- hit.block.items.splice(hit.i, 1);
240
- hit.entry.status = 'confirmed'; hit.entry.dirty = true;
241
- append(blockFor(model, 'confirmed', hit.entry.category), hit.entry);
242
- return { result: hit.entry, changed: true };
243
- });
244
- }
245
-
246
- // Confirmed entries as markdown, grouped by category — what an agent is given.
247
- function renderForAgent(file = brainPath()) {
248
- const { entries } = read(file);
249
- return CATEGORIES.map((c) => {
250
- const items = entries.filter((e) => e.category === c);
251
- return items.length ? `## ${HEADINGS[c]}\n${items.map((e) => `- ${e.text}`).join('\n')}` : '';
252
- }).filter(Boolean).join('\n\n');
253
- }
254
-
255
- function autoAdd() { return globalConfig.get('brain.autoAdd').value !== false; }
256
-
257
- module.exports = { CATEGORIES, HEADINGS, MAX_TEXT, brainPath, parse, serialize, read, add, learn, update, remove, confirm, renderForAgent, autoAdd };
15
+ const store = createMemoryStore({
16
+ name: 'second brain',
17
+ title: 'Second brain',
18
+ categories: ['profile', 'preferences', 'workflow', 'avoid'],
19
+ headings: { profile: 'Profile', preferences: 'Preferences', workflow: 'Working style', avoid: 'Avoid' },
20
+ fallback: 'preferences',
21
+ idPrefix: 'b',
22
+ defaultFile: brainPath,
23
+ autoAdd: () => globalConfig.get('brain.autoAdd').value !== false,
24
+ });
25
+
26
+ module.exports = { ...store, brainPath };
@@ -68,6 +68,7 @@ function createHandlers(root) {
68
68
  // A process restart loses any in-flight orchestration; clear a stale 'running'/'awaiting_approval'
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
+ try { require('./runner').reconcileRunsOnBoot(root); } catch (_) {}
71
72
  }
72
73
  return {
73
74
  handleApi,
@@ -278,7 +278,7 @@ function serveStatic(reqPath, req, res, root) {
278
278
  const PROJECT_PREFIX = /^\/p\/([0-9a-f]{6})(\/.*)?$/;
279
279
 
280
280
  const LOCK = workspace.lockPath();
281
- function writeLock(){ try{ fs.mkdirSync(path.dirname(LOCK),{recursive:true}); fs.writeFileSync(LOCK, JSON.stringify({ pid:process.pid, port:PORT, url:`http://localhost:${PORT}`, startedAt:new Date().toISOString() })+'\n'); }catch{} }
281
+ function writeLock(){ try{ fs.mkdirSync(path.dirname(LOCK),{recursive:true}); fs.writeFileSync(LOCK, JSON.stringify({ pid:process.pid, port:PORT, url:`http://localhost:${PORT}`, version:VERSION, startedAt:new Date().toISOString() })+'\n'); }catch{} }
282
282
  function clearLock(){ try{ const l=JSON.parse(fs.readFileSync(LOCK,'utf8')); if(l.pid===process.pid) fs.unlinkSync(LOCK); }catch{} }
283
283
  process.on('exit', clearLock);
284
284
  ['SIGINT','SIGTERM'].forEach((s)=> process.on(s, ()=>{ if (connector) connector.stop(); clearLock(); process.exit(0); }));
@@ -10,7 +10,7 @@
10
10
  const { spawn } = require('child_process');
11
11
  const store = require('../store');
12
12
  const files = require('./files');
13
- const { resolveRunnerCommand } = require('./runner');
13
+ const { resolveRunnerCommand, trackChild } = require('./runner');
14
14
  const runnerTrust = require('../runner-trust');
15
15
  const { formatLog } = require('./summarize'); // reused as-is rather than reimplemented — see report
16
16
 
@@ -106,6 +106,7 @@ function runMeetingGenerate(root, { agent, date } = {}, emit) {
106
106
  let out = '';
107
107
  child.stdout && child.stdout.on('data', (d) => { out += d.toString(); });
108
108
  child.stderr && child.stderr.on('data', (d) => { out += d.toString(); });
109
+ trackChild(root, child);
109
110
  child.on('close', (code) => {
110
111
  const text = out.trim() || (code === 0 ? '(no output)' : `meeting generate failed (exit ${code})`);
111
112
  files.writeFile(root, meetingPath(day), text);
@@ -11,13 +11,14 @@ const fs = require('fs');
11
11
  const path = require('path');
12
12
  const store = require('../store');
13
13
  const files = require('./files');
14
- const { startRun } = require('./runner');
14
+ const { startRun, stopRuns } = require('./runner');
15
15
  const { runSummarize } = require('./summarize');
16
16
  const { runMeetingGenerate, todayLocal } = require('./meeting');
17
17
  const orchestrator = require('./orchestrator');
18
18
  const adapters = require('../adapters');
19
19
  const detect = require('../detect');
20
20
  const brain = require('../brain');
21
+ const projectMemory = require('../project-memory');
21
22
  const brainSetup = require('../brain-setup');
22
23
  const globalConfig = require('../global-config');
23
24
  const workflowDetect = require('../workflow-detect');
@@ -74,6 +75,7 @@ function writeConfig(root, patch, detectOpts) {
74
75
  if (typeof patch.activeTab === 'string' && patch.activeTab.trim()) cfg.activeTab = patch.activeTab.trim();
75
76
  if (typeof patch.chatOpen === 'boolean') cfg.chatOpen = patch.chatOpen;
76
77
  if (typeof patch.workflowAutoEnable === 'boolean') cfg.workflowAutoEnable = patch.workflowAutoEnable;
78
+ if (typeof patch.memoryAutoAdd === 'boolean') cfg.memoryAutoAdd = patch.memoryAutoAdd;
77
79
  // kanbanColumns: reject the whole patch (leave the current value untouched) rather than silently
78
80
  // filtering out bad entries — an invalid/unknown status id here means the client sent something it
79
81
  // shouldn't have, and a real product-safety rule (never persist zero visible columns) applies too.
@@ -150,8 +152,12 @@ const changed = (ctx, result) => { ctx.emit({ type: 'change' }); return result;
150
152
  // watches ~/.spectoflow/brain.md and tells local tabs only, whoever wrote it (page, MCP, run line).
151
153
  function brainOp(ctx, fn) {
152
154
  if (ctx && ctx.remote) throw new OpError(404, 'Unknown operation.');
153
- try { return fn(); } catch (e) { if (e.status && !(e instanceof OpError)) throw new OpError(e.status, e.message); throw e; }
155
+ return storeOp(fn);
154
156
  }
157
+ const storeOp = (fn) => { try { return fn(); } catch (e) { if (e.status && !(e instanceof OpError)) throw new OpError(e.status, e.message); throw e; } };
158
+ // The project memory is the project's (.spectoflow/memory.md, committed): gated like any project read/write,
159
+ // online included. Only `memory.move` touches the personal brain, so only it is local.
160
+ const memOp = (root, ctx, fn) => { const r = storeOp(() => fn(projectMemory.fileFor(root))); ctx.emit({ type: 'change' }); return r; };
155
161
 
156
162
  const ops = {
157
163
  'project.read': async (root) => {
@@ -165,6 +171,7 @@ const ops = {
165
171
  // for which .spectoflow/meetings/<date>.md "today" resolves to.
166
172
  p.todayDate = todayLocal();
167
173
  p.untrustedRunners = runnerTrust.untrusted(root, p.config);
174
+ p.kitVersion = PKG_VERSION;
168
175
  return p;
169
176
  },
170
177
  'agentfile.read': async (root, { path: rel }) => readAgentFile(root, rel),
@@ -234,6 +241,14 @@ const ops = {
234
241
  if (r.error) bad(r.error);
235
242
  return { runId: r.runId };
236
243
  },
244
+ // Bring the project's framework files up to the installed spectoflow (same as `spectoflow update`). Local
245
+ // only: it writes framework files on the owner's machine (D77).
246
+ 'project.update': async (root, _args, ctx) => {
247
+ if (ctx && ctx.remote) throw new OpError(404, 'Unknown operation.');
248
+ const r = require('../update').runUpdate({ projectRoot: root, templatesDir: path.join(__dirname, '..', '..', 'templates'), version: PKG_VERSION });
249
+ 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
+ },
251
+ 'run.stop': async (root, _args, ctx) => changed(ctx, { stopped: stopRuns(root) }),
237
252
  'chat.summarize': async (root, { agent }, ctx) => {
238
253
  const r = runSummarize(root, { agent }, ctx.emit);
239
254
  if (r.error) bad(r.error);
@@ -298,6 +313,26 @@ const ops = {
298
313
  return { autoAdd: globalConfig.set('brain.autoAdd', autoAdd) };
299
314
  }),
300
315
 
316
+ 'memory.read': async (root) => storeOp(() => ({ ...projectMemory.read(projectMemory.fileFor(root)), path: projectMemory.REL })),
317
+ 'memory.add': async (root, { category, text: body }, ctx) => memOp(root, ctx, (f) => projectMemory.add({ category, text: body, by: 'user' }, f)),
318
+ 'memory.update': async (root, { id, patch }, ctx) => memOp(root, ctx, (f) => ({ entry: projectMemory.update(id, patch || {}, f) })),
319
+ 'memory.remove': async (root, { id }, ctx) => memOp(root, ctx, (f) => projectMemory.remove(id, f)),
320
+ 'memory.confirm': async (root, { id }, ctx) => memOp(root, ctx, (f) => ({ entry: projectMemory.confirm(id, f) })),
321
+ // The agent picked the wrong memory: move one entry to the other, under the category the user chose. Added
322
+ // to the target first, removed from the source after — a failure never loses the fact. Local only: one of
323
+ // the two files is always the user's personal brain.
324
+ 'memory.move': async (root, { from, id, category }, ctx) => brainOp(ctx, () => {
325
+ if (from !== 'user' && from !== 'project') bad('from must be "user" or "project".');
326
+ const pf = projectMemory.fileFor(root);
327
+ const [src, srcFile, dst, dstFile] = from === 'user' ? [brain, undefined, projectMemory, pf] : [projectMemory, pf, brain, undefined];
328
+ const entry = src.get(id, srcFile);
329
+ if (!dst.CATEGORIES.includes(category)) bad(`Unknown category: ${category}.`);
330
+ const added = dst.add({ category, text: entry.text, by: entry.by }, dstFile);
331
+ src.remove(id, srcFile);
332
+ ctx.emit({ type: 'change' });
333
+ return added;
334
+ }),
335
+
301
336
  'attention.remove': async (root, { id }, ctx) => {
302
337
  const rt = store.readRuntime(root); rt.attention = (rt.attention || []).filter((x) => x.id !== id); store.writeRuntime(root, rt);
303
338
  return changed(ctx, { ok: true });