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.
- package/README.md +58 -3
- package/lib/adapters.js +15 -0
- package/lib/brain.js +16 -247
- package/lib/dashboard/handlers.js +1 -0
- package/lib/dashboard/isolation.js +177 -0
- package/lib/dashboard/ops.js +56 -1
- package/lib/dashboard/public/app.js +229 -59
- package/lib/dashboard/public/i18n.js +12 -12
- package/lib/dashboard/public/index.html +30 -12
- package/lib/dashboard/public/styles.css +38 -0
- package/lib/dashboard/routes.js +16 -0
- package/lib/dashboard/runner.js +25 -19
- package/lib/mcp-server.js +2 -1
- package/lib/memory-store.js +268 -0
- package/lib/project-memory.js +34 -0
- package/lib/worktree.js +193 -0
- package/package.json +1 -1
- package/templates/SPECTOFLOW.md +31 -0
- package/templates/config.json +1 -0
package/README.md
CHANGED
|
@@ -307,8 +307,9 @@ off by default):
|
|
|
307
307
|
- **Info** — a project-at-a-glance summary.
|
|
308
308
|
- **Documentation** — the live supported-agents table (your own install status + links) plus the CLI
|
|
309
309
|
command reference.
|
|
310
|
-
- **Second brain** — what spectoflow has learned about you, shared by all your projects
|
|
311
|
-
|
|
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)).
|
|
312
313
|
- **Personalize** — autonomy mode, language, design, the active agent, **navigation tabs**, **slash
|
|
313
314
|
commands**, and **Extend spectoflow** (see *Customize* below).
|
|
314
315
|
|
|
@@ -339,6 +340,34 @@ drawer is served by the one read-only endpoint, `GET /api/agentfile?path=` (scop
|
|
|
339
340
|
`.spectoflow/agents/**` + `.spectoflow/skills/**`, path-traversal-safe) — the framework's only other
|
|
340
341
|
server surface is unchanged.
|
|
341
342
|
|
|
343
|
+
### Isolated work on a task
|
|
344
|
+
|
|
345
|
+
In a git project, open a task and click **Work on it in isolation**. The agent works in its own copy of the
|
|
346
|
+
repository — a git worktree on the branch `spectoflow/<task>` — so your working tree stays untouched, and several
|
|
347
|
+
tasks can run at once without stepping on each other or blocking the chat.
|
|
348
|
+
|
|
349
|
+
```
|
|
350
|
+
task T-012 ── Work on it in isolation ──► its own copy, branch spectoflow/T-012 (task: in progress)
|
|
351
|
+
│ the agent finishes
|
|
352
|
+
▼
|
|
353
|
+
task drawer: what changed + the diff (task: to validate)
|
|
354
|
+
├─ Merge into your current branch, copy removed → done
|
|
355
|
+
├─ Open a PR push + gh pr create → link on the task
|
|
356
|
+
├─ Send feedback the agent works again on the same copy
|
|
357
|
+
└─ Discard copy and branch removed — the rollback → to do
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
- **Nothing to learn:** no command, no setting. Chat runs and orchestration keep working as before.
|
|
361
|
+
- **The agent's last message** shows in the drawer, so a run that changed nothing still says why.
|
|
362
|
+
- **Merge never half-happens:** on a conflict it is aborted, nothing changes, and the drawer shows git's reason —
|
|
363
|
+
send feedback to the agent, or resolve it yourself.
|
|
364
|
+
- **Open a PR** appears when `gh` is installed and signed in and the repository has a remote.
|
|
365
|
+
- The copies live in `~/.spectoflow/worktrees/`, outside your project (no tool sees a second copy of the code) and
|
|
366
|
+
outside `.git` (agents refuse to write there). Uncommitted changes in your working tree aren't in the copy; the
|
|
367
|
+
drawer tells you how many.
|
|
368
|
+
- **Online:** members with write access can start, review, send feedback and discard; merging and opening a pull
|
|
369
|
+
request stay on the owner's machine.
|
|
370
|
+
|
|
342
371
|
### Slash commands
|
|
343
372
|
|
|
344
373
|
Type `/` in either chat surface to open an autocomplete of reusable **prompt macros** — pick one,
|
|
@@ -412,11 +441,37 @@ agents then read and grow your second brain through that server: nothing is copi
|
|
|
412
441
|
`::spectoflow learn category=… msg=…` line instead. Those always wait in *To confirm*, whatever the setting:
|
|
413
442
|
that output also carries command output and file contents, so a line hidden in a repository can't slip
|
|
414
443
|
in unseen.
|
|
415
|
-
- **Private:** the
|
|
444
|
+
- **Private:** the *You* section and its API answer this machine only — not the online dashboard
|
|
416
445
|
(`server/` refuses them for everyone, the project owner included), and not other machines or websites.
|
|
417
446
|
A run started from either of those can't write into it, and a learned fact never goes into a project's chat
|
|
418
447
|
log.
|
|
419
448
|
|
|
449
|
+
### Project memory
|
|
450
|
+
|
|
451
|
+
Some facts aren't about you but about **one project**: its conventions, what breaks, its vocabulary, its
|
|
452
|
+
constraints. Those go in `.spectoflow/memory.md`, **committed with the code**, so your team and their agents
|
|
453
|
+
share them — and your other projects don't inherit them.
|
|
454
|
+
|
|
455
|
+
```markdown
|
|
456
|
+
# Project memory
|
|
457
|
+
|
|
458
|
+
## Conventions
|
|
459
|
+
- Tests run with `npm test -- --runInBand` (shared DB fixtures)
|
|
460
|
+
|
|
461
|
+
## Glossary
|
|
462
|
+
- "Ticket" means a customer support case, never a Jira issue
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
- **Four categories:** Conventions, Pitfalls, Glossary, Constraints.
|
|
466
|
+
- **The agent reads and writes the file directly** — no setup. About you → second brain; about the project →
|
|
467
|
+
this file. Never anything personal in it.
|
|
468
|
+
- **Facts the agent adds land directly by default.** To review them first, untick *Add what the agent learns
|
|
469
|
+
directly* in the *This project* section: that sets `memoryAutoAdd: false` in `.spectoflow/config.json`, for
|
|
470
|
+
the whole team.
|
|
471
|
+
- **Wrong memory?** *Move to…* on any fact sends it to the other one, in the category you pick (local dashboard
|
|
472
|
+
only).
|
|
473
|
+
- Created on the first fact; `spectoflow update` never touches it.
|
|
474
|
+
|
|
420
475
|
## A workflow that fits the project
|
|
421
476
|
|
|
422
477
|
The workflow (`.spectoflow/workflow.md`) isn't one-size-fits-all anymore.
|
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.
|
|
6
|
-
*
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 };
|
|
@@ -69,6 +69,7 @@ function createHandlers(root) {
|
|
|
69
69
|
// so the 409 guard in orchestrate.start can't wedge forever. Not a resume — just un-wedging.
|
|
70
70
|
try { orchestrator.reconcileOnBoot(root); } catch (_) {}
|
|
71
71
|
try { require('./runner').reconcileRunsOnBoot(root); } catch (_) {}
|
|
72
|
+
try { require('./isolation').reconcileOnBoot(root); } catch (_) {}
|
|
72
73
|
}
|
|
73
74
|
return {
|
|
74
75
|
handleApi,
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* The delivery loop (D79, docs/delivery-loop-design.md): a task worked on in isolation. One path, riding on the
|
|
4
|
+
* task's existing status:
|
|
5
|
+
*
|
|
6
|
+
* start → git worktree + branch spectoflow/<id>, the agent runs there task: in progress
|
|
7
|
+
* run ends → what it left uncommitted is committed on the branch task: to validate
|
|
8
|
+
* diff → files and patch, for review in the task drawer
|
|
9
|
+
* feedback → the comment is added to the task, the agent runs again, same worktree
|
|
10
|
+
* merge → merged into the working tree's branch, worktree and branch removed task: done
|
|
11
|
+
* pr → branch pushed, pull request opened with gh, worktree removed (link added to the task)
|
|
12
|
+
* discard → worktree and branch removed — the rollback task: to do
|
|
13
|
+
*
|
|
14
|
+
* State lives in runtime.json → worktrees[<id>] = { branch, base, status, runId, startedAt, endedAt,
|
|
15
|
+
* uncommitted, prUrl }; git itself stays the source of truth (boot drops an entry whose branch is gone).
|
|
16
|
+
*/
|
|
17
|
+
const store = require('../store');
|
|
18
|
+
const worktree = require('../worktree');
|
|
19
|
+
const { startRun, stopRuns, isRunning } = require('./runner');
|
|
20
|
+
|
|
21
|
+
const group = (id) => `task:${id}`;
|
|
22
|
+
const OUTPUT_TAIL = 4000;
|
|
23
|
+
// What the agent said last (its plain output, sentinel lines left out) — shown in the drawer, so a run that
|
|
24
|
+
// changed nothing still says why (a question, a refusal, an error).
|
|
25
|
+
function tailOutput(child) {
|
|
26
|
+
let text = '';
|
|
27
|
+
const keep = (d) => { text = (text + d.toString()).slice(-OUTPUT_TAIL * 2); };
|
|
28
|
+
if (child.stdout) child.stdout.on('data', keep);
|
|
29
|
+
return () => text.split('\n').filter((l) => !/^::spectoflow\s/.test(l.trim())).join('\n').trim().slice(-OUTPUT_TAIL);
|
|
30
|
+
}
|
|
31
|
+
const fail = (status, message) => { throw new worktree.GitError(status, message); };
|
|
32
|
+
|
|
33
|
+
function findTask(root, id) {
|
|
34
|
+
for (const pl of store.readPlans(root)) for (const ph of pl.phases) {
|
|
35
|
+
const task = ph.tasks.find((t) => t.id === id);
|
|
36
|
+
if (task) return { task, file: pl.file };
|
|
37
|
+
}
|
|
38
|
+
return fail(404, `Task ${id} not found.`);
|
|
39
|
+
}
|
|
40
|
+
function readState(root) { return store.readRuntime(root).worktrees || {}; }
|
|
41
|
+
function setState(root, id, patch) {
|
|
42
|
+
const rt = store.readRuntime(root);
|
|
43
|
+
rt.worktrees = rt.worktrees || {};
|
|
44
|
+
if (patch === null) delete rt.worktrees[id];
|
|
45
|
+
else rt.worktrees[id] = { ...(rt.worktrees[id] || {}), ...patch };
|
|
46
|
+
store.writeRuntime(root, rt);
|
|
47
|
+
return rt.worktrees[id] || null;
|
|
48
|
+
}
|
|
49
|
+
const setStatus = (root, file, id, status) => { try { store.updateTaskLine(root, file, id, { status }); } catch (_) {} };
|
|
50
|
+
|
|
51
|
+
function promptFor(id, task, file, branch, feedback) {
|
|
52
|
+
const lines = [
|
|
53
|
+
`Work on task ${id}: ${task.title} (from plans/${file}).`,
|
|
54
|
+
`You are in an isolated git worktree of this project, on branch ${branch}. The user reviews the diff before anything reaches their own working tree.`,
|
|
55
|
+
"Don't edit this task's line or its status in plans/ — the dashboard tracks it. Don't push, don't switch or create branches.",
|
|
56
|
+
'The user started this work from the dashboard: that is their go-ahead to do this task now. If it needs a workflow step that is disabled, enable it in .spectoflow/workflow.md and say so — the change shows in the diff they review.',
|
|
57
|
+
];
|
|
58
|
+
const notes = (task.comments || []).filter((c) => !/^PR: /.test(c));
|
|
59
|
+
if (notes.length) lines.push(`Notes on the task:\n${notes.map((c) => `- ${c}`).join('\n')}`);
|
|
60
|
+
if (feedback) lines.push(`Review feedback on your previous change — address it:\n${feedback}`);
|
|
61
|
+
lines.push('When done, summarize what you changed.');
|
|
62
|
+
return lines.join('\n\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Start (or restart, with feedback) the agent on a task in its worktree.
|
|
66
|
+
function start(root, { id, feedback }, emit, { remote } = {}) {
|
|
67
|
+
const { task, file } = findTask(root, id);
|
|
68
|
+
if (isRunning(root, group(id))) fail(409, `An agent is already working on ${id}.`);
|
|
69
|
+
const made = worktree.create(root, id);
|
|
70
|
+
const prev = readState(root)[id] || null;
|
|
71
|
+
const was = prev || {};
|
|
72
|
+
// Marked running before the agent starts: startRun's own events must never show the previous run's result.
|
|
73
|
+
setState(root, id, {
|
|
74
|
+
branch: made.branch, base: was.base || made.base, status: 'running', runId: null,
|
|
75
|
+
startedAt: new Date().toISOString(), endedAt: null, error: null, output: null, prUrl: was.prUrl || null,
|
|
76
|
+
uncommitted: made.created ? worktree.uncommitted(root, [store.resolvePlansDir(root, store.readConfig(root))]).length : (was.uncommitted || 0),
|
|
77
|
+
});
|
|
78
|
+
const run = startRun(root, {
|
|
79
|
+
prompt: promptFor(id, task, file, made.branch, feedback),
|
|
80
|
+
display: `⎇ ${id} — ${feedback ? 'feedback: ' + feedback : task.title}`,
|
|
81
|
+
task: id, cwd: made.cwd, learn: !remote,
|
|
82
|
+
}, emit);
|
|
83
|
+
if (run.error) {
|
|
84
|
+
setState(root, id, prev);
|
|
85
|
+
if (made.created) { try { worktree.discard(root, id); } catch (_) {} }
|
|
86
|
+
fail(400, run.error);
|
|
87
|
+
}
|
|
88
|
+
setState(root, id, { runId: run.runId });
|
|
89
|
+
setStatus(root, file, id, 'in_progress');
|
|
90
|
+
const output = run.child ? tailOutput(run.child) : () => '';
|
|
91
|
+
const finish = (code, signal) => {
|
|
92
|
+
let error = null;
|
|
93
|
+
try { worktree.commitAll(made.path, `${id}: ${task.title}`); } catch (e) { error = e.message; }
|
|
94
|
+
const status = signal ? 'stopped' : code === 0 && !error ? 'ready' : 'failed';
|
|
95
|
+
setState(root, id, { status, endedAt: new Date().toISOString(), error, output: output() || null });
|
|
96
|
+
setStatus(root, file, id, 'to_validate');
|
|
97
|
+
emit({ type: 'change' });
|
|
98
|
+
};
|
|
99
|
+
if (run.child) run.child.on('close', finish); else finish(1, null);
|
|
100
|
+
emit({ type: 'change' });
|
|
101
|
+
return { runId: run.runId, branch: made.branch, uncommitted: readState(root)[id].uncommitted };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function requireIdle(root, id) {
|
|
105
|
+
if (isRunning(root, group(id))) fail(409, `An agent is still working on ${id}: stop it first.`);
|
|
106
|
+
}
|
|
107
|
+
// Anything left uncommitted in the worktree (a stopped run, a crash, a hand edit) is part of the change.
|
|
108
|
+
function commitLeftovers(root, id, title) {
|
|
109
|
+
const wt = worktree.list(root)[id];
|
|
110
|
+
if (wt) worktree.commitAll(wt, `${id}: ${title}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function diff(root, { id }, { remote } = {}) {
|
|
114
|
+
const { task } = findTask(root, id);
|
|
115
|
+
const state = readState(root)[id] || {};
|
|
116
|
+
if (!isRunning(root, group(id))) commitLeftovers(root, id, task.title);
|
|
117
|
+
const d = worktree.diff(root, id, state.base);
|
|
118
|
+
return { ...d, running: isRunning(root, group(id)), pr: remote ? { ok: false, reason: 'local dashboard only' } : prReadyCached(root) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function merge(root, { id }) {
|
|
122
|
+
const { task, file } = findTask(root, id);
|
|
123
|
+
requireIdle(root, id);
|
|
124
|
+
commitLeftovers(root, id, task.title);
|
|
125
|
+
const r = worktree.merge(root, id, `Merge ${worktree.branchOf(id)}: ${id} ${task.title}`);
|
|
126
|
+
setState(root, id, null);
|
|
127
|
+
setStatus(root, file, id, 'done');
|
|
128
|
+
return r;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function openPr(root, { id }) {
|
|
132
|
+
const { task, file } = findTask(root, id);
|
|
133
|
+
requireIdle(root, id);
|
|
134
|
+
commitLeftovers(root, id, task.title);
|
|
135
|
+
const r = worktree.openPr(root, id, { title: `${id}: ${task.title}`, body: `Task ${id} from \`plans/${file}\`, worked on with spectoflow.` });
|
|
136
|
+
setState(root, id, { status: 'pr', prUrl: r.url });
|
|
137
|
+
try { store.addTaskComment(root, file, id, `PR: ${r.url}`); } catch (_) {}
|
|
138
|
+
return r;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function discard(root, { id }) {
|
|
142
|
+
const { file } = findTask(root, id);
|
|
143
|
+
requireIdle(root, id);
|
|
144
|
+
const r = worktree.discard(root, id);
|
|
145
|
+
setState(root, id, null);
|
|
146
|
+
setStatus(root, file, id, 'todo');
|
|
147
|
+
return r;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const stop = (root, { id }) => ({ stopped: stopRuns(root, group(id)) });
|
|
151
|
+
|
|
152
|
+
// After a restart nothing runs: a 'running' entry was interrupted; an entry whose branch is gone is dropped.
|
|
153
|
+
function reconcileOnBoot(root) {
|
|
154
|
+
const state = readState(root);
|
|
155
|
+
const ids = Object.keys(state);
|
|
156
|
+
if (!ids.length) return;
|
|
157
|
+
const rt = store.readRuntime(root);
|
|
158
|
+
for (const id of ids) {
|
|
159
|
+
const exists = worktree.isRepo(root) && worktree.list(root)[id] !== undefined;
|
|
160
|
+
const branchKept = exists || state[id].status === 'pr';
|
|
161
|
+
if (!branchKept) { delete rt.worktrees[id]; continue; }
|
|
162
|
+
if (state[id].status === 'running') rt.worktrees[id] = { ...state[id], status: 'stopped', endedAt: new Date().toISOString() };
|
|
163
|
+
}
|
|
164
|
+
store.writeRuntime(root, rt);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// `gh auth status` is slow-ish: remember the answer for a minute per project.
|
|
168
|
+
const prCache = new Map();
|
|
169
|
+
function prReadyCached(root) {
|
|
170
|
+
const hit = prCache.get(root);
|
|
171
|
+
if (hit && Date.now() - hit.at < 60000) return hit.value;
|
|
172
|
+
const value = worktree.prReady(root);
|
|
173
|
+
prCache.set(root, { at: Date.now(), value });
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = { start, diff, merge, openPr, discard, stop, reconcileOnBoot, promptFor };
|