jarvis-agent-factory 3.0.0 → 3.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jarvis-agent-factory",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "description": "Jarvis Agent Factory CLI — 跨平台多智能体 AI 编程助手配置安装器 | Multi-agent AI coding assistant config installer for Claude Code / OpenCode / Codex",
5
5
  "keywords": [
6
6
  "jarvis",
@@ -53,7 +53,10 @@ footer{text-align:center;padding:24px;color:var(--muted);font-size:12px}
53
53
  <body>
54
54
  <header>
55
55
  <div><h1>🧠 Jarvis Engine</h1></div>
56
- <div class=ver id=version>v---</div>
56
+ <div style=display:flex;gap:12px;align-items:center>
57
+ <button class="btn primary" onclick=initPipeline()>🔒 Init Pipeline</button>
58
+ <span class=ver id=version>v---</span>
59
+ </div>
57
60
  </header>
58
61
  <main>
59
62
  <div class=stats>
@@ -123,11 +126,15 @@ async function refresh() {
123
126
  async function checkGate(gate) {
124
127
  const r = await fetch('/mcp', {
125
128
  method:'POST', headers:{'Content-Type':'application/json'},
126
- body:JSON.stringify({jsonrpc:'2.0',id:1,method:'tools/call',params:{name:'check_gate',arguments:{gate}}})
129
+ body:JSON.stringify({jsonrpc:'2.0',id:1,method:'tools/call',params:{name:'gate_enforce',arguments:{gate}}})
127
130
  });
128
131
  const m = await r.json();
129
132
  const d = JSON.parse(m.result.content[0].text);
130
- toast(d.passed ? `✅ ${gate} — Ready to advance` : `⏳ ${gate} — ${d.suggestion}`);
133
+ if (d.allowed) {
134
+ toast(`✅ ${gate} — Ready. Click Advance →`);
135
+ } else {
136
+ toast(`🚫 ${gate} BLOCKED — ${d.blocked_reasons?.join('; ') || d.action_required}`);
137
+ }
131
138
  }
132
139
 
133
140
  async function advanceGate(gate) {
@@ -137,7 +144,22 @@ async function advanceGate(gate) {
137
144
  });
138
145
  const m = await r.json();
139
146
  const d = JSON.parse(m.result.content[0].text);
140
- toast(`🚀 Advanced to ${d.current_gate}`);
147
+ if (d.allowed) {
148
+ toast(`🚀 ${d.message || ('Advanced to ' + d.current_gate)}`);
149
+ refresh();
150
+ } else {
151
+ toast(`🚫 FSM BLOCKED — ${d.error}`);
152
+ }
153
+ }
154
+
155
+ async function initPipeline() {
156
+ const r = await fetch('/mcp', {
157
+ method:'POST', headers:{'Content-Type':'application/json'},
158
+ body:JSON.stringify({jsonrpc:'2.0',id:1,method:'tools/call',params:{name:'pipeline_init',arguments:{}}})
159
+ });
160
+ const m = await r.json();
161
+ const d = JSON.parse(m.result.content[0].text);
162
+ toast(d.ok ? '🔒 Pipeline initialized — hard state active' : d.message);
141
163
  refresh();
142
164
  }
143
165
 
@@ -38,84 +38,237 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
38
38
 
39
39
  const server = new McpServer({ name: 'jarvis-engine', version: readPkgVersion() });
40
40
 
41
+ // ---- Session Manager (multi-session safety) ----
42
+ const sessions = new Map(); // sessionId → { id, platform, created_at, last_heartbeat, role }
43
+ const SESSION_TIMEOUT = 120_000; // 2 min heartbeat timeout
44
+ let leaderSessionId = null;
45
+
46
+ function cleanupStaleSessions() {
47
+ const now = Date.now();
48
+ for (const [sid, s] of sessions) {
49
+ if (now - s.last_heartbeat > SESSION_TIMEOUT) {
50
+ sessions.delete(sid);
51
+ if (sid === leaderSessionId) {
52
+ leaderSessionId = null;
53
+ // Elect new leader
54
+ const oldest = [...sessions.values()].sort((a, b) => a.created_at - b.created_at)[0];
55
+ if (oldest) { leaderSessionId = oldest.id; oldest.role = 'leader'; }
56
+ }
57
+ }
58
+ }
59
+ // If no leader but sessions exist, elect oldest
60
+ if (!leaderSessionId && sessions.size > 0) {
61
+ const oldest = [...sessions.values()].sort((a, b) => a.created_at - b.created_at)[0];
62
+ leaderSessionId = oldest.id;
63
+ oldest.role = 'leader';
64
+ }
65
+ }
66
+
67
+ function requireLeader(sessionId) {
68
+ cleanupStaleSessions();
69
+ const s = sessions.get(sessionId);
70
+ if (!s) return { error: 'Session not registered. Call session_join first.' };
71
+ if (s.role !== 'leader') return { error: `Write lock held by session ${leaderSessionId}. You are observer (read-only).` };
72
+ return null;
73
+ }
74
+
75
+ // Heartbeat cleanup
76
+ setInterval(cleanupStaleSessions, 30_000);
77
+
78
+ // ---- Pipeline state machine (hard constraints) ----
79
+ const pipelinePath = join(root, '.jarvis', 'pipeline.json');
80
+
81
+ function readPipeline() {
82
+ if (!existsSync(pipelinePath)) return null;
83
+ try { return JSON.parse(readFileSync(pipelinePath, 'utf-8')); } catch { return null; }
84
+ }
85
+ function writePipeline(state) {
86
+ const dir = join(root, '.jarvis'); if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
87
+ writeFileSync(pipelinePath, JSON.stringify({ ...state, updated_at: new Date().toISOString() }, null, 2));
88
+ }
89
+
41
90
  // ==============================
42
91
  // TOOLS
43
92
  // ==============================
44
93
 
45
- // Tool: pipeline_status (Phase 1, enhanced)
94
+ // --- Session management ---
95
+ server.tool(
96
+ 'session_join',
97
+ '【多会话安全】注册当前会话。第一个注册的会话获得 leader 写锁,后续会话为 observer(只读)。返回 session_id 和角色。',
98
+ { platform: z.enum(['claude','opencode','codex','other']).optional().describe('平台名称') },
99
+ async ({ platform }, extra) => {
100
+ const sessionId = extra?.sessionId || `s${Date.now()}`;
101
+ cleanupStaleSessions();
102
+ const existing = sessions.get(sessionId);
103
+ if (existing) { existing.last_heartbeat = Date.now(); return { content: [{ type: 'text', text: JSON.stringify({ session_id: sessionId, role: existing.role, leader: leaderSessionId, active_sessions: sessions.size }) }] }; }
104
+
105
+ const role = sessions.size === 0 ? 'leader' : 'observer';
106
+ const s = { id: sessionId, platform: platform || 'unknown', created_at: Date.now(), last_heartbeat: Date.now(), role };
107
+ sessions.set(sessionId, s);
108
+ if (role === 'leader') leaderSessionId = sessionId;
109
+
110
+ return {
111
+ content: [{ type: 'text', text: JSON.stringify({
112
+ session_id: sessionId, role, leader: leaderSessionId,
113
+ active_sessions: sessions.size,
114
+ message: role === 'leader' ? '🔑 You are leader — write access granted.' : '👁 You are observer — read-only. Leader holds write lock.',
115
+ }, null, 2) }],
116
+ };
117
+ }
118
+ );
119
+
120
+ server.tool('session_heartbeat', '【多会话安全】发送心跳,保持会话活跃。每 60 秒至少发一次,否则会话超时被清理。', {},
121
+ async (_args, extra) => {
122
+ const sid = extra?.sessionId;
123
+ if (!sid || !sessions.has(sid)) return { content: [{ type: 'text', text: JSON.stringify({ error: 'Session not found. Call session_join first.' }) }] };
124
+ sessions.get(sid).last_heartbeat = Date.now();
125
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, session_id: sid, role: sessions.get(sid).role, leader: leaderSessionId }) }] };
126
+ }
127
+ );
128
+
129
+ server.tool('session_list', '【多会话安全】列出所有活跃会话及其角色。', {},
130
+ async () => {
131
+ cleanupStaleSessions();
132
+ const list = [...sessions.values()].map(s => ({ session_id: s.id, platform: s.platform, role: s.role, leader: s.id === leaderSessionId, last_heartbeat_ago: `${Math.round((Date.now() - s.last_heartbeat) / 1000)}s` }));
133
+ return { content: [{ type: 'text', text: JSON.stringify({ active_sessions: sessions.size, leader_session: leaderSessionId, sessions: list }) }] };
134
+ }
135
+ );
136
+
137
+ server.tool('session_leave', '【多会话安全】主动离开。如果是 leader,锁自动移交给最老的 observer。', {},
138
+ async (_args, extra) => {
139
+ const sid = extra?.sessionId;
140
+ if (!sid || !sessions.has(sid)) return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Session not registered.' }) }] };
141
+ const wasLeader = sessions.get(sid).role === 'leader';
142
+ sessions.delete(sid);
143
+ if (wasLeader) {
144
+ leaderSessionId = null;
145
+ const oldest = [...sessions.values()].sort((a, b) => a.created_at - b.created_at)[0];
146
+ if (oldest) { leaderSessionId = oldest.id; oldest.role = 'leader'; }
147
+ }
148
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Session left.', lock_transferred: wasLeader && leaderSessionId ? `Lock → ${leaderSessionId}` : 'No active sessions' }) }] };
149
+ }
150
+ );
151
+
152
+ // --- Pipeline management ---
153
+
154
+ // Tool: pipeline_init — hard state bootstrap
155
+ server.tool(
156
+ 'pipeline_init',
157
+ '【硬约束·需Leader】初始化流水线状态机。只有 leader 会话可调用。observer 会被拒绝。',
158
+ { project_name: z.string().optional().describe('项目名称(可选)') },
159
+ async ({ project_name }, extra) => {
160
+ const lockErr = requireLeader(extra?.sessionId); if (lockErr) return { content: [{ type: 'text', text: JSON.stringify(lockErr) }] };
161
+ const existing = readPipeline();
162
+ if (existing) return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Pipeline already initialized', state: existing, session: extra?.sessionId, role: 'leader' }, null, 2) }] };
163
+ const state = { project: project_name || root, current_gate: 'Gate A', started_at: new Date().toISOString(), gates_passed: [], mode: 'strict', initialized_by: extra?.sessionId };
164
+ writePipeline(state);
165
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Pipeline initialized — hard state machine active. Next: Gate A', state, session: extra?.sessionId, role: 'leader' }, null, 2) }] };
166
+ }
167
+ );
168
+
169
+ // Tool: pipeline_status
46
170
  server.tool(
47
171
  'pipeline_status',
48
- '完整流水线状态:当前 Gate、已完成 Gate、产物文件、Gate 检查点时间戳',
172
+ '完整流水线状态:当前 Gate、已完成 Gate、产物文件、Gate 检查点时间戳。同时读取硬状态 pipeline.json。',
49
173
  {},
50
174
  async () => {
175
+ const pstate = readPipeline();
51
176
  const gates = GATES.map(gate => {
52
177
  const checkpoints = readCheckpoints(root, gate);
53
- return {
54
- gate,
55
- passed: checkpoints.length > 0,
56
- checkpoints,
57
- artifacts: findGateArtifacts(join(root, 'docs'), gate),
58
- requirement: GATE_CHECKS[gate]?.check || '',
59
- };
178
+ return { gate, passed: checkpoints.length > 0, checkpoints, artifacts: findGateArtifacts(join(root, 'docs'), gate), requirement: GATE_CHECKS[gate]?.check || '' };
60
179
  });
61
- const current = gates.find(g => !g.passed) || gates[gates.length - 1];
180
+ const current = pstate?.current_gate || (gates.find(g => !g.passed)?.gate || 'Gate A');
181
+ const sessionInfo = { active_sessions: sessions.size, leader: leaderSessionId, sessions: [...sessions.values()].map(s => ({ id: s.id, role: s.role, platform: s.platform, alive_s: Math.round((Date.now() - s.last_heartbeat)/1000) })) };
62
182
  return {
63
183
  content: [{ type: 'text', text: JSON.stringify({
64
184
  project: root,
65
- current_gate: current.gate,
185
+ mode: pstate?.mode || 'soft',
186
+ current_gate: current,
66
187
  completed: gates.filter(g => g.passed).map(g => g.gate),
67
188
  gates,
68
- _display: formatGateDisplay(gates, current.gate),
189
+ sessions: sessionInfo,
190
+ _display: formatGateDisplay(gates, current),
69
191
  }, null, 2) }],
70
192
  };
71
193
  }
72
194
  );
