spectoflow 0.27.2 → 0.29.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
@@ -91,6 +91,9 @@ spectoflow dashboard init [--path=<dir>] create/move the dashboard workspa
91
91
  spectoflow dashboard validate <file> check a custom-view JSON against the block schema
92
92
  spectoflow config [get|set <key> [<value>]] global defaults + dashboard URL/path (~/.spectoflow/config.json)
93
93
 
94
+ spectoflow brain your second brain: entries, and which agents can reach it
95
+ spectoflow brain setup [--dry-run] connect your installed agents to it (once per machine)
96
+
94
97
  spectoflow skill create "..." | --auto generate a project skill
95
98
  spectoflow agent create "..." | --auto generate a project agent
96
99
 
@@ -118,18 +121,20 @@ detected it falls back to claude + codex.
118
121
  **Empty project** → your agent asks what to build and runs Intake (brainstorm → analysis → spec → plan).
119
122
  **Existing project** → an existing `CLAUDE.md` is preserved as `CLAUDE.md.tomerge` (merged on first run);
120
123
  an existing `AGENTS.md` or `GEMINI.md` is kept as-is and gets a short, delimited spectoflow section
121
- appended (`<!-- spectoflow:start -->` … `<!-- spectoflow:end -->`) pointing to `.spectoflow/AGENTS.md` —
124
+ appended (`<!-- spectoflow:start -->` … `<!-- spectoflow:end -->`) pointing to `.spectoflow/SPECTOFLOW.md` —
122
125
  `spectoflow update` adds it too to a project installed before that; existing `plans/*.md` tasks are given
123
126
  stable ids.
124
127
 
125
- **Two `AGENTS.md`, on purpose.** The one at your project root is a thin pointer, in the place each agent
126
- reads natively. The real brain (intent router, workflow, rules) is `.spectoflow/AGENTS.md`, owned by the
127
- framework and refreshed by `spectoflow update`.
128
+ **Entry files vs. the brain.** `CLAUDE.md`, `AGENTS.md` and `GEMINI.md` at your project root are thin
129
+ pointers, in the place each agent reads natively. The real brain (intent router, workflow, rules) is
130
+ `.spectoflow/SPECTOFLOW.md`, owned by the framework and refreshed by `spectoflow update`. (Before 0.28 it
131
+ was named `.spectoflow/AGENTS.md`, easy to confuse with the root one — `update` renames it, keeping any
132
+ edits you made, and rewrites the old path in your entry files.)
128
133
 
129
134
  ## Update
130
135
 
131
136
  `init` is idempotent (it never overwrites), so it can't refresh an installed project. `spectoflow
132
- update` refreshes **framework-owned** files (engine, `AGENTS.md`, `capabilities.md`, `policy.md`,
137
+ update` refreshes **framework-owned** files (engine, `SPECTOFLOW.md`, `capabilities.md`, `policy.md`,
133
138
  default agents & skills) to the CLI's version — retiring the project's own vendored dashboard folder
134
139
  along the way for anyone updating from before v0.24 — while **preserving your work** —
135
140
  `config.json`, `workflow.md`, `specs/`, `plans/`, and any agent/skill you created or edited are never
@@ -254,7 +259,7 @@ it grows one extra, optional connection outward:
254
259
  | `spectoflow dashboard logout` | your machine | unlink the machine entirely |
255
260
 
256
261
  The header bar always shows the brand, the **active agent**, autonomy mode, language, a global-progress
257
- meter, a sync dot, and a **Run** quick-action. Thirteen tabs — and **which ones you see, and in what
262
+ meter, a sync dot, and a **Run** quick-action. Fourteen tabs — and **which ones you see, and in what
258
263
  order, is up to you** (Personalize → *Navigation tabs*: enable / disable / reorder; two of them ship
259
264
  off by default):
260
265
 
@@ -277,6 +282,8 @@ off by default):
277
282
  - **Info** — a project-at-a-glance summary.
278
283
  - **Documentation** — the live supported-agents table (your own install status + links) plus the CLI
279
284
  command reference.
