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

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 (87) 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 +382 -0
  4. package/bin/__tests__/codex-native.test.js +579 -80
  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 +494 -26
  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 +82 -155
  26. package/src/claude/gitignore +1 -1
  27. package/src/claude/hooks/__tests__/spec-gate.test.js +238 -0
  28. package/src/claude/hooks/__tests__/state.test.js +249 -3
  29. package/src/claude/hooks/lib/context.cjs +1 -1
  30. package/src/claude/hooks/lib/detect.cjs +1 -1
  31. package/src/claude/hooks/spec-gate.cjs +44 -9
  32. package/src/claude/hooks/spec-state.cjs +51 -13
  33. package/src/claude/hooks/task-scaffold-guard.cjs +1 -1
  34. package/src/claude/migration-manifest.json +17 -3
  35. package/src/claude/rules/manage-docs.md +43 -24
  36. package/src/claude/rules/state-sync.md +57 -62
  37. package/src/claude/rules/workflow.md +30 -13
  38. package/src/claude/scripts/change-firewall.cjs +2 -2
  39. package/src/claude/scripts/generate-skill-catalog.cjs +0 -1
  40. package/src/claude/scripts/spec-receipt.cjs +196 -1
  41. package/src/claude/scripts/spec-resolver.cjs +428 -0
  42. package/src/claude/skills/develop/SKILL.md +89 -125
  43. package/src/claude/skills/develop/references/parallel-waves.md +75 -162
  44. package/src/claude/skills/develop/references/quality-gate.md +61 -121
  45. package/src/claude/skills/develop/references/subagent-patterns.md +31 -38
  46. package/src/claude/skills/question/SKILL.md +1 -1
  47. package/src/claude/skills/specs/SKILL.md +152 -204
  48. package/src/claude/skills/specs/references/review.md +124 -243
  49. package/src/claude/skills/specs/references/templates.md +202 -0
  50. package/src/claude/skills/sync/SKILL.md +66 -90
  51. package/src/claude/skills/sync/references/sync-protocols.md +62 -83
  52. package/src/codex/AGENTS.md +20 -7
  53. package/src/codex/hooks/lib/spec-receipt.cjs +18 -0
  54. package/src/codex/hooks/lib/spec-utils.cjs +25 -0
  55. package/src/codex/hooks/spec-gate.cjs +46 -16
  56. package/src/codex/hooks/spec-state.cjs +54 -16
  57. package/src/codex/rules/state-sync.md +57 -61
  58. package/src/common/AGENTS.md +8 -1
  59. package/bin/__tests__/opencode-spec-gate.test.js +0 -799
  60. package/bin/lib/opencode-install.js +0 -899
  61. package/bin/phases/opencode-runtime.js +0 -38
  62. package/src/claude/skills/specs/references/archive-workflow.md +0 -74
  63. package/src/claude/skills/specs/references/ask-user-question-gates.md +0 -70
  64. package/src/claude/skills/specs/references/codebase-analysis.md +0 -102
  65. package/src/claude/skills/specs/references/cross-spec-dependency.md +0 -76
  66. package/src/claude/skills/specs/references/research-strategy.md +0 -107
  67. package/src/claude/skills/specs/references/scope-inquiry.md +0 -107
  68. package/src/claude/skills/specs/references/translation-mirror.md +0 -83
  69. package/src/claude/skills/specs/rules/design-discovery-full.md +0 -45
  70. package/src/claude/skills/specs/rules/design-discovery-light.md +0 -28
  71. package/src/claude/skills/specs/rules/design-principles.md +0 -138
  72. package/src/claude/skills/specs/rules/design-review.md +0 -55
  73. package/src/claude/skills/specs/rules/ears-format.md +0 -42
  74. package/src/claude/skills/specs/rules/phase-decision-matrix.md +0 -56
  75. package/src/claude/skills/specs/rules/task-scoring-rubric.md +0 -52
  76. package/src/claude/skills/specs/rules/tasks-generation.md +0 -189
  77. package/src/opencode/AGENTS.md +0 -41
  78. package/src/opencode/plugins/docs-sync.ts +0 -175
  79. package/src/opencode/plugins/inspect-block.ts +0 -164
  80. package/src/opencode/plugins/privacy-block.ts +0 -182
  81. package/src/opencode/plugins/rules.ts +0 -155
  82. package/src/opencode/plugins/session.ts +0 -179
  83. package/src/opencode/plugins/spec-gate.ts +0 -457
  84. package/src/opencode/plugins/spec-state.ts +0 -311
  85. package/src/opencode/plugins/state.ts +0 -253
  86. package/src/opencode/plugins/task-scaffold-guard.ts +0 -161
  87. package/src/opencode/plugins/usage.ts +0 -178
