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.
@@ -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
+ });
@@ -1,29 +1,28 @@
1
1
  /**
2
- * hives.cjs — pre-baked subagent swarm templates ("Hives").
2
+ * hives.cjs — subagent recipe templates ("Hives").
3
3
  *
4
- * A Hive is a named collection of subagent roles + an optional default plan,
5
- * launchable as one unit. Concept ported from ClaudeCodeUnleashed; our shape
6
- * is `{ slug, name, description, roles: [{ label, prompt }], defaultPlan? }`
7
- * (renderer launches by configuring Orchestrator with those roles).
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 roles per hive
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: Hive[], error: string | null }
16
- * - hives:get -> { hive: Hive | null, error: string | null }
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 tmp+rename) and
21
- * config.cjs::validatePath (allowedRoots = home dir). Never raw fs.writeFile.
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 MAX_LABEL_LEN = 128;
43
- const MAX_PROMPT_LEN = 16 * 1024;
44
- const MAX_PLAN_LEN = 8 * 1024;
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 hiveRoleSchema = z.object({
49
- label: z.string().min(1).max(MAX_LABEL_LEN),
50
- prompt: z.string().min(1).max(MAX_PROMPT_LEN),
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 hiveSchema = z.object({
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
- roles: z.array(hiveRoleSchema).min(1).max(MAX_ROLES),
58
- defaultPlan: z.string().max(MAX_PLAN_LEN).optional(),
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: hiveSchema,
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 = hiveSchema.safeParse(r.data);
186
+ const parsed = recipeSchema.safeParse(raw);
123
187
  if (!parsed.success) {
124
- return { hive: null, error: `invalid hive on disk: ${parsed.error.issues[0]?.message ?? 'unknown'}` };
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; otherwise force
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. Reject mismatches loudly so the renderer
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.defaultPlan === undefined || out.defaultPlan === '') delete out.defaultPlan;
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 };