@soleri/core 9.7.2 → 9.8.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 (41) hide show
  1. package/dist/enforcement/adapters/index.d.ts +15 -0
  2. package/dist/enforcement/adapters/index.d.ts.map +1 -1
  3. package/dist/enforcement/adapters/index.js +38 -0
  4. package/dist/enforcement/adapters/index.js.map +1 -1
  5. package/dist/enforcement/adapters/opencode.d.ts +21 -0
  6. package/dist/enforcement/adapters/opencode.d.ts.map +1 -0
  7. package/dist/enforcement/adapters/opencode.js +115 -0
  8. package/dist/enforcement/adapters/opencode.js.map +1 -0
  9. package/dist/planning/evidence-collector.d.ts +2 -0
  10. package/dist/planning/evidence-collector.d.ts.map +1 -1
  11. package/dist/planning/evidence-collector.js +7 -2
  12. package/dist/planning/evidence-collector.js.map +1 -1
  13. package/dist/planning/plan-lifecycle.d.ts.map +1 -1
  14. package/dist/planning/plan-lifecycle.js +5 -0
  15. package/dist/planning/plan-lifecycle.js.map +1 -1
  16. package/dist/planning/planner-types.d.ts +2 -0
  17. package/dist/planning/planner-types.d.ts.map +1 -1
  18. package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
  19. package/dist/runtime/orchestrate-ops.js +65 -1
  20. package/dist/runtime/orchestrate-ops.js.map +1 -1
  21. package/dist/runtime/quality-signals.d.ts +42 -0
  22. package/dist/runtime/quality-signals.d.ts.map +1 -0
  23. package/dist/runtime/quality-signals.js +124 -0
  24. package/dist/runtime/quality-signals.js.map +1 -0
  25. package/dist/skills/trust-classifier.js +1 -1
  26. package/dist/skills/trust-classifier.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/enforcement/adapters/index.ts +45 -0
  29. package/src/enforcement/adapters/opencode.test.ts +404 -0
  30. package/src/enforcement/adapters/opencode.ts +153 -0
  31. package/src/planning/evidence-collector.test.ts +95 -0
  32. package/src/planning/evidence-collector.ts +11 -0
  33. package/src/planning/plan-lifecycle.test.ts +49 -0
  34. package/src/planning/plan-lifecycle.ts +5 -0
  35. package/src/planning/planner-types.ts +2 -0
  36. package/src/runtime/orchestrate-ops.test.ts +78 -1
  37. package/src/runtime/orchestrate-ops.ts +91 -1
  38. package/src/runtime/orchestrate-status-readiness.test.ts +162 -0
  39. package/src/runtime/quality-signals.test.ts +312 -0
  40. package/src/runtime/quality-signals.ts +169 -0
  41. package/src/skills/trust-classifier.ts +1 -1
