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.
- package/dist/assets/{TiptapBody-DweEtHks.js → TiptapBody-Cr-O75Yg.js} +1 -1
- package/dist/assets/{index-BxVBtmjA.css → index-CKY3mHgV.css} +1 -1
- package/dist/assets/{index-QRU8uTeq.js → index-CV9TNjxX.js} +631 -624
- package/dist/index.html +2 -2
- package/package.json +3 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -5
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +2 -2
- package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/project-status/SKILL.md +20 -34
- package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +68 -0
- package/scripts/lib/watchdogHelpers.cjs +5 -5
- package/scripts/mint-epic.cjs +28 -0
- package/scripts/propose-epic.cjs +55 -0
- package/src/main/__tests__/epicMint.test.cjs +85 -0
- package/src/main/__tests__/opsErrorLog.test.cjs +87 -0
- package/src/main/__tests__/prdCreate.test.cjs +124 -2
- package/src/main/__tests__/promptSessionEvents.test.cjs +56 -2
- package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +43 -211
- package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +63 -0
- package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +86 -0
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +21 -0
- package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +78 -0
- package/src/main/browserView.cjs +1 -1
- package/src/main/chatRunner.cjs +24 -47
- package/src/main/config.cjs +12 -6
- package/src/main/docEdit.cjs +3 -4
- package/src/main/index.cjs +12 -0
- package/src/main/ipcSchemas.cjs +59 -11
- package/src/main/lib/__tests__/opsOwnership.test.cjs +92 -0
- package/src/main/lib/__tests__/projectBriefCore.test.cjs +74 -0
- package/src/main/lib/epicMint.cjs +133 -59
- package/src/main/lib/opsErrorLog.cjs +96 -0
- package/src/main/lib/opsOwnership.cjs +169 -0
- package/src/main/lib/prdCreate.cjs +39 -1
- package/src/main/lib/prdLocations.cjs +21 -0
- package/src/main/lib/projectBriefCore.cjs +70 -4
- package/src/main/lib/queueStore.cjs +3 -0
- package/src/main/lib/rcaFeedbackHook.cjs +41 -55
- package/src/main/logs.cjs +19 -1
- package/src/main/projectBrief.cjs +36 -2
- package/src/main/promptSessionEvents.cjs +24 -2
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/pty.cjs +15 -0
- package/src/main/queueOps.cjs +1 -1
- package/src/main/scheduler/prdParser.cjs +8 -0
- package/src/main/scheduler.cjs +158 -62
- package/src/main/templates/PRD_AUTHORING.md +1 -1
- package/src/preload/api.d.ts +83 -4
- package/src/preload/index.cjs +26 -5
- package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +0 -140
- package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +0 -290
- 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
|
-
|
|
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 {
|
|
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
|
+
});
|
|
Binary file
|
|
@@ -78,44 +78,37 @@ function baseJob(overrides = {}) {
|
|
|
78
78
|
|
|
79
79
|
// ─── dedupe / new-runId ───────────────────────────────────────────────────────
|
|
80
80
|
|
|
81
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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:
|
|
89
|
+
test('fileRcaFeedback: files a PROPOSED Epic carrying the RCA as its opening prompt', async () => {
|
|
100
90
|
const cwd = makeProjectWithInbox();
|
|
101
|
-
const
|
|
102
|
-
|
|
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
|
|
108
|
-
|
|
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
|
|
115
|
-
|
|
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
|
|
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
|
-
|
|
136
|
-
expect(
|
|
137
|
-
expect(second.
|
|
138
|
-
|
|
139
|
-
expect(
|
|
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:
|
|
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
|
|
170
|
-
|
|
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
|
-
|
|
198
|
-
expect(
|
|
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('
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
366
|
-
expect(
|
|
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
|
+
});
|