@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.
Files changed (86) 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 +137 -0
  4. package/bin/__tests__/codex-native.test.js +130 -74
  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 +10 -20
  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 +76 -155
  26. package/src/claude/gitignore +1 -1
  27. package/src/claude/hooks/__tests__/spec-gate.test.js +162 -0
  28. package/src/claude/hooks/lib/context.cjs +1 -1
  29. package/src/claude/hooks/lib/detect.cjs +1 -1
  30. package/src/claude/hooks/spec-gate.cjs +44 -9
  31. package/src/claude/hooks/spec-state.cjs +22 -11
  32. package/src/claude/hooks/task-scaffold-guard.cjs +1 -1
  33. package/src/claude/migration-manifest.json +17 -3
  34. package/src/claude/rules/manage-docs.md +43 -24
  35. package/src/claude/rules/state-sync.md +57 -62
  36. package/src/claude/rules/workflow.md +30 -13
  37. package/src/claude/scripts/change-firewall.cjs +2 -2
  38. package/src/claude/scripts/generate-skill-catalog.cjs +0 -1
  39. package/src/claude/scripts/spec-receipt.cjs +141 -1
  40. package/src/claude/scripts/spec-resolver.cjs +288 -0
  41. package/src/claude/skills/develop/SKILL.md +89 -125
  42. package/src/claude/skills/develop/references/parallel-waves.md +75 -162
  43. package/src/claude/skills/develop/references/quality-gate.md +61 -121
  44. package/src/claude/skills/develop/references/subagent-patterns.md +31 -38
  45. package/src/claude/skills/question/SKILL.md +1 -1
  46. package/src/claude/skills/specs/SKILL.md +142 -204
  47. package/src/claude/skills/specs/references/review.md +109 -243
  48. package/src/claude/skills/specs/references/templates.md +173 -0
  49. package/src/claude/skills/sync/SKILL.md +66 -90
  50. package/src/claude/skills/sync/references/sync-protocols.md +62 -83
  51. package/src/codex/AGENTS.md +20 -7
  52. package/src/codex/hooks/lib/spec-receipt.cjs +18 -0
  53. package/src/codex/hooks/lib/spec-utils.cjs +25 -0
  54. package/src/codex/hooks/spec-gate.cjs +46 -16
  55. package/src/codex/hooks/spec-state.cjs +22 -12
  56. package/src/codex/rules/state-sync.md +57 -61
  57. package/src/common/AGENTS.md +8 -1
  58. package/bin/__tests__/opencode-spec-gate.test.js +0 -799
  59. package/bin/lib/opencode-install.js +0 -899
  60. package/bin/phases/opencode-runtime.js +0 -38
  61. package/src/claude/skills/specs/references/archive-workflow.md +0 -74
  62. package/src/claude/skills/specs/references/ask-user-question-gates.md +0 -70
  63. package/src/claude/skills/specs/references/codebase-analysis.md +0 -102
  64. package/src/claude/skills/specs/references/cross-spec-dependency.md +0 -76
  65. package/src/claude/skills/specs/references/research-strategy.md +0 -107
  66. package/src/claude/skills/specs/references/scope-inquiry.md +0 -107
  67. package/src/claude/skills/specs/references/translation-mirror.md +0 -83
  68. package/src/claude/skills/specs/rules/design-discovery-full.md +0 -45
  69. package/src/claude/skills/specs/rules/design-discovery-light.md +0 -28
  70. package/src/claude/skills/specs/rules/design-principles.md +0 -138
  71. package/src/claude/skills/specs/rules/design-review.md +0 -55
  72. package/src/claude/skills/specs/rules/ears-format.md +0 -42
  73. package/src/claude/skills/specs/rules/phase-decision-matrix.md +0 -56
  74. package/src/claude/skills/specs/rules/task-scoring-rubric.md +0 -52
  75. package/src/claude/skills/specs/rules/tasks-generation.md +0 -189
  76. package/src/opencode/AGENTS.md +0 -41
  77. package/src/opencode/plugins/docs-sync.ts +0 -175
  78. package/src/opencode/plugins/inspect-block.ts +0 -164
  79. package/src/opencode/plugins/privacy-block.ts +0 -182
  80. package/src/opencode/plugins/rules.ts +0 -155
  81. package/src/opencode/plugins/session.ts +0 -179
  82. package/src/opencode/plugins/spec-gate.ts +0 -457
  83. package/src/opencode/plugins/spec-state.ts +0 -311
  84. package/src/opencode/plugins/state.ts +0 -253
  85. package/src/opencode/plugins/task-scaffold-guard.ts +0 -161
  86. package/src/opencode/plugins/usage.ts +0 -178
