claude-code-session-manager 0.119.1 → 0.121.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 (76) hide show
  1. package/dist/assets/{AgentLibrary-BpW0pGgk.js → AgentLibrary-FO2ei2wB.js} +1 -1
  2. package/dist/assets/{Button-DZX5guWb.js → Button-CeIOXUPU.js} +1 -1
  3. package/dist/assets/{DataModel-WG4pG_4z.js → DataModel-D0p__Hil.js} +1 -1
  4. package/dist/assets/{History-D58whvn_.js → History-DVqQ5azN.js} +1 -1
  5. package/dist/assets/{Hooks-DCI9ezg4.js → Hooks-DkTnRHyT.js} +1 -1
  6. package/dist/assets/{Library-YqMi-vsA.js → Library-l7KS6pkE.js} +1 -1
  7. package/dist/assets/{ListDetail-ap4W5z_Z.js → ListDetail-DDw8fuRT.js} +1 -1
  8. package/dist/assets/{McpServers-CT0pBTF8.js → McpServers-UUNIxk_3.js} +1 -1
  9. package/dist/assets/{Memory-BODDUBy4.js → Memory-CNYUoQ8C.js} +1 -1
  10. package/dist/assets/{Panel-CeOfUS9B.js → Panel-Bb54soc2.js} +1 -1
  11. package/dist/assets/{Permissions-DyOqpLQx.js → Permissions-D-dNdV3f.js} +1 -1
  12. package/dist/assets/{Plugins-DBXzKjL2.js → Plugins-DhcIXRhQ.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-C47IX1Q6.js → ProvenanceBadge-DEAkDNjA.js} +1 -1
  14. package/dist/assets/{RemoteMachines-CtkAJvkZ.js → RemoteMachines-xCEgvTGU.js} +1 -1
  15. package/dist/assets/{SaveBar-YMtzcCzP.js → SaveBar-BSEs7ecA.js} +1 -1
  16. package/dist/assets/{Scheduler-BNiYg0YD.js → Scheduler-BIKJIU-C.js} +5 -5
  17. package/dist/assets/{ScopeSwitcher-Dgi8CiDi.js → ScopeSwitcher-DcaFyAV-.js} +1 -1
  18. package/dist/assets/Settings-vcdWy_Jx.js +4 -0
  19. package/dist/assets/{SkillReferenceGraph-CaFsccNI.js → SkillReferenceGraph-CLrJWTPd.js} +1 -1
  20. package/dist/assets/{Skills-BeCMBQ3l.js → Skills-CYM4rBEC.js} +1 -1
  21. package/dist/assets/{SystemPrompt-C3WmcPEF.js → SystemPrompt-BWNIu-iY.js} +1 -1
  22. package/dist/assets/{TagLibrary-DP4GH7Dr.js → TagLibrary-CYdwHK6m.js} +1 -1
  23. package/dist/assets/{TiptapBody-CXCMvRr_.js → TiptapBody-Kt90lHrD.js} +1 -1
  24. package/dist/assets/{Toggle-TY-Wa8hv.js → Toggle-D1OXWYOz.js} +1 -1
  25. package/dist/assets/{ViewTabs-CaacRnp6.js → ViewTabs-BeQR3CKh.js} +1 -1
  26. package/dist/assets/{historyTotals-C28IlVVQ.js → historyTotals-B0qafrRo.js} +1 -1
  27. package/dist/assets/{index-DdEMkmbi.js → index-Bq9uH08R.js} +201 -201
  28. package/dist/assets/{index-DAMBm3UN.css → index-Dd7UQabV.css} +1 -1
  29. package/dist/assets/{settingsSchema-7Yg6dNNB.js → settingsSchema-0ZksSAPB.js} +1 -1
  30. package/dist/assets/{skillFrontmatter-Dcuys1km.js → skillFrontmatter-C0nh8yfa.js} +1 -1
  31. package/dist/assets/{useAgentsDirWatch-B0H4M-TT.js → useAgentsDirWatch-DPkoBybS.js} +1 -1
  32. package/dist/assets/{useRemotePeers-Zp9jchCS.js → useRemotePeers-BDJMUihy.js} +1 -1
  33. package/dist/index.html +2 -2
  34. package/package.json +1 -1
  35. package/src/main/build-info.json +4 -4
  36. package/src/main/chatRunner.cjs +54 -14
  37. package/src/main/crashDiagnostics.cjs +18 -13
  38. package/src/main/health.cjs +129 -4
  39. package/src/main/index.cjs +57 -18
  40. package/src/main/ipcSchemas.cjs +18 -4
  41. package/src/main/lib/activeSessions.cjs +51 -34
  42. package/src/main/lib/claude/agentModelResolve.cjs +5 -3
  43. package/src/main/lib/claude/personaToolScope.cjs +68 -0
  44. package/src/main/lib/epic/archivedTranscript.cjs +91 -0
  45. package/src/main/lib/epic/archivedTranscriptMigration.cjs +84 -0
  46. package/src/main/lib/epic/delegationReadiness.cjs +2 -0
  47. package/src/main/lib/epic/epicSettle.cjs +21 -5
  48. package/src/main/lib/epic/epicValidationHook.cjs +7 -0
  49. package/src/main/lib/git/depHealth.cjs +161 -0
  50. package/src/main/lib/git/worktreeDeps.cjs +158 -5
  51. package/src/main/lib/machineProfile.cjs +2 -0
  52. package/src/main/lib/mcpToolCatalog.cjs +3 -1
  53. package/src/main/lib/opsRetentionSettings.cjs +183 -0
  54. package/src/main/lib/persona-macro/macroLibrary.cjs +75 -0
  55. package/src/main/lib/prd/prdCreate.cjs +25 -0
  56. package/src/main/lib/prd/prdWorktreeReach.cjs +2 -2
  57. package/src/main/lib/prd/workTypeLibrary.cjs +7 -3
  58. package/src/main/lib/process/procGroupRss.cjs +61 -0
  59. package/src/main/lib/remoteTranscriptTail.cjs +69 -2
  60. package/src/main/lib/scheduler/queueHistory.cjs +68 -9
  61. package/src/main/lib/scheduler/runLogRetention.cjs +129 -43
  62. package/src/main/lib/scheduler/schedulerBatch.cjs +7 -3
  63. package/src/main/lib/scheduler/staleTmpSweep.cjs +117 -0
  64. package/src/main/lib/telemetry/auditLog.cjs +51 -6
  65. package/src/main/lib/telemetry/crashTelemetry.cjs +49 -1
  66. package/src/main/lib/telemetry/opsErrorLog.cjs +30 -10
  67. package/src/main/lib/telemetry/telemetryClient.cjs +5 -2
  68. package/src/main/scheduler/prdParser.cjs +50 -0
  69. package/src/main/scheduler/prdPaths.cjs +61 -16
  70. package/src/main/scheduler/reviewLadder.cjs +24 -1
  71. package/src/main/scheduler.cjs +214 -37
  72. package/src/main/templates/PRD_AUTHORING.md +2 -2
  73. package/src/main/webRemote.cjs +82 -91
  74. package/src/preload/api.d.ts +41 -3
  75. package/src/preload/index.cjs +9 -0
  76. package/dist/assets/Settings-B4LuoRnd.js +0 -4
