claude-code-session-manager 0.57.0 → 0.58.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.
Files changed (45) hide show
  1. package/dist/assets/{TiptapBody-B1llI4h-.js → TiptapBody-BEBLdJl_.js} +1 -1
  2. package/dist/assets/index-DEQzGYa6.js +3227 -0
  3. package/dist/assets/{index-WUc_GHMf.css → index-DwUffaDq.css} +1 -1
  4. package/dist/index.html +2 -2
  5. package/package.json +1 -2
  6. package/plugins/session-manager-dev/skills/develop/SKILL.md +7 -6
  7. package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
  8. package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +1 -1
  9. package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
  10. package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +16 -25
  11. package/plugins/session-manager-dev/skills/project-status/SKILL.md +5 -5
  12. package/scripts/lib/watchdogHelpers.cjs +0 -434
  13. package/scripts/mint-epic.cjs +6 -6
  14. package/src/main/__tests__/activeIndexMerge.test.cjs +2 -2
  15. package/src/main/__tests__/agentModelResolve.test.cjs +119 -0
  16. package/src/main/__tests__/epicContextDigest.test.cjs +100 -0
  17. package/src/main/__tests__/epicMint.test.cjs +165 -207
  18. package/src/main/__tests__/promptSessionSchema.test.cjs +101 -0
  19. package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +139 -0
  20. package/src/main/__tests__/queueHistory.test.cjs +14 -0
  21. package/src/main/__tests__/rcaReport.test.cjs +188 -0
  22. package/src/main/__tests__/runVerify.test.cjs +109 -6
  23. package/src/main/__tests__/scheduler-epic-digest.test.cjs +214 -0
  24. package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +5 -5
  25. package/src/main/chatRunner.cjs +10 -1
  26. package/src/main/index.cjs +2 -4
  27. package/src/main/ipcSchemas.cjs +47 -2
  28. package/src/main/lib/agentModelResolve.cjs +97 -0
  29. package/src/main/lib/auditLog.cjs +2 -2
  30. package/src/main/lib/epicContextDigest.cjs +78 -0
  31. package/src/main/lib/epicMint.cjs +81 -164
  32. package/src/main/lib/promptSessionSchema.cjs +79 -0
  33. package/src/main/lib/promptSessionsCreateEpic.cjs +68 -0
  34. package/src/main/lib/queueHistory.cjs +14 -2
  35. package/src/main/lib/{rcaFeedbackHook.cjs → rcaReport.cjs} +47 -114
  36. package/src/main/runVerify.cjs +17 -2
  37. package/src/main/scheduler.cjs +79 -24
  38. package/src/preload/api.d.ts +25 -48
  39. package/src/preload/index.cjs +6 -7
  40. package/dist/assets/index-DdVGnaCd.js +0 -3227
  41. package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +0 -68
  42. package/scripts/propose-epic.cjs +0 -56
  43. package/src/main/__tests__/rcaFeedbackHook.test.cjs +0 -280
  44. package/src/main/repoAnalyzer.cjs +0 -346
  45. package/src/main/search.cjs +0 -332
@@ -7,10 +7,6 @@ const fs = require('node:fs');
7
7
  const os = require('node:os');
8
8
  const path = require('node:path');
9
9
 
10
- const { activeProjectCwds } = require('./activeSessions.cjs');
11
- const { resolvePrdWriteDir, listEpicPrdDirs } = require('../../src/main/lib/prdLocations.cjs');
12
- const { ensureEpic, appendPrdCreatedEvent } = require('../../src/main/lib/epicMint.cjs');
13
- const { projectQueuePath } = require('../../src/main/lib/queueStore.cjs');
14
10
 
15
11
  // Mirrors scheduler.cjs:215 (single source of truth there; kept in sync here).
