@haposoft/cafekit 0.16.0-rc.6 → 0.16.0-rc.7

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 (86) hide show
  1. package/README.md +21 -58
  2. package/bin/__tests__/change-firewall.semantic-firewall.test.js +4 -5
  3. package/bin/__tests__/codex-hooks.test.js +137 -0
  4. package/bin/__tests__/codex-native.test.js +130 -74
  5. package/bin/__tests__/develop-contract.test.js +155 -76
  6. package/bin/__tests__/installer-safety.test.js +19 -154
  7. package/bin/__tests__/package-inventory.test.js +10 -20
  8. package/bin/__tests__/release-preflight.semantic-firewall.test.js +2 -2
  9. package/bin/__tests__/validator-grounding.test.js +11 -11
  10. package/bin/install.js +2 -7
  11. package/bin/lib/context.js +0 -45
  12. package/bin/lib/copy-utils.js +1 -1
  13. package/bin/lib/i18n.js +0 -3
  14. package/bin/lib/manifest.js +1 -1
  15. package/bin/phases/claude-runtime.js +1 -3
  16. package/bin/phases/codex-runtime.js +4 -2
  17. package/bin/phases/copy-payload.js +3 -15
  18. package/bin/phases/post-install.js +5 -17
  19. package/bin/phases/root-config.js +2 -3
  20. package/bin/phases/select-platform.js +2 -5
  21. package/bin/phases/summary.js +0 -1
  22. package/package.json +2 -5
  23. package/src/claude/CLAUDE.md +19 -6
  24. package/src/claude/agents/project-manager.md +41 -9
  25. package/src/claude/agents/spec-maker.md +76 -155
  26. package/src/claude/gitignore +1 -1
  27. package/src/claude/hooks/__tests__/spec-gate.test.js +162 -0
  28. package/src/claude/hooks/lib/context.cjs +1 -1
  29. package/src/claude/hooks/lib/detect.cjs +1 -1
  30. package/src/claude/hooks/spec-gate.cjs +44 -9
  31. package/src/claude/hooks/spec-state.cjs +22 -11
  32. package/src/claude/hooks/task-scaffold-guard.cjs +1 -1
  33. package/src/claude/migration-manifest.json +17 -3
  34. package/src/claude/rules/manage-docs.md +43 -24
  35. package/src/claude/rules/state-sync.md +57 -62
  36. package/src/claude/rules/workflow.md +30 -13
  37. package/src/claude/scripts/change-firewall.cjs +2 -2
  38. package/src/claude/scripts/generate-skill-catalog.cjs +0 -1
  39. package/src/claude/scripts/spec-receipt.cjs +141 -1
  40. package/src/claude/scripts/spec-resolver.cjs +288 -0
  41. package/src/claude/skills/develop/SKILL.md +89 -125
  42. package/src/claude/skills/develop/references/parallel-waves.md +75 -162
  43. package/src/claude/skills/develop/references/quality-gate.md +61 -121
  44. package/src/claude/skills/develop/references/subagent-patterns.md +31 -38
  45. package/src/claude/skills/question/SKILL.md +1 -1
  46. package/src/claude/skills/specs/SKILL.md +142 -204
  47. package/src/claude/skills/specs/references/review.md +109 -243
  48. package/src/claude/skills/specs/references/templates.md +173 -0
  49. package/src/claude/skills/sync/SKILL.md +66 -90
  50. package/src/claude/skills/sync/references/sync-protocols.md +62 -83
  51. package/src/codex/AGENTS.md +20 -7
  52. package/src/codex/hooks/lib/spec-receipt.cjs +18 -0
  53. package/src/codex/hooks/lib/spec-utils.cjs +25 -0
  54. package/src/codex/hooks/spec-gate.cjs +46 -16
  55. package/src/codex/hooks/spec-state.cjs +22 -12
  56. package/src/codex/rules/state-sync.md +57 -61
  57. package/src/common/AGENTS.md +8 -1
  58. package/bin/__tests__/opencode-spec-gate.test.js +0 -799
  59. package/bin/lib/opencode-install.js +0 -899
  60. package/bin/phases/opencode-runtime.js +0 -38
  61. package/src/claude/skills/specs/references/archive-workflow.md +0 -74
  62. package/src/claude/skills/specs/references/ask-user-question-gates.md +0 -70
  63. package/src/claude/skills/specs/references/codebase-analysis.md +0 -102
  64. package/src/claude/skills/specs/references/cross-spec-dependency.md +0 -76
  65. package/src/claude/skills/specs/references/research-strategy.md +0 -107
  66. package/src/claude/skills/specs/references/scope-inquiry.md +0 -107
  67. package/src/claude/skills/specs/references/translation-mirror.md +0 -83
  68. package/src/claude/skills/specs/rules/design-discovery-full.md +0 -45
  69. package/src/claude/skills/specs/rules/design-discovery-light.md +0 -28
  70. package/src/claude/skills/specs/rules/design-principles.md +0 -138
  71. package/src/claude/skills/specs/rules/design-review.md +0 -55
  72. package/src/claude/skills/specs/rules/ears-format.md +0 -42
  73. package/src/claude/skills/specs/rules/phase-decision-matrix.md +0 -56
  74. package/src/claude/skills/specs/rules/task-scoring-rubric.md +0 -52
  75. package/src/claude/skills/specs/rules/tasks-generation.md +0 -189
  76. package/src/opencode/AGENTS.md +0 -41
  77. package/src/opencode/plugins/docs-sync.ts +0 -175
  78. package/src/opencode/plugins/inspect-block.ts +0 -164
  79. package/src/opencode/plugins/privacy-block.ts +0 -182
  80. package/src/opencode/plugins/rules.ts +0 -155
  81. package/src/opencode/plugins/session.ts +0 -179
  82. package/src/opencode/plugins/spec-gate.ts +0 -457
  83. package/src/opencode/plugins/spec-state.ts +0 -311
  84. package/src/opencode/plugins/state.ts +0 -253
  85. package/src/opencode/plugins/task-scaffold-guard.ts +0 -161
  86. package/src/opencode/plugins/usage.ts +0 -178
