claude-code-session-manager 0.40.3 → 0.41.1

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 (54) hide show
  1. package/dist/assets/{TiptapBody-DweEtHks.js → TiptapBody-Cr-O75Yg.js} +1 -1
  2. package/dist/assets/{index-BxVBtmjA.css → index-CKY3mHgV.css} +1 -1
  3. package/dist/assets/{index-QRU8uTeq.js → index-CV9TNjxX.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__/opsErrorLog.test.cjs +87 -0
  17. package/src/main/__tests__/prdCreate.test.cjs +124 -2
  18. package/src/main/__tests__/promptSessionEvents.test.cjs +56 -2
  19. package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
  20. package/src/main/__tests__/rcaFeedbackHook.test.cjs +43 -211
  21. package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +63 -0
  22. package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +86 -0
  23. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +21 -0
  24. package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +78 -0
  25. package/src/main/browserView.cjs +1 -1
  26. package/src/main/chatRunner.cjs +24 -47
  27. package/src/main/config.cjs +12 -6
  28. package/src/main/docEdit.cjs +3 -4
  29. package/src/main/index.cjs +12 -0
  30. package/src/main/ipcSchemas.cjs +59 -11
  31. package/src/main/lib/__tests__/opsOwnership.test.cjs +92 -0
  32. package/src/main/lib/__tests__/projectBriefCore.test.cjs +74 -0
  33. package/src/main/lib/epicMint.cjs +133 -59
  34. package/src/main/lib/opsErrorLog.cjs +96 -0
  35. package/src/main/lib/opsOwnership.cjs +169 -0
  36. package/src/main/lib/prdCreate.cjs +39 -1
  37. package/src/main/lib/prdLocations.cjs +21 -0
  38. package/src/main/lib/projectBriefCore.cjs +70 -4
  39. package/src/main/lib/queueStore.cjs +3 -0
  40. package/src/main/lib/rcaFeedbackHook.cjs +41 -55
  41. package/src/main/logs.cjs +19 -1
  42. package/src/main/projectBrief.cjs +36 -2
  43. package/src/main/promptSessionEvents.cjs +24 -2
  44. package/src/main/promptSessionTranscript.cjs +0 -0
  45. package/src/main/pty.cjs +15 -0
  46. package/src/main/queueOps.cjs +1 -1
  47. package/src/main/scheduler/prdParser.cjs +8 -0
  48. package/src/main/scheduler.cjs +158 -62
  49. package/src/main/templates/PRD_AUTHORING.md +1 -1
  50. package/src/preload/api.d.ts +83 -4
  51. package/src/preload/index.cjs +26 -5
  52. package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +0 -140
  53. package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +0 -290
  54. package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +0 -265
@@ -23,6 +23,7 @@ const {
23
23
  PRD_CREATE_SLUG_RE,
24
24
  registerAdminRoute,
25
25
  createPrd,
26
+ resolveSourcePromptIdFromClaudeSession,
26
27
  } = require('../lib/prdCreate.cjs');
27
28
  const { createAdminHttp } = require('../lib/localAdminHttp.cjs');
28
29
  const config = require('../config.cjs');
@@ -171,7 +172,7 @@ function makeFakeRemoteWithPrdsDir(prdsDir) {
171
172
  async writePrd(slug, body) {
172
173
  try {
173
174
  const filePath = path.join(prdsDir, `${slug}.md`);
174
- await config.writeTextAtomic(filePath, body);
175
+ await config.writeTextAtomic(filePath, body, { writer: 'scheduler' });
175
176
  return { ok: true };
176
177
  } catch (e) {
177
178
  return { ok: false, error: e?.message };
@@ -413,7 +414,8 @@ test('validateWrite allows writes under <root>/session-manager-operations/schedu
413
414
  await fsp.mkdir(prdsDir, { recursive: true });
414
415
  const filePath = path.join(prdsDir, '1-do-thing.md');
415
416
 
416
- await config.writeTextAtomic(filePath, 'PRD body\n');
417
+ // Writes into scheduler/ must declare the owning surface (single-writer law).
418
+ await config.writeTextAtomic(filePath, 'PRD body\n', { writer: 'scheduler' });
417
419
 
418
420
  expect(fs.existsSync(filePath)).toBeTruthy();
419
421
  expect(await fsp.readFile(filePath, 'utf8')).toBe('PRD body\n');
@@ -437,6 +439,57 @@ test('createPrd normalizes a `~`-prefixed cwd via expandHome before calling remo
437
439
  }
438
440
  });
439
441
 
442
+ // ──────────────────────────────────────────── PRD 862: register cwd as an
443
+ // allowed write root at createPrd() time, not only at pty.spawn() time
444
+ //
445
+ // Before this fix, config.cjs's allowedRoots (the write-boundary allowlist)
446
+ // only ever grew via pty.cjs's addAllowedRoot call inside pty.spawn(). A
447
+ // chat-only Epic (headless `claude -p --resume`, no Terminal PTY ever
448
+ // spawned for its project cwd) reached createPrd() -> remote.writePrd() ->
449
+ // config.writeTextAtomic() -> config.validateWrite() and failed with "Write
450
+ // outside allowed write boundaries", purely because no PTY had registered
451
+ // that cwd yet in this process's lifetime. This test reproduces that exact
452
+ // scenario: a brand-new project root that has NEVER had config.addAllowedRoot
453
+ // called for it (mirroring pty.spawn() never having run), writing through the
454
+ // real config.writeTextAtomic into the real session-manager-operations/
455
+ // scheduler/ subtree (not the mocked-remote temp dirs the other tests here
456
+ // use, which pre-register their root via addAllowedRoot in mkTmpPrdsDir).
457
+ test('createPrd registers cwd as an allowed write root itself, so a chat-only Epic (no Terminal PTY ever spawned for this cwd) can still write its PRD', async () => {
458
+ // Must live under $HOME: validatePath's read boundary is home-dir-wide
459
+ // regardless of allowedRoots registration (matching every real project
460
+ // cwd, which checkInsideHome forces inside $HOME) — the bug this test
461
+ // reproduces is specifically about the *write* boundary (validateWrite),
462
+ // not the read one.
463
+ const root = await fsp.mkdtemp(path.join(os.homedir(), '.sm-chat-only-epic-'));
464
+ // Deliberately do NOT call config.addAllowedRoot(root) here — pty.spawn()
465
+ // is the only other caller of addAllowedRoot, and it never runs for a
466
+ // chat-only Epic. createPrd() itself must perform this registration.
467
+ const prdsEpicDir = path.join(root, 'session-manager-operations', 'scheduler', 'epics', 'chat-only-epic', 'prds');
468
+
469
+ try {
470
+ const remote = {
471
+ async allocateParallelGroup() { return 1; },
472
+ async readPrd() { return { ok: false }; },
473
+ async writePrd(slug, body) {
474
+ await fsp.mkdir(prdsEpicDir, { recursive: true });
475
+ const filePath = path.join(prdsEpicDir, `${slug}.md`);
476
+ // Real config.cjs write path — this is what threw
477
+ // "Write outside allowed write boundaries" before the fix.
478
+ await config.writeTextAtomic(filePath, body, { writer: 'scheduler' });
479
+ return { ok: true };
480
+ },
481
+ };
482
+
483
+ const result = await createPrd(validCreateBody({ cwd: root }), remote);
484
+
485
+ expect(result.ok).toBe(true);
486
+ const written = await fsp.readFile(path.join(prdsEpicDir, result.filename), 'utf8');
487
+ expect(written).toMatch(/title: Add widget frobnication/);
488
+ } finally {
489
+ await fsp.rm(root, { recursive: true, force: true });
490
+ }
491
+ });
492
+
440
493
  test('POST /admin/scheduler/create-prd with sourcePromptId writes it into the created PRD frontmatter', async () => {
441
494
  const prdsDir = await mkTmpPrdsDir();
442
495
  const remote = makeFakeRemoteWithPrdsDir(prdsDir);
@@ -453,3 +506,72 @@ test('POST /admin/scheduler/create-prd with sourcePromptId writes it into the cr
453
506
  await admin.stop();
454
507
  }
455
508
  });
509
+
510
+ // ──────────────────────────────────────────── originClaudeSessionId fallback
511
+ // (2026-08-01 fix: a PRD queued via the scheduler_create_prd MCP tool from
512
+ // inside an Epic's own chat session, without an explicit sourcePromptId,
513
+ // must still join that Epic rather than mint an unrelated sibling one.)
514
+
515
+ async function writeActiveIndexFixture(cwd, sessions) {
516
+ const dir = path.join(cwd, 'session-manager-operations', 'prompt-sessions');
517
+ await fsp.mkdir(dir, { recursive: true });
518
+ await fsp.writeFile(path.join(dir, 'active-index.json'), JSON.stringify({ sessions, events: {} }, null, 2));
519
+ }
520
+
521
+ test('resolveSourcePromptIdFromClaudeSession finds the Epic whose claudeSessionId matches', async () => {
522
+ const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-resolve-epic-'));
523
+ try {
524
+ await writeActiveIndexFixture(root, {
525
+ 'psess-abc123': { id: 'psess-abc123', claudeSessionId: 'claude-sess-1', status: 'active' },
526
+ });
527
+ expect(resolveSourcePromptIdFromClaudeSession(root, 'claude-sess-1')).toBe('psess-abc123');
528
+ expect(resolveSourcePromptIdFromClaudeSession(root, 'claude-sess-unknown')).toBeNull();
529
+ expect(resolveSourcePromptIdFromClaudeSession(root, null)).toBeNull();
530
+ } finally {
531
+ await fsp.rm(root, { recursive: true, force: true });
532
+ }
533
+ });
534
+
535
+ test('createPrd resolves sourcePromptId from originClaudeSessionId when the caller omitted sourcePromptId', async () => {
536
+ const root = await fsp.mkdtemp(path.join(os.homedir(), '.sm-origin-session-epic-'));
537
+ config.addAllowedRoot(root);
538
+ try {
539
+ await writeActiveIndexFixture(root, {
540
+ 'psess-always-select-tab': { id: 'psess-always-select-tab', claudeSessionId: 'claude-sess-epic-1', status: 'active' },
541
+ });
542
+ const prdsDir = path.join(root, 'session-manager-operations', 'scheduler', 'epics', 'psess-always-select-tab', 'prds');
543
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
544
+
545
+ const result = await createPrd(
546
+ validCreateBody({ cwd: root, slug: 'joins-the-epic', originClaudeSessionId: 'claude-sess-epic-1' }),
547
+ remote,
548
+ );
549
+
550
+ expect(result.ok).toBe(true);
551
+ const written = await fsp.readFile(path.join(prdsDir, result.filename), 'utf8');
552
+ expect(written).toMatch(/sourcePromptId: psess-always-select-tab/);
553
+ } finally {
554
+ await fsp.rm(root, { recursive: true, force: true });
555
+ }
556
+ });
557
+
558
+ test('createPrd leaves sourcePromptId unset when originClaudeSessionId matches no known Epic (plain SessionTab chat)', async () => {
559
+ const root = await fsp.mkdtemp(path.join(os.homedir(), '.sm-origin-session-no-match-'));
560
+ config.addAllowedRoot(root);
561
+ try {
562
+ await writeActiveIndexFixture(root, {});
563
+ const prdsDir = path.join(root, 'session-manager-operations', 'scheduler', 'prds');
564
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
565
+
566
+ const result = await createPrd(
567
+ validCreateBody({ cwd: root, slug: 'no-epic-match', originClaudeSessionId: 'claude-sess-not-an-epic' }),
568
+ remote,
569
+ );
570
+
571
+ expect(result.ok).toBe(true);
572
+ const written = await fsp.readFile(path.join(prdsDir, result.filename), 'utf8');
573
+ expect(!/sourcePromptId:/.test(written)).toBeTruthy();
574
+ } finally {
575
+ await fsp.rm(root, { recursive: true, force: true });
576
+ }
577
+ });
@@ -9,13 +9,18 @@
9
9
 
10
10
  'use strict';
11
11
 
12
- import { test, expect, beforeEach } from 'vitest';
12
+ import { test, expect, beforeEach, vi } from 'vitest';
13
13
  const fs = require('node:fs');
14
14
  const fsp = require('node:fs/promises');
15
15
  const os = require('node:os');
16
16
  const path = require('node:path');
17
17
  const config = require('../config.cjs');
18
- const { appendResponseEventIfKnown, promptSessionActiveIndexPath } = require('../promptSessionEvents.cjs');
18
+ const {
19
+ appendResponseEventIfKnown,
20
+ promptSessionActiveIndexPath,
21
+ attachWindow,
22
+ EVENT_APPENDED_CHANNEL,
23
+ } = require('../promptSessionEvents.cjs');
19
24
 
20
25
  let cwd;
21
26
 
@@ -104,3 +109,52 @@ test('returns false (never throws) when cwd or sourcePromptId is missing', async
104
109
  await expect(appendResponseEventIfKnown(null, 'psess-1', 'hi')).resolves.toBe(false);
105
110
  await expect(appendResponseEventIfKnown(cwd, null, 'hi')).resolves.toBe(false);
106
111
  });
112
+
113
+ test('broadcasts EVENT_APPENDED_CHANNEL to the attached window on a successful append', async () => {
114
+ await writeIndex({
115
+ sessions: { 'psess-1': { id: 'psess-1', cwd, status: 'active' } },
116
+ events: {
117
+ 'psess-1': [
118
+ { id: 'pevt-1', promptSessionId: 'psess-1', kind: 'prompt', causedByEventId: null, at: '2026-01-01T00:00:00.000Z' },
119
+ ],
120
+ },
121
+ });
122
+
123
+ const send = vi.fn();
124
+ const fakeWindow = { isDestroyed: () => false, webContents: { isDestroyed: () => false, send } };
125
+ attachWindow(fakeWindow);
126
+ try {
127
+ const routed = await appendResponseEventIfKnown(cwd, 'psess-1', 'PRD finished');
128
+ expect(routed).toBe(true);
129
+ expect(send).toHaveBeenCalledTimes(1);
130
+ const [channel, payload] = send.mock.calls[0];
131
+ expect(channel).toBe(EVENT_APPENDED_CHANNEL);
132
+ expect(payload.cwd).toBe(cwd);
133
+ expect(payload.promptSessionId).toBe('psess-1');
134
+ expect(payload.event.kind).toBe('response');
135
+ expect(payload.event.text).toBe('PRD finished');
136
+ expect(payload.event.causedByEventId).toBe('pevt-1');
137
+ } finally {
138
+ attachWindow(null);
139
+ }
140
+ });
141
+
142
+ test('does not throw broadcasting before a window is attached, or after it is destroyed', async () => {
143
+ await writeIndex({
144
+ sessions: { 'psess-1': { id: 'psess-1', cwd, status: 'active' } },
145
+ events: {
146
+ 'psess-1': [
147
+ { id: 'pevt-1', promptSessionId: 'psess-1', kind: 'prompt', causedByEventId: null, at: '2026-01-01T00:00:00.000Z' },
148
+ ],
149
+ },
150
+ });
151
+
152
+ attachWindow(null);
153
+ await expect(appendResponseEventIfKnown(cwd, 'psess-1', 'no window yet')).resolves.toBe(true);
154
+
155
+ const destroyedWindow = { isDestroyed: () => true, webContents: { isDestroyed: () => false, send: vi.fn() } };
156
+ attachWindow(destroyedWindow);
157
+ await expect(appendResponseEventIfKnown(cwd, 'psess-1', 'window destroyed')).resolves.toBe(true);
158
+ expect(destroyedWindow.webContents.send).not.toHaveBeenCalled();
159
+ attachWindow(null);
160
+ });
@@ -78,44 +78,37 @@ function baseJob(overrides = {}) {
78
78
 
79
79
  // ─── dedupe / new-runId ───────────────────────────────────────────────────────
80
80
 
81
- test('fileRcaFeedback: second call with same (slug, runId) is a no-op', async () => {
82
- const cwd = makeProjectWithInbox();
83
- const runDir = path.join(tmpHome, 'run1');
84
- writeRun(runDir, 'testslug');
85
- writePrd(cwd, 'testslug');
86
- const job = baseJob({ cwd });
81
+ // ─── proposal filing (replaced the feedback-file inbox) ──────────────────────
87
82
 
88
- const first = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
89
- expect(first.filed).toBe(true);
90
-
91
- const second = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
92
- expect(second.filed).toBe(false);
93
- expect(second.reason).toBe('duplicate');
94
-
95
- const files = fs.readdirSync(path.join(cwd, 'session-manager-operations', 'feedback'));
96
- expect(files.length).toBe(1);
97
- });
83
+ function readSessions(cwd) {
84
+ const p = path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
85
+ if (!fs.existsSync(p)) return {};
86
+ return JSON.parse(fs.readFileSync(p, 'utf8')).sessions ?? {};
87
+ }
98
88
 
99
- test('fileRcaFeedback: new runId for the same slug files a new RCA', async () => {
89
+ test('fileRcaFeedback: files a PROPOSED Epic carrying the RCA as its opening prompt', async () => {
100
90
  const cwd = makeProjectWithInbox();
101
- const runDir1 = path.join(tmpHome, 'run1');
102
- const runDir2 = path.join(tmpHome, 'run2');
103
- writeRun(runDir1, 'testslug');
104
- writeRun(runDir2, 'testslug');
91
+ const runDir = path.join(tmpHome, 'run1');
92
+ writeRun(runDir, 'testslug');
105
93
  writePrd(cwd, 'testslug');
106
94
 
107
- const r1 = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd, runId: 'run-aaa' }), runDir: runDir1, verdict: 'transcript_errors' });
108
- const r2 = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd, runId: 'run-bbb' }), runDir: runDir2, verdict: 'transcript_errors' });
109
-
110
- expect(r1.filed).toBe(true);
111
- expect(r2.filed).toBe(true);
112
- expect(r1.path).not.toBe(r2.path);
95
+ const res = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
96
+ expect(res.filed).toBe(true);
97
+ expect(res.created).toBe(true);
113
98
 
