jarvis-agent-factory 3.3.0 → 3.4.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.3.0",
3
+ "version": "3.4.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",
@@ -44,6 +44,7 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@modelcontextprotocol/sdk": "^1.29.0",
47
+ "better-sqlite3": "^12.9.0",
47
48
  "express": "^5.2.1"
48
49
  }
49
50
  }
@@ -0,0 +1,112 @@
1
+ import Database from 'better-sqlite3';
2
+ import { resolve, join } from 'node:path';
3
+ import { existsSync, mkdirSync } from 'node:fs';
4
+
5
+ export function openDb(root) {
6
+ const dir = join(root, '.jarvis');
7
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
8
+ const db = new Database(join(dir, 'engine.db'));
9
+ db.pragma('journal_mode = WAL');
10
+ db.pragma('busy_timeout = 5000');
11
+ initSchema(db);
12
+ return db;
13
+ }
14
+
15
+ function initSchema(db) {
16
+ db.exec(`
17
+ CREATE TABLE IF NOT EXISTS pipeline (
18
+ id INTEGER PRIMARY KEY CHECK(id=1),
19
+ project TEXT NOT NULL,
20
+ current_gate TEXT NOT NULL DEFAULT 'Gate A',
21
+ mode TEXT NOT NULL DEFAULT 'strict',
22
+ started_at TEXT NOT NULL,
23
+ updated_at TEXT NOT NULL
24
+ );
25
+ CREATE TABLE IF NOT EXISTS checkpoints (
26
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
27
+ gate TEXT NOT NULL,
28
+ passed_at TEXT NOT NULL,
29
+ advance_to TEXT,
30
+ session_id TEXT,
31
+ UNIQUE(gate)
32
+ );
33
+ CREATE TABLE IF NOT EXISTS sessions (
34
+ id TEXT PRIMARY KEY,
35
+ platform TEXT DEFAULT 'unknown',
36
+ role TEXT NOT NULL DEFAULT 'observer',
37
+ created_at INTEGER NOT NULL,
38
+ last_heartbeat INTEGER NOT NULL
39
+ );
40
+ CREATE TABLE IF NOT EXISTS agent_models (
41
+ agent_id TEXT PRIMARY KEY,
42
+ model TEXT NOT NULL,
43
+ updated_at TEXT NOT NULL
44
+ );
45
+ -- Init pipeline row if empty
46
+ INSERT OR IGNORE INTO pipeline (id, project, current_gate, mode, started_at, updated_at)
47
+ VALUES (1, '', 'Gate A', 'strict', datetime('now'), datetime('now'));
48
+ `);
49
+ }
50
+
51
+ // ---- Pipeline ----
52
+ export function getPipeline(db) {
53
+ return db.prepare('SELECT * FROM pipeline WHERE id=1').get();
54
+ }
55
+ export function updatePipelineGate(db, gate) {
56
+ db.prepare('UPDATE pipeline SET current_gate=?, updated_at=datetime("now") WHERE id=1').run(gate);
57
+ }
58
+ export function initPipeline(db, project, sessionId) {
59
+ db.prepare('UPDATE pipeline SET project=?, current_gate="Gate A", mode="strict", started_at=datetime("now"), updated_at=datetime("now") WHERE id=1').run(project);
60
+ }
61
+
62
+ // ---- Checkpoints ----
63
+ export function getCheckpoints(db, gate) {
64
+ return gate ? db.prepare('SELECT * FROM checkpoints WHERE gate=?').all(gate)
65
+ : db.prepare('SELECT * FROM checkpoints ORDER BY passed_at').all();
66
+ }
67
+ export function addCheckpoint(db, gate, advanceTo, sessionId) {
68
+ db.prepare('INSERT OR REPLACE INTO checkpoints (gate, passed_at, advance_to, session_id) VALUES (?, datetime("now"), ?, ?)').run(gate, advanceTo, sessionId);
69
+ }
70
+
71
+ // ---- Sessions ----
72
+ export function getSessions(db) {
73
+ return db.prepare('SELECT * FROM sessions ORDER BY created_at').all();
74
+ }
75
+ export function getSession(db, sid) {
76
+ return db.prepare('SELECT * FROM sessions WHERE id=?').get(sid);
77
+ }
78
+ export function addSession(db, sid, platform, role) {
79
+ db.prepare('INSERT OR REPLACE INTO sessions (id, platform, role, created_at, last_heartbeat) VALUES (?, ?, ?, ?, ?)').run(sid, platform, role, Date.now(), Date.now());
80
+ }
81
+ export function heartbeatSession(db, sid) {
82
+ db.prepare('UPDATE sessions SET last_heartbeat=? WHERE id=?').run(Date.now(), sid);
83
+ }
84
+ export function removeSession(db, sid) {
85
+ db.prepare('DELETE FROM sessions WHERE id=?').run(sid);
86
+ }
87
+ export function updateSessionRole(db, sid, role) {
88
+ db.prepare('UPDATE sessions SET role=? WHERE id=?').run(role, sid);
89
+ }
90
+ export function cleanupStaleSessions(db, timeoutMs) {
91
+ const cutoff = Date.now() - timeoutMs;
92
+ const stale = db.prepare('SELECT id FROM sessions WHERE last_heartbeat < ?').all(cutoff);
93
+ for (const s of stale) db.prepare('DELETE FROM sessions WHERE id=?').run(s.id);
94
+ return stale.map(s => s.id);
95
+ }
96
+ export function getOldestSession(db) {
97
+ return db.prepare('SELECT * FROM sessions ORDER BY created_at ASC LIMIT 1').get();
98
+ }
99
+ export function getLeader(db) {
100
+ return db.prepare('SELECT * FROM sessions WHERE role="leader" LIMIT 1').get();
101
+ }
102
+
103
+ // ---- Agent Models ----
104
+ export function getAgentConfig(db) {
105
+ const rows = db.prepare('SELECT * FROM agent_models').all();
106
+ const cfg = {};
107
+ for (const r of rows) cfg[r.agent_id] = r.model;
108
+ return cfg;
109
+ }
110
+ export function setAgentModel(db, agentId, model) {
111
+ db.prepare('INSERT OR REPLACE INTO agent_models (agent_id, model, updated_at) VALUES (?, ?, datetime("now"))').run(agentId, model);
112
+ }
@@ -2,30 +2,17 @@ import express from 'express';
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
4
  import { z } from 'zod';
