claude-code-session-manager 0.57.1 → 0.59.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.
- package/README.md +2 -2
- package/dist/assets/{TiptapBody-CJDxVF7X.js → TiptapBody-PUx4oZTh.js} +1 -1
- package/dist/assets/index-BPnfPdLW.js +3222 -0
- package/dist/assets/{index-WUc_GHMf.css → index-CPMP2XZ_.css} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -2
- package/plugins/session-manager-dev/skills/develop/SKILL.md +7 -6
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +16 -25
- package/plugins/session-manager-dev/skills/project-status/SKILL.md +5 -5
- package/scripts/lib/watchdogHelpers.cjs +0 -434
- package/scripts/mint-epic.cjs +6 -6
- package/src/main/__tests__/activeIndexMerge.test.cjs +2 -2
- package/src/main/__tests__/agentModelResolve.test.cjs +119 -0
- package/src/main/__tests__/classifyTranscriptLine.test.cjs +128 -17
- package/src/main/__tests__/epicContextDigest.test.cjs +100 -0
- package/src/main/__tests__/epicMint.test.cjs +165 -207
- package/src/main/__tests__/epicValidationHook.test.cjs +291 -0
- package/src/main/__tests__/prdMigration.test.cjs +160 -0
- package/src/main/__tests__/projectPages.test.cjs +151 -0
- package/src/main/__tests__/promptSessionEvents.test.cjs +74 -0
- package/src/main/__tests__/promptSessionSchema.test.cjs +101 -0
- package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +139 -0
- package/src/main/__tests__/rcaReport.test.cjs +188 -0
- package/src/main/__tests__/runVerify.test.cjs +109 -6
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +32 -12
- package/src/main/__tests__/scheduler-epic-digest.test.cjs +214 -0
- package/src/main/__tests__/scheduler-heal-refusal.test.cjs +61 -0
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +74 -0
- package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +5 -5
- package/src/main/__tests__/transcripts-doFlush-array.test.cjs +118 -0
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +233 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +7 -2
- package/src/main/chatRunner.cjs +10 -1
- package/src/main/health.cjs +5 -1
- package/src/main/index.cjs +3 -5
- package/src/main/ipcSchemas.cjs +72 -4
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +130 -0
- package/src/main/lib/agentModelResolve.cjs +97 -0
- package/src/main/lib/auditLog.cjs +2 -2
- package/src/main/lib/classifyTranscriptLine.cjs +131 -48
- package/src/main/lib/epicContextDigest.cjs +78 -0
- package/src/main/lib/epicMint.cjs +86 -164
- package/src/main/lib/epicValidationHook.cjs +192 -0
- package/src/main/lib/prdMigration.cjs +76 -5
- package/src/main/lib/promptSessionSchema.cjs +95 -0
- package/src/main/lib/promptSessionsCreateEpic.cjs +70 -0
- package/src/main/lib/{rcaFeedbackHook.cjs → rcaReport.cjs} +47 -114
- package/src/main/lib/schedulerBatch.cjs +70 -111
- package/src/main/lib/schedulerConfig.cjs +0 -1
- package/src/main/otel.cjs +3 -1
- package/src/main/projectPages.cjs +60 -14
- package/src/main/promptSessionEvents.cjs +16 -1
- package/src/main/runVerify.cjs +17 -2
- package/src/main/scheduler.cjs +302 -68
- package/src/main/templates/project-pages-default-home.html +123 -0
- package/src/main/transcripts.cjs +191 -32
- package/src/main/webRemote.cjs +8 -7
- package/src/preload/api.d.ts +100 -57
- package/src/preload/index.cjs +9 -7
- package/dist/assets/index-DCa3QvAi.js +0 -3227
- package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +0 -68
- package/scripts/propose-epic.cjs +0 -56
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +0 -280
- package/src/main/repoAnalyzer.cjs +0 -346
- package/src/main/search.cjs +0 -332
|
@@ -8,21 +8,29 @@
|
|
|
8
8
|
|
|
9
9
|
import { test, expect } from 'vitest';
|
|
10
10
|
|
|
11
|
-
const {
|
|
11
|
+
const {
|
|
12
|
+
classifyLine,
|
|
13
|
+
trimContentArray,
|
|
14
|
+
makeRaw,
|
|
15
|
+
buildPreviewText,
|
|
16
|
+
MAX_RAW_STR,
|
|
17
|
+
PREVIEW_CHARS,
|
|
18
|
+
EXEMPT_TYPES,
|
|
19
|
+
} = require('../lib/classifyTranscriptLine.cjs');
|
|
12
20
|
|
|
13
|
-
test('classifyLine returns
|
|
14
|
-
expect(classifyLine(null)).
|
|
15
|
-
expect(classifyLine('not an object')).
|
|
21
|
+
test('classifyLine returns [] for non-object input', () => {
|
|
22
|
+
expect(classifyLine(null)).toEqual([]);
|
|
23
|
+
expect(classifyLine('not an object')).toEqual([]);
|
|
16
24
|
});
|
|
17
25
|
|
|
18
26
|
test('classifyLine tags usage events', () => {
|
|
19
|
-
const ev = classifyLine({ usage: { input_tokens: 10, output_tokens: 5 } });
|
|
27
|
+
const [ev] = classifyLine({ usage: { input_tokens: 10, output_tokens: 5 } });
|
|
20
28
|
expect(ev.kind).toBe('usage');
|
|
21
29
|
expect(ev.data).toEqual({ input_tokens: 10, output_tokens: 5 });
|
|
22
30
|
});
|
|
23
31
|
|
|
24
32
|
test('classifyLine tags TodoWrite tool_use as todo_write', () => {
|
|
25
|
-
const ev = classifyLine({
|
|
33
|
+
const [ev] = classifyLine({
|
|
26
34
|
message: { content: [{ type: 'tool_use', name: 'TodoWrite', input: { todos: [{ content: 'x' }] } }] },
|
|
27
35
|
});
|
|
28
36
|
expect(ev.kind).toBe('todo_write');
|
|
@@ -30,14 +38,14 @@ test('classifyLine tags TodoWrite tool_use as todo_write', () => {
|
|
|
30
38
|
});
|
|
31
39
|
|
|
32
40
|
test('classifyLine tags ExitPlanMode as plan', () => {
|
|
33
|
-
const ev = classifyLine({
|
|
41
|
+
const [ev] = classifyLine({
|
|
34
42
|
message: { content: [{ type: 'tool_use', name: 'ExitPlanMode', input: { plan: 'do things' } }] },
|
|
35
43
|
});
|
|
36
44
|
expect(ev.kind).toBe('plan');
|
|
37
45
|
});
|
|
38
46
|
|
|
39
47
|
test('classifyLine tags Agent/Task tool_use as agent_spawn with toolUseId', () => {
|
|
40
|
-
const ev = classifyLine({
|
|
48
|
+
const [ev] = classifyLine({
|
|
41
49
|
message: { content: [{ type: 'tool_use', name: 'Agent', id: 'tool-1', input: { description: 'do work' } }] },
|
|
42
50
|
});
|
|
43
51
|
expect(ev.kind).toBe('agent_spawn');
|
|
@@ -45,7 +53,7 @@ test('classifyLine tags Agent/Task tool_use as agent_spawn with toolUseId', () =
|
|
|
45
53
|
});
|
|
46
54
|
|
|
47
55
|
test('classifyLine tags generic tool_use blocks', () => {
|
|
48
|
-
const ev = classifyLine({
|
|
56
|
+
const [ev] = classifyLine({
|
|
49
57
|
message: { content: [{ type: 'tool_use', name: 'Bash', id: 'tool-2', input: { command: 'ls' } }] },
|
|
50
58
|
});
|
|
51
59
|
expect(ev.kind).toBe('tool_use');
|
|
@@ -53,18 +61,62 @@ test('classifyLine tags generic tool_use blocks', () => {
|
|
|
53
61
|
});
|
|
54
62
|
|
|
55
63
|
test('classifyLine tags tool_result blocks with toolUseId', () => {
|
|
56
|
-
const ev = classifyLine({
|
|
64
|
+
const [ev] = classifyLine({
|
|
57
65
|
message: { content: [{ type: 'tool_result', tool_use_id: 'tool-1' }] },
|
|
58
66
|
});
|
|
59
67
|
expect(ev.kind).toBe('tool_result');
|
|
60
68
|
expect(ev.data).toEqual({ toolUseId: 'tool-1' });
|
|
61
69
|
});
|
|
62
70
|
|
|
63
|
-
test('classifyLine falls back to message kind', () => {
|
|
64
|
-
const ev = classifyLine({ type: 'assistant', message: { content: 'hello' } });
|
|
71
|
+
test('classifyLine falls back to message kind for non-array content', () => {
|
|
72
|
+
const [ev] = classifyLine({ type: 'assistant', message: { content: 'hello' } });
|
|
65
73
|
expect(ev.kind).toBe('assistant');
|
|
66
74
|
});
|
|
67
75
|
|
|
76
|
+
test('CORE: a content array of [text, tool_use, tool_use] emits exactly 3 events, not 1', () => {
|
|
77
|
+
const events = classifyLine({
|
|
78
|
+
type: 'assistant',
|
|
79
|
+
message: {
|
|
80
|
+
content: [
|
|
81
|
+
{ type: 'text', text: 'doing two things' },
|
|
82
|
+
{ type: 'tool_use', name: 'Bash', id: 'tu-1', input: { command: 'ls' } },
|
|
83
|
+
{ type: 'tool_use', name: 'Bash', id: 'tu-2', input: { command: 'pwd' } },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
expect(events).toHaveLength(3);
|
|
88
|
+
expect(events[0].kind).toBe('assistant'); // text block inherits the message's own type
|
|
89
|
+
expect(events[0].data).toBe('doing two things');
|
|
90
|
+
expect(events[1].kind).toBe('tool_use');
|
|
91
|
+
expect(events[1].data.id).toBe('tu-1');
|
|
92
|
+
expect(events[2].kind).toBe('tool_use');
|
|
93
|
+
expect(events[2].data.id).toBe('tu-2');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('CORE: a message carrying both usage AND non-empty content emits the usage event AND the content event(s)', () => {
|
|
97
|
+
const events = classifyLine({
|
|
98
|
+
type: 'assistant',
|
|
99
|
+
usage: { input_tokens: 100, output_tokens: 40 },
|
|
100
|
+
message: { content: [{ type: 'text', text: 'hello' }] },
|
|
101
|
+
});
|
|
102
|
+
expect(events).toHaveLength(2);
|
|
103
|
+
expect(events.map((e) => e.kind).sort()).toEqual(['assistant', 'usage']);
|
|
104
|
+
const usageEv = events.find((e) => e.kind === 'usage');
|
|
105
|
+
expect(usageEv.data).toEqual({ input_tokens: 100, output_tokens: 40 });
|
|
106
|
+
const textEv = events.find((e) => e.kind === 'assistant');
|
|
107
|
+
expect(textEv.data).toBe('hello');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('unknown/future content block types are surfaced, not dropped', () => {
|
|
111
|
+
const events = classifyLine({
|
|
112
|
+
type: 'assistant',
|
|
113
|
+
message: { content: [{ type: 'server_tool_use', id: 'x', input: {} }] },
|
|
114
|
+
});
|
|
115
|
+
expect(events).toHaveLength(1);
|
|
116
|
+
expect(events[0].kind).toBe('content_server_tool_use');
|
|
117
|
+
expect(events[0].data).toEqual({ type: 'server_tool_use', id: 'x', input: {} });
|
|
118
|
+
});
|
|
119
|
+
|
|
68
120
|
test('trimContentArray passes through non-array input', () => {
|
|
69
121
|
expect(trimContentArray('not-an-array')).toBe('not-an-array');
|
|
70
122
|
});
|
|
@@ -76,15 +128,74 @@ test('trimContentArray truncates long text fields past MAX_RAW_STR', () => {
|
|
|
76
128
|
expect(block.text.endsWith('…')).toBe(true);
|
|
77
129
|
});
|
|
78
130
|
|
|
79
|
-
test('trimContentArray
|
|
80
|
-
expect(EXEMPT_TYPES.
|
|
81
|
-
expect(EXEMPT_TYPES.has('tool_use')).toBe(true);
|
|
131
|
+
test('trimContentArray now trims tool_use/tool_result blocks too (EXEMPT_TYPES is empty — orchestrator.ts/race.ts, the sole prior consumers, were deleted 2026-07-30)', () => {
|
|
132
|
+
expect(EXEMPT_TYPES.size).toBe(0);
|
|
82
133
|
const longText = 'a'.repeat(MAX_RAW_STR + 100);
|
|
83
134
|
const [block] = trimContentArray([{ type: 'tool_use', text: longText }]);
|
|
84
|
-
expect(block.text.length).toBe(
|
|
135
|
+
expect(block.text.length).toBe(MAX_RAW_STR + 1);
|
|
136
|
+
expect(block.text.endsWith('…')).toBe(true);
|
|
85
137
|
});
|
|
86
138
|
|
|
87
|
-
test('makeRaw builds
|
|
139
|
+
test('makeRaw builds a projection from message content', () => {
|
|
88
140
|
const raw = makeRaw({ message: { content: [{ type: 'text', text: 'hi' }] } });
|
|
89
141
|
expect(raw.message.content).toEqual([{ type: 'text', text: 'hi' }]);
|
|
90
142
|
});
|
|
143
|
+
|
|
144
|
+
test('CORE: makeRaw preserves every top-level field on the line, not just message.content', () => {
|
|
145
|
+
const line = {
|
|
146
|
+
type: 'assistant',
|
|
147
|
+
message: { content: [{ type: 'text', text: 'hi' }] },
|
|
148
|
+
attributionSkill: 'develop',
|
|
149
|
+
attributionPlugin: 'session-manager-dev',
|
|
150
|
+
attributionMcpServer: 'some-server',
|
|
151
|
+
attributionMcpTool: 'some_tool',
|
|
152
|
+
effort: 'high',
|
|
153
|
+
gitBranch: 'main',
|
|
154
|
+
isSidechain: false,
|
|
155
|
+
isMeta: true,
|
|
156
|
+
requestId: 'req-123',
|
|
157
|
+
isApiErrorMessage: false,
|
|
158
|
+
interruptedByShutdown: false,
|
|
159
|
+
permissionMode: 'default',
|
|
160
|
+
promptSource: 'user',
|
|
161
|
+
toolUseResult: { ok: true },
|
|
162
|
+
};
|
|
163
|
+
const raw = makeRaw(line);
|
|
164
|
+
for (const key of [
|
|
165
|
+
'attributionSkill',
|
|
166
|
+
'attributionPlugin',
|
|
167
|
+
'attributionMcpServer',
|
|
168
|
+
'attributionMcpTool',
|
|
169
|
+
'effort',
|
|
170
|
+
'gitBranch',
|
|
171
|
+
'isSidechain',
|
|
172
|
+
'isMeta',
|
|
173
|
+
'requestId',
|
|
174
|
+
'isApiErrorMessage',
|
|
175
|
+
'interruptedByShutdown',
|
|
176
|
+
'permissionMode',
|
|
177
|
+
'promptSource',
|
|
178
|
+
'toolUseResult',
|
|
179
|
+
]) {
|
|
180
|
+
expect(raw[key]).toEqual(line[key]);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('CORE: every event carries a bounded previewText and, when a ref is passed, the ref survives untouched', () => {
|
|
185
|
+
const ref = { filePath: '/tmp/fake.jsonl', byteOffset: 128, byteLength: 64 };
|
|
186
|
+
const [ev] = classifyLine({ type: 'user', message: { content: 'a'.repeat(PREVIEW_CHARS + 500) } }, ref);
|
|
187
|
+
expect(ev.ref).toEqual(ref);
|
|
188
|
+
expect(ev.previewText.length).toBeLessThanOrEqual(PREVIEW_CHARS + 1);
|
|
189
|
+
expect(ev.previewText.endsWith('…')).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('classifyLine omits ref (null) when the caller has no file context', () => {
|
|
193
|
+
const [ev] = classifyLine({ usage: { input_tokens: 1, output_tokens: 1 } });
|
|
194
|
+
expect(ev.ref).toBeNull();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('buildPreviewText caps at PREVIEW_CHARS', () => {
|
|
198
|
+
const s = buildPreviewText('x'.repeat(PREVIEW_CHARS + 50));
|
|
199
|
+
expect(s.length).toBe(PREVIEW_CHARS + 1);
|
|
200
|
+
expect(s.endsWith('…')).toBe(true);
|
|
201
|
+
});
|
|
@@ -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
|
+
});
|