285
+ - **Second brain** — what spectoflow has learned about you, shared by all your projects: read, add, fix,
286
+ confirm (see [Second brain](#second-brain)). Local only.
280
287
  - **Personalize** — autonomy mode, language, design, the active agent, **navigation tabs**, **slash
281
288
  commands**, and **Extend spectoflow** (see *Customize* below).
282
289
 
@@ -341,6 +348,50 @@ spectoflow agent create "owns accessibility review" # or: --auto
341
348
  spectoflow dashboard create "a KPI overview for support" # or: --auto
342
349
  ```
343
350
 
351
+ ## Second brain
352
+
353
+ spectoflow learns about **you** as you work, and remembers it across all your projects: your role, your
354
+ preferences, how you like to work, what to avoid. Every agent session starts with it, whatever the agent.
355
+
356
+ ```
357
+ you work, in any project, with any agent
358
+ │ "commit messages in English, always"
359
+ ▼
360
+ the agent records it ── brain_learn ──► ~/.spectoflow/brain.md (one file, yours, never in a repo)
361
+ │
362
+ ┌──────────────────────────────────────────┼─────────────────────────────┐
363
+ ▼ ▼ ▼
364
+ next session, any project: dashboard → Second brain tab: you can edit the file
365
+ the agent starts with it read, add, fix, confirm by hand, too
366
+ ```
367
+
368
+ **One-time setup, per machine:**
369
+
370
+ ```bash
371
+ spectoflow brain setup --dry-run # see what it would change
372
+ spectoflow brain setup # connect every coding agent installed on this machine
373
+ ```
374
+
375
+ It registers a small MCP server, `spectoflow mcp`, in each installed agent's **user-level** config (Claude
376
+ Code, Codex, Cursor, Gemini, OpenCode, Kiro, Antigravity, Copilot, Amazon Q, Droid, Auggie, Kimi; Goose
377
+ gets a snippet to paste). It never touches an existing entry and never rewrites a file it can't parse. The
378
+ agents then read and grow your second brain through that server: nothing is copied into your projects.
379
+
380
+ - **Four categories:** Profile, Preferences, Working style, Avoid.
381
+ - **Facts an agent records through MCP are added directly by default.** To review them first, untick *Add what the agent learns
382
+ directly* on the page, or run `spectoflow config set brain.autoAdd false`: new facts then wait in
383
+ *To confirm*.
384
+ - **What gets recorded:** durable facts only. Never secrets, credentials or sensitive personal data, never
385
+ a one-off instruction. The rule is written in every agent's instructions and in the MCP tools themselves.
386
+ - **Runs launched from the dashboard:** non-interactive agents often refuse MCP tools, so the agent prints a
387
+ `::spectoflow learn category=… msg=…` line instead. Those always wait in *To confirm*, whatever the setting:
388
+ that output also carries command output and file contents, so a line hidden in a repository can't slip
389
+ in unseen.
390
+ - **Private:** the Second brain tab and its API answer this machine only — not the online dashboard
391
+ (`server/` refuses them for everyone, the project owner included), and not other machines on your network.
392
+ A run started from either of those can't write into it, and a learned fact never goes into a project's chat
393
+ log.
394
+
344
395
  ## Agents vs skills
345
396
 
346
397
  Agents (`.spectoflow/agents/`) are **stable team personas** (Product Manager, Developer, QA Engineer…).
@@ -353,7 +404,7 @@ INVEST, Playwright E2E, Conventional Commits, and more — not generic one-liner
353
404
  ### Clarify before acting
354
405
 
355
406
  spectoflow is an **expert analyst, not an order-taker**. When a request is vague ("login displays
356
- badly, users can't sign in"), an always-on **Clarify reflex** — in the agent's memory (`AGENTS.md`)
407
+ badly, users can't sign in"), an always-on **Clarify reflex** — in the agent's memory (`SPECTOFLOW.md`)
357
408
  and backed by the `clarify` skill — reflects it back and asks **one targeted question at a time**,
358
409
  each with a recommendation anchored in the project's goals and best practices, until the need is
359
410
  crisp; then it runs the normal workflow. It's additive: it feeds the router, never replaces it, and
package/bin/spectoflow.js CHANGED
@@ -133,7 +133,7 @@ async function update() {
133
133
  const r = require('../lib/update').runUpdate({ projectRoot: root, templatesDir: TPL, version: VERSION, dryRun, force });
134
134
 
135
135
  const from = r.fromVersion || 'unknown';
136
- const changed = r.refreshed.length + r.created.length + r.adopted.length + r.newSidecar.length + r.forced.length + r.removed.length + r.migration.movedViews.length + r.pointers.length;
136
+ const changed = r.refreshed.length + r.created.length + r.adopted.length + r.newSidecar.length + r.forced.length + r.removed.length + r.migration.movedViews.length + r.pointers.appended.length + r.pointers.repointed.length + r.migration.repointedUserFiles.length + (r.migration.renamedBrain ? 1 : 0);
137
137
  const row = (sym, label, list, painter, note) => {
138
138
  if (!list.length) return;
139
139
  const n = c.dim(String(list.length).padStart(2));
@@ -150,7 +150,9 @@ async function update() {
150
150
  row(c.y('!'), '.new', r.newSidecar, c.y, 'you edited these — new version saved as *.new, merge by hand (or re-run with --force)');
151
151
  row(c.dim('−'), 'removed', r.removed, c.dim, 'no longer part of the kit (the dashboard lives in the spectoflow package now)');
152
152
  row(c.y('!'), 'kept', r.kept, c.y, 'you modified these and they are no longer part of the kit — delete them yourself when ready');
153
- row(c.cy('+'), 'linked', r.pointers, c.cy, `${r.pointers.join(', ')} existed without a pointer — a spectoflow section was appended`);
153
+ if (r.migration.renamedBrain) console.log(` ${c.cy('→')} ${c.cy('renamed'.padEnd(9))} ${c.dim(' 1')} ${c.dim('.spectoflow/AGENTS.md → .spectoflow/SPECTOFLOW.md (the brain; your edits, if any, came along)')}`);
154
+ row(c.cy('→'), 'repointed', [...r.pointers.repointed, ...r.migration.repointedUserFiles.map((f) => '.spectoflow/' + f)], c.cy, null);
155
+ row(c.cy('+'), 'linked', r.pointers.appended, c.cy, `${r.pointers.appended.join(', ')} existed without a pointer — a spectoflow section was appended`);
154
156
  if (r.migration.movedViews.length) console.log(` ${c.cy('→')} ${c.cy('views'.padEnd(9))} ${c.dim(String(r.migration.movedViews.length).padStart(2))} ${c.dim('custom views moved to .spectoflow/dashboards/')}`);
155
157
  r.migration.conflicts.forEach((f) => console.log(` ${c.y('!')} ${c.y('conflict'.padEnd(9))} ${c.dim(`dashboards/${f} already exists — the old copy stays in dashboard/custom/ for you to merge`)}`));
156
158
  if (r.legacyLeftovers.length) console.log(` ${c.y('!')} ${c.dim('this project has no install manifest, so nothing was deleted. Safe to remove by hand: ' + r.legacyLeftovers.map((p) => '.spectoflow/' + p).join(', '))}`);
@@ -372,6 +374,40 @@ function configCmd() {
372
374
  } catch (e) { console.log(`${c.y('!')} ${e.message}`); process.exitCode = 1; }
373
375
  }
374
376
 
377
+ // ---- brain: the user's second brain (~/.spectoflow/brain.md), shared by every project ----
378
+ function brainCmd() {
379
+ const brain = require('../lib/brain');
380
+ const brainSetup = require('../lib/brain-setup');
381
+ const tilde = (p) => (p.startsWith(os.homedir()) ? '~' + p.slice(os.homedir().length) : p);
382
+ if (argv[1] === 'setup') {
383
+ const dryRun = argv.includes('--dry-run');
384
+ const rows = brainSetup.setup({ binPath: __filename, dryRun });
385
+ console.log(wordmark());
386
+ console.log(` ${c.bold('spectoflow brain setup')}${dryRun ? c.dim(' (dry-run)') : ''} ${c.dim('registers the spectoflow MCP server in each installed agent (user level, once per machine)')}\n`);
387
+ if (!rows.length) { console.log(` ${c.y('!')} No supported coding agent found on PATH.\n`); return; }
388
+ const w = Math.max(...rows.map((r) => r.label.length));
389
+ const sym = { created: c.cy('+'), added: c.g('✓'), exists: c.dim('·'), skipped: c.y('!'), manual: c.y('!'), failed: c.y('✗') };
390
+ for (const r of rows) {
391
+ console.log(` ${sym[r.status] || '?'} ${r.label.padEnd(w)} ${r.status.padEnd(8)} ${c.dim(r.via || tilde(r.file))}`);
392
+ if (r.status === 'skipped') console.log(c.dim(` couldn't parse this file (comments?) — left untouched; add a "spectoflow" server running: spectoflow mcp`));
393
+ if (r.status === 'manual') console.log(c.dim(r.id === 'goose' ? ` YAML isn't edited automatically — add this to ${tilde(r.file)}:\n` : ` this file already mentions spectoflow in a form we can't read safely — check it, and add this if it's missing:\n`) + r.manual.split('\n').map((l) => ' ' + l).join('\n'));
394
+ if (r.status === 'failed') console.log(c.dim(` ${r.detail || 'failed'}${r.manual ? `\n run it yourself: ${r.manual}` : ''}`));
395
+ }
396
+ const changed = rows.filter((r) => ['created', 'added'].includes(r.status)).length;
397
+ console.log(`\n ${dryRun ? c.dim('(dry-run — nothing was written)') : changed ? c.g(`✓ ${changed} agent(s) wired`) + c.dim(' — start a new agent session to load it') : c.dim('Nothing to change')}\n`);
398
+ return;
399
+ }
400
+ const r = brain.read();
401
+ console.log(wordmark());
402
+ console.log(` ${c.bold('Second brain')} ${c.dim(tilde(brain.brainPath()))}`);
403
+ console.log(` ${r.entries.length} entr${r.entries.length === 1 ? 'y' : 'ies'}${r.pending.length ? c.y(` · ${r.pending.length} to confirm`) : ''} ${c.dim('learned facts: ' + (r.autoAdd ? 'added directly' : 'wait for your confirmation') + ' (spectoflow config set brain.autoAdd true|false)')}\n`);
404
+ const agents = brainSetup.status();
405
+ if (!agents.length) console.log(` ${c.y('!')} No supported coding agent found on PATH.`);
406
+ agents.forEach((a) => console.log(` ${a.wired ? c.g('●') : c.dim('○')} ${a.label}${a.wired ? '' : c.dim(' not wired')}`));
407
+ if (agents.some((a) => !a.wired)) console.log(`\n ${c.dim('wire them:')} ${c.g('spectoflow brain setup')} ${c.dim('[--dry-run]')}`);
408
+ console.log('');
409
+ }
410
+
375
411
  // ---- Customize: `spectoflow skill/agent/dashboard create` — the CLI mirror of the dashboard's