114
- const files = fs.readdirSync(path.join(cwd, 'session-manager-operations', 'feedback'));
115
- expect(files.length).toBe(2);
99
+ const sessions = readSessions(cwd);
100
+ const epic = sessions[res.epicId];
101
+ // Nothing may auto-run: the whole point is a human gate.
102
+ expect(epic.status).toBe('proposed');
103
+ expect(epic.tag).toBe('bug');
104
+ expect(epic.goalText).toContain('testslug');
105
+ // Short title for the list, full RCA body for the first prompt.
106
+ expect(epic.goalText.split('\n').length).toBe(1);
107
+ expect(epic.openingPrompt).toContain('Root cause');
108
+ expect(epic.openingPrompt).toContain('prdSlug: testslug');
116
109
  });
117
110
 
118
- test('fileRcaFeedback: a duplicate is still detected after /process-feedback archives the file into processed/', async () => {
111
+ test('fileRcaFeedback: a re-trigger for the same failure joins the proposal instead of duplicating it', async () => {
119
112
  const cwd = makeProjectWithInbox();
120
113
  const runDir = path.join(tmpHome, 'run1');
121
114
  writeRun(runDir, 'testslug');
@@ -123,193 +116,35 @@ test('fileRcaFeedback: a duplicate is still detected after /process-feedback arc
123
116
  const job = baseJob({ cwd });
124
117
 
125
118
  const first = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
126
- expect(first.filed).toBe(true);
127
-
128
- const feedbackDir = path.join(cwd, 'session-manager-operations', 'feedback');
129
- const processedDir = path.join(feedbackDir, 'processed');
130
- fs.mkdirSync(processedDir, { recursive: true });
131
- const fileName = path.basename(first.path);
132
- fs.renameSync(first.path, path.join(processedDir, fileName));
133
-
134
119
  const second = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
135
- expect(second.filed).toBe(false);
136
- expect(second.reason).toBe('duplicate');
137
- expect(second.path).toBe(path.join(processedDir, fileName));
138
-
139
- expect(fs.existsSync(path.join(feedbackDir, fileName))).toBe(false);
140
- const liveFiles = fs.readdirSync(feedbackDir).filter((f) => f !== 'processed');
141
- expect(liveFiles.length).toBe(0);
142
- expect(fs.readdirSync(processedDir).length).toBe(1);
120
+
121
+ expect(first.created).toBe(true);
122
+ expect(second.created).toBe(false);
123
+ expect(second.epicId).toBe(first.epicId);
124
+ expect(Object.keys(readSessions(cwd)).length).toBe(1);
143
125
  });
144
126
 
145
- test('fileRcaFeedback: investigationText updates the file in processed/ in place instead of duplicating it in the live dir', async () => {
127
+ test('fileRcaFeedback: a different verdict for the same slug is its own proposal', async () => {
146
128
  const cwd = makeProjectWithInbox();
147
129
  const runDir = path.join(tmpHome, 'run1');
148
130
  writeRun(runDir, 'testslug');
149
131
  writePrd(cwd, 'testslug');
150
- const job = baseJob({ cwd });
151
-
152
- const first = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
153
- expect(first.filed).toBe(true);
154
-
155
- const feedbackDir = path.join(cwd, 'session-manager-operations', 'feedback');
156
- const processedDir = path.join(feedbackDir, 'processed');
157
- fs.mkdirSync(processedDir, { recursive: true });
158
- const fileName = path.basename(first.path);
159
- const processedPath = path.join(processedDir, fileName);
160
- fs.renameSync(first.path, processedPath);
161
-
162
- const second = await rcaFeedbackHook.fileRcaFeedback({
163
- job, runDir, verdict: 'transcript_errors', investigationText: 'Root cause: forgot to bound the poll loop.',
164
- });
165
- expect(second.filed).toBe(true);
166
- expect(second.updated).toBe(true);
167
- expect(second.path).toBe(processedPath);
168
132
 
169
- const after = fs.readFileSync(processedPath, 'utf8');
170
- expect(after).toContain('## Investigation analysis');
171
- expect(fs.existsSync(path.join(feedbackDir, fileName))).toBe(false);
172
- });
173
-
174
- // ─── failure-class matching ───────────────────────────────────────────────────
175
-
176
- test('classifyFailure: detects stuck-loop from an until/while-true/sleep tail', () => {
177
- const logTail = ['doing work', 'until curl -s http://x | jq .uptime; do sleep 5; done', 'still waiting'].join('\n');
178
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail })).toBe('stuck-loop');
179
- });
180
-
181
- test('classifyFailure: detects post-ac-overrun when work continues well past the last AC checkbox with no PASS', () => {
182
- const lines = ['- [x] first AC item done', '- [x] last AC item done'];
183
- for (let i = 0; i < 30; i++) lines.push(`extra post-AC work line ${i}`);
184
- const logTail = lines.join('\n');
185
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail })).toBe('post-ac-overrun');
186
- });
187
-
188
- test('classifyFailure: falls back to uncommitted-changes for that verdict', () => {
189
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'uncommitted_changes', logTail: 'no special markers here' })).toBe('uncommitted-changes');
190
- });
191
-
192
- test('classifyFailure: falls back to no-sentinel for no_verdict_sentinel/pass_no_commit', () => {
193
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail: 'clean tail, no loops' })).toBe('no-sentinel');
194
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'pass_no_commit', logTail: 'clean tail, no loops' })).toBe('no-sentinel');
195
- });
133
+ const a = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
134
+ const b = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd }), runDir, verdict: 'pass_no_commit' });
196
135
 
