claude-code-session-manager 0.40.3 → 0.41.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 (50) hide show
  1. package/dist/assets/{TiptapBody-DweEtHks.js → TiptapBody-BnRle0iw.js} +1 -1
  2. package/dist/assets/{index-BxVBtmjA.css → index-CKY3mHgV.css} +1 -1
  3. package/dist/assets/{index-QRU8uTeq.js → index-H7rwsoKV.js} +631 -624
  4. package/dist/index.html +2 -2
  5. package/package.json +3 -1
  6. package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -5
  7. package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
  8. package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +2 -2
  9. package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
  10. package/plugins/session-manager-dev/skills/project-status/SKILL.md +20 -34
  11. package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +68 -0
  12. package/scripts/lib/watchdogHelpers.cjs +5 -5
  13. package/scripts/mint-epic.cjs +28 -0
  14. package/scripts/propose-epic.cjs +55 -0
  15. package/src/main/__tests__/epicMint.test.cjs +85 -0
  16. package/src/main/__tests__/prdCreate.test.cjs +54 -2
  17. package/src/main/__tests__/promptSessionEvents.test.cjs +56 -2
  18. package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
  19. package/src/main/__tests__/rcaFeedbackHook.test.cjs +43 -211
  20. package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +63 -0
  21. package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +86 -0
  22. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +21 -0
  23. package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +78 -0
  24. package/src/main/browserView.cjs +1 -1
  25. package/src/main/chatRunner.cjs +2 -46
  26. package/src/main/config.cjs +12 -6
  27. package/src/main/docEdit.cjs +3 -4
  28. package/src/main/index.cjs +12 -0
  29. package/src/main/ipcSchemas.cjs +51 -11
  30. package/src/main/lib/__tests__/opsOwnership.test.cjs +92 -0
  31. package/src/main/lib/__tests__/projectBriefCore.test.cjs +74 -0
  32. package/src/main/lib/epicMint.cjs +133 -59
  33. package/src/main/lib/opsOwnership.cjs +166 -0
  34. package/src/main/lib/prdCreate.cjs +9 -1
  35. package/src/main/lib/prdLocations.cjs +21 -0
  36. package/src/main/lib/projectBriefCore.cjs +70 -4
  37. package/src/main/lib/queueStore.cjs +3 -0
  38. package/src/main/lib/rcaFeedbackHook.cjs +41 -55
  39. package/src/main/projectBrief.cjs +36 -2
  40. package/src/main/promptSessionEvents.cjs +24 -2
  41. package/src/main/promptSessionTranscript.cjs +0 -0
  42. package/src/main/queueOps.cjs +1 -1
  43. package/src/main/scheduler/prdParser.cjs +8 -0
  44. package/src/main/scheduler.cjs +158 -62
  45. package/src/main/templates/PRD_AUTHORING.md +1 -1
  46. package/src/preload/api.d.ts +76 -3
  47. package/src/preload/index.cjs +21 -3
  48. package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +0 -140
  49. package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +0 -290
  50. package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +0 -265
@@ -28,6 +28,7 @@ const fs = require('node:fs');
28
28
  const os = require('node:os');
29
29
  const path = require('node:path');
30
30
  const config = require('../config.cjs');
31
+ const { ensureEpic } = require('./epicMint.cjs');
31
32
  const { splitFrontmatter } = require('./prdFrontmatter.cjs');
32
33
  const { readTail } = require('./fileTail.cjs');
33
34
  const { resolvePrdWriteDir } = require('./prdLocations.cjs');
@@ -35,7 +36,6 @@ const { resolvePrdWriteDir } = require('./prdLocations.cjs');
35
36
  // The one project this hook always knows how to reach, used as the fallback
36
37
  // destination when a job's own cwd has no feedback inbox.
37
38
  const SM_REPO_ROOT = path.join(os.homedir(), 'Projects', 'session-manager');
38
- const SM_REPO_FEEDBACK_DIR = path.join(SM_REPO_ROOT, 'session-manager-operations', 'feedback');
39
39
 
