gru-ai 0.1.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 (143) hide show
  1. package/.claude/skills/brainstorm/SKILL.md +340 -0
  2. package/.claude/skills/code-review-excellence/SKILL.md +198 -0
  3. package/.claude/skills/directive/SKILL.md +121 -0
  4. package/.claude/skills/directive/docs/pipeline/00-delegation-and-triage.md +181 -0
  5. package/.claude/skills/directive/docs/pipeline/01-checkpoint.md +34 -0
  6. package/.claude/skills/directive/docs/pipeline/02-read-directive.md +38 -0
  7. package/.claude/skills/directive/docs/pipeline/03-read-context.md +15 -0
  8. package/.claude/skills/directive/docs/pipeline/04-challenge.md +38 -0
  9. package/.claude/skills/directive/docs/pipeline/05-planning.md +64 -0
  10. package/.claude/skills/directive/docs/pipeline/06-technical-audit.md +88 -0
  11. package/.claude/skills/directive/docs/pipeline/07-plan-approval.md +145 -0
  12. package/.claude/skills/directive/docs/pipeline/07b-project-brainstorm.md +85 -0
  13. package/.claude/skills/directive/docs/pipeline/08-worktree-and-state.md +50 -0
  14. package/.claude/skills/directive/docs/pipeline/09-execute-projects.md +709 -0
  15. package/.claude/skills/directive/docs/pipeline/10-wrapup.md +242 -0
  16. package/.claude/skills/directive/docs/pipeline/11-completion-gate.md +75 -0
  17. package/.claude/skills/directive/docs/reference/rules/casting-rules.md +78 -0
  18. package/.claude/skills/directive/docs/reference/rules/failure-handling.md +20 -0
  19. package/.claude/skills/directive/docs/reference/rules/phase-definitions.md +42 -0
  20. package/.claude/skills/directive/docs/reference/rules/scope-and-dod.md +30 -0
  21. package/.claude/skills/directive/docs/reference/schemas/audit-output.md +44 -0
  22. package/.claude/skills/directive/docs/reference/schemas/brainstorm-output.md +52 -0
  23. package/.claude/skills/directive/docs/reference/schemas/challenger-output.md +13 -0
  24. package/.claude/skills/directive/docs/reference/schemas/checkpoint.md +18 -0
  25. package/.claude/skills/directive/docs/reference/schemas/current-json.md +5 -0
  26. package/.claude/skills/directive/docs/reference/schemas/directive-json.md +143 -0
  27. package/.claude/skills/directive/docs/reference/schemas/investigation-output.md +37 -0
  28. package/.claude/skills/directive/docs/reference/schemas/plan-schema.md +103 -0
  29. package/.claude/skills/directive/docs/reference/templates/architect-prompt.md +66 -0
  30. package/.claude/skills/directive/docs/reference/templates/auditor-prompt.md +53 -0
  31. package/.claude/skills/directive/docs/reference/templates/brainstorm-prompt.md +68 -0
  32. package/.claude/skills/directive/docs/reference/templates/challenger-prompt.md +35 -0
  33. package/.claude/skills/directive/docs/reference/templates/digest.md +134 -0
  34. package/.claude/skills/directive/docs/reference/templates/investigator-prompt.md +51 -0
  35. package/.claude/skills/directive/docs/reference/templates/planner-prompt.md +130 -0
  36. package/.claude/skills/frontend-design/SKILL.md +42 -0
  37. package/.claude/skills/gruai-agents/SKILL.md +161 -0
  38. package/.claude/skills/gruai-config/SKILL.md +61 -0
  39. package/.claude/skills/healthcheck/SKILL.md +216 -0
  40. package/.claude/skills/report/SKILL.md +380 -0
  41. package/.claude/skills/scout/SKILL.md +452 -0
  42. package/.claude/skills/seo-audit/SKILL.md +107 -0
  43. package/.claude/skills/walkthrough/SKILL.md +274 -0
  44. package/.claude/skills/webapp-testing/SKILL.md +96 -0
  45. package/LICENSE +21 -0
  46. package/README.md +206 -0
  47. package/cli/templates/CLAUDE.md.template +57 -0
  48. package/cli/templates/agent-roles/backend.md +47 -0
  49. package/cli/templates/agent-roles/cmo.md +52 -0
  50. package/cli/templates/agent-roles/content.md +48 -0
  51. package/cli/templates/agent-roles/coo.md +66 -0
  52. package/cli/templates/agent-roles/cpo.md +52 -0
  53. package/cli/templates/agent-roles/cto.md +63 -0
  54. package/cli/templates/agent-roles/data.md +46 -0
  55. package/cli/templates/agent-roles/design.md +46 -0
  56. package/cli/templates/agent-roles/frontend.md +47 -0
  57. package/cli/templates/agent-roles/fullstack.md +47 -0
  58. package/cli/templates/agent-roles/qa.md +46 -0
  59. package/cli/templates/backlog.json.template +3 -0
  60. package/cli/templates/directive.json.template +9 -0
  61. package/cli/templates/directive.md.template +23 -0
  62. package/cli/templates/goals-index.md +21 -0
  63. package/cli/templates/gruai.config.json.template +12 -0
  64. package/cli/templates/lessons.md +16 -0
  65. package/cli/templates/vision.md +35 -0
  66. package/cli/templates/welcome-directive/directive.json +9 -0
  67. package/cli/templates/welcome-directive/directive.md +53 -0
  68. package/dist/assets/GamePage-C5XQQOQH.js +49 -0
  69. package/dist/assets/README.md +17 -0
  70. package/dist/assets/characters/char_0.png +0 -0
  71. package/dist/assets/characters/char_1.png +0 -0
  72. package/dist/assets/characters/char_10.png +0 -0
  73. package/dist/assets/characters/char_11.png +0 -0
  74. package/dist/assets/characters/char_2.png +0 -0
  75. package/dist/assets/characters/char_3.png +0 -0
  76. package/dist/assets/characters/char_4.png +0 -0
  77. package/dist/assets/characters/char_5.png +0 -0
  78. package/dist/assets/characters/char_6.png +0 -0
  79. package/dist/assets/characters/char_7.png +0 -0
  80. package/dist/assets/characters/char_8.png +0 -0
  81. package/dist/assets/characters/char_9.png +0 -0
  82. package/dist/assets/index-CnTPDqpP.js +12 -0
  83. package/dist/assets/index-gR5q7ikB.css +1 -0
  84. package/dist/assets/office/furniture.png +0 -0
  85. package/dist/assets/office/room-builder.png +0 -0
  86. package/dist/index.html +16 -0
  87. package/dist-server/scripts/intelligence-trends.d.ts +100 -0
  88. package/dist-server/scripts/intelligence-trends.js +365 -0
  89. package/dist-server/server/actions/cleanup.d.ts +4 -0
  90. package/dist-server/server/actions/cleanup.js +30 -0
  91. package/dist-server/server/actions/send-input.d.ts +6 -0
  92. package/dist-server/server/actions/send-input.js +147 -0
  93. package/dist-server/server/actions/terminal.d.ts +4 -0
  94. package/dist-server/server/actions/terminal.js +427 -0
  95. package/dist-server/server/config.d.ts +9 -0
  96. package/dist-server/server/config.js +217 -0
  97. package/dist-server/server/db.d.ts +7 -0
  98. package/dist-server/server/db.js +79 -0
  99. package/dist-server/server/hooks/event-receiver.d.ts +11 -0
  100. package/dist-server/server/hooks/event-receiver.js +36 -0
  101. package/dist-server/server/index.d.ts +1 -0
  102. package/dist-server/server/index.js +552 -0
  103. package/dist-server/server/notifications/macos.d.ts +5 -0
  104. package/dist-server/server/notifications/macos.js +22 -0
  105. package/dist-server/server/notifications/notifier.d.ts +17 -0
  106. package/dist-server/server/notifications/notifier.js +110 -0
  107. package/dist-server/server/parsers/process-discovery.d.ts +39 -0
  108. package/dist-server/server/parsers/process-discovery.js +776 -0
  109. package/dist-server/server/parsers/session-scanner.d.ts +56 -0
  110. package/dist-server/server/parsers/session-scanner.js +390 -0
  111. package/dist-server/server/parsers/session-state.d.ts +68 -0
  112. package/dist-server/server/parsers/session-state.js +696 -0
  113. package/dist-server/server/parsers/session-state.test.d.ts +1 -0
  114. package/dist-server/server/parsers/session-state.test.js +950 -0
  115. package/dist-server/server/parsers/task-parser.d.ts +10 -0
  116. package/dist-server/server/parsers/task-parser.js +97 -0
  117. package/dist-server/server/parsers/team-parser.d.ts +3 -0
  118. package/dist-server/server/parsers/team-parser.js +67 -0
  119. package/dist-server/server/platform/__tests__/claude-code.test.d.ts +1 -0
  120. package/dist-server/server/platform/__tests__/claude-code.test.js +311 -0
  121. package/dist-server/server/platform/claude-code.d.ts +34 -0
  122. package/dist-server/server/platform/claude-code.js +94 -0
  123. package/dist-server/server/platform/index.d.ts +5 -0
  124. package/dist-server/server/platform/index.js +1 -0
  125. package/dist-server/server/platform/types.d.ts +190 -0
  126. package/dist-server/server/platform/types.js +9 -0
  127. package/dist-server/server/state/aggregator.d.ts +42 -0
  128. package/dist-server/server/state/aggregator.js +1080 -0
  129. package/dist-server/server/state/work-item-types.d.ts +555 -0
  130. package/dist-server/server/state/work-item-types.js +168 -0
  131. package/dist-server/server/types.d.ts +237 -0
  132. package/dist-server/server/types.js +1 -0
  133. package/dist-server/server/watchers/claude-watcher.d.ts +17 -0
  134. package/dist-server/server/watchers/claude-watcher.js +130 -0
  135. package/dist-server/server/watchers/context-watcher.d.ts +22 -0
  136. package/dist-server/server/watchers/context-watcher.js +125 -0
  137. package/dist-server/server/watchers/directive-watcher.d.ts +46 -0
  138. package/dist-server/server/watchers/directive-watcher.js +497 -0
  139. package/dist-server/server/watchers/session-watcher.d.ts +18 -0
  140. package/dist-server/server/watchers/session-watcher.js +126 -0
  141. package/dist-server/server/watchers/state-watcher.d.ts +36 -0
  142. package/dist-server/server/watchers/state-watcher.js +369 -0
  143. package/package.json +68 -0