197
- test('classifyFailure: falls back to transcript-errors for transcript_errors/verify_unavailable', () => {
198
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'transcript_errors', logTail: 'Traceback (most recent call last)' })).toBe('transcript-errors');
199
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'verify_unavailable', logTail: 'verifier threw' })).toBe('transcript-errors');
136
+ expect(a.epicId).not.toBe(b.epicId);
137
+ expect(Object.keys(readSessions(cwd)).length).toBe(2);
200
138
  });
201
139
 
202
- test('classifyFailure: unknown verdict with no matching markers falls back to unknown', () => {
203
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'halt', logTail: 'nothing special' })).toBe('unknown');
204
- });
205
-
206
- test('classifyFailure: detects self-queue from a "Launching skill: session-manager-dev:develop" tail', () => {
207
- const logTail = ['doing setup', 'Launching skill: session-manager-dev:develop', 'skill running'].join('\n');
208
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail })).toBe('self-queue');
209
- });
210
-
211
- test('classifyFailure: detects self-queue from a "Launching skill: session-manager-dev:process-feedback" tail', () => {
212
- const logTail = ['doing setup', 'Launching skill: session-manager-dev:process-feedback', 'skill running'].join('\n');
213
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail })).toBe('self-queue');
214
- });
215
-
216
- test('classifyFailure: detects self-queue from a ScheduleWakeup tool_use even when the tail also contains "sleep" (PRD 771 regression)', () => {
217
- const logTail = [
218
- 'launching background build: eas-cli build --wait',
219
- 'the app will sleep between polls',
220
- '{"type":"tool_use","name":"ScheduleWakeup","input":{"delaySeconds":600}}',
221
- ].join('\n');
222
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail })).toBe('self-queue');
223
- });
224
-
225
- test('classifyFailure: detects already-shipped from a pass_no_commit tail citing a prior commit', () => {
226
- const logTail = ['re-verified acceptance criteria', 'this work was already committed in 9cf0384', 'nothing left to change'].join('\n');
227
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'pass_no_commit', logTail })).toBe('already-shipped');
228
- });
229
-
230
- test('classifyFailure: detects already-shipped from a pass_no_commit tail with "nothing new to commit"', () => {
231
- const logTail = ['checked the diff', 'nothing new to commit for this PRD', 'exiting clean'].join('\n');
232
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'pass_no_commit', logTail })).toBe('already-shipped');
233
- });
234
-
235
- test('classifyFailure: ALREADY_SHIPPED is checked before SELF_QUEUE, so a no_verdict_sentinel tail with both markers classifies as already-shipped', () => {
236
- const logTail = [
237
- 'Launching skill: session-manager-dev:develop',
238
- 'the acceptance criteria were already implemented',
239
- ].join('\n');
240
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail })).toBe('already-shipped');
241
- });
242
-
243
- test('classifyFailure: pass_no_commit tail with no already-shipped phrasing still falls back to no-sentinel (regression)', () => {
244
- const logTail = ['ran the tests', 'everything looked fine', 'printed PASS'].join('\n');
245
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'pass_no_commit', logTail })).toBe('no-sentinel');
246
- });
247
-
248
- test('classifyFailure: STUCK_LOOP/POST_AC_OVERRUN reordering does not change classification when no self-queue marker is present', () => {
249
- const stuckLoopTail = ['doing work', 'until curl -s http://x | jq .uptime; do sleep 5; done', 'still waiting'].join('\n');
250
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail: stuckLoopTail })).toBe('stuck-loop');
251
-
252
- const postAcLines = ['- [x] first AC item done', '- [x] last AC item done'];
253
- for (let i = 0; i < 30; i++) postAcLines.push(`extra post-AC work line ${i}`);
254
- const postAcTail = postAcLines.join('\n');
255
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail: postAcTail })).toBe('post-ac-overrun');
256
- });
257
-
258
- // ─── AC extraction ─────────────────────────────────────────────────────────────
259
-
260
- test('extractAcceptanceCriteria: pulls the section body between the heading and the next heading', () => {
261
- const body = ['# Goal', '', 'Do the thing.', '', '# Acceptance criteria', '', '- [ ] one', '- [ ] two', '', '# Out of scope', '', '- N/A'].join('\n');
262
- expect(rcaFeedbackHook.extractAcceptanceCriteria(body)).toBe('- [ ] one\n- [ ] two');
263
- });
264
-
265
- test('extractAcceptanceCriteria: returns null when no such heading exists', () => {
266
- const body = ['# Goal', '', 'Do the thing.'].join('\n');
267
- expect(rcaFeedbackHook.extractAcceptanceCriteria(body)).toBeNull();
268
- });
269
-
270
- // ─── investigation <RCA> block extraction ──────────────────────────────────────
271
-
272
- test('extractRcaBlock: pulls plain-text block content', () => {
273
- const log = 'preamble\n<RCA>\nForgot to bound the poll loop.\n</RCA>\npostamble';
274
- expect(rcaFeedbackHook.extractRcaBlock(log)).toBe('Forgot to bound the poll loop.');
275
- });
276
-
277
- test('extractRcaBlock: unescapes JSON-escaped newlines from a stream-json transcript', () => {
278
- const log = '{"text":"<RCA>\\nForgot to bound the poll loop.\\nSee scheduler.cjs:1509.\\n</RCA>"}';
279
- expect(rcaFeedbackHook.extractRcaBlock(log)).toBe('Forgot to bound the poll loop.\nSee scheduler.cjs:1509.');
280
- });
281
-
282
- test('extractRcaBlock: returns null when no block is present', () => {
283
- expect(rcaFeedbackHook.extractRcaBlock('no block here')).toBeNull();
284
- });
285
-
286
- // ─── destination resolution ────────────────────────────────────────────────────
287
-
288
- test('resolveDestination: uses the job cwd inbox when it exists', () => {
289
- const cwd = makeProjectWithInbox();
290
- const dest = rcaFeedbackHook.resolveDestination({ cwd });
291
- expect(dest.dir).toBe(path.join(cwd, 'session-manager-operations', 'feedback'));
292
- expect(dest.targetProjectNote).toBeNull();
293
- });
294
-
295
- test('resolveDestination: falls back to the session-manager repo inbox when the target has none', () => {
296
- const cwd = makeProjectWithoutInbox();
297
- const dest = rcaFeedbackHook.resolveDestination({ cwd });
298
- expect(dest.dir).toBe(rcaFeedbackHook.SM_REPO_FEEDBACK_DIR);
299
- expect(dest.targetProjectNote).toContain(cwd);
300
- });
301
-
302
- test('fileRcaFeedback: actually writes into the fallback dir when the target inbox is missing', async () => {
140
+ test('fileRcaFeedback: files into the job\'s own project, no feedback dir required', async () => {
303
141
  const cwd = makeProjectWithoutInbox();
304
142
  const runDir = path.join(tmpHome, 'run1');
305
143
  writeRun(runDir, 'testslug');
306
- writePrd(cwd, 'testslug');
307
144
 
308
145
  const res = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
309
146
  expect(res.filed).toBe(true);
310
- expect(res.path.startsWith(rcaFeedbackHook.SM_REPO_FEEDBACK_DIR)).toBe(true);
311
- const content = fs.readFileSync(res.path, 'utf8');
312
- expect(content).toContain(cwd);
147
+ expect(readSessions(cwd)[res.epicId].status).toBe('proposed');
313
148
  });
314
149
 
315
150
  test('fileRcaFeedback: rejects a slug containing path-traversal segments', async () => {
@@ -339,7 +174,7 @@ test('fileRcaFeedback: SM_RCA_DISABLE=1 skips without writing', async () => {
339
174
 
340
175
  // ─── investigation-text enrichment ──────────────────────────────────────────────
341
176
 
342
- test('fileRcaFeedback: investigationText updates the existing RCA instead of duplicating it', async () => {
177
+ test('fileRcaFeedback: investigationText enriches the pending proposal in place', async () => {
343
178
  const cwd = makeProjectWithInbox();
344
179
  const runDir = path.join(tmpHome, 'run1');
345
180
  writeRun(runDir, 'testslug');
@@ -348,20 +183,17 @@ test('fileRcaFeedback: investigationText updates the existing RCA instead of dup
348
183
 
349
184
  const first = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
350
185
  expect(first.filed).toBe(true);
351
- const before = fs.readFileSync(first.path, 'utf8');
352
- expect(before).not.toContain('## Investigation analysis');
186
+ expect(readSessions(cwd)[first.epicId].openingPrompt).not.toContain('## Investigation analysis');
353
187
 
354
188
  const second = await rcaFeedbackHook.fileRcaFeedback({
355
189
  job, runDir, verdict: 'transcript_errors', investigationText: 'Root cause: forgot to bound the poll loop.',
356
190
  });
357
- expect(second.filed).toBe(true);
191
+ expect(second.epicId).toBe(first.epicId);
358
192
  expect(second.updated).toBe(true);
359
- expect(second.path).toBe(first.path);
360
-
361
- const after = fs.readFileSync(second.path, 'utf8');
362
- expect(after).toContain('## Investigation analysis');
363
- expect(after).toContain('forgot to bound the poll loop');
364
193
 
365
- const files = fs.readdirSync(path.join(cwd, 'session-manager-operations', 'feedback'));
366
- expect(files.length).toBe(1);
194
+ const enriched = readSessions(cwd)[second.epicId];
195
+ expect(enriched.openingPrompt).toContain('## Investigation analysis');
196
+ expect(enriched.openingPrompt).toContain('forgot to bound the poll loop');
197
+ expect(enriched.status).toBe('proposed');
198
+ expect(Object.keys(readSessions(cwd)).length).toBe(1);
367
199
  });
@@ -18,6 +18,7 @@ const fs = require('node:fs');
18
18
  const os = require('node:os');
19
19
  const path = require('node:path');
20
20
  const { archivedTwinExists, archivedPrdPathForJob, prdDirForCwd } = require('../scheduler.cjs');
21
+ const { listArchivedPrdDirs } = require('../lib/prdLocations.cjs');
21
22
 
22
23
  const tmpDirs = [];
23
24
 
@@ -90,3 +91,65 @@ test('archivedPrdPathForJob does not escape prds-archived/ for a well-formed slu
90
91
  const archiveDir = path.resolve(path.join(prdDirForCwd(cwd), '..', 'prds-archived'));
91
92
  expect(resolved.startsWith(archiveDir + path.sep)).toBe(true);
92
93
  });
94
+
95
+ test('archivedTwinExists returns true when the twin lives in an EPIC\'s prds-archived dir', async () => {
96
+ // Regression cover for job 865: every new PRD is Epic-scoped, and
97
+ // archiveCompletedPrd archives into the Epic's own sibling prds-archived/,
98
+ // not the retired flat one. A queue row whose PRD moved there must still
99
+ // be recognized as stale.
100
+ const cwd = makeFixtureCwd();
101
+ const slug = '865-paste-image-in-chat-does-not-work';
102
+ const epicArchiveDir = path.join(
103
+ cwd, 'session-manager-operations', 'scheduler', 'epics', 'psess-fixture-1', 'prds-archived',
104
+ );
105
+ fs.mkdirSync(epicArchiveDir, { recursive: true });
106
+ fs.writeFileSync(path.join(epicArchiveDir, `${slug}.md`), '# Goal\n\nshipped\n', 'utf8');
107
+
108
+ await expect(archivedTwinExists({ slug, cwd })).resolves.toBe(true);
109
+ });
110
+
111
+ test('archivedTwinExists still returns true for a flat-layout twin', async () => {
112
+ const cwd = makeFixtureCwd();
113
+ const slug = 'test-flat-layout-twin';
114
+ const archivedDir = path.join(prdDirForCwd(cwd), '..', 'prds-archived');
115
+ fs.mkdirSync(archivedDir, { recursive: true });
116
+ fs.writeFileSync(path.join(archivedDir, `${slug}.md`), '# Goal\n\nshipped\n', 'utf8');
117
+
118
+ await expect(archivedTwinExists({ slug, cwd })).resolves.toBe(true);
119
+ });
120
+
121
+ test('archivedTwinExists returns false when no twin exists in any layout', async () => {
122
+ const cwd = makeFixtureCwd();
123
+ const slug = 'test-no-twin-anywhere';
124
+ fs.mkdirSync(prdDirForCwd(cwd), { recursive: true });
125
+
126
+ await expect(archivedTwinExists({ slug, cwd })).resolves.toBe(false);
127
+ });
128
+
129
+ test('listArchivedPrdDirs returns the flat dir plus every existing epic archive dir', () => {
130
+ const cwd = makeFixtureCwd();
131
+ const epicArchiveDir1 = path.join(
132
+ cwd, 'session-manager-operations', 'scheduler', 'epics', 'psess-a', 'prds-archived',
133
+ );
134
+ const epicArchiveDir2 = path.join(
135
+ cwd, 'session-manager-operations', 'scheduler', 'epics', 'psess-b', 'prds-archived',
136
+ );
137
+ // psess-c has no prds-archived dir yet — should be excluded.
138
+ fs.mkdirSync(path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', 'psess-c', 'prds'), { recursive: true });
139
+ fs.mkdirSync(epicArchiveDir1, { recursive: true });
140
+ fs.mkdirSync(epicArchiveDir2, { recursive: true });
141
+
142
+ const dirs = listArchivedPrdDirs(cwd);
143
+ const flatDir = path.join(prdDirForCwd(cwd), '..', 'prds-archived');
144
+ expect(dirs).toContain(flatDir);
145
+ expect(dirs).toContain(epicArchiveDir1);
146
+ expect(dirs).toContain(epicArchiveDir2);
147
+ expect(dirs).not.toContain(path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', 'psess-c', 'prds-archived'));
148
+ });
149
+
150
+ test('listArchivedPrdDirs never throws for a cwd with no session-manager-operations/ at all', () => {
151
+ const cwd = makeFixtureCwd();
152
+ expect(() => listArchivedPrdDirs(cwd)).not.toThrow();
153
+ const dirs = listArchivedPrdDirs(cwd);
154
+ expect(dirs.length).toBe(1);
155
+ });