openairev 0.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.
@@ -0,0 +1,292 @@
1
+ import { writeFileSync, readFileSync, existsSync, readdirSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ export function getChainsDir(cwd = process.cwd()) {
5
+ return join(cwd, '.openairev', 'chains');
6
+ }
7
+
8
+ // Valid stages and their allowed transitions
9
+ const TRANSITIONS = {
10
+ analyze: ['awaiting_user', 'planning', 'implementation'],
11
+ awaiting_user: ['planning', 'implementation', 'analyze'],
12
+ planning: ['plan_review'],
13
+ plan_review: ['plan_fix', 'implementation', 'error'],
14
+ plan_fix: ['plan_review'],
15
+ implementation: ['code_review'],
16
+ code_review: ['code_fix', 'implementation', 'done', 'error'],
17
+ code_fix: ['code_review'],
18
+ done: [],
19
+ };
20
+
21
+ /**
22
+ * Create a new chain with full workflow state.
23
+ */
24
+ export function createChain({ executor, reviewer, topic, maxRounds, specRef, cwd = process.cwd() }) {
25
+ const dir = getChainsDir(cwd);
26
+ mkdirSync(dir, { recursive: true });
27
+
28
+ const chain = {
29
+ chain_id: `chain_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
30
+ created: new Date().toISOString(),
31
+ updated: new Date().toISOString(),
32
+ status: 'active',
33
+ stage: 'analyze',
34
+ task: {
35
+ user_request: topic || null,
36
+ spec_ref: specRef || null,
37
+ },
38
+ participants: {
39
+ executor,
40
+ reviewer,
41
+ },
42
+ max_rounds: maxRounds,
43
+ phase_index: 0,
44
+ phases: [],
45
+ artifacts: {
46
+ analysis: null,
47
+ plan: null,
48
+ plan_review: null,
49
+ current_diff_ref: null,
50
+ },
51
+ rounds: [],
52
+ questions: [],
53
+ sessions: {
54
+ executor: {},
55
+ reviewer: {},
56
+ },
57
+ };
58
+
59
+ saveChain(chain, cwd);
60
+ return chain;
61
+ }
62
+
63
+ /**
64
+ * Transition chain to a new stage. Validates the transition is allowed.
65
+ */
66
+ export function transitionTo(chain, newStage, cwd = process.cwd()) {
67
+ const allowed = TRANSITIONS[chain.stage];
68
+ if (!allowed || !allowed.includes(newStage)) {
69
+ throw new Error(`Invalid transition: ${chain.stage} → ${newStage}. Allowed: ${allowed?.join(', ')}`);
70
+ }
71
+
72
+ chain.stage = newStage;
73
+ chain.updated = new Date().toISOString();
74
+
75
+ // Set chain status based on stage
76
+ if (newStage === 'done') {
77
+ chain.status = 'completed';
78
+ } else if (newStage === 'awaiting_user') {
79
+ chain.status = 'blocked';
80
+ } else if (newStage === 'error') {
81
+ chain.status = 'error';
82
+ } else {
83
+ chain.status = 'active';
84
+ }
85
+
86
+ saveChain(chain, cwd);
87
+ return chain;
88
+ }
89
+
90
+ /**
91
+ * Record a completed review round (plan or code).
92
+ */
93
+ export function addRound(chain, { kind, review, toolResults, phaseId, cwd = process.cwd() }) {
94
+ chain.updated = new Date().toISOString();
95
+
96
+ chain.rounds.push({
97
+ round: chain.rounds.length + 1,
98
+ kind: kind || 'code_review',
99
+ phase_id: phaseId || chain.phases[chain.phase_index]?.id || null,
100
+ timestamp: new Date().toISOString(),
101
+ review: {
102
+ verdict: review.verdict,
103
+ },
104
+ tool_results: toolResults || null,
105
+ result: review.verdict?.status || null,
106
+ });
107
+
108
+ // Update reviewer session ID
109
+ if (review.session_id) {
110
+ chain.sessions.reviewer[kind || 'code_review'] = review.session_id;
111
+ }
112
+
113
+ saveChain(chain, cwd);
114
+ return chain;
115
+ }
116
+
117
+ /**
118
+ * Set an artifact on the chain.
119
+ */
120
+ export function setArtifact(chain, key, value, cwd = process.cwd()) {
121
+ chain.artifacts[key] = value;
122
+ chain.updated = new Date().toISOString();
123
+ saveChain(chain, cwd);
124
+ }
125
+
126
+ /**
127
+ * Set executor session ID for the current stage.
128
+ */
129
+ export function setExecutorSession(chain, sessionId, stage, cwd = process.cwd()) {
130
+ chain.sessions.executor[stage || chain.stage] = sessionId;
131
+ chain.updated = new Date().toISOString();
132
+ saveChain(chain, cwd);
133
+ }
134
+
135
+ /**
136
+ * Get the executor session ID for a given stage (or latest).
137
+ */
138
+ export function getExecutorSession(chain, stage) {
139
+ if (stage) return chain.sessions.executor[stage] || null;
140
+ // Return the most recently set session
141
+ const stages = Object.keys(chain.sessions.executor);
142
+ return stages.length > 0 ? chain.sessions.executor[stages[stages.length - 1]] : null;
143
+ }
144
+
145
+ /**
146
+ * Get the reviewer session ID for a given review kind (or latest).
147
+ */
148
+ export function getReviewerSession(chain, kind) {
149
+ if (kind) return chain.sessions.reviewer[kind] || null;
150
+ const kinds = Object.keys(chain.sessions.reviewer);
151
+ return kinds.length > 0 ? chain.sessions.reviewer[kinds[kinds.length - 1]] : null;
152
+ }
153
+
154
+ /**
155
+ * Add a question that blocks the chain.
156
+ */
157
+ export function addQuestion(chain, question, cwd = process.cwd()) {
158
+ chain.questions.push({
159
+ id: `q${chain.questions.length + 1}`,
160
+ question,
161
+ answer: null,
162
+ status: 'pending',
163
+ });
164
+ chain.updated = new Date().toISOString();
165
+ saveChain(chain, cwd);
166
+ return chain.questions[chain.questions.length - 1];
167
+ }
168
+
169
+ /**
170
+ * Answer a pending question. If all answered, chain can proceed.
171
+ */
172
+ export function answerQuestion(chain, questionId, answer, cwd = process.cwd()) {
173
+ const q = chain.questions.find(q => q.id === questionId);
174
+ if (!q) throw new Error(`Question not found: ${questionId}`);
175
+ q.answer = answer;
176
+ q.status = 'answered';
177
+ chain.updated = new Date().toISOString();
178
+ saveChain(chain, cwd);
179
+ return q;
180
+ }
181
+
182
+ /**
183
+ * Check if chain has unanswered questions.
184
+ */
185
+ export function hasPendingQuestions(chain) {
186
+ return chain.questions.some(q => q.status === 'pending');
187
+ }
188
+
189
+ /**
190
+ * Add or update phases on the chain.
191
+ */
192
+ export function setPhases(chain, phases, cwd = process.cwd()) {
193
+ chain.phases = phases.map((p, i) => ({
194
+ id: p.id || `phase_${i + 1}`,
195
+ name: p.name,
196
+ goal: p.goal || null,
197
+ status: p.status || 'pending',
198
+ }));
199
+ chain.phase_index = 0;
200
+ chain.updated = new Date().toISOString();
201
+ saveChain(chain, cwd);
202
+ }
203
+
204
+ /**
205
+ * Advance to the next phase. Returns true if there's a next phase.
206
+ */
207
+ export function advancePhase(chain, cwd = process.cwd()) {
208
+ if (chain.phase_index < chain.phases.length - 1) {
209
+ chain.phases[chain.phase_index].status = 'approved';
210
+ chain.phase_index += 1;
211
+ chain.phases[chain.phase_index].status = 'in_progress';
212
+ chain.updated = new Date().toISOString();
213
+ saveChain(chain, cwd);
214
+ return true;
215
+ }
216
+ // Final phase done
217
+ if (chain.phases.length > 0) {
218
+ chain.phases[chain.phase_index].status = 'approved';
219
+ saveChain(chain, cwd);
220
+ }
221
+ return false;
222
+ }
223
+
224
+ /**
225
+ * Get current phase, or null if no phases defined.
226
+ */
227
+ export function getCurrentPhase(chain) {
228
+ if (!chain.phases || chain.phases.length === 0) return null;
229
+ return chain.phases[chain.phase_index] || null;
230
+ }
231
+
232
+ /**
233
+ * Close chain with a final status.
234
+ */
235
+ export function closeChain(chain, status, cwd = process.cwd()) {
236
+ chain.status = status;
237
+ chain.stage = status === 'completed' ? 'done' : chain.stage;
238
+ chain.updated = new Date().toISOString();
239
+ chain.final_verdict = chain.rounds.length > 0
240
+ ? chain.rounds[chain.rounds.length - 1].review.verdict
241
+ : null;
242
+ saveChain(chain, cwd);
243
+ }
244
+
245
+ /**
246
+ * Load a chain by ID.
247
+ */
248
+ export function loadChain(chainId, cwd = process.cwd()) {
249
+ const filePath = join(getChainsDir(cwd), `${chainId}.json`);
250
+ if (!existsSync(filePath)) return null;
251
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
252
+ }
253
+
254
+ /**
255
+ * List all chains, optionally filtered by status.
256
+ */
257
+ export function listChains(cwd = process.cwd(), { status, limit = 20 } = {}) {
258
+ const dir = getChainsDir(cwd);
259
+ if (!existsSync(dir)) return [];
260
+
261
+ return readdirSync(dir)
262
+ .filter(f => f.endsWith('.json'))
263
+ .map(f => {
264
+ try {
265
+ return JSON.parse(readFileSync(join(dir, f), 'utf-8'));
266
+ } catch {
267
+ return null;
268
+ }
269
+ })
270
+ .filter(Boolean)
271
+ .filter(c => !status || c.status === status)
272
+ .sort((a, b) => new Date(b.updated) - new Date(a.updated))
273
+ .slice(0, limit);
274
+ }
275
+
276
+ /**
277
+ * Get the most recent active or blocked chain.
278
+ */
279
+ export function getActiveChain(cwd = process.cwd()) {
280
+ const chains = listChains(cwd, { limit: 20 });
281
+ return chains.find(c => c.status === 'active' || c.status === 'blocked') || null;
282
+ }
283
+
284
+ let dirEnsured = false;
285
+ function saveChain(chain, cwd) {
286
+ const dir = getChainsDir(cwd);
287
+ if (!dirEnsured) {
288
+ mkdirSync(dir, { recursive: true });
289
+ dirEnsured = true;
290
+ }
291
+ writeFileSync(join(dir, `${chain.chain_id}.json`), JSON.stringify(chain, null, 2));
292
+ }
@@ -0,0 +1,188 @@
1
+ import { describe, it, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdirSync, rmSync } from 'fs';
4
+ import { join } from 'path';
5
+ import {
6
+ createChain, transitionTo, addRound, closeChain, loadChain,
7
+ listChains, getActiveChain, setExecutorSession, getExecutorSession,
8
+ addQuestion, answerQuestion, hasPendingQuestions,
9
+ setPhases, advancePhase, getCurrentPhase, setArtifact,
10
+ } from './chain-manager.js';
11
+
12
+ const TMP = join(process.cwd(), '.test-tmp-chains');
13
+
14
+ describe('chain-manager', () => {
15
+ beforeEach(() => {
16
+ mkdirSync(TMP, { recursive: true });
17
+ });
18
+
19
+ afterEach(() => {
20
+ rmSync(TMP, { recursive: true, force: true });
21
+ });
22
+
23
+ it('creates a chain with workflow state', () => {
24
+ const chain = createChain({
25
+ executor: 'claude_code',
26
+ reviewer: 'codex',
27
+ topic: 'auth middleware',
28
+ maxRounds: 3,
29
+ cwd: TMP,
30
+ });
31
+
32
+ assert.ok(chain.chain_id.startsWith('chain_'));
33
+ assert.equal(chain.status, 'active');
34
+ assert.equal(chain.stage, 'analyze');
35
+ assert.equal(chain.participants.executor, 'claude_code');
36
+ assert.equal(chain.participants.reviewer, 'codex');
37
+ assert.equal(chain.max_rounds, 3);
38
+ assert.equal(chain.task.user_request, 'auth middleware');
39
+ assert.deepEqual(chain.rounds, []);
40
+ assert.deepEqual(chain.phases, []);
41
+ assert.deepEqual(chain.questions, []);
42
+ });
43
+
44
+ it('creates chain with spec ref', () => {
45
+ const chain = createChain({
46
+ executor: 'codex', reviewer: 'claude_code', maxRounds: 1,
47
+ specRef: 'openspec/changes/070_add-admin-dashboard-ui/',
48
+ cwd: TMP,
49
+ });
50
+ assert.equal(chain.task.spec_ref, 'openspec/changes/070_add-admin-dashboard-ui/');
51
+ });
52
+
53
+ it('transitions between valid stages', () => {
54
+ const chain = createChain({ executor: 'a', reviewer: 'b', maxRounds: 3, cwd: TMP });
55
+
56
+ transitionTo(chain, 'planning', TMP);
57
+ assert.equal(chain.stage, 'planning');
58
+ assert.equal(chain.status, 'active');
59
+
60
+ transitionTo(chain, 'plan_review', TMP);
61
+ assert.equal(chain.stage, 'plan_review');
62
+
63
+ transitionTo(chain, 'implementation', TMP);
64
+ assert.equal(chain.stage, 'implementation');
65
+
66
+ transitionTo(chain, 'code_review', TMP);
67
+ transitionTo(chain, 'code_fix', TMP);
68
+ transitionTo(chain, 'code_review', TMP);
69
+ transitionTo(chain, 'done', TMP);
70
+ assert.equal(chain.status, 'completed');
71
+ });
72
+
73
+ it('rejects invalid transitions', () => {
74
+ const chain = createChain({ executor: 'a', reviewer: 'b', maxRounds: 1, cwd: TMP });
75
+ assert.throws(() => transitionTo(chain, 'code_review', TMP), /Invalid transition/);
76
+ });
77
+
78
+ it('sets blocked status on awaiting_user', () => {
79
+ const chain = createChain({ executor: 'a', reviewer: 'b', maxRounds: 1, cwd: TMP });
80
+ transitionTo(chain, 'awaiting_user', TMP);
81
+ assert.equal(chain.status, 'blocked');
82
+ assert.equal(chain.stage, 'awaiting_user');
83
+ });
84
+
85
+ it('manages questions', () => {
86
+ const chain = createChain({ executor: 'a', reviewer: 'b', maxRounds: 1, cwd: TMP });
87
+
88
+ addQuestion(chain, 'Should auth apply to all routes?', TMP);
89
+ assert.equal(chain.questions.length, 1);
90
+ assert.equal(hasPendingQuestions(chain), true);
91
+
92
+ answerQuestion(chain, 'q1', 'Only /api/* routes', TMP);
93
+ assert.equal(chain.questions[0].status, 'answered');
94
+ assert.equal(chain.questions[0].answer, 'Only /api/* routes');
95
+ assert.equal(hasPendingQuestions(chain), false);
96
+ });
97
+
98
+ it('manages phases', () => {
99
+ const chain = createChain({ executor: 'a', reviewer: 'b', maxRounds: 3, cwd: TMP });
100
+
101
+ setPhases(chain, [
102
+ { name: 'Auth middleware', goal: 'Implement middleware' },
103
+ { name: 'Tests', goal: 'Write tests' },
104
+ ], TMP);
105
+
106
+ assert.equal(chain.phases.length, 2);
107
+ assert.equal(chain.phase_index, 0);
108
+ assert.equal(getCurrentPhase(chain).name, 'Auth middleware');
109
+
110
+ const hasMore = advancePhase(chain, TMP);
111
+ assert.equal(hasMore, true);
112
+ assert.equal(chain.phase_index, 1);
113
+ assert.equal(getCurrentPhase(chain).name, 'Tests');
114
+ assert.equal(chain.phases[0].status, 'approved');
115
+
116
+ const hasMore2 = advancePhase(chain, TMP);
117
+ assert.equal(hasMore2, false);
118
+ assert.equal(chain.phases[1].status, 'approved');
119
+ });
120
+
121
+ it('adds rounds with kind', () => {
122
+ const chain = createChain({ executor: 'a', reviewer: 'b', maxRounds: 3, cwd: TMP });
123
+ transitionTo(chain, 'planning', TMP);
124
+ transitionTo(chain, 'plan_review', TMP);
125
+
126
+ addRound(chain, {
127
+ kind: 'plan_review',
128
+ review: { verdict: { status: 'approved', confidence: 0.9 }, session_id: 'rev-1' },
129
+ cwd: TMP,
130
+ });
131
+
132
+ assert.equal(chain.rounds.length, 1);
133
+ assert.equal(chain.rounds[0].kind, 'plan_review');
134
+ assert.equal(chain.rounds[0].result, 'approved');
135
+ assert.equal(chain.sessions.reviewer.plan_review, 'rev-1');
136
+ });
137
+
138
+ it('sets and gets executor sessions per stage', () => {
139
+ const chain = createChain({ executor: 'a', reviewer: 'b', maxRounds: 1, cwd: TMP });
140
+
141
+ setExecutorSession(chain, 'sess-1', 'analyze', TMP);
142
+ setExecutorSession(chain, 'sess-2', 'implementation', TMP);
143
+
144
+ assert.equal(getExecutorSession(chain, 'analyze'), 'sess-1');
145
+ assert.equal(getExecutorSession(chain, 'implementation'), 'sess-2');
146
+ });
147
+
148
+ it('sets artifacts', () => {
149
+ const chain = createChain({ executor: 'a', reviewer: 'b', maxRounds: 1, cwd: TMP });
150
+ setArtifact(chain, 'plan', 'Build the thing in 3 steps', TMP);
151
+ const loaded = loadChain(chain.chain_id, TMP);
152
+ assert.equal(loaded.artifacts.plan, 'Build the thing in 3 steps');
153
+ });
154
+
155
+ it('persists and loads chain', () => {
156
+ const chain = createChain({ executor: 'a', reviewer: 'b', maxRounds: 2, cwd: TMP });
157
+ const loaded = loadChain(chain.chain_id, TMP);
158
+ assert.equal(loaded.chain_id, chain.chain_id);
159
+ assert.equal(loaded.stage, 'analyze');
160
+ });
161
+
162
+ it('lists and filters chains', () => {
163
+ createChain({ executor: 'a', reviewer: 'b', maxRounds: 1, cwd: TMP });
164
+ const c2 = createChain({ executor: 'a', reviewer: 'b', maxRounds: 1, cwd: TMP });
165
+ closeChain(c2, 'completed', TMP);
166
+
167
+ const active = listChains(TMP, { status: 'active' });
168
+ assert.equal(active.length, 1);
169
+
170
+ const all = listChains(TMP);
171
+ assert.equal(all.length, 2);
172
+ });
173
+
174
+ it('getActiveChain finds active or blocked', () => {
175
+ const c1 = createChain({ executor: 'a', reviewer: 'b', maxRounds: 1, cwd: TMP });
176
+ transitionTo(c1, 'awaiting_user', TMP); // blocked
177
+
178
+ const active = getActiveChain(TMP);
179
+ assert.ok(active);
180
+ assert.equal(active.status, 'blocked');
181
+ });
182
+
183
+ it('returns null when no chains exist', () => {
184
+ assert.equal(getActiveChain(TMP), null);
185
+ assert.deepEqual(listChains(TMP), []);
186
+ assert.equal(loadChain('nonexistent', TMP), null);
187
+ });
188
+ });
@@ -0,0 +1,66 @@
1
+ import { writeFileSync, readFileSync, existsSync, readdirSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ export function getSessionsDir(cwd = process.cwd()) {
5
+ return join(cwd, '.openairev', 'sessions');
6
+ }
7
+
8
+ /**
9
+ * Save a review session to disk.
10
+ */
11
+ export function saveSession(session, cwd = process.cwd()) {
12
+ const dir = getSessionsDir(cwd);
13
+ mkdirSync(dir, { recursive: true });
14
+
15
+ const filename = `${session.id}.json`;
16
+ writeFileSync(join(dir, filename), JSON.stringify(session, null, 2));
17
+ return filename;
18
+ }
19
+
20
+ /**
21
+ * Load a session by ID.
22
+ */
23
+ export function loadSession(sessionId, cwd = process.cwd()) {
24
+ const filePath = join(getSessionsDir(cwd), `${sessionId}.json`);
25
+ if (!existsSync(filePath)) return null;
26
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
27
+ }
28
+
29
+ /**
30
+ * List all sessions, sorted by most recent.
31
+ */
32
+ export function listSessions(cwd = process.cwd(), limit = 20) {
33
+ const dir = getSessionsDir(cwd);
34
+ if (!existsSync(dir)) return [];
35
+
36
+ return readdirSync(dir)
37
+ .filter(f => f.endsWith('.json'))
38
+ .map(f => {
39
+ try {
40
+ const data = JSON.parse(readFileSync(join(dir, f), 'utf-8'));
41
+ return data;
42
+ } catch {
43
+ return null;
44
+ }
45
+ })
46
+ .filter(Boolean)
47
+ .sort((a, b) => new Date(b.created) - new Date(a.created))
48
+ .slice(0, limit);
49
+ }
50
+
51
+ /**
52
+ * Create a new session object.
53
+ */
54
+ export function createSession({ executor, reviewer, diff_ref, task }) {
55
+ return {
56
+ id: `review_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
57
+ created: new Date().toISOString(),
58
+ status: 'in_progress',
59
+ executor,
60
+ reviewer,
61
+ diff_ref: diff_ref || null,
62
+ task: task || null,
63
+ iterations: [],
64
+ final_verdict: null,
65
+ };
66
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, it, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdirSync, rmSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { createSession, saveSession, loadSession, listSessions } from './session-manager.js';
6
+
7
+ const TMP = join(process.cwd(), '.test-tmp-sessions');
8
+
9
+ describe('session-manager', () => {
10
+ beforeEach(() => {
11
+ mkdirSync(TMP, { recursive: true });
12
+ });
13
+
14
+ afterEach(() => {
15
+ rmSync(TMP, { recursive: true, force: true });
16
+ });
17
+
18
+ it('creates session with correct structure', () => {
19
+ const session = createSession({
20
+ executor: 'claude_code',
21
+ reviewer: 'codex',
22
+ });
23
+
24
+ assert.ok(session.id.startsWith('review_'));
25
+ assert.equal(session.status, 'in_progress');
26
+ assert.equal(session.executor, 'claude_code');
27
+ assert.equal(session.reviewer, 'codex');
28
+ assert.deepEqual(session.iterations, []);
29
+ assert.equal(session.final_verdict, null);
30
+ });
31
+
32
+ it('saves and loads session', () => {
33
+ const session = createSession({ executor: 'a', reviewer: 'b', depth: 1 });
34
+ saveSession(session, TMP);
35
+
36
+ const loaded = loadSession(session.id, TMP);
37
+ assert.equal(loaded.id, session.id);
38
+ assert.equal(loaded.executor, 'a');
39
+ });
40
+
41
+ it('lists sessions sorted by newest first', async () => {
42
+ const s1 = createSession({ executor: 'a', reviewer: 'b', depth: 1 });
43
+ s1.created = '2026-01-01T00:00:00Z';
44
+ saveSession(s1, TMP);
45
+
46
+ const s2 = createSession({ executor: 'a', reviewer: 'b', depth: 1 });
47
+ s2.created = '2026-03-01T00:00:00Z';
48
+ saveSession(s2, TMP);
49
+
50
+ const list = listSessions(TMP);
51
+ assert.equal(list.length, 2);
52
+ assert.equal(list[0].id, s2.id); // newest first
53
+ });
54
+
55
+ it('respects limit', () => {
56
+ for (let i = 0; i < 5; i++) {
57
+ saveSession(createSession({ executor: 'a', reviewer: 'b', depth: 1 }), TMP);
58
+ }
59
+ const list = listSessions(TMP, 3);
60
+ assert.equal(list.length, 3);
61
+ });
62
+
63
+ it('returns null for nonexistent session', () => {
64
+ assert.equal(loadSession('nonexistent', TMP), null);
65
+ });
66
+
67
+ it('returns empty array when no sessions dir', () => {
68
+ const empty = join(TMP, 'empty');
69
+ mkdirSync(empty, { recursive: true });
70
+ assert.deepEqual(listSessions(empty), []);
71
+ });
72
+ });
@@ -0,0 +1,27 @@
1
+ import { execFileSync } from 'child_process';
2
+
3
+ /**
4
+ * Get the current git diff. Tries staged first, then unstaged.
5
+ * Returns empty string if no changes found.
6
+ */
7
+ export function getDiff(ref) {
8
+ if (ref) {
9
+ return gitExec(['diff', ref]);
10
+ }
11
+
12
+ const staged = gitExec(['diff', '--cached']);
13
+ if (staged.trim()) return staged;
14
+
15
+ const unstaged = gitExec(['diff']);
16
+ if (unstaged.trim()) return unstaged;
17
+
18
+ return '';
19
+ }
20
+
21
+ function gitExec(args) {
22
+ try {
23
+ return execFileSync('git', args, { encoding: 'utf-8', maxBuffer: 5 * 1024 * 1024 });
24
+ } catch (e) {
25
+ throw new Error(`git ${args[0]} failed: ${e.message}`);
26
+ }
27
+ }
@@ -0,0 +1,47 @@
1
+ import { execSync } from 'child_process';
2
+
3
+ /**
4
+ * Run configured tool gates and return results.
5
+ * Uses explicit commands from config, or falls back to defaults.
6
+ */
7
+ export function runToolGates(tools, cwd = process.cwd(), toolCommands = {}) {
8
+ const results = {};
9
+
10
+ for (const tool of tools) {
11
+ const cmd = toolCommands[tool];
12
+ switch (tool) {
13
+ case 'run_tests':
14
+ results.tests = runCommand(cmd || 'npm test', cwd);
15
+ break;
16
+ case 'run_lint':
17
+ results.lint = runCommand(cmd || 'npm run lint', cwd);
18
+ break;
19
+ case 'run_typecheck':
20
+ results.typecheck = runCommand(cmd || 'npx tsc --noEmit', cwd);
21
+ break;
22
+ default:
23
+ if (cmd) {
24
+ results[tool] = runCommand(cmd, cwd);
25
+ }
26
+ break;
27
+ }
28
+ }
29
+
30
+ return results;
31
+ }
32
+
33
+ function runCommand(cmd, cwd) {
34
+ try {
35
+ const output = execSync(cmd, {
36
+ cwd,
37
+ encoding: 'utf-8',
38
+ timeout: 120_000,
39
+ stdio: 'pipe',
40
+ shell: true,
41
+ });
42
+ return { passed: true, output: output.trim().slice(-500) || 'OK' };
43
+ } catch (e) {
44
+ const output = (e.stdout || '') + (e.stderr || '') || e.message;
45
+ return { passed: false, output: output.trim().slice(-500) };
46
+ }
47
+ }