5
- import { readFileSync, readdirSync, existsSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
5
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
6
6
  import { resolve, join } from 'node:path';
7
7
  import { homedir } from 'node:os';
8
+ import { openDb, getPipeline, updatePipelineGate, initPipeline as dbInitPipeline, getCheckpoints, addCheckpoint, getSessions, getSession, addSession, heartbeatSession, removeSession, updateSessionRole, cleanupStaleSessions, getOldestSession, getLeader, getAgentConfig, setAgentModel } from './db.js';
8
9
 
9
10
  const PID_FILE = resolve(homedir(), '.jarvis', 'engine.pid');
10
11
  const DEFAULT_PORT = 3456;
12
+ const SESSION_TIMEOUT = 120_000;
11
13
  const GATES = ['Gate A', 'Gate B', 'Gate C', 'Gate C1', 'Gate C1.5', 'Gate C2', 'Gate D', 'Gate E'];
12
-
13
- const GATE_DIRS = {
14
- 'Gate A': 'requirements', 'Gate B': 'tasks', 'Gate C': 'plans',
15
- 'Gate C1': 'implementation', 'Gate C1.5': 'implementation',
16
- 'Gate C2': 'testing', 'Gate D': 'review', 'Gate E': 'shipping',
17
- };
18
-
19
- const GATE_CHECKS = {
20
- 'Gate A': { requires: ['requirements'], check: '至少 1 个需求文档,含 REQ-XXX 编号' },
21
- 'Gate B': { requires: ['tasks'], check: '每个 TASK-XXX 映射至少 1 个 REQ-XXX' },
22
- 'Gate C': { requires: ['plans'], check: '计划文档含 parallel_batches + Execution Packet' },
23
- 'Gate C1': { requires: ['implementation'], check: 'Lint + Type-check + Build + Deps Audit 全部通过' },
24
- 'Gate C1.5': { requires: ['implementation'], check: '页面/组件视觉验证截图证据已附' },
25
- 'Gate C2': { requires: ['testing'], check: '单元/集成/E2E/浏览器测试全部通过,API 契约验证通过' },
26
- 'Gate D': { requires: ['review'], check: 'review-qa 评审通过,REQ 追踪矩阵完整' },
27
- 'Gate E': { requires: ['shipping'], check: '安全审计 + 上线检查清单 + 回滚预案就绪' },
28
- };
14
+ const GATE_DIRS = { 'Gate A':'requirements','Gate B':'tasks','Gate C':'plans','Gate C1':'implementation','Gate C1.5':'implementation','Gate C2':'testing','Gate D':'review','Gate E':'shipping' };
15
+ const GATE_CHECKS = { 'Gate A':{check:'至少1个需求文档,含REQ-XXX编号'},'Gate B':{check:'每个TASK-XXX映射至少1个REQ-XXX'},'Gate C':{check:'计划文档含parallel_batches+Execution Packet'},'Gate C1':{check:'Lint+Type-check+Build+Deps Audit全部通过'},'Gate C1.5':{check:'页面/组件视觉验证截图证据已附'},'Gate C2':{check:'单元/集成/E2E/浏览器测试全部通过,API契约验证通过'},'Gate D':{check:'review-qa评审通过,REQ追踪矩阵完整'},'Gate E':{check:'安全审计+上线检查清单+回滚预案就绪'} };
29
16
 
30
17
  export async function startEngine({ port = DEFAULT_PORT, dashboard = false, projectRoot = '.' } = {}) {
31
18
  const root = resolve(projectRoot);
@@ -38,131 +25,84 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
38
25
 
39
26
  const server = new McpServer({ name: 'jarvis-engine', version: readPkgVersion() });
40
27
 
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
- }
28
+ // ---- Database (SQLite) ----
29
+ const db = openDb(root);
66
30
 
31
+ // ---- Session Manager (SQLite-backed) ----
67
32
  function requireLeader(sessionId) {
68
- cleanupStaleSessions();
69
- const s = sessions.get(sessionId);
33
+ cleanupStaleSessions(db, SESSION_TIMEOUT);
34
+ const s = getSession(db, sessionId);
70
35
  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).` };
36
+ if (s.role !== 'leader') {
37
+ const leader = getLeader(db);
38
+ return { error: `Write lock held by session ${leader?.id || '?'}. You are observer (read-only).` };
39
+ }
72
40
  return null;
73
41
  }
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; }
42
+ function electLeader() {
43
+ const leader = getLeader(db);
44
+ if (leader) return leader.id;
45
+ const oldest = getOldestSession(db);
46
+ if (oldest) { updateSessionRole(db, oldest.id, 'leader'); return oldest.id; }
47
+ return null;
84
48
  }
49
+ // Heartbeat cleanup every 30s
50
+ setInterval(() => {
51
+ const stale = cleanupStaleSessions(db, SESSION_TIMEOUT);
52
+ if (stale.length) electLeader();
53
+ }, 30_000);
54
+
55
+ // ---- Pipeline state machine (SQLite-backed) ----
56
+ function readPipeline() { return getPipeline(db); }
85
57
  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));
58
+ updatePipelineGate(db, state.current_gate);
88
59
  }
89
60
 
90
61
  // ==============================
91
62
  // TOOLS
92
63
  // ==============================
93
64
 
94
- // --- Session management ---
95
- server.tool(
96
- 'session_join',
97
- '【多会话安全】注册当前会话。第一个注册的会话获得 leader 写锁,后续会话为 observer(只读)。返回 session_id 和角色。',
98
- { platform: z.enum(['claude','opencode','codex','other']).optional().describe('平台名称') },
65
+ // --- Session management (SQLite) ---
66
+ server.tool('session_join', '注册会话。第一个=leader🔑,后续=observer👁。SQLite持久化,引擎重启不丢。',
67
+ { platform: z.enum(['claude','opencode','codex','other']).optional() },
99
68
  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' }) }] };
69
+ const sid = extra?.sessionId || `s${Date.now()}`;
70
+ const existing = getSession(db, sid);
71
+ if (existing) { heartbeatSession(db, sid); const leader = getLeader(db); return { content: [{ type: 'text', text: JSON.stringify({ session_id: sid, role: existing.role, leader: leader?.id, active_sessions: getSessions(db).length }) }] }; }
72
+ const role = getSessions(db).length === 0 ? 'leader' : 'observer';
73
+ addSession(db, sid, platform || 'unknown', role);
74
+ const leader = getLeader(db);
75
+ return { content: [{ type: 'text', text: JSON.stringify({ session_id: sid, role, leader: leader?.id, active_sessions: getSessions(db).length, message: role==='leader'?'🔑 Leader — write access granted.':'👁 Observer — read-only.' }) }] };
149
76
  }
150
77
  );
78
+ server.tool('session_heartbeat', '心跳保活。60s一次,超时自动清理。', {}, async (_args, extra) => {
79
+ const sid = extra?.sessionId; if (!sid || !getSession(db, sid)) return { content: [{ type: 'text', text: JSON.stringify({ error: 'Session not found.' }) }] };
80
+ heartbeatSession(db, sid); return { content: [{ type: 'text', text: JSON.stringify({ ok: true, session_id: sid }) }] };
81
+ });
82
+ server.tool('session_list', '列出所有活跃会话。', {}, async () => {
83
+ cleanupStaleSessions(db, SESSION_TIMEOUT); electLeader();
84
+ const leader = getLeader(db);
85
+ const list = getSessions(db).map(s => ({ session_id: s.id, platform: s.platform, role: s.role, leader: s.id===leader?.id, last_heartbeat_ago: `${Math.round((Date.now()-s.last_heartbeat)/1000)}s` }));
86
+ return { content: [{ type: 'text', text: JSON.stringify({ active_sessions: list.length, leader_session: leader?.id, sessions: list }) }] };
87
+ });
88
+ server.tool('session_leave', '主动离开。leader离开自动移交锁。', {}, async (_args, extra) => {
89
+ const sid = extra?.sessionId; if (!sid || !getSession(db, sid)) return { content: [{ type: 'text', text: JSON.stringify({ ok: true }) }] };
90
+ const wasLeader = getSession(db, sid)?.role === 'leader';
91
+ removeSession(db, sid);
92
+ const newLeader = wasLeader ? electLeader() : null;
93
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, lock_transferred: newLeader ? `→ ${newLeader}` : null }) }] };
94
+ });
151
95
 
152
96
  // --- Pipeline management ---
153
97
 
154
- // Tool: pipeline_init — hard state bootstrap
155
- server.tool(
156
- 'pipeline_init',
157
- '【硬约束·需Leader】初始化流水线状态机。只有 leader 会话可调用。observer 会被拒绝。',
158
- { project_name: z.string().optional().describe('项目名称(可选)') },
98
+ // Tool: pipeline_init — DB-backed
99
+ server.tool('pipeline_init', '【硬约束·需Leader】初始化流水线。SQLite持久化。',
100
+ { project_name: z.string().optional() },
159
101
  async ({ project_name }, extra) => {
160
102
  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) }] };
103
+ dbInitPipeline(db, project_name || root, extra?.sessionId);
104
+ const state = readPipeline();
105
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Pipeline initialized (SQLite). Next: Gate A', state }) }] };
166
106
  }
167
107
  );
168
108
 
@@ -174,11 +114,12 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
174
114
  async () => {
175
115
  const pstate = readPipeline();
176
116
  const gates = GATES.map(gate => {
177
- const checkpoints = readCheckpoints(root, gate);
117
+ const checkpoints = readCheckpointsDb( gate);
178
118
  return { gate, passed: checkpoints.length > 0, checkpoints, artifacts: findGateArtifacts(join(root, 'docs'), gate), requirement: GATE_CHECKS[gate]?.check || '' };
179
119
  });
180
120
  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) })) };
121
+ const allSessions = getSessions(db); const leader = getLeader(db);
122
+ const sessionInfo = { active_sessions: allSessions.length, leader: leader?.id, sessions: allSessions.map(s => ({ id: s.id, role: s.role, platform: s.platform, alive_s: Math.round((Date.now()-s.last_heartbeat)/1000) })) };
182
123
  return {
183
124
  content: [{ type: 'text', text: JSON.stringify({
184
125
  project: root,
@@ -202,7 +143,7 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
202
143
  const pstate = readPipeline();
203
144
  const targetGate = gate || pstate?.current_gate || 'Gate A';
204
145
  const artifacts = findGateArtifacts(join(root, 'docs'), targetGate);
205
- const checkpoints = readCheckpoints(root, targetGate);
146
+ const checkpoints = readCheckpointsDb( targetGate);
206
147
  const requirement = GATE_CHECKS[targetGate];
207
148
 
208
149
  // Hard check: must have artifacts and/or checkpoints
@@ -251,16 +192,14 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
251
192
 
252
193
  // Enforce: must pass current gate
253
194
  const artifacts = findGateArtifacts(join(root, 'docs'), currentGate);
254
- const checkpoints = readCheckpoints(root, currentGate);
195
+ const checkpoints = readCheckpointsDb( currentGate);
255
196
  if (artifacts.length === 0 && checkpoints.length === 0) {
256
197
  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
198
  }
258
199
 
259
- // Allowed — advance
260
- const cpDir = join(root, '.jarvis', 'checkpoints');
261
- if (!existsSync(cpDir)) mkdirSync(cpDir, { recursive: true });
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] });
200
+ // Allowed — advance (SQLite)
201
+ addCheckpoint(db, currentGate, gate, extra?.sessionId);
202
+ updatePipelineGate(db, gate);
264
203
 
265
204
  const nextGate = GATES[targetIdx + 1];
266
205
  return {
@@ -282,7 +221,7 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
282
221
  async () => {
283
222
  const gates = GATES.map(gate => ({
284
223
  gate,
285
- passed: readCheckpoints(root, gate).length > 0,
224
+ passed: readCheckpointsDb( gate).length > 0,
286
225
  artifacts: findGateArtifacts(join(root, 'docs'), gate),
287
226
  }));
288
227
  const completed = gates.filter(g => g.passed).length;
@@ -292,7 +231,7 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
292
231
  if (gate.passed) {
293
232
  reports[gate.gate] = {
294
233
  artifacts: gate.artifacts,
295
- checkpoints: readCheckpoints(root, gate.gate),
234
+ checkpoints: readCheckpointsDb( gate.gate),
296
235
  };
297
236
  }
298
237
  }
@@ -350,7 +289,7 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
350
289
  }, async (uri) => {
351
290
  const gate = decodeURIComponent(uri.pathname.split('/').pop()).replace(/_/g, ' ');
352
291
  const artifacts = findGateArtifacts(join(root, 'docs'), gate);
353
- const checkpoints = readCheckpoints(root, gate);
292
+ const checkpoints = readCheckpointsDb( gate);
354
293
  const text = `# ${gate} Report\n\n**Passed:** ${checkpoints.length > 0}\n**Checkpoints:** ${checkpoints.map(c => c.passed_at).join(', ') || 'none'}\n\n**Artifacts:**\n${artifacts.map(a => `- ${a}`).join('\n') || 'none'}`;
355
294
  return { contents: [{ uri: uri.href, text, mimeType: 'text/markdown' }] };
356
295
  });