@@ -0,0 +1,950 @@
1
+ import { describe, it, before, after, beforeEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { bootstrapFromTail, getFileState, getAllFileStates, removeFileState, getOrBootstrap, processFileUpdate, toSessionActivity, machineStateToLastEntryType, } from './session-state.js';
7
+ // ---------------------------------------------------------------------------
8
+ // Helpers
9
+ // ---------------------------------------------------------------------------
10
+ let tmpDir;
11
+ function tmpFile(name) {
12
+ return path.join(tmpDir, name);
13
+ }
14
+ /** Write a JSONL file from an array of entry objects. */
15
+ function writeJsonl(filePath, entries) {
16
+ const content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n';
17
+ fs.writeFileSync(filePath, content, 'utf-8');
18
+ }
19
+ /** Append JSONL entries to an existing file. */
20
+ function appendJsonl(filePath, entries) {
21
+ const content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n';
22
+ fs.appendFileSync(filePath, content, 'utf-8');
23
+ }
24
+ // --- Entry factories ---
25
+ const ts = (offset) => `2026-02-23T10:00:${String(offset).padStart(2, '0')}Z`;
26
+ function userPrompt(text, extra = {}) {
27
+ return {
28
+ type: 'user',
29
+ message: { role: 'user', content: [{ type: 'text', text }] },
30
+ timestamp: ts(0),
31
+ ...extra,
32
+ };
33
+ }
34
+ function toolResult(content = 'result', count = 1) {
35
+ const results = Array.from({ length: count }, () => ({ type: 'tool_result', content }));
36
+ return {
37
+ type: 'user',
38
+ message: { role: 'user', content: results },
39
+ timestamp: ts(3),
40
+ };
41
+ }
42
+ function assistantText(text, extra = {}) {
43
+ return {
44
+ type: 'assistant',
45
+ message: { role: 'assistant', content: [{ type: 'text', text }] },
46
+ timestamp: ts(1),
47
+ ...extra,
48
+ };
49
+ }
50
+ function assistantToolUse(toolName, input = {}, extraBlocks = []) {
51
+ return {
52
+ type: 'assistant',
53
+ message: {
54
+ role: 'assistant',
55
+ content: [
56
+ { type: 'text', text: 'Let me check.' },
57
+ { type: 'tool_use', name: toolName, input },
58
+ ...extraBlocks,
59
+ ],
60
+ },
61
+ timestamp: ts(2),
62
+ };
63
+ }
64
+ function assistantMultiToolUse(tools) {
65
+ const content = [{ type: 'text', text: 'Working on it.' }];
66
+ for (const t of tools) {
67
+ content.push({ type: 'tool_use', name: t.name, input: t.input ?? {} });
68
+ }
69
+ return {
70
+ type: 'assistant',
71
+ message: { role: 'assistant', content },
72
+ timestamp: ts(2),
73
+ };
74
+ }
75
+ function turnEnd() {
76
+ return { type: 'system', subtype: 'turn_duration', timestamp: ts(4) };
77
+ }
78
+ function progressEntry() {
79
+ return { type: 'progress' };
80
+ }
81
+ function queueOpEntry() {
82
+ return { type: 'queue-operation' };
83
+ }
84
+ function fileHistoryEntry() {
85
+ return { type: 'file-history-snapshot' };
86
+ }
87
+ /**
88
+ * Bootstrap a temp JSONL file and return its state.
89
+ * Clears any prior cached state for the file first.
90
+ */
91
+ function bootstrapEntries(name, entries) {
92
+ const fp = tmpFile(name);
93
+ writeJsonl(fp, entries);
94
+ removeFileState(fp);
95
+ return bootstrapFromTail(fp);
96
+ }
97
+ // ---------------------------------------------------------------------------
98
+ // Tests
99
+ // ---------------------------------------------------------------------------
100
+ before(() => {
101
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-state-test-'));
102
+ });
103
+ after(() => {
104
+ // Clean up all cached states
105
+ for (const [key] of getAllFileStates()) {
106
+ removeFileState(key);
107
+ }
108
+ // Remove temp dir
109
+ fs.rmSync(tmpDir, { recursive: true, force: true });
110
+ });
111
+ beforeEach(() => {
112
+ // Clear cached file states between tests for isolation
113
+ for (const [key] of getAllFileStates()) {
114
+ if (key.startsWith(tmpDir)) {
115
+ removeFileState(key);
116
+ }
117
+ }
118
+ });
119
+ // ===========================================================================
120
+ // State Machine Transitions
121
+ // ===========================================================================
122
+ describe('State machine transitions', () => {
123
+ it('1. USER_PROMPT sets state=working, resets counts to 0/0', () => {
124
+ const state = bootstrapEntries('t1.jsonl', [
125
+ userPrompt('Hello', { sessionId: 'sess-1' }),
126
+ ]);
127
+ assert.ok(state);
128
+ assert.equal(state.machineState, 'working');
129
+ assert.equal(state.toolUseCount, 0);
130
+ assert.equal(state.toolResultCount, 0);
131
+ });
132
+ it('2. ASSISTANT_TOOL_USE sets state=working, toolUseCount incremented', () => {
133
+ const state = bootstrapEntries('t2.jsonl', [
134
+ userPrompt('Do something'),
135
+ assistantToolUse('Read', { file_path: '/foo/bar.ts' }),
136
+ ]);
137
+ assert.ok(state);
138
+ assert.equal(state.machineState, 'working');
139
+ assert.equal(state.toolUseCount, 1);
140
+ assert.equal(state.lastToolName, 'Read');
141
+ });
142
+ it('3. TOOL_RESULT with counts matching stays working (not done)', () => {
143
+ const state = bootstrapEntries('t3.jsonl', [
144
+ userPrompt('Check file'),
145
+ assistantToolUse('Read', { file_path: '/foo.ts' }),
146
+ toolResult('file contents'),
147
+ ]);
148
+ assert.ok(state);
149
+ assert.equal(state.machineState, 'working');
150
+ assert.equal(state.toolUseCount, 1);
151
+ assert.equal(state.toolResultCount, 1);
152
+ });
153
+ it('4. TOOL_RESULT with counts not matching stays working', () => {
154
+ const state = bootstrapEntries('t4.jsonl', [
155
+ userPrompt('Check files'),
156
+ assistantMultiToolUse([
157
+ { name: 'Read', input: { file_path: '/a.ts' } },
158
+ { name: 'Read', input: { file_path: '/b.ts' } },
159
+ ]),
160
+ toolResult('contents of a', 1), // only 1 of 2 resolved
161
+ ]);
162
+ assert.ok(state);
163
+ assert.equal(state.machineState, 'working');
164
+ assert.equal(state.toolUseCount, 2);
165
+ assert.equal(state.toolResultCount, 1);
166
+ });
167
+ it('5. ASSISTANT_TEXT (no question, tools resolved) sets state=done', () => {
168
+ const state = bootstrapEntries('t5.jsonl', [
169
+ userPrompt('Explain'),
170
+ assistantText('Here is the explanation.'),
171
+ ]);
172
+ assert.ok(state);
173
+ assert.equal(state.machineState, 'done');
174
+ });
175
+ it('6. ASSISTANT_TEXT ending with ? sets state=needs_input', () => {
176
+ const state = bootstrapEntries('t6.jsonl', [
177
+ userPrompt('Do something'),
178
+ assistantText('Would you like me to proceed?'),
179
+ ]);
180
+ assert.ok(state);
181
+ assert.equal(state.machineState, 'needs_input');
182
+ });
183
+ it('7. ASSISTANT_TEXT with pendingInputTool sets state=needs_input', () => {
184
+ const state = bootstrapEntries('t7.jsonl', [
185
+ userPrompt('Do something'),
186
+ assistantToolUse('AskUserQuestion', { question: 'Which file?' }),
187
+ toolResult('user answer'),
188
+ // pendingInputTool was set by AskUserQuestion, but cleared when tool resolved
189
+ // Let's test the case where tool is NOT yet resolved:
190
+ ]);
191
+ // Actually, to test pendingInputTool with ASSISTANT_TEXT, we need a sequence where
192
+ // AskUserQuestion was issued, result came back (clearing pendingInputTool),
193
+ // but let me re-create: AskUserQuestion issued, NO result yet, then ASSISTANT_TEXT
194
+ const state2 = bootstrapEntries('t7b.jsonl', [
195
+ userPrompt('Do something'),
196
+ assistantToolUse('AskUserQuestion', { question: 'Which file?' }),
197
+ // No tool_result yet — pendingInputTool still true
198
+ assistantText('I need more info.'),
199
+ ]);
200
+ assert.ok(state2);
201
+ assert.equal(state2.machineState, 'needs_input');
202
+ assert.equal(state2.pendingInputTool, true);
203
+ });
204
+ it('8. ASSISTANT_TOOL_USE with AskUserQuestion sets state=needs_input with pendingInputTool', () => {
205
+ const state = bootstrapEntries('t8.jsonl', [
206
+ userPrompt('Help me'),
207
+ assistantToolUse('AskUserQuestion', { question: 'What do you need?' }),
208
+ ]);
209
+ assert.ok(state);
210
+ assert.equal(state.machineState, 'needs_input');
211
+ assert.equal(state.pendingInputTool, true);
212
+ assert.equal(state.lastToolName, 'AskUserQuestion');
213
+ assert.equal(state.lastToolDetail, 'Waiting for answer');
214
+ });
215
+ it('9. ASSISTANT_TOOL_USE with ExitPlanMode sets state=needs_input', () => {
216
+ const state = bootstrapEntries('t9.jsonl', [
217
+ userPrompt('Plan something'),
218
+ assistantToolUse('ExitPlanMode', {}),
219
+ ]);
220
+ assert.ok(state);
221
+ assert.equal(state.machineState, 'needs_input');
222
+ assert.equal(state.pendingInputTool, true);
223
+ assert.equal(state.lastToolDetail, 'Plan ready for review');
224
+ });
225
+ it('10. TURN_END sets state=done', () => {
226
+ const state = bootstrapEntries('t10.jsonl', [
227
+ userPrompt('Do it'),
228
+ assistantText('Done.'),
229
+ turnEnd(),
230
+ ]);
231
+ assert.ok(state);
232
+ assert.equal(state.machineState, 'done');
233
+ });
234
+ it('11. TURN_END with pendingInputTool sets state=needs_input', () => {
235
+ const state = bootstrapEntries('t11.jsonl', [
236
+ userPrompt('Help'),
237
+ assistantToolUse('AskUserQuestion', { question: 'Which?' }),
238
+ // No tool result — pendingInputTool stays true
239
+ turnEnd(),
240
+ ]);
241
+ assert.ok(state);
242
+ assert.equal(state.machineState, 'needs_input');
243
+ assert.equal(state.pendingInputTool, true);
244
+ });
245
+ it('12. Multiple tool calls: TOOL_USE(2) -> RESULT(1) -> stays working -> RESULT(1) -> stays working', () => {
246
+ const state1 = bootstrapEntries('t12a.jsonl', [
247
+ userPrompt('Read two files'),
248
+ assistantMultiToolUse([
249
+ { name: 'Read', input: { file_path: '/a.ts' } },
250
+ { name: 'Read', input: { file_path: '/b.ts' } },
251
+ ]),
252
+ toolResult('contents a', 1),
253
+ ]);
254
+ assert.ok(state1);
255
+ assert.equal(state1.machineState, 'working');
256
+ assert.equal(state1.toolUseCount, 2);
257
+ assert.equal(state1.toolResultCount, 1);
258
+ const state2 = bootstrapEntries('t12b.jsonl', [
259
+ userPrompt('Read two files'),
260
+ assistantMultiToolUse([
261
+ { name: 'Read', input: { file_path: '/a.ts' } },
262
+ { name: 'Read', input: { file_path: '/b.ts' } },
263
+ ]),
264
+ toolResult('contents a', 1),
265
+ toolResult('contents b', 1),
266
+ ]);
267
+ assert.ok(state2);
268
+ assert.equal(state2.machineState, 'working');
269
+ assert.equal(state2.toolUseCount, 2);
270
+ assert.equal(state2.toolResultCount, 2);
271
+ });
272
+ it('13. Full turn: USER -> TOOL_USE -> RESULT -> TEXT -> done', () => {
273
+ const state = bootstrapEntries('t13.jsonl', [
274
+ userPrompt('Read and summarize'),
275
+ assistantToolUse('Read', { file_path: '/foo.ts' }),
276
+ toolResult('file contents'),
277
+ assistantText('The file contains a function.'),
278
+ turnEnd(),
279
+ ]);
280
+ assert.ok(state);
281
+ assert.equal(state.machineState, 'done');
282
+ });
283
+ it('14. Full turn with question: USER -> TEXT ending with ? -> needs_input', () => {
284
+ const state = bootstrapEntries('t14.jsonl', [
285
+ userPrompt('Do something'),
286
+ assistantText('Would you like me to continue with the refactor?'),
287
+ ]);
288
+ assert.ok(state);
289
+ assert.equal(state.machineState, 'needs_input');
290
+ });
291
+ it('15. Skip entries (progress, queue-operation, file-history-snapshot) cause no state change', () => {
292
+ const state = bootstrapEntries('t15.jsonl', [
293
+ userPrompt('Start'),
294
+ assistantToolUse('Read', { file_path: '/x.ts' }),
295
+ progressEntry(),
296
+ queueOpEntry(),
297
+ fileHistoryEntry(),
298
+ ]);
299
+ assert.ok(state);
300
+ // State should still be working from the tool_use, skip entries don't change it
301
+ assert.equal(state.machineState, 'working');
302
+ assert.equal(state.toolUseCount, 1);
303
+ // messageCount should NOT include skip entries
304
+ assert.equal(state.messageCount, 2); // user + assistant_tool_use
305
+ });
306
+ it('16. Session exit: USER with no response stays working', () => {
307
+ const state = bootstrapEntries('t16.jsonl', [
308
+ userPrompt('Bye!', { sessionId: 'sess-exit' }),
309
+ ]);
310
+ assert.ok(state);
311
+ assert.equal(state.machineState, 'working');
312
+ });
313
+ });
314
+ // ===========================================================================
315
+ // Metadata Accumulation
316
+ // ===========================================================================
317
+ describe('Metadata accumulation', () => {
318
+ it('17. sessionId captured from first entry that has it', () => {
319
+ const state = bootstrapEntries('t17.jsonl', [
320
+ userPrompt('Hi', { sessionId: 'first-session-id' }),
321
+ assistantText('Hello.', { sessionId: 'second-session-id' }),
322
+ ]);
323
+ assert.ok(state);
324
+ assert.equal(state.sessionId, 'first-session-id');
325
+ });
326
+ it('18. model, cwd, gitBranch, version, slug accumulated (newest wins)', () => {
327
+ const state = bootstrapEntries('t18.jsonl', [
328
+ userPrompt('Hi', {
329
+ sessionId: 'sess-18',
330
+ cwd: '/old/path',
331
+ version: '1.0.0',
332
+ gitBranch: 'main',
333
+ }),
334
+ {
335
+ type: 'assistant',
336
+ message: { role: 'assistant', model: 'claude-opus-4-6', content: [{ type: 'text', text: 'Hi!' }] },
337
+ timestamp: ts(1),
338
+ cwd: '/new/path',
339
+ version: '1.1.0',
340
+ gitBranch: 'feature',
341
+ slug: 'my-slug',
342
+ },
343
+ ]);
344
+ assert.ok(state);
345
+ assert.equal(state.model, 'claude-opus-4-6');
346
+ assert.equal(state.cwd, '/new/path');
347
+ assert.equal(state.version, '1.1.0');
348
+ assert.equal(state.gitBranch, 'feature');
349
+ assert.equal(state.slug, 'my-slug');
350
+ });
351
+ it('19. tasksId extracted from entries referencing tasks directories', () => {
352
+ const uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
353
+ const state = bootstrapEntries('t19.jsonl', [
354
+ userPrompt('Check tasks'),
355
+ assistantToolUse('Read', { file_path: `/home/user/.claude/tasks/${uuid}/task-list.json` }),
356
+ ]);
357
+ assert.ok(state);
358
+ assert.equal(state.tasksId, uuid);
359
+ });
360
+ });
361
+ // ===========================================================================
362
+ // machineStateToLastEntryType mapping
363
+ // ===========================================================================
364
+ describe('machineStateToLastEntryType', () => {
365
+ it('20. working maps to assistant-tool', () => {
366
+ const state = bootstrapEntries('t20.jsonl', [
367
+ userPrompt('Do it'),
368
+ assistantToolUse('Bash', { command: 'ls' }),
369
+ ]);
370
+ assert.ok(state);
371
+ assert.equal(state.machineState, 'working');
372
+ assert.equal(machineStateToLastEntryType(state), 'assistant-tool');
373
+ });
374
+ it('21. needs_input maps to assistant-question', () => {
375
+ const state = bootstrapEntries('t21.jsonl', [
376
+ userPrompt('Do something'),
377
+ assistantText('Should I proceed?'),
378
+ ]);
379
+ assert.ok(state);
380
+ assert.equal(state.machineState, 'needs_input');
381
+ assert.equal(machineStateToLastEntryType(state), 'assistant-question');
382
+ });
383
+ it('22. done maps to assistant-text', () => {
384
+ const state = bootstrapEntries('t22.jsonl', [
385
+ userPrompt('Explain'),
386
+ assistantText('Here is the answer.'),
387
+ turnEnd(),
388
+ ]);
389
+ assert.ok(state);
390
+ assert.equal(state.machineState, 'done');
391
+ assert.equal(machineStateToLastEntryType(state), 'assistant-text');
392
+ });
393
+ });
394
+ // ===========================================================================
395
+ // toSessionActivity
396
+ // ===========================================================================
397
+ describe('toSessionActivity', () => {
398
+ it('23. Returns null if no sessionId', () => {
399
+ const state = bootstrapEntries('t23.jsonl', [
400
+ // No sessionId in any entry
401
+ { type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'Hi' }] }, timestamp: ts(0) },
402
+ ]);
403
+ assert.ok(state);
404
+ assert.equal(state.sessionId, undefined);
405
+ const activity = toSessionActivity(state);
406
+ assert.equal(activity, null);
407
+ });
408
+ it('24. Returns activity with tool info when working', () => {
409
+ const state = bootstrapEntries('t24.jsonl', [
410
+ userPrompt('Check file', { sessionId: 'sess-24' }),
411
+ assistantToolUse('Read', { file_path: '/src/index.ts' }),
412
+ ]);
413
+ assert.ok(state);
414
+ // Override mtimeMs to make it "active" (recent)
415
+ state.mtimeMs = Date.now();
416
+ const activity = toSessionActivity(state);
417
+ assert.ok(activity);
418
+ assert.equal(activity.sessionId, 'sess-24');
419
+ assert.equal(activity.tool, 'Read');
420
+ assert.equal(activity.detail, 'index.ts');
421
+ assert.equal(activity.active, true);
422
+ assert.equal(activity.thinking, false);
423
+ });
424
+ it('25. thinking=true when working with no tool and counts match', () => {
425
+ const state = bootstrapEntries('t25.jsonl', [
426
+ userPrompt('Think about it', { sessionId: 'sess-25' }),
427
+ ]);
428
+ assert.ok(state);
429
+ // working, no lastToolName, toolUseCount === toolResultCount (both 0)
430
+ state.mtimeMs = Date.now();
431
+ const activity = toSessionActivity(state);
432
+ assert.ok(activity);
433
+ assert.equal(activity.thinking, true);
434
+ assert.equal(activity.tool, undefined);
435
+ });
436
+ it('25b. active=false when mtime is old', () => {
437
+ const state = bootstrapEntries('t25b.jsonl', [
438
+ userPrompt('Old session', { sessionId: 'sess-25b' }),
439
+ assistantText('Done.'),
440
+ turnEnd(),
441
+ ]);
442
+ assert.ok(state);
443
+ // Make mtime old (> 5 minutes ago)
444
+ state.mtimeMs = Date.now() - 600_000;
445
+ const activity = toSessionActivity(state);
446
+ assert.ok(activity);
447
+ assert.equal(activity.active, false);
448
+ });
449
+ });
450
+ // ===========================================================================
451
+ // Incremental reading (processFileUpdate)
452
+ // ===========================================================================
453
+ describe('processFileUpdate (incremental reading)', () => {
454
+ it('26. New bytes appended reads only new entries', () => {
455
+ const fp = tmpFile('t26.jsonl');
456
+ writeJsonl(fp, [
457
+ userPrompt('Start', { sessionId: 'sess-26' }),
458
+ assistantText('OK.'),
459
+ turnEnd(),
460
+ ]);
461
+ removeFileState(fp);
462
+ const initial = bootstrapFromTail(fp);
463
+ assert.ok(initial);
464
+ assert.equal(initial.machineState, 'done');
465
+ assert.equal(initial.messageCount, 3);
466
+ // Append new entries
467
+ appendJsonl(fp, [
468
+ userPrompt('Next question'),
469
+ assistantToolUse('Bash', { command: 'echo hello' }),
470
+ ]);
471
+ const updated = processFileUpdate(fp);
472
+ assert.ok(updated);
473
+ assert.equal(updated.machineState, 'working');
474
+ assert.equal(updated.messageCount, 5); // 3 original + 2 new
475
+ assert.equal(updated.lastToolName, 'Bash');
476
+ assert.equal(updated.toolUseCount, 1); // reset by USER_PROMPT then +1
477
+ });
478
+ it('27. File truncated triggers re-bootstrap', () => {
479
+ const fp = tmpFile('t27.jsonl');
480
+ writeJsonl(fp, [
481
+ userPrompt('Start', { sessionId: 'sess-27' }),
482
+ assistantText('Long response with lots of content.'),
483
+ turnEnd(),
484
+ ]);
485
+ removeFileState(fp);
486
+ const initial = bootstrapFromTail(fp);
487
+ assert.ok(initial);
488
+ const originalSize = initial.byteOffset;
489
+ // Truncate the file (simulate recreation with smaller content)
490
+ writeJsonl(fp, [
491
+ userPrompt('Fresh start', { sessionId: 'sess-27-new' }),
492
+ ]);
493
+ const stat = fs.statSync(fp);
494
+ assert.ok(stat.size < originalSize, 'File should be smaller after truncation');
495
+ const updated = processFileUpdate(fp);
496
+ assert.ok(updated);
497
+ assert.equal(updated.sessionId, 'sess-27-new');
498
+ assert.equal(updated.machineState, 'working');
499
+ });
500
+ it('28. No new data returns null', () => {
501
+ const fp = tmpFile('t28.jsonl');
502
+ writeJsonl(fp, [
503
+ userPrompt('Start', { sessionId: 'sess-28' }),
504
+ assistantText('Done.'),
505
+ ]);
506
+ removeFileState(fp);
507
+ bootstrapFromTail(fp);
508
+ // Call processFileUpdate without changing the file
509
+ const result = processFileUpdate(fp);
510
+ assert.equal(result, null);
511
+ });
512
+ });
513
+ // ===========================================================================
514
+ // extractDetail (tested through tool use entries)
515
+ // ===========================================================================
516
+ describe('extractDetail (via tool use entries)', () => {
517
+ it('29. Read/Edit/Write extracts file basename', () => {
518
+ const stateRead = bootstrapEntries('t29a.jsonl', [
519
+ userPrompt('Read file'),
520
+ assistantToolUse('Read', { file_path: '/home/user/project/src/components/Button.tsx' }),
521
+ ]);
522
+ assert.ok(stateRead);
523
+ assert.equal(stateRead.lastToolDetail, 'Button.tsx');
524
+ const stateEdit = bootstrapEntries('t29b.jsonl', [
525
+ userPrompt('Edit file'),
526
+ assistantToolUse('Edit', { file_path: '/home/user/server/index.ts' }),
527
+ ]);
528
+ assert.ok(stateEdit);
529
+ assert.equal(stateEdit.lastToolDetail, 'index.ts');
530
+ const stateWrite = bootstrapEntries('t29c.jsonl', [
531
+ userPrompt('Write file'),
532
+ assistantToolUse('Write', { file_path: '/tmp/output.json' }),
533
+ ]);
534
+ assert.ok(stateWrite);
535
+ assert.equal(stateWrite.lastToolDetail, 'output.json');
536
+ });
537
+ it('30. Bash extracts first 40 chars of command', () => {
538
+ const shortCmd = 'ls -la';
539
+ const state1 = bootstrapEntries('t30a.jsonl', [
540
+ userPrompt('Run command'),
541
+ assistantToolUse('Bash', { command: shortCmd }),
542
+ ]);
543
+ assert.ok(state1);
544
+ assert.equal(state1.lastToolDetail, shortCmd);
545
+ const longCmd = 'find /usr/local/lib -name "*.so" -type f -exec ls -la {} \\; | sort -k5 -n -r';
546
+ const state2 = bootstrapEntries('t30b.jsonl', [
547
+ userPrompt('Run long command'),
548
+ assistantToolUse('Bash', { command: longCmd }),
549
+ ]);
550
+ assert.ok(state2);
551
+ assert.equal(state2.lastToolDetail, longCmd.slice(0, 40));
552
+ });
553
+ it('31. Grep extracts pattern', () => {
554
+ const state = bootstrapEntries('t31.jsonl', [
555
+ userPrompt('Search'),
556
+ assistantToolUse('Grep', { pattern: 'export function' }),
557
+ ]);
558
+ assert.ok(state);
559
+ assert.equal(state.lastToolDetail, 'export function');
560
+ });
561
+ it('32. AskUserQuestion detail is "Waiting for answer"', () => {
562
+ const state = bootstrapEntries('t32.jsonl', [
563
+ userPrompt('Help'),
564
+ assistantToolUse('AskUserQuestion', { question: 'What file?' }),
565
+ ]);
566
+ assert.ok(state);
567
+ assert.equal(state.lastToolDetail, 'Waiting for answer');
568
+ });
569
+ it('33. ExitPlanMode detail is "Plan ready for review"', () => {
570
+ const state = bootstrapEntries('t33.jsonl', [
571
+ userPrompt('Plan'),
572
+ assistantToolUse('ExitPlanMode', {}),
573
+ ]);
574
+ assert.ok(state);
575
+ assert.equal(state.lastToolDetail, 'Plan ready for review');
576
+ });
577
+ it('33b. EnterPlanMode detail is "Requesting plan mode"', () => {
578
+ const state = bootstrapEntries('t33b.jsonl', [
579
+ userPrompt('Plan'),
580
+ assistantToolUse('EnterPlanMode', {}),
581
+ ]);
582
+ assert.ok(state);
583
+ assert.equal(state.lastToolDetail, 'Requesting plan mode');
584
+ assert.equal(state.pendingInputTool, true);
585
+ assert.equal(state.machineState, 'needs_input');
586
+ });
587
+ it('33c. Task tool detail is "Spawned agent"', () => {
588
+ const state = bootstrapEntries('t33c.jsonl', [
589
+ userPrompt('Spawn'),
590
+ assistantToolUse('Task', { prompt: 'Do something' }),
591
+ ]);
592
+ assert.ok(state);
593
+ assert.equal(state.lastToolDetail, 'Spawned agent');
594
+ });
595
+ it('33d. Unknown tool returns tool name as detail', () => {
596
+ const state = bootstrapEntries('t33d.jsonl', [
597
+ userPrompt('Custom'),
598
+ assistantToolUse('SomeCustomTool', { data: 'x' }),
599
+ ]);
600
+ assert.ok(state);
601
+ assert.equal(state.lastToolDetail, 'SomeCustomTool');
602
+ });
603
+ it('33e. Tool with no input returns tool name', () => {
604
+ const state = bootstrapEntries('t33e.jsonl', [
605
+ userPrompt('Check'),
606
+ {
607
+ type: 'assistant',
608
+ message: {
609
+ role: 'assistant',
610
+ content: [{ type: 'tool_use', name: 'Read' }], // no input field
611
+ },
612
+ timestamp: ts(2),
613
+ },
614
+ ]);
615
+ assert.ok(state);
616
+ assert.equal(state.lastToolDetail, 'Read');
617
+ });
618
+ });
619
+ // ===========================================================================
620
+ // Edge cases and complex scenarios
621
+ // ===========================================================================
622
+ describe('Edge cases', () => {
623
+ it('Empty file returns null', () => {
624
+ const fp = tmpFile('empty.jsonl');
625
+ fs.writeFileSync(fp, '', 'utf-8');
626
+ removeFileState(fp);
627
+ const state = bootstrapFromTail(fp);
628
+ assert.equal(state, null);
629
+ });
630
+ it('Malformed JSON lines are skipped gracefully', () => {
631
+ const fp = tmpFile('malformed.jsonl');
632
+ fs.writeFileSync(fp, [
633
+ JSON.stringify(userPrompt('Hi', { sessionId: 'sess-mal' })),
634
+ 'this is not json {{{',
635
+ JSON.stringify(assistantText('Hello.')),
636
+ '',
637
+ ].join('\n'), 'utf-8');
638
+ removeFileState(fp);
639
+ const state = bootstrapFromTail(fp);
640
+ assert.ok(state);
641
+ assert.equal(state.sessionId, 'sess-mal');
642
+ assert.equal(state.machineState, 'done');
643
+ assert.equal(state.messageCount, 2); // user + assistant text (malformed skipped)
644
+ });
645
+ it('getOrBootstrap returns cached state on second call', () => {
646
+ const fp = tmpFile('getorboot.jsonl');
647
+ writeJsonl(fp, [userPrompt('Hi', { sessionId: 'sess-gob' })]);
648
+ removeFileState(fp);
649
+ const first = getOrBootstrap(fp);
650
+ assert.ok(first);
651
+ assert.equal(first.sessionId, 'sess-gob');
652
+ // Modify file but don't clear cache — getOrBootstrap should return cached
653
+ appendJsonl(fp, [assistantText('Bye.')]);
654
+ const second = getOrBootstrap(fp);
655
+ assert.ok(second);
656
+ assert.strictEqual(first, second); // same object reference
657
+ // machineState unchanged because we didn't processFileUpdate
658
+ assert.equal(second.machineState, 'working');
659
+ });
660
+ it('removeFileState clears cached state', () => {
661
+ const fp = tmpFile('remove.jsonl');
662
+ writeJsonl(fp, [userPrompt('Hi', { sessionId: 'sess-rm' })]);
663
+ removeFileState(fp);
664
+ bootstrapFromTail(fp);
665
+ assert.ok(getFileState(fp));
666
+ removeFileState(fp);
667
+ assert.equal(getFileState(fp), undefined);
668
+ });
669
+ it('processFileUpdate on unknown file bootstraps it', () => {
670
+ const fp = tmpFile('newfile.jsonl');
671
+ writeJsonl(fp, [
672
+ userPrompt('Brand new', { sessionId: 'sess-new' }),
673
+ assistantText('Welcome.'),
674
+ ]);
675
+ removeFileState(fp);
676
+ // processFileUpdate should bootstrap since no cached state
677
+ const state = processFileUpdate(fp);
678
+ assert.ok(state);
679
+ assert.equal(state.sessionId, 'sess-new');
680
+ assert.equal(state.machineState, 'done');
681
+ });
682
+ it('processFileUpdate on deleted file returns null and clears state', () => {
683
+ const fp = tmpFile('todelete.jsonl');
684
+ writeJsonl(fp, [userPrompt('Hi', { sessionId: 'sess-del' })]);
685
+ removeFileState(fp);
686
+ bootstrapFromTail(fp);
687
+ assert.ok(getFileState(fp));
688
+ // Delete the file
689
+ fs.unlinkSync(fp);
690
+ const result = processFileUpdate(fp);
691
+ assert.equal(result, null);
692
+ assert.equal(getFileState(fp), undefined);
693
+ });
694
+ it('USER_PROMPT resets tool counts and clears pendingInputTool', () => {
695
+ const state = bootstrapEntries('treset.jsonl', [
696
+ userPrompt('First question', { sessionId: 'sess-reset' }),
697
+ assistantToolUse('AskUserQuestion', { question: 'Which?' }),
698
+ // pendingInputTool = true, toolUseCount = 1
699
+ // Now a new user prompt should reset everything
700
+ userPrompt('Second question'),
701
+ ]);
702
+ assert.ok(state);
703
+ assert.equal(state.machineState, 'working');
704
+ assert.equal(state.toolUseCount, 0);
705
+ assert.equal(state.toolResultCount, 0);
706
+ assert.equal(state.pendingInputTool, false);
707
+ assert.equal(state.lastToolName, undefined);
708
+ assert.equal(state.lastToolDetail, undefined);
709
+ });
710
+ it('ASSISTANT_TEXT with tools still running stays working', () => {
711
+ const state = bootstrapEntries('tstillrunning.jsonl', [
712
+ userPrompt('Do two things'),
713
+ assistantMultiToolUse([
714
+ { name: 'Read', input: { file_path: '/a.ts' } },
715
+ { name: 'Bash', input: { command: 'echo hi' } },
716
+ ]),
717
+ toolResult('contents', 1), // only 1 of 2 resolved
718
+ assistantText('Partial update.'), // text while tools still pending
719
+ ]);
720
+ assert.ok(state);
721
+ assert.equal(state.machineState, 'working');
722
+ assert.equal(state.toolUseCount, 2);
723
+ assert.equal(state.toolResultCount, 1);
724
+ });
725
+ it('TOOL_RESULT clears pendingInputTool when all tools resolve', () => {
726
+ const state = bootstrapEntries('tclear.jsonl', [
727
+ userPrompt('Ask something'),
728
+ assistantToolUse('AskUserQuestion', { question: 'What?' }),
729
+ toolResult('user said yes'),
730
+ ]);
731
+ assert.ok(state);
732
+ // pendingInputTool should be cleared because toolResultCount >= toolUseCount
733
+ assert.equal(state.pendingInputTool, false);
734
+ assert.equal(state.machineState, 'working');
735
+ });
736
+ it('Multiple tool use blocks in a single message are all counted', () => {
737
+ const state = bootstrapEntries('tmulti.jsonl', [
738
+ userPrompt('Do many things'),
739
+ assistantMultiToolUse([
740
+ { name: 'Read', input: { file_path: '/a.ts' } },
741
+ { name: 'Read', input: { file_path: '/b.ts' } },
742
+ { name: 'Grep', input: { pattern: 'foo' } },
743
+ ]),
744
+ ]);
745
+ assert.ok(state);
746
+ assert.equal(state.toolUseCount, 3);
747
+ // lastToolName should be the LAST tool in the list
748
+ assert.equal(state.lastToolName, 'Grep');
749
+ assert.equal(state.lastToolDetail, 'foo');
750
+ });
751
+ it('System entry without turn_duration subtype is skipped', () => {
752
+ const state = bootstrapEntries('tsysother.jsonl', [
753
+ userPrompt('Hi', { sessionId: 'sess-sys' }),
754
+ { type: 'system', subtype: 'something_else', timestamp: ts(4) },
755
+ ]);
756
+ assert.ok(state);
757
+ assert.equal(state.machineState, 'working');
758
+ assert.equal(state.messageCount, 1); // only the user prompt counts
759
+ });
760
+ it('ASSISTANT_TEXT question mark detection uses trimEnd', () => {
761
+ // Question with trailing whitespace should still be detected
762
+ const state = bootstrapEntries('ttrimq.jsonl', [
763
+ userPrompt('Check'),
764
+ assistantText('Do you want to continue? '), // trailing spaces
765
+ ]);
766
+ assert.ok(state);
767
+ assert.equal(state.machineState, 'needs_input');
768
+ });
769
+ it('ASSISTANT_TEXT uses LAST text block for question detection', () => {
770
+ // First block ends with ?, but last block does not -> should be done
771
+ const state = bootstrapEntries('tlastblock.jsonl', [
772
+ userPrompt('Multi-block'),
773
+ {
774
+ type: 'assistant',
775
+ message: {
776
+ role: 'assistant',
777
+ content: [
778
+ { type: 'text', text: 'Is this good?' },
779
+ { type: 'text', text: 'I went ahead and did it.' },
780
+ ],
781
+ },
782
+ timestamp: ts(1),
783
+ },
784
+ ]);
785
+ assert.ok(state);
786
+ assert.equal(state.machineState, 'done');
787
+ });
788
+ it('ASSISTANT_TEXT with LAST text block ending in ? sets needs_input', () => {
789
+ const state = bootstrapEntries('tlastblockq.jsonl', [
790
+ userPrompt('Multi-block'),
791
+ {
792
+ type: 'assistant',
793
+ message: {
794
+ role: 'assistant',
795
+ content: [
796
+ { type: 'text', text: 'I did some work.' },
797
+ { type: 'text', text: 'Should I continue?' },
798
+ ],
799
+ },
800
+ timestamp: ts(1),
801
+ },
802
+ ]);
803
+ assert.ok(state);
804
+ assert.equal(state.machineState, 'needs_input');
805
+ });
806
+ it('lastActivityAt updated from timestamps', () => {
807
+ const state = bootstrapEntries('tactivity.jsonl', [
808
+ userPrompt('First', { sessionId: 'sess-act' }),
809
+ assistantText('Response.'),
810
+ { type: 'system', subtype: 'turn_duration', timestamp: '2026-02-23T10:05:00Z' },
811
+ ]);
812
+ assert.ok(state);
813
+ assert.equal(state.lastActivityAt, '2026-02-23T10:05:00Z');
814
+ });
815
+ it('messageCount increments only for non-SKIP events', () => {
816
+ const state = bootstrapEntries('tcount.jsonl', [
817
+ userPrompt('Start'), // +1 USER_PROMPT
818
+ progressEntry(), // SKIP
819
+ assistantToolUse('Read', { file_path: '/x.ts' }), // +1 ASSISTANT_TOOL_USE
820
+ queueOpEntry(), // SKIP
821
+ toolResult('contents'), // +1 TOOL_RESULT
822
+ fileHistoryEntry(), // SKIP
823
+ assistantText('Done.'), // +1 ASSISTANT_TEXT
824
+ turnEnd(), // +1 TURN_END
825
+ ]);
826
+ assert.ok(state);
827
+ assert.equal(state.messageCount, 5);
828
+ });
829
+ });
830
+ // ===========================================================================
831
+ // Complex multi-turn scenarios
832
+ // ===========================================================================
833
+ describe('Complex multi-turn scenarios', () => {
834
+ it('Two complete turns with tool usage', () => {
835
+ const state = bootstrapEntries('ttwoturns.jsonl', [
836
+ // Turn 1
837
+ userPrompt('Read a file', { sessionId: 'sess-2t' }),
838
+ assistantToolUse('Read', { file_path: '/src/main.ts' }),
839
+ toolResult('function main() {}'),
840
+ assistantText('The file contains a main function.'),
841
+ turnEnd(),
842
+ // Turn 2
843
+ userPrompt('Now edit it'),
844
+ assistantToolUse('Edit', { file_path: '/src/main.ts' }),
845
+ toolResult('file edited'),
846
+ assistantText('I have edited the file.'),
847
+ turnEnd(),
848
+ ]);
849
+ assert.ok(state);
850
+ assert.equal(state.machineState, 'done');
851
+ assert.equal(state.lastToolName, 'Edit');
852
+ // toolUseCount/toolResultCount reflect the latest turn (reset by USER_PROMPT)
853
+ assert.equal(state.toolUseCount, 1);
854
+ assert.equal(state.toolResultCount, 1);
855
+ });
856
+ it('Turn interrupted by new user prompt resets state', () => {
857
+ const state = bootstrapEntries('tinterrupt.jsonl', [
858
+ userPrompt('Start task A', { sessionId: 'sess-int' }),
859
+ assistantToolUse('Bash', { command: 'npm test' }),
860
+ // User interrupts before tool result
861
+ userPrompt('Actually, do task B'),
862
+ assistantText('OK, switching to task B.'),
863
+ ]);
864
+ assert.ok(state);
865
+ assert.equal(state.machineState, 'done');
866
+ assert.equal(state.toolUseCount, 0); // reset by second USER_PROMPT
867
+ assert.equal(state.toolResultCount, 0);
868
+ });
869
+ it('Incremental update across multiple calls', () => {
870
+ const fp = tmpFile('tincremental.jsonl');
871
+ // Phase 1: Initial content
872
+ writeJsonl(fp, [
873
+ userPrompt('Begin', { sessionId: 'sess-inc' }),
874
+ ]);
875
+ removeFileState(fp);
876
+ const s1 = bootstrapFromTail(fp);
877
+ assert.ok(s1);
878
+ assert.equal(s1.machineState, 'working');
879
+ // Phase 2: Assistant responds with tool use
880
+ appendJsonl(fp, [
881
+ assistantToolUse('Grep', { pattern: 'TODO' }),
882
+ ]);
883
+ const s2 = processFileUpdate(fp);
884
+ assert.ok(s2);
885
+ assert.equal(s2.machineState, 'working');
886
+ assert.equal(s2.lastToolName, 'Grep');
887
+ assert.equal(s2.toolUseCount, 1);
888
+ // Phase 3: Tool result
889
+ appendJsonl(fp, [
890
+ toolResult('TODO: fix this'),
891
+ ]);
892
+ const s3 = processFileUpdate(fp);
893
+ assert.ok(s3);
894
+ assert.equal(s3.machineState, 'working');
895
+ assert.equal(s3.toolResultCount, 1);
896
+ // Phase 4: Final text + turn end
897
+ appendJsonl(fp, [
898
+ assistantText('Found one TODO.'),
899
+ turnEnd(),
900
+ ]);
901
+ const s4 = processFileUpdate(fp);
902
+ assert.ok(s4);
903
+ assert.equal(s4.machineState, 'done');
904
+ assert.equal(s4.messageCount, 5); // user + tool_use + tool_result + text + turn_end
905
+ });
906
+ it('AskUserQuestion -> result -> continue -> done flow', () => {
907
+ const state = bootstrapEntries('taskflow.jsonl', [
908
+ userPrompt('Help me', { sessionId: 'sess-ask' }),
909
+ assistantToolUse('AskUserQuestion', { question: 'Which environment?' }),
910
+ toolResult('production'), // user answered
911
+ assistantToolUse('Bash', { command: 'deploy --env production' }),
912
+ toolResult('deployed successfully'),
913
+ assistantText('Deployed to production.'),
914
+ turnEnd(),
915
+ ]);
916
+ assert.ok(state);
917
+ assert.equal(state.machineState, 'done');
918
+ assert.equal(state.pendingInputTool, false);
919
+ assert.equal(state.lastToolName, 'Bash');
920
+ });
921
+ });
922
+ // ===========================================================================
923
+ // Bash tool detail edge cases
924
+ // ===========================================================================
925
+ describe('Bash detail edge cases', () => {
926
+ it('Bash with no command field returns "bash"', () => {
927
+ const state = bootstrapEntries('tbashno.jsonl', [
928
+ userPrompt('Run'),
929
+ assistantToolUse('Bash', {}), // no command field
930
+ ]);
931
+ assert.ok(state);
932
+ assert.equal(state.lastToolDetail, 'bash');
933
+ });
934
+ it('Grep with no pattern field returns "grep"', () => {
935
+ const state = bootstrapEntries('tgrepno.jsonl', [
936
+ userPrompt('Search'),
937
+ assistantToolUse('Grep', {}), // no pattern field
938
+ ]);
939
+ assert.ok(state);
940
+ assert.equal(state.lastToolDetail, 'grep');
941
+ });
942
+ it('Read with no file_path returns "Read"', () => {
943
+ const state = bootstrapEntries('treadnofp.jsonl', [
944
+ userPrompt('Read'),
945
+ assistantToolUse('Read', {}), // no file_path
946
+ ]);
947
+ assert.ok(state);
948
+ assert.equal(state.lastToolDetail, 'Read');
949
+ });
950
+ });