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
@@ -0,0 +1,101 @@
1
+ /**
2
+ * promptSessionSchema.test.cjs — unit tests for the canonical PromptSession
3
+ * zod schema and its assertValidPromptSession fail-closed helper.
4
+ *
5
+ * Run: timeout 300 npx vitest run src/main/__tests__/promptSessionSchema.test.cjs
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ import { test, expect } from 'vitest';
11
+ const { PromptSessionSchema, assertValidPromptSession } = require('../lib/promptSessionSchema.cjs');
12
+
13
+ test('a valid full record parses', () => {
14
+ const full = {
15
+ id: 'epic-abc12345',
16
+ cwd: '/home/bilko/Projects/session-manager',
17
+ goalText: 'do the thing',
18
+ claudeSessionId: 'c1b2a3d4-0000-0000-0000-000000000000',
19
+ status: 'proposed',
20
+ createdAt: '2026-08-02T00:00:00.000Z',
21
+ completedAt: null,
22
+ resumedFromId: 'old-epic-id',
23
+ tag: 'feature',
24
+ openingPrompt: 'full body of the first prompt',
25
+ source: {
26
+ producer: 'new-epic-ui',
27
+ prdSlug: '123-fix-thing',
28
+ runId: 'run-1',
29
+ sourceTabId: 'tab-1',
30
+ },
31
+ agentType: 'architect',
32
+ };
33
+ expect(() => assertValidPromptSession(full)).not.toThrow();
34
+ expect(PromptSessionSchema.safeParse(full).success).toBe(true);
35
+ });
36
+
37
+ test('a valid minimal record (only required fields) parses', () => {
38
+ const minimal = {
39
+ id: 'epic-abc12345',
40
+ cwd: '/home/bilko/Projects/session-manager',
41
+ goalText: 'do the thing',
42
+ claudeSessionId: 'c1b2a3d4-0000-0000-0000-000000000000',
43
+ status: 'active',
44
+ createdAt: '2026-08-02T00:00:00.000Z',
45
+ completedAt: null,
46
+ };
47
+ expect(() => assertValidPromptSession(minimal)).not.toThrow();
48
+ });
49
+
50
+ test('missing a required field fails', () => {
51
+ const missingGoalText = {
52
+ id: 'epic-abc12345',
53
+ cwd: '/home/bilko/Projects/session-manager',
54
+ claudeSessionId: 'c1b2a3d4-0000-0000-0000-000000000000',
55
+ status: 'active',
56
+ createdAt: '2026-08-02T00:00:00.000Z',
57
+ completedAt: null,
58
+ };
59
+ expect(() => assertValidPromptSession(missingGoalText)).toThrow(/goalText/);
60
+ });
61
+
62
+ test('an invalid status value fails', () => {
63
+ const badStatus = {
64
+ id: 'epic-abc12345',
65
+ cwd: '/home/bilko/Projects/session-manager',
66
+ goalText: 'do the thing',
67
+ claudeSessionId: 'c1b2a3d4-0000-0000-0000-000000000000',
68
+ status: 'in-progress',
69
+ createdAt: '2026-08-02T00:00:00.000Z',
70
+ completedAt: null,
71
+ };
72
+ expect(() => assertValidPromptSession(badStatus)).toThrow(/status/);
73
+ });
74
+
75
+ test('an invalid tag value fails', () => {
76
+ const badTag = {
77
+ id: 'epic-abc12345',
78
+ cwd: '/home/bilko/Projects/session-manager',
79
+ goalText: 'do the thing',
80
+ claudeSessionId: 'c1b2a3d4-0000-0000-0000-000000000000',
81
+ status: 'active',
82
+ createdAt: '2026-08-02T00:00:00.000Z',
83
+ completedAt: null,
84
+ tag: 'not-a-real-tag',
85
+ };
86
+ expect(() => assertValidPromptSession(badTag)).toThrow(/tag/);
87
+ });
88
+
89
+ test('a source object missing its required producer fails', () => {
90
+ const badSource = {
91
+ id: 'epic-abc12345',
92
+ cwd: '/home/bilko/Projects/session-manager',
93
+ goalText: 'do the thing',
94
+ claudeSessionId: 'c1b2a3d4-0000-0000-0000-000000000000',
95
+ status: 'active',
96
+ createdAt: '2026-08-02T00:00:00.000Z',
97
+ completedAt: null,
98
+ source: { prdSlug: '123-fix-thing' },
99
+ };
100
+ expect(() => assertValidPromptSession(badSource)).toThrow(/producer/);
101
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * promptSessionsCreateEpicHandler.test.cjs — unit tests for the
3
+ * 'promptSessions:create-epic' IPC handler (lib/promptSessionsCreateEpic.cjs),
4
+ * PRD 953: an additive-only IPC channel letting the renderer mint an Epic
5
+ * through main's own ensureEpic() instead of hand-constructing the
6
+ * PromptSession record itself. Nothing in the app calls this yet.
7
+ *
8
+ * Exercises the handler the same way index.cjs wires it — schema validation
9
+ * (ipcSchemas.cjs's `validated` wrapper) in front of the business logic —
10
+ * without touching Electron's `ipcMain`, mirroring activeIndexMerge.test.cjs's
11
+ * pattern of testing the underlying function directly.
12
+ *
13
+ * Run: timeout 300 npx vitest run src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ import { test, expect, afterEach } from 'vitest';
19
+ const fsp = require('node:fs/promises');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+ const { schemas, validated } = require('../ipcSchemas.cjs');
23
+ const { createEpicViaIpc } = require('../lib/promptSessionsCreateEpic.cjs');
24
+ const { readActiveIndex } = require('../lib/epicMint.cjs');
25
+ const { PromptSessionSchema } = require('../lib/promptSessionSchema.cjs');
26
+ const config = require('../config.cjs');
27
+
28
+ const handler = validated(schemas.promptSessionsCreateEpic, ({ cwd, goalText, tag, agentType, source }) =>
29
+ createEpicViaIpc(cwd, { goalText, tag, agentType, source }));
30
+
31
+ const tmpDirs = [];
32
+ afterEach(async () => {
33
+ while (tmpDirs.length) {
34
+ const d = tmpDirs.pop();
35
+ await fsp.rm(d, { recursive: true, force: true });
36
+ }
37
+ });
38
+
39
+ async function mkCwd() {
40
+ const cwd = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-promptsessions-create-epic-'));
41
+ config.addAllowedRoot(cwd);
42
+ tmpDirs.push(cwd);
43
+ return cwd;
44
+ }
45
+
46
+ test('a valid request mints a new proposed Epic and returns a schema-valid session read back from disk', async () => {
47
+ const cwd = await mkCwd();
48
+ const result = await handler(null, {
49
+ cwd,
50
+ goalText: 'Ship the widget',
51
+ tag: 'feature',
52
+ agentType: 'architect',
53
+ source: { producer: 'scheduler-dispatch' },
54
+ });
55
+
56
+ expect(result.epicId).toBeTruthy();
57
+ expect(result.session.id).toBe(result.epicId);
58
+ expect(result.session.status).toBe('proposed');
59
+ expect(result.session.goalText).toBe('Ship the widget');
60
+ expect(result.session.tag).toBe('feature');
61
+ expect(result.session.agentType).toBe('architect');
62
+ expect(result.session.source).toEqual({ producer: 'scheduler-dispatch' });
63
+
64
+ // Response is the just-written record read back from disk, not a second
65
+ // independently re-derived copy.
66
+ const index = readActiveIndex(cwd);
67
+ expect(index.sessions[result.epicId]).toEqual(result.session);
68
+
69
+ const parsed = PromptSessionSchema.safeParse(result.session);
70
+ expect(parsed.success).toBe(true);
71
+ });
72
+
73
+ test('a request with no tag/agentType/source still mints and validates cleanly', async () => {
74
+ const cwd = await mkCwd();
75
+ const result = await handler(null, { cwd, goalText: 'Minimal request' });
76
+
77
+ expect(result.session.goalText).toBe('Minimal request');
78
+ expect(PromptSessionSchema.safeParse(result.session).success).toBe(true);
79
+ });
80
+
81
+ // ipcSchemas.cjs's `validated` wrapper calls schema.parse synchronously
82
+ // before invoking the handler — a ZodError throws synchronously from the
83
+ // call itself (not a rejected promise), which is exactly the "rejected
84
+ // before ensureEpic ever runs" guarantee this test asserts.
85
+ test('a request missing cwd is rejected by ipcSchemas validation before ensureEpic ever runs', () => {
86
+ expect(() => handler(null, { goalText: 'No cwd here' })).toThrow();
87
+ });
88
+
89
+ test('a request missing goalText is rejected by ipcSchemas validation before ensureEpic ever runs', async () => {
90
+ const cwd = await mkCwd();
91
+ expect(() => handler(null, { cwd })).toThrow();
92
+
93
+ const index = readActiveIndex(cwd);
94
+ expect(Object.keys(index.sessions)).toHaveLength(0);
95
+ });
96
+
97
+ test('an unknown tag value is rejected by ipcSchemas validation', () => {
98
+ expect(() => handler(null, { cwd: '/tmp/whatever', goalText: 'x', tag: 'not-a-real-tag' })).toThrow();
99
+ });
100
+
101
+ test('an unknown source.producer value is rejected by ipcSchemas validation', () => {
102
+ expect(() => handler(null, {
103
+ cwd: '/tmp/whatever',
104
+ goalText: 'x',
105
+ source: { producer: 'bogus-producer' },
106
+ })).toThrow();
107
+ });
108
+
109
+ // createEpicViaIpc's whole point is forceNewEpic: true — it must never join
110
+ // an existing Epic via ensureEpic's goalText-similarity join branch, even
111
+ // when called twice with the identical goalText for the same cwd.
112
+ test('two requests with identical goalText for the same cwd mint two distinct Epics, never joining', async () => {
113
+ const cwd = await mkCwd();
114
+ const first = await handler(null, { cwd, goalText: 'Same title both times' });
115
+ const second = await handler(null, { cwd, goalText: 'Same title both times' });
116
+
117
+ expect(first.epicId).not.toBe(second.epicId);
118
+ const index = readActiveIndex(cwd);
119
+ expect(index.sessions[first.epicId]).toBeDefined();
120
+ expect(index.sessions[second.epicId]).toBeDefined();
121
+ });
122
+
123
+ // The renderer can send an arbitrary `cwd` string over IPC — config.cjs's
124
+ // validatePath (allowedRoots = home dir + every cwd a TAB has opened) is the
125
+ // boundary every other renderer-facing path IPC already passes through
126
+ // (config:read-json/write-json, lib/activeIndexMerge.cjs's own merge); this
127
+ // handler must be gated by the same boundary rather than handing an
128
+ // unvalidated path straight to epicMint.cjs's raw `fs` writes.
129
+ test('a cwd outside every allowed root is rejected before anything is minted', async () => {
130
+ const unregistered = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-promptsessions-create-epic-unregistered-'));
131
+ tmpDirs.push(unregistered);
132
+
133
+ await expect(handler(null, { cwd: unregistered, goalText: 'Should never mint' })).rejects.toThrow(
134
+ /outside allowed boundaries/,
135
+ );
136
+
137
+ const index = readActiveIndex(unregistered);
138
+ expect(Object.keys(index.sessions)).toHaveLength(0);
139
+ });
@@ -36,8 +36,20 @@ function old(overrides = {}) {
36
36
  }
37
37
 
38
38
  let queueHistory;
39
+ let prevOverride;
39
40
 
40
41
  beforeEach(() => {
42
+ // Point HISTORY_PATH at an isolated tmpdir file (SM_HISTORY_PATH_OVERRIDE)
43
+ // instead of the real machine-global history.jsonl — a live Electron
44
+ // instance's scheduler.cjs writes that same global file concurrently,
45
+ // which races this suite's unlink/append calls and produces real
46
+ // production data in place of the expected empty state.
47
+ prevOverride = process.env.SM_HISTORY_PATH_OVERRIDE;
48
+ process.env.SM_HISTORY_PATH_OVERRIDE = path.join(
49
+ os.tmpdir(),
50
+ `queueHistory-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl`
51
+ );
52
+
41
53
  // Fresh require per test so HISTORY_PATH's target file starts clean.
42
54
  delete require.cache[require.resolve('../lib/queueHistory.cjs')];
43
55
  queueHistory = require('../lib/queueHistory.cjs');
@@ -46,6 +58,8 @@ beforeEach(() => {
46
58
 
47
59
  afterEach(() => {
48
60
  try { fs.unlinkSync(queueHistory.HISTORY_PATH); } catch { /* ok if absent */ }
