ac-framework 1.9.7 → 1.9.9

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
@@ -6,7 +6,7 @@ It combines three layers in one CLI:
6
6
  - template-based assistant configurations for multiple IDEs and AI CLIs
7
7
  - a built-in spec-driven workflow inspired by OpenSpec / spec-driven development
8
8
  - a persistent local memory system with MCP integration for supported assistants
9
- - an optional collaborative multi-agent runtime powered by OpenCode + tmux
9
+ - an optional collaborative multi-agent runtime powered by OpenCode + zellij (tmux fallback)
10
10
 
11
11
  ## Why AC Framework
12
12
 
@@ -26,7 +26,7 @@ The goal is simple: help AI write better code, with more context, more disciplin
26
26
  - `Spec-driven workflow` - use `acfm spec` to initialize, create, validate, continue, and archive structured changes.
27
27
  - `Persistent memory` - store architectural decisions, bugfixes, refactors, conventions, and context in a local SQLite memory database.
28
28
  - `MCP integration` - connect the memory system to supported assistants through MCP so they can recall and save context directly.
29
- - `Collaborative agents (optional)` - enable SynapseGrid to run planner/critic/coder/reviewer in coordinated tmux panes with shared context.
29
+ - `Collaborative agents (optional)` - enable SynapseGrid to run planner/critic/coder/reviewer in coordinated zellij panes (tmux fallback) with shared context.
30
30
  - `GitHub sync` - use `acfm init --latest` or `acfm update` to pull the latest framework content from GitHub.
31
31
  - `Legacy compatibility` - `.acfm/` is the new default, but existing `openspec/` directories still work.
32
32
 
@@ -57,7 +57,7 @@ The CLI now guides you through:
57
57
  2. choose one or more assistants from that template
