claude-code-session-manager 0.92.1 → 0.94.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/DataModel-B0LDnnSL.js +1 -0
- package/dist/assets/{History-BOR_fJNr.js → History-C685Kytt.js} +2 -2
- package/dist/assets/{Hooks-BtD436iL.js → Hooks-CwLnp0Z_.js} +3 -3
- package/dist/assets/HostBilko-CwEHEKYk.js +1 -0
- package/dist/assets/{Library-DeKdY-tD.js → Library-BU9np05E.js} +25 -25
- package/dist/assets/{MarkdownEditor-CbzC-Oqf.js → MarkdownEditor-B74ogK2f.js} +1 -1
- package/dist/assets/McpServers-BLM3ftzP.js +2 -0
- package/dist/assets/{Memory-B68AJjg2.js → Memory-DAP1t8bA.js} +6 -6
- package/dist/assets/{Permissions-Cr0RRvNy.js → Permissions-3pGLJmxQ.js} +3 -3
- package/dist/assets/Plugins-Q1KoLEzn.js +2 -0
- package/dist/assets/ProvenanceBadge-BnjBQiV9.js +1 -0
- package/dist/assets/{SaveBar-87ZJfscX.js → SaveBar-Zq2NfcPf.js} +1 -1
- package/dist/assets/Scheduler-D488ebKm.js +16 -0
- package/dist/assets/{ScopeSwitcher-CKzpqjJ_.js → ScopeSwitcher-Cnojv22D.js} +1 -1
- package/dist/assets/Settings-D5Lhj7ga.js +3 -0
- package/dist/assets/{SkillReferenceGraph-D_-wg4r9.js → SkillReferenceGraph-BRDiE6Hv.js} +2 -2
- package/dist/assets/Skills-BQDqp-EN.js +3 -0
- package/dist/assets/SystemPrompt-tFr19Od6.js +1 -0
- package/dist/assets/TagLibrary-BjyrnaRE.js +1 -0
- package/dist/assets/{TiptapBody-DYXIp8aG.js → TiptapBody-DGzSg3BO.js} +1 -1
- package/dist/assets/{Toggle-CDXR5l3m.js → Toggle-DzoROibf.js} +1 -1
- package/dist/assets/index-CCl4tz-u.js +3079 -0
- package/dist/assets/{index-D3P_jldk.css → index-CKH5Uxik.css} +1 -1
- package/dist/assets/settingsSchema-BYKVe1WM.js +1 -0
- package/dist/index.html +2 -2
- package/package.json +6 -3
- package/scripts/README.md +3 -0
- package/src/main/__tests__/agentEffortResolve.test.cjs +117 -0
- package/src/main/__tests__/agentLibrary.test.cjs +21 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +16 -0
- package/src/main/__tests__/agentOverlayWrite.test.cjs +95 -0
- package/src/main/__tests__/chat-cancel-terminal.test.cjs +5 -2
- package/src/main/__tests__/chat-exit-close-race.test.cjs +8 -2
- package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +5 -2
- package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +44 -0
- package/src/main/__tests__/intradayRefresh.test.cjs +39 -0
- package/src/main/__tests__/openExternalApp-spawn-error.test.cjs +25 -0
- package/src/main/__tests__/opsErrorLog.test.cjs +22 -0
- package/src/main/__tests__/personaMerge.test.cjs +169 -0
- package/src/main/__tests__/prdCreatePlanId.test.cjs +132 -0
- package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +13 -0
- package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +21 -0
- package/src/main/__tests__/rateLimitPollerStreak.test.cjs +14 -0
- package/src/main/__tests__/runVerify-atomic-verdicts.test.cjs +26 -0
- package/src/main/__tests__/runVerify-landed-commit-outranks.test.cjs +11 -1
- package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +11 -1
- package/src/main/__tests__/runVerify.test.cjs +12 -1
- package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +47 -2
- package/src/main/__tests__/transcriptsUsageFor.test.cjs +112 -1
- package/src/main/agentLibrary.cjs +86 -40
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +50 -10
- package/src/main/git.cjs +9 -2
- package/src/main/historyAggregator.cjs +2 -19
- package/src/main/index.cjs +17 -8
- package/src/main/ipcSchemas.cjs +25 -3
- package/src/main/lib/__tests__/childWithLog.test.cjs +180 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +19 -0
- package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +10 -5
- package/src/main/lib/__tests__/gitCacheBound.test.cjs +69 -0
- package/src/main/lib/__tests__/modelCatalog.test.cjs +202 -0
- package/src/main/lib/agentEffortResolve.cjs +74 -0
- package/src/main/lib/agentModelResolve.cjs +52 -59
- package/src/main/lib/agentPersonaSchema.cjs +5 -0
- package/src/main/lib/childWithLog.cjs +92 -51
- package/src/main/lib/delegationReadiness.cjs +3 -1
- package/src/main/lib/effectiveModelInfo.cjs +38 -20
- package/src/main/lib/epicMint.cjs +5 -2
- package/src/main/lib/epicSpawnPlan.cjs +27 -8
- package/src/main/lib/epicTranscriptPath.cjs +5 -1
- package/src/main/lib/epicWorktreeMerge.cjs +7 -6
- package/src/main/lib/headTailBuffer.cjs +43 -0
- package/src/main/lib/intradayRefresh.cjs +33 -0
- package/src/main/lib/lruCache.cjs +39 -0
- package/src/main/lib/modelCatalog.cjs +243 -0
- package/src/main/lib/openExternalApp.cjs +27 -9
- package/src/main/lib/opsErrorLog.cjs +22 -0
- package/src/main/lib/personaMerge.cjs +166 -0
- package/src/main/lib/prdCreate.cjs +23 -5
- package/src/main/lib/prdDisposition.cjs +46 -0
- package/src/main/lib/prdFrontmatter.cjs +6 -2
- package/src/main/lib/promptSessionSchema.cjs +5 -0
- package/src/main/lib/promptSessionsCreateEpic.cjs +5 -3
- package/src/main/lib/rendererRecovery.cjs +141 -0
- package/src/main/lib/scheduleJobSchema.cjs +3 -0
- package/src/main/runVerify.cjs +3 -1
- package/src/main/scheduler/prdParser.cjs +2 -0
- package/src/main/scheduler.cjs +381 -262
- package/src/main/templates/PRD_AUTHORING.md +4 -0
- package/src/main/transcripts.cjs +54 -11
- package/src/preload/api.d.ts +56 -3
- package/src/preload/index.cjs +4 -0
- package/dist/assets/AgentLibrary-Ci6u03bq.js +0 -3
- package/dist/assets/DataModel-BIqcjsv2.js +0 -1
- package/dist/assets/HostBilko-CMe5cH3H.js +0 -1
- package/dist/assets/ListDetail-CtVjrHKB.js +0 -1
- package/dist/assets/McpServers-DKN75Shn.js +0 -2
- package/dist/assets/Panel-C2YTW-qP.js +0 -1
- package/dist/assets/Plugins-DbZkJs5l.js +0 -2
- package/dist/assets/ProvenanceBadge-kJ__BOQv.js +0 -1
- package/dist/assets/Scheduler-DdEaxku8.js +0 -16
- package/dist/assets/Settings-CjywVsNB.js +0 -3
- package/dist/assets/Skills-Cd_yPO_l.js +0 -3
- package/dist/assets/SystemPrompt-2z6eGPSF.js +0 -1
- package/dist/assets/TagLibrary-DcaWQSp4.js +0 -1
- package/dist/assets/index-D5H_H5wC.js +0 -3074
- package/dist/assets/settingsSchema-JRDfjgHb.js +0 -3
|
@@ -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 };
|
|
@@ -41,6 +41,28 @@ function findCommand(name) {
|
|
|
41
41
|
return null;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Spawn a detached opener and resolve once the OS reports the outcome.
|
|
46
|
+
* A missing/unexecutable binary emits an async 'error' (ENOENT/EACCES); without
|
|
47
|
+
* a listener that is an uncaught exception and the caller would see ok:true.
|
|
48
|
+
* Races 'spawn' against 'error' — never uses shell:true.
|
|
49
|
+
*
|
|
50
|
+
* @returns {Promise<{ ok: true, opener: string } | { ok: false, error: string }>}
|
|
51
|
+
*/
|
|
52
|
+
function spawnDetached(cmd, args, opener = cmd) {
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
let child;
|
|
55
|
+
try {
|
|
56
|
+
child = spawn(cmd, args, { detached: true, stdio: 'ignore', env: cleanChildEnv() });
|
|
57
|
+
} catch (e) {
|
|
58
|
+
resolve({ ok: false, error: `failed to launch ${opener}: ${e?.message ?? e}` });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
child.once('error', (e) => resolve({ ok: false, error: `failed to launch ${opener}: ${e?.message ?? e}` }));
|
|
62
|
+
child.once('spawn', () => { child.unref(); resolve({ ok: true, opener }); });
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
44
66
|
/**
|
|
45
67
|
* Open a project root directory in the user's editor.
|
|
46
68
|
*
|
|
@@ -51,8 +73,7 @@ async function openInEditor({ cwd, editor }) {
|
|
|
51
73
|
const candidates = (editor && editor !== 'auto') ? [editor] : editorCandidates();
|
|
52
74
|
for (const cmd of candidates) {
|
|
53
75
|
if (!findCommand(cmd)) continue;
|
|
54
|
-
|
|
55
|
-
return { ok: true, opener: cmd };
|
|
76
|
+
return spawnDetached(cmd, [cwd]);
|
|
56
77
|
}
|
|
57
78
|
return { ok: false, error: 'no editor found' };
|
|
58
79
|
}
|
|
@@ -89,8 +110,7 @@ async function openFileInEditor({ path: abs, line, col, editor }) {
|
|
|
89
110
|
const supportsGoto = /^(code|cursor|subl)$/.test(cmd);
|
|
90
111
|
const target = (supportsGoto && line) ? `${abs}:${line}${col ? `:${col}` : ''}` : abs;
|
|
91
112
|
const args = supportsGoto ? ['-g', target] : [abs];
|
|
92
|
-
|
|
93
|
-
return { ok: true, opener: cmd };
|
|
113
|
+
return spawnDetached(cmd, args);
|
|
94
114
|
}
|
|
95
115
|
return { ok: false, error: 'no editor found' };
|
|
96
116
|
}
|
|
@@ -128,14 +148,12 @@ async function openInTerminal({ cwd }) {
|
|
|
128
148
|
const args = t === 'gnome-terminal'
|
|
129
149
|
? ['--working-directory=' + cwd]
|
|
130
150
|
: ['-e', `bash -c "cd '${cwd.replace(/'/g, "'\\''")}' && exec bash"`];
|
|
131
|
-
|
|
132
|
-
return { ok: true, opener: t };
|
|
151
|
+
return spawnDetached(t, args);
|
|
133
152
|
}
|
|
134
153
|
} else if (process.platform === 'darwin') {
|
|
135
|
-
|
|
136
|
-
return { ok: true, opener: 'Terminal.app' };
|
|
154
|
+
return spawnDetached('open', ['-a', 'Terminal', cwd], 'Terminal.app');
|
|
137
155
|
}
|
|
138
156
|
return { ok: false, error: 'no terminal found' };
|
|
139
157
|
}
|
|
140
158
|
|
|
141
|
-
module.exports = { findCommand, openInEditor, openFileInEditor, openInFinder, openInTerminal };
|
|
159
|
+
module.exports = { spawnDetached, findCommand, openInEditor, openFileInEditor, openInFinder, openInTerminal };
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
const fs = require('node:fs');
|
|
15
|
+
const os = require('node:os');
|
|
15
16
|
const path = require('node:path');
|
|
16
17
|
const { assertOpsWrite } = require('./opsOwnership.cjs');
|
|
17
18
|
const { isEphemeralCwd } = require('./ephemeralCwd.cjs');
|
|
@@ -125,6 +126,23 @@ function reportToTelemetry({ cwd, scope, level, tabId, epicId, tags, message })
|
|
|
125
126
|
} catch { /* telemetry must never break the caller — this is the sole error funnel */ }
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
function realOrResolved(p) {
|
|
130
|
+
const abs = path.resolve(p);
|
|
131
|
+
try { return fs.realpathSync(abs); } catch { return abs; }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isInside(child, parent) {
|
|
135
|
+
const rel = path.relative(parent, child);
|
|
136
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** True when `cwd` resolves inside os.tmpdir() or SM_SANDBOX_DIR. O(roots). */
|
|
140
|
+
function isInsideTestScratch(cwd) {
|
|
141
|
+
const target = realOrResolved(cwd);
|
|
142
|
+
const roots = [os.tmpdir(), process.env.SM_SANDBOX_DIR].filter(Boolean);
|
|
143
|
+
return roots.some((r) => isInside(target, realOrResolved(r)));
|
|
144
|
+
}
|
|
145
|
+
|
|
128
146
|
/**
|
|
129
147
|
* Append one structured error line, tagged for tracing/analysis.
|
|
130
148
|
*
|
|
@@ -142,6 +160,10 @@ function reportToTelemetry({ cwd, scope, level, tabId, epicId, tags, message })
|
|
|
142
160
|
function appendError({ cwd, scope, level = 'error', tabId, epicId, tags = [], message, meta }) {
|
|
143
161
|
if (!cwd || typeof cwd !== 'string') return; // no project to attribute this line to — skip
|
|
144
162
|
|
|
163
|
+
// Test-process guard: a vitest fixture passing a real project root must never
|
|
164
|
+
// write to that project's logs/ NOR ship a fake event to production telemetry.
|
|
165
|
+
if (process.env.VITEST && !isInsideTestScratch(cwd)) return;
|
|
166
|
+
|
|
145
167
|
if (isEphemeralCwd(cwd)) {
|
|
146
168
|
// A worktree is torn down when its Epic/job ends and os.tmpdir() is
|
|
147
169
|
// scratch space either way — never materialize logs there. See
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|