@@ -1,799 +0,0 @@
1
- 'use strict';
2
-
3
- const assert = require('node:assert/strict');
4
- const fs = require('node:fs');
5
- const os = require('node:os');
6
- const path = require('node:path');
7
- const { test } = require('node:test');
8
- const { pathToFileURL } = require('node:url');
9
-
10
- const PACKAGE_ROOT = path.join(__dirname, '../..');
11
- const GATE_SRC = path.join(PACKAGE_ROOT, 'src/opencode/plugins/spec-gate.ts');
12
- const STATE_SRC = path.join(PACKAGE_ROOT, 'src/opencode/plugins/spec-state.ts');
13
- const BASE_SHA = 'a'.repeat(40);
14
- const HEAD_SHA = 'b'.repeat(40);
15
-
16
- function mkTmp() {
17
- return fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-opencode-'));
18
- }
19
-
20
- function getTypescript() {
21
- try {
22
- return require('typescript');
23
- } catch (_) {
24
- // Fallback to workspace root (pnpm hoists typescript there)
25
- const alt = path.join(PACKAGE_ROOT, '..', '..', 'node_modules', 'typescript');
26
- return require(alt);
27
- }
28
- }
29
-
30
- function compileToMjs(srcPath, outPath) {
31
- const ts = getTypescript();
32
- const source = fs.readFileSync(srcPath, 'utf8');
33
- const result = ts.transpileModule(source, {
34
- compilerOptions: {
35
- target: ts.ScriptTarget.ES2022,
36
- module: ts.ModuleKind.ESNext,
37
- moduleResolution: ts.ModuleResolutionKind.NodeNext,
38
- esModuleInterop: true,
39
- allowSyntheticDefaultImports: true,
40
- },
41
- });
42
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
43
- fs.writeFileSync(outPath, result.outputText, 'utf8');
44
- }
45
-
46
- function ensureSharedValidator(tmp) {
47
- const sources = [
48
- ['workflow-policy.cjs', path.join(PACKAGE_ROOT, 'src/claude/scripts/workflow-policy.cjs')],
49
- ['spec-resolver.cjs', path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs')],
50
- ['provenance.cjs', path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs')],
51
- ];
52
- const dests = [
53
- path.join(tmp, '.opencode', 'scripts'),
54
- path.join(tmp, 'scripts'),
55
- ];
56
- for (const destDir of dests) {
57
- fs.mkdirSync(destDir, { recursive: true });
58
- for (const [name, source] of sources) fs.copyFileSync(source, path.join(destDir, name));
59
- }
60
- }
61
-
62
- test('OpenCode SpecGate lists multiple active candidates and does not pick first', async () => {
63
- const tmp = mkTmp();
64
- ensureSharedValidator(tmp);
65
- try {
66
- const specsDir = path.join(tmp, 'specs');
67
- fs.mkdirSync(specsDir, { recursive: true });
68
- for (const name of ['alpha', 'beta']) {
69
- fs.mkdirSync(path.join(specsDir, name), { recursive: true });
70
- fs.writeFileSync(path.join(specsDir, name, 'spec.json'), JSON.stringify({ feature_name: name, status: 'in_progress', current_phase: 'design', task_registry: {} }));
71
- }
72
- const out = path.join(tmp, 'plugins', 'spec-gate.mjs');
73
- compileToMjs(GATE_SRC, out);
74
- const { SpecGate } = await import(pathToFileURL(out).href);
75
- const gate = await SpecGate({ directory: tmp });
76
- const captured = [];
77
- const origErr = console.error;
78
- console.error = (...args) => captured.push(args.join(' '));
79
- try {
80
- await gate.event({ event: { type: 'session.idle' } });
81
- } finally {
82
- console.error = origErr;
83
- }
84
- const bannerPath = path.join(tmp, '.opencode', 'session-banner.md');
85
- let banner = '';
86
- try { banner = fs.readFileSync(bannerPath, 'utf8'); } catch {}
87
- const combined = captured.join('\n') + '\n' + banner;
88
- assert.match(combined, /multiple active specs detected/i);
89
- assert.match(combined, /alpha/);
90
- assert.match(combined, /beta/);
91
- // Ensure not silently using first spec's task failures (which would be none) — must surface ambiguity
92
- assert.doesNotMatch(combined, /no verification receipt/i);
93
- } finally {
94
- fs.rmSync(tmp, { recursive: true, force: true });
95
- }
96
- });
97
-
98
- test('OpenCode installed gate reports missing and malformed shared policy as controlled unavailable status', async () => {
99
- for (const policyMode of ['missing', 'malformed']) {
100
- const tmp = mkTmp();
101
- const out = path.join(tmp, '.opencode', 'plugins', 'spec-gate.mjs');
102
- if (policyMode === 'malformed') {
103
- fs.mkdirSync(path.join(tmp, '.opencode', 'scripts'), { recursive: true });
104
- fs.writeFileSync(path.join(tmp, '.opencode', 'scripts', 'workflow-policy.cjs'), 'module.exports = {\n');
105
- }
106
- const resolver = path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs');
107
- for (const scriptsDir of [path.join(tmp, 'scripts'), path.join(tmp, '.opencode', 'scripts')]) {
108
- fs.mkdirSync(scriptsDir, { recursive: true });
109
- fs.copyFileSync(resolver, path.join(scriptsDir, 'spec-resolver.cjs'));
110
- }
111
- fs.mkdirSync(path.join(tmp, 'specs', 'demo', 'tasks'), { recursive: true });
112
- fs.writeFileSync(path.join(tmp, 'specs', 'demo', 'spec.json'), JSON.stringify({
113
- feature_name: 'demo',
114
- status: 'in_progress',
115
- task_registry: { 'tasks/task.md': { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
116
- }));
117
- fs.writeFileSync(path.join(tmp, 'specs', 'demo', 'tasks', 'task.md'), `# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: ${BASE_SHA}\nHead: ${HEAD_SHA}\n`);
118
- compileToMjs(GATE_SRC, out);
119
- try {
120
- const { SpecGate } = await import(pathToFileURL(out).href);
121
- const gate = await SpecGate({ directory: tmp });
122
- const captured = [];
123
- const original = console.error;
124
- console.error = (...args) => captured.push(args.join(' '));
125
- try { await gate.event({ event: { type: 'session.idle' } }); } finally { console.error = original; }
126
- const banner = fs.readFileSync(path.join(tmp, '.opencode', 'session-banner.md'), 'utf8');
127
- const combined = `${captured.join('\n')}\n${banner}`;
128
- assert.match(combined, /validator_unavailable|workflow policy/i);
129
- assert.doesNotMatch(combined, /SyntaxError|at .*\.mjs:/);
130
- await assert.rejects(
131
- () => gate['tool.execute.before'](
132
- { tool: 'taskupdate', sessionID: 's-policy', callID: 'c-policy' },
133
- { args: { featureName: 'demo' } },
134
- ),
135
- (error) => error.code === 'CAFEKIT_SPEC_GATE_BLOCKED' && /validator_unavailable|workflow policy/i.test(error.message),
136
- );
137
- } finally {
138
- fs.rmSync(tmp, { recursive: true, force: true });
139
- }
140
- }
141
- });
142
-
143
- test('OpenCode hard-blocks completion tools with a controlled result and allows receipt repair tools', async () => {
144
- const tmp = mkTmp();
145
- ensureSharedValidator(tmp);
146
- try {
147
- const taskDir = path.join(tmp, 'specs', 'demo', 'tasks');
148
- fs.mkdirSync(taskDir, { recursive: true });
149
- fs.writeFileSync(path.join(tmp, 'specs', 'demo', 'spec.json'), JSON.stringify({
150
- feature_name: 'demo',
151
- status: 'in_progress',
152
- task_registry: { 'tasks/task.md': { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
153
- }));
154
- fs.writeFileSync(path.join(taskDir, 'task.md'), '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nExit: 1\n');
155
- const out = path.join(tmp, 'plugins', 'spec-gate.mjs');
156
- compileToMjs(GATE_SRC, out);
157
- const { SpecGate } = await import(pathToFileURL(out).href);
158
- const gate = await SpecGate({ directory: tmp });
159
-
160
- await assert.rejects(
161
- () => gate['tool.execute.before'](
162
- { tool: 'taskupdate', sessionID: 'session-full', callID: 'call-1' },
163
- { args: { featureName: 'demo' } },
164
- ),
165
- (error) => (
166
- error.name === 'CafeKitSpecGateBlocked'
167
- && error.code === 'CAFEKIT_SPEC_GATE_BLOCKED'
168
- && error.result?.decision === 'block'
169
- && /Exit|failed check/i.test(error.message)
170
- ),
171
- );
172
-
173
- // Editing the receipt remains available so the blocked state can be repaired.
174
- await gate['tool.execute.before'](
175
- { tool: 'edit', sessionID: 'session-full', callID: 'call-2' },
176
- { args: { featureName: 'demo' } },
177
- );
178
- } finally {
179
- fs.rmSync(tmp, { recursive: true, force: true });
180
- }
181
- });
182
-
183
- test('OpenCode explicit feature/path target selects the requested spec and never picks first', async () => {
184
- const tmp = mkTmp();
185
- ensureSharedValidator(tmp);
186
- try {
187
- for (const name of ['alpha', 'beta']) {
188
- fs.mkdirSync(path.join(tmp, 'specs', name), { recursive: true });
189
- fs.writeFileSync(path.join(tmp, 'specs', name, 'spec.json'), JSON.stringify({ feature_name: name, status: 'in_progress', task_registry: {} }));
190
- }
191
- const out = path.join(tmp, 'plugins', 'spec-gate.mjs');
192
- compileToMjs(GATE_SRC, out);
193
- const { SpecGate } = await import(pathToFileURL(out).href);
194
- const gate = await SpecGate({ directory: tmp });
195
-
196
- await assert.rejects(
197
- () => gate['tool.execute.before'](
198
- { tool: 'taskupdate', sessionID: 'session-target', callID: 'call-1' },
199
- { args: {} },
200
- ),
201
- (error) => /multiple active specs/i.test(error.message)
202
- && /alpha.*beta/i.test(error.message)
203
- && /featureName|specPath/i.test(error.message),
204
- );
205
- await gate['tool.execute.before'](
206
- { tool: 'taskupdate', sessionID: 'session-target', callID: 'call-2', featureName: 'beta' },
207
- { args: {} },
208
- );
209
- await gate['tool.execute.before'](
210
- { tool: 'taskupdate', sessionID: 'session-target', callID: 'call-3' },
211
- { args: { specPath: path.join('specs', 'beta', 'spec.json') } },
212
- );
213
- } finally {
214
- fs.rmSync(tmp, { recursive: true, force: true });
215
- }
216
- });
217
-
218
- test('OpenCode adapter enforces declared task artifact SHA-256 with missing, invalid, and valid evidence', async () => {
219
- const digest = 'c'.repeat(64);
220
- const cases = [
221
- ['', true],
222
- ['sha256: abc123\n', true],
223
- [`sha256: ${digest}\n`, false],
224
- ];
225
- for (const [hashLine, shouldBlock] of cases) {
226
- const tmp = mkTmp();
227
- ensureSharedValidator(tmp);
228
- try {
229
- const specsDir = path.join(tmp, 'specs');
230
- fs.mkdirSync(path.join(specsDir, 'artifact-demo', 'tasks'), { recursive: true });
231
- fs.writeFileSync(path.join(specsDir, 'artifact-demo', 'spec.json'), JSON.stringify({
232
- feature_name: 'artifact-demo',
233
- status: 'in_progress',
234
- task_registry: {
235
- 'tasks/task.md': {
236
- status: 'done',
237
- completed_at: '2026-08-11T00:00:00.000Z',
238
- artifacts: ['output/bundle.js'],
239
- },
240
- },
241
- }));
242
- fs.writeFileSync(path.join(specsDir, 'artifact-demo', 'tasks', 'task.md'), [
243
- '# Task', '', '**Status:** done', '', '## Evidence', '',
244
- 'Verification: PASS', 'Command: node --test', 'Exit: 0', `Base: ${BASE_SHA}`, `Head: ${HEAD_SHA}`,
245
- 'Artifact: output/bundle.js', hashLine,
246
- ].join('\n'));
247
- const out = path.join(tmp, 'plugins', 'spec-gate.mjs');
248
- compileToMjs(GATE_SRC, out);
249
- const { SpecGate } = await import(pathToFileURL(out).href);
250
- const gate = await SpecGate({ directory: tmp });
251
- const captured = [];
252
- const original = console.error;
253
- console.error = (...args) => captured.push(args.join(' '));
254
- try {
255
- await gate.event({ event: { type: 'session.idle' } });
256
- } finally {
257
- console.error = original;
258
- }
259
- const banner = fs.existsSync(path.join(tmp, '.opencode', 'session-banner.md'))
260
- ? fs.readFileSync(path.join(tmp, '.opencode', 'session-banner.md'), 'utf8')
261
- : '';
262
- const combined = `${captured.join('\n')}\n${banner}`;
263
- if (shouldBlock) {
264
- assert.match(combined, /failed check/i);
265
- assert.match(combined, /\bh\b/);
266
- } else {
267
- assert.doesNotMatch(combined, /failed check/i);
268
- }
269
- } finally {
270
- fs.rmSync(tmp, { recursive: true, force: true });
271
- }
272
- }
273
- });
274
-
275
- test('OpenCode gate and state use the same configured external specs authority', async () => {
276
- const tmp = mkTmp();
277
- ensureSharedValidator(tmp);
278
- try {
279
- const localSpecs = path.join(tmp, 'specs');
280
- const externalSpecs = path.join(tmp, 'external-specs');
281
- fs.mkdirSync(path.join(localSpecs, 'local'), { recursive: true });
282
- fs.mkdirSync(path.join(externalSpecs, 'remote', 'tasks'), { recursive: true });
283
- fs.writeFileSync(path.join(localSpecs, 'local', 'spec.json'), JSON.stringify({ feature_name: 'local', status: 'in_progress', current_phase: 'local' }));
284
- fs.writeFileSync(path.join(externalSpecs, 'remote', 'spec.json'), JSON.stringify({
285
- feature_name: 'remote',
286
- status: 'in_progress',
287
- current_phase: 'remote',
288
- task_registry: { 'tasks/task.md': { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
289
- }));
290
- fs.writeFileSync(path.join(externalSpecs, 'remote', 'tasks', 'task.md'), '# Task\n\n**Status:** done\n');
291
- fs.mkdirSync(path.join(tmp, '.opencode'), { recursive: true });
292
- fs.writeFileSync(path.join(tmp, '.opencode', 'runtime.json'), JSON.stringify({ paths: { specs: externalSpecs } }));
293
-
294
- const gateOut = path.join(tmp, '.opencode', 'plugins', 'spec-gate.mjs');
295
- compileToMjs(GATE_SRC, gateOut);
296
- const { SpecGate } = await import(pathToFileURL(gateOut).href);
297
- const gate = await SpecGate({ directory: tmp });
298
- const captured = [];
299
- const original = console.error;
300
- console.error = (...args) => captured.push(args.join(' '));
301
- try {
302
- await gate.event({ event: { type: 'session.idle' } });
303
- } finally {
304
- console.error = original;
305
- }
306
- const banner = fs.readFileSync(path.join(tmp, '.opencode', 'session-banner.md'), 'utf8');
307
- const gateText = `${captured.join('\n')}\n${banner}`;
308
- assert.match(gateText, /remote/);
309
- assert.doesNotMatch(gateText, /local/);
310
-
311
- const stateOut = path.join(tmp, '.opencode', 'plugins', 'spec-state.mjs');
312
- compileToMjs(STATE_SRC, stateOut);
313
- const { SpecState } = await import(pathToFileURL(stateOut).href);
314
- const state = await SpecState({ directory: tmp });
315
- const output = { message: { id: 'external', sessionID: 'external' }, parts: [] };
316
- await state['chat.message']({}, output);
317
- const stateText = output.parts.map((part) => part.text || '').join('\n');
318
- assert.match(stateText, /remote/);
319
- assert.doesNotMatch(stateText, /local/);
320
- } finally {
321
- fs.rmSync(tmp, { recursive: true, force: true });
322
- }
323
- });
324
-
325
- test('OpenCode SpecState injects multiple-active warning with candidates', async () => {
326
- const tmp = mkTmp();
327
- ensureSharedValidator(tmp);
328
- try {
329
- const specsDir = path.join(tmp, 'specs');
330
- fs.mkdirSync(specsDir, { recursive: true });
331
- for (const name of ['alpha', 'beta']) {
332
- fs.mkdirSync(path.join(specsDir, name), { recursive: true });
333
- fs.writeFileSync(path.join(specsDir, name, 'spec.json'), JSON.stringify({ feature_name: name, status: 'in_progress', current_phase: 'design', task_registry: {} }));
334
- }
335
- const out = path.join(tmp, 'plugins', 'spec-state.mjs');
336
- compileToMjs(STATE_SRC, out);
337
- const { SpecState } = await import(pathToFileURL(out).href);
338
- const state = await SpecState({ directory: tmp });
339
- const output = { message: { id: 'm1', sessionID: 's1' }, parts: [] };
340
- await state['chat.message']({}, output);
341
- const text = output.parts.map((p) => p.text || '').join('\n');
342
- assert.match(text, /Multiple active specs detected/i);
343
- assert.match(text, /alpha/);
344
- assert.match(text, /beta/);
345
- } finally {
346
- fs.rmSync(tmp, { recursive: true, force: true });
347
- }
348
- });
349
-
350
- test('OpenCode SpecGate reports Exit:1 receipt as a completion failure', async () => {
351
- const tmp = mkTmp();
352
- ensureSharedValidator(tmp);
353
- try {
354
- const specsDir = path.join(tmp, 'specs');
355
- fs.mkdirSync(path.join(specsDir, 'demo', 'tasks'), { recursive: true });
356
- // Single active spec with done task but Exit 1 must remain incomplete.
357
- fs.writeFileSync(path.join(specsDir, 'demo', 'spec.json'), JSON.stringify({
358
- feature_name: 'demo',
359
- status: 'in_progress',
360
- current_phase: 'implement',
361
- task_registry: { 'tasks/task.md': { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
362
- }));
363
- fs.writeFileSync(path.join(specsDir, 'demo', 'tasks', 'task.md'), [
364
- '# Task',
365
- '',
366
- '**Status:** done',
367
- '',
368
- '## Evidence',
369
- '',
370
- 'Verification: PASS',
371
- 'Command: pnpm test',
372
- 'Exit: 1',
373
- `Base: ${BASE_SHA}`,
374
- `Head: ${HEAD_SHA}`,
375
- '',
376
- ].join('\n'));
377
- const out = path.join(tmp, 'plugins', 'spec-gate.mjs');
378
- compileToMjs(GATE_SRC, out);
379
- const { SpecGate } = await import(pathToFileURL(out).href);
380
- const gate = await SpecGate({ directory: tmp });
381
- const captured = [];
382
- const origErr = console.error;
383
- console.error = (...args) => captured.push(args.join(' '));
384
- try {
385
- await gate.event({ event: { type: 'session.idle' } });
386
- } finally {
387
- console.error = origErr;
388
- }
389
- const bannerPath = path.join(tmp, '.opencode', 'session-banner.md');
390
- let banner = '';
391
- try { banner = fs.readFileSync(bannerPath, 'utf8'); } catch {}
392
- const combined = captured.join('\n') + '\n' + banner;
393
- // Should surface verification receipt failure, not pass silently
394
- assert.match(combined, /verification receipt|failed check/i);
395
- assert.match(combined, /tasks\/task\.md/);
396
- } finally {
397
- fs.rmSync(tmp, { recursive: true, force: true });
398
- }
399
- });
400
-
401
- test('OpenCode SpecState single active injects tollgate block (not multiple)', async () => {
402
- const tmp = mkTmp();
403
- ensureSharedValidator(tmp);
404
- try {
405
- const specsDir = path.join(tmp, 'specs');
406
- fs.mkdirSync(path.join(specsDir, 'solo'), { recursive: true });
407
- fs.writeFileSync(path.join(specsDir, 'solo', 'spec.json'), JSON.stringify({ feature_name: 'solo', status: 'in_progress', current_phase: 'design', task_registry: { 'tasks/a.md': { status: 'pending' } } }));
408
- const out = path.join(tmp, 'plugins', 'spec-state.mjs');
409
- compileToMjs(STATE_SRC, out);
410
- const { SpecState } = await import(pathToFileURL(out).href);
411
- const state = await SpecState({ directory: tmp });
412
- const output = { message: { id: 'm2', sessionID: 's2' }, parts: [] };
413
- await state['chat.message']({}, output);
414
- const text = output.parts.map((p) => p.text || '').join('\n');
415
- assert.match(text, /`solo`/);
416
- assert.doesNotMatch(text, /Multiple active specs detected/);
417
- } finally {
418
- fs.rmSync(tmp, { recursive: true, force: true });
419
- }
420
- });
421
-
422
- test('OpenCode tollgate cache binds canonical root, full session identity, and spec identity', async () => {
423
- const tmp = mkTmp();
424
- const projectA = mkTmp();
425
- const projectB = mkTmp();
426
- try {
427
- ensureSharedValidator(tmp);
428
- for (const project of [projectA, projectB]) {
429
- fs.mkdirSync(path.join(project, 'specs', 'same'), { recursive: true });
430
- fs.writeFileSync(path.join(project, 'specs', 'same', 'spec.json'), JSON.stringify({
431
- feature_name: 'same',
432
- status: 'in_progress',
433
- current_phase: 'design',
434
- task_registry: { 'tasks/task.md': { status: 'pending' } },
435
- }));
436
- }
437
- const out = path.join(tmp, 'plugins', 'spec-state.mjs');
438
- compileToMjs(STATE_SRC, out);
439
- const { SpecState } = await import(pathToFileURL(out).href);
440
- const state = await SpecState({ directory: projectA });
441
-
442
- const first = { message: { id: 'm-a', sessionID: 'same-session' }, parts: [] };
443
- await state['chat.message']({}, first);
444
- assert.match(first.parts.map((part) => part.text || '').join('\n'), /URGENT SYSTEM TOLLGATE/);
445
-
446
- const second = { message: { id: 'm-b', sessionID: 'same-session' }, parts: [] };
447
- await (await SpecState({ directory: projectB }))['chat.message']({}, second);
448
- assert.match(second.parts.map((part) => part.text || '').join('\n'), /URGENT SYSTEM TOLLGATE/);
449
-
450
- fs.rmSync(path.join(projectA, 'specs', 'same'), { recursive: true, force: true });
451
- fs.mkdirSync(path.join(projectA, 'specs', 'other'), { recursive: true });
452
- fs.writeFileSync(path.join(projectA, 'specs', 'other', 'spec.json'), JSON.stringify({
453
- feature_name: 'other',
454
- status: 'in_progress',
455
- current_phase: 'design',
456
- task_registry: { 'tasks/task.md': { status: 'pending' } },
457
- }));
458
- const third = { message: { id: 'm-c', sessionID: 'same-session' }, parts: [] };
459
- await state['chat.message']({}, third);
460
- const thirdText = third.parts.map((part) => part.text || '').join('\n');
461
- assert.match(thirdText, /`other`/);
462
- assert.match(thirdText, /URGENT SYSTEM TOLLGATE/);
463
- } finally {
464
- fs.rmSync(tmp, { recursive: true, force: true });
465
- fs.rmSync(projectA, { recursive: true, force: true });
466
- fs.rmSync(projectB, { recursive: true, force: true });
467
- }
468
- });
469
-
470
- test('OpenCode P0 regression: placeholder, explicit failure, artifact, symlink and invalid_specs', async () => {
471
- // Probe B/C via the shared completion gate.
472
- const cases = [
473
- { body: 'Verification: PASS\nCommand: TODO\nExit: 0\nBase: a\nHead: b\n', desc: 'Command TODO' },
474
- { body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: TBD\nHead: b\n', desc: 'Base TBD' },
475
- { body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact: x\nsha256: TBD\n', desc: 'artifact TBD' },
476
- { body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nTests failed: 1\n', desc: 'Tests failed' },
477
- { body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nResult: FAIL\nResult: PASS\n', desc: 'Result FAIL then PASS' },
478
- { body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n # fail 1\n', desc: 'TAP # fail 1' },
479
- { body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nnot ok 1 - test\n', desc: 'TAP not ok 1' },
480
- { body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nTests: 1 failed, 2 passed\n', desc: 'Jest Tests: 1 failed' },
481
- { body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nTests 1 failed | 2 passed\n', desc: 'Vitest Tests 1 failed' },
482
- { body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n1 failed, 2 passed\n', desc: '1 failed summary' },
483
- ];
484
- for (const { body, desc } of cases) {
485
- const tmp = mkTmp();
486
- ensureSharedValidator(tmp);
487
- try {
488
- const specsDir = path.join(tmp, 'specs');
489
- fs.mkdirSync(path.join(specsDir, 'demo', 'tasks'), { recursive: true });
490
- fs.writeFileSync(path.join(specsDir, 'demo', 'spec.json'), JSON.stringify({ feature_name: 'demo', status: 'in_progress', current_phase: 'implement', task_registry: { 'tasks/task.md': { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } } }));
491
- fs.writeFileSync(path.join(specsDir, 'demo', 'tasks', 'task.md'), ['# Task','','**Status:** done','','## Evidence','',body].join('\n'));
492
- const out = path.join(tmp, 'plugins', 'spec-gate.mjs');
493
- compileToMjs(GATE_SRC, out);
494
- const { SpecGate } = await import(pathToFileURL(out).href);
495
- const gate = await SpecGate({ directory: tmp });
496
- const captured = [];
497
- const orig = console.error;
498
- console.error = (...a) => captured.push(a.join(' '));
499
- try { await gate.event({ event: { type: 'session.idle' } }); } finally { console.error = orig; }
500
- const banner = (()=>{ try{return fs.readFileSync(path.join(tmp,'.opencode','session-banner.md'),'utf8')}catch{return ''}})();
501
- const combined = captured.join('\n') + '\n' + banner;
502
- assert.match(combined, /verification receipt|failed check/i, `case ${desc} should be rejected`);
503
- } finally {
504
- fs.rmSync(tmp, { recursive: true, force: true });
505
- }
506
- }
507
- // Normal todo substring should pass
508
- {
509
- const tmp = mkTmp();
510
- ensureSharedValidator(tmp);
511
- try {
512
- const specsDir = path.join(tmp, 'specs');
513
- fs.mkdirSync(path.join(specsDir, 'demo', 'tasks'), { recursive: true });
514
- fs.writeFileSync(path.join(specsDir, 'demo', 'spec.json'), JSON.stringify({ feature_name: 'demo', status: 'in_progress', current_phase: 'implement', task_registry: { 'tasks/task.md': { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } } }));
515
- fs.writeFileSync(path.join(specsDir, 'demo', 'tasks', 'task.md'), ['# Task','','**Status:** done','','## Evidence','','Verification: PASS','Command: npm run todo:test','Exit: 0',`Base: ${BASE_SHA}`,`Head: ${HEAD_SHA}`].join('\n'));
516
- const out = path.join(tmp, 'plugins', 'spec-gate.mjs');
517
- compileToMjs(GATE_SRC, out);
518
- const { SpecGate } = await import(pathToFileURL(out).href);
519
- const gate = await SpecGate({ directory: tmp });
520
- const captured = [];
521
- const orig = console.error;
522
- console.error = (...a) => captured.push(a.join(' '));
523
- try { await gate.event({ event: { type: 'session.idle' } }); } finally { console.error = orig; }
524
- const banner = (()=>{ try{return fs.readFileSync(path.join(tmp,'.opencode','session-banner.md'),'utf8')}catch{return ''}})();
525
- const combined = captured.join('\n') + '\n' + banner;
526
- assert.doesNotMatch(combined, /verification receipt|failed check/i, 'todo substring should not block');
527
- } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
528
- }
529
- // Symlink task outside via OpenCode checkReceipt (uses same realpath logic)
530
- {
531
- const tmp = mkTmp();
532
- ensureSharedValidator(tmp);
533
- try {
534
- const specsDir = path.join(tmp, 'specs');
535
- fs.mkdirSync(path.join(specsDir, 'feat', 'tasks'), { recursive: true });
536
- fs.writeFileSync(path.join(specsDir, 'feat', 'spec.json'), JSON.stringify({ feature_name: 'feat', status: 'in_progress', task_registry: { 'tasks/task.md': { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } } }));
537
- const outside = path.join(tmp, 'outside-task.md');
538
- fs.writeFileSync(outside, `# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: ${BASE_SHA}\nHead: ${HEAD_SHA}\n`);
539
- const link = path.join(specsDir, 'feat', 'tasks', 'task.md');
540
- fs.symlinkSync(outside, link);
541
- const out = path.join(tmp, 'plugins', 'spec-gate.mjs');
542
- compileToMjs(GATE_SRC, out);
543
- const { SpecGate } = await import(pathToFileURL(out).href);
544
- const gate = await SpecGate({ directory: tmp });
545
- const captured = [];
546
- const orig = console.error;
547
- console.error = (...a) => captured.push(a.join(' '));
548
- try { await gate.event({ event: { type: 'session.idle' } }); } finally { console.error = orig; }
549
- const banner = (()=>{ try{return fs.readFileSync(path.join(tmp,'.opencode','session-banner.md'),'utf8')}catch{return ''}})();
550
- const combined = captured.join('\n') + '\n' + banner;
551
- assert.match(combined, /failed check/i, 'symlink task outside should be blocked');
552
- assert.match(combined, /tasks\/task\.md/);
553
- } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
554
- }
555
- // Invalid spec JSON and symlink spec handling via SpecState
556
- {
557
- const tmp = mkTmp();
558
- ensureSharedValidator(tmp);
559
- try {
560
- const specsDir = path.join(tmp, 'specs');
561
- fs.mkdirSync(path.join(specsDir, 'good'), { recursive: true });
562
- fs.mkdirSync(path.join(specsDir, 'bad'), { recursive: true });
563
- fs.writeFileSync(path.join(specsDir, 'good', 'spec.json'), JSON.stringify({ feature_name: 'good', status: 'in_progress' }));
564
- fs.writeFileSync(path.join(specsDir, 'bad', 'spec.json'), '{ malformed');
565
- const out = path.join(tmp, 'plugins', 'spec-state.mjs');
566
- compileToMjs(STATE_SRC, out);
567
- const { SpecState } = await import(pathToFileURL(out).href);
568
- const state = await SpecState({ directory: tmp });
569
- const output = { message: { id: 'm3', sessionID: 's3' }, parts: [] };
570
- await state['chat.message']({}, output);
571
- const text = output.parts.map((p)=>p.text||'').join('\n');
572
- assert.match(text, /Invalid spec JSON/i);
573
- assert.match(text, /bad/);
574
- } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
575
- }
576
- // Symlink spec outside should be explicit_malformed or skipped: test via SpecState multiple? Use gate for explicit not needed; ensure non-explicit skips
577
- {
578
- const tmp = mkTmp();
579
- ensureSharedValidator(tmp);
580
- try {
581
- const specsDir = path.join(tmp, 'specs');
582
- fs.mkdirSync(path.join(specsDir, 'valid'), { recursive: true });
583
- fs.writeFileSync(path.join(specsDir, 'valid', 'spec.json'), JSON.stringify({ feature_name: 'valid', status: 'in_progress', task_registry: {} }));
584
- const outside = path.join(tmp, 'outside-spec');
585
- fs.mkdirSync(outside, { recursive: true });
586
- fs.writeFileSync(path.join(outside, 'spec.json'), JSON.stringify({ feature_name: 'linked', status: 'in_progress' }));
587
- const link = path.join(specsDir, 'linked');
588
- fs.symlinkSync(outside, link);
589
- const out = path.join(tmp, 'plugins', 'spec-state.mjs');
590
- compileToMjs(STATE_SRC, out);
591
- const { SpecState } = await import(pathToFileURL(out).href);
592
- const state = await SpecState({ directory: tmp });
593
- const output = { message: { id: 'm4', sessionID: 's4' }, parts: [] };
594
- await state['chat.message']({}, output);
595
- const text = output.parts.map((p)=>p.text||'').join('\n');
596
- assert.match(text, /Invalid spec JSON/i);
597
- assert.match(text, /linked/);
598
- } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
599
- }
600
- // Structured explicit failure and artifact scope parity
601
- {
602
- const tmp = mkTmp();
603
- ensureSharedValidator(tmp);
604
- try {
605
- const specsDir = path.join(tmp, 'specs');
606
- fs.mkdirSync(path.join(specsDir, 'demo', 'tasks'), { recursive: true });
607
- fs.writeFileSync(path.join(specsDir, 'demo', 'spec.json'), JSON.stringify({ feature_name: 'demo', status: 'in_progress', current_phase: 'implement', task_registry: { 'tasks/task.md': { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } } }));
608
- // Notes with failure handling should pass (not structured)
609
- fs.writeFileSync(path.join(specsDir, 'demo', 'tasks', 'task.md'), ['# Task','','**Status:** done','','## Evidence','','Verification: PASS','Command: pnpm test','Exit: 0',`Base: ${BASE_SHA}`,`Head: ${HEAD_SHA}`,'Notes: verifies failure handling'].join('\n'));
610
- const out = path.join(tmp, 'plugins', 'spec-gate.mjs');
611
- compileToMjs(GATE_SRC, out);
612
- const { SpecGate } = await import(pathToFileURL(out).href);
613
- const gate = await SpecGate({ directory: tmp });
614
- let captured = [];
615
- const orig = console.error;
616
- console.error = (...a) => captured.push(a.join(' '));
617
- try { await gate.event({ event: { type: 'session.idle' } }); } finally { console.error = orig; }
618
- let banner = (()=>{ try{return fs.readFileSync(path.join(tmp,'.opencode','session-banner.md'),'utf8')}catch{return ''}})();
619
- assert.doesNotMatch(captured.join('\n') + '\n' + banner, /failed check/i, 'Notes with failure prose should not block');
620
- // Tests failed: 0 should pass
621
- fs.writeFileSync(path.join(specsDir, 'demo', 'tasks', 'task.md'), ['# Task','','**Status:** done','','## Evidence','','Verification: PASS','Command: pnpm test','Exit: 0',`Base: ${BASE_SHA}`,`Head: ${HEAD_SHA}`,'Tests failed: 0'].join('\n'));
622
- captured = [];
623
- console.error = (...a) => captured.push(a.join(' '));
624
- try { await gate.event({ event: { type: 'session.idle' } }); } finally { console.error = orig; }
625
- banner = (()=>{ try{return fs.readFileSync(path.join(tmp,'.opencode','session-banner.md'),'utf8')}catch{return ''}})();
626
- assert.doesNotMatch(captured.join('\n') + '\n' + banner, /failed check/i, 'Tests failed: 0 should not block');
627
- // Status: FAILED should block
628
- fs.writeFileSync(path.join(specsDir, 'demo', 'tasks', 'task.md'), ['# Task','','**Status:** done','','## Evidence','','Verification: PASS','Command: pnpm test','Exit: 0',`Base: ${BASE_SHA}`,`Head: ${HEAD_SHA}`,'Status: FAILED'].join('\n'));
629
- captured = [];
630
- console.error = (...a) => captured.push(a.join(' '));
631
- try { await gate.event({ event: { type: 'session.idle' } }); } finally { console.error = orig; }
632
- banner = (()=>{ try{return fs.readFileSync(path.join(tmp,'.opencode','session-banner.md'),'utf8')}catch{return ''}})();
633
- assert.match(captured.join('\n') + '\n' + banner, /failed check/i, 'Status: FAILED should block');
634
- // Command containing artifact without hash should pass
635
- fs.writeFileSync(path.join(specsDir, 'demo', 'tasks', 'task.md'), ['# Task','','**Status:** done','','## Evidence','','Verification: PASS','Command: npm run artifact:test','Exit: 0',`Base: ${BASE_SHA}`,`Head: ${HEAD_SHA}`].join('\n'));
636
- captured = [];
637
- console.error = (...a) => captured.push(a.join(' '));
638
- try { await gate.event({ event: { type: 'session.idle' } }); } finally { console.error = orig; }
639
- banner = (()=>{ try{return fs.readFileSync(path.join(tmp,'.opencode','session-banner.md'),'utf8')}catch{return ''}})();
640
- assert.doesNotMatch(captured.join('\n') + '\n' + banner, /failed check/i, 'Command with artifact substring should not require hash');
641
- } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
642
- }
643
- // Shared authorities missing should be fail-closed (not crash-silent)
644
- {
645
- const tmp = mkTmp();
646
- // Do NOT ensure shared authorities, so the gate must report unavailable.
647
- try {
648
- const specsDir = path.join(tmp, 'specs');
649
- fs.mkdirSync(path.join(specsDir, 'demo', 'tasks'), { recursive: true });
650
- fs.writeFileSync(path.join(specsDir, 'demo', 'spec.json'), JSON.stringify({ feature_name: 'demo', status: 'in_progress', current_phase: 'implement', task_registry: { 'tasks/task.md': { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } } }));
651
- fs.writeFileSync(path.join(specsDir, 'demo', 'tasks', 'task.md'), ['# Task','','**Status:** done','','## Evidence','','Verification: PASS','Command: pnpm test','Exit: 0',`Base: ${BASE_SHA}`,`Head: ${HEAD_SHA}`].join('\n'));
652
- const out = path.join(tmp, 'plugins', 'spec-gate.mjs');
653
- compileToMjs(GATE_SRC, out);
654
- const { SpecGate } = await import(pathToFileURL(out).href);
655
- const gate = await SpecGate({ directory: tmp });
656
- const captured = [];
657
- const orig = console.error;
658
- console.error = (...a) => captured.push(a.join(' '));
659
- try { await gate.event({ event: { type: 'session.idle' } }); } finally { console.error = orig; }
660
- const banner = (()=>{ try{return fs.readFileSync(path.join(tmp,'.opencode','session-banner.md'),'utf8')}catch{return ''}})();
661
- const combined = captured.join('\n') + '\n' + banner;
662
- assert.match(combined, /resolver_unavailable|shared_validator|unavailable|failed check/i, 'missing shared authority should be fail-closed');
663
- } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
664
- }
665
- // Dangling task symlink should be fail-closed (realpath failure)
666
- {
667
- const tmp = mkTmp();
668
- ensureSharedValidator(tmp);
669
- try {
670
- const specsDir = path.join(tmp, 'specs');
671
- fs.mkdirSync(path.join(specsDir, 'feat', 'tasks'), { recursive: true });
672
- fs.writeFileSync(path.join(specsDir, 'feat', 'spec.json'), JSON.stringify({ feature_name: 'feat', status: 'in_progress', task_registry: { 'tasks/task.md': { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } } }));
673
- fs.writeFileSync(path.join(specsDir, 'feat', 'tasks', 'task.md'), ['# Task','','**Status:** done','','## Evidence','','Verification: PASS','Command: pnpm test','Exit: 0',`Base: ${BASE_SHA}`,`Head: ${HEAD_SHA}`].join('\n'));
674
- const out = path.join(tmp, 'plugins', 'spec-gate.mjs');
675
- compileToMjs(GATE_SRC, out);
676
- // Dangling symlink: task file is a broken symlink, realpath will throw and should be fail-closed
677
- const broken = path.join(specsDir, 'feat', 'tasks', 'broken.md');
678
- fs.symlinkSync(path.join(tmp, 'nonexistent'), broken);
679
- // Update spec to point to broken
680
- const specPath = path.join(specsDir, 'feat', 'spec.json');
681
- const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
682
- spec.task_registry['tasks/broken.md'] = { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' };
683
- fs.writeFileSync(specPath, JSON.stringify(spec));
684
- const { SpecGate } = await import(pathToFileURL(out).href);
685
- const gate = await SpecGate({ directory: tmp });
686
- const captured = [];
687
- const orig = console.error;
688
- console.error = (...a) => captured.push(a.join(' '));
689
- try { await gate.event({ event: { type: 'session.idle' } }); } finally { console.error = orig; }
690
- const banner = (()=>{ try{return fs.readFileSync(path.join(tmp,'.opencode','session-banner.md'),'utf8')}catch{return ''}})();
691
- const combined = captured.join('\n') + '\n' + banner;
692
- assert.match(combined, /failed check/i, 'broken symlink task should be fail-closed');
693
- } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
694
- }
695
- });
696
-
697
- test('OpenCode P0 phantom vectors via installed shared validator (r3)', async () => {
698
- const vectors = [
699
- 'Tests: 0 total',
700
- 'Test Suites: 1 failed, 1 total',
701
- 'FAIL ./foo.test.js',
702
- 'FAIL\tpackage',
703
- 'FAIL\texample.test/probe\t0.431s',
704
- '--- FAIL: TestName (0.00s)',
705
- 'FAILED tests/test_demo.py::test_foo - assert 1 == 2',
706
- 'FAILED tests/test_demo.py',
707
- 'ERROR collecting tests/test_demo.py',
708
- '# fail 1',
709
- 'ℹ fail 1',
710
- 'not ok 1 - test',
711
- '[ERROR] Tests run: 5, Failures: 1, Errors: 0, Skipped: 0',
712
- '[ERROR] Tests run: 5, Failures: 0, Errors: 1, Skipped: 0',
713
- '[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.2.5:test',
714
- 'ℹ tests 0',
715
- 'collected 0 items',
716
- '1 error in 0.12s',
717
- 'ℹ cancelled 1',
718
- ];
719
- for (const v of vectors) {
720
- const tmp = mkTmp();
721
- ensureSharedValidator(tmp);
722
- try {
723
- const specsDir = path.join(tmp, 'specs');
724
- fs.mkdirSync(path.join(specsDir, 'demo', 'tasks'), { recursive: true });
725
- fs.writeFileSync(path.join(specsDir, 'demo', 'spec.json'), JSON.stringify({ feature_name: 'demo', status: 'in_progress', current_phase: 'implement', task_registry: { 'tasks/task.md': { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } } }));
726
- fs.writeFileSync(path.join(specsDir, 'demo', 'tasks', 'task.md'), ['# Task','','**Status:** done','','## Evidence','','Verification: PASS','Command: pnpm test','Exit: 0',`Base: ${BASE_SHA}`,`Head: ${HEAD_SHA}`, v].join('\n'));
727
- const out = path.join(tmp, 'plugins', 'spec-gate.mjs');
728
- compileToMjs(GATE_SRC, out);
729
- const { SpecGate } = await import(pathToFileURL(out).href);
730
- const gate = await SpecGate({ directory: tmp });
731
- const captured = [];
732
- const orig = console.error;
733
- console.error = (...a) => captured.push(a.join(' '));
734
- try { await gate.event({ event: { type: 'session.idle' } }); } finally { console.error = orig; }
735
- const banner = (()=>{ try{return fs.readFileSync(path.join(tmp,'.opencode','session-banner.md'),'utf8')}catch{return ''}})();
736
- const combined = captured.join('\n') + '\n' + banner;
737
- assert.match(combined, /failed check/i, `OpenCode vector should block: ${v}`);
738
- } finally {
739
- fs.rmSync(tmp, { recursive: true, force: true });
740
- }
741
- }
742
- // positive controls must not block via OpenCode shared validator
743
- const passes = [
744
- 'Tests failed: 0',
745
- 'cancelled 0',
746
- 'Test Suites: 0 failed',
747
- 'collected 1 item',
748
- 'Error handling is documented',
749
- '[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
750
- '[ERROR] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
751
- '[ERROR] Error handling is documented',
752
- 'FAILURE mode analysis',
753
- 'Notes: tests failed previously but now fixed',
754
- '# tests 1',
755
- '# suites 0',
756
- '# pass 1',
757
- '# fail 0',
758
- '# cancelled 0',
759
- '# skipped 0',
760
- '# todo 0',
761
- '# duration_ms 114.203625',
762
- ];
763
- for (const v of passes) {
764
- const tmp = mkTmp();
765
- ensureSharedValidator(tmp);
766
- try {
767
- const specsDir = path.join(tmp, 'specs');
768
- fs.mkdirSync(path.join(specsDir, 'demo', 'tasks'), { recursive: true });
769
- fs.writeFileSync(path.join(specsDir, 'demo', 'spec.json'), JSON.stringify({ feature_name: 'demo', status: 'in_progress', current_phase: 'implement', task_registry: { 'tasks/task.md': { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } } }));
770
- fs.writeFileSync(path.join(specsDir, 'demo', 'tasks', 'task.md'), [
771
- '# Task',
772
- '',
773
- '**Status:** done',
774
- '',
775
- '## Evidence',
776
- '',
777
- 'Verification: PASS',
778
- 'Command: pnpm test',
779
- 'Exit: 0',
780
- `Base: ${BASE_SHA}`,
781
- `Head: ${HEAD_SHA}`,
782
- v,
783
- ].join('\n'));
784
- const out = path.join(tmp, 'plugins', 'spec-gate.mjs');
785
- compileToMjs(GATE_SRC, out);
786
- const { SpecGate } = await import(pathToFileURL(out).href);
787
- const gate = await SpecGate({ directory: tmp });
788
- const captured = [];
789
- const orig = console.error;
790
- console.error = (...a) => captured.push(a.join(' '));
791
- try { await gate.event({ event: { type: 'session.idle' } }); } finally { console.error = orig; }
792
- const banner = (()=>{ try{return fs.readFileSync(path.join(tmp,'.opencode','session-banner.md'),'utf8')}catch{return ''}})();
793
- const combined = captured.join('\n') + '\n' + banner;
794
- assert.doesNotMatch(combined, /failed check/i, `OpenCode positive should not block: ${v}`);
795
- } finally {
796
- fs.rmSync(tmp, { recursive: true, force: true });
797
- }
798
- }
799
- });