@@ -366,18 +305,7 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
366
305
  // ---- Health ----
367
306
  app.get('/health', (_req, res) => res.json({ status: 'ok', version: readPkgVersion(), tools: ['pipeline_init', 'pipeline_status', 'gate_enforce', 'advance_gate', 'report_status'] }));
368
307
 
369
- // ---- Agent Model Config ----
370
- const agentConfigPath = join(root, '.jarvis', 'agent-models.json');
371
-
372
- function readAgentConfig() {
373
- if (!existsSync(agentConfigPath)) return {};
374
- try { return JSON.parse(readFileSync(agentConfigPath, 'utf-8')); } catch { return {}; }
375
- }
376
- function writeAgentConfig(cfg) {
377
- const dir = join(root, '.jarvis'); if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
378
- writeFileSync(agentConfigPath, JSON.stringify(cfg, null, 2));
379
- }
380
-
308
+ // ---- Agent Model Config (SQLite) ----
381
309
  const AVAILABLE_MODELS = [
382
310
  'deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek/deepseek-v4-pro', 'deepseek/deepseek-v4-flash',
383
311
  'gpt-5.5', 'gpt-5.4', 'gpt-5.3-codex', 'gpt-5.3-codex-spark', 'gpt-5.4-mini', 'gpt-5.2',
@@ -404,49 +332,45 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
404
332
  { id:'review-qa', name:'Review QA', role:'评审', icon:'eye', defaultModel:'deepseek-v4-pro' },
405
333
  ];