73
195
 
74
- // Tool: check_gate
196
+ // Tool: gate_enforce — HARD constraint
75
197
  server.tool(
76
- 'check_gate',
77
- '验证指定 Gate 的通过条件:检查产物文件、Gate 文档完整性',
78
- { gate: z.enum(GATES).describe('要检查的 Gate 名称') },
198
+ 'gate_enforce',
199
+ '【硬约束】验证当前 Gate 的通过条件。返回 allowed=true 才可以 proceed。allowed=false 时必须先完成条件再重试。不可绕过。',
200
+ { gate: z.enum(GATES).optional().describe('要检查的 Gate(默认当前 Gate)') },
79
201
  async ({ gate }) => {
80
- const artifacts = findGateArtifacts(join(root, 'docs'), gate);
81
- const passed = artifacts.length > 0;
82
- const checkpoints = readCheckpoints(root, gate);
83
- const requirement = GATE_CHECKS[gate];
202
+ const pstate = readPipeline();
203
+ const targetGate = gate || pstate?.current_gate || 'Gate A';
204
+ const artifacts = findGateArtifacts(join(root, 'docs'), targetGate);
205
+ const checkpoints = readCheckpoints(root, targetGate);
206
+ const requirement = GATE_CHECKS[targetGate];
207
+
208
+ // Hard check: must have artifacts and/or checkpoints
209
+ const hasArtifacts = artifacts.length > 0;
210
+ const hasCheckpoint = checkpoints.length > 0;
211
+ const allowed = hasArtifacts || hasCheckpoint;
212
+
213
+ const reasons = [];
214
+ if (!hasArtifacts) reasons.push(`No artifacts found in docs/${GATE_DIRS[targetGate] || '?'}/`);
215
+ if (!hasCheckpoint) reasons.push(`No checkpoint recorded for ${targetGate}`);
216
+
84
217
  return {
85
218
  content: [{ type: 'text', text: JSON.stringify({
86
- gate,
87
- passed,
88
- checkpoints,
89
- artifacts_found: artifacts,
90
- requirement: requirement?.check || 'No specific check defined',
91
- suggestion: passed ? 'Gate 条件满足,可以推进' : `需要完成:${requirement?.check}`,
219
+ gate: targetGate,
220
+ allowed,
221
+ enforced: true,
222
+ requirement: requirement?.check || '',
223
+ check_results: { artifacts_found: artifacts, checkpoints_found: checkpoints.map(c => c.passed_at) },
224
+ ...(allowed ? { message: `✅ ${targetGate} conditions met — proceed to next gate` } : { blocked_reasons: reasons, action_required: requirement?.check || 'Complete gate requirements' }),
92
225
  }, null, 2) }],
93
226
  };
94
227
  }
95
228
  );
