ac-framework 1.9.6 → 1.9.8

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
@@ -141,6 +141,7 @@ Each role runs in turn against a shared, accumulating context so outputs from on
141
141
  | Command | Description |
142
142
  |---|---|
143
143
  | `acfm agents setup` | Install optional dependencies (`opencode` and `tmux`) |
144
+ | `acfm agents doctor` | Validate OpenCode/tmux/model preflight before start |
144
145
  | `acfm agents install-mcps` | Install SynapseGrid MCP server for detected assistants |
145
146
  | `acfm agents uninstall-mcps` | Remove SynapseGrid MCP server from assistants |
146
147
  | `acfm agents start --task "..." --model-coder provider/model` | Start session with optional per-role models |
@@ -149,21 +150,46 @@ Each role runs in turn against a shared, accumulating context so outputs from on
149
150
  | `acfm agents attach` | Attach directly to the SynapseGrid tmux session |
150
151
  | `acfm agents live` | Attach to full live tmux view (all agents) |
151
152
  | `acfm agents logs` | Show recent worker logs (all roles or one role) |
153
+ | `acfm agents transcript --role all --limit 40` | Show captured cross-agent transcript |
154
+ | `acfm agents summary` | Show generated collaboration meeting summary |
152
155
  | `acfm agents export --format md --out file.md` | Export transcript in Markdown or JSON |
153
156
  | `acfm agents send "..."` | Send a new user message into the active session |
154
157
  | `acfm agents status` | Show current collaborative session state |
158
+ | `acfm agents model list` | List available models grouped by provider |
159
+ | `acfm agents model choose` | Interactively pick provider/model and save target role |
155
160
  | `acfm agents model get` | Show default model config (global and per-role) |
156
161
  | `acfm agents model set --role coder provider/model` | Persist a default model for one role |
157
162
  | `acfm agents model clear --role all` | Clear persisted model defaults |
158
163
  | `acfm agents stop` | Stop the active collaborative session |
159
164
 
165
+ ### MCP collaborative run mode (recommended)
166
+
167
+ When driving SynapseGrid from another agent via MCP, prefer asynchronous run tools over role-by-role stepping:
168
+
169
+ - `collab_start_session` to initialize session and optional tmux workers
170
+ - `collab_invoke_team` to launch full 4-role collaboration run
171
+ - `collab_wait_run` to wait for completion/failure with bounded timeout
172
+ - `collab_get_result` to fetch final consolidated output and run diagnostics
173
+ - `collab_cancel_run` to cancel a running collaboration safely
174
+
175
+ `collab_step` remains available for manual/debug control, but is less robust for long tasks.
176
+
160
177
  ### SynapseGrid troubleshooting
161
178
 
162
179
  - If transcript entries show `Agent failed: spawn opencode ENOENT`, run `acfm agents setup` to install dependencies and then retry.
163
180
  - Attach to worker panes with `acfm agents live` (or `acfm agents attach`) to see real-time role discussion.
164
181
  - Inspect worker errors quickly with `acfm agents logs --role all --lines 120`.
182
+ - Inspect collaborative discussion with `acfm agents transcript` and `acfm agents summary`.
165
183
  - 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.
166
- - Configure role models directly at start (for example `--model-planner`, `--model-coder`) or persist defaults via `acfm agents model set`.
184
+ - Configure role models directly at start (for example `--model-planner`, `--model-coder`) or persist defaults via `acfm agents model choose` / `acfm agents model set`.
185
+ - Default SynapseGrid model fallback is `opencode/mimo-v2-pro-free`.
186
+ - Run `acfm agents doctor` when panes look idle to confirm model/provider preflight health.
187
+
188
+ Each collaborative session now keeps human-readable artifacts under `~/.acfm/synapsegrid/<sessionId>/`:
189
+ - `transcript.jsonl`: full chronological message stream
190
+ - `turns/*.json`: one file per round/role turn with captured output metadata
191
+ - `meeting-log.md`: incremental meeting notes generated per turn
192
+ - `meeting-summary.md`: final consolidated summary (roles, decisions, open issues, risks, action items)
167
193
 
168
194
  ### Spec Workflow
169
195
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ac-framework",
3
- "version": "1.9.6",
3
+ "version": "1.9.8",
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
+ }
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import { DEFAULT_SYNAPSE_MODEL } from './constants.js';
5
6
  import { sanitizeRoleModels, normalizeModelId } from './model-selection.js';
6
7
 
7
8
  const ACFM_DIR = join(homedir(), '.acfm');