406
334
 
407
- // REST: agent config
335
+ // REST: agent config (SQLite)
408
336
  app.get('/api/agents', (_req, res) => {
409
- const cfg = readAgentConfig();
410
- const list = AGENT_LIST.map(a => ({ ...a, model: cfg[a.id] || a.defaultModel }));
337
+ const cfg = getAgentConfig(db);
338
+ const list = AGENT_LIST.map(a => ({ ...a, model: cfg[a.id] || a.defaultModel, is_custom: !!cfg[a.id] }));
411
339
  res.json({ agents: list, available_models: AVAILABLE_MODELS });
412
340
  });
413
-
414
341
  app.post('/api/agents', (req, res) => {
415
342
  const { agent_id, model } = req.body;
416
343
  if (!agent_id || !model) return res.status(400).json({ error: 'agent_id and model required' });
417
344
  if (!AVAILABLE_MODELS.includes(model)) return res.status(400).json({ error: `Unknown model. Available: ${AVAILABLE_MODELS.join(', ')}` });
418
- const cfg = readAgentConfig();
419
- cfg[agent_id] = model;
420
- writeAgentConfig(cfg);
345
+ setAgentModel(db, agent_id, model);
421
346
  res.json({ ok: true, agent_id, model });
422
347
  });
423
348
 
