@haposoft/cafekit 0.16.0-rc.6 → 0.16.0-rc.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -58
- package/bin/__tests__/change-firewall.semantic-firewall.test.js +4 -5
- package/bin/__tests__/codex-hooks.test.js +137 -0
- package/bin/__tests__/codex-native.test.js +130 -74
- package/bin/__tests__/develop-contract.test.js +155 -76
- package/bin/__tests__/installer-safety.test.js +19 -154
- package/bin/__tests__/package-inventory.test.js +10 -20
- package/bin/__tests__/release-preflight.semantic-firewall.test.js +2 -2
- package/bin/__tests__/validator-grounding.test.js +11 -11
- package/bin/install.js +2 -7
- package/bin/lib/context.js +0 -45
- package/bin/lib/copy-utils.js +1 -1
- package/bin/lib/i18n.js +0 -3
- package/bin/lib/manifest.js +1 -1
- package/bin/phases/claude-runtime.js +1 -3
- package/bin/phases/codex-runtime.js +4 -2
- package/bin/phases/copy-payload.js +3 -15
- package/bin/phases/post-install.js +5 -17
- package/bin/phases/root-config.js +2 -3
- package/bin/phases/select-platform.js +2 -5
- package/bin/phases/summary.js +0 -1
- package/package.json +2 -5
- package/src/claude/CLAUDE.md +19 -6
- package/src/claude/agents/project-manager.md +41 -9
- package/src/claude/agents/spec-maker.md +76 -155
- package/src/claude/gitignore +1 -1
- package/src/claude/hooks/__tests__/spec-gate.test.js +162 -0
- package/src/claude/hooks/lib/context.cjs +1 -1
- package/src/claude/hooks/lib/detect.cjs +1 -1
- package/src/claude/hooks/spec-gate.cjs +44 -9
- package/src/claude/hooks/spec-state.cjs +22 -11
- package/src/claude/hooks/task-scaffold-guard.cjs +1 -1
- package/src/claude/migration-manifest.json +17 -3
- package/src/claude/rules/manage-docs.md +43 -24
- package/src/claude/rules/state-sync.md +57 -62
- package/src/claude/rules/workflow.md +30 -13
- package/src/claude/scripts/change-firewall.cjs +2 -2
- package/src/claude/scripts/generate-skill-catalog.cjs +0 -1
- package/src/claude/scripts/spec-receipt.cjs +141 -1
- package/src/claude/scripts/spec-resolver.cjs +288 -0
- package/src/claude/skills/develop/SKILL.md +89 -125
- package/src/claude/skills/develop/references/parallel-waves.md +75 -162
- package/src/claude/skills/develop/references/quality-gate.md +61 -121
- package/src/claude/skills/develop/references/subagent-patterns.md +31 -38
- package/src/claude/skills/question/SKILL.md +1 -1
- package/src/claude/skills/specs/SKILL.md +142 -204
- package/src/claude/skills/specs/references/review.md +109 -243
- package/src/claude/skills/specs/references/templates.md +173 -0
- package/src/claude/skills/sync/SKILL.md +66 -90
- package/src/claude/skills/sync/references/sync-protocols.md +62 -83
- package/src/codex/AGENTS.md +20 -7
- package/src/codex/hooks/lib/spec-receipt.cjs +18 -0
- package/src/codex/hooks/lib/spec-utils.cjs +25 -0
- package/src/codex/hooks/spec-gate.cjs +46 -16
- package/src/codex/hooks/spec-state.cjs +22 -12
- package/src/codex/rules/state-sync.md +57 -61
- package/src/common/AGENTS.md +8 -1
- package/bin/__tests__/opencode-spec-gate.test.js +0 -799
- package/bin/lib/opencode-install.js +0 -899
- package/bin/phases/opencode-runtime.js +0 -38
- package/src/claude/skills/specs/references/archive-workflow.md +0 -74
- package/src/claude/skills/specs/references/ask-user-question-gates.md +0 -70
- package/src/claude/skills/specs/references/codebase-analysis.md +0 -102
- package/src/claude/skills/specs/references/cross-spec-dependency.md +0 -76
- package/src/claude/skills/specs/references/research-strategy.md +0 -107
- package/src/claude/skills/specs/references/scope-inquiry.md +0 -107
- package/src/claude/skills/specs/references/translation-mirror.md +0 -83
- package/src/claude/skills/specs/rules/design-discovery-full.md +0 -45
- package/src/claude/skills/specs/rules/design-discovery-light.md +0 -28
- package/src/claude/skills/specs/rules/design-principles.md +0 -138
- package/src/claude/skills/specs/rules/design-review.md +0 -55
- package/src/claude/skills/specs/rules/ears-format.md +0 -42
- package/src/claude/skills/specs/rules/phase-decision-matrix.md +0 -56
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +0 -52
- package/src/claude/skills/specs/rules/tasks-generation.md +0 -189
- package/src/opencode/AGENTS.md +0 -41
- package/src/opencode/plugins/docs-sync.ts +0 -175
- package/src/opencode/plugins/inspect-block.ts +0 -164
- package/src/opencode/plugins/privacy-block.ts +0 -182
- package/src/opencode/plugins/rules.ts +0 -155
- package/src/opencode/plugins/session.ts +0 -179
- package/src/opencode/plugins/spec-gate.ts +0 -457
- package/src/opencode/plugins/spec-state.ts +0 -311
- package/src/opencode/plugins/state.ts +0 -253
- package/src/opencode/plugins/task-scaffold-guard.ts +0 -161
- 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;
|