@haposoft/cafekit 0.16.0-rc.1 → 0.16.0-rc.3
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 +355 -12
- package/bin/__tests__/codex-native.test.js +1 -1
- package/bin/__tests__/develop-contract.test.js +1374 -78
- package/bin/__tests__/installer-safety.test.js +43 -29
- package/bin/__tests__/opencode-spec-gate.test.js +791 -0
- package/bin/__tests__/package-inventory.test.js +157 -0
- package/bin/__tests__/validator-grounding.test.js +707 -2
- package/package.json +1 -1
- package/src/claude/agents/code-auditor.md +8 -1
- package/src/claude/agents/spec-maker.md +107 -200
- package/src/claude/hooks/__tests__/completion-authority.test.js +720 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +511 -38
- package/src/claude/hooks/completion-authority-check.cjs +307 -0
- package/src/claude/hooks/completion-authority-state.cjs +525 -0
- package/src/claude/hooks/completion-authority.cjs +148 -0
- package/src/claude/hooks/session.cjs +33 -19
- package/src/claude/hooks/spec-gate.cjs +173 -61
- package/src/claude/hooks/spec-state.cjs +104 -45
- package/src/claude/migration-manifest.json +6 -1
- package/src/claude/scripts/provenance.cjs +315 -0
- package/src/claude/scripts/spec-resolver.cjs +450 -0
- package/src/claude/scripts/spec-scaffold.cjs +716 -41
- package/src/claude/scripts/validate-spec-output.cjs +355 -53
- package/src/claude/scripts/workflow-policy.cjs +1529 -162
- package/src/claude/settings/settings.json +8 -0
- package/src/claude/skills/backend-development/references/backend-security.md +52 -0
- package/src/claude/skills/code-review/SKILL.md +85 -66
- package/src/claude/skills/develop/SKILL.md +153 -285
- package/src/claude/skills/develop/references/quality-gate.md +112 -124
- package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
- package/src/claude/skills/specs/SKILL.md +170 -381
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +45 -46
- package/src/claude/skills/specs/rules/tasks-generation.md +49 -20
- package/src/claude/skills/specs/templates/spec-state.json +23 -2
- package/src/claude/skills/specs/templates/task.md +20 -0
- package/src/claude/skills/sync/SKILL.md +15 -8
- package/src/claude/skills/sync/references/sync-protocols.md +4 -4
- package/src/claude/skills/test/SKILL.md +141 -167
- package/src/codex/hooks/completion-authority-check.cjs +260 -0
- package/src/codex/hooks/completion-authority-state.cjs +525 -0
- package/src/codex/hooks/completion-authority.cjs +100 -0
- package/src/codex/hooks/lib/hook-context.cjs +42 -2
- package/src/codex/hooks/lib/spec-receipt.cjs +76 -43
- package/src/codex/hooks/lib/spec-utils.cjs +53 -27
- package/src/codex/hooks/session.cjs +2 -0
- package/src/codex/hooks/spec-gate.cjs +132 -20
- package/src/codex/hooks/spec-state.cjs +56 -10
- package/src/codex/hooks.json +10 -0
- 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
|
@@ -3,17 +3,15 @@
|
|
|
3
3
|
*
|
|
4
4
|
* OpenCode Plugin — spec-gate.ts
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* OpenCode exposes a cancellable `tool.execute.before` hook, so completion
|
|
7
|
+
* related state tools can be hard-blocked with a controlled error. It does
|
|
8
|
+
* not expose a cancellable Stop/session.idle hook: a final assistant message
|
|
9
|
+
* without a guarded tool call is therefore outside this adapter's boundary.
|
|
10
|
+
* The plugin never presents that observer event as Claude/Codex parity.
|
|
7
11
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* any newly-done task receipts (Verification: PASS, Command, Exit, provenance, completed_at).
|
|
12
|
-
* - On session.idle (≈ Claude Stop), run the same check and surface a banner + stderr warning.
|
|
13
|
-
*
|
|
14
|
-
* It never throws to break the turn (fail-open). Disable via `.opencode/runtime.json`
|
|
15
|
-
* `{ "spec": { "completion_gate": false } }`. Missing key keeps the advisory gate ON.
|
|
16
|
-
* This is tier-2 compared to Claude/Codex hard gates — docs must not claim parity.
|
|
12
|
+
* Receipt validation and active-spec resolution are loaded from the installed
|
|
13
|
+
* shared executable authorities. Missing or malformed authorities are a
|
|
14
|
+
* blocked/unavailable result; this adapter never invents a local PASS.
|
|
17
15
|
*/
|
|
18
16
|
|
|
19
17
|
import type { Plugin } from "@opencode-ai/plugin";
|
|
@@ -23,19 +21,26 @@ import {
|
|
|
23
21
|
writeFileSync,
|
|
24
22
|
mkdirSync,
|
|
25
23
|
appendFileSync,
|
|
26
|
-
|
|
24
|
+
realpathSync,
|
|
27
25
|
} from "node:fs";
|
|
28
26
|
import { join, dirname, relative, resolve, sep, isAbsolute } from "node:path";
|
|
29
27
|
import { fileURLToPath } from "node:url";
|
|
28
|
+
import { createRequire } from "node:module";
|
|
29
|
+
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
30
31
|
|
|
31
32
|
const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
|
|
32
33
|
const SPEC_GATE_BANNER_START = "<!-- CAFEKIT SPEC-GATE START -->";
|
|
33
34
|
const SPEC_GATE_BANNER_END = "<!-- CAFEKIT SPEC-GATE END -->";
|
|
35
|
+
const BLOCK_CODE = "CAFEKIT_SPEC_GATE_BLOCKED";
|
|
36
|
+
const SHARED_POLICY_FILE = "workflow-policy.cjs";
|
|
37
|
+
const SHARED_RESOLVER_FILE = "spec-resolver.cjs";
|
|
34
38
|
|
|
35
39
|
type TaskEntry = {
|
|
36
40
|
status?: string;
|
|
37
41
|
completed_at?: string;
|
|
38
42
|
receipt?: string;
|
|
43
|
+
artifacts?: string[];
|
|
39
44
|
};
|
|
40
45
|
|
|
41
46
|
type SpecJson = {
|
|
@@ -45,12 +50,115 @@ type SpecJson = {
|
|
|
45
50
|
task_registry?: Record<string, TaskEntry>;
|
|
46
51
|
};
|
|
47
52
|
|
|
53
|
+
type ActiveSpec = {
|
|
54
|
+
featureName: string;
|
|
55
|
+
spec: SpecJson;
|
|
56
|
+
specsDir: string;
|
|
57
|
+
featureDir: string;
|
|
58
|
+
specFile: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type SharedModule = {
|
|
62
|
+
policy: any | null;
|
|
63
|
+
resolver: any | null;
|
|
64
|
+
error: Error | null;
|
|
65
|
+
path: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type GateMultiple = {
|
|
69
|
+
error: "multiple_active";
|
|
70
|
+
candidates: string[];
|
|
71
|
+
reason: string;
|
|
72
|
+
taskPath: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
type GateInvalid = {
|
|
76
|
+
error: "invalid_specs";
|
|
77
|
+
candidates: string[];
|
|
78
|
+
reason: string;
|
|
79
|
+
taskPath: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
type GateExplicit = {
|
|
83
|
+
error: "explicit_not_found" | "explicit_malformed";
|
|
84
|
+
reason: string;
|
|
85
|
+
taskPath: string;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
type GateUnavailable = {
|
|
89
|
+
error: "validator_unavailable" | "resolver_unavailable";
|
|
90
|
+
reason: string;
|
|
91
|
+
taskPath: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
type GateFailures = {
|
|
95
|
+
featureName: string;
|
|
96
|
+
failures: { taskPath: string; fails: string[] }[];
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
type CollectResult = GateFailures | GateMultiple | GateInvalid | GateExplicit | GateUnavailable | null;
|
|
100
|
+
|
|
48
101
|
const EVIDENCE_NAMES = ["Evidence", "Task Test Plan & Verification Evidence", "Verification & Evidence"];
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
102
|
+
const GATED_TOOLS = new Set(["edit", "write", "apply_patch", "task", "taskupdate", "todowrite"]);
|
|
103
|
+
const HARD_BLOCK_TOOLS = new Set(["task", "taskupdate", "todowrite"]);
|
|
104
|
+
|
|
105
|
+
function loadSharedModule(kind: "policy" | "resolver"): SharedModule {
|
|
106
|
+
const authorityFile = kind === "policy" ? SHARED_POLICY_FILE : SHARED_RESOLVER_FILE;
|
|
107
|
+
const relativePath = `../scripts/${authorityFile}`;
|
|
108
|
+
const sourcePath = `../../claude/scripts/${authorityFile}`;
|
|
109
|
+
const installed = join(PLUGIN_DIR, relativePath);
|
|
110
|
+
const source = join(PLUGIN_DIR, sourcePath);
|
|
111
|
+
// Prefer the installed authority. If it exists but cannot load, do not
|
|
112
|
+
// silently substitute another copy with potentially different semantics.
|
|
113
|
+
const candidate = existsSync(installed) ? installed : source;
|
|
114
|
+
try {
|
|
115
|
+
const module = require(candidate);
|
|
116
|
+
const requiredExport = kind === "policy" ? "validateCanonicalReceipt" : "resolveActiveSpec";
|
|
117
|
+
if (!module || typeof module[requiredExport] !== "function") {
|
|
118
|
+
throw new Error(`shared ${kind} has no ${requiredExport} function`);
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
policy: kind === "policy" ? module : null,
|
|
122
|
+
resolver: kind === "resolver" ? module : null,
|
|
123
|
+
error: null,
|
|
124
|
+
path: candidate,
|
|
125
|
+
};
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return {
|
|
128
|
+
policy: null,
|
|
129
|
+
resolver: null,
|
|
130
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
131
|
+
path: candidate,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function loadSharedPolicy(): { policy: any | null; error: Error; path: string } {
|
|
137
|
+
const loaded = loadSharedModule("policy");
|
|
138
|
+
return {
|
|
139
|
+
policy: loaded.policy,
|
|
140
|
+
error: loaded.error || new Error("shared workflow policy is unavailable"),
|
|
141
|
+
path: loaded.path,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function getSharedPolicy(): any | null {
|
|
146
|
+
return loadSharedPolicy().policy;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function getSharedValidate(): ((body: string, options?: any) => string[]) | null {
|
|
150
|
+
const shared = loadSharedPolicy().policy;
|
|
151
|
+
return shared && typeof shared.validateCanonicalReceipt === "function"
|
|
152
|
+
? shared.validateCanonicalReceipt
|
|
153
|
+
: null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isTapMetadataHeading(line: string): boolean {
|
|
157
|
+
const shared = getSharedPolicy();
|
|
158
|
+
return Boolean(shared && typeof shared.isTapMetadataHeading === "function" && shared.isTapMetadataHeading(line));
|
|
159
|
+
}
|
|
52
160
|
|
|
53
|
-
function readRuntime(cwd: string): Record<string,
|
|
161
|
+
function readRuntime(cwd: string): Record<string, unknown> {
|
|
54
162
|
try {
|
|
55
163
|
const file = join(cwd, ".opencode", "runtime.json");
|
|
56
164
|
return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
|
|
@@ -63,148 +171,198 @@ function evidenceBody(text: string): string | null {
|
|
|
63
171
|
const lines = text.split("\n");
|
|
64
172
|
let start = -1;
|
|
65
173
|
let level = 0;
|
|
66
|
-
for (let
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
69
|
-
start =
|
|
70
|
-
level =
|
|
174
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
175
|
+
const match = lines[index].match(/^(#{2,3})\s+(.+?)\s*$/);
|
|
176
|
+
if (match && EVIDENCE_NAMES.includes(match[2])) {
|
|
177
|
+
start = index + 1;
|
|
178
|
+
level = match[1].length;
|
|
71
179
|
break;
|
|
72
180
|
}
|
|
73
181
|
}
|
|
74
182
|
if (start < 0) return null;
|
|
75
183
|
let end = lines.length;
|
|
76
|
-
for (let
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
79
|
-
end =
|
|
184
|
+
for (let index = start; index < lines.length; index += 1) {
|
|
185
|
+
const heading = lines[index].match(/^(#{1,6})\s+/);
|
|
186
|
+
if (heading && heading[1].length <= level && !isTapMetadataHeading(lines[index])) {
|
|
187
|
+
end = index;
|
|
80
188
|
break;
|
|
81
189
|
}
|
|
82
190
|
}
|
|
83
191
|
return lines.slice(start, end).join("\n");
|
|
84
192
|
}
|
|
85
193
|
|
|
86
|
-
function validateCanonicalReceipt(body: string): string[] {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
if (!/^\s*Command(?:\(s\))?\s*:/m.test(body)) fails.push("command");
|
|
90
|
-
if (!/^\s*Exit\s*:|exit\s+code\s*[:=]|\bResult\s*:\s*PASS\b/im.test(body)) fails.push("exit_result");
|
|
91
|
-
const hasBase = /^\s*Base\s*:[ \t]*\S/im.test(body);
|
|
92
|
-
const hasHead = /^\s*Head\s*:[ \t]*\S/im.test(body);
|
|
93
|
-
const hasBaseSha = /\bbase_sha\s*:[ \t]*\S/im.test(body);
|
|
94
|
-
const hasHeadSha = /\bhead_sha\s*:[ \t]*\S/im.test(body);
|
|
95
|
-
if (!((hasBase && hasHead) || (hasBaseSha && hasHeadSha))) fails.push("provenance");
|
|
96
|
-
if (/\bartifact\b/i.test(body) && !/sha256:/i.test(body)) fails.push("artifact_hash");
|
|
97
|
-
return fails;
|
|
194
|
+
function validateCanonicalReceipt(body: string, options?: any): string[] {
|
|
195
|
+
const shared = getSharedValidate();
|
|
196
|
+
return shared ? shared(body, options) : ["shared_validator"];
|
|
98
197
|
}
|
|
99
198
|
|
|
100
|
-
function checkReceipt(featureDir: string, taskPath: string, task: TaskEntry): string[] {
|
|
199
|
+
function checkReceipt(featureDir: string, taskPath: string, task: TaskEntry, policy: any): string[] {
|
|
101
200
|
const fails: string[] = [];
|
|
102
|
-
const abs = resolve(join(featureDir, taskPath));
|
|
103
201
|
const resolvedFeatureDir = resolve(featureDir);
|
|
202
|
+
const abs = resolve(featureDir, taskPath);
|
|
104
203
|
const rel = relative(resolvedFeatureDir, abs);
|
|
105
|
-
|
|
106
|
-
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return ["a"];
|
|
204
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return ["a"];
|
|
107
205
|
if (!existsSync(abs)) return ["a"];
|
|
206
|
+
try {
|
|
207
|
+
const canonicalFeature = realpathSync(resolvedFeatureDir);
|
|
208
|
+
const canonicalTarget = realpathSync(abs);
|
|
209
|
+
const canonicalRelative = relative(canonicalFeature, canonicalTarget);
|
|
210
|
+
if (canonicalRelative === "" || canonicalRelative === ".." || canonicalRelative.startsWith(`..${sep}`) || isAbsolute(canonicalRelative)) return ["a"];
|
|
211
|
+
} catch {
|
|
212
|
+
return ["a"];
|
|
213
|
+
}
|
|
214
|
+
|
|
108
215
|
let text: string;
|
|
109
216
|
try {
|
|
110
217
|
text = readFileSync(abs, "utf8");
|
|
111
218
|
} catch {
|
|
112
219
|
return ["a"];
|
|
113
220
|
}
|
|
114
|
-
const statusLine = text.split("\n").find((
|
|
221
|
+
const statusLine = text.split("\n").find((line) => /^\s*(?:\*\*)?Status(?:\*\*)?\s*:/i.test(line));
|
|
115
222
|
if (!statusLine || !/\bdone\b/i.test(statusLine)) fails.push("a");
|
|
223
|
+
|
|
116
224
|
const body = evidenceBody(text);
|
|
117
225
|
if (body === null) {
|
|
118
226
|
fails.push("b");
|
|
119
|
-
} else if (/\{\{[^}]+\}\}/.test(body) || EXPLICIT_FAILURE.test(body) || !(PASS_MARKER.test(body) || LEGACY_SUCCESS.test(body))) {
|
|
120
|
-
fails.push("c");
|
|
121
227
|
} else {
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
228
|
+
const options = typeof policy?.receiptValidatorOptions === "function"
|
|
229
|
+
? policy.receiptValidatorOptions(task)
|
|
230
|
+
: {};
|
|
231
|
+
const canonical = validateCanonicalReceipt(body, options);
|
|
232
|
+
const map: Record<string, string> = {
|
|
233
|
+
verification_state: "c",
|
|
234
|
+
command: "e",
|
|
235
|
+
exit_result: "f",
|
|
236
|
+
provenance: "g",
|
|
237
|
+
artifact_hash: "h",
|
|
238
|
+
artifact_declaration: "h",
|
|
239
|
+
placeholder: "c",
|
|
240
|
+
shared_validator: "c",
|
|
241
|
+
validator_unavailable: "c",
|
|
242
|
+
};
|
|
243
|
+
for (const failure of canonical) {
|
|
244
|
+
const letter = map[failure];
|
|
130
245
|
if (letter && !fails.includes(letter)) fails.push(letter);
|
|
131
246
|
}
|
|
132
|
-
if (!PASS_MARKER.test(body) && !fails.includes("c")) fails.push("c");
|
|
133
247
|
}
|
|
134
|
-
|
|
135
|
-
|
|
248
|
+
|
|
249
|
+
const completedAt = task?.completed_at;
|
|
250
|
+
if (typeof completedAt !== "string" || !/^\d{4}-\d{2}-\d{2}T/.test(completedAt) || Number.isNaN(Date.parse(completedAt))) {
|
|
251
|
+
fails.push("d");
|
|
252
|
+
}
|
|
136
253
|
return fails;
|
|
137
254
|
}
|
|
138
255
|
|
|
139
|
-
function
|
|
140
|
-
|
|
141
|
-
|
|
256
|
+
function resolverUnavailable(loaded: SharedModule): GateUnavailable {
|
|
257
|
+
return {
|
|
258
|
+
error: "resolver_unavailable",
|
|
259
|
+
reason: `${loaded.error?.message || "shared spec resolver is unavailable"} (${loaded.path})`,
|
|
260
|
+
taskPath: "shared spec resolver",
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function collectFailures(cwd: string, targetSources: unknown[] = []): CollectResult {
|
|
265
|
+
const runtime = readRuntime(cwd);
|
|
266
|
+
if (runtime.spec && (runtime.spec as Record<string, unknown>).completion_gate === false) return null;
|
|
267
|
+
|
|
268
|
+
const resolverLoaded = loadSharedModule("resolver");
|
|
269
|
+
if (!resolverLoaded.resolver) return resolverUnavailable(resolverLoaded);
|
|
270
|
+
|
|
271
|
+
const resolver = resolverLoaded.resolver;
|
|
272
|
+
const target = typeof resolver.extractExplicitTarget === "function"
|
|
273
|
+
? resolver.extractExplicitTarget(...targetSources)
|
|
274
|
+
: null;
|
|
275
|
+
let resolved: any;
|
|
142
276
|
try {
|
|
143
|
-
|
|
144
|
-
} catch {
|
|
145
|
-
return
|
|
277
|
+
resolved = resolver.resolveActiveSpec({ projectRoot: cwd, runtime, ...(target || {}) });
|
|
278
|
+
} catch (error) {
|
|
279
|
+
return {
|
|
280
|
+
error: "resolver_unavailable",
|
|
281
|
+
reason: `${error instanceof Error ? error.message : String(error)} (${resolverLoaded.path})`,
|
|
282
|
+
taskPath: "shared spec resolver",
|
|
283
|
+
};
|
|
146
284
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
285
|
+
if (!resolved) return null;
|
|
286
|
+
if (resolved.error === "multiple_active") {
|
|
287
|
+
return { error: "multiple_active", candidates: resolved.candidates, reason: resolved.reason, taskPath: `specs (${resolved.candidates.join(", ")})` };
|
|
288
|
+
}
|
|
289
|
+
if (resolved.error === "invalid_specs") {
|
|
290
|
+
return { error: "invalid_specs", candidates: resolved.candidates, reason: resolved.reason, taskPath: `specs (${resolved.candidates.join(", ")})` };
|
|
291
|
+
}
|
|
292
|
+
if (resolved.error === "explicit_not_found" || resolved.error === "explicit_malformed") {
|
|
293
|
+
return { error: resolved.error, reason: resolved.reason || "explicit target is invalid", taskPath: "explicit spec target" };
|
|
294
|
+
}
|
|
295
|
+
if (resolved.error) {
|
|
296
|
+
return { error: "resolver_unavailable", reason: `${resolved.error}: ${resolved.reason || "resolution failed"}`, taskPath: "shared spec resolver" };
|
|
159
297
|
}
|
|
160
|
-
return null;
|
|
161
|
-
}
|
|
162
298
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
299
|
+
const policyLoaded = loadSharedPolicy();
|
|
300
|
+
if (!policyLoaded.policy) {
|
|
301
|
+
return {
|
|
302
|
+
error: "validator_unavailable",
|
|
303
|
+
reason: `${policyLoaded.error.message} (${policyLoaded.path})`,
|
|
304
|
+
taskPath: "shared workflow policy",
|
|
305
|
+
};
|
|
306
|
+
}
|
|
166
307
|
|
|
167
|
-
|
|
168
|
-
const runtime = readRuntime(cwd);
|
|
169
|
-
if (runtime.spec && runtime.spec.completion_gate === false) return null;
|
|
170
|
-
const specsPath = join(cwd, runtime.paths?.specs || "specs");
|
|
171
|
-
const active = findActiveSpec(specsPath);
|
|
172
|
-
if (!active) return null;
|
|
308
|
+
const active = resolved as ActiveSpec;
|
|
173
309
|
const registry = active.spec.task_registry || {};
|
|
174
|
-
const staleFlash = Object.entries(registry).filter(([,
|
|
310
|
+
const staleFlash = Object.entries(registry).filter(([, task]) => task?.status === "done" && task.receipt === "FLASH_UNVERIFIED");
|
|
175
311
|
if (staleFlash.length > 0) {
|
|
176
312
|
return {
|
|
177
313
|
featureName: active.featureName,
|
|
178
|
-
failures: staleFlash.map(([
|
|
314
|
+
failures: staleFlash.map(([taskPath]) => ({ taskPath, fails: ["flash"] })),
|
|
179
315
|
};
|
|
180
316
|
}
|
|
181
|
-
|
|
317
|
+
|
|
182
318
|
const failures: { taskPath: string; fails: string[] }[] = [];
|
|
183
319
|
for (const [taskPath, task] of Object.entries(registry)) {
|
|
184
|
-
if (
|
|
185
|
-
const fails = checkReceipt(active.featureDir, taskPath, task
|
|
320
|
+
if (task?.status !== "done") continue;
|
|
321
|
+
const fails = checkReceipt(active.featureDir, taskPath, task, policyLoaded.policy);
|
|
186
322
|
if (fails.length > 0) failures.push({ taskPath, fails });
|
|
187
323
|
}
|
|
188
|
-
|
|
189
|
-
return { featureName: active.featureName, failures };
|
|
324
|
+
return failures.length > 0 ? { featureName: active.featureName, failures } : null;
|
|
190
325
|
}
|
|
191
326
|
|
|
192
|
-
function formatReason(
|
|
193
|
-
|
|
327
|
+
function formatReason(result: CollectResult): string {
|
|
328
|
+
if (!result) return "";
|
|
329
|
+
const targetHint = "Provide `featureName`/`feature` or `specPath`/`featurePath` in the host tool input; do not rely on first-directory selection.";
|
|
330
|
+
if (result.error === "multiple_active") {
|
|
331
|
+
return `Completion gate blocked: multiple active specs detected (${result.candidates.join(", ")}). ${targetHint}`;
|
|
332
|
+
}
|
|
333
|
+
if (result.error === "invalid_specs") {
|
|
334
|
+
return `Completion gate blocked: invalid spec JSON detected (${result.candidates.join(", ")}): ${result.reason}. Repair the malformed spec before completing tasks.`;
|
|
335
|
+
}
|
|
336
|
+
if (result.error === "explicit_not_found" || result.error === "explicit_malformed") {
|
|
337
|
+
return `Completion gate blocked: explicit spec target is invalid: ${result.reason}. ${targetHint}`;
|
|
338
|
+
}
|
|
339
|
+
if (result.error === "validator_unavailable" || result.error === "resolver_unavailable") {
|
|
340
|
+
return `Completion gate blocked: ${result.error}: ${result.reason}. Repair the shared authority before completing tasks.`;
|
|
341
|
+
}
|
|
342
|
+
const flashOnly = result.failures.length === 1 && result.failures[0].fails.includes("flash");
|
|
194
343
|
if (flashOnly) {
|
|
195
|
-
return `Completion gate
|
|
344
|
+
return `Completion gate blocked: feature ${result.featureName}; task ${result.failures[0].taskPath} is FLASH_UNVERIFIED. Run /hapo:test for exact proof, then use explicit sync-finalize.`;
|
|
196
345
|
}
|
|
197
|
-
const lines = [
|
|
198
|
-
for (const { taskPath, fails } of failures) {
|
|
199
|
-
|
|
200
|
-
lines.push(`- \`${taskPath}\`: FLASH_UNVERIFIED`);
|
|
201
|
-
} else {
|
|
202
|
-
lines.push(`- \`${taskPath}\`: failed check(s) ${fails.join(", ")}`);
|
|
203
|
-
}
|
|
346
|
+
const lines = [`Completion gate blocked: feature ${result.featureName}; ${result.failures.length} done task(s) lack valid verification evidence.`];
|
|
347
|
+
for (const { taskPath, fails } of result.failures) {
|
|
348
|
+
lines.push(`- \`${taskPath}\`: failed check(s) ${fails.join(", ")}`);
|
|
204
349
|
}
|
|
205
350
|
return lines.slice(0, 8).join("\n");
|
|
206
351
|
}
|
|
207
352
|
|
|
353
|
+
function createBlockError(result: CollectResult): Error {
|
|
354
|
+
const reason = formatReason(result);
|
|
355
|
+
const error = new Error(`[${BLOCK_CODE}]\n${reason}`) as Error & { code?: string; result?: unknown };
|
|
356
|
+
error.name = "CafeKitSpecGateBlocked";
|
|
357
|
+
error.code = BLOCK_CODE;
|
|
358
|
+
error.result = { decision: "block", reason };
|
|
359
|
+
return error;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function isControlledBlock(error: unknown): boolean {
|
|
363
|
+
return Boolean(error && typeof error === "object" && (error as { code?: string }).code === BLOCK_CODE);
|
|
364
|
+
}
|
|
365
|
+
|
|
208
366
|
function writeBanner(cwd: string, reason: string | null): void {
|
|
209
367
|
try {
|
|
210
368
|
const file = join(cwd, ".opencode", "session-banner.md");
|
|
@@ -223,10 +381,9 @@ function writeBanner(cwd: string, reason: string | null): void {
|
|
|
223
381
|
return;
|
|
224
382
|
}
|
|
225
383
|
const block = [SPEC_GATE_BANNER_START, reason, SPEC_GATE_BANNER_END].join("\n");
|
|
226
|
-
|
|
227
|
-
writeFileSync(file, next);
|
|
384
|
+
writeFileSync(file, cleaned ? `${cleaned}\n\n${block}\n` : `${block}\n`);
|
|
228
385
|
} catch {
|
|
229
|
-
//
|
|
386
|
+
// Banner persistence is secondary to the before-hook decision.
|
|
230
387
|
}
|
|
231
388
|
}
|
|
232
389
|
|
|
@@ -236,43 +393,60 @@ function logCrash(error: unknown): void {
|
|
|
236
393
|
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
|
|
237
394
|
appendFileSync(
|
|
238
395
|
join(logDir, "hook-log.jsonl"),
|
|
239
|
-
JSON.stringify({ ts: new Date().toISOString(), hook: "spec-gate", status: "crash", error: error instanceof Error ? error.message : String(error) })
|
|
396
|
+
`${JSON.stringify({ ts: new Date().toISOString(), hook: "spec-gate", status: "crash", error: error instanceof Error ? error.message : String(error) })}\n`,
|
|
240
397
|
);
|
|
241
398
|
} catch {
|
|
242
|
-
//
|
|
399
|
+
// Logging errors are intentionally ignored.
|
|
243
400
|
}
|
|
244
401
|
}
|
|
245
402
|
|
|
246
|
-
const RELEVANT_TOOLS = new Set(["edit", "write", "apply_patch", "task", "taskcreate", "taskupdate", "todowrite"]);
|
|
247
|
-
|
|
248
403
|
export const SpecGate: Plugin = async ({ directory }) => ({
|
|
249
404
|
event: async ({ event }) => {
|
|
250
405
|
try {
|
|
251
|
-
if (event.type
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
406
|
+
if (event.type !== "session.idle") return;
|
|
407
|
+
const eventRecord = event as unknown as Record<string, unknown>;
|
|
408
|
+
const properties = eventRecord.properties as Record<string, unknown> | undefined;
|
|
409
|
+
const result = collectFailures(directory, [eventRecord, properties]);
|
|
410
|
+
if (result) {
|
|
411
|
+
const reason = formatReason(result);
|
|
412
|
+
writeBanner(directory, reason);
|
|
413
|
+
console.error(`[spec-gate] ${reason}`);
|
|
414
|
+
} else {
|
|
415
|
+
writeBanner(directory, null);
|
|
260
416
|
}
|
|
261
417
|
} catch (error) {
|
|
262
418
|
logCrash(error);
|
|
263
419
|
}
|
|
264
420
|
},
|
|
421
|
+
|
|
422
|
+
// OpenCode's documented cancellation boundary is before tool execution.
|
|
423
|
+
// Throwing here aborts the guarded completion/state tool call.
|
|
424
|
+
"tool.execute.before": async (input, output) => {
|
|
425
|
+
try {
|
|
426
|
+
if (!GATED_TOOLS.has(input.tool)) return;
|
|
427
|
+
const result = collectFailures(directory, [input, (output as { args?: unknown })?.args]);
|
|
428
|
+
if (!result) return;
|
|
429
|
+
const reason = formatReason(result);
|
|
430
|
+
writeBanner(directory, reason);
|
|
431
|
+
if (HARD_BLOCK_TOOLS.has(input.tool)) throw createBlockError(result);
|
|
432
|
+
console.error(`[spec-gate] ${reason}`);
|
|
433
|
+
} catch (error) {
|
|
434
|
+
if (isControlledBlock(error)) throw error;
|
|
435
|
+
logCrash(error);
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
|
|
439
|
+
// After-hook output is observational only; the tool has already run.
|
|
265
440
|
"tool.execute.after": async (input) => {
|
|
266
441
|
try {
|
|
267
|
-
if (!
|
|
268
|
-
const result = collectFailures(directory);
|
|
442
|
+
if (!GATED_TOOLS.has(input.tool)) return;
|
|
443
|
+
const result = collectFailures(directory, [input, (input as { args?: unknown }).args]);
|
|
269
444
|
if (result) {
|
|
270
|
-
const reason = formatReason(result
|
|
445
|
+
const reason = formatReason(result);
|
|
271
446
|
writeBanner(directory, reason);
|
|
272
|
-
console.error(`[spec-gate
|
|
447
|
+
console.error(`[spec-gate] ${reason}`);
|
|
273
448
|
} else {
|
|
274
|
-
|
|
275
|
-
// (keep banner if failures still exist from other tasks — collectFailures already handles)
|
|
449
|
+
writeBanner(directory, null);
|
|
276
450
|
}
|
|
277
451
|
} catch (error) {
|
|
278
452
|
logCrash(error);
|