@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.
Files changed (98) hide show
  1. package/bin/__tests__/codex-hooks.test.js +794 -21
  2. package/bin/__tests__/codex-native.test.js +228 -7
  3. package/bin/__tests__/develop-contract.test.js +1838 -112
  4. package/bin/__tests__/installer-safety.test.js +82 -29
  5. package/bin/__tests__/opencode-spec-gate.test.js +799 -0
  6. package/bin/__tests__/package-inventory.test.js +841 -10
  7. package/bin/__tests__/specs-v2-execution-closeout.test.js +421 -0
  8. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +601 -0
  9. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +952 -0
  10. package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
  11. package/bin/__tests__/validator-grounding.test.js +2469 -71
  12. package/bin/lib/managed-writer.js +12 -3
  13. package/bin/phases/codex-runtime.js +17 -1
  14. package/package.json +1 -1
  15. package/src/claude/CLAUDE.md +6 -0
  16. package/src/claude/agents/code-auditor.md +20 -0
  17. package/src/claude/agents/spec-maker.md +153 -202
  18. package/src/claude/hooks/__tests__/completion-authority.test.js +953 -0
  19. package/src/claude/hooks/__tests__/privacy-block.test.js +172 -0
  20. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
  21. package/src/claude/hooks/__tests__/spec-gate.test.js +604 -56
  22. package/src/claude/hooks/completion-authority-check.cjs +56 -0
  23. package/src/claude/hooks/completion-authority-state.cjs +527 -0
  24. package/src/claude/hooks/completion-authority.cjs +154 -0
  25. package/src/claude/hooks/docs-sync.cjs +5 -2
  26. package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
  27. package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
  28. package/src/claude/hooks/privacy-block.cjs +77 -30
  29. package/src/claude/hooks/semantic-review-authority.cjs +152 -0
  30. package/src/claude/hooks/session.cjs +33 -19
  31. package/src/claude/hooks/spec-gate.cjs +181 -139
  32. package/src/claude/hooks/spec-state.cjs +112 -47
  33. package/src/claude/migration-manifest.json +15 -2
  34. package/src/claude/rules/state-sync.md +52 -21
  35. package/src/claude/rules/workflow.md +17 -9
  36. package/src/claude/scripts/provenance.cjs +315 -0
  37. package/src/claude/scripts/spec-final-state.cjs +244 -0
  38. package/src/claude/scripts/spec-ground.cjs +535 -173
  39. package/src/claude/scripts/spec-readiness.cjs +118 -0
  40. package/src/claude/scripts/spec-receipt.cjs +162 -0
  41. package/src/claude/scripts/spec-resolver.cjs +501 -0
  42. package/src/claude/scripts/spec-scaffold.cjs +1945 -55
  43. package/src/claude/scripts/spec-semantic-model.cjs +396 -0
  44. package/src/claude/scripts/validate-spec-output.cjs +3204 -262
  45. package/src/claude/scripts/workflow-policy.cjs +1888 -203
  46. package/src/claude/settings/settings.json +12 -0
  47. package/src/claude/skills/code-review/SKILL.md +94 -66
  48. package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
  49. package/src/claude/skills/code-review/references/verification-gate.md +13 -0
  50. package/src/claude/skills/develop/SKILL.md +138 -283
  51. package/src/claude/skills/develop/references/parallel-waves.md +9 -6
  52. package/src/claude/skills/develop/references/quality-gate.md +123 -148
  53. package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
  54. package/src/claude/skills/specs/SKILL.md +192 -397
  55. package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
  56. package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
  57. package/src/claude/skills/specs/references/research-strategy.md +4 -1
  58. package/src/claude/skills/specs/references/review.md +200 -260
  59. package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
  60. package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
  61. package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
  62. package/src/claude/skills/specs/rules/design-principles.md +136 -180
  63. package/src/claude/skills/specs/rules/design-review.md +42 -97
  64. package/src/claude/skills/specs/rules/ears-format.md +42 -50
  65. package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
  66. package/src/claude/skills/specs/rules/task-scoring-rubric.md +46 -46
  67. package/src/claude/skills/specs/rules/tasks-generation.md +189 -271
  68. package/src/claude/skills/specs/templates/design.md +49 -263
  69. package/src/claude/skills/specs/templates/requirements.md +26 -36
  70. package/src/claude/skills/specs/templates/research.md +9 -98
  71. package/src/claude/skills/specs/templates/spec-state.json +26 -67
  72. package/src/claude/skills/specs/templates/task.md +30 -82
  73. package/src/claude/skills/sync/SKILL.md +45 -15
  74. package/src/claude/skills/sync/references/sync-protocols.md +34 -16
  75. package/src/claude/skills/test/SKILL.md +151 -167
  76. package/src/codex/AGENTS.md +6 -0
  77. package/src/codex/hooks/completion-authority-check.cjs +63 -0
  78. package/src/codex/hooks/completion-authority-state.cjs +527 -0
  79. package/src/codex/hooks/completion-authority.cjs +106 -0
  80. package/src/codex/hooks/lib/hook-context.cjs +58 -4
  81. package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
  82. package/src/codex/hooks/lib/privacy-state.cjs +38 -16
  83. package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
  84. package/src/codex/hooks/lib/spec-receipt.cjs +86 -115
  85. package/src/codex/hooks/lib/spec-utils.cjs +104 -27
  86. package/src/codex/hooks/privacy-approval.cjs +1 -1
  87. package/src/codex/hooks/privacy-block.cjs +67 -11
  88. package/src/codex/hooks/semantic-review-authority.cjs +130 -0
  89. package/src/codex/hooks/session.cjs +2 -0
  90. package/src/codex/hooks/spec-gate.cjs +151 -24
  91. package/src/codex/hooks/spec-state.cjs +65 -12
  92. package/src/codex/hooks/state.cjs +2 -0
  93. package/src/codex/hooks.json +16 -1
  94. package/src/codex/rules/state-sync.md +47 -27
  95. package/src/common/AGENTS.md +1 -1
  96. package/src/opencode/AGENTS.md +4 -2
  97. package/src/opencode/plugins/spec-gate.ts +296 -122
  98. 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