@@ -0,0 +1,404 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { OpenCodeAdapter } from './opencode.js';
3
+ import type { EnforcementAction, EnforcementRule } from '../types.js';
4
+
5
+ // Mock node:fs so detectHost() doesn't hit real filesystem
6
+ vi.mock('node:fs', async (importOriginal) => {
7
+ const actual = await importOriginal<typeof import('node:fs')>();
8
+ return { ...actual, existsSync: vi.fn(() => false) };
9
+ });
10
+
11
+ import { existsSync } from 'node:fs';
12
+ import { detectHost, createHostAdapter } from './index.js';
13
+
14
+ const mockedExistsSync = vi.mocked(existsSync);
15
+
16
+ // ─── Helpers ──────────────────────────────────────────────────────
17
+
18
+ function makeRule(overrides: Partial<EnforcementRule> = {}): EnforcementRule {
19
+ return {
20
+ id: 'test-rule',
21
+ description: 'Test rule',
22
+ trigger: 'pre-tool-use',
23
+ action: 'block',
24
+ message: 'Blocked',
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ // ─── OpenCodeAdapter ──────────────────────────────────────────────
30
+
31
+ describe('OpenCodeAdapter', () => {
32
+ const adapter = new OpenCodeAdapter();
33
+
34
+ // ─── supports() ─────────────────────────────────────────────────
35
+
36
+ describe('supports', () => {
37
+ it('returns true for pre-tool-use', () => {
38
+ expect(adapter.supports('pre-tool-use')).toBe(true);
39
+ });
40
+
41
+ it('returns true for post-tool-use', () => {
42
+ expect(adapter.supports('post-tool-use')).toBe(true);
43
+ });
44
+
45
+ it('returns true for pre-compact', () => {
46
+ expect(adapter.supports('pre-compact')).toBe(true);
47
+ });
48
+
49
+ it('returns true for session-start', () => {
50
+ expect(adapter.supports('session-start')).toBe(true);
51
+ });
52
+
53
+ it('returns false for pre-commit', () => {
54
+ expect(adapter.supports('pre-commit')).toBe(false);
55
+ });
56
+
57
+ it('returns false for on-save', () => {
58
+ expect(adapter.supports('on-save')).toBe(false);
59
+ });
60
+ });
61
+
62
+ // ─── translate() — empty config ─────────────────────────────────
63
+
64
+ describe('translate with empty config', () => {
65
+ it('returns empty files array when no rules provided', () => {
66
+ const result = adapter.translate({ rules: [] });
67
+ expect(result.host).toBe('opencode');
68
+ expect(result.files).toHaveLength(0);
69
+ expect(result.skipped).toHaveLength(0);
70
+ });
71
+ });
72
+
73
+ // ─── translate() — config generation format ─────────────────────
74
+
75
+ describe('translate config generation', () => {
76
+ it('generates plugin file at .opencode/plugins/soleri-enforcement.ts', () => {
77
+ const result = adapter.translate({
78
+ rules: [makeRule({ id: 'r1', trigger: 'pre-tool-use', pattern: 'test' })],
79
+ });
80
+
81
+ expect(result.files).toHaveLength(1);
82
+ expect(result.files[0].path).toBe('.opencode/plugins/soleri-enforcement.ts');
83
+ });
84
+
85
+ it('includes auto-generated header comment', () => {
86
+ const result = adapter.translate({
87
+ rules: [makeRule({ id: 'r1', trigger: 'pre-tool-use', pattern: 'test' })],
88
+ });
89
+
90
+ expect(result.files[0].content).toContain('Auto-generated');
91
+ expect(result.files[0].content).toContain('do not edit manually');
92
+ });
93
+
94
+ it('exports a default object with hooks', () => {
95
+ const result = adapter.translate({
96
+ rules: [makeRule({ id: 'r1', trigger: 'pre-tool-use', pattern: 'test' })],
97
+ });
98
+
99
+ const content = result.files[0].content;
100
+ expect(content).toContain('export default {');
101
+ expect(content).toContain('hooks: {');
102
+ });
103
+
104
+ it('maps pre-tool-use to tool.execute.before event', () => {
105
+ const result = adapter.translate({
106
+ rules: [makeRule({ trigger: 'pre-tool-use', pattern: 'test' })],
107
+ });
108
+
109
+ expect(result.files[0].content).toContain("'tool.execute.before'");
110
+ });
111
+
112
+ it('maps post-tool-use to tool.execute.after event', () => {
113
+ const result = adapter.translate({
114
+ rules: [makeRule({ trigger: 'post-tool-use', pattern: 'test' })],
115
+ });
116
+
117
+ expect(result.files[0].content).toContain("'tool.execute.after'");
118
+ });
119
+
120
+ it('maps pre-compact to session.compacted event', () => {
121
+ const result = adapter.translate({
122
+ rules: [makeRule({ trigger: 'pre-compact', pattern: 'test' })],
123
+ });
124
+
125
+ expect(result.files[0].content).toContain("'session.compacted'");
126
+ });
127
+
128
+ it('maps session-start to session.created event', () => {
129
+ const result = adapter.translate({
130
+ rules: [makeRule({ trigger: 'session-start', pattern: 'test' })],
131
+ });
132
+
133
+ expect(result.files[0].content).toContain("'session.created'");
134
+ });
135
+
136
+ it('groups handlers by event when multiple rules share a trigger', () => {
137
+ const result = adapter.translate({
138
+ rules: [
139
+ makeRule({
140
+ id: 'r1',
141
+ trigger: 'pre-tool-use',
142
+ pattern: 'foo',
143
+ action: 'block',
144
+ message: 'No foo',
145
+ }),
146
+ makeRule({
147
+ id: 'r2',
148
+ trigger: 'pre-tool-use',
149
+ pattern: 'bar',
150
+ action: 'warn',
151
+ message: 'No bar',
152
+ }),
153
+ ],
154
+ });
155
+
156
+ const content = result.files[0].content;
157
+ // Should have only one 'tool.execute.before' event entry with both checks
158
+ const eventMatches = content.match(/tool\.execute\.before/g);
159
+ expect(eventMatches).toHaveLength(1);
160
+ expect(content).toContain('r1');
161
+ expect(content).toContain('r2');
162
+ });
163
+ });
164
+
165
+ // ─── translate() — block/warn/suggest actions ───────────────────
166
+
167
+ describe('action code generation', () => {
168
+ it('block action generates throw new Error', () => {
169
+ const result = adapter.translate({
170
+ rules: [
171
+ makeRule({ id: 'no-exec', action: 'block', message: 'Do not execute', pattern: 'exec' }),
172
+ ],
173
+ });
174
+
175
+ const content = result.files[0].content;
176
+ expect(content).toContain('throw new Error');
177
+ expect(content).toContain('[no-exec] BLOCKED: Do not execute');
178
+ });
179
+
180
+ it('warn action generates console.warn', () => {
181
+ const result = adapter.translate({
182
+ rules: [
183
+ makeRule({ id: 'risky', action: 'warn', message: 'Risky operation', pattern: 'risk' }),
184
+ ],
185
+ });
186
+
187
+ const content = result.files[0].content;
188
+ expect(content).toContain('console.warn');
189
+ expect(content).toContain('[risky] WARNING: Risky operation');
190
+ });
191
+
192
+ it('suggest action generates console.info', () => {
193
+ const result = adapter.translate({
194
+ rules: [
195
+ makeRule({ id: 'tip', action: 'suggest', message: 'Consider this', pattern: 'maybe' }),
196
+ ],
197
+ });
198
+
199
+ const content = result.files[0].content;
200
+ expect(content).toContain('console.info');
201
+ expect(content).toContain('[tip] SUGGESTION: Consider this');
202
+ });
203
+
204
+ it('unknown action falls back to console.warn', () => {
205
+ const result = adapter.translate({
206
+ rules: [
207
+ makeRule({
208
+ id: 'unk',
209
+ action: 'unknown' as unknown as EnforcementAction,
210
+ message: 'Fallback msg',
211
+ pattern: 'x',
212
+ }),
213
+ ],
214
+ });
215
+
216
+ const content = result.files[0].content;
217
+ expect(content).toContain('console.warn');
218
+ expect(content).toContain('[unk] Fallback msg');
219
+ });
220
+
221
+ it('rules without pattern generate action code without regex test', () => {
222
+ const result = adapter.translate({
223
+ rules: [makeRule({ id: 'always', action: 'block', message: 'Always block' })],
224
+ });
225
+
226
+ const content = result.files[0].content;
227
+ expect(content).toContain('throw new Error');
228
+ expect(content).not.toContain('.test(');
229
+ });
230
+
231
+ it('rules with pattern generate regex test against ctx.input', () => {
232
+ const result = adapter.translate({
233
+ rules: [makeRule({ id: 'pat', action: 'warn', message: 'Match found', pattern: 'danger' })],
234
+ });
235
+
236
+ const content = result.files[0].content;
237
+ expect(content).toContain('/danger/.test(JSON.stringify(ctx.input');
238
+ });
239
+ });
240
+
241
+ // ─── translate() — skipped triggers ─────────────────────────────
242
+
243
+ describe('skipped triggers', () => {
244
+ it('skips pre-commit with reason', () => {
245
+ const result = adapter.translate({
246
+ rules: [makeRule({ id: 'commit-check', trigger: 'pre-commit' })],
247
+ });
248
+
249
+ expect(result.files).toHaveLength(0);
250
+ expect(result.skipped).toHaveLength(1);
251
+ expect(result.skipped[0].ruleId).toBe('commit-check');
252
+ expect(result.skipped[0].reason).toContain('not supported by OpenCode');
253
+ });
254
+
255
+ it('skips on-save with reason', () => {
256
+ const result = adapter.translate({
257
+ rules: [makeRule({ id: 'save-check', trigger: 'on-save' })],
258
+ });
259
+
260
+ expect(result.files).toHaveLength(0);
261
+ expect(result.skipped).toHaveLength(1);
262
+ expect(result.skipped[0].ruleId).toBe('save-check');
263
+ expect(result.skipped[0].reason).toContain('not supported by OpenCode');
264
+ });
265
+
266
+ it('handles mix of supported and unsupported triggers', () => {
267
+ const result = adapter.translate({
268
+ rules: [
269
+ makeRule({ id: 'ok', trigger: 'pre-tool-use', pattern: 'test' }),
270
+ makeRule({ id: 'skip1', trigger: 'pre-commit' }),
271
+ makeRule({ id: 'skip2', trigger: 'on-save' }),
272
+ ],
273
+ });
274
+
275
+ expect(result.files).toHaveLength(1);
276
+ expect(result.skipped).toHaveLength(2);
277
+ expect(result.skipped.map((s) => s.ruleId)).toEqual(['skip1', 'skip2']);
278
+ });
279
+
280
+ it('returns only skipped items when all rules are unsupported', () => {
281
+ const result = adapter.translate({
282
+ rules: [
283
+ makeRule({ id: 's1', trigger: 'pre-commit' }),
284
+ makeRule({ id: 's2', trigger: 'on-save' }),
285
+ ],
286
+ });
287
+
288
+ expect(result.files).toHaveLength(0);
289
+ expect(result.skipped).toHaveLength(2);
290
+ });
291
+ });
292
+
293
+ // ─── host property ──────────────────────────────────────────────
294
+
295
+ describe('host', () => {
296
+ it('identifies as opencode', () => {
297
+ expect(adapter.host).toBe('opencode');
298
+ });
299
+ });
300
+ });
301
+
302
+ // ─── detectHost() ─────────────────────────────────────────────────
303
+
304
+ describe('detectHost', () => {
305
+ const originalEnv = { ...process.env };
306
+
307
+ beforeEach(() => {
308
+ // Clear relevant env vars before each test
309
+ delete process.env.OPENCODE;
310
+ delete process.env.OPENCODE_SESSION;
311
+ delete process.env.CLAUDE_CODE;
312
+ mockedExistsSync.mockReset().mockReturnValue(false);
313
+ });
314
+
315
+ afterEach(() => {
316
+ process.env = { ...originalEnv };
317
+ });
318
+
319
+ it('returns opencode when OPENCODE env var is set and no Claude indicators', () => {
320
+ process.env.OPENCODE = '1';
321
+ mockedExistsSync.mockReturnValue(false);
322
+
323
+ expect(detectHost()).toBe('opencode');
324
+ });
325
+
326
+ it('returns opencode when OPENCODE_SESSION env var is set and no Claude indicators', () => {
327
+ process.env.OPENCODE_SESSION = 'abc123';
328
+ mockedExistsSync.mockReturnValue(false);
329
+
330
+ expect(detectHost()).toBe('opencode');
331
+ });
332
+
333
+ it('returns claude-code when CLAUDE_CODE env var is set and no OpenCode indicators', () => {
334
+ process.env.CLAUDE_CODE = '1';
335
+ mockedExistsSync.mockReturnValue(false);
336
+
337
+ expect(detectHost()).toBe('claude-code');
338
+ });
339
+
340
+ it('returns claude-code when both OpenCode and Claude indicators present', () => {
341
+ process.env.OPENCODE = '1';
342
+ process.env.CLAUDE_CODE = '1';
343
+ mockedExistsSync.mockReturnValue(false);
344
+
345
+ expect(detectHost()).toBe('claude-code');
346
+ });
347
+
348
+ it('returns claude-code when neither host is detected (default)', () => {
349
+ mockedExistsSync.mockReturnValue(false);
350
+
351
+ expect(detectHost()).toBe('claude-code');
352
+ });
353
+
354
+ it('detects opencode via filesystem config when env vars absent', () => {
355
+ mockedExistsSync.mockImplementation((p: unknown) => {
356
+ const path = String(p);
357
+ if (path.includes('opencode/opencode.json')) return true;
358
+ if (path.includes('.claude')) return false;
359
+ return false;
360
+ });
361
+
362
+ expect(detectHost()).toBe('opencode');
363
+ });
364
+
365
+ it('detects claude-code via filesystem when .claude dir exists', () => {
366
+ mockedExistsSync.mockImplementation((p: unknown) => {
367
+ const path = String(p);
368
+ if (path.includes('.claude')) return true;
369
+ return false;
370
+ });
371
+
372
+ expect(detectHost()).toBe('claude-code');
373
+ });
374
+ });
375
+
376
+ // ─── createHostAdapter() ──────────────────────────────────────────
377
+
378
+ describe('createHostAdapter', () => {
379
+ const originalEnv = { ...process.env };
380
+
381
+ beforeEach(() => {
382
+ delete process.env.OPENCODE;
383
+ delete process.env.OPENCODE_SESSION;
384
+ delete process.env.CLAUDE_CODE;
385
+ mockedExistsSync.mockReset().mockReturnValue(false);
386
+ });
387
+
388
+ afterEach(() => {
389
+ process.env = { ...originalEnv };
390
+ });
391
+
392
+ it('returns OpenCodeAdapter when opencode is detected', () => {
393
+ process.env.OPENCODE = '1';
394
+
395
+ const adapter = createHostAdapter();
396
+ expect(adapter.host).toBe('opencode');
397
+ expect(adapter).toBeInstanceOf(OpenCodeAdapter);
398
+ });
399
+
400
+ it('returns ClaudeCodeAdapter by default', () => {
401
+ const adapter = createHostAdapter();
402
+ expect(adapter.host).toBe('claude-code');
403
+ });
404
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * OpenCode host adapter — translates enforcement rules to OpenCode plugin config.
3
+ *
4
+ * Maps:
5
+ * - pre-tool-use → tool.execute.before
6
+ * - post-tool-use → tool.execute.after
7
+ * - pre-compact → session.compacted
8
+ * - session-start → session.created
9
+ *
10
+ * Unsupported: pre-commit, on-save (no OpenCode hook equivalents).
11
+ */
12
+
13
+ import type {
14
+ EnforcementConfig,
15
+ EnforcementTrigger,
16
+ HostAdapter,
17
+ HostAdapterResult,
18
+ } from '../types.js';
19
+
20
+ const TRIGGER_TO_EVENT: Partial<Record<EnforcementTrigger, string>> = {
21
+ 'pre-tool-use': 'tool.execute.before',
22
+ 'post-tool-use': 'tool.execute.after',
23
+ 'pre-compact': 'session.compacted',
24
+ 'session-start': 'session.created',
25
+ };
26
+
27
+ interface HookHandler {
28
+ event: string;
29
+ ruleId: string;
30
+ pattern?: string;
31
+ action: string;
32
+ message: string;
33
+ }
34
+
35
+ export class OpenCodeAdapter implements HostAdapter {
36
+ readonly host = 'opencode';
37
+
38
+ supports(trigger: EnforcementTrigger): boolean {
39
+ return trigger in TRIGGER_TO_EVENT;
40
+ }
41
+
42
+ translate(config: EnforcementConfig): HostAdapterResult {
43
+ const handlers: HookHandler[] = [];
44
+ const skipped: Array<{ ruleId: string; reason: string }> = [];
45
+
46
+ for (const rule of config.rules) {
47
+ if (!this.supports(rule.trigger)) {
48
+ skipped.push({
49
+ ruleId: rule.id,
50
+ reason: `Trigger '${rule.trigger}' not supported by OpenCode`,
51
+ });
52
+ continue;
53
+ }
54
+
55
+ const event = TRIGGER_TO_EVENT[rule.trigger];
56
+ if (!event) {
57
+ skipped.push({
58
+ ruleId: rule.id,
59
+ reason: `No event mapping for '${rule.trigger}'`,
60
+ });
61
+ continue;
62
+ }
63
+
64
+ handlers.push({
65
+ event,
66
+ ruleId: rule.id,
67
+ pattern: rule.pattern,
68
+ action: rule.action,
69
+ message: rule.message,
70
+ });
71
+ }
72
+
73
+ const files: Array<{ path: string; content: string }> = [];
74
+
75
+ if (handlers.length > 0) {
76
+ files.push({
77
+ path: '.opencode/plugins/soleri-enforcement.ts',
78
+ content: this.generatePluginFile(handlers),
79
+ });
80
+ }
81
+
82
+ return { host: this.host, files, skipped };
83
+ }
84
+
85
+ private generatePluginFile(handlers: HookHandler[]): string {
86
+ // Group handlers by event
87
+ const byEvent = new Map<string, HookHandler[]>();
88
+ for (const h of handlers) {
89
+ const existing = byEvent.get(h.event) ?? [];
90
+ existing.push(h);
91
+ byEvent.set(h.event, existing);
92
+ }
93
+
94
+ const hookEntries: string[] = [];
95
+
96
+ for (const [event, eventHandlers] of Array.from(byEvent.entries())) {
97
+ const checks = eventHandlers.map((h) => this.generateCheck(h)).join('\n');
98
+ hookEntries.push(` '${event}': (ctx) => {\n${checks}\n }`);
99
+ }
100
+
101
+ const lines = [
102
+ '/**',
103
+ ' * Soleri enforcement plugin for OpenCode.',
104
+ ' * Auto-generated — do not edit manually.',
105
+ ' */',
106
+ '',
107
+ 'export default {',
108
+ ' hooks: {',
109
+ hookEntries.join(',\n'),
110
+ ' },',
111
+ '};',
112
+ '',
113
+ ];
114
+
115
+ return lines.join('\n');
116
+ }
117
+
118
+ private generateCheck(handler: HookHandler): string {
119
+ const indent = ' ';
120
+
121
+ if (!handler.pattern) {
122
+ // No pattern — always fires
123
+ return this.generateActionCode(indent, handler.ruleId, handler.action, handler.message);
124
+ }
125
+
126
+ // Pattern-based check
127
+ const lines = [
128
+ `${indent}// Rule: ${handler.ruleId}`,
129
+ `${indent}if (/${handler.pattern}/.test(JSON.stringify(ctx.input ?? ''))) {`,
130
+ this.generateActionCode(`${indent} `, handler.ruleId, handler.action, handler.message),
131
+ `${indent}}`,
132
+ ];
133
+ return lines.join('\n');
134
+ }
135
+
136
+ private generateActionCode(
137
+ indent: string,
138
+ ruleId: string,
139
+ action: string,
140
+ message: string,
141
+ ): string {
142
+ switch (action) {
143
+ case 'block':
144
+ return `${indent}throw new Error('[${ruleId}] BLOCKED: ${message}');`;
145
+ case 'warn':
146
+ return `${indent}console.warn('[${ruleId}] WARNING: ${message}');`;
147
+ case 'suggest':
148
+ return `${indent}console.info('[${ruleId}] SUGGESTION: ${message}');`;
149
+ default:
150
+ return `${indent}console.warn('[${ruleId}] ${message}');`;
151
+ }
152
+ }
153
+ }
@@ -340,6 +340,101 @@ describe('collectGitEvidence', () => {
340
340
  });
341
341
  });
342
342
 
343
+ describe('collectGitEvidence — rework tracking', () => {
344
+ it('includes fixIterations in task evidence when present', () => {
345
+ mockExecFileSync
346
+ .mockReturnValueOnce('feature/auth\n')
347
+ .mockReturnValueOnce('M\tsrc/auth/middleware.ts\n');
348
+
349
+ const plan = makePlan({
350
+ tasks: [
351
+ {
352
+ id: 'task-1',
353
+ title: 'Add auth middleware',
354
+ description: 'Auth middleware',
355
+ status: 'completed',
356
+ fixIterations: 2,
357
+ updatedAt: Date.now(),
358
+ },
359
+ ],
360
+ });
361
+ const report = collectGitEvidence(plan, '/project', 'main');
362
+
363
+ expect(report.taskEvidence[0].fixIterations).toBe(2);
364
+ });
365
+
366
+ it('omits fixIterations when task has zero rework', () => {
367
+ mockExecFileSync
368
+ .mockReturnValueOnce('feature/auth\n')
369
+ .mockReturnValueOnce('M\tsrc/auth/middleware.ts\n');
370
+
371
+ const plan = makePlan({
372
+ tasks: [
373
+ {
374
+ id: 'task-1',
375
+ title: 'Add auth middleware',
376
+ description: 'Auth middleware',
377
+ status: 'completed',
378
+ updatedAt: Date.now(),
379
+ },
380
+ ],
381
+ });
382
+ const report = collectGitEvidence(plan, '/project', 'main');
383
+
384
+ expect(report.taskEvidence[0].fixIterations).toBeUndefined();
385
+ });
386
+
387
+ it('includes rework count in summary when tasks were reworked', () => {
388
+ mockExecFileSync
389
+ .mockReturnValueOnce('feature/auth\n')
390
+ .mockReturnValueOnce('M\tsrc/auth/middleware.ts\nM\tsrc/auth/login.ts\n');
391
+
392
+ const plan = makePlan({
393
+ tasks: [
394
+ {
395
+ id: 'task-1',
396
+ title: 'Add auth middleware',
397
+ description: 'Auth middleware',
398
+ status: 'completed',
399
+ fixIterations: 1,
400
+ updatedAt: Date.now(),
401
+ },
402
+ {
403
+ id: 'task-2',
404
+ title: 'Add login endpoint',
405
+ description: 'Login endpoint',
406
+ status: 'completed',
407
+ updatedAt: Date.now(),
408
+ },
409
+ ],
410
+ });
411
+ const report = collectGitEvidence(plan, '/project', 'main');
412
+
413
+ expect(report.summary).toContain('1 required rework');
414
+ });
415
+
416
+ it('does not include rework in summary when no tasks were reworked', () => {
417
+ mockExecFileSync
418
+ .mockReturnValueOnce('feature/auth\n')
419
+ .mockReturnValueOnce('M\tsrc/auth/middleware.ts\n');
420
+
421
+ const plan = makePlan({
422
+ tasks: [
423
+ {
424
+ id: 'task-1',
425
+ title: 'Add auth middleware',
426
+ description: 'Auth middleware',
427
+ status: 'completed',
428
+ updatedAt: Date.now(),
429
+ },
430
+ ],
431
+ });
432
+ const report = collectGitEvidence(plan, '/project', 'main');
433
+
434
+ expect(report.summary).not.toContain('rework');
435
+ });
436
+ });
437
+
343
438
  describe('collectVerificationGaps', () => {
344
439
  it('returns empty array when no tasks have verification', () => {
345
440
  const tasks: PlanTask[] = [makeTask()];
@@ -19,6 +19,8 @@ export interface GitTaskEvidence {
19
19
  plannedStatus: string;
20
20
  matchedFiles: FileChange[];
21
21
  verdict: 'DONE' | 'PARTIAL' | 'MISSING' | 'SKIPPED';
22
+ /** Number of rework cycles this task went through (0 = first pass). */
23
+ fixIterations?: number;
22
24
  }
23
25
 
24
26
  export interface UnplannedChange {
@@ -72,6 +74,8 @@ export function collectGitEvidence(
72
74
  plannedStatus: task.status,
73
75
  matchedFiles: matches,
74
76
  verdict,
77
+ ...(task.fixIterations !== undefined &&
78
+ task.fixIterations > 0 && { fixIterations: task.fixIterations }),
75
79
  });
76
80
  }
77
81
 
@@ -93,12 +97,17 @@ export function collectGitEvidence(
93
97
  ? Math.round(((doneTasks + partialTasks * 0.5 + skippedTasks * 0.25) / totalTasks) * 100)
94
98
  : 100;
95
99
 
100
+ const reworkedTasks = taskEvidence.filter(
101
+ (te) => te.fixIterations && te.fixIterations > 0,
102
+ ).length;
103
+
96
104
  const summary = buildSummary(
97
105
  totalTasks,
98
106
  doneTasks,
99
107
  partialTasks,
100
108
  missingWork.length,
101
109
  unplannedChanges.length,
110
+ reworkedTasks,
102
111
  );
103
112
 
104
113
  const verificationGaps = collectVerificationGaps(plan.tasks, taskEvidence);
@@ -284,11 +293,13 @@ function buildSummary(
284
293
  partial: number,
285
294
  missing: number,
286
295
  unplanned: number,
296
+ reworked: number = 0,
287
297
  ): string {
288
298
  const parts: string[] = [];
289
299
  parts.push(`${done}/${total} tasks verified by git evidence`);
290
300
  if (partial > 0) parts.push(`${partial} partially done`);
291
301
  if (missing > 0) parts.push(`${missing} with no file evidence`);
292
302
  if (unplanned > 0) parts.push(`${unplanned} unplanned file changes`);
303
+ if (reworked > 0) parts.push(`${reworked} required rework`);
293
304
  return parts.join(', ');
294
305
  }