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
@@ -63,6 +63,7 @@ const prdParser = require('./scheduler/prdParser.cjs');
63
63
  const sessionsStore = require('./sessionsStore.cjs');
64
64
  const { enqueueExternalPrompt } = require('./chatRunner.cjs');
65
65
  const { appendResponseEventIfKnown } = require('./promptSessionEvents.cjs');
66
+ const promptSessionTranscript = require('./promptSessionTranscript.cjs');
66
67
  const { verifyRun } = require('./runVerify.cjs');
67
68
  const { latestTerminalOutcomeForSlug, COMPLETED_EQUIVALENT_VERDICTS } = require('./lib/terminalRunOutcome.cjs');
68
69
  const logs = require('./logs.cjs');
@@ -84,9 +85,8 @@ const queueOps = require('./queueOps.cjs');
84
85
  // Plain Node module, no Electron dependency; queuePath/prdsDir defaults already
85
86
  // match ROOT/QUEUE_PATH below since both resolve the same ~/.claude/session-manager
86
87
  // home-dir layout.
87
- const { sweep: sweepFeedback } = require('../../scripts/lib/watchdogHelpers.cjs');
88
- const { resolvePrdsDirs, resolvePrdWriteDir, listEpicPrdDirs } = require('./lib/prdLocations.cjs');
89
- const { ensureEpic, appendPrdCreatedEvent, readActiveIndex } = require('./lib/epicMint.cjs');
88
+ const { resolvePrdsDirs, resolvePrdWriteDir, listEpicPrdDirs, listArchivedPrdDirs } = require('./lib/prdLocations.cjs');
89
+ const { ensureEpic, appendPrdCreatedEvent, removeEpic, readActiveIndex } = require('./lib/epicMint.cjs');
90
90
 
91
91
  // ---------- origin session resolution (PRD 832) ----------
92
92
  // An Epic IS a tagged claude session — job rows carry the originating
@@ -144,6 +144,10 @@ const POST_RESULT_GRACE_MS = 90_000;
144
144
  const POST_RESULT_KILL_MS = 30_000;
145
145
  const RESULT_TAIL_POLL_MS = 5_000;
146
146
  const RESULT_TAIL_BYTES = 8 * 1024;
147
+ // Wider than RESULT_TAIL_BYTES (which only needs the subtype near the
148
+ // boundary) — a real `result` string can be a full paragraph, so
149
+ // extractResultTextFromLog scans more of the tail to find it whole.
150
+ const RESULT_TEXT_TAIL_BYTES = 64 * 1024;
147
151
 
148
152
  // Idle-output watchdog: if the log file mtime stops advancing for this long
149
153
  // while the process is still alive, the agent is hung mid-work (network
@@ -492,23 +496,43 @@ function prdPathForJob(job) {
492
496
  return path.join(prdDirForCwd(job && job.cwd), `${job && job.slug}.md`);
493
497
  }
494
498
 
495
- /** Absolute path to the sibling `prds-archived/<slug>.md` twin of a job's PRD. */
499
+ /**
500
+ * Absolute path to a job's archived-twin `<slug>.md`. Resolves to the first
501
+ * archive dir (across the flat legacy layout and every Epic's own sibling
502
+ * archive — see listArchivedPrdDirs) that actually contains the slug, falling
503
+ * back to the flat `prds-archived/<slug>.md` path when none do (this fallback
504
+ * is only ever used for its string value — logging/note text in
505
+ * prdArchivedSkipResult — never as an existence check).
506
+ */
496
507
  function archivedPrdPathForJob(job) {
497
- return path.join(prdDirForCwd(job && job.cwd), '..', 'prds-archived', `${job && job.slug}.md`);
508
+ const slug = job && job.slug;
509
+ const flatPath = path.join(prdDirForCwd(job && job.cwd), '..', 'prds-archived', `${slug}.md`);
510
+ for (const dir of listArchivedPrdDirs((job && job.cwd) || DEFAULT_PROJECT_CWD)) {
511
+ const candidate = safeSlugPathIn(dir, slug);
512
+ if (candidate && fs.existsSync(candidate)) return candidate;
513
+ }
514
+ return flatPath;
498
515
  }
499
516
 
500
517
  /**
501
- * True if a job's PRD has already been archived (sibling `prds-archived/<slug>.md`
502
- * exists). A queue entry whose PRD moved there is stale — the work already shipped
503
- * — not a genuine missing-PRD failure.
518
+ * True if a job's PRD has already been archived — in the flat legacy
519
+ * `prds-archived/` dir OR in the Epic-scoped sibling archive dir every new
520
+ * PRD actually lands in (archiveCompletedPrd archives into the source PRD's
521
+ * OWN parent's prds-archived/, which for an Epic PRD is inside that Epic).
522
+ * A queue entry whose PRD moved to either is stale — the work already
523
+ * shipped — not a genuine missing-PRD failure.
504
524
  */