40
40
  // NEVER write here: ~/.claude/session-manager/feedback/ is the scheduler's
41
41
  // auto-PRD intake (src/main/scheduler.cjs's periodic feedback sweep) — it
@@ -200,27 +200,31 @@ function extractAcceptanceCriteria(prdBody) {
200
200
  // ─── Destination resolution ──────────────────────────────────────────────────
201
201
 
202
202
  /**
203
- * Resolve where an RCA for `job` should be filed.
204
- * 1. <job.cwd>/session-manager-operations/feedback/ if it exists.
205
- * 2. Else session-manager's own repo inbox, naming the target project.
203
+ * Resolve which PROJECT the proposal is filed into.
204
+ * 1. the job's own cwd — Epics live in every project, so this is almost
205
+ * always the answer.
206
+ * 2. else session-manager's own repo, naming the target project in the body.
207
+ *
208
+ * The old version probed for a `session-manager-operations/feedback/`
209
+ * directory and fell back when it was missing; with proposals there is
210
+ * nothing to probe for — the Epic store is created on demand.
206
211
  */
207
212
  function resolveDestination(job) {
208
213
  const jobCwd = job?.cwd;
209
214
  if (jobCwd) {
210
- const candidate = path.join(jobCwd, 'session-manager-operations', 'feedback');
211
- try {
212
- if (fs.statSync(candidate).isDirectory()) {
213
- return { dir: candidate, allowlistRoot: jobCwd, targetProjectNote: null };
214
- }
215
- } catch {
216
- // missing/inaccessible — fall through to the session-manager fallback
217
- }
215
+ return { cwd: jobCwd, allowlistRoot: jobCwd, targetProjectNote: null };
218
216
  }
219
- const targetProjectNote = jobCwd
220
- ? `Filed into session-manager's own feedback inbox because \`${jobCwd}\` has no ` +
221
- `\`session-manager-operations/feedback/\` directory. Target project: \`${jobCwd}\`.`
222
- : 'Filed into session-manager\'s own feedback inbox (job had no cwd on record).';
223
- return { dir: SM_REPO_FEEDBACK_DIR, allowlistRoot: SM_REPO_ROOT, targetProjectNote };
217
+ return {
218
+ cwd: SM_REPO_ROOT,
219
+ allowlistRoot: SM_REPO_ROOT,
220
+ targetProjectNote: "Filed into session-manager's own project (job had no cwd on record).",
221
+ };
222
+ }
223
+
224
+ /** One-line title for the proposal — goalText is what the Epics list shows,
225
+ * so it must stay short; the full RCA markdown rides in openingPrompt. */
226
+ function rcaProposalTitle(job, verdict) {
227
+ return `PRD ${job?.slug ?? 'unknown'} needs review: ${humanVerdict(verdict)}`;
224
228
  }
225
229
 
226
230
  function shortRunId(runId) {
@@ -228,10 +232,6 @@ function shortRunId(runId) {
228
232
  return cleaned.slice(0, 12) || 'norun';
229
233
  }
230
234
 
231
- function rcaFileName(dateStr, slug, runId) {
232
- return `${dateStr}-rca-${slug}-${shortRunId(runId)}.md`;
233
- }
234
-
235
235
  function lastNLines(text, n) {
236
236
  const lines = (text || '').split('\n');
237
237
  return lines.slice(-n).join('\n');
@@ -328,35 +328,6 @@ async function fileRcaFeedback({ job, runDir, verdict, annotations, investigatio
328
328
  }
329
329
 
330
330
  const dest = resolveDestination(job);
331
- const dateStr = (now instanceof Date ? now : new Date()).toISOString().slice(0, 10);
332
- const fileName = rcaFileName(dateStr, job.slug, job.runId);
333
- const destPath = path.resolve(path.join(dest.dir, fileName));
334
- if (!destPath.startsWith(path.resolve(dest.dir) + path.sep)) {
335
- console.log(`[rca] skip: destPath escaped dest dir (${destPath})`);
336
- return { filed: false, reason: 'unsafe-path' };
337
- }
338
- // /process-feedback archives dispositioned items to <dest.dir>/processed/
339
- // immediately at disposition time (its own README convention) — long
340
- // before this hook's own run-verify/self-heal passes might touch the same
341
- // (slug, runId) again. Once archived, the live-dir check alone goes false
342
- // and a re-trigger would refile a duplicate straight into the live inbox.
343
- const processedPath = path.resolve(path.join(dest.dir, 'processed', fileName));
344
- if (!processedPath.startsWith(path.resolve(path.join(dest.dir, 'processed')) + path.sep)) {
345
- console.log(`[rca] skip: processedPath escaped processed dir (${processedPath})`);
346
- return { filed: false, reason: 'unsafe-path' };
347
- }
348
-
349
- const liveExists = fs.existsSync(destPath);
350
- const processedExists = !liveExists && fs.existsSync(processedPath);
351
- const alreadyFiled = liveExists || processedExists;
352
- if (alreadyFiled && !investigationText) {
353
- const existingPath = liveExists ? destPath : processedPath;
354
- console.log(`[rca] skip: already filed for ${job.slug}@${job.runId}`);
355
- return { filed: false, reason: 'duplicate', path: existingPath };
356
- }
357
- // An investigationText update must target wherever the file actually
358
- // lives (live dir or processed/), not assume the live dir.
359
- const targetPath = liveExists || !processedExists ? destPath : processedPath;
360
331
 
361
332
  const meta = readRunMeta(runDir, job.slug);
362
333
  const logPath = runDir ? path.join(runDir, `${job.slug}.log`) : null;
@@ -369,11 +340,27 @@ async function fileRcaFeedback({ job, runDir, verdict, annotations, investigatio
369
340
  targetProjectNote: dest.targetProjectNote, annotations,
370
341
  });
371
342
 
343
+ // File it as a PROPOSED Epic rather than a markdown file in a feedback
344
+ // inbox. The proposal lands directly in the Epics workspace behind an
345
+ // Approve & start gate: nothing runs and nothing is spent until a human
346
+ // approves it. This replaced the feedback-folder intake (and the
347
+ // process-feedback triage pass that existed only to read it back out) —
348
+ // the proposal IS the work item, already carrying its own session and
349
+ // PRD directory. reuseByGoal makes a re-trigger for the same failure
350
+ // join the existing proposal instead of duplicating it, which is what
351
+ // the old live/processed existence checks were for.
372
352
  config.addAllowedRoot(dest.allowlistRoot);
373
- await config.writeTextAtomic(targetPath, markdown);
353
+ const title = rcaProposalTitle(job, verdict);
354
+ const { epicId, created } = await ensureEpic(dest.cwd, {
355
+ goalText: title,
356
+ tag: 'bug',
357
+ status: 'proposed',
358
+ reuseByGoal: true,
359
+ openingPrompt: markdown,
360
+ });
374
361
 
375
- console.log(`[rca] ${alreadyFiled ? 'updated (investigation)' : 'filed'} ${targetPath}`);
376
- return { filed: true, path: targetPath, updated: alreadyFiled };
362
+ console.log(`[rca] ${created ? 'proposed' : 'joined'} epic ${epicId} for ${job.slug}`);
363
+ return { filed: true, epicId, created, updated: !created };
377
364
  } catch (e) {
378
365
  console.error('[rca] error filing RCA feedback', e?.message ?? String(e));
379
366
  return { filed: false, reason: 'error', error: e?.message ?? String(e) };
@@ -382,15 +369,14 @@ async function fileRcaFeedback({ job, runDir, verdict, annotations, investigatio
382
369
 
383
370
  module.exports = {
384
371
  fileRcaFeedback,
372
+ rcaProposalTitle,
385
373
  // Pure helpers, exported for unit tests.
386
374
  humanVerdict,
387
375
  classifyFailure,
388
376
  extractAcceptanceCriteria,
389
377
  extractRcaBlock,
390
378
  resolveDestination,
391
- rcaFileName,
392
379
  buildRcaMarkdown,
393
380
  FAILURE_CLASSES,
394
381
  VERDICT_LABELS,
395
- SM_REPO_FEEDBACK_DIR,
396
382
  };
@@ -226,8 +226,22 @@ async function gatherSrcTree(cwd) {
226
226
 
227
227
  const inFlight = new Set();
228
228
 
229
+ /**
230
+ * Register the project root as an allowed WRITE root before persisting
231
+ * brief.json. config.cjs only grants project-root writes to roots added via
232
+ * addAllowedRoot, and until now the sole caller was pty.cjs's spawn — so a
233
+ * project whose tab had no live PTY (dormant/hydrated tab, chat-only Epic)
234
+ * failed the write with "Write outside allowed write boundaries" *after*
235
+ * paying for a full synthesis. The read-side validatePath above has already
236
+ * proven the cwd is inside allowedRoots, so this widens nothing.
237
+ */
238
+ function allowBriefWrites(realCwd) {
239
+ config.addAllowedRoot(realCwd);
240
+ }
241
+
229
242
  async function refresh({ cwd }) {
230
243
  const realCwd = config.validatePath(cwd);
244
+ allowBriefWrites(realCwd);
231
245
 
232
246
  if (inFlight.has(realCwd)) {
233
247
  return { ok: false, error: 'refresh already running' };
@@ -279,9 +293,10 @@ async function refresh({ cwd }) {
279
293
  priorPinned,
280
294
  model: BRIEF_MODEL,
281
295
  nowIso: new Date().toISOString(),
296
+ priorEditedAt: priorBrief ? priorBrief.editedAt : null,
282
297
  });
283
298
 
284
- await config.writeJson(briefPath(realCwd), persisted);
299
+ await config.writeJson(briefPath(realCwd), persisted, { writer: 'project-home' });
285
300
  return { ok: true, brief: persisted };
286
301
  } finally {
287
302
  if (token) sessionSlots.release(token);
@@ -289,12 +304,29 @@ async function refresh({ cwd }) {
289
304
  }
290
305
  }
291
306
 
307
+ /**
308
+ * Hand-edit brief.json in place — the "maintain" half of the generate-and-
309
+ * maintain loop, and the reason the brief is a real file rather than a cache.
310
+ * Zero LLM cost. Edited pinnable blocks are auto-pinned by computeUpdate so
311
+ * the next refresh feeds them back in instead of rewriting them.
312
+ */
313
+ async function update({ cwd, patch }) {
314
+ const realCwd = config.validatePath(cwd);
315
+ allowBriefWrites(realCwd);
316
+ const currentBrief = await readBrief(realCwd);
317
+ const result = core.computeUpdate(currentBrief, patch, new Date().toISOString());
318
+ if (!result.ok) return result;
319
+ await config.writeJson(briefPath(realCwd), result.brief, { writer: 'project-home' });
320
+ return { ok: true, brief: result.brief };
321
+ }
322
+
292
323
  async function setPin({ cwd, block, pinned }) {
293
324
  const realCwd = config.validatePath(cwd);
325
+ allowBriefWrites(realCwd);
294
326
  const currentBrief = await readBrief(realCwd);
295
327
  const result = core.computeSetPin(currentBrief, block, pinned);
296
328
  if (!result.ok) return result;
297
- await config.writeJson(briefPath(realCwd), result.brief);
329
+ await config.writeJson(briefPath(realCwd), result.brief, { writer: 'project-home' });
298
330
  return { ok: true, brief: result.brief };
299
331
  }
300
332
 
@@ -303,6 +335,7 @@ function registerProjectBriefIpc() {
303
335
  ipcMain.handle('project-brief:get', v(s.projectBriefCwd, get));
304
336
  ipcMain.handle('project-brief:refresh', v(s.projectBriefCwd, refresh));
305
337
  ipcMain.handle('project-brief:set-pin', v(s.projectBriefSetPin, setPin));
338
+ ipcMain.handle('project-brief:update', v(s.projectBriefUpdate, update));
306
339
  }
307
340
 
308
341
  module.exports = {
@@ -310,4 +343,5 @@ module.exports = {
310
343
  get,
311
344
  refresh,
312
345
  setPin,
346
+ update,
313
347
  };
@@ -18,6 +18,22 @@ function promptSessionActiveIndexPath(cwd) {
18
18
  return `${String(cwd).replace(/\/+$/, '')}/session-manager-operations/prompt-sessions/active-index.json`;
19
19
  }
20
20
 
21
+ // IPC channel broadcast whenever an event is appended to a PromptSession's
22
+ // chain from the main process (currently only the scheduler's response-event
23
+ // append below). Mirrors chatRunner.cjs's attachWindow/broadcast pattern.
24
+ const EVENT_APPENDED_CHANNEL = 'promptSession:event-appended';
25
+
26
+ let mainWindow = null;
27
+ function attachWindow(win) { mainWindow = win; }
28
+
29
+ function broadcast(channel, payload) {
30
+ try {
31
+ if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents && !mainWindow.webContents.isDestroyed()) {
32
+ mainWindow.webContents.send(channel, payload);
33
+ }
34
+ } catch { /* render frame may be gone */ }
35
+ }
36
+
21
37
  let seq = 0;
22
38
  function mintEventId() {
23
39
  seq += 1;
@@ -75,7 +91,8 @@ async function appendResponseEventIfKnown(cwd, sourcePromptId, text) {
75
91
  text,
76
92
  };
77
93
  data.events[sourcePromptId] = [...events, event];
78
- await config.writeJson(path, data);
94
+ await config.writeJson(path, data, { writer: 'scheduler' });
95
+ broadcast(EVENT_APPENDED_CHANNEL, { cwd, promptSessionId: sourcePromptId, event });
79
96
  return true;
80
97
  });
81
98
  } catch (e) {
@@ -84,4 +101,9 @@ async function appendResponseEventIfKnown(cwd, sourcePromptId, text) {
84
101
  }
85
102
  }
86
103
 
87
- module.exports = { appendResponseEventIfKnown, promptSessionActiveIndexPath };
104
+ module.exports = {
105
+ appendResponseEventIfKnown,
106
+ promptSessionActiveIndexPath,
107
+ attachWindow,
108
+ EVENT_APPENDED_CHANNEL,
109
+ };
@@ -519,7 +519,7 @@ async function retagOne({ slug, parallelGroup, estimateMinutes }) {
519
519
  // Atomic write via shared helper. If slug changed, write at the new path
520
520
  // and unlink the old slug.
521
521
  try {
522
- await config.writeTextAtomic(dst, newRaw);
522
+ await config.writeTextAtomic(dst, newRaw, { writer: 'scheduler' });
523
523
  if (dst !== src) {
524
524
  try { await fsp.unlink(src); } catch { /* if src is already same as dst (race), fine */ }
525
525
  }
@@ -73,6 +73,14 @@ async function parsePrdRaw(filePath) {
73
73
  // dropped straight into an Epic's prds/ dir still get real linkage
74
74
  // instead of null.
75
75
  sourcePromptId: fm.sourcePromptId || deriveEpicIdFromPrdPath(filePath) || null,
76
+ // The Epic this PRD actually belongs to, derived purely from its location
77
+ // (epic dir name == epic id, 1:1 by design — prdLocations.deriveEpicIdFromPrdPath).
78
+ // Deliberately separate from sourcePromptId: that field is *intent* and
79
+ // can be stale or hand-written wrong in frontmatter (real rows in
80
+ // queue.json exist where sourcePromptId and sourceTabId disagree), while
81
+ // this one is *fact* on disk. Every surface that shows "which Epic is this
82
+ // from" reads this, so Scheduler and Epics can't drift apart.
83
+ epicId: deriveEpicIdFromPrdPath(filePath),
76
84
  // Optional traceability back to the chat tab that queued this PRD (PRD
77
85
  // 761) — read back at job completion to route a status prompt via
78
86
  // enqueueExternalPrompt (PRD 753). Additive — absent on every PRD