424
- // MCP: agent_config
425
- server.tool('agent_config', '配置子 Agent 模型。读取/设置特定 Agent 的模型。', {
426
- agent_id: z.string().optional().describe('Agent ID(不传则列出全部)'),
427
- model: z.string().optional().describe('模型名(不传则只读当前配置)'),
349
+ // MCP: agent_config (SQLite)
350
+ server.tool('agent_config', '配置子Agent模型(SQLite持久化)。', {
351
+ agent_id: z.string().optional(), model: z.string().optional(),
428
352
  }, async ({ agent_id, model }) => {
429
- const cfg = readAgentConfig();
353
+ const cfg = getAgentConfig(db);
430
354
  if (agent_id && model) {
431
355
  if (!AVAILABLE_MODELS.includes(model)) return { content: [{ type: 'text', text: JSON.stringify({ error: `Unknown model. Available: ${AVAILABLE_MODELS.join(', ')}` }) }] };
432
- cfg[agent_id] = model;
433
- writeAgentConfig(cfg);
434
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, agent_id, model, message: `${agent_id} → ${model}` }) }] };
356
+ setAgentModel(db, agent_id, model);
357
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, agent_id, model }) }] };
435
358
  }
436
359
  const list = AGENT_LIST.map(a => ({ id: a.id, name: a.name, role: a.role, model: cfg[a.id] || a.defaultModel, is_custom: !!cfg[a.id] }));
