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
@@ -51,6 +51,13 @@ function isSstHarnessCwd(cwd) {
51
51
  // Transcript reads only need the last line with a `cwd` field — a few dozen
52
52
  // lines is always enough. 64 KB keeps peak RSS proportionate.
53
53
  const TAIL_BYTES = 64 * 1024;
54
+ // A single transcript line (base64 render, big tool result) can exceed TAIL_BYTES
55
+ // and leave the tail with no complete `cwd` row. Only on that miss the tail
56
+ // grows (x16 per step) up to MAX_TAIL_BYTES, then up to MAX_FALLBACK_FILES
57
+ // newest transcripts are tried before the project is given up on.
58
+ const MAX_TAIL_BYTES = 16 * 1024 * 1024;
59
+ const MAX_FALLBACK_FILES = 5;
60
+ const TAIL_GROWTH = 16;
54
61
 
55
62
  // Safety cap on distinct cwds to bound result set size. O(1) extra space.
56
63
  const MAX_CWDS = 50;
@@ -139,6 +146,20 @@ function readTailLines(filePath, maxBytes) {
139
146
  return buf.toString('utf8').split('\n').filter((l) => l.trim());
140
147
  }
141
148
 
149
+ function fileSizeSync(fp) {
150
+ try { return fs.statSync(fp).size; } catch { return 0; }
151
+ }
152
+
153
+ /** lastCwdInLines(lines) → the `cwd` of the last parseable row that has one, or null. */
154
+ function lastCwdInLines(lines) {
155
+ for (let i = lines.length - 1; i >= 0; i--) {
156
+ let row;
157
+ try { row = JSON.parse(lines[i]); } catch { continue; }
158
+ if (row && row.cwd) return row.cwd;
159
+ }
160
+ return null;
161
+ }
162
+
142
163
  /**
143
164
  * scanOneProjectDirSync(projDir) → {rawCwd, newestMtimeMs} | null
144
165
  * Newest *.jsonl's mtime plus its last parseable `cwd`, for one project dir.
@@ -149,28 +170,25 @@ function scanOneProjectDirSync(projDir) {
149
170
  entries = fs.readdirSync(projDir).filter((f) => f.endsWith('.jsonl'));
150
171
  } catch { return null; }
151
172
 
152
- let newestPath = null;
153
- let newestMtimeMs = 0;
173
+ const files = [];
154
174
  for (const tf of entries) {
155
175
  const fp = path.join(projDir, tf);
156
- try {
157
- const st = fs.statSync(fp);
158
- if (st.mtimeMs > newestMtimeMs) {
159
- newestMtimeMs = st.mtimeMs;
160
- newestPath = fp;
161
- }
162
- } catch { continue; }
176
+ try { files.push({ fp, mtimeMs: fs.statSync(fp).mtimeMs }); } catch { continue; }
163
177
  }
164
- if (!newestPath) return null;
178
+ if (!files.length) return null;
179
+ files.sort((x, y) => y.mtimeMs - x.mtimeMs);
180
+ const newestMtimeMs = files[0].mtimeMs;
165
181
 
166
- const tlines = readTailLines(newestPath, TAIL_BYTES);
167
- let rawCwd = null;
168
- for (let i = tlines.length - 1; i >= 0; i--) {
169
- let row;
170
- try { row = JSON.parse(tlines[i]); } catch { continue; }
171
- if (row.cwd) { rawCwd = row.cwd; break; }
182
+ for (const { fp } of files.slice(0, MAX_FALLBACK_FILES)) {
183
+ let size = TAIL_BYTES;
184
+ for (;;) {
185
+ const rawCwd = lastCwdInLines(readTailLines(fp, size));
186
+ if (rawCwd) return { rawCwd, newestMtimeMs };
187
+ if (size >= MAX_TAIL_BYTES || size >= fileSizeSync(fp)) break;
188
+ size = Math.min(size * TAIL_GROWTH, MAX_TAIL_BYTES);
189
+ }
172
190
  }
173
- return rawCwd ? { rawCwd, newestMtimeMs } : null;
191
+ return null;
174
192
  }
175
193
 
176
194
  /**
@@ -262,28 +280,27 @@ async function scanOneProjectDirAsync(projDir) {
262
280
  entries = (await fsp.readdir(projDir)).filter((f) => f.endsWith('.jsonl'));
263
281
  } catch { return null; }
264
282
 
265
- let newestPath = null;
266
- let newestMtimeMs = 0;
283
+ const files = [];
267
284
  for (const tf of entries) {
268
285
  const fp = path.join(projDir, tf);
269
- try {
270
- const st = await fsp.stat(fp);
271
- if (st.mtimeMs > newestMtimeMs) {
272
- newestMtimeMs = st.mtimeMs;
273
- newestPath = fp;
274
- }
275
- } catch { continue; }
286
+ try { files.push({ fp, mtimeMs: (await fsp.stat(fp)).mtimeMs }); } catch { continue; }
276
287
  }
277
- if (!newestPath) return null;
288
+ if (!files.length) return null;
289
+ files.sort((x, y) => y.mtimeMs - x.mtimeMs);
290
+ const newestMtimeMs = files[0].mtimeMs;
278
291
 
279
- const tlines = await readTailLinesAsync(newestPath, TAIL_BYTES);
280
- let rawCwd = null;
281
- for (let i = tlines.length - 1; i >= 0; i--) {
282
- let row;
283
- try { row = JSON.parse(tlines[i]); } catch { continue; }
284
- if (row.cwd) { rawCwd = row.cwd; break; }
292
+ for (const { fp } of files.slice(0, MAX_FALLBACK_FILES)) {
293
+ let size = TAIL_BYTES;
294
+ for (;;) {
295
+ const rawCwd = lastCwdInLines(await readTailLinesAsync(fp, size));
296
+ if (rawCwd) return { rawCwd, newestMtimeMs };
297
+ let fileSize = 0;
298
+ try { fileSize = (await fsp.stat(fp)).size; } catch { /* gone: stop growing */ }
299
+ if (size >= MAX_TAIL_BYTES || size >= fileSize) break;
300
+ size = Math.min(size * TAIL_GROWTH, MAX_TAIL_BYTES);
301
+ }
285
302
  }