376
412
  // Settings → Customize UI. Both surfaces build the same natural-language prompt (customize-prompts.js)
377
413
  // and post it through the same pipeline (runner.js's startRun — the function /api/run itself calls),
@@ -587,6 +623,10 @@ ${c.bold('Dashboard')}
587
623
  ${c.g('dashboard login')} ${c.dim('--url=<u> --token=<t>')} connect this machine to an online dashboard ${c.dim('(logout · publish · unpublish)')}
588
624
  ${c.g('projects')} ${c.dim('[remove <id>]')} list every project seen so far
589
625
 
626
+ ${c.bold('Second brain')} ${c.dim('— what spectoflow learns about you, shared by all your projects')}
627
+ ${c.g('brain')} what it holds, and which agents can reach it
628
+ ${c.g('brain setup')} ${c.dim('[--dry-run]')} register the spectoflow MCP server in your installed agents
629
+
590
630
  ${c.bold('Customize')} ${c.dim('— same as Settings → Customize, from the terminal')}
591
631
  ${c.g('skill create')} ${c.dim('"<description>" | --auto')} generate a project skill
592
632
  ${c.g('agent create')} ${c.dim('"<description>" | --auto')} generate a project agent
@@ -616,7 +656,7 @@ const HELP = {
616
656
  ${c.dim('an existing AGENTS.md/GEMINI.md is kept and gets a spectoflow pointer section appended.')}
617
657
  ${c.dim('Full list with docs links: the dashboard\'s Documentation tab, or the README.')}`,
618
658
  update: `${c.bold('spectoflow update')} ${c.dim('[--dry-run] [--force|-f]')}\n
619
- Refresh framework-owned files (engine, default agents & skills, AGENTS.md, policy…)
659
+ Refresh framework-owned files (engine, default agents & skills, SPECTOFLOW.md, policy…)
620
660
  to this CLI's version, ${c.bold('preserving your work')}: config.json, workflow.md, specs/, plans/
621
661
  and any agent/skill you edited are never overwritten (an edited file's new version lands as
622
662
  ${c.dim('*.new')} for you to merge). ${c.g('--dry-run')} previews without writing.
@@ -660,6 +700,14 @@ const HELP = {
660
700
  agents: `${c.bold('spectoflow agents')}\n List the stable team personas (name · capability · role).`,
661
701
  skills: `${c.bold('spectoflow skills')}\n List the evolving procedures (name · capability · what it does).`,
662
702
  workflow: `${c.bold('spectoflow workflow')}\n Show the pipeline steps, marking which are enabled (●) or disabled (○).`,
703
+ brain: `${c.bold('spectoflow brain')} ${c.dim('[setup [--dry-run]]')}\n
704
+ Your second brain — what spectoflow has learned about you (profile, preferences, working style,
705
+ things to avoid), in ${c.dim('~/.spectoflow/brain.md')}, shared by all your projects and editable in the
706
+ dashboard's ${c.bold('Second brain')} tab. Agents read and grow it through the ${c.g('spectoflow mcp')} server.
707
+ ${c.g('brain')} show it: entries, entries to confirm, and which installed agents are wired
708
+ ${c.g('brain setup')} register the MCP server in each installed agent's USER-level config (once
709
+ per machine; never touches an existing entry; Goose gets a snippet to paste)
710
+ Learned facts are added directly by default: ${c.g('spectoflow config set brain.autoAdd false')} to confirm them first.`,
663
711
  stop: `${c.bold('spectoflow stop')}\n Stop the running dashboard (alias for ${c.g('spectoflow dashboard stop')}).`,
664
712
  config: `${c.bold('spectoflow config')} ${c.dim('[get <key> | set <key> <value>]')}\n
665
713
  Global settings that apply to every project on this machine, stored in ${c.dim('~/.spectoflow/config.json')}:
@@ -675,6 +723,8 @@ const fns = {
675
723
  init, update, dashboard, stop: stopDashboard, status, list: listAll, help, version,
676
724
  projects: projectsCmd,
677
725
  config: configCmd,
726
+ mcp: () => require('../lib/mcp-server').serve({ version: VERSION }),
727
+ brain: brainCmd,
678
728
  agents: () => { console.log(wordmark()); printAgents(false); },
679
729
  skills: () => { console.log(wordmark()); printSkills(false); },
680
730
  workflow: () => { console.log(wordmark()); printWorkflow(false); },
package/lib/adapters.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
  /*
3
- * Per-agent adapters. The canonical brain is .spectoflow/AGENTS.md. Each agent needs a thin entry
3
+ * Per-agent adapters. The canonical brain is .spectoflow/SPECTOFLOW.md. Each agent needs a thin entry
4
4
  * file in the place IT reads, pointing back to the canonical brain — so the framework stays in
5
5
  * .spectoflow/ (swappable, no pollution) while every agent still finds its native entry point.
6
6
  * (Same pattern OpenSpec uses to support claude/codex/cursor/gemini/…)
@@ -14,7 +14,7 @@ const path = require('path');
14
14
 
15
15
  const CLAUDE_MD = `# CLAUDE.md — spectoflow
16
16
 
17
- This project uses **spectoflow**. **Read \`.spectoflow/AGENTS.md\` and follow it** as your operating
17
+ This project uses **spectoflow**. **Read \`.spectoflow/SPECTOFLOW.md\` and follow it** as your operating
18
18
  instructions (intent router, workflow, standing rules).
19
19
 
20
20
  > If \`CLAUDE.md.tomerge\` exists next to this file, it is your previous CLAUDE.md, preserved on
@@ -23,7 +23,12 @@ instructions (intent router, workflow, standing rules).
23
23
 
24
24
  **Be an expert analyst, not an order-taker.** When a request is ambiguous, **clarify before acting**:
25
25
  reflect it back and ask **one targeted question at a time** (each with a recommendation) until the need
26
- is clear — then execute. See the Clarify reflex in \`.spectoflow/AGENTS.md\`.
26
+ is clear — then execute. See the Clarify reflex in \`.spectoflow/SPECTOFLOW.md\`.
27
+
28
+ **Second brain.** Durable facts about the user come from the \`spectoflow\` MCP server — apply them. When you
29
+ learn one (a stated preference, a correction of how you work, their role), record it with \`brain_learn\`, or print
30
+ \`::spectoflow learn category=<profile|preferences|workflow|avoid> msg=<fact>\` if that tool is unavailable. Never
31
+ secrets or sensitive data.
27
32
 
28
33
  - Command: \`/spectoflow\` (\`init\` / \`status\` / or just a request).
29
34
  - Dashboard: \`spectoflow dashboard\` → http://localhost:4319
@@ -32,30 +37,40 @@ is clear — then execute. See the Clarify reflex in \`.spectoflow/AGENTS.md\`.
32
37
 
33
38
  const ROOT_AGENTS_MD = `# AGENTS.md — spectoflow
34
39
 
35
- This project uses **spectoflow**. **Read \`.spectoflow/AGENTS.md\` and follow it** as your operating
40
+ This project uses **spectoflow**. **Read \`.spectoflow/SPECTOFLOW.md\` and follow it** as your operating
36
41
  instructions. Artifacts are markdown in \`specs/\` and \`plans/\`; the workflow is \`.spectoflow/workflow.md\`.
37
42
 
38
43
  **Be an expert analyst, not an order-taker.** When a request is ambiguous, **clarify before acting**:
39
44
  reflect it back and ask **one targeted question at a time** (each with a recommendation) until the need
40
- is clear — then execute. See the Clarify reflex in \`.spectoflow/AGENTS.md\`.
45
+ is clear — then execute. See the Clarify reflex in \`.spectoflow/SPECTOFLOW.md\`.
46
+
47
+ **Second brain.** Durable facts about the user come from the \`spectoflow\` MCP server — apply them. When you
48
+ learn one (a stated preference, a correction of how you work, their role), record it with \`brain_learn\`, or print
49
+ \`::spectoflow learn category=<profile|preferences|workflow|avoid> msg=<fact>\` if that tool is unavailable. Never
50
+ secrets or sensitive data.
41
51
  `;
42
52
 
43
53
  const GEMINI_MD = `# GEMINI.md — spectoflow
44
54
 
45
- This project uses **spectoflow**. **Read \`.spectoflow/AGENTS.md\` and follow it** as your operating
55
+ This project uses **spectoflow**. **Read \`.spectoflow/SPECTOFLOW.md\` and follow it** as your operating
46
56
  instructions (intent router, workflow, standing rules). Artifacts are markdown in \`specs/\` and
47
57
  \`plans/\`; the workflow is \`.spectoflow/workflow.md\`.
48
58
 
49
59
  **Be an expert analyst, not an order-taker.** When a request is ambiguous, **clarify before acting**:
50
60
  reflect it back and ask **one targeted question at a time** (each with a recommendation) until the need
51
- is clear — then execute. See the Clarify reflex in \`.spectoflow/AGENTS.md\`.
61
+ is clear — then execute. See the Clarify reflex in \`.spectoflow/SPECTOFLOW.md\`.
62
+
63
+ **Second brain.** Durable facts about the user come from the \`spectoflow\` MCP server — apply them. When you
64
+ learn one (a stated preference, a correction of how you work, their role), record it with \`brain_learn\`, or print
65
+ \`::spectoflow learn category=<profile|preferences|workflow|avoid> msg=<fact>\` if that tool is unavailable. Never
66
+ secrets or sensitive data.
52
67
  `;
53
68
 
54
69
  const SLASH_CMD = `---
55
70
  description: spectoflow — spec-driven control (init / status / or just a request)
56
71
  ---
57
72
 
58
- Read \`.spectoflow/AGENTS.md\` and \`.spectoflow/config.json\` first.
73
+ Read \`.spectoflow/SPECTOFLOW.md\` and \`.spectoflow/config.json\` first.
59
74
 
60
75
  Argument: \`$ARGUMENTS\`
61
76
 
@@ -63,7 +78,7 @@ Argument: \`$ARGUMENTS\`
63
78
  \`specs/\` and \`plans/\` are empty, greet me, state the mode, and start Intake (brainstorm → analysis
64
79
  → spec → plan) by asking what I want to build.
65
80
  - \`status\`: summarize progress from \`plans/*.md\` and \`.spectoflow/runtime.json\`.
66
- - otherwise: treat \`$ARGUMENTS\` as a request and run the Router in \`.spectoflow/AGENTS.md\`.
81
+ - otherwise: treat \`$ARGUMENTS\` as a request and run the Router in \`.spectoflow/SPECTOFLOW.md\`.
67
82
  `;
68
83
 
69
84
  // Priority order = which agent becomes the default when several are detected.
@@ -232,43 +247,60 @@ const POINTER_START = '<!-- spectoflow:start -->';
232
247
  const POINTER_END = '<!-- spectoflow:end -->';
233
248
  const POINTER_BLOCK = `${POINTER_START}\n${ROOT_AGENTS_MD.replace(/^# .*\n/, '## spectoflow\n').trimEnd()}\n${POINTER_END}\n`;
234
249
 
235
- const hasPointer = (text) => text.includes('.spectoflow/AGENTS.md');
250
+ // The brain was `.spectoflow/AGENTS.md` before 0.28 — renamed so it can't be mistaken for the
251
+ // project-root AGENTS.md. Pointers written back then are rewritten in place, never duplicated.
252
+ const BRAIN = '.spectoflow/SPECTOFLOW.md';
253
+ const LEGACY_BRAIN = '.spectoflow/AGENTS.md';
254
+ const ENTRY_FILES = [...MEMORY_FILES, '.claude/commands/spectoflow.md'];
236
255
 
237
- function appendPointer(fp, dryRun) {
238
- const text = fs.readFileSync(fp, 'utf8');
239
- if (hasPointer(text)) return false;
240
- if (!dryRun) fs.writeFileSync(fp, text.replace(/\s*$/, '') + '\n\n' + POINTER_BLOCK);
241
- return true;
256
+ // Bring one existing entry file up to date: rewrite a legacy pointer, then (memory files only)
257
+ // append the pointer section if it still has none. Returns what it did (or would do, on dryRun).
258
+ function linkEntry(fp, rel, dryRun) {
259
+ const before = fs.readFileSync(fp, 'utf8');
260
+ let text = before.split(LEGACY_BRAIN).join(BRAIN);
261
+ const repointed = text !== before;
262
+ const appended = MEMORY_FILES.includes(rel) && !text.includes(BRAIN);
263
+ if (appended) text = text.replace(/\s*$/, '') + '\n\n' + POINTER_BLOCK;
264
+ if ((repointed || appended) && !dryRun) fs.writeFileSync(fp, text);
265
+ return { repointed, appended };
242
266
  }
243
267
 
244
268
  // Write the native entry-file shims for each selected agent. Shared files (AGENTS.md across
245
- // codex/cursor) are handled once. A memory file that already exists gets the pointer appended.
269
+ // codex/cursor) are handled once. An entry file that already exists is linked instead (linkEntry).
246
270
  function generate(projectRoot, agents) {
247
271
  const list = (agents && agents.length ? agents : ['claude', 'codex']).map(byId).filter(Boolean);
248
- const written = [], appended = [];
272
+ const written = [], appended = [], repointed = [], seen = new Set();
249
273
  for (const a of list) {
250
274
  for (const e of a.entries) {
251
- if (written.includes(e.path) || appended.includes(e.path)) continue;
275
+ if (seen.has(e.path)) continue;
276
+ seen.add(e.path);
252
277
  const fp = path.join(projectRoot, e.path);
253
278
  if (!fs.existsSync(fp)) {
254
279
  fs.mkdirSync(path.dirname(fp), { recursive: true });
255
280
  fs.writeFileSync(fp, e.content);
256
281
  written.push(e.path);
257
- } else if (MEMORY_FILES.includes(e.path) && appendPointer(fp)) {
258
- appended.push(e.path);
282
+ continue;
259
283
  }
284
+ const r = linkEntry(fp, e.path, false);
285
+ if (r.appended) appended.push(e.path);
286
+ if (r.repointed) repointed.push(e.path);
260
287
  }
261
288
  }
262
- return { written, appended };
289
+ return { written, appended, repointed };
263
290
  }
264
291
 
265
- // For `update`: an already-installed project whose existing memory files never got the pointer.
266
- // Only repairs files that exist — never creates one for an agent the project may not use.
292
+ // For `update`: link every entry file that exists — never creates one for an agent the project may
293
+ // not use.
267
294
  function ensurePointers(projectRoot, dryRun = false) {
268
- return MEMORY_FILES.filter((rel) => {
295
+ const out = { appended: [], repointed: [] };
296
+ for (const rel of ENTRY_FILES) {
269
297
  const fp = path.join(projectRoot, rel);
270
- return fs.existsSync(fp) && appendPointer(fp, dryRun);
271
- });
298
+ if (!fs.existsSync(fp)) continue;
299
+ const r = linkEntry(fp, rel, dryRun);
300
+ if (r.appended) out.appended.push(rel);
301
+ if (r.repointed) out.repointed.push(rel);
302
+ }
303
+ return out;
272
304
  }
273
305
 
274
306
  // { id: runner } defaults for the given agents — used to seed config.runners at init.
@@ -287,4 +319,4 @@ function knownAgents() {
287
319
  return REGISTRY.map((a) => ({ id: a.id, label: a.label, bin: a.detect.bin, dirs: a.detect.dirs || [], runner: a.runner, headless: a.headless, docsUrl: a.docsUrl }));
288
320
  }
289
321
 
290
- module.exports = { generate, ensurePointers, defaultRunners, REGISTRY, knownAgents };
322
+ module.exports = { generate, ensurePointers, defaultRunners, REGISTRY, knownAgents, BRAIN, LEGACY_BRAIN };
@@ -0,0 +1,165 @@
1
+ 'use strict';
2
+ /*
3
+ * `spectoflow brain setup` — registers the `spectoflow mcp` server (lib/mcp-server.js) in the
4
+ * USER-LEVEL MCP config of every coding agent installed on this machine, once per machine. User level
5
+ * because the second brain is personal, and because project-level MCP config is unreliable or absent
6
+ * for several agents (trusted-projects-only in Codex, ignored by Antigravity, missing in Goose/Kimi).
7
+ *
8
+ * Paths and entry shapes come from each agent's own docs (research pass 2026-09-16, see
9
+ * docs/second-brain-design.md). Same rules as lib/mcp.js: an existing entry is never touched, a file
10
+ * that can't be parsed is never rewritten. Claude Code is the one agent wired through its own CLI —
11
+ * it rewrites ~/.claude.json constantly, so editing that file directly could lose the write.
12
+ */
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+ const path = require('path');
16
+ const { spawnSync } = require('child_process');
17
+ const { REGISTRY } = require('./adapters');
18
+ const detect = require('./detect');
19
+ const { mergeMcpServer } = require('./mcp');
20
+
21
+ const NAME = 'spectoflow';
22
+
23
+ // `spectoflow mcp` when the `spectoflow` on PATH is this very install; otherwise node + this bin's
24
+ // absolute path (running from a clone, or another version on PATH). Always node + path on Windows: there
25
+ // `spectoflow` is an npm .cmd shim, which MCP hosts that spawn without a shell can't start.
26
+ function serverCommand({ binPath, env = process.env, platform = process.platform } = {}) {
27
+ const found = findOnPath('spectoflow', env, platform);
28
+ let same = false;
29
+ if (found && platform !== 'win32') { try { same = fs.realpathSync(found) === fs.realpathSync(binPath); } catch { same = false; } }
30
+ const cmd = same ? { command: 'spectoflow', args: ['mcp'] } : { command: process.execPath, args: [binPath, 'mcp'] };
31
+ if (env.SPECTOFLOW_HOME) cmd.env = { SPECTOFLOW_HOME: env.SPECTOFLOW_HOME };
32
+ return cmd;
33
+ }
34
+ function findOnPath(bin, env, platform) {
35
+ const exts = platform === 'win32' ? ['', ...(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)] : [''];
36
+ for (const d of (env.PATH || env.Path || '').split(path.delimiter).filter(Boolean)) {
37
+ for (const e of exts) { const fp = path.join(d, bin + e); if (fs.existsSync(fp)) return fp; }
38
+ }
39
+ return null;
40
+ }
41
+
42
+ const home = () => os.homedir();
43
+ const appData = () => process.env.APPDATA || path.join(home(), 'AppData', 'Roaming');
44
+
45
+ // id → how to wire it. `file` is resolved lazily (tests point HOME elsewhere).
46
+ const TARGETS = {
47
+ claude: { kind: 'claude-cli', file: () => path.join(process.env.CLAUDE_CONFIG_DIR || home(), '.claude.json') },
48
+ codex: { kind: 'toml', file: () => path.join(process.env.CODEX_HOME || path.join(home(), '.codex'), 'config.toml') },
49
+ cursor: { kind: 'json', file: () => path.join(home(), '.cursor', 'mcp.json') },
50
+ gemini: { kind: 'json', file: () => path.join(home(), '.gemini', 'settings.json') },
51
+ opencode: { kind: 'opencode', file: () => path.join(home(), '.config', 'opencode', 'opencode.json') },
52
+ kiro: { kind: 'json', file: () => path.join(home(), '.kiro', 'settings', 'mcp.json') },
53
+ antigravity: { kind: 'json', file: () => path.join(home(), '.gemini', 'config', 'mcp_config.json') },
54
+ copilot: { kind: 'json', extra: { type: 'local', tools: ['*'] }, file: () => path.join(process.env.COPILOT_HOME || path.join(home(), '.copilot'), 'mcp-config.json') },
55
+ 'amazon-q': { kind: 'json', file: () => path.join(home(), '.aws', 'amazonq', 'mcp.json') },
56
+ droid: { kind: 'json', file: () => path.join(home(), '.factory', 'mcp.json') },
57
+ auggie: { kind: 'json', file: () => path.join(home(), '.augment', 'settings.json') },
58
+ goose: { kind: 'yaml-snippet', file: () => (process.platform === 'win32' ? path.join(appData(), 'Block', 'goose', 'config', 'config.yaml') : path.join(home(), '.config', 'goose', 'config.yaml')) },
59
+ kimi: { kind: 'json', file: () => path.join(home(), '.kimi', 'mcp.json') },
60
+ };
61
+
62
+ const tomlString = (s) => JSON.stringify(String(s));
63
+ function tomlBlock(cmd) {
64
+ const lines = [`[mcp_servers.${NAME}]`, `command = ${tomlString(cmd.command)}`, `args = [${cmd.args.map(tomlString).join(', ')}]`];
65
+ if (cmd.env) lines.push(`env = { ${Object.entries(cmd.env).map(([k, v]) => `${k} = ${tomlString(v)}`).join(', ')} }`);
66
+ return lines.join('\n') + '\n';
67
+ }
68
+ function yamlBlock(cmd) {
69
+ const q = (s) => JSON.stringify(String(s));
70
+ const lines = ['extensions:', ` ${NAME}:`, ' type: stdio', ` name: ${NAME}`, ' enabled: true', ` cmd: ${q(cmd.command)}`, ` args: [${cmd.args.map(q).join(', ')}]`, ' timeout: 300'];
71
+ if (cmd.env) lines.push(` envs: { ${Object.entries(cmd.env).map(([k, v]) => `${k}: ${q(v)}`).join(', ')} }`);
72
+ return lines.join('\n') + '\n';
73
+ }
74
+ const readText = (fp) => { try { return fs.readFileSync(fp, 'utf8'); } catch { return null; } };
75
+
76
+ // Is a `spectoflow` MCP server declared in this Codex TOML, in any of the forms TOML allows:
77
+ // [mcp_servers.spectoflow] (bare or quoted key, trailing comment), a dotted key, or a key inside [mcp_servers].
78
+ function tomlHasServer(text) {
79
+ const key = `(["']?)${NAME}\\1`;
80
+ if (new RegExp(`^\\s*\\[\\s*mcp_servers\\s*\\.\\s*${key}\\s*(\\.[^\\]]*)?\\]\\s*(#.*)?$`, 'm').test(text)) return true;
81
+ if (new RegExp(`^\\s*mcp_servers\\s*\\.\\s*${key}\\s*[.=]`, 'm').test(text)) return true;
82
+ let inTable = false;
83
+ for (const line of text.split(/\r?\n/)) {
84
+ const h = line.match(/^\s*\[\s*([^\]]+?)\s*\]/);
85
+ if (h) { inTable = h[1].replace(/\s+/g, '') === 'mcp_servers'; continue; }
86
+ if (inTable && new RegExp(`^\\s*${key}\\s*[.=]`).test(line)) return true;
87
+ }
88
+ return false;
89
+ }
90
+
91
+ // Is the spectoflow server already registered for this agent? (true/false; null = can't tell)
92
+ function isWired(id) {
93
+ const t = TARGETS[id]; if (!t) return null;
94
+ const fp = t.file(), text = readText(fp);
95
+ if (text === null) return false;
96
+ if (t.kind === 'toml') return tomlHasServer(text);
97
+ if (t.kind === 'yaml-snippet') return /^extensions:\s*$/m.test(text) && new RegExp(`^ {2}${NAME}:\\s*$`, 'm').test(text);
98
+ try {
99
+ const doc = JSON.parse(text);
100
+ const map = t.kind === 'opencode' ? doc.mcp : doc.mcpServers;
101
+ return !!(map && typeof map === 'object' && Object.prototype.hasOwnProperty.call(map, NAME));
102
+ } catch { return null; }
103
+ }
104
+
105
+ function wireOne(id, cmd, { dryRun, run }) {
106
+ const t = TARGETS[id], fp = t.file();
107
+ if (t.kind === 'claude-cli') {
108
+ if (isWired(id)) return { status: 'exists', file: fp };
109
+ // Name before --env: --env takes several values and would swallow the name otherwise.
110
+ const args = ['mcp', 'add', '--scope', 'user', NAME, ...Object.entries(cmd.env || {}).flatMap(([k, v]) => ['--env', `${k}=${v}`]), '--', cmd.command, ...cmd.args];
111
+ if (dryRun) return { status: 'added', file: fp, via: `claude ${args.join(' ')}` };
112
+ const r = run('claude', args);
113
+ const out = `${r.stderr || ''}${r.stdout || ''}`.trim();
114
+ if (r.status === 0) return { status: 'added', file: fp, via: 'claude mcp add' };
115
+ if (/already exists/i.test(out)) return { status: 'exists', file: fp };
116
+ return { status: 'failed', file: fp, detail: out || (r.error && r.error.message) || 'failed', manual: `claude ${args.join(' ')}` };
117
+ }
118
+ if (t.kind === 'json') return { status: mergeMcpServer(fp, NAME, { ...(t.extra || {}), command: cmd.command, args: cmd.args, ...(cmd.env ? { env: cmd.env } : {}) }, { dryRun }), file: fp };
119
+ if (t.kind === 'opencode') return { status: mergeMcpServer(fp, NAME, { type: 'local', command: [cmd.command, ...cmd.args], enabled: true, ...(cmd.env ? { environment: cmd.env } : {}) }, { key: 'mcp', dryRun }), file: fp };
120
+ if (t.kind === 'toml') {
121
+ const text = readText(fp);
122
+ if (text !== null && isWired(id)) return { status: 'exists', file: fp };
123
+ // Any other mention of spectoflow is a form we can't classify: appending could declare the table twice,
124
+ // and a duplicate key makes Codex refuse its whole config. Hand the user the block instead.
125
+ if (text !== null && text.includes(NAME)) return { status: 'manual', file: fp, manual: tomlBlock(cmd) };
126
+ // `mcp_servers = { … }` (an inline table) can't be extended by a [mcp_servers.x] header either.
127
+ if (text !== null && /^\s*mcp_servers\s*=/m.test(text)) return { status: 'manual', file: fp, manual: tomlBlock(cmd) };
128
+ if (!dryRun) {
129
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
130
+ fs.writeFileSync(fp, text === null ? tomlBlock(cmd) : text.replace(/\s*$/, '') + '\n\n' + tomlBlock(cmd));
131
+ }
132
+ return { status: text === null ? 'created' : 'added', file: fp };
133
+ }
134
+ // Goose: YAML can't be edited safely without a parser — hand the user the exact block instead.
135
+ if (isWired(id)) return { status: 'exists', file: fp };
136
+ return { status: 'manual', file: fp, manual: yamlBlock(cmd) };
137
+ }
138
+
139
+ // No shell on POSIX. On Windows `claude` is a .cmd shim, which needs cmd.exe — with every argument quoted,
140
+ // or a path like C:\\Program Files\\nodejs\\node.exe would be split.
141
+ function defaultRun(bin, args) {
142
+ if (process.platform !== 'win32') return spawnSync(bin, args, { encoding: 'utf8', windowsHide: true });
143
+ const q = (a) => `"${String(a).replace(/"/g, '""')}"`;
144
+ return spawnSync('cmd.exe', ['/d', '/s', '/c', `"${[bin, ...args].map(q).join(' ')}"`], { encoding: 'utf8', windowsHide: true, windowsVerbatimArguments: true });
145
+ }
146
+
147
+ // → [{ id, label, status: created|added|exists|skipped|manual|failed, file, manual?, detail? }]
148
+ // for every agent installed on this machine. `agents` overrides detection (tests, explicit choice).
149
+ function setup({ binPath, dryRun = false, agents, run = defaultRun, env = process.env } = {}) {
150
+ const cmd = serverCommand({ binPath, env });
151
+ const ids = agents || REGISTRY.filter((a) => TARGETS[a.id] && detect.binOnPath(a.detect.bin, { env })).map((a) => a.id);
152
+ return ids.filter((id) => TARGETS[id]).map((id) => {
153
+ const label = (REGISTRY.find((a) => a.id === id) || {}).label || id;
154
+ try { return { id, label, ...wireOne(id, cmd, { dryRun, run }) }; }
155
+ catch (e) { return { id, label, status: 'failed', file: TARGETS[id].file(), detail: e.message }; }
156
+ });
157
+ }
158
+
159
+ // For the dashboard page: each installed agent and whether the server is registered for it.
160
+ function status({ env = process.env } = {}) {
161
+ return REGISTRY.filter((a) => TARGETS[a.id] && detect.binOnPath(a.detect.bin, { env }))
162
+ .map((a) => ({ id: a.id, label: a.label, wired: isWired(a.id) === true }));
163
+ }
164
+
165
+ module.exports = { setup, status, isWired, serverCommand, TARGETS, NAME };