groove-dev 0.27.102 → 0.27.103

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 (50) hide show
  1. package/CLAUDE.md +0 -7
  2. package/moe-training/client/domain-tagger.js +205 -0
  3. package/moe-training/client/edit-normalizer.js +188 -0
  4. package/moe-training/client/envelope-builder.js +1 -1
  5. package/moe-training/client/parsers/claude-code.js +56 -9
  6. package/moe-training/client/parsers/codex.js +25 -5
  7. package/moe-training/client/parsers/gemini.js +21 -2
  8. package/moe-training/client/parsers/grok.js +18 -0
  9. package/moe-training/client/trajectory-capture.js +95 -3
  10. package/moe-training/server/routes/ingest.js +26 -0
  11. package/moe-training/server/verifier.js +34 -0
  12. package/moe-training/shared/constants.js +9 -0
  13. package/moe-training/shared/envelope-schema.js +128 -2
  14. package/moe-training/test/client/domain-tagger.test.js +203 -0
  15. package/moe-training/test/client/edit-normalizer.test.js +376 -0
  16. package/moe-training/test/client/envelope-builder.test.js +28 -0
  17. package/moe-training/test/client/parsers/claude-code.test.js +248 -38
  18. package/moe-training/test/client/parsers/codex.test.js +2 -0
  19. package/moe-training/test/client/parsers/gemini.test.js +2 -0
  20. package/moe-training/test/client/trajectory-capture.test.js +345 -0
  21. package/moe-training/test/server/verifier.test.js +94 -0
  22. package/moe-training/test/shared/envelope-schema.test.js +291 -0
  23. package/node_modules/@groove-dev/cli/package.json +1 -1
  24. package/node_modules/@groove-dev/daemon/package.json +1 -1
  25. package/node_modules/@groove-dev/gui/package.json +1 -1
  26. package/node_modules/moe-training/client/domain-tagger.js +205 -0
  27. package/node_modules/moe-training/client/edit-normalizer.js +188 -0
  28. package/node_modules/moe-training/client/envelope-builder.js +1 -1
  29. package/node_modules/moe-training/client/parsers/claude-code.js +56 -9
  30. package/node_modules/moe-training/client/parsers/codex.js +25 -5
  31. package/node_modules/moe-training/client/parsers/gemini.js +21 -2
  32. package/node_modules/moe-training/client/parsers/grok.js +18 -0
  33. package/node_modules/moe-training/client/trajectory-capture.js +95 -3
  34. package/node_modules/moe-training/server/routes/ingest.js +26 -0
  35. package/node_modules/moe-training/server/verifier.js +34 -0
  36. package/node_modules/moe-training/shared/constants.js +9 -0
  37. package/node_modules/moe-training/shared/envelope-schema.js +128 -2
  38. package/node_modules/moe-training/test/client/domain-tagger.test.js +203 -0
  39. package/node_modules/moe-training/test/client/edit-normalizer.test.js +376 -0
  40. package/node_modules/moe-training/test/client/envelope-builder.test.js +28 -0
  41. package/node_modules/moe-training/test/client/parsers/claude-code.test.js +248 -38
  42. package/node_modules/moe-training/test/client/parsers/codex.test.js +2 -0
  43. package/node_modules/moe-training/test/client/parsers/gemini.test.js +2 -0
  44. package/node_modules/moe-training/test/client/trajectory-capture.test.js +345 -0
  45. package/node_modules/moe-training/test/server/verifier.test.js +94 -0
  46. package/node_modules/moe-training/test/shared/envelope-schema.test.js +291 -0
  47. package/package.json +1 -1
  48. package/packages/cli/package.json +1 -1
  49. package/packages/daemon/package.json +1 -1
  50. package/packages/gui/package.json +1 -1
@@ -3,6 +3,27 @@
3
3
  import { describe, it } from 'node:test';
4
4
  import assert from 'node:assert/strict';
5
5
  import { ClaudeCodeParser } from '../../../client/parsers/claude-code.js';
6
+ import { PIIScrubber } from '../../../client/scrubber.js';
7
+ import { OBSERVATION_TOKEN_LIMIT } from '../../../shared/constants.js';
8
+
9
+ function makeToolUseEvent(id, name, input) {
10
+ return {
11
+ type: 'assistant',
12
+ message: {
13
+ content: [{ type: 'tool_use', id, name, input }],
14
+ usage: {},
15
+ },
16
+ };
17
+ }
18
+
19
+ function makeUserToolResult(toolUseId, content, isError = false) {
20
+ return {
21
+ type: 'user',
22
+ message: {
23
+ content: [{ type: 'tool_result', tool_use_id: toolUseId, content, is_error: isError }],
24
+ },
25
+ };
26
+ }
6
27
 
