@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.
- package/dist/enforcement/adapters/index.d.ts +15 -0
- package/dist/enforcement/adapters/index.d.ts.map +1 -1
- package/dist/enforcement/adapters/index.js +38 -0
- package/dist/enforcement/adapters/index.js.map +1 -1
- package/dist/enforcement/adapters/opencode.d.ts +21 -0
- package/dist/enforcement/adapters/opencode.d.ts.map +1 -0
- package/dist/enforcement/adapters/opencode.js +115 -0
- package/dist/enforcement/adapters/opencode.js.map +1 -0
- package/dist/planning/evidence-collector.d.ts +2 -0
- package/dist/planning/evidence-collector.d.ts.map +1 -1
- package/dist/planning/evidence-collector.js +7 -2
- package/dist/planning/evidence-collector.js.map +1 -1
- package/dist/planning/plan-lifecycle.d.ts.map +1 -1
- package/dist/planning/plan-lifecycle.js +5 -0
- package/dist/planning/plan-lifecycle.js.map +1 -1
- package/dist/planning/planner-types.d.ts +2 -0
- package/dist/planning/planner-types.d.ts.map +1 -1
- package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
- package/dist/runtime/orchestrate-ops.js +65 -1
- package/dist/runtime/orchestrate-ops.js.map +1 -1
- package/dist/runtime/quality-signals.d.ts +42 -0
- package/dist/runtime/quality-signals.d.ts.map +1 -0
- package/dist/runtime/quality-signals.js +124 -0
- package/dist/runtime/quality-signals.js.map +1 -0
- package/dist/skills/trust-classifier.js +1 -1
- package/dist/skills/trust-classifier.js.map +1 -1
- package/package.json +1 -1
- package/src/enforcement/adapters/index.ts +45 -0
- package/src/enforcement/adapters/opencode.test.ts +404 -0
- package/src/enforcement/adapters/opencode.ts +153 -0
- package/src/planning/evidence-collector.test.ts +95 -0
- package/src/planning/evidence-collector.ts +11 -0
- package/src/planning/plan-lifecycle.test.ts +49 -0
- package/src/planning/plan-lifecycle.ts +5 -0
- package/src/planning/planner-types.ts +2 -0
- package/src/runtime/orchestrate-ops.test.ts +78 -1
- package/src/runtime/orchestrate-ops.ts +91 -1
- package/src/runtime/orchestrate-status-readiness.test.ts +162 -0
- package/src/runtime/quality-signals.test.ts +312 -0
- package/src/runtime/quality-signals.ts +169 -0
- 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
|
}
|