286
- return rawCwd ? { rawCwd, newestMtimeMs } : null;
303
+ return null;
287
304
  }
288
305
 
289
306
  /**
@@ -212,10 +212,10 @@ function buildPersonaBodyText(rawBody, personaPath) {
212
212
  * same runaway-file protection.
213
213
  *
214
214
  * @param {{ cwd: string, agentType: string|null, fallbackModel?: string, deps?: object }} opts
215
- * @returns {Promise<{ model: string, systemPrompt: string|null, personaPath: string|null }>}
215
+ * @returns {Promise<{ model: string, systemPrompt: string|null, personaPath: string|null, tools: string[]|null }>}
216
216
  */
217
217
  async function resolvePrdPersonaForSpawn({ cwd, agentType, fallbackModel = FALLBACK_MODEL, deps = {} } = {}) {
218
- const miss = { model: fallbackModel, systemPrompt: null, personaPath: null };
218
+ const miss = { model: fallbackModel, systemPrompt: null, personaPath: null, tools: null };
219
219
  if (!agentType) return miss;
220
220
  try {
221
221
  const getBody = deps.getPersonaBody || require('../../agentLibrary.cjs').getPersonaBody;
@@ -230,7 +230,9 @@ async function resolvePrdPersonaForSpawn({ cwd, agentType, fallbackModel = FALLB
230
230
  const { fm, body } = persona.fm ? persona : splitFrontmatter(persona.text);
231
231
  const model = fm.model && fm.model !== 'inherit' ? fm.model : fallbackModel;
232
232
  const systemPrompt = buildPersonaBodyText(body, persona.path);
233
- return { model, systemPrompt, personaPath: persona.path };
233
+ const parseTools = deps.parseTools || require('../../agentLibrary.cjs').parseTools;
234
+ const list = parseTools(typeof fm.tools === 'string' ? fm.tools : '');
235
+ return { model, systemPrompt, personaPath: persona.path, tools: list.length ? list : null };
234
236
  } catch {
235
237
  return miss;
236
238
  }
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * personaToolScope.cjs — turns a persona's `tools:` list into the claude CLI
5
+ * args that actually ENFORCE it on a headless `claude -p` run.
6
+ *
7
+ * Verified on CLI 2.1.302: under `--dangerously-skip-permissions`,
8
+ * `--allowedTools` restricts nothing and `--tools` only filters built-ins (MCP
9
+ * tools stay). `--agents '<json>' --agent <name>` with a `tools` array enforces
10
+ * exact per-tool scoping, MCP tools included, while the Claude Code harness
11
+ * prompt, CLAUDE.md, `--append-system-prompt` and `--disallowedTools` keep
12
+ * working.
13
+ *
14
+ * Pure: no fs, and no require of scheduler.cjs (cycle) — the caller passes its
15
+ * HEADLESS_DISALLOWED_TOOLS list in.
16
+ */
17
+
18
+ // Always granted to a scoped run: the finish protocol runs /code-review and
19
+ // /security-review (Skill + Agent); MCP tools are deferred, so ToolSearch is
20
+ // needed to reach them; TodoWrite is the run's own planning aid.
21
+ const HEADLESS_TOOL_FLOOR = Object.freeze(['Agent', 'Skill', 'ToolSearch', 'TodoWrite']);
22
+
23
+ const HEADLESS_SCOPE_AGENT_NAME = 'sm-headless-scoped';
24
+
25
+ /**
26
+ * @param {readonly string[]|null|undefined} tools persona `tools:` list
27
+ * @param {{ disallowed?: readonly string[] }} [opts]
28
+ * @returns {{ tools: string[] }|null} null = persona declares no restriction
29
+ */
30
+ function buildPersonaToolScope(tools, { disallowed = [] } = {}) {
31
+ if (!Array.isArray(tools) || tools.length === 0) return null;
32
+ const names = tools
33
+ .filter((t) => typeof t === 'string')
34
+ .map((t) => t.trim())
35
+ .filter(Boolean);
36
+ if (names.length === 0 || names.includes('*')) return null;
37
+ const blocked = new Set(disallowed);
38
+ const out = [];
39
+ for (const name of [...names, ...HEADLESS_TOOL_FLOOR]) {
40
+ if (blocked.has(name) || out.includes(name)) continue;
41
+ out.push(name);
42
+ }
43
+ return { tools: out };
44
+ }
45
+
46
+ /**
47
+ * @param {{ tools: string[] }|null} scope result of buildPersonaToolScope
48
+ * @returns {string[]} argv fragment ([] when unscoped)
49
+ */
50
+ function personaToolScopeArgs(scope) {
51
+ if (!scope) return [];
52
+ const def = {
53
+ description: 'Headless run with a scheduler-enforced tool scope.',
54
+ prompt: 'Tool scope is enforced by the scheduler.',
55
+ tools: scope.tools,
56
+ };
57
+ return [
58
+ '--agents', JSON.stringify({ [HEADLESS_SCOPE_AGENT_NAME]: def }),
59
+ '--agent', HEADLESS_SCOPE_AGENT_NAME,
60
+ ];
61
+ }
62
+
63
+ module.exports = {
64
+ HEADLESS_TOOL_FLOOR,
65
+ HEADLESS_SCOPE_AGENT_NAME,
66
+ buildPersonaToolScope,
67
+ personaToolScopeArgs,
68
+ };
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * archivedTranscript.cjs — gzip sidecar store for the raw Claude JSONL that
5
+ * archived Epic JSONs used to embed as a `transcript` string. The sidecar
6
+ * lives at `prompt-sessions/transcripts-archive/<epicId>.jsonl.gz` (inside the
7
+ * `prompt-sessions` namespace, so the `epics` owner already covers it) and the
8
+ * caller keeps only the small pointer returned by writeSidecar.
9
+ *
10
+ * Electron-free at load (like epicStatusMirror.cjs); registerIpc takes ipcMain.
11
+ */
12
+
13
+ const crypto = require('node:crypto');
14
+ const fs = require('node:fs');
15
+ const zlib = require('node:zlib');
16
+ const { assertOpsWrite, opsPath } = require('../opsOwnership.cjs');
17
+ const atomicFs = require('../util/atomicFs.cjs');
18
+
19
+ const SUBDIR = 'transcripts-archive';
20
+
21
+ function assertSafeEpicId(epicId) {
22
+ if (typeof epicId !== 'string' || !epicId || epicId.includes('..') || /[\\/\0]/.test(epicId)) {
23
+ throw new Error(`archivedTranscript: invalid epicId "${epicId}"`);
24
+ }
25
+ }
26
+
27
+ function sidecarPath(cwd, epicId) {
28
+ assertSafeEpicId(epicId);
29
+ return opsPath(cwd, 'prompt-sessions', SUBDIR, `${epicId}.jsonl.gz`);
30
+ }
31
+
32
+ const sha256Of = (text) => crypto.createHash('sha256').update(text, 'utf8').digest('hex');
33
+
34
+ /**
35
+ * writeSidecar(cwd, epicId, text) -> {path, bytes, gzBytes, sha256}.
36
+ * Idempotent: if the existing sidecar already decompresses to the same text,
37
+ * nothing is rewritten.
38
+ */
39
+ function writeSidecar(cwd, epicId, text, { writer = 'epics' } = {}) {
40
+ if (typeof text !== 'string') throw new Error('archivedTranscript: text must be a string');
41
+ const file = sidecarPath(cwd, epicId);
42
+ assertOpsWrite(file, writer);
43
+ const sha256 = sha256Of(text);
44
+ const bytes = Buffer.byteLength(text, 'utf8');
45
+ const existing = readSidecar(cwd, epicId);
46
+ if (existing !== null && sha256Of(existing) === sha256) {
47
+ return { path: file, bytes, gzBytes: fs.statSync(file).size, sha256 };
48
+ }
49
+ const gz = zlib.gzipSync(Buffer.from(text, 'utf8'));
50
+ atomicFs.writeTextAtomicSync(file, gz);
51
+ return { path: file, bytes, gzBytes: gz.length, sha256 };
52
+ }
53
+
54
+ /** readSidecar(cwd, epicId) -> string | null (null when absent or unreadable). */
55
+ function readSidecar(cwd, epicId) {
56
+ const file = sidecarPath(cwd, epicId);
57
+ try {
58
+ return zlib.gunzipSync(fs.readFileSync(file)).toString('utf8');
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * archiveTranscript({cwd, epicId, claudeSessionId}, {resolvePath}) -> pointer | null.
66
+ * Reads the raw Claude JSONL main-side and writes the sidecar; null when the
67
+ * JSONL is missing or empty (caller falls back to durableTurns).
68
+ */
69
+ function archiveTranscript(args, { resolvePath } = {}) {
70
+ const { cwd, epicId, claudeSessionId } = args || {};
71
+ if (typeof cwd !== 'string' || !require('node:path').isAbsolute(cwd)) {
72
+ throw new Error('archivedTranscript: cwd must be an absolute path');
73
+ }
74
+ assertSafeEpicId(epicId);
75
+ if (typeof claudeSessionId !== 'string' || !claudeSessionId) return null;
76
+ const resolve = resolvePath || ((c, s) => require('../../transcripts.cjs').transcriptPath(c, s));
77
+ let text;
78
+ try {
79
+ text = fs.readFileSync(resolve(cwd, claudeSessionId), 'utf8');
80
+ } catch {
81
+ return null;
82
+ }
83
+ if (!text) return null;
84
+ return writeSidecar(cwd, epicId, text);
85
+ }
86
+
87
+ function registerIpc(ipcMain, deps) {
88
+ ipcMain.handle('promptSessions:archiveTranscript', async (_e, payload) => archiveTranscript(payload, deps));
89
+ }
90
+
91
+ module.exports = { writeSidecar, readSidecar, sidecarPath, archiveTranscript, registerIpc };
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * archivedTranscriptMigration.cjs — one-shot, idempotent, crash-safe move of the
5
+ * embedded `transcript` string in archived Epic JSONs into the gzip sidecar
6
+ * (archivedTranscript.cjs). Per file: write sidecar -> re-read + compare sha ->
7
+ * only then atomically rewrite the JSON with `transcriptRef` and no `transcript`.
8
+ * A crash or failure before the rewrite leaves the original JSON byte-identical.
9
+ *
10
+ * Never touches `active-index.json` or files whose status is 'active'.
11
+ * Writes as the `epics` owner (the sidecar lives inside `prompt-sessions`).
12
+ */
13
+
14
+ const fs = require('node:fs');
15
+ const path = require('node:path');
16
+ const { assertOpsWrite, opsPath } = require('../opsOwnership.cjs');
17
+ const atomicFs = require('../util/atomicFs.cjs');
18
+ const archivedTranscript = require('./archivedTranscript.cjs');
19
+
20
+ const SKIP_FILES = new Set(['active-index.json']);
21
+
22
+ /**
23
+ * migrateProject(projectRoot, {dryRun, limit, yieldFn, deps})
24
+ * -> Promise<{scanned, migrated, skipped, freedBytes, errors}>
25
+ * `limit` caps files migrated (or failed) per call; dryRun counts what would
26
+ * migrate without writing. `deps` lets tests inject the sidecar functions.
27
+ */
28
+ async function migrateProject(projectRoot, { dryRun = false, limit = Infinity, yieldFn = null, deps = {} } = {}) {
29
+ const writeSidecar = deps.writeSidecar || archivedTranscript.writeSidecar;
30
+ const readSidecar = deps.readSidecar || archivedTranscript.readSidecar;
31
+ const result = { scanned: 0, migrated: 0, skipped: 0, freedBytes: 0, errors: [] };
32
+ const dir = opsPath(projectRoot, 'prompt-sessions');
33
+ let names;
34
+ try {
35
+ names = fs.readdirSync(dir);
36
+ } catch {
37
+ return result;
38
+ }
39
+ let attempted = 0;
40
+ for (const name of names) {
41
+ if (!name.endsWith('.json') || SKIP_FILES.has(name)) continue;
42
+ if (attempted >= limit) break;
43
+ result.scanned += 1;
44
+ const file = path.join(dir, name);
45
+ try {
46
+ const raw = fs.readFileSync(file, 'utf8');
47
+ // Cheap pre-check so files without a transcript are never JSON-parsed.
48
+ if (!raw.includes('"transcript"')) { result.skipped += 1; continue; }
49
+ const doc = JSON.parse(raw);
50
+ if (!doc || typeof doc !== 'object' || typeof doc.transcript !== 'string' || doc.status === 'active') {
51
+ result.skipped += 1;
52
+ continue;
53
+ }
54
+ attempted += 1;
55
+ const epicId = name.slice(0, -'.json'.length);
56
+ if (dryRun) {
57
+ result.migrated += 1;
58
+ result.freedBytes += Buffer.byteLength(doc.transcript, 'utf8');
59
+ continue;
60
+ }
61
+ assertOpsWrite(file, 'epics');
62
+ const ref = writeSidecar(projectRoot, epicId, doc.transcript);
63
+ const back = readSidecar(projectRoot, epicId);
64
+ if (typeof back !== 'string' || archivedTranscriptSha(back) !== ref.sha256) {
65
+ throw new Error('sidecar sha mismatch');
66
+ }
67
+ const { transcript: _drop, ...rest } = doc;
68
+ const next = { ...rest, transcriptRef: { path: ref.path, bytes: ref.bytes, gzBytes: ref.gzBytes, sha256: ref.sha256 } };
69
+ atomicFs.writeJsonAtomicSync(file, next);
70
+ result.migrated += 1;
71
+ result.freedBytes += Math.max(0, Buffer.byteLength(raw, 'utf8') - fs.statSync(file).size);
72
+ } catch (e) {
73
+ result.errors.push({ file: name, error: e && e.message ? e.message : String(e) });
74
+ }
75
+ if (yieldFn) await yieldFn();
76
+ }
77
+ return result;
78
+ }
79
+
80
+ function archivedTranscriptSha(text) {
81
+ return require('node:crypto').createHash('sha256').update(text, 'utf8').digest('hex');
82
+ }
83
+
84
+ module.exports = { migrateProject };
@@ -671,6 +671,7 @@ function logAutoInstallEvent({ cwd, guard, action, error }) {
671
671
  scope: 'delegationReadiness',
672
672
  level: action === 'error' ? 'error' : 'info',
673
673
  message: `auto-install ${guard}: ${action}`,
674
+ code: `guard-install-${action}:${guard}`,
674
675
  meta: { guard, action, ...(error ? { error } : {}) },
675
676
  });
676
677
  } catch { /* logging must never break the caller */ }
@@ -796,6 +797,7 @@ async function ensureGuardsInstalled(cwd, { homeDir = os.homedir() } = {}) {
796
797
  module.exports = {
797
798
  checkDelegationReadiness,
798
799
  ensureGuardsInstalled,
800
+ logAutoInstallEvent,
799
801
  clearGuardsInstalledCache,
800
802
  installPrdWriteGuard,
801
803
  installDestructiveGitGuard,
@@ -21,6 +21,17 @@ const ts = (at) => {
21
21
  const isValidatorCheckin = (e, bare) =>
22
22
  e.agentType === 'validator' || (e.agentType == null && bare.startsWith('validate-'));
23
23
 
24
+ // Mirrors the scheduler's cancel rule locally (lib/epic stays free of scheduler deps).
25
+ const isCancelledRow = (r) => {
26
+ if (!r || (r.status !== 'failed' && r.status !== 'needs_review')) return false;
27
+ if (typeof r.cancelledAt === 'string' && r.cancelledAt) return true;
28
+ const hist = Array.isArray(r.statusHistory) ? r.statusHistory : [];
29
+ for (let i = hist.length - 1; i >= 0; i--) {
30
+ if (hist[i] && hist[i].to === r.status) return hist[i].source === 'remote:cancelJob';
31
+ }
32
+ return false;
33
+ };
34
+
24
35
  /**
25
36
  * @param {{ epicId: string, cwd?: string|null, events?: any[], jobs?: any[], now?: number, horizonMs?: number }} args
26
37
  */
@@ -34,8 +45,13 @@ function evaluateEpicSettle({ epicId, cwd, events, jobs, now, horizonMs } = {})
34
45
  (r) => r && (r.epicId === epicId || r.sourcePromptId === epicId) && (r.cwd == null || cwd == null || r.cwd === cwd),
35
46
  );
36
47
  const settled = !epicRows.some((r) => r.status === 'pending' || r.status === 'running');
48
+ const cancelled = epicRows
49
+ .filter(isCancelledRow)
50
+ .map((r) => String(r.slug))
51
+ .sort();
52
+ const cancelledSet = new Set(cancelled.flatMap((s) => [s, bareSlug(s)]));
37
53
  const parked = epicRows
38
- .filter((r) => r.status === 'needs_review')
54
+ .filter((r) => r.status === 'needs_review' && !isCancelledRow(r))
39
55
  .map((r) => String(r.slug))
40
56
  .sort();
41
57
 
@@ -83,7 +99,7 @@ function evaluateEpicSettle({ epicId, cwd, events, jobs, now, horizonMs } = {})
83
99
  const failed = [];
84
100
  for (const [bare, { event, t }] of checkins) {
85
101
  if (event.outcome === 'failed') {
86
- failed.push(event.prdSlug);
102
+ if (!cancelledSet.has(event.prdSlug) && !cancelledSet.has(bare)) failed.push(event.prdSlug);
87
103
  continue;
88
104
  }
89
105
  const v = verdicts.get(bare);
@@ -94,12 +110,12 @@ function evaluateEpicSettle({ epicId, cwd, events, jobs, now, horizonMs } = {})
94
110
  failed.sort();
95
111
 
96
112
  const digestKey = createHash('sha1')
97
- .update(JSON.stringify({ verified, refuted, failed, parked, uncovered: uncovered.map((u) => u.slug) }))
113
+ .update(JSON.stringify({ verified, refuted, failed, parked, cancelled, uncovered: uncovered.map((u) => u.slug) }))
98
114
  .digest('hex')
99
115
  .slice(0, 16);
100
- const hasContent = [uncovered, verified, refuted, failed, parked].some((l) => l.length > 0);
116
+ const hasContent = [uncovered, verified, refuted, failed, parked, cancelled].some((l) => l.length > 0);
101
117
 
102
- return { settled, uncovered, verified, refuted, failed, parked, digestKey, hasContent };
118
+ return { settled, uncovered, verified, refuted, failed, parked, cancelled, digestKey, hasContent };
103
119
  }
104
120
 
105
121
  module.exports = { evaluateEpicSettle, DEFAULT_SETTLE_HORIZON_MS };
@@ -52,6 +52,7 @@ function buildSettledDigest(settle, { epicId, notValidatedReason } = {}) {
52
52
  const refuted = s.refuted || [];
53
53
  const failed = s.failed || [];
54
54
  const parked = s.parked || [];
55
+ const cancelled = s.cancelled || [];
55
56
  const notValidated = (s.uncovered || []).map((u) => (typeof u === 'string' ? u : u.slug));
56
57
  const lines = [
57
58
  `PLAN SETTLED — no PRD of this session is pending or running. This is ONE digest, not a per-PRD request.`,
@@ -70,6 +71,12 @@ function buildSettledDigest(settle, { epicId, notValidatedReason } = {}) {
70
71
  if (parked.length) {
71
72
  lines.push('', `Parked (needs_review) (${parked.length}):`, ...parked.map((p) => `- ${p}`));
72
73
  }
74
+ if (cancelled.length) {
75
+ lines.push(
76
+ '', `Cancelled (${cancelled.length}) — stopped by scheduler_cancel_job; only scheduler_reset_job revives them:`,
77
+ ...cancelled.map((c) => `- ${c}`),
78
+ );
79
+ }
73
80
  if (notValidated.length) {
74
81
  lines.push(
75
82
  '', `Not validated (${notValidated.length}) — ${notValidatedReason || 'no validator checked them'}:`,
@@ -0,0 +1,161 @@
1
+ /**
2
+ * depHealth.cjs — detect and repair a broken pnpm node_modules in the main checkout.
3
+ *
4
+ * Incident: a `pnpm install` run through a symlinked worktree node_modules purged
5
+ * the main checkout's modules dir, so every later gate went red with
6
+ * `Cannot find module .../node_modules/typescript/bin/tsc`. Recovery is
7
+ * `pnpm install --offline` in the main checkout; it needs CI=1 plus
8
+ * `--config.confirmModulesPurge=false`, otherwise pnpm prompts and aborts with
9
+ * ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY.
10
+ *
11
+ * Second incident (Bilko, 2026-10-18): the same install rewrote 37 workspace links
12
+ * in the main checkout's node_modules to point into a job worktree; once the
13
+ * worktree was deleted they all dangled. Workspace packages are not in the root
14
+ * package.json deps, so the deps check missed it. findWorktreeEscapingLinks flags
15
+ * top-level links that resolve into a worktree root or dangle.
16
+ *
17
+ * Neither function throws. The check runs on the scheduler main loop, so it is
18
+ * O(deps) stats plus one bounded top-level readdir (no deep walks).
19
+ */
20
+ 'use strict';
21
+
22
+ const fs = require('node:fs');
23
+ const path = require('node:path');
24
+ const { execFile } = require('node:child_process');
25
+
26
+ const MAX_DEPS = 200;
27
+ const MAX_MISSING = 20;
28
+ const TAIL_CHARS = 2000;
29
+ const MAX_SCANNED = 2000;
30
+
31
+ function defaultWorktreeRoots() {
32
+ try {
33
+ const sp = require('../scheduler/schedulerPaths.cjs');
34
+ return [sp.worktreeRoot('job'), sp.worktreeRoot('epic')].filter((r) => typeof r === 'string' && r);
35
+ } catch {
36
+ return [];
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Scan node_modules/* and node_modules/@scope/* (lstat only, no deeper) for symlinks
42
+ * that resolve into a worktree root or whose target is gone.
43
+ * @param {string} mainCwd
44
+ * @param {{ worktreeRoots?: string[] }} [opts]
45
+ * @returns {{ intoWorktree: string[], dangling: string[] }}
46
+ */
47
+ function findWorktreeEscapingLinks(mainCwd, opts = {}) {
48
+ const intoWorktree = [];
49
+ const dangling = [];
50
+ try {
51
+ const rawRoots = Array.isArray(opts.worktreeRoots) ? opts.worktreeRoots : defaultWorktreeRoots();
52
+ const roots = [];
53
+ for (const r of rawRoots) {
54
+ if (!r) continue;
55
+ roots.push(path.resolve(r));
56
+ try { roots.push(fs.realpathSync(r)); } catch { /* root absent */ }
57
+ }
58
+ const modules = path.join(mainCwd, 'node_modules');
59
+ let scanned = 0;
60
+ const check = (rel) => {
61
+ const full = path.join(modules, rel);
62
+ let target;
63
+ try {
64
+ target = fs.realpathSync(full);
65
+ } catch (e) {
66
+ if (dangling.length < MAX_MISSING) dangling.push(rel);
67
+ return;
68
+ }
69
+ if (roots.some((r) => target.startsWith(r + path.sep)) && intoWorktree.length < MAX_MISSING) {
70
+ intoWorktree.push(rel);
71
+ }
72
+ };
73
+ const entries = fs.readdirSync(modules, { withFileTypes: true });
74
+ outer: for (const ent of entries) {
75
+ if (ent.name.startsWith('.')) continue;
76
+ if (++scanned > MAX_SCANNED) break;
77
+ if (ent.name.startsWith('@')) {
78
+ let subs = [];
79
+ try { subs = fs.readdirSync(path.join(modules, ent.name), { withFileTypes: true }); } catch { continue; }
80
+ for (const sub of subs) {
81
+ if (sub.name.startsWith('.')) continue;
82
+ if (++scanned > MAX_SCANNED) break outer;
83
+ if (sub.isSymbolicLink()) check(`${ent.name}/${sub.name}`);
84
+ }
85
+ } else if (ent.isSymbolicLink()) {
86
+ check(ent.name);
87
+ }
88
+ }
89
+ } catch {
90
+ return { intoWorktree: [], dangling: [] };
91
+ }
92
+ return { intoWorktree, dangling };
93
+ }
94
+
95
+ /**
96
+ * @param {string} mainCwd
97
+ * @param {{ worktreeRoots?: string[] }} [opts]
98
+ * @returns {{ ok: boolean, skipped?: string, reason?: string, missing?: string[] }}
99
+ */
100
+ function checkMainDependencyHealth(mainCwd, opts = {}) {
101
+ try {
102
+ if (!fs.existsSync(path.join(mainCwd, 'pnpm-lock.yaml'))) return { ok: true, skipped: 'not-pnpm' };
103
+ const modules = path.join(mainCwd, 'node_modules');
104
+ let isDir = false;
105
+ try { isDir = fs.statSync(modules).isDirectory(); } catch { /* missing */ }
106
+ if (!isDir) return { ok: false, reason: 'node_modules-missing' };
107
+ if (!fs.existsSync(path.join(modules, '.modules.yaml'))) return { ok: false, reason: 'modules-yaml-missing' };
108
+
109
+ const links = findWorktreeEscapingLinks(mainCwd, { worktreeRoots: opts && opts.worktreeRoots });
110
+ if (links.intoWorktree.length) return { ok: false, reason: 'links-into-worktree', missing: links.intoWorktree };
111
+ if (links.dangling.length) return { ok: false, reason: 'dangling-links', missing: links.dangling };
112
+
113
+ let pkg;
114
+ try {
115
+ pkg = JSON.parse(fs.readFileSync(path.join(mainCwd, 'package.json'), 'utf8'));
116
+ } catch {
117
+ return { ok: true, skipped: 'no-package-json' };
118
+ }
119
+ if (!pkg || typeof pkg !== 'object') return { ok: true, skipped: 'no-package-json' };
120
+ const names = [
121
+ ...Object.keys(pkg.dependencies || {}),
122
+ ...Object.keys(pkg.devDependencies || {}),
123
+ ].slice(0, MAX_DEPS);
124
+ const missing = [];
125
+ for (const name of names) {
126
+ try {
127
+ fs.statSync(path.join(modules, name, 'package.json'));
128
+ } catch {
129
+ if (missing.length < MAX_MISSING) missing.push(name);
130
+ }
131
+ }
132
+ return missing.length ? { ok: false, reason: 'unresolved-deps', missing } : { ok: true };
133
+ } catch {
134
+ return { ok: true, skipped: 'no-package-json' };
135
+ }
136
+ }
137
+
138
+ /**
139
+ * @param {string} mainCwd
140
+ * @returns {Promise<{ ok: boolean, code: number|null, tail: string }>}
141
+ */
142
+ function repairMainDependencies(mainCwd) {
143
+ return new Promise((resolve) => {
144
+ try {
145
+ execFile(
146
+ process.env.SM_PNPM_BIN || 'pnpm',
147
+ ['install', '--offline', '--frozen-lockfile', '--config.confirmModulesPurge=false'],
148
+ { cwd: mainCwd, env: { ...process.env, CI: '1' }, timeout: 300_000, maxBuffer: 16 * 1024 * 1024 },
149
+ (err, stdout, stderr) => {
150
+ const tail = `${stdout || ''}${stderr || ''}`.slice(-TAIL_CHARS) || (err ? String(err.message) : '');
151
+ if (!err) return resolve({ ok: true, code: 0, tail });
152
+ resolve({ ok: false, code: typeof err.code === 'number' ? err.code : null, tail: tail || String(err.message) });
153
+ },
154
+ );
155
+ } catch (e) {
156
+ resolve({ ok: false, code: null, tail: String((e && e.message) || e) });
157
+ }
158
+ });
159
+ }
160
+
161
+ module.exports = { checkMainDependencyHealth, findWorktreeEscapingLinks, repairMainDependencies };