@@ -9,12 +9,40 @@
9
9
  * `info/exclude` as `/<name>` — otherwise the link shows as untracked and the
10
10
  * job parks as `uncommitted_changes`.
11
11
  *
12
+ * pnpm projects are the exception for node_modules: `pnpm install` purges and
13
+ * rebuilds the modules dir THROUGH a symlink, wiping the main checkout's
14
+ * node_modules. So a pnpm worktree gets its own node_modules, installed
15
+ * offline from the shared store (`installed`); the main checkout is never
16
+ * touched. `node_modules/` (any depth) is excluded so nested workspace
17
+ * node_modules stay untracked.
18
+ *
19
+ * Relative `file:`/`link:` deps that point OUTSIDE the repo (e.g.
20
+ * `file:../Bilko/packages/host-kit`) cannot resolve from a worktree, which lives
21
+ * under the session-manager state dir rather than next to the sibling repo — the
22
+ * offline install would ENOENT and leave no node_modules. So before installing,
23
+ * `linkOutOfRepoLocalDeps` creates a symlink at the equivalent location relative
24
+ * to the worktree (first path segment past the `..` hops, e.g.
25
+ * `<worktrees>/<hash>/Bilko -> ~/Projects/Bilko`). That link may therefore sit
26
+ * OUTSIDE the worktree; it is only created where nothing exists, never inside
27
+ * the main checkout, and never overwrites anything.
28
+ *
29
+ * uv projects (`uv.lock`) are the exception for .venv/venv: `uv run` and
30
+ * `uv sync` re-sync the project env, and for an editable project install that
31
+ * rewrites site-packages/<pkg>.pth to the CURRENT tree's src. Through a shared
32
+ * symlink that silently repoints the main checkout's imports at the worktree
33
+ * (social-signals-trader, 2026-10-18), and worktree tests import whichever
34
+ * tree synced last. So a uv worktree gets its own env, built by an offline
35
+ * frozen `uv sync` (hardlinks from uv's cache — cheap); the main env is never
36
+ * linked. If that sync fails the name is skipped and the executor's own `uv`
37
+ * call builds a worktree-local env — never the main one.
38
+ *
12
39
  * Never throws; failures land in `skipped`.
13
40
  */
14
41
  'use strict';
15
42
 
16
43
  const fs = require('node:fs');
17
44
  const path = require('node:path');
45
+ const { execFile } = require('node:child_process');
18
46
  const { execGit } = require('./gitExec.cjs');
19
47
 
20
48
  const DEFAULT_DEP_DIRS = ['node_modules', '.venv', 'venv'];
@@ -34,27 +62,141 @@ async function isIgnored(cwd, name) {
34
62
  } catch { return false; }
35
63
  }
36
64
 
