@haposoft/cafekit 0.16.0-rc.2 → 0.16.0-rc.4
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/bin/__tests__/codex-hooks.test.js +794 -21
- package/bin/__tests__/codex-native.test.js +228 -7
- package/bin/__tests__/develop-contract.test.js +1838 -112
- package/bin/__tests__/installer-safety.test.js +82 -29
- package/bin/__tests__/opencode-spec-gate.test.js +799 -0
- package/bin/__tests__/package-inventory.test.js +841 -10
- package/bin/__tests__/specs-v2-execution-closeout.test.js +421 -0
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +601 -0
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +952 -0
- package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
- package/bin/__tests__/validator-grounding.test.js +2469 -71
- package/bin/lib/managed-writer.js +12 -3
- package/bin/phases/codex-runtime.js +17 -1
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +6 -0
- package/src/claude/agents/code-auditor.md +20 -0
- package/src/claude/agents/spec-maker.md +153 -202
- package/src/claude/hooks/__tests__/completion-authority.test.js +953 -0
- package/src/claude/hooks/__tests__/privacy-block.test.js +172 -0
- package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +604 -56
- package/src/claude/hooks/completion-authority-check.cjs +56 -0
- package/src/claude/hooks/completion-authority-state.cjs +527 -0
- package/src/claude/hooks/completion-authority.cjs +154 -0
- package/src/claude/hooks/docs-sync.cjs +5 -2
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
- package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
- package/src/claude/hooks/privacy-block.cjs +77 -30
- package/src/claude/hooks/semantic-review-authority.cjs +152 -0
- package/src/claude/hooks/session.cjs +33 -19
- package/src/claude/hooks/spec-gate.cjs +181 -139
- package/src/claude/hooks/spec-state.cjs +112 -47
- package/src/claude/migration-manifest.json +15 -2
- package/src/claude/rules/state-sync.md +52 -21
- package/src/claude/rules/workflow.md +17 -9
- package/src/claude/scripts/provenance.cjs +315 -0
- package/src/claude/scripts/spec-final-state.cjs +244 -0
- package/src/claude/scripts/spec-ground.cjs +535 -173
- package/src/claude/scripts/spec-readiness.cjs +118 -0
- package/src/claude/scripts/spec-receipt.cjs +162 -0
- package/src/claude/scripts/spec-resolver.cjs +501 -0
- package/src/claude/scripts/spec-scaffold.cjs +1945 -55
- package/src/claude/scripts/spec-semantic-model.cjs +396 -0
- package/src/claude/scripts/validate-spec-output.cjs +3204 -262
- package/src/claude/scripts/workflow-policy.cjs +1888 -203
- package/src/claude/settings/settings.json +12 -0
- package/src/claude/skills/code-review/SKILL.md +94 -66
- package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
- package/src/claude/skills/code-review/references/verification-gate.md +13 -0
- package/src/claude/skills/develop/SKILL.md +138 -283
- package/src/claude/skills/develop/references/parallel-waves.md +9 -6
- package/src/claude/skills/develop/references/quality-gate.md +123 -148
- package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
- package/src/claude/skills/specs/SKILL.md +192 -397
- package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
- package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
- package/src/claude/skills/specs/references/research-strategy.md +4 -1
- package/src/claude/skills/specs/references/review.md +200 -260
- package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
- package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
- package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
- package/src/claude/skills/specs/rules/design-principles.md +136 -180
- package/src/claude/skills/specs/rules/design-review.md +42 -97
- package/src/claude/skills/specs/rules/ears-format.md +42 -50
- package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +46 -46
- package/src/claude/skills/specs/rules/tasks-generation.md +189 -271
- package/src/claude/skills/specs/templates/design.md +49 -263
- package/src/claude/skills/specs/templates/requirements.md +26 -36
- package/src/claude/skills/specs/templates/research.md +9 -98
- package/src/claude/skills/specs/templates/spec-state.json +26 -67
- package/src/claude/skills/specs/templates/task.md +30 -82
- package/src/claude/skills/sync/SKILL.md +45 -15
- package/src/claude/skills/sync/references/sync-protocols.md +34 -16
- package/src/claude/skills/test/SKILL.md +151 -167
- package/src/codex/AGENTS.md +6 -0
- package/src/codex/hooks/completion-authority-check.cjs +63 -0
- package/src/codex/hooks/completion-authority-state.cjs +527 -0
- package/src/codex/hooks/completion-authority.cjs +106 -0
- package/src/codex/hooks/lib/hook-context.cjs +58 -4
- package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
- package/src/codex/hooks/lib/privacy-state.cjs +38 -16
- package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +86 -115
- package/src/codex/hooks/lib/spec-utils.cjs +104 -27
- package/src/codex/hooks/privacy-approval.cjs +1 -1
- package/src/codex/hooks/privacy-block.cjs +67 -11
- package/src/codex/hooks/semantic-review-authority.cjs +130 -0
- package/src/codex/hooks/session.cjs +2 -0
- package/src/codex/hooks/spec-gate.cjs +151 -24
- package/src/codex/hooks/spec-state.cjs +65 -12
- package/src/codex/hooks/state.cjs +2 -0
- package/src/codex/hooks.json +16 -1
- package/src/codex/rules/state-sync.md +47 -27
- package/src/common/AGENTS.md +1 -1
- package/src/opencode/AGENTS.md +4 -2
- package/src/opencode/plugins/spec-gate.ts +296 -122
- package/src/opencode/plugins/spec-state.ts +183 -116
|
@@ -0,0 +1,799 @@
|
|
|
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
|
+
});
|