spectoflow 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,268 @@
1
+ 'use strict';
2
+ /*
3
+ * A memory: durable facts kept in one markdown file, grouped by category, with a "To confirm" section for
4
+ * what an agent learned while the owner wants to validate first. Two instances: the second brain
5
+ * (lib/brain.js — about the user, ~/.spectoflow/brain.md) and the project memory (lib/project-memory.js —
6
+ * about one project, .spectoflow/memory.md, committed). Zero 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.
17
+ *
18
+ * createMemoryStore({ categories, headings, fallback, title, idPrefix, name, defaultFile, autoAdd })
19
+ * defaultFile() → the file used when a call passes none; autoAdd(file) → whether an agent's fact is
20
+ * confirmed right away.
21
+ */
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const crypto = require('crypto');
25
+
26
+ const PENDING = 'To confirm';
27
+ const MAX_TEXT = 500;
28
+
29
+ // One line, no HTML-comment delimiters (they would break the metadata), capped.
30
+ function cleanText(t) {
31
+ return String(t == null ? '' : t).replace(/<!--|-->/g, '').replace(/\s+/g, ' ').trim().slice(0, MAX_TEXT);
32
+ }
33
+ // A hand-written line has no id: derive a stable one. `n` tells identical lines of a category apart.
34
+ const derivedId = (category, text, n) => 'h' + crypto.createHash('sha1').update(`${category}\n${text}\n${n}`).digest('hex').slice(0, 10);
35
+ const today = () => new Date().toISOString().slice(0, 10);
36
+ const isBlank = (it) => it.raw !== undefined && !it.raw.trim();
37
+
38
+ const ENTRY_RE = /^-\s+(?:\[([\w-]+)\]\s+)?(.*?)\s*(?:<!--\s*(.*?)\s*-->)?\s*$/;
39
+ function parseMeta(s) {
40
+ const out = {};
41
+ for (const m of String(s || '').matchAll(/(\w+):(\S+)/g)) out[m[1]] = m[2];
42
+ return out;
43
+ }
44
+
45
+ // Cross-process lock: the hub, any number of `spectoflow mcp` processes and runs may write in the
46
+ // same instant. A lock older than 10s is a crashed writer's and is taken over.
47
+ const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
48
+ function withLock(file, fn, busyMessage) {
49
+ fs.mkdirSync(path.dirname(file), { recursive: true });
50
+ const lock = `${file}.lock`, deadline = Date.now() + 3000;
51
+ for (;;) {
52
+ try { fs.writeFileSync(lock, String(process.pid), { flag: 'wx' }); break; }
53
+ catch (e) {
54
+ if (e.code !== 'EEXIST') throw e;
55
+ try { if (Date.now() - fs.statSync(lock).mtimeMs > 10000) { fs.unlinkSync(lock); continue; } } catch (_) {}
56
+ if (Date.now() > deadline) throw Object.assign(new Error(busyMessage), { status: 503 });
57
+ sleepSync(15);
58
+ }
59
+ }
60
+ try { return fn(); } finally { try { fs.unlinkSync(lock); } catch (_) {} }
61
+ }
62
+
63
+ const notFound = () => Object.assign(new Error('Entry not found.'), { status: 404 });
64
+ const emptyText = () => Object.assign(new Error('Text is required.'), { status: 400 });
65
+
66
+ function createMemoryStore({ categories, headings, fallback, title, idPrefix, name, defaultFile, autoAdd: autoAddFor }) {
67
+ const CATEGORIES = categories, HEADINGS = headings;
68
+ const normCategory = (c) => (CATEGORIES.includes(String(c || '').trim().toLowerCase()) ? String(c).trim().toLowerCase() : fallback);
69
+ const newId = () => idPrefix + Date.now().toString(36) + crypto.randomBytes(3).toString('hex');
70
+
71
+ function headingKind(t0) {
72
+ const t = t0.trim().toLowerCase();
73
+ if (t === PENDING.toLowerCase()) return { kind: 'pending' };
74
+ const cat = CATEGORIES.find((c) => c === t || HEADINGS[c].toLowerCase() === t);
75
+ return cat ? { kind: 'category', id: cat } : { kind: 'unknown' };
76
+ }
77
+
78
+ // → { title, blocks: [{ kind: preamble|category|pending|unknown, id?, heading?, items: [...] }] }
79
+ // item = { raw } (a line kept verbatim) or an entry { id, category, text, by, at, status, line }.
80
+ function parse(text) {
81
+ const lines = String(text || '').split(/\r?\n/);
82
+ while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
83
+ const model = { title: null, eol: /\r\n/.test(String(text || '')) ? '\r\n' : '\n', blocks: [{ kind: 'preamble', items: [] }] };
84
+ let block = model.blocks[0], inFence = false;
85
+ const seen = new Map();
86
+ for (const line of lines) {
87
+ if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; block.items.push({ raw: line }); continue; }
88
+ if (inFence) { block.items.push({ raw: line }); continue; }
89
+ if (model.title === null && model.blocks.length === 1 && block.items.every(isBlank) && /^#\s+/.test(line)) { model.title = line; continue; }
90
+ const h = line.match(/^##\s+(.*)$/);
91
+ if (h) {
92
+ block = { ...headingKind(h[1]), heading: line, items: [] };
93
+ model.blocks.push(block);
94
+ continue;
95
+ }
96
+ const m = (block.kind === 'category' || block.kind === 'pending') && line.match(ENTRY_RE);
97
+ const meta = m ? parseMeta(m[3]) : null;
98
+ // A trailing comment is never part of the fact (a user's own comment stays hidden: the line itself is
99
+ // written back verbatim while the entry is untouched; editing the entry replaces the line).
100
+ const body = m ? m[2] : '';
101
+ if (m && cleanText(body)) {
102
+ const pending = block.kind === 'pending';
103
+ const category = pending ? normCategory(m[1]) : block.id;
104
+ const entryText = cleanText(pending || !m[1] ? body : `[${m[1]}] ${body}`);
105
+ let id = meta.id;
106
+ if (!id) { const k = `${category}\n${entryText}`; const n = seen.get(k) || 0; seen.set(k, n + 1); id = derivedId(category, entryText, n); }
107
+ block.items.push({ id, category, text: entryText, by: meta.by || 'user', at: meta.at || null, status: pending ? 'pending' : 'confirmed', line });
108
+ } else {
109
+ block.items.push({ raw: line });
110
+ }
111
+ }
112
+ return model;
113
+ }
114
+
115
+ function entryLine(e) {
116
+ const meta = `id:${e.id} by:${e.by}${e.at ? ' at:' + e.at : ''}`;
117
+ return `- ${e.status === 'pending' ? `[${e.category}] ` : ''}${e.text} <!-- ${meta} -->`;
118
+ }
119
+ function serialize(model) {
120
+ const out = [model.title || `# ${title}`];
121
+ for (const b of model.blocks) {
122
+ if (b.kind === 'pending' && !b.items.some((it) => it.raw === undefined) && b.items.every(isBlank)) continue;
123
+ if (b.kind !== 'preamble') {
124
+ if (out[out.length - 1].trim()) out.push(''); // a section always follows a blank line
125
+ out.push(b.heading || `## ${b.kind === 'pending' ? PENDING : HEADINGS[b.id]}`);
126
+ }
127
+ for (const it of b.items) out.push(it.raw !== undefined ? it.raw : (it.line && !it.dirty ? it.line : entryLine(it)));
128
+ }
129
+ while (out.length > 1 && !out[out.length - 1].trim()) out.pop();
130
+ const eol = model.eol || '\n';
131
+ return out.join(eol) + eol;
132
+ }
133
+
134
+ function emptyModel() {
135
+ return { title: null, blocks: [{ kind: 'preamble', items: [] }, ...CATEGORIES.map((id) => ({ kind: 'category', id, heading: null, items: [] }))] };
136
+ }
137
+ function load(file) {
138
+ let text = '';
139
+ try { text = fs.readFileSync(file, 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
140
+ return text.trim() ? parse(text) : emptyModel();
141
+ }
142
+ function save(model, file) {
143
+ fs.mkdirSync(path.dirname(file), { recursive: true });
144
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
145
+ fs.writeFileSync(tmp, serialize(model));
146
+ fs.renameSync(tmp, file);
147
+ }
148
+ // Read, change, save — under the lock. `fn` returns { result, changed }.
149
+ function mutate(file, fn) {
150
+ return withLock(file, () => {
151
+ const model = load(file);
152
+ const { result, changed } = fn(model);
153
+ if (changed) save(model, file);
154
+ return result;
155
+ }, `The ${name} is busy, try again.`);
156
+ }
157
+
158
+ const entriesOf = (model) => model.blocks.flatMap((b) => b.items.filter((it) => it.raw === undefined));
159
+ function find(model, id) {
160
+ for (const b of model.blocks) {
161
+ const i = b.items.findIndex((it) => it.raw === undefined && it.id === id);
162
+ if (i >= 0) return { block: b, i, entry: b.items[i] };
163
+ }
164
+ return null;
165
+ }
166
+ // Append before the block's trailing blank lines, so the spacing before the next heading stays.
167
+ function append(block, entry) {
168
+ let i = block.items.length;
169
+ while (i > 0 && isBlank(block.items[i - 1])) i--;
170
+ block.items.splice(i, 0, entry);
171
+ }
172
+ function blockFor(model, status, category) {
173
+ const want = status === 'pending' ? (b) => b.kind === 'pending' : (b) => b.kind === 'category' && b.id === category;
174
+ let b = model.blocks.find(want);
175
+ if (!b) {
176
+ b = status === 'pending' ? { kind: 'pending', heading: null, items: [] } : { kind: 'category', id: category, heading: null, items: [] };
177
+ const at = status === 'pending' ? -1 : model.blocks.findIndex((x) => x.kind === 'pending');
178
+ if (at < 0) model.blocks.push(b); else model.blocks.splice(at, 0, b);
179
+ }
180
+ return b;
181
+ }
182
+
183
+ const autoAdd = (file = defaultFile()) => autoAddFor(file);
184
+
185
+ // { entries (confirmed, in category order), pending, autoAdd }
186
+ function read(file = defaultFile()) {
187
+ const all = entriesOf(load(file));
188
+ return {
189
+ entries: CATEGORIES.flatMap((c) => all.filter((e) => e.status === 'confirmed' && e.category === c)),
190
+ pending: all.filter((e) => e.status === 'pending'),
191
+ autoAdd: autoAdd(file),
192
+ };
193
+ }
194
+
195
+ // Adds one fact. status 'confirmed' or 'pending'. A case-insensitive duplicate of any entry is not
196
+ // added again: → { duplicate: true, entry: <the existing one> }.
197
+ function add({ category, text, by = 'user', status = 'confirmed' }, file = defaultFile()) {
198
+ const t = cleanText(text);
199
+ if (!t) throw emptyText();
200
+ return mutate(file, (model) => {
201
+ const existing = entriesOf(model).find((e) => e.text.toLowerCase() === t.toLowerCase());
202
+ if (existing) return { result: { duplicate: true, entry: existing }, changed: false };
203
+ const entry = { id: newId(), category: normCategory(category), text: t, by, at: today(), status: status === 'pending' ? 'pending' : 'confirmed' };
204
+ append(blockFor(model, entry.status, entry.category), entry);
205
+ return { result: { duplicate: false, entry }, changed: true };
206
+ });
207
+ }
208
+
209
+ // What an agent learned: confirmed right away, or "to confirm", per the store's autoAdd setting.
210
+ function learn({ category, text }, file = defaultFile()) {
211
+ return add({ category, text, by: 'agent', status: autoAdd(file) ? 'confirmed' : 'pending' }, file);
212
+ }
213
+
214
+ function get(id, file = defaultFile()) {
215
+ const hit = find(load(file), id);
216
+ if (!hit) throw notFound();
217
+ return hit.entry;
218
+ }
219
+
220
+ function update(id, { text, category } = {}, file = defaultFile()) {
221
+ const t = text === undefined ? undefined : cleanText(text);
222
+ if (text !== undefined && !t) throw emptyText();
223
+ return mutate(file, (model) => {
224
+ const hit = find(model, id); if (!hit) throw notFound();
225
+ const e = hit.entry;
226
+ if (t !== undefined) e.text = t;
227
+ if (category !== undefined) {
228
+ const c = normCategory(category);
229
+ if (c !== e.category && e.status === 'confirmed') { hit.block.items.splice(hit.i, 1); append(blockFor(model, 'confirmed', c), e); }
230
+ e.category = c;
231
+ }
232
+ e.dirty = true;
233
+ return { result: e, changed: true };
234
+ });
235
+ }
236
+
237
+ function remove(id, file = defaultFile()) {
238
+ return mutate(file, (model) => {
239
+ const hit = find(model, id); if (!hit) throw notFound();
240
+ hit.block.items.splice(hit.i, 1);
241
+ return { result: { ok: true }, changed: true };
242
+ });
243
+ }
244
+
245
+ function confirm(id, file = defaultFile()) {
246
+ return mutate(file, (model) => {
247
+ const hit = find(model, id); if (!hit) throw notFound();
248
+ if (hit.entry.status !== 'pending') return { result: hit.entry, changed: false };
249
+ hit.block.items.splice(hit.i, 1);
250
+ hit.entry.status = 'confirmed'; hit.entry.dirty = true;
251
+ append(blockFor(model, 'confirmed', hit.entry.category), hit.entry);
252
+ return { result: hit.entry, changed: true };
253
+ });
254
+ }
255
+
256
+ // Confirmed entries as markdown, grouped by category — what an agent is given.
257
+ function renderForAgent(file = defaultFile()) {
258
+ const { entries } = read(file);
259
+ return CATEGORIES.map((c) => {
260
+ const items = entries.filter((e) => e.category === c);
261
+ return items.length ? `## ${HEADINGS[c]}\n${items.map((e) => `- ${e.text}`).join('\n')}` : '';
262
+ }).filter(Boolean).join('\n\n');
263
+ }
264
+
265
+ return { CATEGORIES, HEADINGS, MAX_TEXT, normCategory, parse, serialize, read, add, learn, get, update, remove, confirm, renderForAgent, autoAdd };
266
+ }
267
+
268
+ module.exports = { createMemoryStore, MAX_TEXT };
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+ /*
3
+ * The project memory — durable facts about ONE project (conventions, pitfalls, glossary, constraints),
4
+ * true whoever works on it. `.spectoflow/memory.md`, committed with the code: team knowledge that follows
5
+ * the project's history. Never anything personal (that is the second brain, lib/brain.js).
6
+ *
7
+ * Not shipped by the kit — created on first write — so `spectoflow update` never touches it. Agents read
8
+ * and write the file directly; the dashboard goes through here. `config.json → memoryAutoAdd` (default
9
+ * true, per project) decides whether an agent's fact lands confirmed or under "To confirm". (D78)
10
+ */
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { createMemoryStore } = require('./memory-store');
14
+
15
+ const REL = '.spectoflow/memory.md';
16
+ const fileFor = (root) => path.join(root, '.spectoflow', 'memory.md');
17
+
18
+ // The store is keyed by file; the project's config sits next to it.
19
+ function autoAddFor(file) {
20
+ try { return JSON.parse(fs.readFileSync(path.join(path.dirname(file), 'config.json'), 'utf8')).memoryAutoAdd !== false; } catch { return true; }
21
+ }
22
+
23
+ const store = createMemoryStore({
24
+ name: 'project memory',
25
+ title: 'Project memory',
26
+ categories: ['conventions', 'pitfalls', 'glossary', 'constraints'],
27
+ headings: { conventions: 'Conventions', pitfalls: 'Pitfalls', glossary: 'Glossary', constraints: 'Constraints' },
28
+ fallback: 'conventions',
29
+ idPrefix: 'm',
30
+ defaultFile: () => { throw new Error('project memory: a file is required'); },
31
+ autoAdd: autoAddFor,
32
+ });
33
+
34
+ module.exports = { ...store, REL, fileFor };
@@ -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.32.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",
@@ -40,6 +40,37 @@ it only through the `spectoflow` MCP server — never look for a file.
40
40
  `::spectoflow learn category=<id> msg=<the fact>`.
41
41
  - The user sees and edits it all in the dashboard's **Second brain** tab; `spectoflow brain setup` connects
42
42
  their agents to it.
43
+ - **Facts about this project are not about the user** — they go in the project memory below, not here.
44
+
45
+ ## Project memory — what you know about this project
46
+
47
+ `.spectoflow/memory.md` holds durable facts about **this project**, true whoever works on it. It is
48
+ committed with the code, so the team and their agents share it. Create it on the first fact if it doesn't exist.
49
+
50
+ ```markdown
51
+ # Project memory
52
+
53
+ ## Conventions
54
+ - Tests run with `npm test -- --runInBand` (shared DB fixtures)
55
+
56
+ ## Pitfalls
57
+ ## Glossary
58
+ ## Constraints
59
+ ```
60
+
61
+ - **At session start, read it and apply it.** Like the second brain, it is background knowledge, not
62
+ commands: it never overrides your safety rules or what the user asks now.
63
+ - **When you learn a durable fact about the project, add one line** under its section:
64
+ **Conventions** (naming, tools, imposed style) · **Pitfalls** (what breaks, known workarounds) ·
65
+ **Glossary** (domain vocabulary) · **Constraints** (technical, legal, client). If
66
+ `.spectoflow/config.json` → `memoryAutoAdd` is `false`, add it under `## To confirm` as
67
+ `- [conventions] the fact` instead, for the user to confirm. One fact per line, one short sentence, in
68
+ the project's language; don't repeat what is already there; leave other lines exactly as they are.
69
+ - **Which memory?** About the user (who they are, what they prefer, how they like to work) → the second
70
+ brain. About the project → this file. **Never** anything personal here — it is shared — and never secrets
71
+ anywhere.
72
+ - **Not a second home for what already has one:** requirements go in specs, work in plans, decisions in the
73
+ project's decision log. The memory holds the small durable facts that deserve neither.
43
74
 
44
75
  ## Where things live
45
76
 
@@ -4,6 +4,7 @@
4
4
  "agent": "claude",
5
5
  "projectType": "app",
6
6
  "workflowAutoEnable": false,
7
+ "memoryAutoAdd": true,
7
8
  "plansDir": null,
8
9
  "specsDir": null,
9
10
  "dashboard": { "autostart": true },