7
28
  describe('ClaudeCodeParser', () => {
8
29
  it('parses assistant message as thought', () => {
@@ -35,44 +56,6 @@ describe('ClaudeCodeParser', () => {
35
56
  assert.deepEqual(result.arguments, { file_path: '/test.js' });
36
57
  });
37
58
 
38
- it('parses tool_result as observation', () => {
39
- const parser = new ClaudeCodeParser();
40
- // First register a tool use
41
- parser.parseEvent({
42
- type: 'assistant',
43
- message: {
44
- content: [{ type: 'tool_use', id: 'tu_2', name: 'Read', input: {} }],
45
- usage: {},
46
- },
47
- });
48
- // Then parse result
49
- const event = {
50
- type: 'assistant',
51
- message: {
52
- content: [{ type: 'tool_result', tool_use_id: 'tu_2', content: 'file contents here', is_error: false }],
53
- usage: {},
54
- },
55
- };
56
- const result = parser.parseEvent(event);
57
- assert.equal(result.type, 'observation');
58
- assert.equal(result.content, 'file contents here');
59
- assert.equal(result.is_error, false);
60
- });
61
-
62
- it('parses error tool_result as error', () => {
63
- const parser = new ClaudeCodeParser();
64
- const event = {
65
- type: 'assistant',
66
- message: {
67
- content: [{ type: 'tool_result', tool_use_id: 'tu_3', content: 'Permission denied', is_error: true }],
68
- usage: {},
69
- },
70
- };
71
- const result = parser.parseEvent(event);
72
- assert.equal(result.type, 'error');
73
- assert.equal(result.is_error, true);
74
- });
75
-
76
59
  it('parses result event as resolution', () => {
77
60
  const parser = new ClaudeCodeParser();
78
61
  const event = { type: 'result', result: 'Task completed successfully', total_tokens_used: 500 };
@@ -116,4 +99,231 @@ describe('ClaudeCodeParser', () => {
116
99
  assert.equal(parser.parseEvent({ type: 'system' }), null);
117
100
  assert.equal(parser.parseEvent(null), null);
118
101
  });
102
+
103
+ describe('observation steps from user events', () => {
104
+ it('emits observation for Bash tool result', () => {
105
+ const parser = new ClaudeCodeParser();
106
+ parser.parseEvent(makeToolUseEvent('tu_bash', 'Bash', { command: 'ls -la' }));
107
+
108
+ const result = parser.parseEvent(makeUserToolResult('tu_bash', 'file1.js\nfile2.js'));
109
+ assert.equal(result.type, 'observation');
110
+ assert.equal(result.content, 'file1.js\nfile2.js');
111
+ assert.equal(result.is_error, false);
112
+ assert.equal(result.tool, 'Bash');
113
+ assert.equal(result.truncated, false);
114
+ assert.equal(result.original_token_count, Math.ceil('file1.js\nfile2.js'.length / 4));
115
+ });
116
+
117
+ it('emits observation for Read tool result', () => {
118
+ const parser = new ClaudeCodeParser();
119
+ parser.parseEvent(makeToolUseEvent('tu_read', 'Read', { file_path: '/src/index.js' }));
120
+
121
+ const result = parser.parseEvent(makeUserToolResult('tu_read', 'const x = 1;'));
122
+ assert.equal(result.type, 'observation');
123
+ assert.equal(result.content, 'const x = 1;');
124
+ assert.equal(result.tool, 'Read');
125
+ });
126
+
127
+ it('emits observation for Edit tool result', () => {
128
+ const parser = new ClaudeCodeParser();
129
+ parser.parseEvent(makeToolUseEvent('tu_edit', 'Edit', { file_path: '/src/app.js' }));
130
+
131
+ const result = parser.parseEvent(makeUserToolResult('tu_edit', 'Edit applied successfully'));
132
+ assert.equal(result.type, 'observation');
133
+ assert.equal(result.content, 'Edit applied successfully');
134
+ assert.equal(result.tool, 'Edit');
135
+ });
136
+
137
+ it('emits error for failed tool result', () => {
138
+ const parser = new ClaudeCodeParser();
139
+ parser.parseEvent(makeToolUseEvent('tu_fail', 'Bash', { command: 'cat /nonexistent' }));
140
+
141
+ const result = parser.parseEvent(makeUserToolResult('tu_fail', 'cat: /nonexistent: No such file or directory', true));
142
+ assert.equal(result.type, 'error');
143
+ assert.equal(result.is_error, true);
144
+ assert.equal(result.tool, 'Bash');
145
+ assert.match(result.content, /No such file or directory/);
146
+ });
147
+
148
+ it('handles array content in tool_result blocks', () => {
149
+ const parser = new ClaudeCodeParser();
150
+ parser.parseEvent(makeToolUseEvent('tu_arr', 'Grep', { pattern: 'TODO' }));
151
+
152
+ const event = {
153
+ type: 'user',
154
+ message: {
155
+ content: [{
156
+ type: 'tool_result',
157
+ tool_use_id: 'tu_arr',
158
+ content: [{ type: 'text', text: 'src/a.js:1:TODO fix' }, { type: 'text', text: 'src/b.js:5:TODO refactor' }],
159
+ is_error: false,
160
+ }],
161
+ },
162
+ };
163
+ const result = parser.parseEvent(event);
164
+ assert.equal(result.type, 'observation');
165
+ assert.equal(result.content, 'src/a.js:1:TODO fix\nsrc/b.js:5:TODO refactor');
166
+ });
167
+
168
+ it('handles multiple tool_result blocks in one user event', () => {
169
+ const parser = new ClaudeCodeParser();
170
+ parser.parseEvent(makeToolUseEvent('tu_m1', 'Read', { file_path: '/a.js' }));
171
+ parser.parseEvent(makeToolUseEvent('tu_m2', 'Read', { file_path: '/b.js' }));
172
+
173
+ const event = {
174
+ type: 'user',
175
+ message: {
176
+ content: [
177
+ { type: 'tool_result', tool_use_id: 'tu_m1', content: 'content of a', is_error: false },
178
+ { type: 'tool_result', tool_use_id: 'tu_m2', content: 'content of b', is_error: false },
179
+ ],
180
+ },
181
+ };
182
+ const results = parser.parseEvent(event);
183
+ assert.ok(Array.isArray(results));
184
+ assert.equal(results.length, 2);
185
+ assert.equal(results[0].type, 'observation');
186
+ assert.equal(results[0].tool, 'Read');
187
+ assert.equal(results[1].type, 'observation');
188
+ assert.equal(results[1].tool, 'Read');
189
+ });
190
+
191
+ it('returns null for user event without tool_result blocks', () => {
192
+ const parser = new ClaudeCodeParser();
193
+ const result = parser.parseEvent({
194
+ type: 'user',
195
+ message: { content: [{ type: 'text', text: 'user said something' }] },
196
+ });
197
+ assert.equal(result, null);
198
+ });
199
+
200
+ it('returns null for user event with non-array content', () => {
201
+ const parser = new ClaudeCodeParser();
202
+ assert.equal(parser.parseEvent({ type: 'user', message: { content: 'just text' } }), null);
203
+ assert.equal(parser.parseEvent({ type: 'user', message: {} }), null);
204
+ });
205
+
206
+ it('cleans up _pendingToolUse after matching result', () => {
207
+ const parser = new ClaudeCodeParser();
208
+ parser.parseEvent(makeToolUseEvent('tu_clean', 'Bash', { command: 'echo hi' }));
209
+ assert.equal(parser._pendingToolUse.size, 1);
210
+
211
+ parser.parseEvent(makeUserToolResult('tu_clean', 'hi'));
212
+ assert.equal(parser._pendingToolUse.size, 0);
213
+ });
214
+
215
+ it('handles unmatched tool_result (no prior tool_use)', () => {
216
+ const parser = new ClaudeCodeParser();
217
+ const result = parser.parseEvent(makeUserToolResult('tu_orphan', 'some output'));
218
+ assert.equal(result.type, 'observation');
219
+ assert.equal(result.tool, undefined);
220
+ });
221
+ });
222
+
223
+ describe('observation truncation', () => {
224
+ it('truncates observation exceeding token limit', () => {
225
+ const parser = new ClaudeCodeParser();
226
+ parser.parseEvent(makeToolUseEvent('tu_long', 'Bash', { command: 'cat bigfile' }));
227
+
228
+ const charLimit = OBSERVATION_TOKEN_LIMIT * 4;
229
+ const longContent = 'x'.repeat(charLimit + 1000);
230
+
231
+ const result = parser.parseEvent(makeUserToolResult('tu_long', longContent));
232
+ assert.equal(result.type, 'observation');
233
+ assert.equal(result.truncated, true);
234
+ assert.ok(result.original_token_count > OBSERVATION_TOKEN_LIMIT);
235
+ assert.match(result.content, /\[TRUNCATED/);
236
+ assert.ok(result.content.length <= charLimit + 100);
237
+ });
238
+
239
+ it('does not truncate short observation content', () => {
240
+ const parser = new ClaudeCodeParser();
241
+ parser.parseEvent(makeToolUseEvent('tu_short', 'Bash', { command: 'echo hi' }));
242
+
243
+ const shortContent = 'line 1\nline 2\nline 3';
244
+ const result = parser.parseEvent(makeUserToolResult('tu_short', shortContent));
245
+ assert.equal(result.content, shortContent);
246
+ assert.equal(result.truncated, false);
247
+ assert.equal(result.original_token_count, Math.ceil(shortContent.length / 4));
248
+ });
249
+
250
+ it('does not truncate errors', () => {
251
+ const parser = new ClaudeCodeParser();
252
+ parser.parseEvent(makeToolUseEvent('tu_err_long', 'Bash', { command: 'bad' }));
253
+
254
+ const longError = 'x'.repeat(20000);
255
+ const result = parser.parseEvent(makeUserToolResult('tu_err_long', longError, true));
256
+ assert.equal(result.type, 'error');
257
+ assert.equal(result.content, longError);
258
+ assert.equal(result.truncated, undefined);
259
+ });
260
+ });
261
+
262
+ describe('scrubber integration', () => {
263
+ it('observation content is scrubber-compatible (string format)', () => {
264
+ const parser = new ClaudeCodeParser();
265
+ parser.parseEvent(makeToolUseEvent('tu_scrub', 'Bash', { command: 'env' }));
266
+
267
+ const result = parser.parseEvent(makeUserToolResult('tu_scrub', 'API_KEY=sk_test_abc123def456ghi789'));
268
+ assert.equal(typeof result.content, 'string');
269
+
270
+ const scrubber = new PIIScrubber();
271
+ const scrubbed = scrubber.scrub(result.content);
272
+ assert.match(scrubbed, /\[API_KEY\]/);
273
+ assert.ok(!scrubbed.includes('sk_test_abc123def456ghi789'));
274
+ });
275
+
276
+ it('scrubber handles observation with email addresses', () => {
277
+ const parser = new ClaudeCodeParser();
278
+ parser.parseEvent(makeToolUseEvent('tu_email', 'Bash', { command: 'git log' }));
279
+
280
+ const result = parser.parseEvent(makeUserToolResult('tu_email', 'Author: user@example.com'));
281
+ const scrubber = new PIIScrubber();
282
+ const scrubbed = scrubber.scrub(result.content);
283
+ assert.match(scrubbed, /\[EMAIL\]/);
284
+ });
285
+ });
286
+
287
+ describe('full ReAct loop', () => {
288
+ it('produces thought → action → observation → thought sequence', () => {
289
+ const parser = new ClaudeCodeParser();
290
+ const steps = [];
291
+
292
+ // Thought + action (assistant event with text and tool_use)
293
+ const assistantEvent = {
294
+ type: 'assistant',
295
+ message: {
296
+ content: [
297
+ { type: 'text', text: 'Let me check the file' },
298
+ { type: 'tool_use', id: 'tu_react', name: 'Read', input: { file_path: '/src/app.js' } },
299
+ ],
300
+ usage: { input_tokens: 100, output_tokens: 50 },
301
+ },
302
+ };
303
+ const parsed1 = parser.parseEvent(assistantEvent);
304
+ const events1 = Array.isArray(parsed1) ? parsed1 : [parsed1];
305
+ steps.push(...events1);
306
+
307
+ // Observation (user event with tool_result)
308
+ const parsed2 = parser.parseEvent(makeUserToolResult('tu_react', 'const app = express();'));
309
+ const events2 = Array.isArray(parsed2) ? parsed2 : [parsed2];
310
+ steps.push(...events2);
311
+
312
+ // Next thought (assistant event)
313
+ const parsed3 = parser.parseEvent({
314
+ type: 'assistant',
315
+ message: {
316
+ content: [{ type: 'text', text: 'I see the Express setup, now let me fix it' }],
317
+ usage: { input_tokens: 200, output_tokens: 60 },
318
+ },
319
+ });
320
+ steps.push(parsed3);
321
+
322
+ assert.equal(steps.length, 4);
323
+ assert.equal(steps[0].type, 'thought');
324
+ assert.equal(steps[1].type, 'action');
325
+ assert.equal(steps[2].type, 'observation');
326
+ assert.equal(steps[3].type, 'thought');
327
+ });
328
+ });
119
329
  });
@@ -44,6 +44,8 @@ describe('CodexParser', () => {
44
44
  });
45
45
  assert.equal(result.type, 'observation');
46
46
  assert.equal(result.content, 'file1\nfile2');
47
+ assert.equal(result.truncated, false);
48
+ assert.equal(typeof result.original_token_count, 'number');
47
49
  });
48
50
 
49
51
  it('parses item.completed with error exit code as error', () => {
@@ -57,6 +57,8 @@ describe('GeminiParser', () => {
57
57
  });
58
58
  assert.equal(result.type, 'observation');
59
59
  assert.equal(result.content, 'Found 3 matches');
60
+ assert.equal(result.truncated, false);
61
+ assert.equal(typeof result.original_token_count, 'number');
60
62
  });
61
63
 
62
64
  it('parses error event', () => {
@@ -0,0 +1,345 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import { describe, it } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { TrajectoryCapture } from '../../client/trajectory-capture.js';
6
+
7
+ function makeTc() {
8
+ const tc = new TrajectoryCapture({ centralCommandUrl: 'http://localhost:9999' });
9
+ return tc;
10
+ }
11
+
12
+ function makeCtx(overrides = {}) {
13
+ return {
14
+ sessionId: 'sess_test_1',
15
+ stepCount: overrides.stepCount ?? 20,
16
+ totalTokens: overrides.totalTokens ?? 5000,
17
+ errorsEncountered: overrides.errorsEncountered ?? 0,
18
+ errorsRecovered: overrides.errorsRecovered ?? 0,
19
+ coordinationEvents: overrides.coordinationEvents ?? 0,
20
+ revisionRounds: overrides.revisionRounds ?? 0,
21
+ allSteps: overrides.allSteps ?? [
22
+ { step: 1, type: 'thought', content: 'thinking' },
23
+ { step: 2, type: 'action', tool: 'Bash', content: 'running command' },
24
+ { step: 3, type: 'observation', content: 'output here' },
25
+ { step: 4, type: 'thought', content: 'next step' },
26
+ { step: 5, type: 'action', tool: 'Edit', content: 'editing file' },
27
+ ],
28
+ metadata: {
29
+ session_quality: overrides.quality ?? 80,
30
+ },
31
+ builder: {
32
+ buildSessionClose: (outcome) => ({
33
+ envelope_id: 'env_test_close',
34
+ session_id: 'sess_test_1',
35
+ type: 'SESSION_CLOSE',
36
+ outcome,
37
+ }),
38
+ },
39
+ };
40
+ }
41
+
42
+ describe('TrajectoryCapture — quality tier', () => {
43
+ it('TIER_A: high quality, no errors, no interventions, SUCCESS', () => {
44
+ const tc = makeTc();
45
+ const ctx = makeCtx({ quality: 80, errorsEncountered: 0 });
46
+ const result = tc._computeQualityTier(ctx, 'SUCCESS', 0);
47
+ assert.equal(result.tier, 'TIER_A');
48
+ assert.equal(result.reason, 'high_quality_no_errors');
49
+ });
50
+
51
+ it('TIER_A requires quality >= 70', () => {
52
+ const tc = makeTc();
53
+ const ctx = makeCtx({ quality: 69, errorsEncountered: 0 });
54
+ const result = tc._computeQualityTier(ctx, 'SUCCESS', 0);
55
+ assert.notEqual(result.tier, 'TIER_A');
56
+ });
57
+
58
+ it('TIER_A requires zero errors', () => {
59
+ const tc = makeTc();
60
+ const ctx = makeCtx({ quality: 80, errorsEncountered: 1 });
61
+ const result = tc._computeQualityTier(ctx, 'SUCCESS', 0);
62
+ assert.notEqual(result.tier, 'TIER_A');
63
+ });
64
+
65
+ it('TIER_A requires zero user interventions', () => {
66
+ const tc = makeTc();
67
+ const ctx = makeCtx({ quality: 80, errorsEncountered: 0 });
68
+ const result = tc._computeQualityTier(ctx, 'SUCCESS', 1);
69
+ assert.notEqual(result.tier, 'TIER_A');
70
+ });
71
+
72
+ it('TIER_A requires SUCCESS status', () => {
73
+ const tc = makeTc();
74
+ const ctx = makeCtx({ quality: 80, errorsEncountered: 0 });
75
+ const result = tc._computeQualityTier(ctx, 'CRASH', 0);
76
+ assert.notEqual(result.tier, 'TIER_A');
77
+ });
78
+
79
+ it('TIER_B: moderate quality >= 50 with SUCCESS', () => {
80
+ const tc = makeTc();
81
+ const ctx = makeCtx({ quality: 55, errorsEncountered: 0 });
82
+ const result = tc._computeQualityTier(ctx, 'SUCCESS', 0);
83
+ assert.equal(result.tier, 'TIER_B');
84
+ assert.equal(result.reason, 'moderate_quality');
85
+ });
86
+
87
+ it('TIER_C: non-SUCCESS status overrides quality', () => {
88
+ const tc = makeTc();
89
+ const ctx = makeCtx({ quality: 55, errorsEncountered: 0 });
90
+ const result = tc._computeQualityTier(ctx, 'CRASH', 0);
91
+ assert.equal(result.tier, 'TIER_C');
92
+ assert.equal(result.reason, 'non_success_status');
93
+ });
94
+
95
+ it('TIER_B: errors fully recovered', () => {
96
+ const tc = makeTc();
97
+ const ctx = makeCtx({ quality: 40, errorsEncountered: 2, errorsRecovered: 2 });
98
+ const result = tc._computeQualityTier(ctx, 'SUCCESS', 0);
99
+ assert.equal(result.tier, 'TIER_B');
100
+ assert.equal(result.reason, 'errors_recovered');
101
+ });
102
+
103
+ it('TIER_C: low quality below 50', () => {
104
+ const tc = makeTc();
105
+ const ctx = makeCtx({ quality: 30, errorsEncountered: 0 });
106
+ const result = tc._computeQualityTier(ctx, 'SUCCESS', 0);
107
+ assert.equal(result.tier, 'TIER_C');
108
+ assert.equal(result.reason, 'low_quality');
109
+ });
110
+
111
+ it('TIER_C: unrecovered errors with low quality', () => {
112
+ const tc = makeTc();
113
+ const ctx = makeCtx({ quality: 45, errorsEncountered: 3, errorsRecovered: 1 });
114
+ const result = tc._computeQualityTier(ctx, 'SUCCESS', 2);
115
+ assert.equal(result.tier, 'TIER_C');
116
+ });
117
+
118
+ it('TIER_B at exactly quality=50 boundary', () => {
119
+ const tc = makeTc();
120
+ const ctx = makeCtx({ quality: 50, errorsEncountered: 0 });
121
+ const result = tc._computeQualityTier(ctx, 'SUCCESS', 0);
122
+ assert.equal(result.tier, 'TIER_B');
123
+ });
124
+
125
+ it('TIER_A at exactly quality=70 boundary', () => {
126
+ const tc = makeTc();
127
+ const ctx = makeCtx({ quality: 70, errorsEncountered: 0 });
128
+ const result = tc._computeQualityTier(ctx, 'SUCCESS', 0);
129
+ assert.equal(result.tier, 'TIER_A');
130
+ });
131
+ });
132
+
133
+ describe('TrajectoryCapture — training eligibility', () => {
134
+ it('eligible when all criteria met', () => {
135
+ const tc = makeTc();
136
+ const ctx = makeCtx();
137
+ const result = tc._computeTrainingEligibility(ctx, 60);
138
+ assert.equal(result.eligible, true);
139
+ assert.equal(result.exclusionReason, null);
140
+ });
141
+
142
+ it('ineligible: too_few_steps (< 5)', () => {
143
+ const tc = makeTc();
144
+ const ctx = makeCtx({ stepCount: 4 });
145
+ const result = tc._computeTrainingEligibility(ctx, 60);
146
+ assert.equal(result.eligible, false);
147
+ assert.equal(result.exclusionReason, 'too_few_steps');
148
+ });
149
+
150
+ it('ineligible: no_actions (no step with type action + tool)', () => {
151
+ const tc = makeTc();
152
+ const ctx = makeCtx({
153
+ allSteps: [
154
+ { step: 1, type: 'thought', content: 'thinking' },
155
+ { step: 2, type: 'thought', content: 'more thinking' },
156
+ { step: 3, type: 'observation', content: 'output' },
157
+ { step: 4, type: 'thought', content: 'done' },
158
+ { step: 5, type: 'resolution', content: 'completed' },
159
+ ],
160
+ });
161
+ const result = tc._computeTrainingEligibility(ctx, 60);
162
+ assert.equal(result.eligible, false);
163
+ assert.equal(result.exclusionReason, 'no_actions');
164
+ });
165
+
166
+ it('ineligible: no_observations', () => {
167
+ const tc = makeTc();
168
+ const ctx = makeCtx({
169
+ allSteps: [
170
+ { step: 1, type: 'thought', content: 'thinking' },
171
+ { step: 2, type: 'action', tool: 'Bash', content: 'run' },
172
+ { step: 3, type: 'thought', content: 'hmm' },
173
+ { step: 4, type: 'action', tool: 'Edit', content: 'edit' },
174
+ { step: 5, type: 'resolution', content: 'done' },
175
+ ],
176
+ });
177
+ const result = tc._computeTrainingEligibility(ctx, 60);
178
+ assert.equal(result.eligible, false);
179
+ assert.equal(result.exclusionReason, 'no_observations');
180
+ });
181
+
182
+ it('ineligible: insufficient_tokens (< 500)', () => {
183
+ const tc = makeTc();
184
+ const ctx = makeCtx({ totalTokens: 400 });
185
+ const result = tc._computeTrainingEligibility(ctx, 60);
186
+ assert.equal(result.eligible, false);
187
+ assert.equal(result.exclusionReason, 'insufficient_tokens');
188
+ });
189
+
190
+ it('ineligible: too_short (duration < 10s)', () => {
191
+ const tc = makeTc();
192
+ const ctx = makeCtx({ totalTokens: 5000 });
193
+ const result = tc._computeTrainingEligibility(ctx, 9);
194
+ assert.equal(result.eligible, false);
195
+ assert.equal(result.exclusionReason, 'too_short');
196
+ });
197
+
198
+ it('eligible at exact boundary values', () => {
199
+ const tc = makeTc();
200
+ const ctx = makeCtx({ stepCount: 5, totalTokens: 500 });
201
+ const result = tc._computeTrainingEligibility(ctx, 10);
202
+ assert.equal(result.eligible, true);
203
+ assert.equal(result.exclusionReason, null);
204
+ });
205
+
206
+ it('exclusion reasons follow priority order', () => {
207
+ const tc = makeTc();
208
+ const ctx = makeCtx({
209
+ stepCount: 3,
210
+ totalTokens: 100,
211
+ allSteps: [
212
+ { step: 1, type: 'thought', content: 'thinking' },
213
+ { step: 2, type: 'thought', content: 'more' },
214
+ { step: 3, type: 'thought', content: 'done' },
215
+ ],
216
+ });
217
+ const result = tc._computeTrainingEligibility(ctx, 5);
218
+ assert.equal(result.exclusionReason, 'too_few_steps');
219
+ });
220
+ });
221
+
222
+ describe('TrajectoryCapture — user feedback emission', () => {
223
+ it('emits accepted signal on SUCCESS with 0 interventions and 0 revisions', () => {
224
+ const tc = makeTc();
225
+ const captured = [];
226
+ tc._signAndTransmit = (_sid, envelope) => { captured.push(envelope); };
227
+
228
+ const ctx = makeCtx({ revisionRounds: 0 });
229
+ ctx.stepCount = 10;
230
+ tc._emitUserFeedback(ctx, 'SUCCESS', 0);
231
+
232
+ assert.equal(captured.length, 1);
233
+ assert.equal(captured[0].type, 'USER_FEEDBACK');
234
+ assert.equal(captured[0].feedback.signal, 'accepted');
235
+ assert.equal(captured[0].feedback.revision_rounds, 0);
236
+ assert.equal(captured[0].feedback.target_step, 10);
237
+ });
238
+
239
+ it('emits iterated signal on SUCCESS with revision rounds > 0', () => {
240
+ const tc = makeTc();
241
+ const captured = [];
242
+ tc._signAndTransmit = (_sid, envelope) => { captured.push(envelope); };
243
+
244
+ const ctx = makeCtx({ revisionRounds: 3 });
245
+ ctx.stepCount = 25;
246
+ tc._emitUserFeedback(ctx, 'SUCCESS', 2);
247
+
248
+ assert.equal(captured.length, 1);
249
+ assert.equal(captured[0].feedback.signal, 'iterated');
250
+ assert.equal(captured[0].feedback.revision_rounds, 3);
251
+ assert.ok(captured[0].feedback.context.includes('3 revision'));
252
+ });
253
+
254
+ it('does not emit feedback on non-SUCCESS status', () => {
255
+ const tc = makeTc();
256
+ const captured = [];
257
+ tc._signAndTransmit = (_sid, envelope) => { captured.push(envelope); };
258
+
259
+ const ctx = makeCtx({ revisionRounds: 0 });
260
+ tc._emitUserFeedback(ctx, 'CRASH', 0);
261
+
262
+ assert.equal(captured.length, 0);
263
+ });
264
+
265
+ it('does not emit feedback when SHUTDOWN', () => {
266
+ const tc = makeTc();
267
+ const captured = [];
268
+ tc._signAndTransmit = (_sid, envelope) => { captured.push(envelope); };
269
+
270
+ const ctx = makeCtx({ revisionRounds: 0 });
271
+ tc._emitUserFeedback(ctx, 'SHUTDOWN', 0);
272
+
273
+ assert.equal(captured.length, 0);
274
+ });
275
+
276
+ it('feedback envelope has correct structure', () => {
277
+ const tc = makeTc();
278
+ const captured = [];
279
+ tc._signAndTransmit = (_sid, envelope) => { captured.push(envelope); };
280
+
281
+ const ctx = makeCtx({ revisionRounds: 0 });
282
+ ctx.stepCount = 15;
283
+ tc._emitUserFeedback(ctx, 'SUCCESS', 0);
284
+
285
+ const fb = captured[0];
286
+ assert.ok(fb.envelope_id.startsWith('env_'));
287
+ assert.equal(fb.session_id, 'sess_test_1');
288
+ assert.equal(fb.type, 'USER_FEEDBACK');
289
+ assert.ok(fb.attestation);
290
+ assert.ok(fb.feedback.timestamp > 0);
291
+ assert.equal(fb.feedback.delta_summary, null);
292
+ });
293
+ });
294
+
295
+ describe('TrajectoryCapture — _computeQuality', () => {
296
+ it('base score is 50', () => {
297
+ const tc = makeTc();
298
+ const ctx = makeCtx({ allSteps: [] });
299
+ ctx.coordinationEvents = 0;
300
+ ctx.errorsRecovered = 0;
301
+ ctx.stepCount = 0;
302
+ const quality = tc._computeQuality(ctx);
303
+ assert.equal(quality, 50);
304
+ });
305
+
306
+ it('correction adds 10 points', () => {
307
+ const tc = makeTc();
308
+ const ctx = makeCtx({
309
+ allSteps: [{ step: 1, type: 'correction', content: 'fix' }],
310
+ });
311
+ ctx.coordinationEvents = 0;
312
+ ctx.errorsRecovered = 0;
313
+ ctx.stepCount = 1;
314
+ const quality = tc._computeQuality(ctx);
315
+ assert.ok(quality >= 60);
316
+ });
317
+
318
+ it('coordination adds 10 points', () => {
319
+ const tc = makeTc();
320
+ const ctx = makeCtx({ allSteps: [] });
321
+ ctx.coordinationEvents = 1;
322
+ ctx.errorsRecovered = 0;
323
+ ctx.stepCount = 0;
324
+ const quality = tc._computeQuality(ctx);
325
+ assert.equal(quality, 60);
326
+ });
327
+
328
+ it('caps at 100', () => {
329
+ const tc = makeTc();
330
+ const ctx = makeCtx({
331
+ allSteps: [
332
+ { step: 1, type: 'correction' },
333
+ { step: 2, type: 'thought' },
334
+ { step: 3, type: 'action' },
335
+ { step: 4, type: 'observation' },
336
+ ...Array.from({ length: 16 }, (_, i) => ({ step: i + 5, type: 'thought' })),
337
+ ],
338
+ });
339
+ ctx.coordinationEvents = 5;
340
+ ctx.errorsRecovered = 3;
341
+ ctx.stepCount = 20;
342
+ const quality = tc._computeQuality(ctx);
343
+ assert.equal(quality, 100);
344
+ });
345
+ });