claude-code-session-manager 0.57.0 → 0.58.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 (45) hide show
  1. package/dist/assets/{TiptapBody-B1llI4h-.js → TiptapBody-BEBLdJl_.js} +1 -1
  2. package/dist/assets/index-DEQzGYa6.js +3227 -0
  3. package/dist/assets/{index-WUc_GHMf.css → index-DwUffaDq.css} +1 -1
  4. package/dist/index.html +2 -2
  5. package/package.json +1 -2
  6. package/plugins/session-manager-dev/skills/develop/SKILL.md +7 -6
  7. package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
  8. package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +1 -1
  9. package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
  10. package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +16 -25
  11. package/plugins/session-manager-dev/skills/project-status/SKILL.md +5 -5
  12. package/scripts/lib/watchdogHelpers.cjs +0 -434
  13. package/scripts/mint-epic.cjs +6 -6
  14. package/src/main/__tests__/activeIndexMerge.test.cjs +2 -2
  15. package/src/main/__tests__/agentModelResolve.test.cjs +119 -0
  16. package/src/main/__tests__/epicContextDigest.test.cjs +100 -0
  17. package/src/main/__tests__/epicMint.test.cjs +165 -207
  18. package/src/main/__tests__/promptSessionSchema.test.cjs +101 -0
  19. package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +139 -0
  20. package/src/main/__tests__/queueHistory.test.cjs +14 -0
  21. package/src/main/__tests__/rcaReport.test.cjs +188 -0
  22. package/src/main/__tests__/runVerify.test.cjs +109 -6
  23. package/src/main/__tests__/scheduler-epic-digest.test.cjs +214 -0
  24. package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +5 -5
  25. package/src/main/chatRunner.cjs +10 -1
  26. package/src/main/index.cjs +2 -4
  27. package/src/main/ipcSchemas.cjs +47 -2
  28. package/src/main/lib/agentModelResolve.cjs +97 -0
  29. package/src/main/lib/auditLog.cjs +2 -2
  30. package/src/main/lib/epicContextDigest.cjs +78 -0
  31. package/src/main/lib/epicMint.cjs +81 -164
  32. package/src/main/lib/promptSessionSchema.cjs +79 -0
  33. package/src/main/lib/promptSessionsCreateEpic.cjs +68 -0
  34. package/src/main/lib/queueHistory.cjs +14 -2
  35. package/src/main/lib/{rcaFeedbackHook.cjs → rcaReport.cjs} +47 -114
  36. package/src/main/runVerify.cjs +17 -2
  37. package/src/main/scheduler.cjs +79 -24
  38. package/src/preload/api.d.ts +25 -48
  39. package/src/preload/index.cjs +6 -7
  40. package/dist/assets/index-DdVGnaCd.js +0 -3227
  41. package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +0 -68
  42. package/scripts/propose-epic.cjs +0 -56
  43. package/src/main/__tests__/rcaFeedbackHook.test.cjs +0 -280
  44. package/src/main/repoAnalyzer.cjs +0 -346
  45. package/src/main/search.cjs +0 -332
@@ -1,13 +1,21 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * rcaFeedbackHook.cjs — files a deterministic Root Cause Analysis feedback
5
- * item when a scheduler job is parked in `needs_review`.
4
+ * rcaReport.cjs — writes a deterministic Root Cause Analysis report into the
5
+ * run directory when a scheduler job is parked in `needs_review`.
6
6
  *
7
7
  * Mirrors the dodDrainHook.cjs pattern: fire-and-forget, idempotent,
8
8
  * kill-switched, never throws to the caller. Unlike the DoD gate this fires
9
9
  * per-job at the needs_review transition, not at queue drain.
10
10
  *
11
+ * This module creates NOTHING but a markdown file next to the run's own log.
12
+ * It used to file a `proposed` Epic per parked job; Epics are now created in
13
+ * exactly one place — the human pressing New Epic (see epicMint.cjs's
14
+ * SINGLE-CREATOR LAW). Surfacing the parked job back to a human is the
15
+ * scheduler's job (notifyNeedsReview → appendResponseEventIfKnown), which
16
+ * routes it into the Epic that AUTHORED the PRD rather than opening a
17
+ * sibling Epic about it.
18
+ *
11
19
  * No LLM calls live here — the RCA body is assembled deterministically from
12
20
  * the run's meta.json, log tail, and the original PRD's acceptance-criteria
