spectoflow 0.32.0 → 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.
@@ -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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spectoflow",
3
- "version": "0.32.0",
3
+ "version": "0.33.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 },