claude-code-session-manager 0.92.0 → 0.93.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 (75) hide show
  1. package/dist/assets/DataModel-DDGx3qrn.js +1 -0
  2. package/dist/assets/{History-BB6wpf02.js → History-CHBjYZyD.js} +2 -2
  3. package/dist/assets/{Hooks-zQZH56Sv.js → Hooks-DG4c-Rzz.js} +3 -3
  4. package/dist/assets/{HostBilko-CW0pHoPS.js → HostBilko-DHXcpV2c.js} +1 -1
  5. package/dist/assets/{Library-DrGdaz9N.js → Library-CN1h5rAE.js} +1 -1
  6. package/dist/assets/{MarkdownEditor-H1ytN4Tk.js → MarkdownEditor-DmBxrhdn.js} +1 -1
  7. package/dist/assets/{McpServers-CYSRCI-j.js → McpServers-Beb_HD6I.js} +2 -2
  8. package/dist/assets/{Memory-zMNpJyRU.js → Memory-Dk4uDCjg.js} +4 -4
  9. package/dist/assets/{Permissions-Be6cIkPg.js → Permissions-BovobeZw.js} +3 -3
  10. package/dist/assets/Plugins-DvOf_B62.js +2 -0
  11. package/dist/assets/ProvenanceBadge-Dutz_4OY.js +1 -0
  12. package/dist/assets/{SaveBar-xQZ2EBOn.js → SaveBar-CT5u6ZCJ.js} +1 -1
  13. package/dist/assets/Scheduler-B7GSnZj5.js +16 -0
  14. package/dist/assets/{ScopeSwitcher-BAfquN6Y.js → ScopeSwitcher-BUYDpz2v.js} +1 -1
  15. package/dist/assets/Settings-DIRmr3tL.js +3 -0
  16. package/dist/assets/{SkillReferenceGraph-BI1YtCbB.js → SkillReferenceGraph-CdGr6d3_.js} +1 -1
  17. package/dist/assets/Skills-BQOheQv4.js +3 -0
  18. package/dist/assets/SystemPrompt-5LsimKbX.js +1 -0
  19. package/dist/assets/TagLibrary-DreLsT3n.js +1 -0
  20. package/dist/assets/{TiptapBody-BptXAVR9.js → TiptapBody-C9kmlBnI.js} +1 -1
  21. package/dist/assets/{Toggle-BwiClCSI.js → Toggle-BKLCBu9A.js} +1 -1
  22. package/dist/assets/{index-D3P_jldk.css → index-CKH5Uxik.css} +1 -1
  23. package/dist/assets/index-Db_SK9rW.js +3079 -0
  24. package/dist/assets/settingsSchema-B5jBhSUP.js +1 -0
  25. package/dist/index.html +2 -2
  26. package/package.json +1 -1
  27. package/src/main/__tests__/agentEffortResolve.test.cjs +117 -0
  28. package/src/main/__tests__/agentLibrary.test.cjs +21 -0
  29. package/src/main/__tests__/agentModelResolve.test.cjs +16 -0
  30. package/src/main/__tests__/agentOverlayWrite.test.cjs +95 -0
  31. package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +44 -0
  32. package/src/main/__tests__/personaMerge.test.cjs +169 -0
  33. package/src/main/__tests__/prdCreatePlanId.test.cjs +132 -0
  34. package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +13 -0
  35. package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +75 -10
  36. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +47 -2
  37. package/src/main/agentLibrary.cjs +86 -40
  38. package/src/main/build-info.json +4 -4
  39. package/src/main/chatRunner.cjs +5 -0
  40. package/src/main/index.cjs +10 -0
  41. package/src/main/ipcSchemas.cjs +12 -0
  42. package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +10 -5
  43. package/src/main/lib/__tests__/modelCatalog.test.cjs +202 -0
  44. package/src/main/lib/agentEffortResolve.cjs +74 -0
  45. package/src/main/lib/agentModelResolve.cjs +52 -59
  46. package/src/main/lib/agentPersonaSchema.cjs +3 -0
  47. package/src/main/lib/effectiveModelInfo.cjs +38 -20
  48. package/src/main/lib/epicMint.cjs +5 -2
  49. package/src/main/lib/epicWorktreeMerge.cjs +7 -6
  50. package/src/main/lib/modelCatalog.cjs +243 -0
  51. package/src/main/lib/personaMerge.cjs +166 -0
  52. package/src/main/lib/prdCreate.cjs +23 -5
  53. package/src/main/lib/prdDisposition.cjs +46 -0
  54. package/src/main/lib/prdFrontmatter.cjs +6 -2
  55. package/src/main/lib/promptSessionSchema.cjs +3 -0
  56. package/src/main/lib/promptSessionsCreateEpic.cjs +5 -3
  57. package/src/main/pty.cjs +16 -2
  58. package/src/main/scheduler/prdParser.cjs +2 -0
  59. package/src/main/scheduler.cjs +21 -5
  60. package/src/main/templates/PRD_AUTHORING.md +4 -0
  61. package/src/preload/api.d.ts +53 -2
  62. package/src/preload/index.cjs +4 -0
  63. package/dist/assets/AgentLibrary-Bu-1eT_n.js +0 -3
  64. package/dist/assets/DataModel-Bk9rkJ8x.js +0 -1
  65. package/dist/assets/ListDetail-D4bxU_-2.js +0 -1
  66. package/dist/assets/Panel-RMz3Acpu.js +0 -1
  67. package/dist/assets/Plugins-B8sTWf2K.js +0 -2
  68. package/dist/assets/ProvenanceBadge-CO-DZN38.js +0 -1
  69. package/dist/assets/Scheduler-CF3zsJBw.js +0 -16
  70. package/dist/assets/Settings-uPjmqutA.js +0 -3
  71. package/dist/assets/Skills-BEy9FDYd.js +0 -3
  72. package/dist/assets/SystemPrompt-BYkkw-LL.js +0 -1
  73. package/dist/assets/TagLibrary-Kg3yOC5b.js +0 -1
  74. package/dist/assets/index-a2MVk8qh.js +0 -3074
  75. package/dist/assets/settingsSchema-DgxlStF5.js +0 -3
