claude-code-session-manager 0.25.1 → 0.27.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/dist/assets/{TiptapBody-_BRc_wPu.js → TiptapBody-BsNr6F0B.js} +1 -1
- package/dist/assets/index-DU1pkhIQ.js +3533 -0
- package/dist/assets/index-Dwb94Uxm.css +32 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/dod-drain-hook.test.cjs +197 -0
- package/src/main/__tests__/dod-report.test.cjs +304 -0
- package/src/main/hives.cjs +96 -36
- package/src/main/lib/definitionOfDone.cjs +231 -2
- package/src/main/lib/dodDrainHook.cjs +81 -0
- package/src/main/scheduler.cjs +85 -22
- package/src/preload/api.d.ts +8 -7
- package/dist/assets/index-CK5Ob11w.js +0 -3525
- package/dist/assets/index-Cu9X6oyA.css +0 -32
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dod-report.test.cjs — unit tests for flagRiskySurfaces + writeReport.
|
|
3
|
+
*
|
|
4
|
+
* Run: timeout 120 node --test src/main/__tests__/dod-report.test.cjs
|
|
5
|
+
*
|
|
6
|
+
* Fixtures: os.tmpdir() only — never writes into real runs/.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const { test } = require('node:test');
|
|
12
|
+
const assert = require('node:assert/strict');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const { flagRiskySurfaces, writeReport } = require('../lib/definitionOfDone.cjs');
|
|
17
|
+
|
|
18
|
+
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function makeTmpDir() {
|
|
21
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'dod-report-test-'));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function rmdir(dir) {
|
|
25
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* */ }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writePrd(prdsDir, slug, implNotes, cwd) {
|
|
29
|
+
const body = [
|
|
30
|
+
'---',
|
|
31
|
+
`title: ${slug}`,
|
|
32
|
+
`cwd: ${cwd}`,
|
|
33
|
+
'estimateMinutes: 5',
|
|
34
|
+
'---',
|
|
35
|
+
'',
|
|
36
|
+
'# Goal',
|
|
37
|
+
'',
|
|
38
|
+
'Test fixture.',
|
|
39
|
+
'',
|
|
40
|
+
'# Acceptance criteria',
|
|
41
|
+
'',
|
|
42
|
+
'- [ ] `timeout 10 node -e "process.exit(0)"` passes.',
|
|
43
|
+
'',
|
|
44
|
+
'# Implementation notes',
|
|
45
|
+
'',
|
|
46
|
+
implNotes,
|
|
47
|
+
'',
|
|
48
|
+
'# Out of scope',
|
|
49
|
+
'',
|
|
50
|
+
'- N/A',
|
|
51
|
+
].join('\n');
|
|
52
|
+
fs.writeFileSync(path.join(prdsDir, `${slug}.md`), body);
|
|
53
|
+
return { slug, cwd };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── flagRiskySurfaces: money-path ────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
test('flagRiskySurfaces: flags money-path when impl notes reference payment', () => {
|
|
59
|
+
const tmpDir = makeTmpDir();
|
|
60
|
+
const prdsDir = path.join(tmpDir, 'prds');
|
|
61
|
+
const cwd = path.join(tmpDir, 'project');
|
|
62
|
+
fs.mkdirSync(prdsDir);
|
|
63
|
+
fs.mkdirSync(cwd);
|
|
64
|
+
const job = writePrd(prdsDir, '201-billing', '- Edit `src/payment-processor.js`\n- Update order schema', cwd);
|
|
65
|
+
try {
|
|
66
|
+
const flags = flagRiskySurfaces([job], { prdsDir });
|
|
67
|
+
assert.strictEqual(flags.length, 1);
|
|
68
|
+
assert.strictEqual(flags[0].slug, '201-billing');
|
|
69
|
+
assert.ok(flags[0].surfaces.includes('money-path'), `expected money-path in ${JSON.stringify(flags[0].surfaces)}`);
|
|
70
|
+
} finally {
|
|
71
|
+
rmdir(tmpDir);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('flagRiskySurfaces: flags auth when impl notes reference auth/token', () => {
|
|
76
|
+
const tmpDir = makeTmpDir();
|
|
77
|
+
const prdsDir = path.join(tmpDir, 'prds');
|
|
78
|
+
const cwd = path.join(tmpDir, 'project');
|
|
79
|
+
fs.mkdirSync(prdsDir);
|
|
80
|
+
fs.mkdirSync(cwd);
|
|
81
|
+
const job = writePrd(prdsDir, '202-auth', '- Modify `src/auth-middleware.py`\n- Update token refresh logic', cwd);
|
|
82
|
+
try {
|
|
83
|
+
const flags = flagRiskySurfaces([job], { prdsDir });
|
|
84
|
+
assert.strictEqual(flags.length, 1);
|
|
85
|
+
assert.ok(flags[0].surfaces.includes('auth'));
|
|
86
|
+
} finally {
|
|
87
|
+
rmdir(tmpDir);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('flagRiskySurfaces: flags migration when impl notes reference .sql file', () => {
|
|
92
|
+
const tmpDir = makeTmpDir();
|
|
93
|
+
const prdsDir = path.join(tmpDir, 'prds');
|
|
94
|
+
const cwd = path.join(tmpDir, 'project');
|
|
95
|
+
fs.mkdirSync(prdsDir);
|
|
96
|
+
fs.mkdirSync(cwd);
|
|
97
|
+
const job = writePrd(prdsDir, '203-schema', '- Run `db/migrations/0042_users.sql`', cwd);
|
|
98
|
+
try {
|
|
99
|
+
const flags = flagRiskySurfaces([job], { prdsDir });
|
|
100
|
+
assert.strictEqual(flags.length, 1);
|
|
101
|
+
assert.ok(flags[0].surfaces.includes('migration'));
|
|
102
|
+
} finally {
|
|
103
|
+
rmdir(tmpDir);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('flagRiskySurfaces: clean job is not returned', () => {
|
|
108
|
+
const tmpDir = makeTmpDir();
|
|
109
|
+
const prdsDir = path.join(tmpDir, 'prds');
|
|
110
|
+
const cwd = path.join(tmpDir, 'project');
|
|
111
|
+
fs.mkdirSync(prdsDir);
|
|
112
|
+
fs.mkdirSync(cwd);
|
|
113
|
+
const job = writePrd(prdsDir, '204-clean', '- Update `src/ui/button.tsx`\n- Add tooltip component', cwd);
|
|
114
|
+
try {
|
|
115
|
+
const flags = flagRiskySurfaces([job], { prdsDir });
|
|
116
|
+
assert.strictEqual(flags.length, 0);
|
|
117
|
+
} finally {
|
|
118
|
+
rmdir(tmpDir);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('flagRiskySurfaces: job with no PRD file is skipped (not thrown)', () => {
|
|
123
|
+
const tmpDir = makeTmpDir();
|
|
124
|
+
const prdsDir = path.join(tmpDir, 'prds');
|
|
125
|
+
const cwd = path.join(tmpDir, 'project');
|
|
126
|
+
fs.mkdirSync(prdsDir);
|
|
127
|
+
fs.mkdirSync(cwd);
|
|
128
|
+
try {
|
|
129
|
+
const flags = flagRiskySurfaces([{ slug: '205-missing', cwd }], { prdsDir });
|
|
130
|
+
assert.strictEqual(flags.length, 0);
|
|
131
|
+
} finally {
|
|
132
|
+
rmdir(tmpDir);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('flagRiskySurfaces: mixed batch returns only risky jobs', () => {
|
|
137
|
+
const tmpDir = makeTmpDir();
|
|
138
|
+
const prdsDir = path.join(tmpDir, 'prds');
|
|
139
|
+
const cwd = path.join(tmpDir, 'project');
|
|
140
|
+
fs.mkdirSync(prdsDir);
|
|
141
|
+
fs.mkdirSync(cwd);
|
|
142
|
+
const jobRisky = writePrd(prdsDir, '206-risky', '- Modify `src/trade-engine.js`', cwd);
|
|
143
|
+
const jobClean = writePrd(prdsDir, '207-clean', '- Update `src/tooltip.tsx`', cwd);
|
|
144
|
+
try {
|
|
145
|
+
const flags = flagRiskySurfaces([jobRisky, jobClean], { prdsDir });
|
|
146
|
+
assert.strictEqual(flags.length, 1);
|
|
147
|
+
assert.strictEqual(flags[0].slug, '206-risky');
|
|
148
|
+
} finally {
|
|
149
|
+
rmdir(tmpDir);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ─── writeReport: structure ────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
test('writeReport: all three jobs appear in AC table', () => {
|
|
156
|
+
const tmpDir = makeTmpDir();
|
|
157
|
+
const runsDir = path.join(tmpDir, 'runs');
|
|
158
|
+
const acResults = [
|
|
159
|
+
{ slug: '101-pass', status: 'pass', code: 0, ms: 500 },
|
|
160
|
+
{ slug: '102-fail', status: 'fail', code: 1, ms: 300 },
|
|
161
|
+
{ slug: '103-risky', status: 'pass', code: 0, ms: 400 },
|
|
162
|
+
];
|
|
163
|
+
const riskFlags = [{ slug: '103-risky', surfaces: ['money-path'] }];
|
|
164
|
+
try {
|
|
165
|
+
const reportPath = writeReport('abcd1234', { acResults, riskFlags, runsDir });
|
|
166
|
+
assert.ok(fs.existsSync(reportPath), `report missing at ${reportPath}`);
|
|
167
|
+
const content = fs.readFileSync(reportPath, 'utf8');
|
|
168
|
+
assert.ok(content.includes('101-pass'), 'missing 101-pass in table');
|
|
169
|
+
assert.ok(content.includes('102-fail'), 'missing 102-fail in table');
|
|
170
|
+
assert.ok(content.includes('103-risky'), 'missing 103-risky in table');
|
|
171
|
+
} finally {
|
|
172
|
+
rmdir(tmpDir);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('writeReport: fail and risky slug both appear in needs-attention list', () => {
|
|
177
|
+
const tmpDir = makeTmpDir();
|
|
178
|
+
const runsDir = path.join(tmpDir, 'runs');
|
|
179
|
+
const acResults = [
|
|
180
|
+
{ slug: '101-pass', status: 'pass', code: 0, ms: 500 },
|
|
181
|
+
{ slug: '102-fail', status: 'fail', code: 1, ms: 300 },
|
|
182
|
+
{ slug: '103-risky', status: 'pass', code: 0, ms: 400 },
|
|
183
|
+
];
|
|
184
|
+
const riskFlags = [{ slug: '103-risky', surfaces: ['money-path'] }];
|
|
185
|
+
try {
|
|
186
|
+
const reportPath = writeReport('abcd1234', { acResults, riskFlags, runsDir });
|
|
187
|
+
const content = fs.readFileSync(reportPath, 'utf8');
|
|
188
|
+
// Both should appear in the Needs Human Attention section
|
|
189
|
+
const attentionIdx = content.indexOf('## Needs Human Attention');
|
|
190
|
+
assert.ok(attentionIdx !== -1, 'missing Needs Human Attention section');
|
|
191
|
+
const attentionSection = content.slice(attentionIdx);
|
|
192
|
+
assert.ok(attentionSection.includes('102-fail'), '102-fail missing from attention list');
|
|
193
|
+
assert.ok(attentionSection.includes('103-risky'), '103-risky missing from attention list');
|
|
194
|
+
// Clean pass should NOT be in attention list
|
|
195
|
+
const passInAttention = attentionSection.includes('101-pass');
|
|
196
|
+
assert.ok(!passInAttention, '101-pass should not appear in attention list');
|
|
197
|
+
} finally {
|
|
198
|
+
rmdir(tmpDir);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('writeReport: report states review is recommended, not auto-run', () => {
|
|
203
|
+
const tmpDir = makeTmpDir();
|
|
204
|
+
const runsDir = path.join(tmpDir, 'runs');
|
|
205
|
+
try {
|
|
206
|
+
const reportPath = writeReport('abcd1234', { acResults: [], riskFlags: [], runsDir });
|
|
207
|
+
const content = fs.readFileSync(reportPath, 'utf8');
|
|
208
|
+
assert.ok(
|
|
209
|
+
content.toLowerCase().includes('recommended') && content.toLowerCase().includes('not auto-run'),
|
|
210
|
+
'report must state review is recommended, not auto-run'
|
|
211
|
+
);
|
|
212
|
+
} finally {
|
|
213
|
+
rmdir(tmpDir);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('writeReport: no .tmp files left after write', () => {
|
|
218
|
+
const tmpDir = makeTmpDir();
|
|
219
|
+
const runsDir = path.join(tmpDir, 'runs');
|
|
220
|
+
const acResults = [{ slug: '101-pass', status: 'pass', code: 0, ms: 100 }];
|
|
221
|
+
try {
|
|
222
|
+
writeReport('deadbeef', { acResults, riskFlags: [], runsDir });
|
|
223
|
+
// Walk all files in runsDir recursively, check none end in .tmp-*
|
|
224
|
+
function findTmps(dir) {
|
|
225
|
+
const hits = [];
|
|
226
|
+
try {
|
|
227
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
228
|
+
const full = path.join(dir, e.name);
|
|
229
|
+
if (e.isDirectory()) hits.push(...findTmps(full));
|
|
230
|
+
else if (e.name.includes('.tmp-')) hits.push(full);
|
|
231
|
+
}
|
|
232
|
+
} catch { /* */ }
|
|
233
|
+
return hits;
|
|
234
|
+
}
|
|
235
|
+
const tmps = findTmps(runsDir);
|
|
236
|
+
assert.deepStrictEqual(tmps, [], `found leftover tmp files: ${tmps.join(', ')}`);
|
|
237
|
+
} finally {
|
|
238
|
+
rmdir(tmpDir);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('writeReport: second call with same batchKey produces clean report, no tmp files', () => {
|
|
243
|
+
const tmpDir = makeTmpDir();
|
|
244
|
+
const runsDir = path.join(tmpDir, 'runs');
|
|
245
|
+
const acResults = [
|
|
246
|
+
{ slug: '101-pass', status: 'pass', code: 0, ms: 100 },
|
|
247
|
+
{ slug: '102-fail', status: 'fail', code: 1, ms: 200 },
|
|
248
|
+
];
|
|
249
|
+
const riskFlags = [{ slug: '101-pass', surfaces: ['auth'] }];
|
|
250
|
+
try {
|
|
251
|
+
writeReport('cafebabe', { acResults, riskFlags, runsDir });
|
|
252
|
+
// Small delay to guarantee different timestamp dir
|
|
253
|
+
const t = Date.now() + 2;
|
|
254
|
+
while (Date.now() < t) { /* busy wait 2ms */ }
|
|
255
|
+
const report2 = writeReport('cafebabe', { acResults, riskFlags, runsDir });
|
|
256
|
+
|
|
257
|
+
// Second report must exist and be valid
|
|
258
|
+
assert.ok(fs.existsSync(report2));
|
|
259
|
+
const content = fs.readFileSync(report2, 'utf8');
|
|
260
|
+
|
|
261
|
+
// No duplicate rows in the AC table — slice only the AC table section.
|
|
262
|
+
const acStart = content.indexOf('## AC Results');
|
|
263
|
+
const riskStart = content.indexOf('## Risk Flags');
|
|
264
|
+
const acSection = content.slice(acStart, riskStart === -1 ? undefined : riskStart);
|
|
265
|
+
const passCount = (acSection.match(/101-pass/g) || []).length;
|
|
266
|
+
const failCount = (acSection.match(/102-fail/g) || []).length;
|
|
267
|
+
assert.strictEqual(passCount, 1, `101-pass appears ${passCount} times in AC table`);
|
|
268
|
+
assert.strictEqual(failCount, 1, `102-fail appears ${failCount} times in AC table`);
|
|
269
|
+
|
|
270
|
+
// No tmp files anywhere
|
|
271
|
+
function findTmps(dir) {
|
|
272
|
+
const hits = [];
|
|
273
|
+
try {
|
|
274
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
275
|
+
const full = path.join(dir, e.name);
|
|
276
|
+
if (e.isDirectory()) hits.push(...findTmps(full));
|
|
277
|
+
else if (e.name.includes('.tmp-')) hits.push(full);
|
|
278
|
+
}
|
|
279
|
+
} catch { /* */ }
|
|
280
|
+
return hits;
|
|
281
|
+
}
|
|
282
|
+
assert.deepStrictEqual(findTmps(runsDir), []);
|
|
283
|
+
} finally {
|
|
284
|
+
rmdir(tmpDir);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('writeReport: report filename contains the batchKey', () => {
|
|
289
|
+
const tmpDir = makeTmpDir();
|
|
290
|
+
const runsDir = path.join(tmpDir, 'runs');
|
|
291
|
+
try {
|
|
292
|
+
const reportPath = writeReport('feed1234', { acResults: [], riskFlags: [], runsDir });
|
|
293
|
+
assert.ok(path.basename(reportPath).includes('feed1234'));
|
|
294
|
+
} finally {
|
|
295
|
+
rmdir(tmpDir);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('writeReport: invalid batchKey throws', () => {
|
|
300
|
+
assert.throws(
|
|
301
|
+
() => writeReport('not-hex!!', { acResults: [], riskFlags: [] }),
|
|
302
|
+
/invalid batchKey/
|
|
303
|
+
);
|
|
304
|
+
});
|
package/src/main/hives.cjs
CHANGED
|
@@ -1,29 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* hives.cjs —
|
|
2
|
+
* hives.cjs — subagent recipe templates ("Hives").
|
|
3
3
|
*
|
|
4
|
-
* A
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* A Recipe is a named sequence of steps, each referencing an agent by name
|
|
5
|
+
* from `~/.claude/agents/<agentName>.md`. Shape:
|
|
6
|
+
* { slug, name, description, brief?, steps: [{ agentName, note? }] }
|
|
7
|
+
*
|
|
8
|
+
* Legacy hives (old shape: `roles: [{ label, prompt }]`) are migrated on
|
|
9
|
+
* first read: each role is materialized as an agent .md and replaced by a
|
|
10
|
+
* step referencing it. Migration is idempotent and permanent (re-saved).
|
|
8
11
|
*
|
|
9
12
|
* Storage: `~/.claude/session-manager/hives/<slug>.json`
|
|
10
13
|
* - slug must match SLUG_RE: /^[a-z0-9-_]{1,64}$/
|
|
11
|
-
* - up to 32
|
|
14
|
+
* - up to 32 steps per recipe
|
|
12
15
|
* - per-field byte caps mirrored in the inline zod schemas below
|
|
13
16
|
*
|
|
14
|
-
* IPC namespace:
|
|
15
|
-
* - hives:list -> { hives:
|
|
16
|
-
* - hives:get -> { hive:
|
|
17
|
+
* IPC namespace (channel names kept for backwards compat):
|
|
18
|
+
* - hives:list -> { hives: Recipe[], error: string | null }
|
|
19
|
+
* - hives:get -> { hive: Recipe | null, error: string | null }
|
|
17
20
|
* - hives:save -> { ok: boolean, error: string | null }
|
|
18
21
|
* - hives:delete -> { ok: boolean, error: string | null }
|
|
19
22
|
*
|
|
20
|
-
* All mutations go through config.cjs::writeJson (atomic
|
|
21
|
-
* config.cjs::validatePath (allowedRoots = home dir).
|
|
22
|
-
*
|
|
23
|
-
* Default hives (Code review / Build feature / Bug hunt) ship in the renderer
|
|
24
|
-
* (src/renderer/lib/defaultHives.ts) and are NOT writable to disk — they exist
|
|
25
|
-
* only as in-memory starter examples so a fresh install has content. The IPC
|
|
26
|
-
* layer only sees user-saved hives.
|
|
23
|
+
* All mutations go through config.cjs::writeJson / writeTextAtomic (atomic
|
|
24
|
+
* tmp+rename) and config.cjs::validatePath (allowedRoots = home dir).
|
|
25
|
+
* Never raw fs.writeFile.
|
|
27
26
|
*/
|
|
28
27
|
|
|
29
28
|
'use strict';
|
|
@@ -37,34 +36,35 @@ const config = require('./config.cjs');
|
|
|
37
36
|
|
|
38
37
|
// ──────────────────────────────────────────── caps
|
|
39
38
|
const SLUG_RE = /^[a-z0-9-_]{1,64}$/;
|
|
39
|
+
const AGENT_NAME_RE = /^[a-z0-9-_]{1,64}$/;
|
|
40
40
|
const MAX_NAME_LEN = 128;
|
|
41
41
|
const MAX_DESC_LEN = 2048;
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const MAX_ROLES = 32;
|
|
42
|
+
const MAX_BRIEF_LEN = 8 * 1024;
|
|
43
|
+
const MAX_NOTE_LEN = 2048;
|
|
44
|
+
const MAX_STEPS = 32;
|
|
46
45
|
|
|
47
46
|
// ──────────────────────────────────────────── inline zod schemas
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
const recipeStepSchema = z.object({
|
|
48
|
+
agentName: z.string().regex(AGENT_NAME_RE),
|
|
49
|
+
note: z.string().max(MAX_NOTE_LEN).optional(),
|
|
51
50
|
}).strict();
|
|
52
51
|
|
|
53
|
-
const
|
|
52
|
+
const recipeSchema = z.object({
|
|
54
53
|
slug: z.string().regex(SLUG_RE),
|
|
55
54
|
name: z.string().min(1).max(MAX_NAME_LEN),
|
|
56
55
|
description: z.string().max(MAX_DESC_LEN).default(''),
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
brief: z.string().max(MAX_BRIEF_LEN).optional(),
|
|
57
|
+
steps: z.array(recipeStepSchema).min(1).max(MAX_STEPS),
|
|
59
58
|
}).strict();
|
|
60
59
|
|
|
61
60
|
const slugPayload = z.object({
|
|
62
61
|
slug: z.string().regex(SLUG_RE),
|
|
63
62
|
}).strict();
|
|
64
63
|
|
|
64
|
+
// Field name "hive" kept for IPC channel backwards compatibility.
|
|
65
65
|
const savePayload = z.object({
|
|
66
66
|
slug: z.string().regex(SLUG_RE),
|
|
67
|
-
hive:
|
|
67
|
+
hive: recipeSchema,
|
|
68
68
|
}).strict();
|
|
69
69
|
|
|
70
70
|
// ──────────────────────────────────────────── paths
|
|
@@ -72,6 +72,10 @@ function rootDir() {
|
|
|
72
72
|
return path.join(os.homedir(), '.claude', 'session-manager', 'hives');
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
function agentsDir() {
|
|
76
|
+
return path.join(os.homedir(), '.claude', 'agents');
|
|
77
|
+
}
|
|
78
|
+
|
|
75
79
|
function hivePath(slug) {
|
|
76
80
|
if (!SLUG_RE.test(slug)) {
|
|
77
81
|
throw new Error(`invalid hive slug (must match ${SLUG_RE.source})`);
|
|
@@ -83,6 +87,58 @@ async function ensureRoot() {
|
|
|
83
87
|
await fsp.mkdir(rootDir(), { recursive: true });
|
|
84
88
|
}
|
|
85
89
|
|
|
90
|
+
// ──────────────────────────────────────────── helpers
|
|
91
|
+
function kebabCase(s) {
|
|
92
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'agent';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ──────────────────────────────────────────── legacy migration
|
|
96
|
+
async function migrateLegacyHive(slug, raw) {
|
|
97
|
+
const adir = agentsDir();
|
|
98
|
+
await fsp.mkdir(adir, { recursive: true });
|
|
99
|
+
|
|
100
|
+
const usedNames = new Set();
|
|
101
|
+
const steps = [];
|
|
102
|
+
|
|
103
|
+
for (const role of (raw.roles ?? [])) {
|
|
104
|
+
let base = kebabCase(role.label ?? 'agent');
|
|
105
|
+
let agentName = base;
|
|
106
|
+
let suffix = 2;
|
|
107
|
+
while (usedNames.has(agentName)) {
|
|
108
|
+
agentName = `${base.slice(0, 62)}-${suffix++}`;
|
|
109
|
+
}
|
|
110
|
+
usedNames.add(agentName);
|
|
111
|
+
|
|
112
|
+
const agentFilePath = path.join(adir, `${agentName}.md`);
|
|
113
|
+
// validatePath ensures we stay within allowedRoots (home dir).
|
|
114
|
+
const validPath = config.validatePath(agentFilePath);
|
|
115
|
+
|
|
116
|
+
let exists = false;
|
|
117
|
+
try { await fsp.stat(validPath); exists = true; } catch { /* ENOENT = not yet created */ }
|
|
118
|
+
|
|
119
|
+
if (!exists) {
|
|
120
|
+
const descLine = (role.prompt ?? '').replace(/[\r\n]+/g, ' ').slice(0, 200);
|
|
121
|
+
const content = `---\nname: ${role.label}\ndescription: ${descLine}\n---\n\n${role.prompt ?? ''}\n`;
|
|
122
|
+
await config.writeTextAtomic(validPath, content);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
steps.push({ agentName });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const recipe = {
|
|
129
|
+
slug,
|
|
130
|
+
name: raw.name,
|
|
131
|
+
description: raw.description ?? '',
|
|
132
|
+
steps,
|
|
133
|
+
...(raw.defaultPlan ? { brief: raw.defaultPlan } : {}),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Persist migrated shape permanently so subsequent reads skip migration.
|
|
137
|
+
await saveHive(slug, recipe);
|
|
138
|
+
|
|
139
|
+
return { hive: recipe, error: null };
|
|
140
|
+
}
|
|
141
|
+
|
|
86
142
|
// ──────────────────────────────────────────── core ops
|
|
87
143
|
async function listHives() {
|
|
88
144
|
try {
|
|
@@ -118,13 +174,20 @@ async function readHive(slug) {
|
|
|
118
174
|
const r = await config.readJson(abs);
|
|
119
175
|
if (!r || !r.exists) return { hive: null, error: null };
|
|
120
176
|
if (r.parseError) return { hive: null, error: `parse error: ${r.parseError}` };
|
|
177
|
+
|
|
178
|
+
const raw = r.data;
|
|
179
|
+
|
|
180
|
+
// Legacy migration: old shape has `roles` array instead of `steps`.
|
|
181
|
+
if (raw && Array.isArray(raw.roles) && !Array.isArray(raw.steps)) {
|
|
182
|
+
return migrateLegacyHive(slug, raw);
|
|
183
|
+
}
|
|
184
|
+
|
|
121
185
|
// Re-validate stored shape so a hand-edited file can't smuggle in bad data.
|
|
122
|
-
const parsed =
|
|
186
|
+
const parsed = recipeSchema.safeParse(raw);
|
|
123
187
|
if (!parsed.success) {
|
|
124
|
-
return { hive: null, error: `invalid
|
|
188
|
+
return { hive: null, error: `invalid recipe on disk: ${parsed.error.issues[0]?.message ?? 'unknown'}` };
|
|
125
189
|
}
|
|
126
|
-
// Trust the file's own slug only if it matches the filename;
|
|
127
|
-
// them to agree (filename wins — that's the storage key).
|
|
190
|
+
// Trust the file's own slug only if it matches the filename; filename wins.
|
|
128
191
|
const hive = { ...parsed.data, slug };
|
|
129
192
|
return { hive, error: null };
|
|
130
193
|
}
|
|
@@ -139,8 +202,7 @@ async function getHive(slug) {
|
|
|
139
202
|
|
|
140
203
|
async function saveHive(slug, hive) {
|
|
141
204
|
try {
|
|
142
|
-
// Body slug must match path slug.
|
|
143
|
-
// can't accidentally overwrite the wrong file by tampering with `slug`.
|
|
205
|
+
// Body slug must match path slug.
|
|
144
206
|
if (hive.slug !== slug) {
|
|
145
207
|
return { ok: false, error: `slug mismatch: payload slug "${hive.slug}" != path "${slug}"` };
|
|
146
208
|
}
|
|
@@ -148,10 +210,9 @@ async function saveHive(slug, hive) {
|
|
|
148
210
|
const abs = hivePath(slug);
|
|
149
211
|
const out = {
|
|
150
212
|
...hive,
|
|
151
|
-
// Strip undefined for clean JSON on disk.
|
|
152
213
|
description: hive.description ?? '',
|
|
153
214
|
};
|
|
154
|
-
if (out.
|
|
215
|
+
if (out.brief === undefined || out.brief === '') delete out.brief;
|
|
155
216
|
const result = await config.writeJson(abs, out);
|
|
156
217
|
if (!result || !result.ok) {
|
|
157
218
|
return { ok: false, error: result?.error ?? 'write failed' };
|
|
@@ -178,7 +239,6 @@ async function deleteHive(slug) {
|
|
|
178
239
|
await fsp.unlink(real);
|
|
179
240
|
} catch (e) {
|
|
180
241
|
if (e.code === 'ENOENT') {
|
|
181
|
-
// Treat missing as success — idempotent delete, same as fs unlink ENOENT.
|
|
182
242
|
return { ok: true, error: null };
|
|
183
243
|
}
|
|
184
244
|
return { ok: false, error: e.message };
|