@@ -11,7 +12,7 @@ function normalizeConfig(raw) {
11
12
  const agents = raw?.agents && typeof raw.agents === 'object' ? raw.agents : {};
12
13
  return {
13
14
  agents: {
14
- defaultModel: normalizeModelId(agents.defaultModel) || null,
15
+ defaultModel: normalizeModelId(agents.defaultModel) || DEFAULT_SYNAPSE_MODEL,
15
16
  defaultRoleModels: sanitizeRoleModels(agents.defaultRoleModels),
16
17
  },
17
18
  };
@@ -4,5 +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/mimo-v2-pro-free';
8
+ export const DEFAULT_ROLE_TIMEOUT_MS = 180000;
9
+ export const DEFAULT_ROLE_RETRIES = 1;
7
10
  export const SESSION_ROOT_DIR = join(homedir(), '.acfm', 'synapsegrid');
8
11
  export const CURRENT_SESSION_FILE = join(SESSION_ROOT_DIR, 'current-session.json');
@@ -1,9 +1,29 @@
1
- import { execFile } from 'node:child_process';
2
- import { promisify } from 'node:util';
3
-
4
- const execFileAsync = promisify(execFile);
1
+ import { spawn } from 'node:child_process';
5
2
 
6
3
  function parseOpenCodeRunOutput(stdout) {
4
+ const ndjsonLines = stdout
5
+ .split('\n')
6
+ .map((line) => line.trim())
7
+ .filter(Boolean);
8
+
9
+ if (ndjsonLines.length > 0) {
10
+ const textChunks = [];
11
+ for (const line of ndjsonLines) {
12
+ try {
13
+ const event = JSON.parse(line);
14
+ const text = event?.part?.text;
15
+ if (typeof text === 'string' && text.trim()) {
16
+ textChunks.push(text.trim());
17
+ }
18
+ } catch {
19
+ // ignore malformed lines and continue
20
+ }
21
+ }
22
+ if (textChunks.length > 0) {
23
+ return textChunks.join('\n\n');
24
+ }
25
+ }
26
+
7
27
  try {
8
28
  const parsed = JSON.parse(stdout);
9
29
  if (Array.isArray(parsed)) {
@@ -22,7 +42,24 @@ function parseOpenCodeRunOutput(stdout) {
22
42
  return stdout.trim();
23
43
  }
24
44
 
25
- 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 }) {
26
63
  const binary = binaryPath || process.env.ACFM_OPENCODE_BIN || 'opencode';
27
64
  const args = ['run', '--format', 'json'];
28
65
  if (model) {
@@ -33,15 +70,132 @@ export async function runOpenCodePrompt({ prompt, cwd, model, agent, timeoutMs =
33
70
  }
34
71
  args.push('--', prompt);
35
72
 
36
- const { stdout, stderr } = await execFileAsync(binary, args, {
73
+ const { stdout, stderr } = await new Promise((resolvePromise, rejectPromise) => {
74
+ const child = spawn(binary, args, {
75
+ cwd,
76
+ env: process.env,
77
+ stdio: ['ignore', 'pipe', 'pipe'],
78
+ });
79
+
80
+ let out = '';
81
+ let err = '';
82
+ let timedOut = false;
83
+
84
+ const timer = setTimeout(() => {
85
+ timedOut = true;
86
+ child.kill('SIGTERM');
87
+ setTimeout(() => child.kill('SIGKILL'), 1500).unref();
88
+ }, timeoutMs);
89
+
90
+ child.stdout.on('data', (chunk) => {
91
+ out += chunk.toString();
92
+ });
93
+ child.stderr.on('data', (chunk) => {
94
+ err += chunk.toString();
95
+ });
96
+
97
+ child.on('error', (error) => {
98
+ clearTimeout(timer);
99
+ rejectPromise(error);
100
+ });
101
+
102
+ child.on('close', (code, signal) => {
103
+ clearTimeout(timer);
104
+ if (timedOut) {
105
+ rejectPromise(new Error(`opencode timed out after ${timeoutMs}ms`));
106
+ return;
107
+ }
108
+ if (code !== 0) {
109
+ const details = [err.trim(), out.trim()].filter(Boolean).join(' | ');
110
+ rejectPromise(new Error(details || `opencode exited with code ${code}${signal ? ` (${signal})` : ''}`));
111
+ return;
112
+ }
113
+ resolvePromise({ stdout: out, stderr: err });
114
+ });
115
+ });
116
+
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,
37
131
  cwd,
38
- timeout: timeoutMs,
39
- maxBuffer: 10 * 1024 * 1024,
132
+ model,
133
+ agent,
134
+ timeoutMs,
135
+ binaryPath,
40
136
  });
137
+ return detailed.text;
138
+ }
41
139
 
42
- const parsed = parseOpenCodeRunOutput(stdout);
43
- if (!parsed && stderr?.trim()) {
44
- return stderr.trim();
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);
45
145
  }
46
- return parsed;
146
+ if (refresh) {
147
+ args.push('--refresh');
148
+ }
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));
47
201
  }