96
229
 
97
- // Tool: advance_gate
230
+ // Tool: advance_gate — FSM enforced + leader check
98
231
  server.tool(
99
232
  'advance_gate',
100
- '标记 Gate 为已通过,写入检查点文件。调用前应先 check_gate 确认条件满足。',
101
- { gate: z.enum(GATES).describe('要推进到的 Gate(标记此 Gate 之前的 Gate 为已通过)') },
102
- async ({ gate }) => {
103
- const idx = GATES.indexOf(gate);
104
- if (idx === -1) return { content: [{ type: 'text', text: JSON.stringify({ error: `Unknown gate: ${gate}` }) }] };
105
- const prevGate = GATES[Math.max(0, idx - 1)];
233
+ '【硬约束·需Leader】推进到下一个 Gate。仅 leader 会话可调用,observer 被拒绝。非顺序推进被 FSM 拒绝。',
234
+ { gate: z.enum(GATES).describe('要推进到的 Gate 名称') },
235
+ async ({ gate }, extra) => {
236
+ const lockErr = requireLeader(extra?.sessionId); if (lockErr) return { content: [{ type: 'text', text: JSON.stringify(lockErr) }] };
237
+ const pstate = readPipeline();
238
+ const currentGate = pstate?.current_gate || 'Gate A';
239
+ const currentIdx = GATES.indexOf(currentGate);
240
+ const targetIdx = GATES.indexOf(gate);
241
+
242
+ if (targetIdx === -1) return { content: [{ type: 'text', text: JSON.stringify({ error: `Unknown gate: ${gate}` }) }] };
243
+
244
+ // FSM: only allow current gate → next gate (no skipping)
245
+ if (targetIdx <= currentIdx) {
246
+ return { content: [{ type: 'text', text: JSON.stringify({ allowed: false, error: `FSM blocked: Cannot move backward or stay. Current: ${currentGate}, Requested: ${gate}` }) }] };
247
+ }
248
+ if (targetIdx > currentIdx + 1) {
249
+ return { content: [{ type: 'text', text: JSON.stringify({ allowed: false, error: `FSM blocked: Cannot skip gates. Current: ${currentGate}, Requested: ${gate}. Must advance to ${GATES[currentIdx + 1]} first.` }) }] };
250
+ }
251
+
252
+ // Enforce: must pass current gate
253
+ const artifacts = findGateArtifacts(join(root, 'docs'), currentGate);
254
+ const checkpoints = readCheckpoints(root, currentGate);
255
+ if (artifacts.length === 0 && checkpoints.length === 0) {
256
+ return { content: [{ type: 'text', text: JSON.stringify({ allowed: false, error: `FSM blocked: ${currentGate} conditions NOT met. Run gate_enforce first. Required: ${GATE_CHECKS[currentGate]?.check}` }) }] };
257
+ }
258
+
259
+ // Allowed — advance
106
260
  const cpDir = join(root, '.jarvis', 'checkpoints');
107
261
  if (!existsSync(cpDir)) mkdirSync(cpDir, { recursive: true });
108
- const cpFile = join(cpDir, `${prevGate.replace(/ /g, '_')}.json`);
109
- writeFileSync(cpFile, JSON.stringify({
110
- gate: prevGate, passed_at: new Date().toISOString(), advance_to: gate,
111
- }, null, 2));
262
+ writeFileSync(join(cpDir, `${currentGate.replace(/ /g, '_')}.json`), JSON.stringify({ gate: currentGate, passed_at: new Date().toISOString(), advance_to: gate }, null, 2));
263
+ writePipeline({ ...pstate, current_gate: gate, gates_passed: [...(pstate?.gates_passed || []), currentGate] });
264
+
265
+ const nextGate = GATES[targetIdx + 1];
112
266
  return {
113
267
  content: [{ type: 'text', text: JSON.stringify({
114
- ok: true,
115
- previous_gate: prevGate,
116
- marked_passed_at: new Date().toISOString(),
117
- current_gate: gate,
118
- next: GATES[idx + 1] || 'Done',
268
+ allowed: true,
269
+ previous_gate: currentGate, marked_passed_at: new Date().toISOString(),
270
+ current_gate: gate, next: nextGate || 'Pipeline Complete',
271
+ message: nextGate ? `Next: ${nextGate}` : '🎉 All gates passed! Move to Gate E: Release.',
119
272
  }, null, 2) }],
120
273
  };
121
274
  }
@@ -211,7 +364,7 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
211
364
  await server.connect(transport);
212
365
 
213
366
  // ---- Health ----
214
- app.get('/health', (_req, res) => res.json({ status: 'ok', version: readPkgVersion(), tools: ['pipeline_status', 'check_gate', 'advance_gate', 'report_status'] }));
367
+ app.get('/health', (_req, res) => res.json({ status: 'ok', version: readPkgVersion(), tools: ['pipeline_init', 'pipeline_status', 'gate_enforce', 'advance_gate', 'report_status'] }));
215
368
 
216
369
  // ---- SSE (real-time pipeline events) ----
217
370
  const sseClients = new Set();