58
58
  3. install the matching root instruction files like `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, or `copilot-instructions.md`
59
59
  4. optionally initialize NexusVault persistent memory and MCP connections
60
- 5. optionally enable SynapseGrid collaborative agents (auto-installs OpenCode + tmux)
60
+ 5. optionally enable SynapseGrid collaborative agents (auto-installs OpenCode + zellij/tmux)
61
61
 
62
62
  If enabled, `acfm init` also auto-installs the optional SynapseGrid MCP server into detected assistants.
63
63
 
@@ -130,7 +130,7 @@ Some assistants include bundled companions automatically:
130
130
 
131
131
  ### Collaborative Agents (Optional)
132
132
 
133
- SynapseGrid is an optional collaborative runtime that coordinates 4 OpenCode-backed roles in tmux panes:
133
+ SynapseGrid is an optional collaborative runtime that coordinates 4 OpenCode-backed roles in multiplexer panes (zellij preferred, tmux fallback):
134
134
  - planner
135
135
  - critic
136
136
  - coder
@@ -140,34 +140,60 @@ Each role runs in turn against a shared, accumulating context so outputs from on
140
140
 
141
141
  | Command | Description |
142
142
  |---|---|
143
- | `acfm agents setup` | Install optional dependencies (`opencode` and `tmux`) |
144
- | `acfm agents doctor` | Validate OpenCode/tmux/model preflight before start |
143
+ | `acfm agents setup` | Install optional dependencies (`opencode` and `zellij`/`tmux`) |
144
+ | `acfm agents doctor` | Validate OpenCode/multiplexer/model preflight before start |
145
145
  | `acfm agents install-mcps` | Install SynapseGrid MCP server for detected assistants |
146
146
  | `acfm agents uninstall-mcps` | Remove SynapseGrid MCP server from assistants |
147
147
  | `acfm agents start --task "..." --model-coder provider/model` | Start session with optional per-role models |
148
+ | `acfm agents start --task "..." --mux zellij` | Start session forcing zellij backend (`auto`/`tmux` also supported) |
149
+ | `acfm agents runtime get` | Show configured multiplexer backend (`auto`, `zellij`, `tmux`) |
150
+ | `acfm agents runtime set zellij` | Persist preferred multiplexer backend |
148
151
  | `acfm agents resume` | Resume a previous session and recreate workers if needed |
149
152
  | `acfm agents list` | List recent SynapseGrid sessions |
150
- | `acfm agents attach` | Attach directly to the SynapseGrid tmux session |
151
- | `acfm agents live` | Attach to full live tmux view (all agents) |
153
+ | `acfm agents attach` | Attach directly to the active SynapseGrid multiplexer session |
154
+ | `acfm agents live` | Attach to full live multiplexer view (all agents) |
152
155
  | `acfm agents logs` | Show recent worker logs (all roles or one role) |
156
+ | `acfm agents transcript --role all --limit 40` | Show captured cross-agent transcript |
157
+ | `acfm agents summary` | Show generated collaboration meeting summary |
153
158
  | `acfm agents export --format md --out file.md` | Export transcript in Markdown or JSON |
154
159
  | `acfm agents send "..."` | Send a new user message into the active session |
155
160
  | `acfm agents status` | Show current collaborative session state |
161
+ | `acfm agents model list` | List available models grouped by provider |
162
+ | `acfm agents model choose` | Interactively pick provider/model and save target role |
156
163
  | `acfm agents model get` | Show default model config (global and per-role) |
157
164
  | `acfm agents model set --role coder provider/model` | Persist a default model for one role |
158
165
  | `acfm agents model clear --role all` | Clear persisted model defaults |
159
166
  | `acfm agents stop` | Stop the active collaborative session |
160
167
 
168
+ ### MCP collaborative run mode (recommended)
169
+
170
+ When driving SynapseGrid from another agent via MCP, prefer asynchronous run tools over role-by-role stepping:
171
+
172
+ - `collab_start_session` to initialize session and optional zellij/tmux workers
173
+ - `collab_invoke_team` to launch full 4-role collaboration run
174
+ - `collab_wait_run` to wait for completion/failure with bounded timeout
175
+ - `collab_get_result` to fetch final consolidated output and run diagnostics
176
+ - `collab_cancel_run` to cancel a running collaboration safely
177
+
178
+ `collab_step` remains available for manual/debug control, but is less robust for long tasks.
179
+
161
180
  ### SynapseGrid troubleshooting
162
181
 
163
182
  - If transcript entries show `Agent failed: spawn opencode ENOENT`, run `acfm agents setup` to install dependencies and then retry.
164
183
  - Attach to worker panes with `acfm agents live` (or `acfm agents attach`) to see real-time role discussion.
165
184
  - Inspect worker errors quickly with `acfm agents logs --role all --lines 120`.
166
- - MCP starts can now create tmux workers directly; if your assistant used headless steps before, start a new session and ensure worker spawning is enabled.
167
- - Configure role models directly at start (for example `--model-planner`, `--model-coder`) or persist defaults via `acfm agents model set`.
168
- - Default SynapseGrid model fallback is `opencode/minimax-m2.5-free`.
185
+ - Inspect collaborative discussion with `acfm agents transcript` and `acfm agents summary`.
186
+ - MCP starts can now create zellij/tmux workers directly; if your assistant used headless steps before, start a new session and ensure worker spawning is enabled.
187
+ - Configure role models directly at start (for example `--model-planner`, `--model-coder`) or persist defaults via `acfm agents model choose` / `acfm agents model set`.
188
+ - Default SynapseGrid model fallback is `opencode/mimo-v2-pro-free`.
169
189
  - Run `acfm agents doctor` when panes look idle to confirm model/provider preflight health.
170
190
 
191
+ Each collaborative session now keeps human-readable artifacts under `~/.acfm/synapsegrid/<sessionId>/`:
192
+ - `transcript.jsonl`: full chronological message stream
193
+ - `turns/*.json`: one file per round/role turn with captured output metadata
194
+ - `meeting-log.md`: incremental meeting notes generated per turn
195
+ - `meeting-summary.md`: final consolidated summary (roles, decisions, open issues, risks, action items)
196
+
171
197
  ### Spec Workflow
172
198
 
173
199
  | Command | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ac-framework",
3
- "version": "1.9.7",
3
+ "version": "1.9.9",
4
4
  "description": "Agentic Coding Framework - Multi-assistant configuration system with OpenSpec workflows",
5
5
  "main": "src/index.js",
6
6
  "exports": {
@@ -0,0 +1,120 @@
1
+ export function summarizeText(text, maxLen = 700) {
2
+ if (typeof text !== 'string') return '';
3
+ const normalized = text.replace(/\r\n/g, '\n').trim();
4
+ if (!normalized) return '';
5
+ if (normalized.length <= maxLen) return normalized;
6
+ return `${normalized.slice(0, Math.max(0, maxLen - 3)).trimEnd()}...`;
7
+ }
8
+
9
+ export function extractBulletLines(text, limit = 5) {
10
+ if (typeof text !== 'string') return [];
11
+ const bullets = text
12
+ .split('\n')
13
+ .map((line) => line.trim())
14
+ .filter((line) => /^[-*]\s+/.test(line) || /^\d+\)\s+/.test(line));
15
+ return bullets.slice(0, Math.max(0, limit));
16
+ }
17
+
18
+ export function createTurnRecord({ round, role, model, content, events }) {
19
+ const snippet = summarizeText(content, 1000);
20
+ const keyPoints = extractBulletLines(content, 6);
21
+ return {
22
+ round,
23
+ role,
24
+ model: model || null,
25
+ timestamp: new Date().toISOString(),
26
+ snippet,
27
+ keyPoints,
28
+ eventCount: Array.isArray(events) ? events.length : 0,
29
+ };
30
+ }
31
+
32
+ export function updateSharedContext(prev, turn) {
33
+ const base = prev && typeof prev === 'object'
34
+ ? prev
35
+ : { decisions: [], openIssues: [], risks: [], actionItems: [], notes: [] };
36
+
37
+ const next = {
38
+ decisions: [...(base.decisions || [])],
39
+ openIssues: [...(base.openIssues || [])],
40
+ risks: [...(base.risks || [])],
41
+ actionItems: [...(base.actionItems || [])],
42
+ notes: [...(base.notes || [])],
43
+ };
44
+
45
+ const summary = summarizeText(turn?.snippet || turn?.content || '', 300);
46
+ if (summary) {
47
+ next.notes.push(`[r${turn.round}] ${turn.role}: ${summary}`);
48
+ }
49
+
50
+ const lines = String(turn?.snippet || turn?.content || '')
51
+ .split('\n')
52
+ .map((line) => line.trim())
53
+ .filter(Boolean);
54
+
55
+ for (const line of lines) {
56
+ const lower = line.toLowerCase();
57
+ if (lower.includes('risk') || lower.includes('failure') || lower.includes('blind spot')) {
58
+ next.risks.push(`[r${turn.round}] ${turn.role}: ${line}`);
59
+ }
60
+ if (lower.includes('open issue') || lower.includes('blocker') || lower.includes('unresolved')) {
61
+ next.openIssues.push(`[r${turn.round}] ${turn.role}: ${line}`);
62
+ }
63
+ if (lower.includes('action') || lower.includes('next step') || lower.includes('implement')) {
64
+ next.actionItems.push(`[r${turn.round}] ${turn.role}: ${line}`);
65
+ }
66
+ if (lower.includes('decision') || lower.includes('agreed') || lower.includes('approve')) {
67
+ next.decisions.push(`[r${turn.round}] ${turn.role}: ${line}`);
68
+ }
69
+ }
70
+
71
+ const dedupe = (arr) => [...new Set(arr)].slice(-30);
72
+ return {
73
+ decisions: dedupe(next.decisions),
74
+ openIssues: dedupe(next.openIssues),
75
+ risks: dedupe(next.risks),
76
+ actionItems: dedupe(next.actionItems),
77
+ notes: dedupe(next.notes),
78
+ };
79
+ }
80
+
81
+ export function buildMeetingSummary(messages = [], run = null, sharedContext = null) {
82
+ const byRole = new Map();
83
+ for (const msg of messages) {
84
+ if (!msg?.from || msg.from === 'user') continue;
85
+ if (!byRole.has(msg.from)) byRole.set(msg.from, []);
86
+ byRole.get(msg.from).push(msg.content || '');
87
+ }
88
+
89
+ const roles = ['planner', 'critic', 'coder', 'reviewer'];
90
+ const lines = [];
91
+ lines.push('# SynapseGrid Meeting Summary');
92
+ if (run?.runId) lines.push(`Run: ${run.runId}`);
93
+ if (run?.status) lines.push(`Status: ${run.status}`);
94
+ lines.push('');
95
+ lines.push('## Per-role recap');
96
+
97
+ for (const role of roles) {
98
+ const items = byRole.get(role) || [];
99
+ const last = items.length > 0 ? items[items.length - 1] : '';
100
+ lines.push(`- ${role}: ${summarizeText(last, 500) || '(no contribution captured)'}`);
101
+ }
102
+
103
+ const ctx = sharedContext && typeof sharedContext === 'object' ? sharedContext : null;
104
+ if (ctx) {
105
+ lines.push('');
106
+ lines.push('## Decisions');
107
+ for (const entry of (ctx.decisions || []).slice(-8)) lines.push(`- ${entry}`);
108
+ lines.push('');
109
+ lines.push('## Open issues');
110
+ for (const entry of (ctx.openIssues || []).slice(-8)) lines.push(`- ${entry}`);
111
+ lines.push('');
112
+ lines.push('## Risks');
113
+ for (const entry of (ctx.risks || []).slice(-8)) lines.push(`- ${entry}`);
114
+ lines.push('');
115
+ lines.push('## Action items');
116
+ for (const entry of (ctx.actionItems || []).slice(-8)) lines.push(`- ${entry}`);
117
+ }
118
+
119
+ return lines.join('\n').trim() + '\n';
120
+ }
@@ -10,10 +10,13 @@ const CONFIG_PATH = join(ACFM_DIR, 'config.json');
10
10
 
11
11
  function normalizeConfig(raw) {
12
12
  const agents = raw?.agents && typeof raw.agents === 'object' ? raw.agents : {};
13
+ const configuredMultiplexer = typeof agents.multiplexer === 'string' ? agents.multiplexer.trim().toLowerCase() : '';
14
+ const multiplexer = ['auto', 'zellij', 'tmux'].includes(configuredMultiplexer) ? configuredMultiplexer : 'auto';
13
15
  return {
14
16
  agents: {
15
17
  defaultModel: normalizeModelId(agents.defaultModel) || DEFAULT_SYNAPSE_MODEL,
16
18
  defaultRoleModels: sanitizeRoleModels(agents.defaultRoleModels),
19
+ multiplexer,
17
20
  },
18
21
  };
19
22
  }
@@ -4,6 +4,8 @@ import { join } from 'node:path';
4
4
  export const COLLAB_SYSTEM_NAME = 'SynapseGrid';
5
5
  export const COLLAB_ROLES = ['planner', 'critic', 'coder', 'reviewer'];
6
6
  export const DEFAULT_MAX_ROUNDS = 3;
7
- export const DEFAULT_SYNAPSE_MODEL = 'opencode/minimax-m2.5-free';
7
+ export const DEFAULT_SYNAPSE_MODEL = 'opencode/mimo-v2-pro-free';
8
+ export const DEFAULT_ROLE_TIMEOUT_MS = 180000;
9
+ export const DEFAULT_ROLE_RETRIES = 1;
8
10
  export const SESSION_ROOT_DIR = join(homedir(), '.acfm', 'synapsegrid');
9
11
  export const CURRENT_SESSION_FILE = join(SESSION_ROOT_DIR, 'current-session.json');
@@ -42,7 +42,24 @@ function parseOpenCodeRunOutput(stdout) {
42
42
  return stdout.trim();
43
43
  }
44
44
 
45
- export async function runOpenCodePrompt({ prompt, cwd, model, agent, timeoutMs = 180000, binaryPath }) {
45
+ function parseOpenCodeEvents(stdout) {
46
+ const lines = stdout
47
+ .split('\n')
48
+ .map((line) => line.trim())
49
+ .filter(Boolean);
50
+
51
+ const events = [];
52
+ for (const line of lines) {
53
+ try {
54
+ events.push(JSON.parse(line));
55
+ } catch {
56
+ // ignore malformed event lines
57
+ }
58
+ }
59
+ return events;
60
+ }
61
+
62
+ export async function runOpenCodePromptDetailed({ prompt, cwd, model, agent, timeoutMs = 180000, binaryPath }) {
46
63
  const binary = binaryPath || process.env.ACFM_OPENCODE_BIN || 'opencode';
47
64
  const args = ['run', '--format', 'json'];
48
65
  if (model) {
@@ -97,9 +114,88 @@ export async function runOpenCodePrompt({ prompt, cwd, model, agent, timeoutMs =
97
114
  });
98
115
  });
99
116
 
100
- const parsed = parseOpenCodeRunOutput(stdout);
101
- if (!parsed && stderr?.trim()) {
102
- return stderr.trim();
117
+ const text = parseOpenCodeRunOutput(stdout);
118
+ const events = parseOpenCodeEvents(stdout);
119
+
120
+ return {
121
+ text: text || (stderr?.trim() || ''),
122
+ stdout,
123
+ stderr,
124
+ events,
125
+ };
126
+ }
127
+
128
+ export async function runOpenCodePrompt({ prompt, cwd, model, agent, timeoutMs = 180000, binaryPath }) {
129
+ const detailed = await runOpenCodePromptDetailed({
130
+ prompt,
131
+ cwd,
132
+ model,
133
+ agent,
134
+ timeoutMs,
135
+ binaryPath,
136
+ });
137
+ return detailed.text;
138
+ }
139
+
140
+ export async function listOpenCodeModels({ provider = null, binaryPath, refresh = false, timeoutMs = 45000 }) {
141
+ const binary = binaryPath || process.env.ACFM_OPENCODE_BIN || 'opencode';
142
+ const args = ['models'];
143
+ if (provider) {
144
+ args.push(provider);
145
+ }
146
+ if (refresh) {
147
+ args.push('--refresh');
103
148
  }
104
- return parsed;
149
+
150
+ const stdout = await new Promise((resolvePromise, rejectPromise) => {
151
+ const child = spawn(binary, args, {
152
+ cwd: process.cwd(),
153
+ env: process.env,
154
+ stdio: ['ignore', 'pipe', 'pipe'],
155
+ });
156
+
157
+ let out = '';
158
+ let err = '';
159
+ let timedOut = false;
160
+
161
+ const timer = setTimeout(() => {
162
+ timedOut = true;
163
+ child.kill('SIGTERM');
164
+ setTimeout(() => child.kill('SIGKILL'), 1500).unref();
165
+ }, timeoutMs);
166
+
167
+ child.stdout.on('data', (chunk) => {
168
+ out += chunk.toString();
169
+ });
170
+ child.stderr.on('data', (chunk) => {
171
+ err += chunk.toString();
172
+ });
173
+
174
+ child.on('error', (error) => {
175
+ clearTimeout(timer);
176
+ rejectPromise(error);
177
+ });
178
+
179
+ child.on('close', (code, signal) => {
180
+ clearTimeout(timer);
181
+ if (timedOut) {
182
+ rejectPromise(new Error(`opencode models timed out after ${timeoutMs}ms`));
183
+ return;
184
+ }
185
+ if (code !== 0) {
186
+ const details = [err.trim(), out.trim()].filter(Boolean).join(' | ');
187
+ rejectPromise(new Error(details || `opencode models exited with code ${code}${signal ? ` (${signal})` : ''}`));
188
+ return;
189
+ }
190
+ resolvePromise(out);
191
+ });
192
+ });
193
+
194
+ const models = stdout
195
+ .split('\n')
196
+ .map((line) => line.trim())
197
+ .filter(Boolean)
198
+ .filter((line) => line.includes('/'));
199
+
200
+ return [...new Set(models)].sort((a, b) => a.localeCompare(b));
105
201
  }