13
21
  * section. When an Opus auto-fix investigation later runs (spawnInvestigation
@@ -17,34 +25,17 @@
17
25
  *
18
26
  * Kill-switch: SM_RCA_DISABLE=1 (mirrors SM_DOD_DISABLE / SM_SUPERVISOR_DISABLE).
19
27
  *
20
- * Electron-free by design (no require('electron')) so tests stay hermetic.
21
- * config.cjs itself requires('electron'), but that resolves to a harmless
22
- * string (not the electron module) when required outside the Electron
23
- * runtime, and this lib never touches ipcMain — only the atomic-write /
24
- * path-validation helpers.
28
+ * Electron-free by design (no require('electron')) so tests stay hermetic —
29
+ * it only reads run artifacts and writes one markdown file into the run dir
30
+ * the caller already owns.
25
31
  */
26
32
 
27
33
  const fs = require('node:fs');
28
- const os = require('node:os');
29
34
  const path = require('node:path');
30
- const config = require('../config.cjs');
31
- const { ensureEpic, findJoinableEpic } = require('./epicMint.cjs');
32
35
  const { splitFrontmatter } = require('./prdFrontmatter.cjs');
33
36
  const { readTail } = require('./fileTail.cjs');
34
37
  const { resolvePrdWriteDir } = require('./prdLocations.cjs');
35
38
 
36
- // The one project this hook always knows how to reach, used as the fallback
37
- // destination when a job's own cwd has no feedback inbox.
38
- const SM_REPO_ROOT = path.join(os.homedir(), 'Projects', 'session-manager');
39
-
40
- // NEVER write here: ~/.claude/session-manager/feedback/ is the scheduler's
41
- // auto-PRD intake (src/main/scheduler.cjs's periodic feedback sweep) — it
42
- // converts anything dropped there into a scheduled PRD automatically. Filing
43
- // an RCA there would create an RCA → auto-PRD → run → needs_review → RCA
44
- // cycle with no human in the loop. Both real destinations below are project
45
- // feedback INBOXES (consumed by /process-feedback), which is a different,
46
- // human/agent-triaged path.
47
-
48
39
  // ─── VERDICT_LABELS — deliberate duplicate ──────────────────────────────────
49
40
  // Mirrors src/renderer/components/tabs/scheduler/sched-primitives.tsx:123-131.
50
41
  // The main process cannot import renderer TSX, so this vocabulary is kept in
@@ -197,41 +188,12 @@ function extractAcceptanceCriteria(prdBody) {
197
188
  return text || null;
198
189
  }
199
190
 
200
- // ─── Destination resolution ──────────────────────────────────────────────────
201
-
202
- /**
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.
211
- */
212
- function resolveDestination(job) {
213
- const jobCwd = job?.cwd;
214
- if (jobCwd) {
215
- return { cwd: jobCwd, allowlistRoot: jobCwd, targetProjectNote: null };
216
- }
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) {
191
+ /** One-line summary of the parked job — used as the report's title and as the
192
+ * text the scheduler surfaces back into the Epic that authored the PRD. */
193
+ function rcaSummaryLine(job, verdict) {
227
194
  return `PRD ${job?.slug ?? 'unknown'} needs review: ${humanVerdict(verdict)}`;
228
195
  }
229
196
 
230
- function shortRunId(runId) {
231
- const cleaned = String(runId ?? '').replace(/[^a-zA-Z0-9]/g, '');
232
- return cleaned.slice(0, 12) || 'norun';
233
- }
234
-
235
197
  function lastNLines(text, n) {
236
198
  const lines = (text || '').split('\n');
237
199
  return lines.slice(-n).join('\n');
@@ -261,7 +223,7 @@ function readPrdBody(cwd, slug) {
261
223
  * Assemble the deterministic RCA markdown body (frontmatter + sections).
262
224
  * Pure — no I/O, no LLM.
263
225
  */
264
- function buildRcaMarkdown({ job, verdict, meta, logTail, acText, failureClass, investigationText, targetProjectNote }) {
226
+ function buildRcaMarkdown({ job, verdict, meta, logTail, acText, failureClass, investigationText }) {
265
227
  const title = `Root cause: ${job.slug} → needs_review (${humanVerdict(verdict)})`;
266
228
  const fm = ['---', `title: ${title}`, 'source: scheduler-rca', 'type: rca', 'severity: normal', `prdSlug: ${job.slug}`, `runId: ${job.runId}`, `verdict: ${verdict}`, '---'].join('\n');
267
229
 
@@ -277,21 +239,21 @@ function buildRcaMarkdown({ job, verdict, meta, logTail, acText, failureClass, i
277
239
  `# The PRD's acceptance criteria\n\n${acText || '(acceptance criteria not found — original PRD may have been archived or removed)'}`,
278
240
  `# Likely failure class\n\n**${failureClass}**\n\nPrevention: ${prevention}`,
279
241
  ];
280
- if (targetProjectNote) sections.push(`# Note\n\n${targetProjectNote}`);
281
242
  if (investigationText) sections.push(`## Investigation analysis\n\n${investigationText.trim()}`);
282
243
 
283
244
  return `${fm}\n\n${sections.join('\n\n')}\n`;
284
245
  }
285
246
 
286
247
  /**
287
- * File (or update) a deterministic RCA feedback item for a needs_review job.
248
+ * Write (or refresh) the deterministic RCA report for a needs_review job,
249
+ * as `<runDir>/root-cause-<slug>.md` — next to the run's own log, the same
250
+ * place the definition-of-done gate writes its report (dodDrainHook.cjs).
288
251
  * Never throws — logs one line on success/skip/error and always resolves.
289
252
  *
290
- * Idempotency: the destination filename is deterministic per (slug, runId).
291
- * A second call for the same (slug, runId) with no investigationText is a
292
- * no-op; a second call WITH investigationText overwrites the same file to
293
- * add/replace the "Investigation analysis" section rather than filing a
294
- * duplicate. A different runId (new run of the same slug) files a new RCA.
253
+ * Idempotency: one report per run directory per slug, and a run dir is
254
+ * per-(runId). A second call for the same run overwrites it in place, which
255
+ * is exactly what the investigation-enrichment pass wants (it re-renders the
256
+ * same body plus an "Investigation analysis" section) — never a duplicate.
295
257
  *
296
258
  * @param {object} opts
297
259
  * @param {{slug: string, cwd?: string, runId: string, exitCode?: number, error?: string, epicId?: string}} opts.job
@@ -300,10 +262,9 @@ function buildRcaMarkdown({ job, verdict, meta, logTail, acText, failureClass, i
300
262
  * @param {Array} [opts.annotations] Non-blocking verifier annotations (currently unused
301
263
  * in the body beyond being accepted for future use).
302
264
  * @param {string} [opts.investigationText] <RCA> block text from an Opus investigation.
303
- * @param {Date} [opts.now] Override for the date segment of the filename (testing).
304
- * @returns {Promise<{filed: boolean, path?: string, updated?: boolean, reason?: string}>}
265
+ * @returns {Promise<{filed: boolean, path?: string, summary?: string, failureClass?: string, reason?: string}>}
305
266
  */
306
- async function fileRcaFeedback({ job, runDir, verdict, annotations, investigationText, now } = {}) {
267
+ async function writeRcaReport({ job, runDir, verdict, annotations, investigationText } = {}) {
307
268
  try {
308
269
  if (process.env.SM_RCA_DISABLE === '1') {
309
270
  console.log('[rca] skip: SM_RCA_DISABLE=1');
@@ -327,7 +288,10 @@ async function fileRcaFeedback({ job, runDir, verdict, annotations, investigatio
327
288
  return { filed: false, reason: 'no-verdict' };
328
289
  }
329
290
 
330
- const dest = resolveDestination(job);
291
+ if (!runDir) {
292
+ console.log(`[rca] skip: no run directory for ${job.slug}`);
293
+ return { filed: false, reason: 'no-run-dir' };
294
+ }
331
295
 
332
296
  const meta = readRunMeta(runDir, job.slug);
333
297
  const logPath = runDir ? path.join(runDir, `${job.slug}.log`) : null;
@@ -336,67 +300,36 @@ async function fileRcaFeedback({ job, runDir, verdict, annotations, investigatio
336
300
  const failureClass = classifyFailure({ verdict, logTail });
337
301
 
338
302
  const markdown = buildRcaMarkdown({
339
- job, verdict, meta, logTail, acText, failureClass, investigationText,
340
- targetProjectNote: dest.targetProjectNote, annotations,
341
- });
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.
352
- config.addAllowedRoot(dest.allowlistRoot);
353
- const title = rcaProposalTitle(job, verdict);
354
-
355
- // job.epicId (present on jobs authored after Epic-gating landed; absent
356
- // on legacy PRDs) is the known origin Epic this failing job belonged to.
357
- // Passing it straight through as ensureEpic's `epicId` would hit its
358
- // explicitEpicId join-only branch, which joins unconditionally — even a
359
- // 'completed' Epic. findJoinableEpic's preferEpicId strategy is the one
360
- // that only joins when the Epic is still open ('proposed'/'active'), so
361
- // resolve it here and only forward it when that strategy actually fires
362
- // — otherwise fall through to the existing reuseByGoal/mint behavior.
363
- // Once forwarded, ensureEpic joins it unconditionally by id (no second
364
- // status check) — the status gate lives entirely in this resolve step.
365
- let preferredEpicId;
366
- if (typeof job.epicId === 'string' && job.epicId) {
367
- const joinable = findJoinableEpic(dest.cwd, { goalText: title, preferEpicId: job.epicId, status: 'proposed' });
368
- if (joinable && joinable.matchedBy === 'preferEpicId') {
369
- preferredEpicId = joinable.epicId;
370
- }
371
- }
372
-
373
- const { epicId, created } = await ensureEpic(dest.cwd, {
374
- goalText: title,
375
- tag: 'bug',
376
- status: 'proposed',
377
- reuseByGoal: true,
378
- openingPrompt: markdown,
379
- epicId: preferredEpicId,
380
- source: { producer: 'rca-hook', prdSlug: job.slug, runId: job.runId },
303
+ job, verdict, meta, logTail, acText, failureClass, investigationText, annotations,
381
304
  });
382
305
 
383
- console.log(`[rca] ${created ? 'proposed' : 'joined'} epic ${epicId} for ${job.slug}`);
384
- return { filed: true, epicId, created, updated: !created };
306
+ // Raw fs (not config.cjs's writeTextAtomic) for the same reason the rest
307
+ // of this lib uses it: the run directory lives under
308
+ // ~/.claude/session-manager/scheduled-plans/runs/, which is Session-Manager
309
+ // runtime state, not an OWNERS namespace under a project's operations root.
310
+ const reportPath = path.join(runDir, `root-cause-${job.slug}.md`);
311
+ fs.mkdirSync(runDir, { recursive: true });
312
+ const tmp = `${reportPath}.tmp-${process.pid}`;
313
+ fs.writeFileSync(tmp, markdown);
314
+ fs.renameSync(tmp, reportPath);
315
+
316
+ const summary = rcaSummaryLine(job, verdict);
317
+ console.log(`[rca] wrote ${reportPath} (${failureClass})`);
318
+ return { filed: true, path: reportPath, summary, failureClass };
385
319
  } catch (e) {
386
- console.error('[rca] error filing RCA feedback', e?.message ?? String(e));
320
+ console.error('[rca] error writing RCA report', e?.message ?? String(e));
387
321
  return { filed: false, reason: 'error', error: e?.message ?? String(e) };
388
322
  }
389
323
  }
390
324
 
391
325
  module.exports = {
392
- fileRcaFeedback,
393
- rcaProposalTitle,
326
+ writeRcaReport,
327
+ rcaSummaryLine,
394
328
  // Pure helpers, exported for unit tests.
395
329
  humanVerdict,
396
330
  classifyFailure,
397
331
  extractAcceptanceCriteria,
398
332
  extractRcaBlock,
399
- resolveDestination,
400
333
  buildRcaMarkdown,
401
334
  FAILURE_CLASSES,
402
335
  VERDICT_LABELS,
@@ -135,8 +135,8 @@ function extractContent(item) {
135
135
  * Parse the run log into a flat sequence of extracted events.
136
136
  *
137
137
  * Event shapes:
138
- * { kind:'tool_use', seq, toolUseId, toolName, description, command }
139
- * { kind:'tool_result', seq, toolUseId, content, isError }
138
+ * { kind:'tool_use', seq, toolUseId, toolName, description, command, parentToolUseId }
139
+ * { kind:'tool_result', seq, toolUseId, content, isError, parentToolUseId }
140
140
  * { kind:'result', seq, subtype, resultText }
141
141
  *
142
142
  * Non-JSON lines (scheduler metadata "[scheduler] ...") are silently skipped.
@@ -190,6 +190,7 @@ function parseLog(logPath) {
190
190
  toolName: item.name ?? '',
191
191
  description: item.input?.description ?? '',
192
192
  command: item.input?.command ?? '',
193
+ parentToolUseId: obj.parent_tool_use_id ?? null,
193
194
  });
194
195
  }
195
196
  }
@@ -206,6 +207,7 @@ function parseLog(logPath) {
206
207
  toolUseId: item.tool_use_id ?? '',
207
208
  content: extractContent(item),
208
209
  isError: item.is_error === true,
210
+ parentToolUseId: obj.parent_tool_use_id ?? null,
209
211
  });
210
212
  }
211
213
  }
@@ -783,6 +785,19 @@ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedD
783
785
  // seen in 58-web-remote-correctness-batch, 2026-06-10).
784
786
  if (isHarnessToolError(ev.content)) continue;
785
787
 
788
+ // A tool_result carrying a non-null parent_tool_use_id happened INSIDE a Task
789
+ // subagent's own execution, not in the main agent's. Subagents do ordinary
790
+ // exploratory work (greps that exit 1 on no-match, ls on a path that may not
791
+ // exist) whose failures say nothing about whether the parent run succeeded —
792
+ // the same false-positive class the Task exemption below already covers for the
793
+ // content-pattern scan, which sits after this branch and so never reached
794
+ // is_error. The subagent's OWN final Task tool_result has parentToolUseId ===
795
+ // null and is still scanned, so a genuinely failed subagent still surfaces.
796
+ // (Incident: 955-route-epic-create-through-ipc, 2026-08-03 — a fully green,
797
+ // committed run parked in needs_review over a code-reviewer subagent's
798
+ // no-match grep at event 450/499.)
799
+ if (ev.parentToolUseId) continue;
800
+
786
801
  // is_error:true in the final 20% of the transcript.
787
802
  if (ev.isError && i >= last20pctStart) {
788
803
  const desc = toolUseDesc(events, ev.toolUseId);
@@ -77,7 +77,7 @@ const {
77
77
  } = require('./lib/schedulerConfig.cjs');
78
78
  const { pickForProject, pickNextBatch, DEFAULT_PROJECT_CWD } = require('./lib/schedulerBatch.cjs');
79
79
  const { runDefinitionOfDoneOnDrain } = require('./lib/dodDrainHook.cjs');
80
- const { fileRcaFeedback, extractRcaBlock } = require('./lib/rcaFeedbackHook.cjs');
80
+ const { writeRcaReport, extractRcaBlock } = require('./lib/rcaReport.cjs');
81
81
  const queueHistory = require('./lib/queueHistory.cjs');
82
82
  const queueOps = require('./queueOps.cjs');
83
83
  // Feedback-auto-PRD sweep — formerly only run by the external scheduler-watchdog
@@ -87,6 +87,7 @@ const queueOps = require('./queueOps.cjs');
87
87
  // home-dir layout.
88
88
  const { resolvePrdsDirs, resolveArchivedPrdsDirs, resolvePrdWriteDir, listEpicPrdDirs, listArchivedPrdDirs } = require('./lib/prdLocations.cjs');
89
89
  const { ensureEpic, appendPrdCreatedEvent, readActiveIndex } = require('./lib/epicMint.cjs');
90
+ const { buildContextDigest } = require('./lib/epicContextDigest.cjs');
90
91
 
91
92
  // ---------- origin session resolution (PRD 832) ----------
92
93
  // An Epic IS a tagged claude session — job rows carry the originating
@@ -1421,8 +1422,8 @@ let heartbeatInterval = null;
1421
1422
  // (The 5-minute feedback sweep that used to piggyback on this heartbeat is
1422
1423
  // gone: it scanned each active project's session-manager-operations/feedback/
1423
1424
  // and auto-queued a /process-feedback PRD. Both the folder and that skill are
1424
- // retired — agents now file a `proposed` Epic that waits for human approval,
1425
- // so there is nothing to sweep for. See lib/rcaFeedbackHook.cjs.)
1425
+ // retired, and nothing auto-creates work any more — a parked job is surfaced
1426
+ // back into the Epic that authored its PRD instead. See lib/rcaReport.cjs.)
1426
1427
  // In-memory set of slugs currently spawned in this process. Prevents
1427
1428
  // double-spawn when runDueJobs() is called while jobs are in flight.
1428
1429
  const runningSet = new Set();
@@ -1893,6 +1894,42 @@ async function notifyOriginatingTab(job, {
1893
1894
  }
1894
1895
  }
1895
1896
 
1897
+ /**
1898
+ * notifyNeedsReview(job, report) → Promise<boolean>
1899
+ *
1900
+ * A job parked in `needs_review` is a question, not a result: something about
1901
+ * the PRD was unclear, unverifiable, or ran past its acceptance criteria.
1902
+ * That question belongs in the conversation that WROTE the PRD, so this
1903
+ * appends a 'response' event to the authoring Epic's own chain (the same
1904
+ * channel notifyOriginatingTab uses for completed/failed, which deliberately
1905
+ * excludes needs_review — isNotifiableTerminalStatus). It resolves the Epic
1906
+ * from the PRD's `sourcePromptId`, falling back to the job's own `epicId`.
1907
+ *
1908
+ * Join-only by construction: appendResponseEventIfKnown refuses an unknown or
1909
+ * non-active session and returns false. Nothing here can create an Epic — if
1910
+ * no open authoring Epic exists, the root-cause report in the run directory
1911
+ * is the whole record and the Scheduler tab is where it surfaces.
1912
+ */
1913
+ async function notifyNeedsReview(job, report, {
1914
+ parsePrdRaw = prdParser.parsePrdRaw,
1915
+ appendResponseEvent = appendResponseEventIfKnown,
1916
+ } = {}) {
1917
+ try {
1918
+ if (!job || !report || !report.filed) return false;
1919
+ const prd = await parsePrdRaw(prdPathForJob(job)).catch(() => null);
1920
+ const epicId = prd?.sourcePromptId || job.epicId || null;
1921
+ if (!epicId || !job.cwd) {
1922
+ console.log(`[scheduler] notifyNeedsReview: no authoring Epic for ${job.slug}, report only`);
1923
+ return false;
1924
+ }
1925
+ const message = `${report.summary}. Root-cause report: ${report.path}`;
1926
+ return await appendResponseEvent(job.cwd, epicId, message);
1927
+ } catch (e) {
1928
+ console.error('[scheduler] notifyNeedsReview error', job?.slug, e);
1929
+ return false;
1930
+ }
1931
+ }
1932
+
1896
1933
  /** Scan the tail of a job's log for the canonical rate-limit signal. We look
1897
1934
  * at the last 16 KB — final result event always lands at the end.
1898
1935
  * Uses readTail() so no raw fd lifecycle is needed here. */
@@ -2109,6 +2146,25 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
2109
2146
  }
2110
2147
  }
2111
2148
 
2149
+ // Prepend the Epic's own session digest (PRD 950/958) when this job traces
2150
+ // back to a known Epic — additive only, never mutates the PRD body itself.
2151
+ // A missing/unresolved epicId or a digest build failure is a silent no-op:
2152
+ // the PRD's own body must remain sufficient to complete the job on its own.
2153
+ const digestEpicId = job.epicId ?? job.sourcePromptId ?? null;
2154
+ const originSessionId = resolveOriginSessionId(cwd, digestEpicId);
2155
+ let contextDigestApplied = false;
2156
+ if (originSessionId) {
2157
+ try {
2158
+ const digestText = await buildContextDigest({ cwd, epicId: digestEpicId });
2159
+ if (digestText) {
2160
+ prompt = digestText + '\n\n' + prompt;
2161
+ contextDigestApplied = true;
2162
+ }
2163
+ } catch (e) {
2164
+ safeLog(`[scheduler] context digest build failed (job still dispatches without it): ${e?.message ?? e}\n`);
2165
+ }
2166
+ }
2167
+
2112
2168
  const promptCheck = validatePromptForSpawn(prompt, prdPath);
2113
2169
  if (!promptCheck.ok) {
2114
2170
  safeLog(`[scheduler] ${promptCheck.error}\n`);
@@ -2262,7 +2318,7 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
2262
2318
  sl(`\n[scheduler] ${errMsg}\n`);
2263
2319
  // Sync write: inside a Promise executor callback; must flush meta
2264
2320
  // before resolve() so the spawnJob mutate() that follows sees it.
2265
- config.writeJsonSync(metaPath, { slug: job.slug, cwd, sessionId, exitCode: -1, error: errMsg, startedAt, finishedAt: Date.now(), durationMs, schedulerBootedAt: SCHEDULER_BOOTED_AT, schedulerCodeSha: SCHEDULER_CODE_SHA });
2321
+ config.writeJsonSync(metaPath, { slug: job.slug, cwd, sessionId, exitCode: -1, error: errMsg, startedAt, finishedAt: Date.now(), durationMs, schedulerBootedAt: SCHEDULER_BOOTED_AT, schedulerCodeSha: SCHEDULER_CODE_SHA, originSessionId, contextDigestApplied });
2266
2322
  resolve({ exitCode: -1, durationMs, error: errMsg, sessionId });
2267
2323
  return;
2268
2324
  }
@@ -2293,6 +2349,7 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
2293
2349
  startedAt, finishedAt: Date.now(), durationMs,
2294
2350
  agentResultSubtype, mappedFromSignal: mappedToSuccess ? signal || `code=${exitCode}` : null,
2295
2351
  schedulerBootedAt: SCHEDULER_BOOTED_AT, schedulerCodeSha: SCHEDULER_CODE_SHA,
2352
+ originSessionId, contextDigestApplied,
2296
2353
  });
2297
2354
  resolve({ exitCode: effectiveCode, durationMs, rateLimited, networkError, sessionId });
2298
2355
  },
@@ -2610,19 +2667,19 @@ async function spawnInvestigation(failedJob, runDir) {
2610
2667
  return;
2611
2668
  }
2612
2669
  sl(`\n[scheduler] investigation exit code=${exitCode}\n`);
2613
- // Fold the investigation's <RCA> summary into the RCA feedback item already
2614
- // filed for this job (needs_review jobs only — fileRcaFeedback no-ops when
2670
+ // Fold the investigation's <RCA> summary into the root-cause report already
2671
+ // written for this job (needs_review jobs only — writeRcaReport no-ops when
2615
2672
  // failedJob has no verifierVerdict, e.g. plain 'failed' jobs never got one).
2616
2673
  const investigationText = extractRcaBlock(readTail(investigationLogPath, 64 * 1024));
2617
2674
  if (investigationText) {
2618
- fileRcaFeedback({
2675
+ writeRcaReport({
2619
2676
  job: failedJob,
2620
2677
  runDir,
2621
2678
  verdict: failedJob.verifierVerdict,
2622
2679
  annotations: failedJob.verifierAnnotations,
2623
2680
  investigationText,
2624
2681
  }).catch((e) => {
2625
- console.error('[scheduler] fileRcaFeedback (investigation enrichment) error', failedJob.slug, e);
2682
+ console.error('[scheduler] writeRcaReport (investigation enrichment) error', failedJob.slug, e);
2626
2683
  });
2627
2684
  }
2628
2685
  if (fs.existsSync(fixPath)) {
@@ -3006,14 +3063,16 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
3006
3063
  if (needsReviewRcaSnapshot) {
3007
3064
  // Fire-and-forget, mirroring the DoD drain hook: never blocks the status
3008
3065
  // transition, never throws to this caller.
3009
- fileRcaFeedback({
3066
+ writeRcaReport({
3010
3067
  job: needsReviewRcaSnapshot,
3011
3068
  runDir,
3012
3069
  verdict: needsReviewRcaSnapshot.verifierVerdict,
3013
3070
  annotations: needsReviewRcaSnapshot.verifierAnnotations,
3014
- }).catch((e) => {
3015
- console.error('[scheduler] fileRcaFeedback error', job.slug, e);
3016
- });
3071
+ })
3072
+ .then((report) => notifyNeedsReview(needsReviewRcaSnapshot, report))
3073
+ .catch((e) => {
3074
+ console.error('[scheduler] writeRcaReport error', job.slug, e);
3075
+ });
3017
3076
  }
3018
3077
 
3019
3078
  if (actuallyFailed && failedJobSnapshot) {
@@ -4399,12 +4458,9 @@ const remote = {
4399
4458
  if (candidate && fs.existsSync(candidate)) { dir = d; break; }
4400
4459
  }
4401
4460
  if (!dir) {
4402
- // Every PRD must join an EXISTING, already-human-approved Epic —
4403
- // mintIfMissing:false means this never conjures a new one. Only
4404
- // Epics born from explicit human intent (New Epic UI, or
4405
- // /propose-epic + Approve & start) may write PRDs; this write path
4406
- // is not itself a human-intent gate, so it must not become one by
4407
- // accident.
4461
+ // Every PRD must join an EXISTING, human-created Epic — ensureEpic
4462
+ // is join-only for every caller but the New Epic UI (epicMint.cjs's
4463
+ // SINGLE-CREATOR LAW), so this can never conjure one.
4408
4464
  const { fm } = splitFrontmatter(body);
4409
4465
  try {
4410
4466
  const epic = await ensureEpic(cwd, {
@@ -4415,11 +4471,10 @@ const remote = {
4415
4471
  // a literal-equality join. Any other id (e.g. a PromptTicket.id)
4416
4472
  // simply won't match and this call throws below.
4417
4473
  epicId: fm.sourcePromptId,
4418
- mintIfMissing: false,
4419
- // Join-only call (mintIfMissing:false, explicit epicId): the
4474
+ // Join-only call (no mintAuthority, explicit epicId): the
4420
4475
  // explicitEpicId branch in ensureEpic returns before `source` is
4421
4476
  // ever read, so this is a no-op today — kept for symmetry/audit
4422
- // trail if that join path ever grows a source-touch (PRD 902/905).
4477
+ // trail if that join path ever grows a source-touch.
4423
4478
  source: { producer: 'scheduler-dispatch', prdSlug: slug },
4424
4479
  });
4425
4480
  dir = epic.prdDir;
@@ -4428,7 +4483,7 @@ const remote = {
4428
4483
  return {
4429
4484
  ok: false,
4430
4485
  error: `no existing Epic to join (sourcePromptId=${fm.sourcePromptId ?? 'none'}): ${e?.message ?? 'unknown error'}. `
4431
- + 'Create the Epic first via the New Epic UI or /propose-epic + Approve & start, then pass its id as sourcePromptId.',
4486
+ + 'Create the Epic first via the New Epic UI, then pass its id as sourcePromptId.',
4432
4487
  };
4433
4488
  }
4434
4489
  }
@@ -4438,7 +4493,7 @@ const remote = {
4438
4493
  if (dir === PRDS_DIR) ensureDirs();
4439
4494
  }
4440
4495
 
4441
- // writePrd only ever JOINS an existing Epic now (mintIfMissing:false
4496
+ // writePrd only ever JOINS an existing Epic now (no mintAuthority
4442
4497
  // above) — it can never mint one, so there is no orphaned-mint case left
4443
4498
  // to roll back here (contrast the old PRD 825/851 cleanup, removed).
4444
4499
  const resolved = safeSlugPathIn(dir, slug);
@@ -4540,4 +4595,4 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
4540
4595
  });
4541
4596
  }
4542
4597
 
4543
- 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, candidateArchivedPrdsDirs, resolveArchivedPrdStatus, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, runPrdMigration, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields, executeJob, prdArchivedSkipResult };
4598
+ 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, notifyNeedsReview, isNotifiableTerminalStatus, extractResultTextFromLog, candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, runPrdMigration, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields, executeJob, prdArchivedSkipResult };
@@ -699,47 +699,6 @@ export interface FilesDuplicateResult { ok: boolean; path?: string; error?: stri
699
699
  export interface DocEditResult { ok: boolean; after?: string; error?: string }
700
700
  export interface DocEditSessionResult { tabId: string; requestId: string; ok: boolean; after?: string; error?: string }
701
701
 
702
- export interface SearchFileEntry {
703
- name: string;
704
- path: string;
705
- isDirectory: false;
706
- isFile: true;
707
- }
708
- export interface SearchFilesResult {
709
- ok: boolean;
710
- files: SearchFileEntry[];
711
- error: string | null;
712
- usedRipgrep: boolean;
713
- }
714
- export interface SearchTextMatch {
715
- path: string;
716
- line: number;
717
- column: number;
718
- text: string;
719
- }
720
- export interface SearchTextResult {
721
- ok: boolean;
722
- matches: SearchTextMatch[];
723
- error: string | null;
724
- usedRipgrep: boolean;
725
- }
726
-
727
- export interface RepoLanguageStats { files: number; lines: number }
728
- export interface RepoTopDirectory { path: string; fileCount: number; totalLines: number }
729
- export interface RepoGitStatusSummary { uncommitted: number; branch: string | null }
730
- export interface RepoAnalyzeResult {
731
- ok: true;
732
- cwd: string;
733
- totalFiles: number;
734
- totalLines: number;
735
- languageBreakdown: Record<string, RepoLanguageStats>;
736
- topDirectories: RepoTopDirectory[];
737
- gitStatus: RepoGitStatusSummary;
738
- truncated: boolean;
739
- durationMs: number;
740
- }
741
- export interface RepoAnalyzeError { ok: false; error: string }
742
-
743
702
  export interface WatcherInfo {
744
703
  watcherId: string;
745
704
  tabId: string;
@@ -1521,13 +1480,6 @@ export interface SessionManagerAPI {
1521
1480
  | { as: 'revealPath'; path: string }
1522
1481
  ) => Promise<{ ok: boolean; opener?: string; error?: string }>;
1523
1482
  };
1524
- search: {
1525
- files: (cwd: string, query?: string, opts?: { limit?: number }) => Promise<SearchFilesResult>;
1526
- text: (cwd: string, query: string, opts?: { limit?: number; caseSensitive?: boolean }) => Promise<SearchTextResult>;
1527
- };
1528
- repo: {
1529
- analyze: (cwd: string) => Promise<RepoAnalyzeResult | RepoAnalyzeError>;
1530
- };
1531
1483
  history: {
1532
1484
  aggregate: (req?: HistoryAggregateRequest) => Promise<HistoryAggregateResult>;
1533
1485
  scanProjects: () => Promise<SessionScanResult>;
@@ -1765,6 +1717,12 @@ export interface SessionManagerAPI {
1765
1717
  * in-memory contribution for the cwd; disk stays main's truth, merged
1766
1718
  * inside the same lock epicMint.cjs's ensureEpic serializes through. */
1767
1719
  mergeActiveIndex: (payload: PromptSessionsMergeActiveIndexPayload) => Promise<PromptSessionsMergeActiveIndexResult>;
1720
+ /** Mints a brand-new 'proposed' Epic through main's own ensureEpic()
1721
+ * (lib/promptSessionsCreateEpic.cjs) instead of the renderer
1722
+ * hand-constructing the PromptSession record itself. Unused today —
1723
+ * additive step 1 of a two-PRD chain; a later PRD routes
1724
+ * createPromptSession through this. */
1725
+ create: (payload: PromptSessionsCreateEpicPayload) => Promise<PromptSessionsCreateEpicResult>;
1768
1726
  };
1769
1727
  }
1770
1728
 
@@ -1782,6 +1740,25 @@ export interface PromptSessionsMergeActiveIndexResult {
1782
1740
  events: Record<string, unknown[]>;
1783
1741
  }
1784
1742
 
1743
+ // ─────────────────────────────────── PromptSessions create-epic (main-side ensureEpic)
1744
+ export interface PromptSessionsCreateEpicPayload {
1745
+ cwd: string;
1746
+ goalText: string;
1747
+ tag?: 'feature' | 'bug' | 'discussion' | 'build' | 'project-home-builder' | 'bilko-host-publisher';
1748
+ agentType?: string;
1749
+ source?: {
1750
+ producer: 'new-epic-ui' | 'scheduler-dispatch';
1751
+ prdSlug?: string;
1752
+ runId?: string;
1753
+ sourceTabId?: string;
1754
+ };
1755
+ }
1756
+
1757
+ export interface PromptSessionsCreateEpicResult {
1758
+ epicId: string;
1759
+ session: Record<string, unknown>;
1760
+ }
1761
+
1785
1762
  // ─────────────────────────────────── PromptSession event-appended broadcast
1786
1763
 
1787
1764
  export interface PromptSessionEventAppendedPayload {
@@ -258,13 +258,6 @@ contextBridge.exposeInMainWorld('api', {
258
258
  shell: {
259
259
  open: (opts) => ipcRenderer.invoke('shell:open', opts),
260
260
  },
261
- search: {
262
- files: (cwd, query, opts) => ipcRenderer.invoke('search:files', { cwd, query, opts }),
263
- text: (cwd, query, opts) => ipcRenderer.invoke('search:text', { cwd, query, opts }),
264
- },
265
- repo: {
266
- analyze: (cwd) => ipcRenderer.invoke('repo:analyze', { cwd }),
267
- },
268
261
  schedule: {
269
262
  state: () => ipcRenderer.invoke('schedule:state'),
270
263
  sessionSlots: () => ipcRenderer.invoke('schedule:session-slots'),
@@ -493,5 +486,11 @@ contextBridge.exposeInMainWorld('api', {
493
486
  * renderer-side read-merge-write. Payload carries only this renderer's
494
487
  * own in-memory contribution for the cwd; disk stays main's truth. */
495
488
  mergeActiveIndex: (payload) => ipcRenderer.invoke('promptSessions:merge-active-index', payload),
489
+ /** Mints a brand-new 'proposed' Epic through main's own ensureEpic()
490
+ * (lib/promptSessionsCreateEpic.cjs) instead of the renderer
491
+ * hand-constructing the PromptSession record itself. Unused today —
492
+ * additive step 1 of a two-PRD chain; a later PRD routes
493
+ * createPromptSession through this. */
494
+ create: (payload) => ipcRenderer.invoke('promptSessions:create-epic', payload),
496
495
  },
497
496
  });