claude-code-session-manager 0.57.1 → 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 (43) hide show
  1. package/dist/assets/{TiptapBody-CJDxVF7X.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__/rcaReport.test.cjs +188 -0
  21. package/src/main/__tests__/runVerify.test.cjs +109 -6
  22. package/src/main/__tests__/scheduler-epic-digest.test.cjs +214 -0
  23. package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +5 -5
  24. package/src/main/chatRunner.cjs +10 -1
  25. package/src/main/index.cjs +2 -4
  26. package/src/main/ipcSchemas.cjs +47 -2
  27. package/src/main/lib/agentModelResolve.cjs +97 -0
  28. package/src/main/lib/auditLog.cjs +2 -2
  29. package/src/main/lib/epicContextDigest.cjs +78 -0
  30. package/src/main/lib/epicMint.cjs +81 -164
  31. package/src/main/lib/promptSessionSchema.cjs +79 -0
  32. package/src/main/lib/promptSessionsCreateEpic.cjs +68 -0
  33. package/src/main/lib/{rcaFeedbackHook.cjs → rcaReport.cjs} +47 -114
  34. package/src/main/runVerify.cjs +17 -2
  35. package/src/main/scheduler.cjs +79 -24
  36. package/src/preload/api.d.ts +25 -48
  37. package/src/preload/index.cjs +6 -7
  38. package/dist/assets/index-DCa3QvAi.js +0 -3227
  39. package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +0 -68
  40. package/scripts/propose-epic.cjs +0 -56
  41. package/src/main/__tests__/rcaFeedbackHook.test.cjs +0 -280
  42. package/src/main/repoAnalyzer.cjs +0 -346
  43. package/src/main/search.cjs +0 -332
@@ -0,0 +1,100 @@
1
+ /**
2
+ * epicContextDigest.test.cjs — unit tests for buildContextDigest
3
+ * (lib/epicContextDigest.cjs), which composes an Epic's goalText
4
+ * (active-index.json, via epicMint.cjs) with its most-recent transcript
5
+ * turns (promptSessionTranscript.cjs) into a bounded, real digest string.
6
+ *
7
+ * Run: timeout 300 npx vitest run src/main/__tests__/epicContextDigest.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test, expect, afterEach } from 'vitest';
13
+ const fsp = require('node:fs/promises');
14
+ const os = require('node:os');
15
+ const path = require('node:path');
16
+ const { buildContextDigest } = require('../lib/epicContextDigest.cjs');
17
+ const { ensureEpic, MINT_AUTHORITY_NEW_EPIC_UI } = require('../lib/epicMint.cjs');
18
+ const { appendTurn } = require('../promptSessionTranscript.cjs');
19
+ const config = require('../config.cjs');
20
+
21
+ const tmpDirs = [];
22
+ afterEach(async () => {
23
+ while (tmpDirs.length) {
24
+ const d = tmpDirs.pop();
25
+ await fsp.rm(d, { recursive: true, force: true });
26
+ }
27
+ });
28
+
29
+ async function mkCwd() {
30
+ const cwd = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-epiccontextdigest-cwd-'));
31
+ config.addAllowedRoot(cwd);
32
+ tmpDirs.push(cwd);
33
+ return cwd;
34
+ }
35
+
36
+ test('normal digest: goalText followed by recent turns', async () => {
37
+ const cwd = await mkCwd();
38
+ const { epicId } = await ensureEpic(cwd, { goalText: 'Fix the flaky login test', status: 'proposed', mintAuthority: MINT_AUTHORITY_NEW_EPIC_UI });
39
+ await appendTurn(cwd, epicId, { role: 'user', text: 'Please investigate the flaky test.' });
40
+ await appendTurn(cwd, epicId, { role: 'assistant', text: 'Found the race condition, fixing now.' });
41
+
42
+ const digest = await buildContextDigest({ cwd, epicId });
43
+ expect(digest).toContain('Fix the flaky login test');
44
+ expect(digest).toContain('[user] Please investigate the flaky test.');
45
+ expect(digest).toContain('[assistant] Found the race condition, fixing now.');
46
+ });
47
+
48
+ test('missing epicId returns empty string', async () => {
49
+ const cwd = await mkCwd();
50
+ expect(await buildContextDigest({ cwd, epicId: '' })).toBe('');
51
+ expect(await buildContextDigest({ cwd })).toBe('');
52
+ });
53
+
54
+ test('epicId absent from active-index.json returns empty string', async () => {
55
+ const cwd = await mkCwd();
56
+ const digest = await buildContextDigest({ cwd, epicId: 'psess-does-not-exist' });
57
+ expect(digest).toBe('');
58
+ });
59
+
60
+ test('empty transcript still returns goalText only, no throw', async () => {
61
+ const cwd = await mkCwd();
62
+ const { epicId } = await ensureEpic(cwd, { goalText: 'Empty transcript epic', status: 'proposed', mintAuthority: MINT_AUTHORITY_NEW_EPIC_UI });
63
+
64
+ const digest = await buildContextDigest({ cwd, epicId });
65
+ expect(digest).toBe('Empty transcript epic');
66
+ });
67
+
68
+ test('maxChars truncation drops the oldest turns first, never a mid-turn slice', async () => {
69
+ const cwd = await mkCwd();
70
+ const { epicId } = await ensureEpic(cwd, { goalText: 'Truncation epic', status: 'proposed', mintAuthority: MINT_AUTHORITY_NEW_EPIC_UI });
71
+ await appendTurn(cwd, epicId, { role: 'user', text: 'oldest turn text goes here' });
72
+ await appendTurn(cwd, epicId, { role: 'assistant', text: 'middle turn text goes here' });
73
+ await appendTurn(cwd, epicId, { role: 'user', text: 'newest turn text goes here' });
74
+
75
+ // Small enough that only the newest turn (plus goalText) can fit.
76
+ const digest = await buildContextDigest({ cwd, epicId, maxChars: 80 });
77
+
78
+ expect(digest.length).toBeLessThanOrEqual(80);
79
+ expect(digest).toContain('newest turn text goes here');
80
+ expect(digest).not.toContain('oldest turn text goes here');
81
+ expect(digest).not.toContain('middle turn text goes here');
82
+ // No turn line appears partially — every included "[role] " line is whole.
83
+ for (const line of digest.split('\n')) {
84
+ if (line.startsWith('[')) {
85
+ expect(line).toMatch(/^\[(user|assistant)\] .+$/);
86
+ }
87
+ }
88
+ });
89
+
90
+ test('maxChars smaller than goalText alone clamps the header itself, never exceeding the bound', async () => {
91
+ const cwd = await mkCwd();
92
+ const longGoal = 'This is a deliberately long goalText that on its own already exceeds the small maxChars bound used below';
93
+ const { epicId } = await ensureEpic(cwd, { goalText: longGoal, status: 'proposed', mintAuthority: MINT_AUTHORITY_NEW_EPIC_UI });
94
+ await appendTurn(cwd, epicId, { role: 'user', text: 'a turn that cannot possibly fit either' });
95
+
96
+ const digest = await buildContextDigest({ cwd, epicId, maxChars: 20 });
97
+
98
+ expect(digest.length).toBe(20);
99
+ expect(digest).toBe(longGoal.slice(0, 20));
100
+ });
@@ -1,8 +1,7 @@
1
1
  /**
2
- * epicMint.test.cjs — unit tests for ensureEpic's sourcePromptId join
3
- * semantics (see PRD authoring notes: sourcePromptId must be an existing
4
- * Epic's promptSessionId, i.e. its active-index.json sessions key — NOT a
5
- * PromptTicket.id).
2
+ * epicMint.test.cjs — unit tests for ensureEpic's two behaviors: JOIN (every
3
+ * automated caller, by explicit epicId) and MINT (the New Epic UI alone, via
4
+ * mintAuthority — epicMint.cjs's SINGLE-CREATOR LAW).
6
5
  *
7
6
  * Run: timeout 120 npx vitest run src/main/__tests__/epicMint.test.cjs
8
7
  */
@@ -14,7 +13,7 @@ const fs = require('node:fs');
14
13
  const fsp = require('node:fs/promises');
15
14
  const os = require('node:os');
16
15
  const path = require('node:path');
17
- const { ensureEpic, removeEpic, readActiveIndex, findJoinableEpic } = require('../lib/epicMint.cjs');
16
+ const { ensureEpic, removeEpic, readActiveIndex, MINT_AUTHORITY_NEW_EPIC_UI } = require('../lib/epicMint.cjs');
18
17
 
19
18
  const tmpDirs = [];
20
19
  afterEach(async () => {
@@ -30,297 +29,256 @@ async function mkCwd() {
30
29
  return cwd;
31
30
  }
32
31
 
33
- test('ensureEpic mints a new Epic when explicit epicId matches no existing Epic (documents current behavior for a stray PromptTicket.id)', async () => {
34
- const cwd = await mkCwd();
35
- const notAPromptSessionId = 'ticket-abc123';
32
+ /** The New Epic UI's call shape — the only caller allowed to mint. */
33
+ function mint(cwd, opts = {}) {
34
+ return ensureEpic(cwd, { mintAuthority: MINT_AUTHORITY_NEW_EPIC_UI, ...opts });
35
+ }
36
36
 
37
- const result = await ensureEpic(cwd, { goalText: 'do the thing', epicId: notAPromptSessionId });
37
+ function writeIndex(cwd, index) {
38
+ fs.writeFileSync(
39
+ path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'),
40
+ JSON.stringify(index, null, 2),
41
+ );
42
+ }
38
43
 
39
- expect(result.created).toBe(true);
40
- expect(result.epicId).not.toBe(notAPromptSessionId);
44
+ function readAuditRecordsForCwd(auditLog, cwd) {
45
+ if (!fs.existsSync(auditLog.AUDIT_LOG_PATH)) return [];
46
+ return fs.readFileSync(auditLog.AUDIT_LOG_PATH, 'utf8')
47
+ .split('\n')
48
+ .filter(Boolean)
49
+ .map((line) => {
50
+ try {
51
+ return JSON.parse(line);
52
+ } catch {
53
+ return null;
54
+ }
55
+ })
56
+ .filter((record) => record && record.cwd === cwd);
57
+ }
41
58
 
42
- const index = readActiveIndex(cwd);
43
- expect(index.sessions[notAPromptSessionId]).toBeUndefined();
44
- expect(index.sessions[result.epicId]).toBeDefined();
45
- expect(Object.keys(index.sessions)).toHaveLength(1);
46
- });
59
+ // ─── SINGLE-CREATOR LAW ──────────────────────────────────────────────────────
47
60
 
48
- test('ensureEpic joins the existing Epic when explicit epicId equals its promptSessionId', async () => {
61
+ test('ensureEpic refuses to mint without mintAuthority, and appends an epic_mint_refused audit event', async () => {
49
62
  const cwd = await mkCwd();
63
+ const auditLog = require('../lib/auditLog.cjs');
50
64
 
51
- const first = await ensureEpic(cwd, { goalText: 'initial epic goal' });
52
- expect(first.created).toBe(true);
53
-
54
- const second = await ensureEpic(cwd, { goalText: 'a different PRD title', epicId: first.epicId });
55
-
56
- expect(second.created).toBe(false);
57
- expect(second.epicId).toBe(first.epicId);
58
- expect(second.prdDir).toBe(first.prdDir);
65
+ await expect(ensureEpic(cwd, { goalText: 'agent wants work done' }))
66
+ .rejects.toThrow(/no open Epic to join/);
59
67
 
60
- const index = readActiveIndex(cwd);
61
- expect(Object.keys(index.sessions)).toHaveLength(1);
68
+ expect(Object.keys(readActiveIndex(cwd).sessions)).toHaveLength(0);
69
+ const records = readAuditRecordsForCwd(auditLog, cwd);
70
+ expect(records).toHaveLength(1);
71
+ expect(records[0].kind).toBe('epic_mint_refused');
62
72
  });
63
73
 
64
- test('removeEpic deletes a minted Epic from both sessions and events maps', async () => {
74
+ test('ensureEpic refuses to mint when mintAuthority is a wrong/guessed value', async () => {
65
75
  const cwd = await mkCwd();
66
- const minted = await ensureEpic(cwd, { goalText: 'to be rolled back' });
67
- expect(readActiveIndex(cwd).sessions[minted.epicId]).toBeDefined();
68
76
 
69
- const removed = await removeEpic(cwd, minted.epicId);
77
+ await expect(ensureEpic(cwd, { goalText: 'sneaky', mintAuthority: 'agent' }))
78
+ .rejects.toThrow(/no open Epic to join/);
70
79
 
71
- expect(removed).toBe(true);
72
- const index = readActiveIndex(cwd);
73
- expect(index.sessions[minted.epicId]).toBeUndefined();
74
- expect(index.events[minted.epicId]).toBeUndefined();
80
+ expect(Object.keys(readActiveIndex(cwd).sessions)).toHaveLength(0);
75
81
  });
76
82
 
77
- test('removeEpic is a no-op (returns false) for an unknown epicId', async () => {
83
+ test('ensureEpic mints when the New Epic UI presents mintAuthority', async () => {
78
84
  const cwd = await mkCwd();
79
- await ensureEpic(cwd, { goalText: 'unrelated epic' });
80
-
81
- const removed = await removeEpic(cwd, 'nonexistent-epic-id');
82
-
83
- expect(removed).toBe(false);
84
- expect(Object.keys(readActiveIndex(cwd).sessions)).toHaveLength(1);
85
- });
86
85
 
87
- // A plain object's bracket lookup with a prototype-chain key like
88
- // "__proto__"/"constructor" returns a truthy Object.prototype member even
89
- // when no such Epic was ever written — without an own-property check, this
90
- // would make mintIfMissing:false's "join an existing Epic" gate falsely
91
- // report a match, defeating the restriction that a PRD write may only join
92
- // a real, already-approved Epic.
93
- for (const pollutedKey of ['__proto__', 'constructor', 'toString', 'hasOwnProperty']) {
94
- test(`ensureEpic refuses to "join" the prototype-chain key "${pollutedKey}" (mintIfMissing:false)`, async () => {
95
- const cwd = await mkCwd();
96
-
97
- await expect(
98
- ensureEpic(cwd, { epicId: pollutedKey, mintIfMissing: false }),
99
- ).rejects.toThrow(/no existing Epic found/);
86
+ const minted = await mint(cwd, { goalText: 'human-created epic' });
100
87
 
101
- const index = readActiveIndex(cwd);
102
- expect(Object.keys(index.sessions)).toHaveLength(0);
103
- });
104
- }
105
-
106
- test('removeEpic refuses to report success for the prototype-chain key "__proto__"', async () => {
107
- const cwd = await mkCwd();
108
- const removed = await removeEpic(cwd, '__proto__');
109
- expect(removed).toBe(false);
88
+ expect(minted.created).toBe(true);
89
+ const index = readActiveIndex(cwd);
90
+ expect(index.sessions[minted.epicId].status).toBe('proposed');
91
+ expect(index.events[minted.epicId]).toHaveLength(1);
110
92
  });
111
93
 
112
- test('ensureEpic persists the passed source onto the written active-index.json session record', async () => {
94
+ test('a mint never joins a near-identical open Epic — the human asked for a new one', async () => {
113
95
  const cwd = await mkCwd();
114
- const source = { producer: 'rca-hook', prdSlug: 'x', runId: 'y' };
115
96
 
116
- const minted = await ensureEpic(cwd, { goalText: 'auto-filed epic', source });
97
+ const first = await mint(cwd, { goalText: 'Fix the login button color' });
98
+ const second = await mint(cwd, { goalText: 'Fix login button colour issue' });
117
99
 
118
- const index = readActiveIndex(cwd);
119
- expect(index.sessions[minted.epicId].source).toEqual(source);
100
+ expect(second.created).toBe(true);
101
+ expect(second.epicId).not.toBe(first.epicId);
102
+ expect(Object.keys(readActiveIndex(cwd).sessions)).toHaveLength(2);
120
103
  });
121
104
 
122
- test('ensureEpic omits source from the written record when not passed', async () => {
105
+ // ─── JOIN ────────────────────────────────────────────────────────────────────
106
+
107
+ test('ensureEpic joins the existing Epic when explicit epicId equals its promptSessionId', async () => {
123
108
  const cwd = await mkCwd();
124
109
 
125
- const minted = await ensureEpic(cwd, { goalText: 'human-filed epic' });
110
+ const first = await mint(cwd, { goalText: 'initial epic goal' });
111
+ const second = await ensureEpic(cwd, { goalText: 'a different PRD title', epicId: first.epicId });
126
112
 
127
- const index = readActiveIndex(cwd);
128
- expect('source' in index.sessions[minted.epicId]).toBe(false);
113
+ expect(second.created).toBe(false);
114
+ expect(second.epicId).toBe(first.epicId);
115
+ expect(second.prdDir).toBe(first.prdDir);
116
+ expect(Object.keys(readActiveIndex(cwd).sessions)).toHaveLength(1);
129
117
  });
130
118
 
131
- test('ensureEpic joins an existing open Epic when goalText is a near-identical paraphrase (no preferEpicId)', async () => {
119
+ test('ensureEpic joins an already-active Epic via explicit epicId', async () => {
132
120
  const cwd = await mkCwd();
121
+ const first = await mint(cwd, { goalText: 'already running epic' });
122
+ const index = readActiveIndex(cwd);
123
+ index.sessions[first.epicId].status = 'active';
124
+ writeIndex(cwd, index);
133
125
 
134
- const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
135
- expect(first.created).toBe(true);
136
-
137
- const second = await ensureEpic(cwd, { goalText: 'Fix login button colour issue' });
126
+ const second = await ensureEpic(cwd, { goalText: 'unrelated text', epicId: first.epicId });
138
127
 
139
128
  expect(second.created).toBe(false);
140
129
  expect(second.epicId).toBe(first.epicId);
141
-
142
- const index = readActiveIndex(cwd);
143
- expect(Object.keys(index.sessions)).toHaveLength(1);
144
130
  });
145
131
 
146
- test('ensureEpic mints a distinct Epic when goalText is genuinely unrelated', async () => {
132
+ test('ensureEpic refuses to join a completed Epic and does NOT mint a replacement', async () => {
147
133
  const cwd = await mkCwd();
134
+ const first = await mint(cwd, { goalText: 'completed epic' });
135
+ const index = readActiveIndex(cwd);
136
+ index.sessions[first.epicId].status = 'completed';
137
+ writeIndex(cwd, index);
138
+ const auditLog = require('../lib/auditLog.cjs');
148
139
 
149
- const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
150
- expect(first.created).toBe(true);
140
+ await expect(ensureEpic(cwd, { goalText: 'follow-up work', epicId: first.epicId }))
141
+ .rejects.toThrow(/no open Epic to join/);
151
142
 
152
- const second = await ensureEpic(cwd, { goalText: 'Add CSV export to History tab' });
143
+ expect(Object.keys(readActiveIndex(cwd).sessions)).toHaveLength(1);
144
+ const refusal = readAuditRecordsForCwd(auditLog, cwd).find((r) => r.kind === 'epic_mint_refused');
145
+ expect(refusal).toBeDefined();
146
+ });
153
147
 
154
- expect(second.created).toBe(true);
155
- expect(second.epicId).not.toBe(first.epicId);
148
+ test('ensureEpic refuses an unknown explicit epicId rather than minting a sibling', async () => {
149
+ const cwd = await mkCwd();
156
150
 
157
- const index = readActiveIndex(cwd);
158
- expect(Object.keys(index.sessions)).toHaveLength(2);
151
+ await expect(ensureEpic(cwd, { goalText: 'do the thing', epicId: 'ticket-abc123' }))
152
+ .rejects.toThrow(/no open Epic to join/);
153
+
154
+ expect(Object.keys(readActiveIndex(cwd).sessions)).toHaveLength(0);
159
155
  });
160
156
 
161
- test('findJoinableEpic joins the preferEpicId Epic even when goalText similarity is near-zero', async () => {
162
- const cwd = await mkCwd();
157
+ // A plain object's bracket lookup with a prototype-chain key like
158
+ // "__proto__"/"constructor" returns a truthy Object.prototype member even
159
+ // when no such Epic was ever written — without an own-property check, this
160
+ // would make the join gate falsely report a match.
161
+ for (const pollutedKey of ['__proto__', 'constructor', 'toString', 'hasOwnProperty']) {
162
+ test(`ensureEpic refuses to "join" the prototype-chain key "${pollutedKey}"`, async () => {
163
+ const cwd = await mkCwd();
163
164
 
164
- const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
165
- expect(first.created).toBe(true);
165
+ await expect(ensureEpic(cwd, { epicId: pollutedKey })).rejects.toThrow(/no open Epic to join/);
166
166
 
167
- const joined = findJoinableEpic(cwd, {
168
- goalText: 'Completely unrelated topic about database migrations',
169
- preferEpicId: first.epicId,
167
+ expect(Object.keys(readActiveIndex(cwd).sessions)).toHaveLength(0);
170
168
  });
169
+ }
171
170
 
172
- expect(joined).toEqual({ epicId: first.epicId, matchedBy: 'preferEpicId' });
173
- });
171
+ // ─── removeEpic ──────────────────────────────────────────────────────────────
174
172
 
175
- test('findJoinableEpic falls through to the similarity check when preferEpicId points at a completed Epic', async () => {
173
+ test('removeEpic deletes a minted Epic from both sessions and events maps', async () => {
176
174
  const cwd = await mkCwd();
175
+ const minted = await mint(cwd, { goalText: 'to be rolled back' });
177
176
 
178
- const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
179
- const index = readActiveIndex(cwd);
180
- index.sessions[first.epicId].status = 'completed';
181
- fs.writeFileSync(
182
- path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'),
183
- JSON.stringify(index, null, 2),
184
- );
185
-
186
- // No other open Epic shares tokens with the completed one, so the
187
- // fall-through similarity check finds nothing rather than joining the
188
- // dead (completed) preferEpicId Epic.
189
- const joined = findJoinableEpic(cwd, { goalText: 'Fix login button colour issue', preferEpicId: first.epicId });
177
+ const removed = await removeEpic(cwd, minted.epicId);
190
178
 
191
- expect(joined).toBeNull();
179
+ expect(removed).toBe(true);
180
+ const index = readActiveIndex(cwd);
181
+ expect(index.sessions[minted.epicId]).toBeUndefined();
182
+ expect(index.events[minted.epicId]).toBeUndefined();
192
183
  });
193
184
 
194
- test('findJoinableEpic falls through to the similarity check when preferEpicId points at a nonexistent Epic, and can still find a match there', async () => {
185
+ test('removeEpic is a no-op (returns false) for an unknown epicId', async () => {
195
186
  const cwd = await mkCwd();
196
- const sibling = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
187
+ await mint(cwd, { goalText: 'unrelated epic' });
197
188
 
198
- const joined = findJoinableEpic(cwd, { goalText: 'Fix login button colour issue', preferEpicId: 'nonexistent-epic-id' });
189
+ expect(await removeEpic(cwd, 'nonexistent-epic-id')).toBe(false);
190
+ expect(Object.keys(readActiveIndex(cwd).sessions)).toHaveLength(1);
191
+ });
199
192
 
200
- expect(joined).toEqual({ epicId: sibling.epicId, matchedBy: 'similarity', score: expect.any(Number) });
193
+ test('removeEpic refuses to report success for the prototype-chain key "__proto__"', async () => {
194
+ const cwd = await mkCwd();
195
+ expect(await removeEpic(cwd, '__proto__')).toBe(false);
201
196
  });
202
197
 
203
- test('ensureEpic mints with status "proposed" by default', async () => {
198
+ // ─── written record ──────────────────────────────────────────────────────────
199
+
200
+ test('ensureEpic persists the passed source onto the written active-index.json session record', async () => {
204
201
  const cwd = await mkCwd();
202
+ const source = { producer: 'new-epic-ui' };
205
203
 
206
- const minted = await ensureEpic(cwd, { goalText: 'default status epic' });
204
+ const minted = await mint(cwd, { goalText: 'epic with source', source });
207
205
 
208
- const index = readActiveIndex(cwd);
209
- expect(index.sessions[minted.epicId].status).toBe('proposed');
206
+ expect(readActiveIndex(cwd).sessions[minted.epicId].source).toEqual(source);
210
207
  });
211
208
 
212
- test('ensureEpic throws when a mint is requested with status "active" (born-proposed law), and appends an audit event', async () => {
209
+ test('ensureEpic omits source from the written record when not passed', async () => {
213
210
  const cwd = await mkCwd();
214
- const auditLog = require('../lib/auditLog.cjs');
215
211
 
216
- await expect(
217
- ensureEpic(cwd, { goalText: 'should never mint active', status: 'active' }),
218
- ).rejects.toThrow(/born 'proposed'/);
212
+ const minted = await mint(cwd, { goalText: 'sourceless epic' });
219
213
 
220
- const index = readActiveIndex(cwd);
221
- expect(Object.keys(index.sessions)).toHaveLength(0);
222
-
223
- const records = fs.readFileSync(auditLog.AUDIT_LOG_PATH, 'utf8')
224
- .split('\n')
225
- .filter(Boolean)
226
- .map((line) => JSON.parse(line))
227
- .filter((r) => r.cwd === cwd);
228
- expect(records).toHaveLength(1);
229
- expect(records[0].kind).toBe('epic_mint_refused');
230
- expect(records[0].status).toBe('active');
214
+ expect('source' in readActiveIndex(cwd).sessions[minted.epicId]).toBe(false);
231
215
  });
232
216
 
233
- test('ensureEpic still joins an existing active Epic via preferEpicId even when status:"active" is requested', async () => {
217
+ test('ensureEpic mints successfully for a real-world call shape carrying tag, openingPrompt, and source', async () => {
234
218
  const cwd = await mkCwd();
235
- const first = await ensureEpic(cwd, { goalText: 'already running epic' });
236
- const index = readActiveIndex(cwd);
237
- index.sessions[first.epicId].status = 'active';
238
- fs.writeFileSync(
239
- path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'),
240
- JSON.stringify(index, null, 2),
241
- );
242
219
 
243
- const second = await ensureEpic(cwd, { goalText: 'unrelated text', epicId: first.epicId, status: 'active' });
220
+ const minted = await mint(cwd, {
221
+ goalText: 'Fix the merge validation',
222
+ tag: 'bug',
223
+ openingPrompt: 'full opening prompt text',
224
+ source: { producer: 'new-epic-ui' },
225
+ });
244
226
 
245
- expect(second.created).toBe(false);
246
- expect(second.epicId).toBe(first.epicId);
227
+ const session = readActiveIndex(cwd).sessions[minted.epicId];
228
+ expect(session.tag).toBe('bug');
229
+ expect(session.openingPrompt).toBe('full opening prompt text');
230
+ expect(session.source).toEqual({ producer: 'new-epic-ui' });
247
231
  });
248
232
 
249
- test('ensureEpic falls through to a "proposed" mint when explicit epicId is archived/completed, never throwing', async () => {
233
+ // ─── BORN-PROPOSED LAW ───────────────────────────────────────────────────────
234
+
235
+ test('ensureEpic mints with status "proposed" by default', async () => {
250
236
  const cwd = await mkCwd();
251
- const first = await ensureEpic(cwd, { goalText: 'completed epic' });
252
- const index = readActiveIndex(cwd);
253
- index.sessions[first.epicId].status = 'completed';
254
- fs.writeFileSync(
255
- path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'),
256
- JSON.stringify(index, null, 2),
257
- );
258
237
 
259
- const second = await ensureEpic(cwd, { goalText: 'totally unrelated goal text here', epicId: first.epicId });
238
+ const minted = await mint(cwd, { goalText: 'default status epic' });
260
239
 
261
- expect(second.created).toBe(true);
262
- expect(second.epicId).not.toBe(first.epicId);
263
- const after = readActiveIndex(cwd);
264
- expect(after.sessions[second.epicId].status).toBe('proposed');
240
+ expect(readActiveIndex(cwd).sessions[minted.epicId].status).toBe('proposed');
265
241
  });
266
242
 
267
- function readAuditRecordsForCwd(auditLog, cwd) {
268
- if (!fs.existsSync(auditLog.AUDIT_LOG_PATH)) return [];
269
- return fs.readFileSync(auditLog.AUDIT_LOG_PATH, 'utf8')
270
- .split('\n')
271
- .filter(Boolean)
272
- .map((line) => {
273
- try {
274
- return JSON.parse(line);
275
- } catch {
276
- return null;
277
- }
278
- })
279
- .filter((record) => record && record.cwd === cwd);
280
- }
281
-
282
- test('mintIfMissing:false refusal appends an epic_mint_refused audit event', async () => {
243
+ test('ensureEpic throws when a mint is requested with status "active", and appends an audit event', async () => {
283
244
  const cwd = await mkCwd();
284
245
  const auditLog = require('../lib/auditLog.cjs');
285
246
 
286
- await expect(ensureEpic(cwd, { goalText: 'no epic yet', mintIfMissing: false })).rejects.toThrow();
247
+ await expect(mint(cwd, { goalText: 'should never mint active', status: 'active' }))
248
+ .rejects.toThrow(/born 'proposed'/);
287
249
 
250
+ expect(Object.keys(readActiveIndex(cwd).sessions)).toHaveLength(0);
288
251
  const records = readAuditRecordsForCwd(auditLog, cwd);
289
252
  expect(records).toHaveLength(1);
290
253
  expect(records[0].kind).toBe('epic_mint_refused');
291
- expect(records[0].cwd).toBe(cwd);
254
+ expect(records[0].status).toBe('active');
292
255
  });
293
256
 
294
- test('not-open explicit-epicId fall-through appends an epic_mint_refused audit event', async () => {
257
+ test('ensureEpic refuses the mint and appends an epic_mint_refused audit event when the constructed session object fails schema validation', async () => {
295
258
  const cwd = await mkCwd();
296
- const first = await ensureEpic(cwd, { goalText: 'to be completed' });
297
- const index = readActiveIndex(cwd);
298
- index.sessions[first.epicId].status = 'completed';
299
- fs.writeFileSync(
300
- path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'),
301
- JSON.stringify(index, null, 2),
302
- );
303
259
  const auditLog = require('../lib/auditLog.cjs');
260
+ const schema = require('../lib/promptSessionSchema.cjs');
261
+
262
+ // The real construction in ensureEpic is hardcoded and always produces a
263
+ // valid PromptSession, so to exercise the fail-closed wiring we simulate
264
+ // a corrupted construction by monkeypatching the schema module's exported
265
+ // validator in place — epicMint.cjs reads `schema.assertValidPromptSession`
266
+ // off the same exports object at call time (property lookup, not a
267
+ // destructured copy), so mutating it here is visible to ensureEpic.
268
+ const original = schema.assertValidPromptSession;
269
+ schema.assertValidPromptSession = () => {
270
+ throw new Error('assertValidPromptSession: invalid PromptSession shape — forced test failure');
271
+ };
272
+ try {
273
+ await expect(mint(cwd, { goalText: 'should be refused by schema validation' }))
274
+ .rejects.toThrow(/forced test failure/);
275
+ } finally {
276
+ schema.assertValidPromptSession = original;
277
+ }
304
278
 
305
- await ensureEpic(cwd, { goalText: 'unrelated fresh goal xyz', epicId: first.epicId });
306
-
279
+ expect(Object.keys(readActiveIndex(cwd).sessions)).toHaveLength(0);
307
280
  const records = readAuditRecordsForCwd(auditLog, cwd);
308
- const refusal = records.find((r) => r.kind === 'epic_mint_refused');
309
- expect(refusal).toBeDefined();
310
- expect(refusal.epicId).toBe(first.epicId);
311
- });
312
-
313
- test('ensureEpic with forceNewEpic:true mints even when a near-identical open Epic exists', async () => {
314
- const cwd = await mkCwd();
315
-
316
- const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
317
- expect(first.created).toBe(true);
318
-
319
- const second = await ensureEpic(cwd, { goalText: 'Fix login button colour issue', forceNewEpic: true });
320
-
321
- expect(second.created).toBe(true);
322
- expect(second.epicId).not.toBe(first.epicId);
323
-
324
- const index = readActiveIndex(cwd);
325
- expect(Object.keys(index.sessions)).toHaveLength(2);
281
+ expect(records).toHaveLength(1);
282
+ expect(records[0].kind).toBe('epic_mint_refused');
283
+ expect(records[0].reason).toMatch(/forced test failure/);
326
284
  });