16
12
  const DEFAULT_HEARTBEAT_PATH = path.join(
@@ -100,89 +96,6 @@ function heartbeatFresh(heartbeatPath = DEFAULT_HEARTBEAT_PATH, maxAgeMs = DEFAU
100
96
  return (Date.now() - ts) < maxAgeMs;
101
97
  }
102
98
 
103
- // Default paths — callers can override via opts for testing.
104
- const DEFAULT_QUEUE_PATH = path.join(
105
- os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'queue.json',
106
- );
107
- const PLUGIN_CACHE_ROOT = path.join(
108
- os.homedir(), '.claude', 'plugins', 'cache', 'session-manager', 'session-manager-dev',
109
- );
110
-
111
- /**
112
- * compareVersionsDesc(a, b) → number
113
- *
114
- * Numeric per-segment version compare (descending) for plugin-cache version
115
- * dirs like "0.1.0" / "0.2.0" / "0.10.0". Plain lexicographic sort is wrong
116
- * here — "0.10.0" < "0.2.0" as strings even though 10 > 2 numerically. Falls
117
- * back to a string compare for any non-numeric segment (e.g. a stray dir name
118
- * that isn't a version) so listSkillCandidates never throws on garbage input.
119
- */
120
- function compareVersionsDesc(a, b) {
121
- const as = a.split('.');
122
- const bs = b.split('.');
123
- const len = Math.max(as.length, bs.length);
124
- for (let i = 0; i < len; i++) {
125
- const an = Number(as[i]);
126
- const bn = Number(bs[i]);
127
- if (Number.isFinite(an) && Number.isFinite(bn)) {
128
- if (an !== bn) return bn - an;
129
- } else if (as[i] !== bs[i]) {
130
- return (as[i] ?? '') < (bs[i] ?? '') ? 1 : -1;
131
- }
132
- }
133
- return 0;
134
- }
135
-
136
- /**
137
- * listSkillCandidates(cwd, skillName, fileName, pluginCacheRoot?) → string[]
138
- *
139
- * Ordered candidate paths for an inlined skill/standards file, most-specific
140
- * first — the process-feedback SKILL.md and develop standards.md moved out of
141
- * the legacy ~/.claude/skills/ location into the session-manager-dev plugin:
142
- *
143
- * 1. <cwd>/plugins/session-manager-dev/skills/<skillName>/<fileName> — repo-local
144
- * copy, correct when the swept project vendors the plugin (e.g. session-manager itself).
145
- * 2. <pluginCacheRoot>/<newest-version>/skills/<skillName>/<fileName> — the installed
146
- * plugin cache (default ~/.claude/plugins/cache/session-manager/session-manager-dev).
147
- * Enumerates every version dir present, newest (lexicographically-highest) first.
148
- * 3. ~/.claude/skills/<skillName>/<fileName> — legacy location, kept last so an
149
- * old install still works.
150
- *
151
- * pluginCacheRoot is overridable for tests; defaults to the real plugin cache.
152
- */
153
- function listSkillCandidates(cwd, skillName, fileName, pluginCacheRoot = PLUGIN_CACHE_ROOT) {
154
- const candidates = [
155
- path.join(cwd, 'plugins', 'session-manager-dev', 'skills', skillName, fileName),
156
- ];
157
-
158
- let entries = [];
159
- try {
160
- entries = fs.readdirSync(pluginCacheRoot, { withFileTypes: true });
161
- } catch { /* plugin cache not installed */ }
162
- const versions = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort(compareVersionsDesc);
163
- for (const version of versions) {
164
- candidates.push(path.join(pluginCacheRoot, version, 'skills', skillName, fileName));
165
- }
166
-
167
- candidates.push(path.join(os.homedir(), '.claude', 'skills', skillName, fileName));
168
- return candidates;
169
- }
170
-
171
- /**
172
- * resolveSkillFile(cwd, skillName, fileName, pluginCacheRoot?) → string | null
173
- *
174
- * Returns the first candidate from listSkillCandidates() that is a readable
175
- * file, or null if none resolve.
176
- */
177
- function resolveSkillFile(cwd, skillName, fileName, pluginCacheRoot = PLUGIN_CACHE_ROOT) {
178
- for (const candidate of listSkillCandidates(cwd, skillName, fileName, pluginCacheRoot)) {
179
- try {
180
- if (fs.statSync(candidate).isFile()) return candidate;
181
- } catch { /* try next candidate */ }
182
- }
183
- return null;
184
- }
185
-
186
99
  /**
187
100
  * isPidAlive(pid) → boolean
188
101
  *
@@ -212,286 +125,6 @@ function isPidAlive(pid) {
212
125
  // relaunch means the app is never down long enough to need an external
213
126
  // reconciler — the in-app scheduler is queue.json's single owner again.
214
127
 
215
- // ── slugify ──────────────────────────────────────────────────────────────────
216
-
217
- /** Lowercase, non-alphanumeric runs → single `-`, strip leading/trailing `-`. */
218
- function slugify(name) {
219
- return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
220
- }
221
-
222
- // ── YAML frontmatter stripper ─────────────────────────────────────────────────
223
-
224
- /**
225
- * Strip leading YAML frontmatter block (---\n…\n---) from skill/standards files
226
- * so they can be inlined cleanly into a PRD body.
227
- */
228
- function stripFrontmatter(content) {
229
- if (!content.startsWith('---')) return content;
230
- const end = content.indexOf('\n---', 3);
231
- if (end === -1) return content;
232
- return content.slice(end + 4).replace(/^\n/, '');
233
- }
234
-
235
- /**
236
- * Strip a leading H1 line (`# …\n`) — used to drop the `# Engineering standards`
237
- * heading from standards.md before adding our own `## Engineering standards` heading.
238
- */
239
- function stripLeadingH1(content) {
240
- return content.replace(/^# [^\n]*\n/, '');
241
- }
242
-
243
- // ── hasOpenFeedback ───────────────────────────────────────────────────────────
244
-
245
- /**
246
- * hasOpenFeedback(cwd) → boolean
247
- *
248
- * Returns true iff `<cwd>/session-manager-operations/feedback/` (the PRD-462
249
- * convention) or, as a transitional fallback for repos not yet relocated,
250
- * the legacy `<cwd>/feedback/` exists AND contains at least one *.md file
251
- * directly in its root (i.e. NOT inside `processed/` or any subdirectory).
252
- * Pure filesystem check — no LLM call.
253
- *
254
- * Mirrors process-feedback skill step 0 (cheap quick-exit signal).
255
- * Complexity: O(F) where F ≤ entries in the feedback folder root.
256
- */
257
- function hasOpenFeedback(cwd) {
258
- for (const folderName of [path.join('session-manager-operations', 'feedback'), 'feedback']) {
259
- const folderPath = path.join(cwd, folderName);
260
- let entries;
261
- try {
262
- entries = fs.readdirSync(folderPath);
263
- } catch {
264
- continue; // folder does not exist
265
- }
266
- for (const entry of entries) {
267
- if (!entry.endsWith('.md')) continue;
268
- // README.md is the folder's convention doc, not an open feedback item —
269
- // counting it makes a folder with only a README look perpetually "pending".
270
- if (entry.toLowerCase() === 'readme.md') continue;
271
- try {
272
- const st = fs.statSync(path.join(folderPath, entry));
273
- if (st.isFile()) return true;
274
- } catch { continue; }
275
- }
276
- }
277
- return false;
278
- }
279
-
280
- // ── emitFeedbackPRD ──────────────────────────────────────────────────────────
281
-
282
- /**
283
- * emitFeedbackPRD(cwd, opts?) → { emitted: boolean, slug?: string, prdPath?: string, reason?: string }
284
- *
285
- * De-dups against queue.json (pending/running jobs whose slug matches
286
- * `^\d+-feedback-<project>$`). If a match exists, returns { emitted: false, reason: 'duplicate' }.
287
- *
288
- * Otherwise picks the next free NN by scanning prdsDir, writes a self-contained
289
- * PRD file with the inlined process-feedback procedure + engineering standards,
290
- * and returns { emitted: true, slug, prdPath }.
291
- *
292
- * Never writes to queue.json — the app's reconcile picks up new PRD files.
293
- * Atomic write: tmp → rename (mirrors config.cjs writeJsonSync pattern).
294
- *
295
- * Complexity: O(P) over PRD files in prdsDir + O(J) over queue jobs.
296
- */
297
- async function emitFeedbackPRD(cwd, {
298
- prdsDir,
299
- queuePath,
300
- skillPath,
301
- standardsPath,
302
- pluginCacheRoot = PLUGIN_CACHE_ROOT,
303
- } = {}) {
304
- // Explicit overrides (used by tests) are honored verbatim and skip the
305
- // candidate search entirely — only resolve via candidates when the caller
306
- // didn't pass a path. Same convention for prdsDir: an explicit override
307
- // (tests pass a tmpdir) wins verbatim; otherwise resolve the project's own
308
- // PRD dir so the sweep writer agrees with scheduler.cjs's reader (see
309
- // prdLocations.cjs — PRD chain 808→811 moved PRD sources out of the legacy
310
- // global dir into `<cwd>/session-manager-operations/scheduler/prds`).
311
- let epicId = null;
312
- if (prdsDir === undefined) {
313
- // Every PRD belongs to an Epic (CLAUDE.md domain model). The sweep joins
314
- // one standing "Inbound feedback" Epic per project (reuseByGoal) so
315
- // recurring ticks chain into a single Epic rather than minting one each.
316
- // This is an unattended automated sweep, not a human action — status
317
- // MUST be 'proposed' (same as lib/rcaFeedbackHook.cjs) so the Epic lands
318
- // behind the Approve & start human gate instead of running immediately;
319
- // an 'active' default here would let an unattended process mint and
320
- // execute PRDs with zero human approval, exactly the hole the 'proposed'
321
- // gate exists to close.
322
- const epic = await ensureEpic(cwd, {
323
- goalText: 'Inbound feedback processing',
324
- tag: 'discussion',
325
- status: 'proposed',
326
- reuseByGoal: true,
327
- source: { producer: 'feedback-sweep' },
328
- });
329
- epicId = epic.epicId;
330
- prdsDir = epic.prdDir;
331
- }
332
- const skillPathExplicit = skillPath !== undefined;
333
- const standardsPathExplicit = standardsPath !== undefined;
334
- if (!skillPathExplicit) {
335
- skillPath = resolveSkillFile(cwd, 'process-feedback', 'SKILL.md', pluginCacheRoot);
336
- }
337
- if (!standardsPathExplicit) {
338
- standardsPath = resolveSkillFile(cwd, 'develop', 'standards.md', pluginCacheRoot);
339
- }
340
- const project = slugify(path.basename(cwd));
341
-
342
- // De-dup: check the queue for a pending/running feedback job for this
343
- // project. Federated state (2026-07-31): the project's own shard is the
344
- // system of record; the legacy global path is still consulted as a fallback
345
- // during transition. An explicit queuePath override (tests) wins verbatim.
346
- const queuePaths = queuePath !== undefined
347
- ? [queuePath]
348
- : [projectQueuePath(cwd), DEFAULT_QUEUE_PATH];
349
- const queueState = { jobs: [] };
350
- for (const qp of queuePaths) {
351
- try {
352
- const parsed = JSON.parse(fs.readFileSync(qp, 'utf8'));
353
- queueState.jobs.push(...(Array.isArray(parsed.jobs) ? parsed.jobs : []));
354
- } catch { /* file may not exist */ }
355
- }
356
-
357
- // project only contains [a-z0-9-] after slugify — no regex metachar escaping needed.
358
- const dupRe = new RegExp(`^\\d+-feedback-${project}$`);
359
- const isDuplicate = (queueState.jobs ?? []).some(
360
- (j) => (j.status === 'pending' || j.status === 'running') && dupRe.test(j.slug),
361
- );
362
- if (isDuplicate) {
363
- return { emitted: false, reason: 'duplicate' };
364
- }
365
-
366
- // Pick next NN: max existing NN prefix + 1. O(P) scan.
367
- // We ALSO dedup against the prds dir here: while the app is down it never
368
- // ingests new PRD files into queue.json, so the queue check above can't see a
369
- // feedback PRD we already wrote on a previous tick. Without this, every stale
370
- // watchdog tick emits a fresh NN-feedback-<project>.md, unbounded.
371
- const prdDupRe = new RegExp(`^\\d+-feedback-${project}\\.md$`);
372
- let maxNN = 0;
373
- // NN numbering and the on-disk dedup are project-global, so scan every PRD
374
- // location the project has: the write dir, the legacy flat dir, and every
375
- // Epic's prds/ dir. (An explicit prdsDir override — tests — scans only it.)
376
- const scanDirs = new Set([prdsDir]);
377
- if (epicId !== null) {
378
- try { scanDirs.add(resolvePrdWriteDir(cwd)); } catch { /* unusable cwd */ }
379
- for (const d of listEpicPrdDirs(cwd)) scanDirs.add(d);
380
- }
381
- for (const dir of scanDirs) {
382
- let prdFiles = [];
383
- try {
384
- prdFiles = fs.readdirSync(dir);
385
- } catch { continue; /* dir may not exist yet */ }
386
- for (const f of prdFiles) {
387
- if (prdDupRe.test(f)) {
388
- return { emitted: false, reason: 'duplicate' };
389
- }
390
- const m = f.match(/^(\d+)-/);
391
- if (m) {
392
- const n = parseInt(m[1], 10);
393
- if (n > maxNN) maxNN = n;
394
- }
395
- }
396
- }
397
- const nn = String(maxNN + 1).padStart(2, '0');
398
- const slug = `${nn}-feedback-${project}`;
399
- const prdPath = path.join(prdsDir, `${slug}.md`);
400
-
401
- // Read and inline skill content.
402
- let rawSkill = '';
403
- if (skillPath) {
404
- try {
405
- rawSkill = fs.readFileSync(skillPath, 'utf8');
406
- } catch { /* handled by the empty-body guard below */ }
407
- }
408
- const skillBody = stripFrontmatter(rawSkill).trim();
409
-
410
- // Read and inline standards — strip H1 so our ## heading is the section anchor.
411
- let rawStandards = '';
412
- if (standardsPath) {
413
- try {
414
- rawStandards = fs.readFileSync(standardsPath, 'utf8');
415
- } catch { /* handled by the empty-body guard below */ }
416
- }
417
- const standardsBody = stripLeadingH1(stripFrontmatter(rawStandards)).trim();
418
-
419
- // Hard-fail rather than silently ship a hollow PRD: if either inline is
420
- // empty/unreadable, refuse to write anything and name every candidate tried
421
- // so a future relocation shows up loudly in the watchdog log instead of
422
- // queueing a template with no procedure and no standards in it.
423
- if (!skillBody || !standardsBody) {
424
- const missing = [];
425
- if (!skillBody) {
426
- const tried = skillPathExplicit ? [skillPath] : listSkillCandidates(cwd, 'process-feedback', 'SKILL.md', pluginCacheRoot);
427
- missing.push(`process-feedback SKILL.md (tried: ${tried.join(', ')})`);
428
- }
429
- if (!standardsBody) {
430
- const tried = standardsPathExplicit ? [standardsPath] : listSkillCandidates(cwd, 'develop', 'standards.md', pluginCacheRoot);
431
- missing.push(`develop standards.md (tried: ${tried.join(', ')})`);
432
- }
433
- process.stderr.write(`[emitFeedbackPRD] refusing to emit for ${cwd}: missing inline(s) — ${missing.join('; ')}\n`);
434
- return { emitted: false, reason: 'missing-inline' };
435
- }
436
-
437
- const projectName = path.basename(cwd);
438
- const body = [
439
- '---',
440
- `title: Process feedback for ${projectName}`,
441
- `cwd: ${cwd}`,
442
- 'estimateMinutes: 15',
443
- '---',
444
- '',
445
- '# Goal',
446
- '',
447
- `Process the inbound feedback folder for ${projectName}. The quick-exit (step 0 below)`,
448
- 'means this run bails in milliseconds if no open items exist — safe to execute even if',
449
- 'feedback was already cleared between scheduling and execution.',
450
- '',
451
- '# Acceptance criteria',
452
- '',
453
- '- [ ] All open feedback items evaluated and either queued via /develop, declined with RESOLUTION, or forwarded upstream.',
454
- '- [ ] Processed items archived to session-manager-operations/feedback/processed/ (or, for repos not yet relocated, legacy feedback/processed/) with RESOLUTION notes.',
455
- '- [ ] session-manager-operations/feedback/README.md (or legacy feedback/README.md) self-improved with lessons from this pass.',
456
- '- [ ] timeout 60 git diff --exit-code runs clean (no uncommitted inline work).',
457
- '',
458
- '# Implementation notes',
459
- '',
460
- 'Follow the inlined process-feedback procedure below exactly. No skills are loaded in',
461
- 'headless execution — the procedure is fully self-contained. Use /develop for any work',
462
- 'that belongs to this project; never implement feedback inline.',
463
- '',
464
- '# Out of scope',
465
- '',
466
- '- Implementing feedback items directly (that is /develop → scheduler).',
467
- '- Cross-project feedback beyond filing upstream items.',
468
- '',
469
- skillBody,
470
- '',
471
- '## Engineering standards',
472
- '',
473
- standardsBody,
474
- ].join('\n');
475
-
476
- // Atomic write: tmp-<pid>-<ts> → rename (mirrors config.cjs writeJsonSync).
477
- const tmpPath = `${prdPath}.tmp-${process.pid}-${Date.now()}`;
478
- try {
479
- fs.mkdirSync(prdsDir, { recursive: true });
480
- fs.writeFileSync(tmpPath, body + '\n', 'utf8');
481
- fs.renameSync(tmpPath, prdPath);
482
- if (epicId !== null) {
483
- // Trace the dispatch on the Epic's event chain (prompt → prd_created →
484
- // ...) so the Epic conversation shows the PRD it spawned.
485
- try { await appendPrdCreatedEvent(cwd, epicId, slug, `Feedback sweep emitted ${slug}`); } catch { /* trace is best-effort */ }
486
- }
487
- } catch (e) {
488
- try { fs.unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
489
- throw e;
490
- }
491
-
492
- return { emitted: true, slug, prdPath };
493
- }
494
-
495
128
  // ── app liveness + auto-relaunch ─────────────────────────────────────────────
496
129
  //
497
130
  // The watchdog's one supervision job: is session-manager itself running, and
@@ -644,68 +277,6 @@ function maybeRelaunchApp({
644
277
  return { relaunched: true, reason: 'launched', attemptCount };
645
278
  }
646
279
 
647
- // ── sweep ─────────────────────────────────────────────────────────────────────
648
-
649
- /**
650
- * sweep(opts?) → { scanned: number, emitted: number, skipped: number }
651
- *
652
- * For each active-session cwd (from activeProjectCwds), cheaply checks for open
653
- * feedback. Projects without open feedback cost a readdir per folder name — no LLM.
654
- * Projects with open feedback get a PRD emitted into prdsDir (de-duped against queue).
655
- *
656
- * Never mutates queue.json — only adds PRD files, so it is safe to run while the
657
- * in-app scheduler is alive.
658
- *
659
- * Complexity: O(C × F) where C = active cwds (≤50) and F = root entries per
660
- * feedback folder (typically small).
661
- *
662
- * opts:
663
- * projectsDir — forwarded to activeProjectCwds for testing
664
- * prdsDir, queuePath, skillPath, standardsPath — forwarded to emitFeedbackPRD
665
- */
666
- async function sweep({
667
- projectsDir,
668
- prdsDir,
669
- queuePath,
670
- skillPath,
671
- standardsPath,
672
- } = {}) {
673
- const activeOpts = {};
674
- if (projectsDir !== undefined) activeOpts.projectsDir = projectsDir;
675
-
676
- const cwds = activeProjectCwds(90, activeOpts);
677
-
678
- let scanned = 0;
679
- let emitted = 0;
680
- let skipped = 0;
681
-
682
- for (const cwd of cwds) {
683
- scanned++;
684
- if (!hasOpenFeedback(cwd)) continue;
685
-
686
- // prdsDir unset → emitFeedbackPRD resolves per-cwd (project's own PRD
687
- // dir); an explicit override (tests) is forwarded verbatim to every cwd.
688
- let result;
689
- try {
690
- result = await emitFeedbackPRD(cwd, { prdsDir, queuePath, skillPath, standardsPath });
691
- } catch (e) {
692
- process.stderr.write(`[sweep] error emitting PRD for ${cwd}: ${e?.message}\n`);
693
- continue;
694
- }
695
-
696
- if (result.emitted) {
697
- emitted++;
698
- process.stderr.write(`[sweep] emitted ${result.slug} for ${cwd}\n`);
699
- } else {
700
- skipped++;
701
- process.stderr.write(`[sweep] skipped ${cwd} (${result.reason})\n`);
702
- }
703
- }
704
-
705
- process.stderr.write(`[sweep] scanned=${scanned} emitted=${emitted} skipped=${skipped}\n`);
706
- return { scanned, emitted, skipped };
707
- }
708
-
709
280
  // ── maybeFinalizeHistory ─────────────────────────────────────────────────────
710
281
  //
711
282
  // Precomputes closed History-dashboard days outside Electron, so the app's
@@ -853,11 +424,6 @@ module.exports = {
853
424
  readLastHeartbeatTs,
854
425
  heartbeatFresh,
855
426
  isPidAlive,
856
- hasOpenFeedback,
857
- resolveSkillFile,
858
- listSkillCandidates,
859
- emitFeedbackPRD,
860
- sweep,
861
427
  localDateStr,
862
428
  maybeFinalizeHistory,
863
429
  tryAcquireLock,
@@ -5,11 +5,11 @@
5
5
  * manual-write fallback).
6
6
  *
7
7
  * This script never creates an Epic. It only resolves an EXISTING Epic's
8
- * prds/ write directory. An Epic may only come into existence through
9
- * explicit human intent — the New Epic UI, or /propose-epic followed by a
10
- * human pressing Approve & start — never implicitly from a PRD-authoring
11
- * path. /develop must already be running inside that Epic's own
12
- * conversation (or have the approved Epic's id in hand) before it calls this.
8
+ * prds/ write directory. An Epic comes into existence in exactly one place —
9
+ * the human pressing New Epic in the app (epicMint.cjs's SINGLE-CREATOR LAW)
10
+ * — never implicitly from a PRD-authoring path. /develop must already be
11
+ * running inside that Epic's own conversation (or have its id in hand)
12
+ * before it calls this.
13
13
  *
14
14
  * Usage:
15
15
  * node scripts/mint-epic.cjs <cwd> <epic-id>
@@ -31,7 +31,7 @@ if (!cwd || !epicId) {
31
31
 
32
32
  (async () => {
33
33
  try {
34
- const { prdDir } = await ensureEpic(cwd, { epicId, mintIfMissing: false });
34
+ const { prdDir } = await ensureEpic(cwd, { epicId });
35
35
  process.stderr.write(`joined epic ${epicId}\n`);
36
36
  process.stdout.write(`${prdDir}\n`);
37
37
  } catch (e) {
@@ -21,7 +21,7 @@ const fsp = require('node:fs/promises');
21
21
  const os = require('node:os');
22
22
  const path = require('node:path');
23
23
  const { mergeActiveIndex } = require('../lib/activeIndexMerge.cjs');
24
- const { ensureEpic, readActiveIndex } = require('../lib/epicMint.cjs');
24
+ const { ensureEpic, readActiveIndex, MINT_AUTHORITY_NEW_EPIC_UI } = require('../lib/epicMint.cjs');
25
25
  const { appendResponseEventIfKnown } = require('../promptSessionEvents.cjs');
26
26
  const config = require('../config.cjs');
27
27
 
@@ -199,7 +199,7 @@ test('mint-during-persist: an ensureEpic mint interleaved with a merge call surv
199
199
  await mergeActiveIndex(cwd, { sessions: { [existing.id]: existing }, events: {} });
200
200
 
201
201
  const [mintResult] = await Promise.all([
202
- ensureEpic(cwd, { goalText: 'minted during a concurrent merge' }),
202
+ ensureEpic(cwd, { goalText: 'minted during a concurrent merge', mintAuthority: MINT_AUTHORITY_NEW_EPIC_UI }),
203
203
  mergeActiveIndex(cwd, { sessions: { [existing.id]: existing }, events: {} }),
204
204
  ]);
205
205
 
@@ -0,0 +1,119 @@
1
+ /**
2
+ * agentModelResolve.test.cjs — unit tests for resolveEpicModel: the shared
3
+ * persona-model resolver both EpicTerminalPane.tsx's Terminal-view launch
4
+ * and chatRunner.cjs's headless Chat-view launch should agree with.
5
+ *
6
+ * Run: timeout 120 npx vitest run src/main/__tests__/agentModelResolve.test.cjs
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ import { test, expect, afterEach } from 'vitest';
12
+ const fs = require('node:fs');
13
+ const fsp = require('node:fs/promises');
14
+ const os = require('node:os');
15
+ const path = require('node:path');
16
+ const {
17
+ FALLBACK_MODEL,
18
+ resolveEpicModel,
19
+ findAgentTypeByClaudeSessionId,
20
+ readPersonaModel,
21
+ } = require('../lib/agentModelResolve.cjs');
22
+
23
+ const tmpDirs = [];
24
+ afterEach(async () => {
25
+ while (tmpDirs.length) {
26
+ const d = tmpDirs.pop();
27
+ await fsp.rm(d, { recursive: true, force: true });
28
+ }
29
+ });
30
+
31
+ async function mkTmpDir(prefix) {
32
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), prefix));
33
+ tmpDirs.push(dir);
34
+ return dir;
35
+ }
36
+
37
+ function writeIndex(cwd, sessions) {
38
+ const dir = path.join(cwd, 'session-manager-operations', 'prompt-sessions');
39
+ fs.mkdirSync(dir, { recursive: true });
40
+ fs.writeFileSync(path.join(dir, 'active-index.json'), JSON.stringify({ sessions, events: {} }, null, 2));
41
+ }
42
+
43
+ function writePersona(globalDir, name, frontmatter) {
44
+ fs.mkdirSync(globalDir, { recursive: true });
45
+ const lines = ['---', `name: ${name}`, ...frontmatter, '---', 'body'];
46
+ fs.writeFileSync(path.join(globalDir, `${name}.md`), lines.join('\n'));
47
+ }
48
+
49
+ const noopValidatePath = (p) => p;
50
+
51
+ test('resolves the agentType persona model for a matching Epic', async () => {
52
+ const cwd = await mkTmpDir('sm-agentmodel-cwd-');
53
+ const globalDir = await mkTmpDir('sm-agentmodel-agents-');
54
+ writeIndex(cwd, { 'epic-1': { id: 'epic-1', claudeSessionId: 'sess-opus', agentType: 'opus-persona' } });
55
+ writePersona(globalDir, 'opus-persona', ['model: opus']);
56
+
57
+ const model = resolveEpicModel({
58
+ cwd,
59
+ claudeSessionId: 'sess-opus',
60
+ deps: { globalDir, validatePath: noopValidatePath },
61
+ });
62
+
63
+ expect(model).toBe('opus');
64
+ });
65
+
66
+ test('falls back to the fallback model when the Epic has no agentType', async () => {
67
+ const cwd = await mkTmpDir('sm-agentmodel-cwd-');
68
+ writeIndex(cwd, { 'epic-1': { id: 'epic-1', claudeSessionId: 'sess-none' } });
69
+
70
+ const model = resolveEpicModel({ cwd, claudeSessionId: 'sess-none' });
71
+
72
+ expect(model).toBe(FALLBACK_MODEL);
73
+ });
74
+
75
+ test("falls back when the persona's model is 'inherit'", async () => {
76
+ const cwd = await mkTmpDir('sm-agentmodel-cwd-');
77
+ const globalDir = await mkTmpDir('sm-agentmodel-agents-');
78
+ writeIndex(cwd, { 'epic-1': { id: 'epic-1', claudeSessionId: 'sess-inherit', agentType: 'inherit-persona' } });
79
+ writePersona(globalDir, 'inherit-persona', ['model: inherit']);
80
+
81
+ const model = resolveEpicModel({
82
+ cwd,
83
+ claudeSessionId: 'sess-inherit',
84
+ deps: { globalDir, validatePath: noopValidatePath },
85
+ });
86
+
87
+ expect(model).toBe(FALLBACK_MODEL);
88
+ });
89
+
90
+ test('falls back when no Epic matches the claudeSessionId', () => {
91
+ const model = resolveEpicModel({ cwd: '/nonexistent-cwd-xyz', claudeSessionId: 'no-such-session' });
92
+ expect(model).toBe(FALLBACK_MODEL);
93
+ });
94
+
95
+ test('never throws when cwd/claudeSessionId are missing', () => {
96
+ expect(resolveEpicModel({})).toBe(FALLBACK_MODEL);
97
+ });
98
+
99
+ test('findAgentTypeByClaudeSessionId returns null for no match', async () => {
100
+ const cwd = await mkTmpDir('sm-agentmodel-cwd-');
101
+ writeIndex(cwd, {});
102
+ expect(findAgentTypeByClaudeSessionId(cwd, 'missing')).toBeNull();
103
+ });
104
+
105
+ test('readPersonaModel returns null for a persona with no model field', async () => {
106
+ const globalDir = await mkTmpDir('sm-agentmodel-agents-');
107
+ writePersona(globalDir, 'no-model-persona', []);
108
+ expect(readPersonaModel('no-model-persona', { globalDir, validatePath: noopValidatePath })).toBeNull();
109
+ });
110
+
111
+ test('readPersonaModel returns null (never throws) for a path-traversal agentType rejected by validatePath', async () => {
112
+ const globalDir = await mkTmpDir('sm-agentmodel-agents-');
113
+ const realValidatePath = (p) => {
114
+ const real = path.resolve(p);
115
+ if (!real.startsWith(path.resolve(globalDir) + path.sep)) throw new Error('outside allowed boundaries');
116
+ return real;
117
+ };
118
+ expect(readPersonaModel('../../../etc/passwd', { globalDir, validatePath: realValidatePath })).toBeNull();
119
+ });