437
360
  return { content: [{ type: 'text', text: JSON.stringify({ agents: list, available_models: AVAILABLE_MODELS }) }] };
438
361
  });
439
362
 
440
- // ---- SSE (real-time pipeline events) ----
363
+ // ---- SSE ----
441
364
  const sseClients = new Set();
442
365
  app.get('/api/events', (req, res) => {
443
366
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
444
- sseClients.add(res);
445
- req.on('close', () => sseClients.delete(res));
367
+ sseClients.add(res); req.on('close', () => sseClients.delete(res));
446
368
  });
447
369
  setInterval(() => {
448
370
  if (sseClients.size === 0) return;
449
- const gates = GATES.map(g => ({ gate: g, passed: readCheckpoints(root, g).length > 0, artifacts: findGateArtifacts(join(root, 'docs'), g), checkpoints: readCheckpoints(root, g), requirement: GATE_CHECKS[g]?.check || '' }));
371
+ const checkpoints = getCheckpoints(db);
372
+ const cpGateMap = {}; for (const c of checkpoints) cpGateMap[c.gate] = c;
373
+ const gates = GATES.map(g => ({ gate: g, passed: !!cpGateMap[g], checkpoints: cpGateMap[g] ? [cpGateMap[g]] : [], artifacts: findGateArtifacts(join(root, 'docs'), g), requirement: GATE_CHECKS[g]?.check || '' }));
450
374
  const current = gates.find(g => !g.passed)?.gate || 'Complete';
451
375
  const completed = gates.filter(g => g.passed).map(g => g.gate);
452
376
  const pct = Math.round(completed.length / gates.length * 100);
@@ -486,13 +410,7 @@ export function engineStatus() {
486
410
  }
487
411
 
488
412
  function readPkgVersion() { try { return JSON.parse(readFileSync(resolve(import.meta.dirname, '..', '..', 'package.json'), 'utf-8')).version; } catch { return '?.?.?'; } }
489
-
490
- function readCheckpoints(root, gate) {
491
- const cpDir = join(root, '.jarvis', 'checkpoints');
492
- if (!existsSync(cpDir)) return [];
493
- const files = readdirSync(cpDir).filter(f => f.includes(gate.replace(/ /g, '_')));
494
- return files.map(f => { try { return JSON.parse(readFileSync(join(cpDir, f), 'utf-8')); } catch { return null; } }).filter(Boolean);
495
- }
413
+ function readCheckpointsDb(gate) { return getCheckpoints(db, gate); }
496
414
 
497
415
  function findGateArtifacts(docsDir, gate) {
498
416
  const subdir = GATE_DIRS[gate]; if (!subdir) return [];