@@ -208,7 +208,7 @@ function withPathLock(lockPath, task) {
208
208
  }
209
209
 
210
210
  /**
211
- * ensureEpic(cwd, { epicId?, goalText, tag?, status?, openingPrompt?, source?, agentType?, mintAuthority? }) → Promise<{ epicId, prdDir, created }>
211
+ * ensureEpic(cwd, { epicId?, goalText, tag?, status?, openingPrompt?, source?, agentType?, model?, effort?, mintAuthority? }) → Promise<{ epicId, prdDir, created }>
212
212
  *
213
213
  * Two behaviors, and only two:
214
214
  * - JOIN (the default, for every automated caller): `epicId` names an Epic
@@ -227,7 +227,7 @@ function withPathLock(lockPath, task) {
227
227
  * The Epic's id doubles as its directory name under scheduler/epics/, so the
228
228
  * PromptSession ↔ on-disk Epic mapping is 1:1 with no lookup table.
229
229
  */
230
- function ensureEpic(cwd, { goalText, tag, epicId: explicitEpicId, status = 'proposed', openingPrompt = null, sections = null, source = null, agentType = null, mintAuthority = null } = {}, deps = {}) {
230
+ function ensureEpic(cwd, { goalText, tag, epicId: explicitEpicId, status = 'proposed', openingPrompt = null, sections = null, source = null, agentType = null, model = null, effort = null, mintAuthority = null } = {}, deps = {}) {
231
231
  if (!cwd || typeof cwd !== 'string') throw new Error('ensureEpic: cwd is required');
232
232
  // A relative cwd (e.g. a caller passing '.') would otherwise get stored
233
233
  // verbatim on the minted Epic's `cwd` field — the renderer's EpicsWorkspace
@@ -337,6 +337,9 @@ function ensureEpic(cwd, { goalText, tag, epicId: explicitEpicId, status = 'prop
337
337
  // ensureEpic IPC path (state/promptSessions.ts). Never affects which
338
338
  // claude CLI spawns.
339
339
  ...(agentType ? { agentType } : {}),
340
+ // Per-Epic runtime overrides — written only when set, so an Epic minted without one keeps its on-disk shape.
341
+ ...(typeof model === 'string' && model ? { model } : {}),
342
+ ...(typeof effort === 'string' && effort ? { effort } : {}),
340
343
  };
341
344
 
342
345
  // Validate the constructed record against the canonical PromptSession
@@ -9,10 +9,10 @@
9
9
  * This is the ONLY point where that per-Epic isolation resolves back into
10
10
  * the shared tree — never mid-session (see gitWorktree.cjs's own header
11
11
  * comment for the ff-only -> merge-commit -> abort-and-flag algorithm this
12
- * reuses verbatim). Callers reach it two ways: an explicit "merge to main"
13
- * action (renderer state's `mergeEpicToMain`), and `markCompleted`'s
14
- * merge-before-archive checkpoint — both funnel through this one handler so
15
- * the integration strategy is never duplicated.
12
+ * reuses verbatim). The single caller is `markCompleted`'s merge-before-archive
13
+ * checkpoint (via `attemptMergeToMainInternal` in
14
+ * `src/renderer/state/promptSessions.ts`); there is no explicit "merge to
15
+ * main" action.
16
16
  *
17
17
  * On success the worktree checkout is torn down (cleanupEpicWorktree) since
18
18
  * its content now safely lives on the main tree. On a real conflict, the
@@ -20,8 +20,9 @@
20
20
  * — so a human can still open a Terminal into `worktree.dir` (the spawn
21
21
  * resolution in epicSpawnCwd.cjs is status-aware: `needs_merge_resolution`
22
22
  * with an existing dir still lands there, while `merged`/`disabled` skip the
23
- * re-attach and fall back to the project cwd) and resolve it manually before
24
- * re-running this same action.
23
+ * re-attach and fall back to the project cwd) and resolve it manually — the
24
+ * branch and worktree dir are deliberately left intact so the human can merge
25
+ * them from the command line.
25
26
  */
26
27
 
27
28
  const { integrateEpicBranch, cleanupEpicWorktree } = require('./gitWorktree.cjs');
@@ -0,0 +1,243 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * modelCatalog.cjs — pure observer answering "which model aliases, concrete model
5
+ * ids and effort levels does the installed `claude` CLI offer right now".
6
+ *
7
+ * Sources per field (ladder: live → stale cache → CATALOG_FLOOR):
8
+ * aliases ← `claude -p "/model"` probe
9
+ * effortLevels ← `claude -p "/effort"` probe
10
+ * models ← bounded streaming scan of the CLI binary for `claude-<family>-N…` ids
11
+ * settingsEffortLevels ← bundled settings schema (`properties.effortLevel.enum`)
12
+ * availableModels ← settings.json scope chain allowlist (reported, never applied here)
13
+ * Cached at ~/.claude/session-manager/model-catalog.json keyed by the installed CLI version (24 h TTL).
14
+ * (NOT userData: config.cjs's validateWrite permits only update-check.json there, so a userData write
15
+ * is silently swallowed.) An unknown (null) CLI version never reads a cache as fresh and never writes one.
16
+ * Never throws/rejects. Influences no spawn decision; writes nothing under session-manager-operations/.
17
+ */
18
+
19
+ const fs = require('node:fs');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+
23
+ const CACHE_FILE = 'model-catalog.json';
24
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
25
+ const PROBE_TIMEOUT_MS = 45_000;
26
+ const SCAN_CHUNK_BYTES = 1024 * 1024;
27
+ // Longest real id is ~40 bytes; 128 comfortably covers an id split across two reads.
28
+ const SCAN_OVERLAP_BYTES = 128;
29
+ const MODEL_ID_RE = /claude-(?:opus|sonnet|haiku|fable)-[0-9][0-9-]*/g;
30
+ // A real CLI binary is tens of MB; test stubs and launcher shims are tiny shell scripts.
31
+ const MIN_SCAN_BYTES = 1024 * 1024;
32
+ const SCHEMA_PATH = path.join(__dirname, '..', '..', 'renderer', 'data', 'claude-settings-schema.json');
33
+
34
+ const CATALOG_FLOOR = Object.freeze({
35
+ aliases: Object.freeze(['haiku', 'sonnet', 'opus', 'fable']),
36
+ models: Object.freeze([]),
37
+ effortLevels: Object.freeze(['low', 'medium', 'high', 'xhigh', 'max']),
38
+ settingsEffortLevels: Object.freeze(['low', 'medium', 'high', 'xhigh']),
39
+ });
40
+
41
+ let inFlight = null;
42
+
43
+ /** `Usage: /model <name>. Available: a, b, or a full model ID.` → ['a','b'] (or null). */
44
+ function parseAliases(out) {
45
+ const m = /Available:\s*([^\n]+)/.exec(String(out || ''));
46
+ if (!m) return null;
47
+ const list = m[1].trim().replace(/\.$/, '').split(',')
48
+ .map((s) => s.trim())
49
+ .filter((s) => s && !/^or a full model id$/i.test(s));
50
+ return list.length ? list : null;
51
+ }
52
+
53
+ /** `Usage: /effort <low|medium|…>` → ['low','medium',…] (or null). */
54
+ function parseEffortLevels(out) {
55
+ const m = /\/effort\s*<([^>]+)>/.exec(String(out || ''));
56
+ if (!m) return null;
57
+ const list = m[1].split('|').map((s) => s.trim()).filter(Boolean);
58
+ return list.length ? list : null;
59
+ }
60
+
61
+ /** `auto` is a `/effort` RESET verb, never a `--effort` value — keep it out of the selectable list. */
62
+ function selectableEffort(list) {
63
+ const l = list ? list.filter((x) => x !== 'auto') : null;
64
+ return l && l.length ? l : null;
65
+ }
66
+
67
+ /**
68
+ * Streaming scan of `file` for concrete model ids. O(size) time, O(chunk+overlap) space.
69
+ * A match touching the window end is deferred (it may be truncated) and re-found via the overlap.
70
+ */
71
+ function scanModelIds(file, { fsImpl = fs, chunkSize = SCAN_CHUNK_BYTES, overlap = SCAN_OVERLAP_BYTES } = {}) {
72
+ const found = new Set();
73
+ const fd = fsImpl.openSync(file, 'r');
74
+ try {
75
+ const buf = Buffer.alloc(chunkSize);
76
+ let carry = '';
77
+ for (;;) {
78
+ const n = fsImpl.readSync(fd, buf, 0, chunkSize, null);
79
+ const last = n <= 0;
80
+ const win = carry + (last ? '' : buf.latin1Slice(0, n));
81
+ MODEL_ID_RE.lastIndex = 0;
82
+ let m;
83
+ while ((m = MODEL_ID_RE.exec(win))) {
84
+ if (!last && m.index + m[0].length >= win.length) continue;
85
+ found.add(m[0].replace(/-+$/, ''));
86
+ }
87
+ if (last) break;
88
+ carry = win.slice(-overlap);
89
+ }
90
+ } finally {
91
+ try { fsImpl.closeSync(fd); } catch { /* best-effort */ }
92
+ }
93
+ return [...found].sort();
94
+ }
95
+
96
+ /** True only for a large non-shell-script file — never scan a stub/launcher shim. */
97
+ function isScannableBinary(file, fsImpl) {
98
+ const st = fsImpl.statSync(file);
99
+ if (!st.isFile() || st.size < MIN_SCAN_BYTES) return false;
100
+ const fd = fsImpl.openSync(file, 'r');
101
+ try {
102
+ const head = Buffer.alloc(64);
103
+ const n = fsImpl.readSync(fd, head, 0, 64, 0);
104
+ return !/^#!\s*\/\S*(?:\/env\s+)?(?:ba|z|da|k)?sh\b/.test(head.latin1Slice(0, n));
105
+ } finally {
106
+ try { fsImpl.closeSync(fd); } catch { /* best-effort */ }
107
+ }
108
+ }
109
+
110
+ function readSettingsEffortLevels(fsImpl) {
111
+ try {
112
+ const e = JSON.parse(fsImpl.readFileSync(SCHEMA_PATH, 'utf8')).properties.effortLevel.enum;
113
+ return Array.isArray(e) && e.length ? e.map(String) : null;
114
+ } catch { return null; }
115
+ }
116
+
117
+ function readAvailableModels(cwd, d, fsImpl) {
118
+ const home = d.homeDir ? d.homeDir() : os.homedir();
119
+ let root = null;
120
+ if (cwd) {
121
+ try { root = (d.projectRootOf || require('./activeSessions.cjs').projectRootOf)(path.resolve(cwd)); } catch { root = path.resolve(cwd); }
122
+ }
123
+ const files = [path.join(home, '.claude', 'settings.json')];
124
+ if (root) files.push(path.join(root, '.claude', 'settings.json'), path.join(root, '.claude', 'settings.local.json'));
125
+ let merged = null;
126
+ for (const f of files) {
127
+ try {
128
+ const a = JSON.parse(fsImpl.readFileSync(f, 'utf8')).availableModels;
129
+ if (Array.isArray(a)) merged = [...(merged || []), ...a.filter((x) => typeof x === 'string')];
130
+ } catch { /* missing/unreadable scope */ }
131
+ }
132
+ return merged ? [...new Set(merged)] : null;
133
+ }
134
+
135
+ function readCache(file, fsImpl) {
136
+ try {
137
+ const c = JSON.parse(fsImpl.readFileSync(file, 'utf8'));
138
+ return c && typeof c === 'object' ? c : null;
139
+ } catch { return null; }
140
+ }
141
+
142
+ const strList = (v) => (Array.isArray(v) && v.length && v.every((x) => typeof x === 'string') ? v : null);
143
+
144
+ /** Ladder: live → stale cache → floor. Returns [value, source]. */
145
+ function ladder(live, liveSource, stale, floor) {
146
+ if (live) return [live, liveSource];
147
+ const s = strList(stale);
148
+ if (s) return [s, 'cache'];
149
+ return [[...floor], 'floor'];
150
+ }
151
+
152
+ async function doResolve({ cwd, force, deps }) {
153
+ const d = deps || {};
154
+ const env = d.env || process.env;
155
+ const fsImpl = d.fs || fs;
156
+ const now = (d.now || Date.now)();
157
+ let cacheFile = null;
158
+ try {
159
+ const home = d.homeDir ? d.homeDir() : os.homedir();
160
+ if (home) cacheFile = path.join(home, '.claude', 'session-manager', CACHE_FILE);
161
+ } catch { /* no home → no cache */ }
162
+ const cached = cacheFile ? readCache(cacheFile, fsImpl) : null;
163
+ if (cached && Array.isArray(cached.effortLevels)) cached.effortLevels = selectableEffort(cached.effortLevels);
164
+
165
+ const availableModels = readAvailableModels(cwd, d, fsImpl);
166
+ const settingsEffort = readSettingsEffortLevels(fsImpl);
167
+ const settingsEffortLevels = settingsEffort || strList(cached && cached.settingsEffortLevels) || [...CATALOG_FLOOR.settingsEffortLevels];
168
+
169
+ const fromCache = (c) => ({
170
+ aliases: c.aliases, models: c.models || [], effortLevels: c.effortLevels,
171
+ settingsEffortLevels, availableModels,
172
+ claudeVersion: c.claudeVersion ?? null, probedAt: c.probedAt ?? null,
173
+ sources: { aliases: 'cache', models: 'cache', effortLevels: 'cache' }, degraded: Boolean(c.degraded),
174
+ });
175
+
176
+ // Hermetic e2e: never spawn — any usable cache, else the floor.
177
+ if (env.SM_E2E === '1') {
178
+ if (cached && strList(cached.aliases) && strList(cached.effortLevels)) return fromCache(cached);
179
+ return {
180
+ aliases: [...CATALOG_FLOOR.aliases], models: [], effortLevels: [...CATALOG_FLOOR.effortLevels],
181
+ settingsEffortLevels, availableModels, claudeVersion: null, probedAt: null,
182
+ sources: { aliases: 'floor', models: 'floor', effortLevels: 'floor' }, degraded: true,
183
+ };
184
+ }
185
+
186
+ let claudeVersion = null;
187
+ try { claudeVersion = (await (d.probeClaudeVersion || require('./claudeBin.cjs').probeClaudeVersion)()) || null; } catch { /* unknown */ }
188
+
189
+ if (!force && cached && claudeVersion && cached.claudeVersion === claudeVersion && !cached.degraded
190
+ && strList(cached.aliases) && strList(cached.effortLevels)) {
191
+ const age = now - Date.parse(cached.probedAt);
192
+ if (age >= 0 && age < CACHE_TTL_MS) return fromCache(cached);
193
+ }
194
+
195
+ const run = d.runClaudeP || require('./runClaudeP.cjs').runClaudeP;
196
+ const probe = async (prompt) => {
197
+ try { return await run(prompt, { model: 'haiku', timeoutMs: PROBE_TIMEOUT_MS }); } catch { return { ok: false }; }
198
+ };
199
+ const [modelRes, effortRes] = await Promise.all([probe('/model'), probe('/effort')]);
200
+ const liveAliases = modelRes && modelRes.ok ? parseAliases(modelRes.out) : null;
201
+ const liveEffort = effortRes && effortRes.ok ? selectableEffort(parseEffortLevels(effortRes.out)) : null;
202
+
203
+ let liveModels = null;
204
+ try {
205
+ const bin = (d.resolveClaudeBin || require('./claudeBin.cjs').resolveClaudeBin)();
206
+ const real = fsImpl.realpathSync(bin);
207
+ if (!isScannableBinary(real, fsImpl)) throw new Error('not a scannable binary');
208
+ const ids = (d.scanModelIds || scanModelIds)(real, { fsImpl });
209
+ liveModels = ids.length ? ids : null;
210
+ } catch { /* unreadable/missing binary */ }
211
+
212
+ const [aliases, aliasSrc] = ladder(liveAliases, 'probe', cached && cached.aliases, CATALOG_FLOOR.aliases);
213
+ const [effortLevels, effortSrc] = ladder(liveEffort, 'probe', cached && cached.effortLevels, CATALOG_FLOOR.effortLevels);
214
+ const [models, modelsSrc] = ladder(liveModels, 'binary', cached && cached.models, CATALOG_FLOOR.models);
215
+ const degraded = !(liveAliases && liveEffort && liveModels);
216
+
217
+ const catalog = {
218
+ aliases, models, effortLevels, settingsEffortLevels, availableModels,
219
+ claudeVersion, probedAt: new Date(now).toISOString(),
220
+ sources: { aliases: aliasSrc, models: modelsSrc, effortLevels: effortSrc }, degraded,
221
+ };
222
+ // Unknown version = unknown cache key: writing it would stamp `claudeVersion: null` over a real entry.
223
+ if (!degraded && cacheFile && claudeVersion) {
224
+ try { await (d.writeJson || require('../config.cjs').writeJson)(cacheFile, catalog); } catch { /* cache is best-effort */ }
225
+ }
226
+ return catalog;
227
+ }
228
+
229
+ /** Resolve the live catalog. Never throws. Concurrent calls share one in-flight resolve. */
230
+ function resolveModelCatalog({ cwd, force, deps } = {}) {
231
+ if (inFlight) return inFlight;
232
+ inFlight = doResolve({ cwd, force, deps })
233
+ .catch(() => ({
234
+ aliases: [...CATALOG_FLOOR.aliases], models: [], effortLevels: [...CATALOG_FLOOR.effortLevels],
235
+ settingsEffortLevels: [...CATALOG_FLOOR.settingsEffortLevels], availableModels: null,
236
+ claudeVersion: null, probedAt: null,
237
+ sources: { aliases: 'floor', models: 'floor', effortLevels: 'floor' }, degraded: true,
238
+ }))
239
+ .finally(() => { inFlight = null; });
240
+ return inFlight;
241
+ }
242
+
243
+ module.exports = { resolveModelCatalog, CATALOG_FLOOR, parseAliases, parseEffortLevels, scanModelIds };
@@ -0,0 +1,166 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * personaMerge.cjs — the ONE place a project-local persona overlay
5
+ * (`<cwd>/.claude/agents/<name>.md`) is combined with the global persona
6
+ * (`~/.claude/agents/<name>.md`). Every reader — agentModelResolve.cjs (model +
7
+ * PRD spawn), agentEffortResolve.cjs, agentLibrary.cjs (getPersonaBody /
8
+ * listPersonas), effectiveModelInfo.cjs — goes through `resolveMergedPersona`;
9
+ * do not add a second merge per reader.
10
+ *
11
+ * MERGE RULES
12
+ * - Frontmatter: overlay keys override the global's; a key absent from the
13
+ * overlay (or present with an empty value) falls back to the global.
14
+ * - Body: an overlay with a non-empty body replaces the global body wholesale
15
+ * (the pre-merge behavior); a frontmatter-only overlay (`body.trim() === ''`)
16
+ * inherits the global body.
17
+ * - `model: inherit` in an overlay is a real value that WINS: it means "defer
18
+ * to the settings default", NOT "fall back to the global persona's model".
19
+ * (Callers already treat `inherit` as "use the fallback model".) A project
20
+ * that wants the global model simply omits the key.
21
+ * - An overlay with no global counterpart is the whole persona (standalone).
22
+ * - A MALFORMED overlay (opens a `---` frontmatter fence that never closes)
23
+ * is ignored, reported once via opsErrorLog, and the global persona
24
+ * applies — it never throws and never lets a half-parsed file pick the
25
+ * model. With no global to degrade to, the persona is unresolvable (null).
26
+ *
27
+ * SECURITY: only the two paths `epicMint.cjs`'s `resolvePersonaPaths` yields
28
+ * are ever read, each through `validatePath`, and `PERSONA_NAME_RE` gates the
29
+ * agentType before any path is built — merge never widens what can be read.
30
+ *
31
+ * Sync fs on purpose: `readOverlayAwarePersonaModel` must stay synchronous for
32
+ * chatRunner.cjs's executeRun() (see agentModelResolve.cjs).
33
+ */
34
+
35
+ const fs = require('node:fs');
36
+ const { splitFrontmatter } = require('./prdFrontmatter.cjs');
37
+ const { resolvePersonaPaths } = require('./epicMint.cjs');
38
+ const configMgr = require('../config.cjs');
39
+
40
+ /** Filename-safe persona name: lowercase, hyphenated, matches the `.md` files on disk. */
41
+ const PERSONA_NAME_RE = /^[a-z][a-z0-9-]*$/;
42
+
43
+ const reportedIssues = new Set();
44
+
45
+ function reportOverlayIssueOnce(cwd, agentType, overlayPath, message) {
46
+ const key = `${cwd}::${agentType}::${message}`;
47
+ if (reportedIssues.has(key)) return;
48
+ reportedIssues.add(key);
49
+ try {
50
+ const { appendError } = require('./opsErrorLog.cjs');
51
+ appendError({
52
+ cwd,
53
+ scope: 'personaMerge',
54
+ level: 'warn',
55
+ message: `project overlay ${overlayPath} for agentType '${agentType}' ${message} — ignored, using the global persona`,
56
+ meta: { agentType, overlayPath },
57
+ });
58
+ } catch { /* reporting must never break resolution */ }
59
+ }
60
+
61
+ /** Raw text of a validated persona path, or null on any miss. */
62
+ function readRaw(candidate, validatePath) {
63
+ let real;
64
+ try {
65
+ real = validatePath(candidate);
66
+ } catch {
67
+ return null;
68
+ }
69
+ try {
70
+ return { path: real, text: fs.readFileSync(real, 'utf8') };
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /** True when the text opens a `---` frontmatter fence that splitFrontmatter could not parse. */
77
+ function hasMalformedFrontmatter(text) {
78
+ return text.trimStart().startsWith('---') && splitFrontmatter(text).fmLineCount === 0;
79
+ }
80
+
81
+ /** Re-serializes merged frontmatter + body into `.md` text (used only when a merge actually happened). */
82
+ function serializeMerged(fm, body) {
83
+ const lines = ['---'];
84
+ for (const [k, v] of Object.entries(fm)) lines.push(`${k}: ${v}`);
85
+ lines.push('---', '');
86
+ return `${lines.join('\n')}${body.trim()}\n`;
87
+ }
88
+
89
+ /**
90
+ * @param {string} cwd
91
+ * @param {string} agentType
92
+ * @param {{ validatePath?: Function, resolvePersonaPaths?: Function, globalDir?: string, projectDir?: string }} [deps]
93
+ * @returns {null | {
94
+ * fm: Record<string,string>, body: string, text: string, path: string,
95
+ * overlayPath: string|null, globalPath: string|null,
96
+ * fromOverlay: boolean, bodySource: 'overlay'|'global',
97
+ * provenance: Record<string,'overlay'|'global'>, overlayIssue: string|null,
98
+ * }}
99
+ * `path` is the file the BODY came from; `text` is that file's raw text when
100
+ * no merge changed anything (so a full-body overlay resolves byte-identically
101
+ * to the pre-merge behavior), else the re-serialized merged persona.
102
+ * Null on invalid name / total miss. Never throws.
103
+ */
104
+ function resolveMergedPersona(cwd, agentType, deps = {}) {
105
+ try {
106
+ if (!agentType || !PERSONA_NAME_RE.test(agentType)) return null;
107
+ const validatePath = deps.validatePath || configMgr.validatePath;
108
+ const resolvePaths = deps.resolvePersonaPaths || resolvePersonaPaths;
109
+ const { projectPath, globalPath } = resolvePaths(cwd, agentType, deps);
110
+
111
+ const overlayRaw = projectPath ? readRaw(projectPath, validatePath) : null;
112
+ const globalRaw = readRaw(globalPath, validatePath);
113
+
114
+ let overlayIssue = null;
115
+ let overlay = null;
116
+ if (overlayRaw) {
117
+ if (hasMalformedFrontmatter(overlayRaw.text)) {
118
+ overlayIssue = 'has malformed frontmatter (unclosed --- fence)';
119
+ reportOverlayIssueOnce(cwd, agentType, overlayRaw.path, overlayIssue);
120
+ } else {
121
+ overlay = { ...splitFrontmatter(overlayRaw.text), path: overlayRaw.path, text: overlayRaw.text };
122
+ }
123
+ }
124
+ const global = globalRaw ? { ...splitFrontmatter(globalRaw.text), path: globalRaw.path, text: globalRaw.text } : null;
125
+
126
+ const base = { overlayPath: overlay ? overlay.path : null, globalPath: global ? global.path : null, overlayIssue };
127
+
128
+ if (!overlay && !global) return null;
129
+ if (!overlay) {
130
+ const provenance = Object.fromEntries(Object.keys(global.fm).map((k) => [k, 'global']));
131
+ return { ...base, fm: global.fm, body: global.body, text: global.text, path: global.path, fromOverlay: false, bodySource: 'global', provenance };
132
+ }
133
+ if (!global) {
134
+ const provenance = Object.fromEntries(Object.keys(overlay.fm).map((k) => [k, 'overlay']));
135
+ return { ...base, fm: overlay.fm, body: overlay.body, text: overlay.text, path: overlay.path, fromOverlay: true, bodySource: 'overlay', provenance };
136
+ }
137
+
138
+ const fm = { ...global.fm };
139
+ const provenance = Object.fromEntries(Object.keys(global.fm).map((k) => [k, 'global']));
140
+ for (const [k, v] of Object.entries(overlay.fm)) {
141
+ if (typeof v !== 'string' || v.trim() === '') continue; // empty value == absent key
142
+ fm[k] = v;
143
+ provenance[k] = 'overlay';
144
+ }
145
+ const overlayHasBody = overlay.body.trim() !== '';
146
+ const body = overlayHasBody ? overlay.body : global.body;
147
+ const bodySource = overlayHasBody ? 'overlay' : 'global';
148
+ // A full-body overlay keeps its raw text (byte-identical to pre-merge); only a
149
+ // frontmatter-only overlay needs a synthesized merged text.
150
+ const text = overlayHasBody ? overlay.text : serializeMerged(fm, body);
151
+ return {
152
+ ...base,
153
+ fm,
154
+ body,
155
+ text,
156
+ path: overlayHasBody ? overlay.path : global.path,
157
+ fromOverlay: true,
158
+ bodySource,
159
+ provenance,
160
+ };
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ module.exports = { resolveMergedPersona, PERSONA_NAME_RE };
@@ -30,7 +30,7 @@ const { fixChainDepthOf, baseSlugOf } = require('./fixChainDepth.cjs');
30
30
  const { DEFAULT_PRD_AGENT_TYPE, assertAgentTypeWritable } = require('./prdAgentType.cjs');
31
31
  const { resolveDepSlug, findNearMatches } = require('./depSlugResolve.cjs');
32
32
  const { isFixPlanSlug } = require('./fixPlanSlug.cjs');
33
- const { isIncomplete, resolveChainTerminals } = require('./prdDisposition.cjs');
33
+ const { isIncomplete, resolveChainTerminals, isValidPlanId, mintPlanId, resolveInheritedPlanId } = require('./prdDisposition.cjs');
34
34
 
35
35
  // A caller-supplied slug that already starts with its own `NN-` (e.g.
36
36
  // "254-perf-x") used to silently become the double-prefixed row
@@ -75,7 +75,7 @@ function deriveSlugFromTitle(title) {
75
75
  function buildPrdBody(input) {
76
76
  const {
77
77
  title, cwd, estimateMinutes, goal, acceptanceCriteria,
78
- implementationNotes, outOfScope, sourcePromptId, sourceTabId, tag, agentType, dependsOn, quietMachine, disposition, deliverable, artifactPaths,
78
+ implementationNotes, outOfScope, sourcePromptId, sourceTabId, tag, agentType, dependsOn, quietMachine, disposition, deliverable, artifactPaths, planId,
79
79
  } = input;
80
80
 
81
81
  // No `parallelGroup` frontmatter key by convention (SKILL.md) — the NN-
@@ -114,6 +114,9 @@ function buildPrdBody(input) {
114
114
  // site above); a first-ever PRD in an Epic, or one with its own explicit
115
115
  // dependsOn, has nothing to decide against and omits this key.
116
116
  if (disposition) fmLines.push(`disposition: ${disposition}`);
117
+ // Durable wave identity — owned by createPrd() (never a caller input), so
118
+ // buildPlans groups by it instead of re-deriving from the live dependsOn graph.
119
+ if (isValidPlanId(planId)) fmLines.push(`planId: ${planId}`);
117
120
  // Artifact-only declaration — validated by createPrd() before this runs.
118
121
  if (deliverable) fmLines.push(`deliverable: ${deliverable}`);
119
122
  if (artifactPaths && artifactPaths.length) fmLines.push(`artifactPaths: [${artifactPaths.join(', ')}]`);
@@ -420,16 +423,18 @@ async function createPrd(input, remote) {
420
423
  // see later. A listPrds() read failure is skipped-with-a-warning, never a
421
424
  // write outage: an I/O hiccup on the read side must not block every PRD
422
425
  // write in the project.
426
+ let depRows = null;
423
427
  if (input.dependsOn && input.dependsOn.length) {
424
428
  let listing;
425
429
  try {
426
- listing = await remote.listPrds({ cwd: input.cwd, limit: Number.MAX_SAFE_INTEGER });
430
+ listing = await remote.listPrds({ cwd: input.cwd, fields: 'full', limit: Number.MAX_SAFE_INTEGER });
427
431
  } catch (e) {
428
432
  console.warn(`[prdCreate] dependsOn validation skipped (listPrds failed): ${e?.message ?? e}`);
429
433
  listing = null;
430
434
  }
431
435
  if (listing) {
432
- const candidateSlugs = (listing.prds ?? []).map((p) => p.slug);
436
+ depRows = listing.prds ?? [];
437
+ const candidateSlugs = depRows.map((p) => p.slug);
433
438
  for (const dep of input.dependsOn) {
434
439
  if (resolveDepSlug(dep, candidateSlugs).length > 0) continue;
435
440
  const near = findNearMatches(dep, candidateSlugs);
@@ -444,7 +449,20 @@ async function createPrd(input, remote) {
444
449
  }
445
450
  }
446
451
 
447
- const body = buildPrdBody(input);
452
+ // planId is API-owned: any caller-supplied value is discarded. Inherited from the
453
+ // resolved dependencies (append terminals or explicit dependsOn) using the FK-check
454
+ // listing above; otherwise (first-ever / new-head / legacy deps) freshly minted.
455
+ let planId = null;
456
+ if (input.dependsOn && input.dependsOn.length && depRows) {
457
+ const inherited = resolveInheritedPlanId(input.dependsOn, depRows);
458
+ planId = inherited.planId;
459
+ if (inherited.diverged) {
460
+ console.warn(`[prdCreate] dependsOn spans plans ${inherited.diverged.join(', ')} — inheriting ${planId} (lowest-numbered dependency)`);
461
+ }
462
+ }
463
+ if (!planId) planId = mintPlanId();
464
+
465
+ const body = buildPrdBody({ ...input, planId });
448
466
  const writeResult = await remote.writePrd(filenameSlug, body, input.cwd);
449
467
  if (!writeResult?.ok) {
450
468
  return { ok: false, status: 500, error: writeResult?.error ?? 'write failed' };
@@ -28,6 +28,7 @@
28
28
  * the scheduler considers a row's real blockers at run time.
29
29
  */
30
30
 
31
+ const crypto = require('node:crypto');
31
32
  const { resolveDepSlug } = require('./depSlugResolve.cjs');
32
33
 
33
34
  /** A row is "incomplete" (still part of the Epic's active plan) unless it
@@ -191,7 +192,52 @@ function computeDispositionRewrite({ slug, disposition, dependsOn, rows }) {
191
192
  return { ok: true, dependsOn: newDependsOn };
192
193
  }
193
194
 
195
+ /** planId shape: a single frontmatter token, no commas/brackets/whitespace, so the
196
+ * line-oriented parsers need no tokenizer. Anything else is treated as absent. */
197
+ const PLAN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
198
+
199
+ function isValidPlanId(v) {
200
+ return typeof v === 'string' && PLAN_ID_RE.test(v);
201
+ }
202
+
203
+ /** Fresh, sortable, coordination-free plan id: `pl-<base36 ms>-<6 hex random>`. */
204
+ function mintPlanId() {
205
+ return `pl-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
206
+ }
207
+
208
+ /**
209
+ * Resolve the planId a new PRD inherits from the PRDs it depends on. `depSlugs` are
210
+ * resolved against `rows` ({ slug, planId }) via the shared resolveDepSlug rule. Returns
211
+ * { planId, diverged } — planId is null when no dependency carries a valid one (caller
212
+ * mints). When dependencies carry two or more DIFFERENT planIds, the lowest-numbered
213
+ * dependency's wins (ties broken by slug) and `diverged` lists the distinct ids.
214
+ */
215
+ function resolveInheritedPlanId(depSlugs, rows) {
216
+ const slugs = rows.map((r) => r.slug);
217
+ const seen = new Set();
218
+ const deps = [];
219
+ for (const d of depSlugs ?? []) {
220
+ for (const s of resolveDepSlug(d, slugs)) {
221
+ if (seen.has(s)) continue;
222
+ seen.add(s);
223
+ const row = rows.find((r) => r.slug === s);
224
+ if (row && isValidPlanId(row.planId)) deps.push(row);
225
+ }
226
+ }
227
+ if (!deps.length) return { planId: null, diverged: null };
228
+ const num = (slug) => {
229
+ const m = /^(\d+)-/.exec(slug);
230
+ return m ? Number(m[1]) : Number.MAX_SAFE_INTEGER;
231
+ };
232
+ deps.sort((a, b) => num(a.slug) - num(b.slug) || a.slug.localeCompare(b.slug));
233
+ const distinct = [...new Set(deps.map((r) => r.planId))];
234
+ return { planId: deps[0].planId, diverged: distinct.length > 1 ? distinct : null };
235
+ }
236
+
194
237
  module.exports = {
238
+ isValidPlanId,
239
+ mintPlanId,
240
+ resolveInheritedPlanId,
195
241
  isIncomplete,
196
242
  resolveChainTerminals,
197
243
  wouldCreateCycle,
@@ -77,8 +77,8 @@ function splitFrontmatter(raw) {
77
77
  * never emitted, which is how `scheduler_update_prd` clears a dependency.
78
78
  */
79
79
 
80
- const RECOGNIZED_KEYS = new Set(['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine', 'disposition', 'deliverable', 'artifactPaths']);
81
- const EMIT_ORDER = ['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine', 'disposition', 'deliverable', 'artifactPaths'];
80
+ const RECOGNIZED_KEYS = new Set(['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine', 'disposition', 'planId', 'deliverable', 'artifactPaths']);
81
+ const EMIT_ORDER = ['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine', 'disposition', 'planId', 'deliverable', 'artifactPaths'];
82
82
  const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
83
83
 
84
84
  function indentOf(line) {
@@ -179,6 +179,10 @@ function applyKey(fm, key, after) {
179
179
  // a no-op line for the overwhelming majority of PRDs that omit it.
180
180
  if (v === true) fm.quietMachine = true;
181
181
  return;
182
+ case 'planId':
183
+ // Durable wave identity stamped by createPrd(); a garbage value is dropped.
184
+ if (typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(v)) fm.planId = v;
185
+ return;
182
186
  case 'disposition':
183
187
  // Wave-authoring decision ('append'/'new-head') — see
184
188
  // src/renderer/lib/prdFrontmatter.ts's mirror of this field for the
@@ -72,6 +72,9 @@ const PromptSessionSchema = z.object({
72
72
  openingPrompt: z.string().nullable().optional(),
73
73
  source: EpicSourceSchema.optional(),
74
74
  agentType: z.string().optional(),
75
+ // Per-Epic runtime overrides chosen on the New Session card; absent = the persona's own value.
76
+ model: z.string().optional(),
77
+ effort: z.string().optional(),
75
78
  // Absent on Epics minted before this field existed — those fall back to
76
79
  // rendering the flat `openingPrompt` as a single block (see
77
80
  // ChatTranscriptTurn.tsx's EpicIntakeCard).
@@ -41,12 +41,14 @@ const { validatePath } = require('../config.cjs');
41
41
  * IPC (config:read-json/write-json, lib/activeIndexMerge.cjs's own merge) is
42
42
  * already required to pass through.
43
43
  */
44
- async function createEpicViaIpc(cwd, { goalText, tag, agentType, source, openingPrompt, sections } = {}) {
44
+ async function createEpicViaIpc(cwd, { goalText, tag, agentType, model, effort, source, openingPrompt, sections } = {}) {
45
45
  validatePath(cwd);
46
46
  const { epicId } = await ensureEpic(cwd, {
47
47
  goalText,
48
48
  tag,
49
49
  agentType,
50
+ model,
51
+ effort,
50
52
  source,
51
53
  openingPrompt,
52
54
  sections,
@@ -62,8 +64,8 @@ function registerPromptSessionsCreateEpicHandlers() {
62
64
  const { schemas: s, validated: v } = require('../ipcSchemas.cjs');
63
65
  ipcMain.handle(
64
66
  'promptSessions:create-epic',
65
- v(s.promptSessionsCreateEpic, ({ cwd, goalText, tag, agentType, source, openingPrompt, sections }) =>
66
- createEpicViaIpc(cwd, { goalText, tag, agentType, source, openingPrompt, sections })),
67
+ v(s.promptSessionsCreateEpic, ({ cwd, goalText, tag, agentType, model, effort, source, openingPrompt, sections }) =>
68
+ createEpicViaIpc(cwd, { goalText, tag, agentType, model, effort, source, openingPrompt, sections })),
67
69
  );
68
70
  }
69
71