@@ -1,179 +0,0 @@
1
- /**
2
- * Copyright (c) 2026 Haposoft. MIT License.
3
- *
4
- * OpenCode Plugin — session.ts
5
- *
6
- * Port of .claude/hooks/session.cjs (banner half only).
7
- *
8
- * Behavior:
9
- * - On session.created: detect project type, package manager, framework, git
10
- * branch and write a one-line banner to .opencode/session-banner.md
11
- * alongside docs-sync's section. Also emit to stderr (server log).
12
- * - On session.compacted: append compaction warning to the banner so the
13
- * next assistant turn is reminded to re-verify pending approvals.
14
- *
15
- * Note: Claude's CLAUDE_ENV_FILE writes are dropped — OpenCode does not
16
- * expose an equivalent env-injection channel.
17
- */
18
-
19
- import type { Plugin } from "@opencode-ai/plugin";
20
- import {
21
- existsSync,
22
- readFileSync,
23
- writeFileSync,
24
- mkdirSync,
25
- appendFileSync,
26
- } from "node:fs";
27
- import { join, dirname } from "node:path";
28
- import { fileURLToPath } from "node:url";
29
- import { execSync } from "node:child_process";
30
-
31
- const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
32
- const SESSION_SECTION_START = "<!-- CAFEKIT SESSION START -->";
33
- const SESSION_SECTION_END = "<!-- CAFEKIT SESSION END -->";
34
-
35
- function run(cmd: string, cwd: string, fallback = ""): string {
36
- try {
37
- return execSync(cmd, {
38
- cwd,
39
- encoding: "utf8",
40
- timeout: 3000,
41
- stdio: ["pipe", "pipe", "pipe"],
42
- }).trim();
43
- } catch {
44
- return fallback;
45
- }
46
- }
47
-
48
- function readRuntime(cwd: string): Record<string, any> {
49
- try {
50
- const file = join(cwd, ".opencode", "runtime.json");
51
- return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
52
- } catch {
53
- return {};
54
- }
55
- }
56
-
57
- function detectProjectType(cwd: string): string {
58
- if (existsSync(join(cwd, "pnpm-workspace.yaml")) || existsSync(join(cwd, "lerna.json"))) {
59
- return "monorepo";
60
- }
61
- try {
62
- const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
63
- if (pkg.workspaces) return "monorepo";
64
- if (pkg.main || pkg.exports || pkg.module) return "library";
65
- } catch {
66
- // ignore
67
- }
68
- return "app";
69
- }
70
-
71
- function detectPackageManager(cwd: string): string {
72
- if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
73
- if (existsSync(join(cwd, "yarn.lock"))) return "yarn";
74
- if (existsSync(join(cwd, "bun.lockb"))) return "bun";
75
- if (existsSync(join(cwd, "package-lock.json"))) return "npm";
76
- return "";
77
- }
78
-
79
- function detectFramework(cwd: string): string {
80
- try {
81
- const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
82
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
83
- if (deps["next"]) return "next";
84
- if (deps["nuxt"]) return "nuxt";
85
- if (deps["@sveltejs/kit"]) return "sveltekit";
86
- if (deps["react"]) return "react";
87
- if (deps["vue"]) return "vue";
88
- if (deps["svelte"]) return "svelte";
89
- if (deps["express"]) return "express";
90
- if (deps["fastify"]) return "fastify";
91
- if (deps["hono"]) return "hono";
92
- } catch {
93
- // ignore
94
- }
95
- return "";
96
- }
97
-
98
- function resolveSetting(cwd: string, runtimeValue: unknown, detector: (c: string) => string): string {
99
- if (runtimeValue === "auto" || runtimeValue == null) return detector(cwd);
100
- return typeof runtimeValue === "string" ? runtimeValue : detector(cwd);
101
- }
102
-
103
- function writeBannerSection(cwd: string, content: string): void {
104
- const target = join(cwd, ".opencode", "session-banner.md");
105
- const dir = dirname(target);
106
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
107
-
108
- const existing = existsSync(target) ? readFileSync(target, "utf8") : "";
109
- const pattern = new RegExp(`${SESSION_SECTION_START}[\\s\\S]*?${SESSION_SECTION_END}\\n?`);
110
- const cleaned = existing.replace(pattern, "").trimEnd();
111
- const block = [SESSION_SECTION_START, content, SESSION_SECTION_END].join("\n");
112
- const next = cleaned ? `${cleaned}\n\n${block}\n` : `${block}\n`;
113
- writeFileSync(target, next);
114
- }
115
-
116
- function logCrash(error: unknown): void {
117
- try {
118
- const logDir = join(PLUGIN_DIR, ".logs");
119
- if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
120
- appendFileSync(
121
- join(logDir, "hook-log.jsonl"),
122
- JSON.stringify({
123
- ts: new Date().toISOString(),
124
- hook: "session",
125
- status: "crash",
126
- error: error instanceof Error ? error.message : String(error),
127
- }) + "\n",
128
- );
129
- } catch {
130
- // fail-open
131
- }
132
- }
133
-
134
- function handleSessionCreated(cwd: string): void {
135
- const runtime = readRuntime(cwd);
136
-
137
- const projectType = resolveSetting(cwd, runtime.project?.type, detectProjectType);
138
- const packageManager = resolveSetting(cwd, runtime.project?.packageManager, detectPackageManager);
139
- const framework = resolveSetting(cwd, runtime.project?.framework, detectFramework);
140
- const gitBranch = run("git branch --show-current", cwd);
141
-
142
- const parts: string[] = [];
143
- if (projectType) parts.push(`Type: ${projectType}`);
144
- if (packageManager) parts.push(`PM: ${packageManager}`);
145
- if (framework) parts.push(`Framework: ${framework}`);
146
- if (gitBranch) parts.push(`Branch: ${gitBranch}`);
147
-
148
- const summary = `Session started. ${parts.length ? parts.join(" | ") : "No project info detected."}`;
149
- writeBannerSection(cwd, summary);
150
- console.error(summary);
151
- }
152
-
153
- function handleSessionCompacted(cwd: string): void {
154
- const lines = [
155
- "🚨 SESSION COMPRESSED — VERIFY PENDING AUTHORIZATIONS:",
156
- "Any pending confirmations requested via the question tool might have been lost.",
157
- "Do not proceed without explicitly asking the user again to ensure safety.",
158
- 'Use the question tool: "The chat context was compressed. Do I still have permission to proceed?"',
159
- ];
160
- writeBannerSection(cwd, lines.join("\n"));
161
- console.error(lines.join("\n"));
162
- }
163
-
164
- export const Session: Plugin = async ({ directory }) => ({
165
- event: async ({ event }) => {
166
- try {
167
- if (event.type === "session.created") {
168
- handleSessionCreated(directory);
169
- return;
170
- }
171
- if (event.type === "session.compacted") {
172
- handleSessionCompacted(directory);
173
- return;
174
- }
175
- } catch (error) {
176
- logCrash(error);
177
- }
178
- },
179
- });
@@ -1,457 +0,0 @@
1
- /**
2
- * Copyright (c) 2026 Haposoft. MIT License.
3
- *
4
- * OpenCode Plugin — spec-gate.ts
5
- *
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.
11
- *
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.
15
- */
16
-
17
- import type { Plugin } from "@opencode-ai/plugin";
18
- import {
19
- existsSync,
20
- readFileSync,
21
- writeFileSync,
22
- mkdirSync,
23
- appendFileSync,
24
- realpathSync,
25
- } from "node:fs";
26
- import { join, dirname, relative, resolve, sep, isAbsolute } from "node:path";
27
- import { fileURLToPath } from "node:url";
28
- import { createRequire } from "node:module";
29
-
30
- const require = createRequire(import.meta.url);
31
-
32
- const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
33
- const SPEC_GATE_BANNER_START = "<!-- CAFEKIT SPEC-GATE START -->";
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";
38
-
39
- type TaskEntry = {
40
- status?: string;
41
- completed_at?: string;
42
- receipt?: string;
43
- artifacts?: string[];
44
- };
45
-
46
- type SpecJson = {
47
- status?: string;
48
- current_phase?: string;
49
- phase?: string;
50
- task_registry?: Record<string, TaskEntry>;
51
- };
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
-
101
- const EVIDENCE_NAMES = ["Evidence", "Task Test Plan & Verification Evidence", "Verification & Evidence"];
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
- }
160
-
161
- function readRuntime(cwd: string): Record<string, unknown> {
162
- try {
163
- const file = join(cwd, ".opencode", "runtime.json");
164
- return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
165
- } catch {
166
- return {};
167
- }
168
- }
169
-
170
- function evidenceBody(text: string): string | null {
171
- const lines = text.split("\n");
172
- let start = -1;
173
- let level = 0;
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;
179
- break;
180
- }
181
- }
182
- if (start < 0) return null;
183
- let end = lines.length;
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;
188
- break;
189
- }
190
- }
191
- return lines.slice(start, end).join("\n");
192
- }
193
-
194
- function validateCanonicalReceipt(body: string, options?: any): string[] {
195
- const shared = getSharedValidate();
196
- return shared ? shared(body, options) : ["shared_validator"];
197
- }
198
-
199
- function checkReceipt(featureDir: string, taskPath: string, task: TaskEntry, policy: any): string[] {
200
- const fails: string[] = [];
201
- const resolvedFeatureDir = resolve(featureDir);
202
- const abs = resolve(featureDir, taskPath);
203
- const rel = relative(resolvedFeatureDir, abs);
204
- if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return ["a"];
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
-
215
- let text: string;
216
- try {
217
- text = readFileSync(abs, "utf8");
218
- } catch {
219
- return ["a"];
220
- }
221
- const statusLine = text.split("\n").find((line) => /^\s*(?:\*\*)?Status(?:\*\*)?\s*:/i.test(line));
222
- if (!statusLine || !/\bdone\b/i.test(statusLine)) fails.push("a");
223
-
224
- const body = evidenceBody(text);
225
- if (body === null) {
226
- fails.push("b");
227
- } else {
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];
245
- if (letter && !fails.includes(letter)) fails.push(letter);
246
- }
247
- }
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
- }
253
- return fails;
254
- }
255
-
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;
276
- try {
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
- };
284
- }
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" };
297
- }
298
-
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
- }
307
-
308
- const active = resolved as ActiveSpec;
309
- const registry = active.spec.task_registry || {};
310
- const staleFlash = Object.entries(registry).filter(([, task]) => task?.status === "done" && task.receipt === "FLASH_UNVERIFIED");
311
- if (staleFlash.length > 0) {
312
- return {
313
- featureName: active.featureName,
314
- failures: staleFlash.map(([taskPath]) => ({ taskPath, fails: ["flash"] })),
315
- };
316
- }
317
-
318
- const failures: { taskPath: string; fails: string[] }[] = [];
319
- for (const [taskPath, task] of Object.entries(registry)) {
320
- if (task?.status !== "done") continue;
321
- const fails = checkReceipt(active.featureDir, taskPath, task, policyLoaded.policy);
322
- if (fails.length > 0) failures.push({ taskPath, fails });
323
- }
324
- return failures.length > 0 ? { featureName: active.featureName, failures } : null;
325
- }
326
-
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");
343
- if (flashOnly) {
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.`;
345
- }
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(", ")}`);
349
- }
350
- return lines.slice(0, 8).join("\n");
351
- }
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
-
366
- function writeBanner(cwd: string, reason: string | null): void {
367
- try {
368
- const file = join(cwd, ".opencode", "session-banner.md");
369
- const dir = dirname(file);
370
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
371
- let existing = "";
372
- try {
373
- existing = existsSync(file) ? readFileSync(file, "utf8") : "";
374
- } catch {
375
- existing = "";
376
- }
377
- const pattern = new RegExp(`${SPEC_GATE_BANNER_START}[\\s\\S]*?${SPEC_GATE_BANNER_END}\\n?`);
378
- const cleaned = existing.replace(pattern, "").trimEnd();
379
- if (!reason) {
380
- if (cleaned !== existing.trimEnd()) writeFileSync(file, cleaned ? `${cleaned}\n` : "");
381
- return;
382
- }
383
- const block = [SPEC_GATE_BANNER_START, reason, SPEC_GATE_BANNER_END].join("\n");
384
- writeFileSync(file, cleaned ? `${cleaned}\n\n${block}\n` : `${block}\n`);
385
- } catch {
386
- // Banner persistence is secondary to the before-hook decision.
387
- }
388
- }
389
-
390
- function logCrash(error: unknown): void {
391
- try {
392
- const logDir = join(PLUGIN_DIR, ".logs");
393
- if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
394
- appendFileSync(
395
- join(logDir, "hook-log.jsonl"),
396
- `${JSON.stringify({ ts: new Date().toISOString(), hook: "spec-gate", status: "crash", error: error instanceof Error ? error.message : String(error) })}\n`,
397
- );
398
- } catch {
399
- // Logging errors are intentionally ignored.
400
- }
401
- }
402
-
403
- export const SpecGate: Plugin = async ({ directory }) => ({
404
- event: async ({ event }) => {
405
- try {
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);
416
- }
417
- } catch (error) {
418
- logCrash(error);
419
- }
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.
440
- "tool.execute.after": async (input) => {
441
- try {
442
- if (!GATED_TOOLS.has(input.tool)) return;
443
- const result = collectFailures(directory, [input, (input as { args?: unknown }).args]);
444
- if (result) {
445
- const reason = formatReason(result);
446
- writeBanner(directory, reason);
447
- console.error(`[spec-gate] ${reason}`);
448
- } else {
449
- writeBanner(directory, null);
450
- }
451
- } catch (error) {
452
- logCrash(error);
453
- }
454
- },
455
- });
456
-
457
- export default SpecGate;