@@ -1,175 +0,0 @@
1
- /**
2
- * Copyright (c) 2026 Haposoft. MIT License.
3
- *
4
- * OpenCode Plugin — docs-sync.ts
5
- *
6
- * Port of .claude/hooks/docs-sync.cjs.
7
- *
8
- * Behavior:
9
- * - On session.created: detect if project has source code but no docs/, or if
10
- * docs/.sync_hash is stale relative to current source-only git hash.
11
- * - Write banner to `.opencode/session-banner.md` so AGENTS.md / skills can
12
- * surface it (OpenCode event console.log does not flow into assistant
13
- * context, only the server log).
14
- * - Also print to stderr so the OpenCode server log records it.
15
- *
16
- * Disable via `.opencode/runtime.json` { "docsSync": false }.
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 BANNER_SECTION_START = "<!-- CAFEKIT DOCS SYNC START -->";
33
- const BANNER_SECTION_END = "<!-- CAFEKIT DOCS SYNC END -->";
34
-
35
- function readRuntime(cwd: string): Record<string, any> {
36
- try {
37
- const file = join(cwd, ".opencode", "runtime.json");
38
- return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
39
- } catch {
40
- return {};
41
- }
42
- }
43
-
44
- function resolveDocsDir(cwd: string, runtime: Record<string, any>): string {
45
- const configured = runtime.paths?.docs;
46
- return join(cwd, typeof configured === "string" && configured ? configured : "docs");
47
- }
48
-
49
- function hasSourceCode(cwd: string): boolean {
50
- return (
51
- existsSync(join(cwd, "src")) ||
52
- existsSync(join(cwd, "app")) ||
53
- existsSync(join(cwd, "lib")) ||
54
- existsSync(join(cwd, "package.json")) ||
55
- existsSync(join(cwd, "index.js")) ||
56
- existsSync(join(cwd, "main.py"))
57
- );
58
- }
59
-
60
- function currentSourceHash(cwd: string): string {
61
- try {
62
- return execSync('git log -1 --format="%H" -- . ":(exclude)docs"', {
63
- cwd,
64
- encoding: "utf8",
65
- stdio: ["ignore", "pipe", "ignore"],
66
- }).trim();
67
- } catch {
68
- return "";
69
- }
70
- }
71
-
72
- function buildMissingDocsBanner(): string {
73
- return [
74
- "### Missing docs/",
75
- "> Source exists but `docs/` does not. Create baseline docs first:",
76
- "> 1. `docs/system-architecture.md` — system architecture and flows",
77
- "> 2. `docs/project-overview-pdr.md` — overview, goals, features",
78
- '> 3. Write current hash to `docs/.sync_hash` via `git log -1 --format="%H" -- . ":(exclude)docs"`',
79
- ].join("\n");
80
- }
81
-
82
- function buildStaleDocsBanner(currentHash: string, lastSyncHash: string): string {
83
- return [
84
- "### Docs sync needed",
85
- `> Source changed (\`${currentHash}\`) since last docs sync (\`${lastSyncHash || "none"}\`).`,
86
- `> 1. Review recent changes (\`git diff ${lastSyncHash} ${currentHash}\` or \`git log\`).`,
87
- "> 2. Update architecture/code-standards docs and changelog as needed.",
88
- `> 3. Write \`${currentHash}\` to \`docs/.sync_hash\` when done.`,
89
- ].join("\n");
90
- }
91
-
92
- function writeBanner(cwd: string, banner: string | null): void {
93
- const target = join(cwd, ".opencode", "session-banner.md");
94
- const dir = dirname(target);
95
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
96
-
97
- const existing = existsSync(target) ? readFileSync(target, "utf8") : "";
98
- const pattern = new RegExp(`${BANNER_SECTION_START}[\\s\\S]*?${BANNER_SECTION_END}\\n?`);
99
- const cleaned = existing.replace(pattern, "").trimEnd();
100
-
101
- if (!banner) {
102
- if (cleaned !== existing.trimEnd()) {
103
- writeFileSync(target, cleaned ? `${cleaned}\n` : "");
104
- }
105
- return;
106
- }
107
-
108
- const block = [BANNER_SECTION_START, banner, BANNER_SECTION_END].join("\n");
109
- const next = cleaned ? `${cleaned}\n\n${block}\n` : `${block}\n`;
110
- writeFileSync(target, next);
111
- }
112
-
113
- function logCrash(error: unknown): void {
114
- try {
115
- const logDir = join(PLUGIN_DIR, ".logs");
116
- if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
117
- appendFileSync(
118
- join(logDir, "hook-log.jsonl"),
119
- JSON.stringify({
120
- ts: new Date().toISOString(),
121
- hook: "docs-sync",
122
- status: "crash",
123
- error: error instanceof Error ? error.message : String(error),
124
- }) + "\n",
125
- );
126
- } catch {
127
- // fail-open
128
- }
129
- }
130
-
131
- export const DocsSync: Plugin = async ({ directory }) => ({
132
- event: async ({ event }) => {
133
- try {
134
- if (event.type !== "session.created") return;
135
-
136
- const cwd = directory;
137
- const runtime = readRuntime(cwd);
138
- if (runtime.docsSync === false) return;
139
-
140
- if (!hasSourceCode(cwd)) {
141
- writeBanner(cwd, null);
142
- return;
143
- }
144
-
145
- const docsDir = resolveDocsDir(cwd, runtime);
146
-
147
- if (!existsSync(docsDir)) {
148
- const banner = buildMissingDocsBanner();
149
- writeBanner(cwd, banner);
150
- console.error(banner);
151
- return;
152
- }
153
-
154
- const currentHash = currentSourceHash(cwd);
155
- if (!currentHash) {
156
- writeBanner(cwd, null);
157
- return;
158
- }
159
-
160
- const syncFile = join(docsDir, ".sync_hash");
161
- const lastSyncHash = existsSync(syncFile) ? readFileSync(syncFile, "utf8").trim() : "";
162
-
163
- if (lastSyncHash === currentHash) {
164
- writeBanner(cwd, null);
165
- return;
166
- }
167
-
168
- const banner = buildStaleDocsBanner(currentHash, lastSyncHash);
169
- writeBanner(cwd, banner);
170
- console.error(banner);
171
- } catch (error) {
172
- logCrash(error);
173
- }
174
- },
175
- });
@@ -1,164 +0,0 @@
1
- /**
2
- * Copyright (c) 2026 Haposoft. MIT License.
3
- *
4
- * OpenCode Plugin — inspect-block.ts
5
- *
6
- * Port of .claude/hooks/inspect-block.cjs.
7
- *
8
- * Behavior:
9
- * - Block reads/writes/globs/greps that target heavy directories (node_modules,
10
- * .git, dist, build, .next, .venv, vendor, target, coverage, ...).
11
- * - Block excessively broad glob patterns (e.g. **\/* alone).
12
- * - Allow approved build/package-manager bash commands.
13
- *
14
- * Disable via `.opencode/runtime.json` { "inspect": { "enabled": false } }
15
- * or `.opencode/runtime.json` { "scout": { "enabled": false } }.
16
- */
17
-
18
- import type { Plugin } from "@opencode-ai/plugin";
19
- import { existsSync, readFileSync, mkdirSync, appendFileSync } from "node:fs";
20
- import { join, dirname } from "node:path";
21
- import { fileURLToPath } from "node:url";
22
-
23
- const BLOCKED_DIRS = [
24
- "node_modules",
25
- "dist",
26
- "build",
27
- ".next",
28
- ".nuxt",
29
- ".output",
30
- "__pycache__",
31
- ".venv",
32
- "venv",
33
- ".env",
34
- "vendor",
35
- "target",
36
- ".git",
37
- "coverage",
38
- ".nyc_output",
39
- ];
40
-
41
- const BROAD_GLOB = [/^\*\*\/\*$/, /^\*\*\.\w+$/, /^\*\*\/\*\.\w+$/];
42
-
43
- const ALLOWED_CMD =
44
- /^(npm|pnpm|yarn|bun|npx|pnpx|bunx|tsc|vite|esbuild|webpack|rollup|turbo|nx|jest|vitest|eslint|prettier|go|cargo|make|mvn|gradle|dotnet|docker|kubectl|helm|python3?|pip|uv|deno|bundle|rake|php|composer|ruby|mix)\b/;
45
- const VENV_EXEC = /(^|[\/\\])\.?venv[\/\\](bin|Scripts)[\/\\]/;
46
- const VENV_CREATE = /^(python3?|py)\s+.*-m\s+venv\s+|^uv\s+venv(\s|$)/;
47
-
48
- const GATED_TOOLS = new Set(["read", "write", "edit", "glob", "grep", "bash"]);
49
-
50
- const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
51
-
52
- function isAllowedCommand(cmd: string): boolean {
53
- const stripped = cmd
54
- .trim()
55
- .replace(/^(\w+=\S+\s+)+/, "")
56
- .replace(/^(sudo|env|time)\s+/, "")
57
- .trim();
58
- return ALLOWED_CMD.test(stripped) || VENV_EXEC.test(stripped) || VENV_CREATE.test(stripped);
59
- }
60
-
61
- function isBlockedPath(p: string): string | null {
62
- const segments = p.replace(/\\/g, "/").split("/");
63
- return segments.find((seg) => BLOCKED_DIRS.includes(seg)) ?? null;
64
- }
65
-
66
- function isBroadGlob(p: string): boolean {
67
- return BROAD_GLOB.some((rule) => rule.test(p.trim()));
68
- }
69
-
70
- function extractPaths(tool: string, args: Record<string, unknown>): string[] {
71
- const out: string[] = [];
72
- if (!args) return out;
73
-
74
- if (typeof args.filePath === "string") out.push(args.filePath);
75
- if (typeof args.path === "string") out.push(args.path);
76
- if (typeof args.pattern === "string" && tool === "glob") out.push(args.pattern);
77
-
78
- if (tool === "bash" && typeof args.command === "string") {
79
- const matches = args.command.match(/(?:cat|ls|find|head|tail)\s+(\S+)/g);
80
- if (matches) {
81
- for (const m of matches) {
82
- const tail = m.trim().split(/\s+/).pop();
83
- if (tail) out.push(tail);
84
- }
85
- }
86
- }
87
-
88
- return out.filter(Boolean);
89
- }
90
-
91
- function readRuntime(cwd: string): Record<string, any> {
92
- try {
93
- const file = join(cwd, ".opencode", "runtime.json");
94
- return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
95
- } catch {
96
- return {};
97
- }
98
- }
99
-
100
- function logCrash(error: unknown): void {
101
- try {
102
- const logDir = join(PLUGIN_DIR, ".logs");
103
- if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
104
- appendFileSync(
105
- join(logDir, "hook-log.jsonl"),
106
- JSON.stringify({
107
- ts: new Date().toISOString(),
108
- hook: "inspect-block",
109
- status: "crash",
110
- error: error instanceof Error ? error.message : String(error),
111
- }) + "\n",
112
- );
113
- } catch {
114
- // fail-open
115
- }
116
- }
117
-
118
- export const InspectBlock: Plugin = async ({ directory }) => ({
119
- "tool.execute.before": async (input, output) => {
120
- try {
121
- if (!GATED_TOOLS.has(input.tool)) return;
122
-
123
- const runtime = readRuntime(directory);
124
- if (runtime.scout?.enabled === false || runtime.inspect?.enabled === false) return;
125
-
126
- const args = (output.args ?? {}) as Record<string, unknown>;
127
-
128
- if (input.tool === "bash" && typeof args.command === "string") {
129
- const cmds = args.command.split(/\s*(?:&&|\|\||;)\s*/).filter(Boolean);
130
- if (cmds.every((c) => isAllowedCommand(c))) return;
131
- }
132
-
133
- if (input.tool === "glob" && typeof args.pattern === "string" && isBroadGlob(args.pattern)) {
134
- throw new Error(
135
- [
136
- "SCOPE LIMIT EXCEEDED: Glob pattern is excessively broad",
137
- `Requested Pattern: ${args.pattern}`,
138
- "",
139
- "Please narrow your scope (e.g., src/**/*.ts rather than **/*.ts).",
140
- ].join("\n"),
141
- );
142
- }
143
-
144
- const paths = extractPaths(input.tool, args);
145
- for (const p of paths) {
146
- const blocked = isBlockedPath(p);
147
- if (blocked) {
148
- throw new Error(
149
- [
150
- `SCOPE LIMIT EXCEEDED: Directory "${blocked}/" is explicitly forbidden`,
151
- `Requested Path: ${p}`,
152
- `Restricted zones: ${BLOCKED_DIRS.join(", ")}`,
153
- ].join("\n"),
154
- );
155
- }
156
- }
157
- } catch (error) {
158
- if (error instanceof Error && error.message.startsWith("SCOPE LIMIT EXCEEDED")) {
159
- throw error;
160
- }
161
- logCrash(error);
162
- }
163
- },
164
- });
@@ -1,182 +0,0 @@
1
- /**
2
- * Copyright (c) 2026 Haposoft. MIT License.
3
- *
4
- * OpenCode Plugin — privacy-block.ts
5
- *
6
- * Port of .claude/hooks/privacy-block.cjs to OpenCode plugin protocol.
7
- *
8
- * Behavior:
9
- * - Match tool input paths against RESTRICTED_PATTERNS.
10
- * - For `bash`: log warning and allow (caller may have user approval already).
11
- * - For other tools: throw with @@PRIVACY_PROMPT_START@@ marker payload so the
12
- * assistant prompts the user before retrying via bash cat.
13
- *
14
- * Disable via `.opencode/runtime.json` { "privacyBlock": false }.
15
- */
16
-
17
- import type { Plugin } from "@opencode-ai/plugin";
18
- import { existsSync, readFileSync, mkdirSync, appendFileSync } from "node:fs";
19
- import { join, basename, dirname } from "node:path";
20
- import { fileURLToPath } from "node:url";
21
-
22
- const RESTRICTED_PATTERNS: RegExp[] = [
23
- /^\.env(\.|$)/i,
24
- /^credentials/i,
25
- /secrets?\.(ya?ml|json)$/i,
26
- /\.pem$/i,
27
- /\.key$/i,
28
- /\.p12$/i,
29
- /\.pfx$/i,
30
- /^id_(rsa|ed25519|ecdsa|dsa)$/i,
31
- /\.netrc$/i,
32
- /\.pgpass$/i,
33
- /kubeconfig/i,
34
- /\.keystore$/i,
35
- /\.jks$/i,
36
- /auth\.json$/i,
37
- /token(s)?\.json$/i,
38
- ];
39
-
40
- const ALLOWED_EXEMPTIONS: RegExp[] = [/\.env\.(example|sample|template|test)$/i];
41
-
42
- const GATED_TOOLS = new Set(["read", "write", "edit", "glob", "grep", "bash"]);
43
-
44
- const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
45
-
46
- function readRuntime(cwd: string): Record<string, unknown> {
47
- try {
48
- const file = join(cwd, ".opencode", "runtime.json");
49
- return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
50
- } catch {
51
- return {};
52
- }
53
- }
54
-
55
- function isSafe(filePath: string): boolean {
56
- const base = basename(filePath);
57
- return ALLOWED_EXEMPTIONS.some((rule) => rule.test(base) || rule.test(filePath));
58
- }
59
-
60
- function isSensitive(filePath: string): boolean {
61
- const base = basename(filePath);
62
- return RESTRICTED_PATTERNS.some((rule) => rule.test(base) || rule.test(filePath));
63
- }
64
-
65
- function extractBashPaths(command: string): string[] {
66
- const paths: string[] = [];
67
- const regex = /(?:cat|less|more|head|tail|source|\.)\s+(?:"([^"]+)"|'([^']+)'|([^\s]+))/g;
68
- let match: RegExpExecArray | null;
69
- while ((match = regex.exec(command)) !== null) {
70
- const value = match[1] || match[2] || match[3];
71
- if (value) paths.push(value);
72
- }
73
- return paths;
74
- }
75
-
76
- function extractPaths(tool: string, args: Record<string, unknown>): string[] {
77
- const paths: string[] = [];
78
- if (!args) return paths;
79
-
80
- const filePath = typeof args.filePath === "string" ? args.filePath.trim() : "";
81
- if (filePath) paths.push(filePath);
82
-
83
- const path = typeof args.path === "string" ? args.path.trim() : "";
84
- if (path) paths.push(path);
85
-
86
- if (tool === "bash" && typeof args.command === "string") {
87
- paths.push(...extractBashPaths(args.command));
88
- }
89
-
90
- return paths.filter(Boolean);
91
- }
92
-
93
- function formatBlockMessage(filePath: string): string {
94
- const fileBase = basename(filePath);
95
- const promptData = {
96
- type: "PRIVACY_PROMPT",
97
- file: filePath,
98
- basename: fileBase,
99
- question: {
100
- header: "File Access",
101
- text: `I need to read "${fileBase}" which may contain sensitive data (API keys, passwords, tokens). Do you approve?`,
102
- options: [
103
- {
104
- label: "Yes, approve access",
105
- description: `Allow reading ${fileBase} this time`,
106
- },
107
- {
108
- label: "No, skip this file",
109
- description: "Continue without accessing this file",
110
- },
111
- ],
112
- },
113
- };
114
-
115
- return [
116
- "NOTE: This is not an error. This block protects sensitive data.",
117
- "",
118
- "PRIVACY BLOCK: Sensitive file access requires user approval",
119
- `File: ${filePath}`,
120
- "",
121
- "@@PRIVACY_PROMPT_START@@",
122
- JSON.stringify(promptData, null, 2),
123
- "@@PRIVACY_PROMPT_END@@",
124
- "",
125
- "OpenCode follow-up:",
126
- `- If approved: use bash to read: cat "${filePath}"`,
127
- "- If denied: continue without this file",
128
- ].join("\n");
129
- }
130
-
131
- function logCrash(error: unknown): void {
132
- try {
133
- const logDir = join(PLUGIN_DIR, ".logs");
134
- if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
135
- appendFileSync(
136
- join(logDir, "hook-log.jsonl"),
137
- JSON.stringify({
138
- ts: new Date().toISOString(),
139
- hook: "privacy-block",
140
- status: "crash",
141
- error: error instanceof Error ? error.message : String(error),
142
- }) + "\n",
143
- );
144
- } catch {
145
- // fail-open
146
- }
147
- }
148
-
149
- export const PrivacyBlock: Plugin = async ({ directory }) => ({
150
- "tool.execute.before": async (input, output) => {
151
- try {
152
- if (!GATED_TOOLS.has(input.tool)) return;
153
-
154
- const runtime = readRuntime(directory);
155
- if (runtime.privacyBlock === false) return;
156
-
157
- const args = (output.args ?? {}) as Record<string, unknown>;
158
- const paths = extractPaths(input.tool, args);
159
- if (paths.length === 0) return;
160
-
161
- for (const filePath of paths) {
162
- if (isSafe(filePath)) continue;
163
- if (!isSensitive(filePath)) continue;
164
-
165
- if (input.tool === "bash") {
166
- console.error(
167
- `WARN: Privacy-sensitive file access via bash allowed for approved follow-up: ${basename(filePath)}`,
168
- );
169
- return;
170
- }
171
-
172
- throw new Error(formatBlockMessage(filePath));
173
- }
174
- } catch (error) {
175
- // Re-throw the privacy block error (intentional); only swallow programming errors.
176
- if (error instanceof Error && error.message.includes("@@PRIVACY_PROMPT_START@@")) {
177
- throw error;
178
- }
179
- logCrash(error);
180
- }
181
- },
182
- });
@@ -1,155 +0,0 @@
1
- /**
2
- * Copyright (c) 2026 Haposoft. MIT License.
3
- *
4
- * OpenCode Plugin — rules.ts
5
- *
6
- * Port of .claude/hooks/rules.cjs adapted to OpenCode.
7
- *
8
- * Claude's version injects a per-prompt rules reminder via stdout into
9
- * the UserPromptSubmit hook. OpenCode has no equivalent per-prompt
10
- * injection channel, so this plugin renders the same dynamic rules
11
- * (paths, docs.maxLoc, locale) on every session.created and upserts a
12
- * managed block inside the project AGENTS.md. OpenCode auto-loads
13
- * AGENTS.md as instructions on each session, so the rules reach the
14
- * model context at session start instead of per prompt.
15
- *
16
- * Static rules content already lives in AGENTS.md base template — this
17
- * plugin only refreshes the dynamic block so paths/locale stay in sync
18
- * with runtime.json.
19
- */
20
-
21
- import type { Plugin } from "@opencode-ai/plugin";
22
- import {
23
- createHash,
24
- } from "node:crypto";
25
- import {
26
- existsSync,
27
- mkdirSync,
28
- readFileSync,
29
- writeFileSync,
30
- } from "node:fs";
31
- import { join, dirname } from "node:path";
32
- import { fileURLToPath } from "node:url";
33
-
34
- const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
35
- const RULES_BLOCK_START = "<!-- CAFEKIT DYNAMIC RULES START -->";
36
- const RULES_BLOCK_END = "<!-- CAFEKIT DYNAMIC RULES END -->";
37
-
38
- function resolveProjectDir(directory?: string): string {
39
- if (directory && existsSync(directory)) return directory;
40
- return dirname(dirname(PLUGIN_DIR));
41
- }
42
-
43
- function readRuntime(cwd: string): Record<string, any> | null {
44
- try {
45
- const file = join(cwd, ".opencode", "runtime.json");
46
- if (existsSync(file)) {
47
- return JSON.parse(readFileSync(file, "utf8"));
48
- }
49
- } catch {
50
- /* fail-open */
51
- return null;
52
- }
53
- return null;
54
- }
55
-
56
- function reserveSession(cwd: string, sessionId?: string): boolean {
57
- if (!sessionId) return false;
58
- try {
59
- const key = createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
60
- const file = join(cwd, ".opencode", "plugins", ".logs", `rules-${key}.json`);
61
- mkdirSync(join(cwd, ".opencode", "plugins", ".logs"), { recursive: true });
62
- writeFileSync(file, JSON.stringify({ sessionId }), { encoding: "utf8", flag: "wx", mode: 0o600 });
63
- return true;
64
- } catch {
65
- return false;
66
- }
67
- }
68
-
69
- function renderDynamicRules(cwd: string): string | null {
70
- const runtime = readRuntime(cwd);
71
- if (runtime === null) return null;
72
- const thinkLang: string = runtime?.locale?.thinkingLanguage || "";
73
- const respondLang: string = runtime?.locale?.responseLanguage || "";
74
- const effectThink = thinkLang || (respondLang ? "en" : "");
75
- const plansPath = join(cwd, runtime?.paths?.plans || "plans");
76
- const docsPath = join(cwd, runtime?.paths?.docs || "docs");
77
- const maxLoc: number = runtime?.docs?.maxLoc || 800;
78
-
79
- const lines: string[] = [];
80
- lines.push(RULES_BLOCK_START);
81
- lines.push("");
82
- lines.push("## CafeKit Session Rules (auto-refreshed)");
83
- lines.push("");
84
-
85
- const hasThink = effectThink && effectThink !== respondLang;
86
- if (hasThink || respondLang) {
87
- lines.push("### Language");
88
- if (hasThink) lines.push(`- Thinking: Use ${effectThink} for reasoning.`);
89
- if (respondLang) lines.push(`- Response: Respond in ${respondLang}.`);
90
- lines.push("");
91
- }
92
-
93
- lines.push("### Rules");
94
- lines.push(`- Markdown files: Plans → "${plansPath}/" | Docs → "${docsPath}/"`);
95
- lines.push(
96
- "- **DO NOT** create markdown files outside of those directories unless explicitly asked.",
97
- );
98
- lines.push(`- docs.maxLoc: ${maxLoc} lines max per doc file`);
99
- lines.push("");
100
- lines.push(RULES_BLOCK_END);
101
-
102
- return lines.join("\n");
103
- }
104
-
105
- function upsertBlock(filePath: string, block: string): boolean {
106
- if (!existsSync(filePath)) return false;
107
- let content: string;
108
- try {
109
- content = readFileSync(filePath, "utf8");
110
- } catch {
111
- return false;
112
- }
113
-
114
- const startIdx = content.indexOf(RULES_BLOCK_START);
115
- const endIdx = content.indexOf(RULES_BLOCK_END);
116
-
117
- let next: string;
118
- if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
119
- const before = content.slice(0, startIdx);
120
- const after = content.slice(endIdx + RULES_BLOCK_END.length);
121
- next = `${before}${block}${after}`;
122
- } else {
123
- const sep = content.endsWith("\n") ? "" : "\n";
124
- next = `${content}${sep}\n${block}\n`;
125
- }
126
-
127
- if (next === content) return false;
128
- try {
129
- writeFileSync(filePath, next);
130
- return true;
131
- } catch {
132
- return false;
133
- }
134
- }
135
-
136
- export const RulesPlugin: Plugin = async ({ directory }) => {
137
- const cwd = resolveProjectDir(directory);
138
-
139
- const refresh = (sessionId?: string) => {
140
- if (!reserveSession(cwd, sessionId)) return;
141
- const agentsFile = join(cwd, "AGENTS.md");
142
- const block = renderDynamicRules(cwd);
143
- if (block) upsertBlock(agentsFile, block);
144
- };
145
-
146
- return {
147
- event: async ({ event }) => {
148
- if (event.type === "session.created") {
149
- refresh(event.properties?.info?.id);
150
- }
151
- },
152
- };
153
- };
154
-
155
- export default RulesPlugin;