505
525
  async function archivedTwinExists(job) {
506
- try {
507
- await fsp.access(archivedPrdPathForJob(job));
508
- return true;
509
- } catch {
510
- return false;
526
+ const slug = job && job.slug;
527
+ for (const dir of listArchivedPrdDirs((job && job.cwd) || DEFAULT_PROJECT_CWD)) {
528
+ const candidate = safeSlugPathIn(dir, slug);
529
+ if (!candidate) continue;
530
+ try {
531
+ await fsp.access(candidate);
532
+ return true;
533
+ } catch { /* not here — try the next archive dir */ }
511
534
  }
535
+ return false;
512
536
  }
513
537
 
514
538
  /**
@@ -1106,9 +1130,13 @@ async function reconcile(state) {
1106
1130
  estimateMinutes: p.estimateMinutes,
1107
1131
  sourcePromptId: reconcileSourcePromptId(job, p.sourcePromptId),
1108
1132
  sourceTabId: p.sourceTabId,
1133
+ // Unlike sourcePromptId (frozen once a row leaves 'pending'), epicId is
1134
+ // refreshed from disk every pass: the PRD's directory IS its Epic
1135
+ // membership, so moving the file between Epic dirs must re-point the row.
1136
+ epicId: p.epicId ?? job.epicId ?? null,
1109
1137
  dependsOn: p.dependsOn,
1110
1138
  originSessionId: job.originSessionId
1111
- ?? resolveOriginSessionId(p.cwd, reconcileSourcePromptId(job, p.sourcePromptId)),
1139
+ ?? resolveOriginSessionId(p.cwd, p.epicId ?? reconcileSourcePromptId(job, p.sourcePromptId)),
1112
1140
  bodyPreview: p.body.split('\n').slice(0, 6).join('\n'),
1113
1141
  });
1114
1142
  }
@@ -1167,8 +1195,9 @@ async function reconcile(state) {
1167
1195
  estimateMinutes: p.estimateMinutes,
1168
1196
  sourcePromptId: p.sourcePromptId,
1169
1197
  sourceTabId: p.sourceTabId,
1198
+ epicId: p.epicId ?? null,
1170
1199
  dependsOn: p.dependsOn,
1171
- originSessionId: resolveOriginSessionId(p.cwd, p.sourcePromptId),
1200
+ originSessionId: resolveOriginSessionId(p.cwd, p.epicId ?? p.sourcePromptId),
1172
1201
  bodyPreview: p.body.split('\n').slice(0, 6).join('\n'),
1173
1202
  status: 'pending',
1174
1203
  runId: null,
@@ -1267,17 +1296,11 @@ let resumeTimer = null;
1267
1296
  let pollLoopTimer = null;
1268
1297
  let rescheduleInterval = null;
1269
1298
  let heartbeatInterval = null;
1270
- // Feedback sweep piggybacks on the 60s heartbeat tick but runs far less often —
1271
- // every Nth tick — since it's a readdir-per-active-project scan, not free.
1272
- // 5 ticks = 5 minutes; well inside sweep()/activeProjectCwds()'s 90-minute
1273
- // active-session window, so no active project can be missed between sweeps.
1274
- const FEEDBACK_SWEEP_TICK_INTERVAL = 5;
1275
- let feedbackSweepTickCount = 0;
1276
- /** Pure gate for the tick counter above — exported so the wiring is testable
1277
- * without waiting on real setInterval timers. */
1278
- function feedbackSweepDue(tickCount, interval = FEEDBACK_SWEEP_TICK_INTERVAL) {
1279
- return tickCount >= interval;
1280
- }
1299
+ // (The 5-minute feedback sweep that used to piggyback on this heartbeat is
1300
+ // gone: it scanned each active project's session-manager-operations/feedback/
1301
+ // and auto-queued a /process-feedback PRD. Both the folder and that skill are
1302
+ // retired — agents now file a `proposed` Epic that waits for human approval,
1303
+ // so there is nothing to sweep for. See lib/rcaFeedbackHook.cjs.)
1281
1304
  // In-memory set of slugs currently spawned in this process. Prevents
1282
1305
  // double-spawn when runDueJobs() is called while jobs are in flight.
1283
1306
  const runningSet = new Set();
@@ -1623,6 +1646,34 @@ function isNotifiableTerminalStatus(effectiveStatus) {
1623
1646
  return effectiveStatus === 'completed' || effectiveStatus === 'failed';
1624
1647
  }
1625
1648
 
1649
+ /**
1650
+ * Scans a run log's tail for the last `{"type":"result",...}` JSONL event
1651
+ * (the claude harness's final message) and returns its `result` string, or
1652
+ * null if the log is missing, unreadable, or has no parseable result line.
1653
+ * Never throws — a missing/torn log must not break notifyOriginatingTab.
1654
+ */
1655
+ function extractResultTextFromLog(logPath) {
1656
+ if (!logPath) return null;
1657
+ try {
1658
+ const tail = readTail(logPath, RESULT_TEXT_TAIL_BYTES);
1659
+ if (!tail) return null;
1660
+ const lines = tail.split('\n');
1661
+ for (let i = lines.length - 1; i >= 0; i--) {
1662
+ const line = lines[i].trim();
1663
+ if (!line || !line.includes('"type":"result"')) continue;
1664
+ try {
1665
+ const parsed = JSON.parse(line);
1666
+ if (parsed && typeof parsed.result === 'string') return parsed.result;
1667
+ } catch {
1668
+ // torn/partial line — keep scanning backward
1669
+ }
1670
+ }
1671
+ return null;
1672
+ } catch {
1673
+ return null;
1674
+ }
1675
+ }
1676
+
1626
1677
  /**
1627
1678
  * notifyOriginatingTab(job) → void
1628
1679
  *
@@ -1630,31 +1681,62 @@ function isNotifiableTerminalStatus(effectiveStatus) {
1630
1681
  * rateLimited auto-pause, which resets the job to pending instead), publish
1631
1682
  * a short status notification for the PRD that queued this job.
1632
1683
  *
1633
- * Resolution order (PRD 814): (1) if the PRD's `sourcePromptId` resolves to
1634
- * a known, still-active PromptSession (minted for a dev-work dispatch, PRD
1635
- * 813) under the job's cwd, append a 'response' PromptSessionEvent to THAT
1636
- * session's own event chain — its own scoped PromptSessionConversation, not
1637
- * whatever tab happens to be active — and stop; (2) otherwise, fall back to
1638
- * today's behavior: push a short status prompt into the chat tab that queued
1639
- * this PRD via enqueueExternalPrompt (PRD 753), resolved via the PRD's own
1640
- * `sourceTabId` frontmatter, then the first open tab (per sessionsStore's
1641
- * persisted tabs.json) whose cwd matches the job's cwd — first match only,
1642
- * no fan-out to multiple matching tabs; (3) no-op. Never throws to the
1643
- * caller (fire-and-forget from spawnJob). Deps are injectable (mirrors
1644
- * partitionBootOrphans's isAlive param) so unit tests can exercise the
1645
- * resolution logic without touching disk/electron.
1684
+ * Resolution order (PRD 814, extended by PRD 854): (1) if the PRD's
1685
+ * `sourcePromptId` resolves to a known, still-active PromptSession (minted
1686
+ * for a dev-work dispatch, PRD 813) under the job's cwd, append a 'response'
1687
+ * PromptSessionEvent to THAT session's own event chain — its own scoped Epic
1688
+ * conversation (EpicDetail.tsx), not whatever tab happens to be active — and
1689
+ * stop; (2) otherwise, fall back to pushing a short status prompt via
1690
+ * enqueueExternalPrompt (PRD 753) at a target id resolved from, in order,
1691
+ * the PRD's own `sourceTabId` frontmatter, then `sourcePromptId` again (a PRD
1692
+ * dispatched straight from an Epic's composer, dispatchPromptSessionToPrd,
1693
+ * never sets sourceTabId — sourcePromptId IS the Epic id and is a valid
1694
+ * chat:external-send target too, which the renderer now resolves against
1695
+ * both open tabs and known Epics, refusing a completed one), then the first
1696
+ * open tab (per sessionsStore's persisted tabs.json) whose cwd matches the
1697
+ * job's cwd — first match only, no fan-out to multiple matching tabs; (3)
1698
+ * no-op. The cwd-match step only runs when NEITHER sourceTabId nor
1699
+ * sourcePromptId is present in frontmatter at all — `sendPrompt` (
1700
+ * enqueueExternalPrompt) is fire-and-forget IPC with no ack, so once step
1701
+ * (2) picks either id it is not retried even if the renderer can't resolve
1702
+ * it either (e.g. a stale/garbage id) — same non-guaranteed-delivery
1703
+ * tradeoff this function already accepted for sourceTabId pre-PRD-854.
1704
+ * Never throws to the caller (fire-and-forget from spawnJob). Deps are
1705
+ * injectable (mirrors partitionBootOrphans's isAlive param) so unit tests
1706
+ * can exercise the resolution logic without touching disk/electron.
1646
1707
  */
1647
1708
  async function notifyOriginatingTab(job, {
1648
1709
  parsePrdRaw = prdParser.parsePrdRaw,
1649
1710
  loadSessions = sessionsStore.load,
1650
1711
  sendPrompt = enqueueExternalPrompt,
1651
1712
  appendResponseEvent = appendResponseEventIfKnown,
1713
+ appendTranscriptTurn = promptSessionTranscript.appendTurn,
1714
+ readResultFromLog = extractResultTextFromLog,
1652
1715
  } = {}) {
1653
1716
  try {
1654
1717
  const prdPath = prdPathForJob(job);
1655
1718
  const prd = await parsePrdRaw(prdPath).catch(() => null);
1656
1719
  const message = `PRD ${job.slug} finished: ${job.status}. Check Scheduler for details.`;
1657
1720
 
1721
+ // Persist the job's real result text (not just the short status chip
1722
+ // above) to the durable per-Epic transcript, keyed off whichever id
1723
+ // notifyOriginatingTab would otherwise notify. Best-effort: a missing
1724
+ // cwd/epic id, an unreadable run log, or an IPC error here must never
1725
+ // block the notification below.
1726
+ const epicIdForTranscript = prd?.sourcePromptId || prd?.sourceTabId || null;
1727
+ if (epicIdForTranscript && job.cwd) {
1728
+ try {
1729
+ const logPath = job.runId ? path.join(RUNS_DIR, job.runId, `${job.slug}.log`) : null;
1730
+ const resultText = readResultFromLog(logPath);
1731
+ await appendTranscriptTurn(job.cwd, epicIdForTranscript, {
1732
+ role: 'assistant',
1733
+ text: resultText || message,
1734
+ });
1735
+ } catch (e) {
1736
+ console.error('[scheduler] notifyOriginatingTab transcript append error', job?.slug, e);
1737
+ }
1738
+ }
1739
+
1658
1740
  if (prd?.sourcePromptId) {
1659
1741
  const routed = await appendResponseEvent(job.cwd || null, prd.sourcePromptId, message).catch((e) => {
1660
1742
  console.error('[scheduler] notifyOriginatingTab appendResponseEvent error', job?.slug, e);
@@ -1663,7 +1745,13 @@ async function notifyOriginatingTab(job, {
1663
1745
  if (routed) return;
1664
1746
  }
1665
1747
 
1666
- let targetTabId = prd?.sourceTabId || null;
1748
+ // appendResponseEvent already refused above (unknown id, completed
1749
+ // Epic, or disk error) — the sourcePromptId fallback below re-sends to
1750
+ // the SAME id via chat:external-send, which the renderer independently
1751
+ // re-checks against its own live PromptSession store. Deliberate
1752
+ // defense-in-depth (main's disk-backed check vs. the renderer's
1753
+ // in-memory one can disagree/race), not a redundant duplicate to prune.
1754
+ let targetTabId = prd?.sourceTabId || prd?.sourcePromptId || null;
1667
1755
  if (!targetTabId) {
1668
1756
  const jobCwd = job.cwd || null;
1669
1757
  if (jobCwd) {
@@ -1673,7 +1761,7 @@ async function notifyOriginatingTab(job, {
1673
1761
  }
1674
1762
  }
1675
1763
  if (!targetTabId) {
1676
- console.log(`[scheduler] notifyOriginatingTab: no target tab for ${job.slug}, skipping`);
1764
+ console.log(`[scheduler] notifyOriginatingTab: no open tab and no Epic for ${job.slug}, skipping`);
1677
1765
  return;
1678
1766
  }
1679
1767
 
@@ -1838,8 +1926,13 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
1838
1926
  }
1839
1927
 
1840
1928
  // Read full PRD body fresh from disk (queue stored only the preview).
1929
+ // Resolve through findPrdDir's full candidate search (legacy flat dir +
1930
+ // every project's Epic-scoped dirs) first, so the common case — a live
1931
+ // Epic-scoped PRD — is a first-try hit instead of probing the retired flat
1932
+ // dir and only then falling back.
1841
1933
  let prompt;
1842
- let prdPath = prdPathForJob(job);
1934
+ const resolvedDir = await findPrdDir(job.slug);
1935
+ let prdPath = resolvedDir ? path.join(resolvedDir, `${job.slug}.md`) : prdPathForJob(job);
1843
1936
  try {
1844
1937
  const parsed = await parsePrd(prdPath);
1845
1938
  // Centrally enforce the review → security-review → verify → commit finish
@@ -3757,7 +3850,7 @@ function registerScheduleHandlers() {
3757
3850
  const resolved = safeSlugPathIn(dir, data.slug);
3758
3851
  if (!resolved) return { ok: false, error: 'invalid slug' };
3759
3852
  try {
3760
- await config.writeTextAtomic(resolved, data.body);
3853
+ await config.writeTextAtomic(resolved, data.body, { writer: 'scheduler' });
3761
3854
  } catch (e) {
3762
3855
  return { ok: false, error: e?.message ?? 'write failed' };
3763
3856
  }
@@ -3795,6 +3888,7 @@ function registerScheduleHandlers() {
3795
3888
  cwd: parsed.cwd || '',
3796
3889
  estimateMinutes: parsed.estimateMinutes,
3797
3890
  sourcePromptId: parsed.sourcePromptId,
3891
+ epicId: parsed.epicId ?? null,
3798
3892
  mtimeMs: stat.mtimeMs,
3799
3893
  });
3800
3894
  } catch (e) {
@@ -4006,16 +4100,6 @@ async function init() {
4006
4100
  utilization: cachedUtilization,
4007
4101
  consecutiveFailures,
4008
4102
  });
4009
-
4010
- feedbackSweepTickCount++;
4011
- if (feedbackSweepDue(feedbackSweepTickCount, FEEDBACK_SWEEP_TICK_INTERVAL)) {
4012
- feedbackSweepTickCount = 0;
4013
- try {
4014
- sweepFeedback();
4015
- } catch (e) {
4016
- console.warn('[scheduler] feedback sweep failed', e?.message);
4017
- }
4018
- }
4019
4103
  }, 60_000);
4020
4104
  if (heartbeatInterval.unref) heartbeatInterval.unref();
4021
4105
 
@@ -4125,10 +4209,16 @@ const remote = {
4125
4209
  if (!dir) {
4126
4210
  try {
4127
4211
  const { fm } = splitFrontmatter(body);
4128
- const epic = ensureEpic(cwd, {
4212
+ const epic = await ensureEpic(cwd, {
4129
4213
  goalText: fm.title || slug,
4130
4214
  tag: fm.tag,
4131
4215
  // An Epic-conversation dispatch already has its Epic — join it.
4216
+ // fm.sourcePromptId must be an existing Epic's promptSessionId
4217
+ // (== its active-index.json sessions key), NOT a
4218
+ // PromptTicket.id — epicMint.cjs's ensureEpic looks it up via
4219
+ // `index.sessions[explicitEpicId]` (see epicMint.cjs ~line 73),
4220
+ // a literal-equality join. Any other id (e.g. a PromptTicket.id)
4221
+ // simply won't match and mints a sibling Epic instead of joining.
4132
4222
  epicId: fm.sourcePromptId,
4133
4223
  });
4134
4224
  dir = epic.prdDir;
@@ -4151,16 +4241,22 @@ const remote = {
4151
4241
  // PRD 825: if this call minted a brand-new Epic (ensureEpic's `created`)
4152
4242
  // and the write below never lands, don't strand an empty Epic dir —
4153
4243
  // best-effort remove `<epic>/prds` then `<epic>` itself, only when empty.
4244
+ // PRD 851: also drop the Epic's active-index.json entry (sessions/events)
4245
+ // so an orphaned seed-prompt-only Epic doesn't linger in the Epics nav.
4246
+ // Gated on epicCreated (never fires when ensureEpic joined an existing
4247
+ // Epic) so a pre-existing Epic's history is never touched.
4154
4248
  const cleanupEmptyMintedEpic = async () => {
4155
4249
  if (!epicCreated || !epicId) return;
4156
4250
  try {
4157
4251
  const entries = await fsp.readdir(dir);
4158
- if (entries.length > 0) return;
4159
- await fsp.rmdir(dir);
4160
- const epicRootDir = path.dirname(dir);
4161
- const epicRootEntries = await fsp.readdir(epicRootDir);
4162
- if (epicRootEntries.length === 0) await fsp.rmdir(epicRootDir);
4252
+ if (entries.length === 0) {
4253
+ await fsp.rmdir(dir);
4254
+ const epicRootDir = path.dirname(dir);
4255
+ const epicRootEntries = await fsp.readdir(epicRootDir);
4256
+ if (epicRootEntries.length === 0) await fsp.rmdir(epicRootDir);
4257
+ }
4163
4258
  } catch { /* best-effort only */ }
4259
+ try { removeEpic(cwd, epicId); } catch { /* best-effort only */ }
4164
4260
  };
4165
4261
 
4166
4262
  const resolved = safeSlugPathIn(dir, slug);
@@ -4185,11 +4281,11 @@ const remote = {
4185
4281
  await cleanupEmptyMintedEpic();
4186
4282
  return { ok: false, error: 'invalid slug' };
4187
4283
  }
4188
- await config.writeTextAtomic(resolved, body);
4284
+ await config.writeTextAtomic(resolved, body, { writer: 'scheduler' });
4189
4285
  const stat = await fsp.stat(resolved);
4190
4286
  if (epicTrace) {
4191
4287
  // Best-effort: record the dispatch on the minted Epic's event chain.
4192
- try { appendPrdCreatedEvent(cwd, epicTrace, slug); } catch { /* trace only */ }
4288
+ try { await appendPrdCreatedEvent(cwd, epicTrace, slug); } catch { /* trace only */ }
4193
4289
  }
4194
4290
  return { ok: true, bytesWritten: stat.size };
4195
4291
  } catch (e) {
@@ -4288,4 +4384,4 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
4288
4384
  });
4289
4385
  }
4290
4386
 
4291
- module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, writeQueue, reconcile, reconcileSourcePromptId, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, commitGuardVerdict, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, feedbackSweepDue, FEEDBACK_SWEEP_TICK_INTERVAL, sweepFeedback, registerAdminRoutes, notifyOriginatingTab, isNotifiableTerminalStatus, candidatePrdsDirs, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, runPrdMigration, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields };
4387
+ module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, writeQueue, reconcile, reconcileSourcePromptId, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, commitGuardVerdict, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, registerAdminRoutes, notifyOriginatingTab, isNotifiableTerminalStatus, extractResultTextFromLog, candidatePrdsDirs, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, runPrdMigration, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields };
@@ -437,7 +437,7 @@ not be re-proposed:
437
437
 
438
438
  ### Reference implementation of this exact loop
439
439
 
440
- `/process-feedback`'s step 0b (source → triage → `/develop` → queue) is a working example of this
440
+ The proposal → approve → `/develop` → queue path is a working example of this
441
441
  shape, landed 2026-07-14 (`feat(process-feedback): sync open GitHub issues into the feedback
442
442
  intake`, commit `352b89c`). It syncs open GitHub issues into `session-manager-operations/feedback/`
443
443
  (deduped on a `gh-issue-<N>` token), then processes each item through the same triage → `/develop`
@@ -122,6 +122,11 @@ export interface ImportRef {
122
122
  ok: boolean;
123
123
  }
124
124
 
125
+ /** Declared owner ids for the single-writer law over a project's
126
+ * session-manager-operations/ root. Mirrors OWNERS in
127
+ * src/main/lib/opsOwnership.cjs — one writer per namespace; everyone reads. */
128
+ export type OpsWriter = 'epics' | 'scheduler' | 'project-home' | 'feedback' | 'browser';
129
+
125
130
  export interface WriteResult {
126
131
  ok: boolean;
127
132
  mtimeMs: number;
@@ -415,6 +420,11 @@ export interface ScheduleJob {
415
420
  * `sourceTabId` for referential tracing even when the tab/session no
416
421
  * longer resolves. */
417
422
  sourcePromptId?: string | null;
423
+ /** The Epic this job's PRD belongs to, derived from the PRD's directory
424
+ * (`scheduler/epics/<epicId>/prds/`) — fact on disk, unlike the
425
+ * intent-carrying `sourcePromptId`/`sourceTabId`, which can disagree.
426
+ * Authoritative source for "which Epic is this from" in every UI. */
427
+ epicId?: string | null;
418
428
  }
419
429
 
420
430
  export interface SchedulePaths {
@@ -459,6 +469,8 @@ export interface PrdListItem {
459
469
  /** PRD frontmatter `sourcePromptId` — the PromptSession (Epic) id this PRD
460
470
  * was dispatched from, if any. */
461
471
  sourcePromptId?: string | null;
472
+ /** Owning Epic id, derived from the PRD's directory. See ScheduleJob.epicId. */
473
+ epicId?: string | null;
462
474
  }
463
475
 
464
476
  export interface SupervisorConfig {
@@ -874,6 +886,8 @@ export interface ProjectBriefScopeEntry {
874
886
  export interface ProjectBrief {
875
887
  version: number;
876
888
  synthesizedAt: string;
889
+ /** ISO timestamp of the last hand-edit through `projectBrief.update`, or null. */
890
+ editedAt: string | null;
877
891
  model: string;
878
892
  purpose: string;
879
893
  what: string[];
@@ -884,6 +898,15 @@ export interface ProjectBrief {
884
898
  pinned: { what: string[] | null; conventions: string[] | null };
885
899
  }
886
900
 
901
+ export interface PromptSessionTranscriptTurn {
902
+ v: 1;
903
+ epicId: string;
904
+ eventId: string | null;
905
+ role: 'user' | 'assistant';
906
+ at: string;
907
+ text: string;
908
+ }
909
+
887
910
  export interface ProjectBriefSource {
888
911
  label: string;
889
912
  detail: string;
@@ -904,6 +927,19 @@ export type ProjectBriefSetPinResult =
904
927
  | { ok: true; brief: ProjectBrief }
905
928
  | { ok: false; error: string };
906
929
 
930
+ /** Hand-edit patch over brief.json. Editing `what`/`conventions` auto-pins them. */
931
+ export interface ProjectBriefPatch {
932
+ purpose?: string;
933
+ what?: string[];
934
+ conventions?: string[];
935
+ areas?: ProjectBriefArea[];
936
+ scope?: ProjectBriefScopeEntry[];
937
+ }
938
+
939
+ export type ProjectBriefUpdateResult =
940
+ | { ok: true; brief: ProjectBrief }
941
+ | { ok: false; error: string };
942
+
907
943
  // ────────────────────────────────────────────── Per-subagent memory
908
944
  // Stored at ~/.claude/session-manager/agent-memory/<agentId>.json. Keyed by
909
945
  // agent name (the .md filename in ~/.claude/agents/), not by workspace cwd.
@@ -1184,7 +1220,7 @@ export interface SessionManagerAPI {
1184
1220
  | { ok: true; url: string; title: string; dataUrl: string }
1185
1221
  | { ok: false; error: string }
1186
1222
  >;
1187
- saveBinary: (path: string, base64: string) => Promise<{ ok: boolean; error?: string }>;
1223
+ saveBinary: (path: string, base64: string, writer?: OpsWriter) => Promise<{ ok: boolean; error?: string }>;
1188
1224
  /** Opens a native "Save As" dialog and writes `text` to the chosen path directly
1189
1225
  * (bypasses config.cjs's write-boundary check — the path is user-chosen via OS dialog). */
1190
1226
  saveRecording: (payload: { defaultName: string; text: string }) => Promise<
@@ -1253,8 +1289,10 @@ export interface SessionManagerAPI {
1253
1289
  config: {
1254
1290
  readJson: (path: string) => Promise<ReadJsonResult>;
1255
1291
  readText: (path: string) => Promise<ReadTextResult>;
1256
- writeJson: (path: string, data: unknown) => Promise<WriteResult>;
1257
- writeText: (path: string, text: string) => Promise<WriteResult>;
1292
+ /** `writer` declares the owning surface for the single-writer law — required
1293
+ * when `path` is inside a project's session-manager-operations/ root. */
1294
+ writeJson: (path: string, data: unknown, writer?: OpsWriter) => Promise<WriteResult>;
1295
+ writeText: (path: string, text: string, writer?: OpsWriter) => Promise<WriteResult>;
1258
1296
  listDir: (path: string, opts?: { filesOnly?: boolean; dirsOnly?: boolean; includeHidden?: boolean }) => Promise<ListDirResult>;
1259
1297
  exists: (path: string) => Promise<boolean>;
1260
1298
  watch: (paths: string[]) => void;
@@ -1439,6 +1477,18 @@ export interface SessionManagerAPI {
1439
1477
  refresh: (cwd: string) => Promise<ProjectBriefRefreshResult>;
1440
1478
  /** Pin/unpin a synthesized block ('what' | 'conventions'), freezing or clearing its frozen copy. */
1441
1479
  setPin: (cwd: string, block: ProjectBriefPinnableBlock, pinned: boolean) => Promise<ProjectBriefSetPinResult>;
1480
+ /** Hand-edit brief.json in place — no LLM cost. Edited pinnable blocks are auto-pinned so the next refresh preserves them. */
1481
+ update: (cwd: string, patch: ProjectBriefPatch) => Promise<ProjectBriefUpdateResult>;
1482
+ };
1483
+ promptSessionTranscript: {
1484
+ /** Append one full-text turn to an Epic's durable JSONL transcript. Best-effort — resolves `{ok:false}` rather than throwing on failure. */
1485
+ append: (
1486
+ cwd: string,
1487
+ epicId: string,
1488
+ turn: { role: 'user' | 'assistant'; text: string; at?: string; eventId?: string },
1489
+ ) => Promise<{ ok: boolean }>;
1490
+ /** Read back an Epic's full-text turns (optionally capped to the last `limit`). Skips corrupt lines rather than throwing. */
1491
+ read: (cwd: string, epicId: string, limit?: number) => Promise<{ turns: PromptSessionTranscriptTurn[] }>;
1442
1492
  };
1443
1493
  agentMemory: {
1444
1494
  /** List all memory entries for one subagent. Sorted newest first. */
@@ -1522,6 +1572,29 @@ export interface SessionManagerAPI {
1522
1572
  offset?: number;
1523
1573
  }) => Promise<Exchange[]>;
1524
1574
  };
1575
+ promptSessions: {
1576
+ /** Fires when the main process appends an event to a PromptSession's
1577
+ * chain on disk (currently only the scheduler's response-event append
1578
+ * from notifyOriginatingTab) — lets an already-hydrated Epic pick it up
1579
+ * live instead of waiting for a restart. */
1580
+ onEventAppended: (handler: (e: PromptSessionEventAppendedPayload) => void) => () => void;
1581
+ };
1582
+ }
1583
+
1584
+ // ─────────────────────────────────── PromptSession event-appended broadcast
1585
+
1586
+ export interface PromptSessionEventAppendedPayload {
1587
+ cwd: string;
1588
+ promptSessionId: string;
1589
+ event: {
1590
+ id: string;
1591
+ promptSessionId: string;
1592
+ kind: 'prompt' | 'prd_created' | 'response' | 'closed';
1593
+ causedByEventId: string | null;
1594
+ at: string;
1595
+ prdSlug?: string;
1596
+ text?: string;
1597
+ };
1525
1598
  }
1526
1599
 
1527
1600
  // ────────────────────────────────────────────── Exchanges (PRD 324 read path)
@@ -82,7 +82,7 @@ contextBridge.exposeInMainWorld('api', {
82
82
  },
83
83
  captureDom: (payload) => ipcRenderer.invoke('browser:capture-dom', payload),
84
84
  captureShot: (viewId) => ipcRenderer.invoke('browser:capture-shot', { viewId }),
85
- saveBinary: (path, base64) => ipcRenderer.invoke('browser:save-binary', { path, base64 }),
85
+ saveBinary: (path, base64, writer) => ipcRenderer.invoke('browser:save-binary', { path, base64, writer }),
86
86
  saveRecording: (payload) => ipcRenderer.invoke('browser:save-recording', payload),
87
87
  replay: (payload) => ipcRenderer.invoke('browser:replay', payload),
88
88
  onReplayStep: (viewId, handler) => {
@@ -153,8 +153,8 @@ contextBridge.exposeInMainWorld('api', {
153
153
  config: {
154
154
  readJson: (path) => ipcRenderer.invoke('config:read-json', { path }),
155
155
  readText: (path) => ipcRenderer.invoke('config:read-text', { path }),
156
- writeJson: (path, data) => ipcRenderer.invoke('config:write-json', { path, data }),
157
- writeText: (path, text) => ipcRenderer.invoke('config:write-text', { path, text }),
156
+ writeJson: (path, data, writer) => ipcRenderer.invoke('config:write-json', { path, data, writer }),
157
+ writeText: (path, text, writer) => ipcRenderer.invoke('config:write-text', { path, text, writer }),
158
158
  listDir: (path, opts) => ipcRenderer.invoke('config:list-dir', { path, opts }),
159
159
  exists: (path) => ipcRenderer.invoke('config:exists', { path }),
160
160
  watch: (paths) => ipcRenderer.send('config:watch', { paths }),
@@ -320,6 +320,13 @@ contextBridge.exposeInMainWorld('api', {
320
320
  get: (cwd) => ipcRenderer.invoke('project-brief:get', { cwd }),
321
321
  refresh: (cwd) => ipcRenderer.invoke('project-brief:refresh', { cwd }),
322
322
  setPin: (cwd, block, pinned) => ipcRenderer.invoke('project-brief:set-pin', { cwd, block, pinned }),
323
+ update: (cwd, patch) => ipcRenderer.invoke('project-brief:update', { cwd, patch }),
324
+ },
325
+ promptSessionTranscript: {
326
+ append: (cwd, epicId, turn) =>
327
+ ipcRenderer.invoke('promptSessionTranscript:append', { cwd, epicId, ...turn }),
328
+ read: (cwd, epicId, limit) =>
329
+ ipcRenderer.invoke('promptSessionTranscript:read', { cwd, epicId, ...(limit ? { limit } : {}) }),
323
330
  },
324
331
  agentMemory: {
325
332
  list: (agentId) => ipcRenderer.invoke('agent-memory:list', { agentId }),
@@ -440,4 +447,15 @@ contextBridge.exposeInMainWorld('api', {
440
447
  * `sessionId` filters to one session; `limit`/`offset` for pagination. */
441
448
  list: (payload) => ipcRenderer.invoke('exchanges:list', payload),
442
449
  },
450
+ promptSessions: {
451
+ /** Fires when the main process (currently only the scheduler's
452
+ * response-event append) appends an event to a PromptSession's chain on
453
+ * disk — lets an already-hydrated Epic pick it up live instead of
454
+ * waiting for a restart. */
455
+ onEventAppended: (handler) => {
456
+ const listener = (_e, payload) => handler(payload);
457
+ ipcRenderer.on('promptSession:event-appended', listener);
458
+ return () => ipcRenderer.removeListener('promptSession:event-appended', listener);
459
+ },
460
+ },
443
461
  });