61
+ if (prevOverride === undefined) delete process.env.SM_HISTORY_PATH_OVERRIDE;
62
+ else process.env.SM_HISTORY_PATH_OVERRIDE = prevOverride;
49
63
  });
50
64
 
51
65
  // ---------- partitionJobs ----------
@@ -0,0 +1,188 @@
1
+ /**
2
+ * rcaReport.test.cjs — unit tests for src/main/lib/rcaReport.cjs.
3
+ *
4
+ * Run: timeout 120 npx vitest run src/main/__tests__/rcaReport.test.cjs
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ import { test, expect, beforeEach, afterEach } from 'vitest';
10
+ const fs = require('node:fs');
11
+ const os = require('node:os');
12
+ const path = require('node:path');
13
+
14
+ let tmpHome;
15
+ let realHome;
16
+ let rcaReport;
17
+
18
+ // config.cjs bakes os.homedir() into top-level consts at first require
19
+ // (allowedRoots/WRITE_PREFIXES). Node's require cache must be purged per test
20
+ // so each test's tmpHome takes effect — vi.resetModules() only resets vitest's
21
+ // ESM graph, not require().
22
+ const MODULES_TO_RELOAD = ['../lib/rcaReport.cjs', '../config.cjs'];
23
+
24
+ function purgeRequireCache() {
25
+ for (const m of MODULES_TO_RELOAD) {
26
+ try { delete require.cache[require.resolve(m)]; } catch { /* not loaded yet */ }
27
+ }
28
+ }
29
+
30
+ beforeEach(() => {
31
+ realHome = process.env.HOME;
32
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'rca-report-test-'));
33
+ process.env.HOME = tmpHome;
34
+ delete process.env.SM_RCA_DISABLE;
35
+ purgeRequireCache();
36
+ rcaReport = require('../lib/rcaReport.cjs');
37
+ });
38
+
39
+ afterEach(() => {
40
+ process.env.HOME = realHome;
41
+ delete process.env.SM_RCA_DISABLE;
42
+ fs.rmSync(tmpHome, { recursive: true, force: true });
43
+ purgeRequireCache();
44
+ });
45
+
46
+ // ─── helpers ──────────────────────────────────────────────────────────────────
47
+
48
+ function makeProject(name = 'proj') {
49
+ const cwd = path.join(tmpHome, name);
50
+ fs.mkdirSync(cwd, { recursive: true });
51
+ return cwd;
52
+ }
53
+
54
+ function writeRun(runDir, slug, { logLines = ['line1', 'line2'], exitCode = 1, durationMs = 1234 } = {}) {
55
+ fs.mkdirSync(runDir, { recursive: true });
56
+ fs.writeFileSync(path.join(runDir, `${slug}.log`), logLines.join('\n') + '\n');
57
+ fs.writeFileSync(path.join(runDir, `${slug}.meta.json`), JSON.stringify({ exitCode, durationMs }));
58
+ }
59
+
60
+ function writePrd(cwd, slug, { acBody = '- [ ] `timeout 10 echo ok` passes.' } = {}) {
61
+ const prdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'prds');
62
+ fs.mkdirSync(prdsDir, { recursive: true });
63
+ const body = ['---', `title: ${slug}`, '---', '', '# Goal', '', 'Do the thing.', '', '# Acceptance criteria', '', acBody, '', '# Out of scope', '', '- N/A'].join('\n');
64
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), body);
65
+ }
66
+
67
+ function baseJob(overrides = {}) {
68
+ return { slug: 'testslug', runId: '2026-07-24T00-00-00-000Z', exitCode: 1, error: 'something broke', ...overrides };
69
+ }
70
+
71
+ function readSessions(cwd) {
72
+ const p = path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
73
+ if (!fs.existsSync(p)) return {};
74
+ return JSON.parse(fs.readFileSync(p, 'utf8')).sessions ?? {};
75
+ }
76
+
77
+ // ─── report writing ──────────────────────────────────────────────────────────
78
+
79
+ test('writeRcaReport: writes root-cause-<slug>.md into the run directory', async () => {
80
+ const cwd = makeProject();
81
+ const runDir = path.join(tmpHome, 'run1');
82
+ writeRun(runDir, 'testslug');
83
+ writePrd(cwd, 'testslug');
84
+
85
+ const res = await rcaReport.writeRcaReport({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
86
+
87
+ expect(res.filed).toBe(true);
88
+ expect(res.path).toBe(path.join(runDir, 'root-cause-testslug.md'));
89
+ const body = fs.readFileSync(res.path, 'utf8');
90
+ expect(body).toContain('prdSlug: testslug');
91
+ expect(body).toContain('# What happened');
92
+ expect(body).toContain('# The PRD\'s acceptance criteria');
93
+ expect(body).toContain('timeout 10 echo ok');
94
+ expect(res.summary).toContain('testslug');
95
+ expect(res.summary.split('\n')).toHaveLength(1);
96
+ });
97
+
98
+ test('writeRcaReport: creates no Epic — a parked job never opens work on its own', async () => {
99
+ const cwd = makeProject();
100
+ const runDir = path.join(tmpHome, 'run1');
101
+ writeRun(runDir, 'testslug');
102
+ writePrd(cwd, 'testslug');
103
+
104
+ await rcaReport.writeRcaReport({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
105
+
106
+ expect(readSessions(cwd)).toEqual({});
107
+ });
108
+
109
+ test('writeRcaReport: a second call for the same run overwrites in place, never duplicates', async () => {
110
+ const cwd = makeProject();
111
+ const runDir = path.join(tmpHome, 'run1');
112
+ writeRun(runDir, 'testslug');
113
+ writePrd(cwd, 'testslug');
114
+ const job = baseJob({ cwd });
115
+
116
+ const first = await rcaReport.writeRcaReport({ job, runDir, verdict: 'transcript_errors' });
117
+ const second = await rcaReport.writeRcaReport({ job, runDir, verdict: 'transcript_errors' });
118
+
119
+ expect(second.path).toBe(first.path);
120
+ expect(fs.readdirSync(runDir).filter((f) => f.startsWith('root-cause-'))).toHaveLength(1);
121
+ });
122
+
123
+ test('writeRcaReport: investigationText enriches the report in place', async () => {
124
+ const cwd = makeProject();
125
+ const runDir = path.join(tmpHome, 'run1');
126
+ writeRun(runDir, 'testslug');
127
+ writePrd(cwd, 'testslug');
128
+ const job = baseJob({ cwd });
129
+
130
+ const first = await rcaReport.writeRcaReport({ job, runDir, verdict: 'transcript_errors' });
131
+ expect(fs.readFileSync(first.path, 'utf8')).not.toContain('## Investigation analysis');
132
+
133
+ const second = await rcaReport.writeRcaReport({
134
+ job, runDir, verdict: 'transcript_errors', investigationText: 'Root cause: forgot to bound the poll loop.',
135
+ });
136
+
137
+ expect(second.path).toBe(first.path);
138
+ const enriched = fs.readFileSync(second.path, 'utf8');
139
+ expect(enriched).toContain('## Investigation analysis');
140
+ expect(enriched).toContain('forgot to bound the poll loop');
141
+ });
142
+
143
+ test('writeRcaReport: works for a job whose PRD cannot be read (no acceptance criteria on record)', async () => {
144
+ const cwd = makeProject('bare-proj');
145
+ const runDir = path.join(tmpHome, 'run1');
146
+ writeRun(runDir, 'testslug');
147
+
148
+ const res = await rcaReport.writeRcaReport({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
149
+
150
+ expect(res.filed).toBe(true);
151
+ expect(fs.readFileSync(res.path, 'utf8')).toContain('acceptance criteria not found');
152
+ });
153
+
154
+ // ─── skip paths ──────────────────────────────────────────────────────────────
155
+
156
+ test('writeRcaReport: rejects a slug containing path-traversal segments', async () => {
157
+ const cwd = makeProject();
158
+ const runDir = path.join(tmpHome, 'run1');
159
+ fs.mkdirSync(runDir, { recursive: true });
160
+
161
+ const res = await rcaReport.writeRcaReport({ job: baseJob({ cwd, slug: '../../etc/passwd' }), runDir, verdict: 'transcript_errors' });
162
+
163
+ expect(res.filed).toBe(false);
164
+ expect(res.reason).toBe('unsafe-slug');
165
+ });
166
+
167
+ test('writeRcaReport: skips when there is no run directory to write into', async () => {
168
+ const cwd = makeProject();
169
+
170
+ const res = await rcaReport.writeRcaReport({ job: baseJob({ cwd }), verdict: 'transcript_errors' });
171
+
172
+ expect(res.filed).toBe(false);
173
+ expect(res.reason).toBe('no-run-dir');
174
+ });
175
+
176
+ test('writeRcaReport: SM_RCA_DISABLE=1 skips without writing', async () => {
177
+ process.env.SM_RCA_DISABLE = '1';
178
+ const cwd = makeProject();
179
+ const runDir = path.join(tmpHome, 'run1');
180
+ writeRun(runDir, 'testslug');
181
+ writePrd(cwd, 'testslug');
182
+
183
+ const res = await rcaReport.writeRcaReport({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
184
+
185
+ expect(res.filed).toBe(false);
186
+ expect(res.reason).toBe('disabled');
187
+ expect(fs.readdirSync(runDir).filter((f) => f.startsWith('root-cause-'))).toHaveLength(0);
188
+ });
@@ -25,7 +25,7 @@ const os = require('node:os');
25
25
  const fs = require('node:fs');
26
26
  const path = require('node:path');
27
27
  const { execFileSync } = require('node:child_process');
28
- const { verifyRun } = require('../runVerify.cjs');
28
+ const { verifyRun, parseLog } = require('../runVerify.cjs');
29
29
 
30
30
  // ─── helpers ──────────────────────────────────────────────────────────────────
31
31
 
@@ -792,6 +792,109 @@ test('harness tool error (<tool_use_error>) in final 20% → clean', async () =>
792
792
  } finally { rmdir(tmp); }
793
793
  });
794
794
 
795
+ // ─── subagent-internal tool errors (incident: 955-route-epic-create-through-ipc, 2026-08-03) ──
796
+
797
+ test('subagent-internal is_error (parent_tool_use_id set) in final 20% → clean', async () => {
798
+ const tmp = makeTmpDir();
799
+ try {
800
+ const slug = '955-route-epic-create-through-ipc';
801
+ const events = [];
802
+ for (let k = 0; k < 8; k++) {
803
+ events.push({ type: 'assistant', message: { role: 'assistant', content: [
804
+ { type: 'tool_use', id: `t${k}`, name: 'Read', input: { description: `read ${k}` } }] } });
805
+ events.push({ type: 'user', message: { role: 'user', content: [
806
+ { type: 'tool_result', tool_use_id: `t${k}`, content: 'ok', is_error: false }] } });
807
+ }
808
+ // A code-reviewer Task subagent runs its own exploratory grep, which exits
809
+ // 1 (no matches) — this happened INSIDE the subagent, not the main agent.
810
+ events.push({
811
+ type: 'assistant',
812
+ parent_tool_use_id: 'toolu_task_001',
813
+ message: { role: 'assistant', content: [
814
+ { type: 'tool_use', id: 'tgrep', name: 'Bash', input: { description: 'grep for buildPromptSession' } }] },
815
+ });
816
+ events.push({
817
+ type: 'user',
818
+ parent_tool_use_id: 'toolu_task_001',
819
+ message: { role: 'user', content: [
820
+ { type: 'tool_result', tool_use_id: 'tgrep', content: '', is_error: true }] },
821
+ });
822
+ events.push({ type: 'result', subtype: 'success', result: 'All acceptance criteria verified.' });
823
+
824
+ writeLog(tmp, slug, events);
825
+ const prdPath = writePrd(tmp, slug, '# Route Epic create through IPC');
826
+ const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [], committedDuringRun: true });
827
+ assert.equal(verdict.verdict, 'clean', `subagent-internal is_error must not flag, got ${verdict.verdict}: ${verdict.reason}`);
828
+ } finally { rmdir(tmp); }
829
+ });
830
+
831
+ test('main-agent is_error (no parent_tool_use_id) in final 20% → still transcript_errors', async () => {
832
+ const tmp = makeTmpDir();
833
+ try {
834
+ const slug = '955-main-agent-error-guard';
835
+ const events = [];
836
+ for (let k = 0; k < 8; k++) {
837
+ events.push({ type: 'assistant', message: { role: 'assistant', content: [
838
+ { type: 'tool_use', id: `t${k}`, name: 'Read', input: { description: `read ${k}` } }] } });
839
+ events.push({ type: 'user', message: { role: 'user', content: [
840
+ { type: 'tool_result', tool_use_id: `t${k}`, content: 'ok', is_error: false }] } });
841
+ }
842
+ events.push({ type: 'assistant', message: { role: 'assistant', content: [
843
+ { type: 'tool_use', id: 'tbad', name: 'Bash', input: { description: 'run tests' } }] } });
844
+ events.push({ type: 'user', message: { role: 'user', content: [
845
+ { type: 'tool_result', tool_use_id: 'tbad', content: 'FAILED: 1 test failed', is_error: true }] } });
846
+ events.push({ type: 'result', subtype: 'success', result: 'All acceptance criteria verified.' });
847
+
848
+ writeLog(tmp, slug, events);
849
+ const prdPath = writePrd(tmp, slug, '# Guard: main-agent errors still flag');
850
+ const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [], committedDuringRun: true });
851
+ assert.equal(verdict.verdict, 'transcript_errors', `main-agent is_error must still flag, got ${verdict.verdict}: ${verdict.reason}`);
852
+ } finally { rmdir(tmp); }
853
+ });
854
+
855
+ test('parseLog populates parentToolUseId on tool_use and tool_result events, defaulting to null', () => {
856
+ const tmp = makeTmpDir();
857
+ try {
858
+ const slug = '955-parselog-parent-id';
859
+ const events = [
860
+ {
861
+ type: 'assistant',
862
+ parent_tool_use_id: 'toolu_parent_001',
863
+ message: { role: 'assistant', content: [
864
+ { type: 'tool_use', id: 'tchild', name: 'Bash', input: { description: 'grep in subagent' } }] },
865
+ },
866
+ {
867
+ type: 'user',
868
+ parent_tool_use_id: 'toolu_parent_001',
869
+ message: { role: 'user', content: [
870
+ { type: 'tool_result', tool_use_id: 'tchild', content: '', is_error: true }] },
871
+ },
872
+ {
873
+ type: 'assistant',
874
+ message: { role: 'assistant', content: [
875
+ { type: 'tool_use', id: 'ttop', name: 'Bash', input: { description: 'run in main agent' } }] },
876
+ },
877
+ {
878
+ type: 'user',
879
+ message: { role: 'user', content: [
880
+ { type: 'tool_result', tool_use_id: 'ttop', content: 'ok', is_error: false }] },
881
+ },
882
+ ];
883
+ writeLog(tmp, slug, events);
884
+ const { events: parsed } = parseLog(path.join(tmp, `${slug}.log`));
885
+
886
+ const childUse = parsed.find((ev) => ev.kind === 'tool_use' && ev.toolUseId === 'tchild');
887
+ const childResult = parsed.find((ev) => ev.kind === 'tool_result' && ev.toolUseId === 'tchild');
888
+ const topUse = parsed.find((ev) => ev.kind === 'tool_use' && ev.toolUseId === 'ttop');
889
+ const topResult = parsed.find((ev) => ev.kind === 'tool_result' && ev.toolUseId === 'ttop');
890
+
891
+ assert.equal(childUse.parentToolUseId, 'toolu_parent_001');
892
+ assert.equal(childResult.parentToolUseId, 'toolu_parent_001');
893
+ assert.equal(topUse.parentToolUseId, null);
894
+ assert.equal(topResult.parentToolUseId, null);
895
+ } finally { rmdir(tmp); }
896
+ });
897
+
795
898
  test('isHarnessToolError detects wrapper and "No such tool available"', () => {
796
899
  const { isHarnessToolError } = require('../runVerify.cjs');
797
900
  assert.equal(isHarnessToolError('<tool_use_error>Error: No such tool available: bash</tool_use_error>'), true);
@@ -1145,8 +1248,8 @@ test('pass_no_commit: original PRD, PASS no commit, all deliverable paths alread
1145
1248
  const tmp = makeTmpDir();
1146
1249
  try {
1147
1250
  const slug = '655-needs-review-rca-feedback-hook';
1148
- writeLog(tmp, slug, noOpRunEvents('Verified rcaFeedbackHook.cjs already shipped in e13168d; typecheck + tests green; no commit needed.\nSCHEDULER_VERDICT: PASS'));
1149
- const prdPath = writePrd(tmp, slug, 'Deliverables: `src/main/lib/rcaFeedbackHook.cjs` and `src/main/__tests__/rcaFeedbackHook.test.cjs`.');
1251
+ writeLog(tmp, slug, noOpRunEvents('Verified rcaReport.cjs already shipped in e13168d; typecheck + tests green; no commit needed.\nSCHEDULER_VERDICT: PASS'));
1252
+ const prdPath = writePrd(tmp, slug, 'Deliverables: `src/main/lib/rcaReport.cjs` and `src/main/__tests__/rcaReport.test.cjs`.');
1150
1253
  const verdict = await verifyRun({
1151
1254
  runDir: tmp,
1152
1255
  prdPath,
@@ -1166,7 +1269,7 @@ test('pass_no_commit: original PRD, PASS no commit, one deliverable path untrack
1166
1269
  try {
1167
1270
  const slug = '655-needs-review-rca-feedback-hook';
1168
1271
  writeLog(tmp, slug, noOpRunEvents('Nothing to do here.\nSCHEDULER_VERDICT: PASS'));
1169
- const prdPath = writePrd(tmp, slug, 'Deliverables: `src/main/lib/rcaFeedbackHook.cjs` and `src/main/lib/does-not-exist-xyz.cjs`.');
1272
+ const prdPath = writePrd(tmp, slug, 'Deliverables: `src/main/lib/rcaReport.cjs` and `src/main/lib/does-not-exist-xyz.cjs`.');
1170
1273
  const verdict = await verifyRun({
1171
1274
  runDir: tmp,
1172
1275
  prdPath,
@@ -1227,9 +1330,9 @@ test('pass_no_commit: commit-guard-retry PRD naming scheduler.cjs + its committe
1227
1330
  });
1228
1331
 
1229
1332
  test('extractPrdDeliverablePaths: extracts backticked repo-relative paths, ignores prose', () => {
1230
- const body = 'Deliverables: `src/main/lib/rcaFeedbackHook.cjs` and `src/main/__tests__/rcaFeedbackHook.test.cjs`. Also see node_modules/foo/bar.js which should be excluded.';
1333
+ const body = 'Deliverables: `src/main/lib/rcaReport.cjs` and `src/main/__tests__/rcaReport.test.cjs`. Also see node_modules/foo/bar.js which should be excluded.';
1231
1334
  const paths = extractPrdDeliverablePaths(body);
1232
- assert.deepEqual(paths, ['src/main/lib/rcaFeedbackHook.cjs', 'src/main/__tests__/rcaFeedbackHook.test.cjs']);
1335
+ assert.deepEqual(paths, ['src/main/lib/rcaReport.cjs', 'src/main/__tests__/rcaReport.test.cjs']);
1233
1336
  assert.deepEqual(extractPrdDeliverablePaths(''), []);
1234
1337
  assert.deepEqual(extractPrdDeliverablePaths(undefined), []);
1235
1338
  });