37
- async function addExcludes(mainCwd, names) {
65
+ /** True when mainCwd is a pnpm project (lockfile or pnpm modules manifest). Never throws. */
66
+ function isPnpmProject(mainCwd) {
67
+ try {
68
+ return fs.existsSync(path.join(mainCwd, 'pnpm-lock.yaml'))
69
+ || fs.existsSync(path.join(mainCwd, 'node_modules', '.modules.yaml'));
70
+ } catch { return false; }
71
+ }
72
+
73
+ /** True when mainCwd is a uv project (uv.lock). Never throws. */
74
+ function isUvProject(mainCwd) {
75
+ try { return fs.existsSync(path.join(mainCwd, 'uv.lock')); } catch { return false; }
76
+ }
77
+
78
+ /** Offline frozen `uv sync` into worktreeCwd/<name>. Resolves true on exit 0; never throws. */
79
+ function installUvEnv(worktreeCwd, name) {
80
+ return new Promise((resolve) => {
81
+ try {
82
+ const env = { ...process.env, UV_PROJECT_ENVIRONMENT: path.join(worktreeCwd, name) };
83
+ delete env.VIRTUAL_ENV;
84
+ execFile(
85
+ 'uv',
86
+ ['sync', '--frozen', '--offline'],
87
+ { cwd: worktreeCwd, env, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 },
88
+ (err) => resolve(!err),
89
+ );
90
+ } catch { resolve(false); }
91
+ });
92
+ }
93
+
94
+ /** Top-level importer dirs from pnpm-lock.yaml (`.` is the root). Simple line scan; never throws. */
95
+ function lockImporters(cwd) {
96
+ const out = ['.'];
97
+ try {
98
+ let inImporters = false;
99
+ for (const line of fs.readFileSync(path.join(cwd, 'pnpm-lock.yaml'), 'utf8').split('\n')) {
100
+ if (/^importers:\s*$/.test(line)) { inImporters = true; continue; }
101
+ if (!inImporters) continue;
102
+ if (/^\S/.test(line)) break;
103
+ const m = /^ {2}(\S[^:]*):\s*$/.exec(line);
104
+ if (m) out.push(m[1].replace(/^(['"])(.*)\1$/, '$2'));
105
+ }
106
+ } catch { /* no lockfile */ }
107
+ return [...new Set(out)];
108
+ }
109
+
110
+ /** Relative file:/link: specifiers of a package.json. Never throws. */
111
+ function localSpecs(pkgJson) {
112
+ const specs = [];
113
+ try {
114
+ const pkg = JSON.parse(fs.readFileSync(pkgJson, 'utf8'));
115
+ for (const f of ['dependencies', 'devDependencies', 'optionalDependencies']) {
116
+ for (const v of Object.values(pkg[f] || {})) {
117
+ const m = typeof v === 'string' && /^(?:file|link):(.+)$/.exec(v);
118
+ if (m && !path.isAbsolute(m[1])) specs.push(m[1]);
119
+ }
120
+ }
121
+ } catch { /* unreadable */ }
122
+ return specs;
123
+ }
124
+
125
+ const within = (parent, p) => p === parent || p.startsWith(parent + path.sep);
126
+
127
+ /**
128
+ * Best-effort: link out-of-repo relative file:/link: dep targets at their
129
+ * worktree-relative location so the offline install can resolve them. Never throws.
130
+ */
131
+ function linkOutOfRepoLocalDeps(mainCwd, worktreeCwd) {
132
+ try {
133
+ for (const importer of lockImporters(worktreeCwd)) {
134
+ const mainDir = path.resolve(mainCwd, importer);
135
+ const wtDir = path.resolve(worktreeCwd, importer);
136
+ for (const spec of localSpecs(path.join(mainDir, 'package.json'))) {
137
+ try {
138
+ const target = path.resolve(mainDir, spec);
139
+ if (within(mainCwd, target) || !exists(target)) continue;
140
+ const segs = path.normalize(spec).split(path.sep).filter((x) => x && x !== '.');
141
+ let ups = 0;
142
+ while (segs[ups] === '..') ups++;
143
+ const rest = segs.slice(ups);
144
+ if (!rest.length) continue;
145
+ // Link the first segment past the `..` hops, to the matching target ancestor.
146
+ let wtBase = wtDir;
147
+ for (let i = 0; i < ups; i++) wtBase = path.dirname(wtBase);
148
+ const linkPath = path.join(wtBase, rest[0]);
149
+ let linkTarget = target;
150
+ for (let i = 1; i < rest.length; i++) linkTarget = path.dirname(linkTarget);
151
+ if (within(mainCwd, linkPath) || within(worktreeCwd, linkPath) || exists(linkPath)) continue;
152
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
153
+ fs.symlinkSync(linkTarget, linkPath, isDir(linkTarget) ? 'dir' : 'file');
154
+ } catch { /* best-effort per dep */ }
155
+ }
156
+ }
157
+ } catch { /* best-effort */ }
158
+ }
159
+
160
+ /** Offline frozen install in the worktree. Resolves true on exit 0; never throws. */
161
+ function installPnpmModules(worktreeCwd) {
162
+ return new Promise((resolve) => {
163
+ try {
164
+ execFile(
165
+ 'pnpm',
166
+ ['install', '--offline', '--frozen-lockfile', '--config.confirmModulesPurge=false'],
167
+ { cwd: worktreeCwd, env: { ...process.env, CI: '1' }, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 },
168
+ (err) => resolve(!err),
169
+ );
170
+ } catch { resolve(false); }
171
+ });
172
+ }
173
+
174
+ async function addExcludeLines(mainCwd, lines) {
38
175
  const out = (await execGit(mainCwd, ['rev-parse', '--git-common-dir'], { timeout: 10_000 })).trim();
39
176
  const commonDir = path.resolve(mainCwd, out);
40
177
  const file = path.join(commonDir, 'info', 'exclude');
41
178
  let existing = '';
42
179
  try { existing = fs.readFileSync(file, 'utf8'); } catch { /* absent */ }
43
180
  const have = new Set(existing.split('\n').map((l) => l.trim()));
44
- const missing = names.map((n) => `/${n}`).filter((l) => !have.has(l));
181
+ const missing = lines.filter((l) => !have.has(l));
45
182
  if (!missing.length) return;
46
183
  fs.mkdirSync(path.dirname(file), { recursive: true });
47
184
  const sep = existing && !existing.endsWith('\n') ? '\n' : '';
48
185
  fs.appendFileSync(file, `${sep}${missing.join('\n')}\n`);
49
186
  }
50
187
 
188
+ function addExcludes(mainCwd, names) {
189
+ return addExcludeLines(mainCwd, names.map((n) => `/${n}`));
190
+ }
191
+
51
192
  /**
52
193
  * @param {{ mainCwd: string, worktreeCwd: string, names?: string[] }} opts
53
- * @returns {Promise<{ linked: string[], skipped: string[] }>}
194
+ * @returns {Promise<{ linked: string[], skipped: string[], installed: string[] }>}
54
195
  */
55
196
  async function linkDependencyDirs({ mainCwd, worktreeCwd, names = DEFAULT_DEP_DIRS }) {
56
197
  const linked = [];
57
198
  const skipped = [];
199
+ const installed = [];
58
200
  for (const name of names) {
59
201
  try {
60
202
  const src = path.join(mainCwd, name);
@@ -63,6 +205,17 @@ async function linkDependencyDirs({ mainCwd, worktreeCwd, names = DEFAULT_DEP_DI
63
205
  skipped.push(name);
64
206
  continue;
65
207
  }
208
+ if (name === 'node_modules' && isPnpmProject(mainCwd)) {
209
+ await addExcludeLines(mainCwd, ['node_modules/']);
210
+ linkOutOfRepoLocalDeps(mainCwd, worktreeCwd);
211
+ (await installPnpmModules(worktreeCwd) ? installed : skipped).push(name);
212
+ continue;
213
+ }
214
+ if ((name === '.venv' || name === 'venv') && isUvProject(mainCwd)) {
215
+ await addExcludes(mainCwd, [name]);
216
+ (await installUvEnv(worktreeCwd, name) ? installed : skipped).push(name);
217
+ continue;
218
+ }
66
219
  // Exclude first: a link without its exclude line would dirty status.
67
220
  await addExcludes(mainCwd, [name]);
68
221
  fs.symlinkSync(src, dest, 'dir');
@@ -71,7 +224,7 @@ async function linkDependencyDirs({ mainCwd, worktreeCwd, names = DEFAULT_DEP_DI
71
224
  skipped.push(name);
72
225
  }
73
226
  }
74
- return { linked, skipped };
227
+ return { linked, skipped, installed };
75
228
  }
76
229
 
77
- module.exports = { linkDependencyDirs, DEFAULT_DEP_DIRS };
230
+ module.exports = { linkDependencyDirs, isPnpmProject, isUvProject, DEFAULT_DEP_DIRS };
@@ -117,6 +117,8 @@ async function buildMachineProfile(opts = {}) {
117
117
  let appPath = null;
118
118
  if (electronApp) {
119
119
  try { appVersion = electronApp.getVersion(); } catch { /* not ready / not a real app object */ }
120
+ // Unpackaged launch with a script path makes getVersion() return Electron's own version.
121
+ if (appVersion && process.versions && appVersion === process.versions.electron) appVersion = null;
120
122
  try { appPath = electronApp.getAppPath(); } catch { /* not ready / not a real app object */ }
121
123
  }
122
124
  if (!appVersion) appVersion = resolvePackageVersion();
@@ -193,7 +193,9 @@ const MCP_TOOL_CATALOG = [
193
193
  purpose: "THE ONLY SUPPORTED WAY to cancel a not-yet-terminal scheduler job via the session-manager app's admin "
194
194
  + 'API. A running job is SIGTERM\'d; a pending job is simply retired. There is no "cancelled" job status, so a '
195
195
  + 'cancelled job lands as "failed" with an error naming the cause. Refuses a slug whose job is already terminal '
196
- + '(completed/failed/needs_review) — nothing to cancel.',
196
+ + '(completed/failed/needs_review) — nothing to cancel. A cancelled running job\'s commits are never landed on '
197
+ + 'the base branch; they stay on the job branch, reported in the result as landed:false plus '
198
+ + 'jobBranch/jobBranchSha.',
197
199
  whenToUse: 'Use to stop a running or pending job that should not continue (e.g. it was queued in error, or scope changed underneath it).',
198
200
  whenNotToUse: 'Do not use on an already-terminal job (completed/failed/needs_review) — use scheduler_reset_job if it should run again.',
199
201
  exampleArgs: { slug: 'add-mcp-tool-catalog' },
@@ -0,0 +1,183 @@
1
+ /**
2
+ * opsRetentionSettings — machine-level retention policy for the ops sweeps.
3
+ *
4
+ * Storage: ~/.claude/session-manager/ops-retention.json
5
+ * Shape (DEFAULTS): { runLogs:{maxAgeDays,keepPerSlug}, runsArchiveDays, auditLogMonths,
6
+ * reservedMarkersKeep }
7
+ * Archived transcripts are kept forever (fixed policy, not a setting).
8
+ *
9
+ * Top-level in lib/ (no domain fits: read by scheduler, telemetry and epic sweeps alike).
10
+ * preview() runs the sweeps' own eligibility code in dry-run mode — it deletes nothing.
11
+ * Modelled on updaterSettings.cjs: frozen DEFAULTS, never-throwing read, strict write.
12
+ */
13
+ 'use strict';
14
+
15
+ const fs = require('node:fs');
16
+ const path = require('node:path');
17
+ const os = require('node:os');
18
+ const atomicFs = require('./util/atomicFs.cjs');
19
+
20
+ const DEFAULTS = Object.freeze({
21
+ runLogs: Object.freeze({ maxAgeDays: 14, keepPerSlug: 3 }),
22
+ runsArchiveDays: 30,
23
+ auditLogMonths: 6,
24
+ reservedMarkersKeep: 100,
25
+ });
26
+
27
+ // [min, max] per integer field; out-of-range values clamp, non-integers round.
28
+ const BOUNDS = Object.freeze({
29
+ 'runLogs.maxAgeDays': [3, 3650],
30
+ 'runLogs.keepPerSlug': [1, 1000],
31
+ runsArchiveDays: [1, 3650],
32
+ auditLogMonths: [1, 120],
33
+ reservedMarkersKeep: [1, 100000],
34
+ });
35
+
36
+ function storePath() {
37
+ return path.join(require('./scheduler/schedulerPaths.cjs').claudeHome(), 'session-manager', 'ops-retention.json');
38
+ }
39
+
40
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
41
+
42
+ function clamp(key, v) {
43
+ const [min, max] = BOUNDS[key];
44
+ return Math.min(max, Math.max(min, Math.round(v)));
45
+ }
46
+
47
+ /**
48
+ * Strict patch validation (used by write() and the IPC set handler). Returns a
49
+ * partial settings object with clamped integers; throws on unknown keys,
50
+ * or wrong types.
51
+ */
52
+ function validatePatch(patch) {
53
+ if (!isObj(patch)) throw new Error('Invalid ops retention config');
54
+ const out = {};
55
+ const num = (key, v) => {
56
+ if (typeof v !== 'number' || !Number.isFinite(v)) throw new Error(`Invalid ops retention config: ${key}`);
57
+ return clamp(key, v);
58
+ };
59
+ for (const [k, v] of Object.entries(patch)) {
60
+ if (k === 'runLogs') {
61
+ if (!isObj(v)) throw new Error('Invalid ops retention config: runLogs');
62
+ out.runLogs = {};
63
+ for (const [rk, rv] of Object.entries(v)) {
64
+ if (rk !== 'maxAgeDays' && rk !== 'keepPerSlug') throw new Error(`Invalid ops retention config: runLogs.${rk}`);
65
+ out.runLogs[rk] = num(`runLogs.${rk}`, rv);
66
+ }
67
+ } else if (k === 'runsArchiveDays' || k === 'auditLogMonths' || k === 'reservedMarkersKeep') {
68
+ out[k] = num(k, v);
69
+ } else {
70
+ throw new Error(`Invalid ops retention config: unknown key ${k}`);
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /** Lenient field-by-field sanitize of on-disk data: bad fields are dropped, not fatal. */
77
+ function sanitizeStored(data) {
78
+ if (!isObj(data)) return null;
79
+ const out = {};
80
+ const int = (key, v) => (typeof v === 'number' && Number.isFinite(v) ? clamp(key, v) : undefined);
81
+ if (isObj(data.runLogs)) {
82
+ const m = int('runLogs.maxAgeDays', data.runLogs.maxAgeDays);
83
+ const k = int('runLogs.keepPerSlug', data.runLogs.keepPerSlug);
84
+ if (m !== undefined || k !== undefined) {
85
+ out.runLogs = {
86
+ maxAgeDays: m !== undefined ? m : DEFAULTS.runLogs.maxAgeDays,
87
+ keepPerSlug: k !== undefined ? k : DEFAULTS.runLogs.keepPerSlug,
88
+ };
89
+ }
90
+ }
91
+ for (const key of ['runsArchiveDays', 'auditLogMonths', 'reservedMarkersKeep']) {
92
+ const v = int(key, data[key]);
93
+ if (v !== undefined) out[key] = v;
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /** What is explicitly stored (sanitized), or null when absent/corrupt. Never throws. */
99
+ function readStored() {
100
+ try {
101
+ const raw = JSON.parse(fs.readFileSync(storePath(), 'utf8'));
102
+ return sanitizeStored(raw);
103
+ } catch (e) {
104
+ if (e.code !== 'ENOENT') console.warn('[opsRetentionSettings] read failed:', e.message);
105
+ return null;
106
+ }
107
+ }
108
+
109
+ function withDefaults(stored) {
110
+ const s = stored || {};
111
+ return {
112
+ runLogs: { ...DEFAULTS.runLogs, ...(s.runLogs || {}) },
113
+ runsArchiveDays: s.runsArchiveDays ?? DEFAULTS.runsArchiveDays,
114
+ auditLogMonths: s.auditLogMonths ?? DEFAULTS.auditLogMonths,
115
+ reservedMarkersKeep: s.reservedMarkersKeep ?? DEFAULTS.reservedMarkersKeep,
116
+ };
117
+ }
118
+
119
+ /** Effective settings. Never throws: missing/corrupt file yields DEFAULTS. */
120
+ function read() {
121
+ return withDefaults(readStored());
122
+ }
123
+
124
+ /** Validate + merge `patch` over the current settings and persist the full object. */
125
+ function write(patch) {
126
+ const valid = validatePatch(patch);
127
+ const cur = read();
128
+ const next = {
129
+ ...cur,
130
+ ...valid,
131
+ runLogs: { ...cur.runLogs, ...(valid.runLogs || {}) },
132
+ };
133
+ atomicFs.writeJsonAtomicSync(storePath(), next, { mode: 0o600 });
134
+ return next;
135
+ }
136
+
137
+ /**
138
+ * Dry-run report of what each policy would free, via the sweeps' own
139
+ * eligibility code. Read-only. opts: { now, runsDir, plansRoot, jobs, liveKeys }.
140
+ */
141
+ function preview(opts) {
142
+ const o = opts || {};
143
+ const schedulerPaths = require('./scheduler/schedulerPaths.cjs');
144
+ const runLogRetention = require('./scheduler/runLogRetention.cjs');
145
+ const staleTmpSweep = require('./scheduler/staleTmpSweep.cjs');
146
+ const s = read();
147
+ const now = typeof o.now === 'number' ? o.now : Date.now();
148
+ const runsDir = o.runsDir || schedulerPaths.runsDir();
149
+ const plansRoot = o.plansRoot || schedulerPaths.scheduledPlansRoot();
150
+
151
+ let liveKeys = o.liveKeys;
152
+ if (!liveKeys) {
153
+ let jobs = o.jobs;
154
+ if (!jobs) {
155
+ try {
156
+ jobs = require('./scheduler/queueStore.cjs').readMergedSync().jobs || [];
157
+ } catch {
158
+ jobs = [];
159
+ }
160
+ }
161
+ liveKeys = runLogRetention.liveKeysFromJobs(jobs, { runsDir });
162
+ }
163
+ const report = runLogRetention.computeReport(runsDir, { ...s.runLogs }, { now, liveKeys });
164
+ const archive = staleTmpSweep.pruneRunsArchive(path.join(plansRoot, 'runs-archive'), {
165
+ maxAgeDays: s.runsArchiveDays,
166
+ now,
167
+ dryRun: true,
168
+ });
169
+ const tmp = staleTmpSweep.sweepStaleTmp(plansRoot, { now, dryRun: true });
170
+ return {
171
+ runLogs: { files: report.eligible.reduce((n, e) => n + e.removeFiles.length, 0) + report.dodFiles.length, bytes: report.eligibleSummary.bytes },
172
+ runsArchive: { entries: archive.removedFiles, bytes: archive.freedBytes },
173
+ tmp: { files: tmp.removedFiles, bytes: tmp.freedBytes },
174
+ };
175
+ }
176
+
177
+ function registerIpc(ipcMain) {
178
+ ipcMain.handle('opsRetention:get', () => read());
179
+ ipcMain.handle('opsRetention:set', (_e, patch) => write(patch));
180
+ ipcMain.handle('opsRetention:preview', () => preview());
181
+ }
182
+
183
+ module.exports = { storePath, read, readStored, write, validatePatch, preview, registerIpc, DEFAULTS, BOUNDS };
@@ -93,6 +93,61 @@ function refineInputs(value, ctx) {
93
93
  }
94
94
  }
95
95
 
96
+ /** Distinct `{{name}}` placeholder names in first-seen order. */
97
+ function macroPlaceholders(prompt) {
98
+ const names = [];
99
+ for (const m of String(prompt).matchAll(new RegExp(MACRO_PLACEHOLDER_RE.source, 'g'))) {
100
+ if (!names.includes(m[1])) names.push(m[1]);
101
+ }
102
+ return names;
103
+ }
104
+
105
+ /** `{ [name]: default ?? '' }` for every declared input. */
106
+ function defaultMacroValues(inputs) {
107
+ const out = {};
108
+ for (const i of inputs || []) out[i.name] = i.default ?? '';
109
+ return out;
110
+ }
111
+
112
+ function sanitizeMacroValue(v) {
113
+ // eslint-disable-next-line no-control-regex
114
+ return v.replace(/[\r\n\t]+/g, ' ').replace(/[\u0000-\u001f\u007f]/g, '').trim();
115
+ }
116
+
117
+ const MACRO_PROMPT_RENDERED_MAX_LEN = 8000;
118
+
119
+ /**
120
+ * Mirrors `src/renderer/lib/macroPrompt.ts` renderMacroPrompt, but strict for macros
121
+ * without inputs: a placeholder with no declared input is always an error.
122
+ * @returns {{ ok: true, prompt: string } | { ok: false, error: string }}
123
+ */
124
+ function renderMacroPrompt(prompt, inputs, values) {
125
+ const vals = values && typeof values === 'object' ? values : {};
126
+ const clean = Object.create(null);
127
+ for (const input of inputs || []) {
128
+ const own = Object.prototype.hasOwnProperty.call(vals, input.name) && typeof vals[input.name] === 'string';
129
+ const v = sanitizeMacroValue(own ? vals[input.name] : (input.default ?? ''));
130
+ if (v === '') {
131
+ if (input.required) return { ok: false, error: `"${input.label}" is required` };
132
+ } else if (v.length > MACRO_INPUT_MAX_LEN) {
133
+ return { ok: false, error: `"${input.label}" is longer than ${MACRO_INPUT_MAX_LEN} characters` };
134
+ } else if (input.options && input.options.length > 0 && !input.options.includes(v)) {
135
+ return { ok: false, error: `"${input.label}" must be one of: ${input.options.join(', ')}` };
136
+ }
137
+ clean[input.name] = v;
138
+ }
139
+
140
+ for (const name of macroPlaceholders(prompt)) {
141
+ if (!(name in clean)) return { ok: false, error: `prompt uses undeclared input {{${name}}}` };
142
+ }
143
+
144
+ const rendered = String(prompt).replace(new RegExp(MACRO_PLACEHOLDER_RE.source, 'g'), (_m, n) => clean[n]);
145
+ if (rendered.length > MACRO_PROMPT_RENDERED_MAX_LEN) {
146
+ return { ok: false, error: `rendered prompt is longer than ${MACRO_PROMPT_RENDERED_MAX_LEN} characters` };
147
+ }
148
+ return { ok: true, prompt: rendered };
149
+ }
150
+
96
151
  const MacroSchema = z.object({
97
152
  id: MacroIdSchema,
98
153
  label: MacroLabelSchema,
@@ -151,6 +206,12 @@ function normalizeCwd(cwd) {
151
206
  return collapsed.replace(/\/+$/, '');
152
207
  }
153
208
 
209
+ /** True iff the macro is shown for `cwd`: its projects include '*' or the normalized cwd. */
210
+ function macroVisibleIn(macro, cwd) {
211
+ const projects = (macro && macro.projects) || [];
212
+ return projects.includes(ALL_PROJECTS) || projects.includes(normalizeCwd(cwd));
213
+ }
214
+
154
215
  function normalizeProjects(projects) {
155
216
  const out = [];
156
217
  for (const p of projects || []) {
@@ -414,6 +475,14 @@ async function loadForWrite(filePath, opts) {
414
475
  * Create (no id) or update (id) a macro. Returns the saved Macro.
415
476
  * Throws on invalid input or unknown id.
416
477
  */
478
+ /** Merged-macro check: every `{{name}}` in the prompt must be a declared input. */
479
+ function assertPlaceholdersDeclared(macro) {
480
+ const declared = new Set((macro.inputs || []).map((i) => i.name));
481
+ for (const name of macroPlaceholders(macro.prompt)) {
482
+ if (!declared.has(name)) throw new Error(`prompt placeholder {{${name}}} is not declared in inputs`);
483
+ }
484
+ }
485
+
417
486
  async function saveMacro(input, opts = {}) {
418
487
  const parsed = MacroSaveSchema.parse(input);
419
488
  const filePath = resolvePath(opts);
@@ -442,6 +511,7 @@ async function saveMacro(input, opts = {}) {
442
511
  } else {
443
512
  saved.inputs = parsed.inputs;
444
513
  }
514
+ assertPlaceholdersDeclared(saved);
445
515
  macros[idx] = saved;
446
516
  } else {
447
517
  const projects = parsed.projects !== undefined ? normalizeProjects(parsed.projects) : [];
@@ -457,6 +527,7 @@ async function saveMacro(input, opts = {}) {
457
527
  updatedAt: now,
458
528
  };
459
529
  if (parsed.inputs && parsed.inputs.length > 0) saved.inputs = parsed.inputs;
530
+ assertPlaceholdersDeclared(saved);
460
531
  macros.push(saved);
461
532
  }
462
533
  await writeStore(filePath, macros);
@@ -518,6 +589,10 @@ module.exports = {
518
589
  MacroSaveSchema,
519
590
  MacroDeleteSchema,
520
591
  MacroSetProjectSchema,
592
+ macroPlaceholders,
593
+ defaultMacroValues,
594
+ renderMacroPrompt,
595
+ macroVisibleIn,
521
596
  listMacros,
522
597
  saveMacro,
523
598
  deleteMacro,
@@ -29,6 +29,7 @@ const { resolveProjectContext } = require('../projectRootResolve.cjs');
29
29
  const { fixChainDepthOf, baseSlugOf } = require('../scheduler/fixChainDepth.cjs');
30
30
  const { DEFAULT_PRD_AGENT_TYPE, assertAgentTypeWritable } = require('./prdAgentType.cjs');
31
31
  const { resolveDepSlug, findNearMatches } = require('./depSlugResolve.cjs');
32
+ const { resolveEpicPrdWriteDir } = require('./prdLocations.cjs');
32
33
  const { isFixPlanSlug } = require('../scheduler/fixPlanSlug.cjs');
33
34
  const { isIncomplete, resolveChainTerminals, isValidPlanId, mintPlanId, resolveInheritedPlanId } = require('./prdDisposition.cjs');
34
35
  const { sizingWarnings } = require('./prdSizing.cjs');
@@ -443,6 +444,30 @@ async function createPrd(input, remote) {
443
444
  }
444
445
  extraWarnings.push(...await worktreeReachAdvisory(input, cwd));
445
446
 
447
+ // Duplicate-slug guard (2110/2111 phone-drop-summary-state: duplicate re-issue after archive).
448
+ // Finished rows leave queue.json, so an author can re-issue a landed PRD under a fresh NN.
449
+ // Refused before NN allocation so it burns no number. Fix plans are exempt.
450
+ if (input.sourcePromptId && !isFixPlanSlug(`0-${slug}`)) {
451
+ let epicDirs = [];
452
+ try {
453
+ const prdsDir = resolveEpicPrdWriteDir(cwd, input.sourcePromptId);
454
+ epicDirs = [prdsDir, path.join(path.dirname(prdsDir), 'prds-archived')];
455
+ } catch { /* unsafe epic id — no dirs to scan */ }
456
+ for (const dir of epicDirs) {
457
+ let names = [];
458
+ try { names = await fsp.readdir(dir); } catch { /* missing dir counts as empty */ }
459
+ const dup = names.find((n) => n.endsWith('.md') && n.slice(0, -3).replace(/^\d+-/, '') === slug);
460
+ if (dup) {
461
+ return {
462
+ ok: false,
463
+ status: 409,
464
+ error: `A PRD with slug "${slug}" already exists in this Epic as "${dup}". Its work may already have landed. ` +
465
+ 'Use scheduler_update_prd / scheduler_reset_job on it, or pick a distinct slug.',
466
+ };
467
+ }
468
+ }
469
+ }
470
+
446
471
  const nn = await remote.allocateParallelGroup(input.cwd);
447
472
  const filenameSlug = `${nn}-${slug}`;
448
473
 
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Incident: burrow PRD 1516-fb-bilko-first-pumpkin named a gitignored runtime path by RELATIVE
5
5
  * path (downloads/fb-tracker.db) and an install-cron.sh that baked a worktree path. Scheduled
6
- * PRDs run in an isolated git job worktree holding only tracked files plus symlinked dependency
7
- * dirs (DEFAULT_DEP_DIRS), so that path pointed at a file that did not exist there and the job
6
+ * PRDs run in an isolated git job worktree holding only tracked files plus symlinked or (pnpm) worktree-local
7
+ * dependency dirs (DEFAULT_DEP_DIRS), so that path pointed at a file that did not exist there and the job
8
8
  * parked needs_human.
9
9
  *
10
10
  * Two exports: `extractRepoPathCandidates(text)` pulls repo-relative path-looking tokens out of
@@ -27,6 +27,8 @@ const WORK_TYPES = Object.freeze([
27
27
  'discussion',
28
28
  'build',
29
29
  'project-home-builder',
30
+ 'art',
31
+ 'portfolio',
30
32
  ]);
31
33
 
32
34
  const WorkTypeSchema = z.enum(WORK_TYPES);
@@ -37,17 +39,19 @@ const WorkTypeSchema = z.enum(WORK_TYPES);
37
39
  // legacy-drop preprocessing.
38
40
  const LEGACY_WORK_TYPES = Object.freeze(['bilko-host-publisher']);
39
41
 
40
- // A 'discussion'-tagged ticket never reaches PRD authoring (a domain
42
+ // 'discussion' and 'portfolio' tickets never reach PRD authoring (a domain
41
43
  // invariant unrelated to the Epic/PRD tag unification above), so the PRD
42
44
  // frontmatter `tag` field — parsed in prdFrontmatter.cjs/.ts and patched via
43
- // ipcSchemas.cjs's adminPrdFrontmatterPatch — excludes it even though
45
+ // ipcSchemas.cjs's adminPrdFrontmatterPatch — excludes them even though
44
46
  // WorkTypeSchema itself (used at PRD-create time) does not.
45
- const PRD_WORK_TYPES = Object.freeze(WORK_TYPES.filter((t) => t !== 'discussion'));
47
+ const NON_PRD_WORK_TYPES = Object.freeze(['discussion', 'portfolio']);
48
+ const PRD_WORK_TYPES = Object.freeze(WORK_TYPES.filter((t) => !NON_PRD_WORK_TYPES.includes(t)));
46
49
  const PrdWorkTypeSchema = z.enum(PRD_WORK_TYPES);
47
50
 
48
51
  module.exports = {
49
52
  WORK_TYPES,
50
53
  WorkTypeSchema,
54
+ NON_PRD_WORK_TYPES,
51
55
  PRD_WORK_TYPES,
52
56
  PrdWorkTypeSchema,
53
57
  LEGACY_WORK_TYPES,
@@ -0,0 +1,61 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * procGroupRss.cjs — summed resident memory (MB) of every process in a
6
+ * process group, read from /proc (Linux only).
7
+ *
8
+ * Why: the scheduler's memory gate double-counted running jobs (2026-10-18).
9
+ * Each job reserves 2500 MB, but part of that is already consumed and thus
10
+ * already missing from MemAvailable; subtracting the full reservation again
11
+ * counts it twice. Scheduler jobs are spawned `detached: true`, so a job's
12
+ * pid leads its own process group and its tsc/vite/node children share that
13
+ * pgid — summing the group's RSS tells the gate how much is already used.
14
+ *
15
+ * Parsing: /proc/<pid>/stat is `pid (comm) state ppid pgrp ... rss ...` and
16
+ * comm may contain spaces and parentheses, so split only after the LAST ')'.
17
+ * In the remainder (0-based): state=0, ppid=1, pgrp=2, rss=21 (pages).
18
+ */
19
+
20
+ const fs = require('node:fs');
21
+
22
+ const PGRP_IDX = 2;
23
+ const RSS_IDX = 21;
24
+
25
+ /**
26
+ * @param {number} pgid
27
+ * @param {{ procRoot?: string, pageSizeKb?: number }} [opts]
28
+ * @returns {number} integer MB (floor); 0 on any non-applicable/failed case
29
+ */
30
+ function processGroupRssMb(pgid, opts = {}) {
31
+ if (!Number.isInteger(pgid) || pgid <= 0) return 0;
32
+ if (process.platform !== 'linux') return 0;
33
+ const procRoot = opts.procRoot ?? '/proc';
34
+ const pageSizeKb = opts.pageSizeKb ?? 4;
35
+
36
+ let names;
37
+ try {
38
+ names = fs.readdirSync(procRoot);
39
+ } catch {
40
+ return 0;
41
+ }
42
+
43
+ let totalKb = 0;
44
+ for (const name of names) {
45
+ if (!/^\d+$/.test(name)) continue;
46
+ try {
47
+ const stat = fs.readFileSync(`${procRoot}/${name}/stat`, 'utf8');
48
+ const close = stat.lastIndexOf(')');
49
+ if (close < 0) continue;
50
+ const fields = stat.slice(close + 1).trim().split(/\s+/);
51
+ if (Number(fields[PGRP_IDX]) !== pgid) continue;
52
+ const rssPages = Number(fields[RSS_IDX]);
53
+ if (Number.isFinite(rssPages) && rssPages > 0) totalKb += rssPages * pageSizeKb;
54
+ } catch {
55
+ // vanished or unreadable entry — skip it
56
+ }
57
+ }
58
+ return Math.floor(totalKb / 1024);
59
+ }
60
+
61
+ module.exports = { processGroupRssMb };