- * Advisory port of .claude/hooks/spec-gate.cjs and .codex/hooks/spec-gate.cjs.
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
- * OpenCode has no Stop-hook equivalent that can hard-block a turn end.
9
- * This plugin provides a best-effort advisory gate:
10
- * - On tool.execute.after for writes to specs/<feature>/spec.json or task markdown, re-validate
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
- readdirSync,
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 PASS_MARKER = /^\s*Verification:\s*PASS\s*$/m;
50
- const LEGACY_SUCCESS = /^\s*(?:PASS(?:ED)?|✓)(?:\s*:|$)|exit\s+code\s*[:=]?\s*0\b/im;
51
- const EXPLICIT_FAILURE = /\bFAIL(?:ED|URE|URES|ING)?\b|tests?\s+failed|exit\s+code\s*[:=]?\s*[1-9]\d*|\b0\s+tests?\b/i;
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, any> {
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 i = 0; i < lines.length; i++) {
67
- const m = lines[i].match(/^(#{2,3})\s+(.+?)\s*$/);
68
- if (m && EVIDENCE_NAMES.includes(m[2])) {
69
- start = i + 1;
70
- level = m[1].length;
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 i = start; i < lines.length; i++) {
77
- const hm = lines[i].match(/^(#{1,6})\s+/);
78
- if (hm && hm[1].length <= level) {
79
- end = i;
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 fails: string[] = [];
88
- if (!/^\s*Verification:\s*PASS\s*$/m.test(body)) fails.push("verification_state");
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
- // prevent path traversal: taskPath must stay inside featureDir (cross-platform sibling-prefix safe)
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((l) => /^\s*(?:\*\*)?Status(?:\*\*)?\s*:/i.test(l));
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 canonical = validateCanonicalReceipt(body);
123
- const map: Record<string, string> = { verification_state: "c", command: "e", exit_result: "f", provenance: "g", artifact_hash: "h" };
124
- for (const f of canonical) {
125
- if (f === "verification_state") {
126
- if (!PASS_MARKER.test(body) && !fails.includes("c")) fails.push("c");
127
- continue;
128
- }
129
- const letter = map[f];
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
- const at = task?.completed_at;
135
- if (typeof at !== "string" || !/^\d{4}-\d{2}-\d{2}T/.test(at) || Number.isNaN(Date.parse(at))) fails.push("d");
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 findActiveSpec(specsPath: string): { featureName: string; spec: SpecJson; specsDir: string; featureDir: string } | null {
140
- if (!existsSync(specsPath)) return null;
141
- let entries;
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
- entries = readdirSync(specsPath, { withFileTypes: true });
144
- } catch {
145
- return null;
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
- for (const entry of entries) {
148
- if (!entry.isDirectory()) continue;
149
- const specFile = join(specsPath, entry.name, "spec.json");
150
- if (!existsSync(specFile)) continue;
151
- try {
152
- const data = JSON.parse(readFileSync(specFile, "utf8")) as SpecJson;
153
- if (data.status === "in_progress" || data.status === "in-progress") {
154
- return { featureName: entry.name, spec: data, specsDir: specsPath, featureDir: join(specsPath, entry.name) };
155
- }
156
- } catch {
157
- // skip bad json
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
- function isStaleFlashDone(task: TaskEntry): boolean {
164
- return task?.status === "done" && task.receipt === "FLASH_UNVERIFIED";
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
- function collectFailures(cwd: string): { featureName: string; failures: { taskPath: string; fails: string[] }[] } | null {
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(([, t]) => isStaleFlashDone(t as TaskEntry));
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(([p]) => ({ taskPath: p, fails: ["flash"] })),
314
+ failures: staleFlash.map(([taskPath]) => ({ taskPath, fails: ["flash"] })),
179
315
  };
180
316
  }
181
- // Check every done task (advisory — we have no cache to know newly-done, so check all)
317
+
182
318
  const failures: { taskPath: string; fails: string[] }[] = [];
183
319
  for (const [taskPath, task] of Object.entries(registry)) {
184
- if ((task as TaskEntry)?.status !== "done") continue;
185
- const fails = checkReceipt(active.featureDir, taskPath, task as TaskEntry);
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
- if (failures.length === 0) return null;
189
- return { featureName: active.featureName, failures };
324
+ return failures.length > 0 ? { featureName: active.featureName, failures } : null;
190
325
  }
191
326
 
192
- function formatReason(featureName: string, failures: { taskPath: string; fails: string[] }[]): string {
193
- const flashOnly = failures.length === 1 && failures[0].fails.includes("flash");
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 (advisory): 1 task(s) marked done with FLASH_UNVERIFIED (${failures[0].taskPath}). Run /hapo:test for exact proof, then use explicit sync-finalize.`;
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 = [`⚠️ Completion gate (advisory): ${failures.length} done task(s) lack a verification receipt.`];
198
- for (const { taskPath, fails } of failures) {
199
- if (fails.includes("flash")) {
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
- const next = cleaned ? `${cleaned}\n\n${block}\n` : `${block}\n`;
227
- writeFileSync(file, next);
384
+ writeFileSync(file, cleaned ? `${cleaned}\n\n${block}\n` : `${block}\n`);
228
385
  } catch {
229
- // fail-open
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) }) + "\n",
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
- // fail-open
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 === "session.idle") {
252
- const result = collectFailures(directory);
253
- if (result) {
254
- const reason = formatReason(result.featureName, result.failures);
255
- writeBanner(directory, reason);
256
- console.error(reason);
257
- } else {
258
- writeBanner(directory, null);
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 (!RELEVANT_TOOLS.has(input.tool)) return;
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.featureName, result.failures);
445
+ const reason = formatReason(result);
271
446
  writeBanner(directory, reason);
272
- console.error(`[spec-gate advisory] ${reason}`);
447
+ console.error(`[spec-gate] ${reason}`);
273
448
  } else {
274
- // clear banner if no failures after a relevant tool
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);