@haposoft/cafekit 0.13.4 → 0.14.0

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 (119) hide show
  1. package/README.md +2 -23
  2. package/bin/lib/i18n.js +2 -2
  3. package/bin/lib/manifest.js +15 -0
  4. package/bin/lib/opencode-install.js +0 -14
  5. package/bin/phases/claude-runtime.js +4 -1
  6. package/bin/phases/claude-settings.js +38 -7
  7. package/bin/phases/root-config.js +5 -24
  8. package/package.json +1 -1
  9. package/src/claude/agents/ui-ux-designer.md +4 -4
  10. package/src/claude/gitignore +4 -11
  11. package/src/claude/hooks/__tests__/privacy-block.test.js +79 -0
  12. package/src/claude/hooks/__tests__/session-writeenv.test.js +68 -0
  13. package/src/claude/hooks/__tests__/spec-gate.test.js +270 -0
  14. package/src/claude/hooks/privacy-block.cjs +22 -2
  15. package/src/claude/hooks/session.cjs +5 -1
  16. package/src/claude/hooks/spec-gate.cjs +173 -0
  17. package/src/claude/hooks/spec-state.cjs +10 -17
  18. package/src/claude/hooks/task-scaffold-guard.cjs +4 -2
  19. package/src/claude/migration-manifest.json +5 -5
  20. package/src/claude/rules/skill-domain-routing.md +2 -3
  21. package/src/claude/rules/skill-workflow-routing.md +2 -11
  22. package/src/claude/rules/state-sync.md +4 -0
  23. package/src/claude/runtime.json +4 -0
  24. package/src/claude/scripts/validate-docs.cjs +4 -7
  25. package/src/claude/scripts/validate-spec-output.cjs +29 -9
  26. package/src/claude/settings/settings.json +4 -0
  27. package/src/claude/skills/ai-multimodal/.env.example +11 -13
  28. package/src/claude/skills/ai-multimodal/SKILL.md +1 -1
  29. package/src/claude/skills/ai-multimodal/references/audio-processing.md +25 -27
  30. package/src/claude/skills/ai-multimodal/references/video-analysis.md +23 -27
  31. package/src/claude/skills/ai-multimodal/references/vision-understanding.md +26 -26
  32. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +1 -1
  33. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +7 -9
  34. package/src/claude/skills/brainstorm/SKILL.md +1 -2
  35. package/src/claude/skills/docx/SKILL.md +1 -1
  36. package/src/claude/skills/frontend-design/SKILL.md +1 -1
  37. package/src/claude/skills/frontend-design/references/ai-multimodal-overview.md +4 -4
  38. package/src/claude/skills/frontend-design/references/analysis-techniques.md +3 -3
  39. package/src/claude/skills/frontend-design/references/asset-generation.md +2 -2
  40. package/src/claude/skills/frontend-design/references/design-extraction-overview.md +4 -4
  41. package/src/claude/skills/frontend-design/references/extraction-best-practices.md +3 -3
  42. package/src/claude/skills/frontend-design/references/technical-accessibility.md +1 -1
  43. package/src/claude/skills/frontend-design/references/technical-optimization.md +1 -1
  44. package/src/claude/skills/frontend-design/references/technical-overview.md +2 -2
  45. package/src/claude/skills/frontend-design/references/technical-workflows.md +6 -6
  46. package/src/claude/skills/frontend-design/references/visual-analysis-overview.md +3 -3
  47. package/src/claude/skills/inspect/SKILL.md +3 -3
  48. package/src/claude/skills/inspect/references/external-gemini-inspection.md +9 -9
  49. package/src/claude/skills/pdf/SKILL.md +1 -1
  50. package/src/claude/skills/pptx/SKILL.md +1 -1
  51. package/src/claude/skills/specs/SKILL.md +1 -1
  52. package/src/claude/skills/xlsx/SKILL.md +1 -1
  53. package/src/opencode/AGENTS.md +1 -2
  54. package/src/opencode/plugins/session.ts +2 -2
  55. package/src/opencode/plugins/spec-state.ts +244 -0
  56. package/src/opencode/plugins/task-scaffold-guard.ts +161 -0
  57. package/src/claude/scripts/browser-tool.cjs +0 -138
  58. package/src/claude/skills/delegate/SKILL.md +0 -124
  59. package/src/claude/skills/delegate/references/codex-delegation.md +0 -71
  60. package/src/claude/skills/delegate/references/grok-delegation.md +0 -84
  61. package/src/claude/skills/generate-graph/LICENSE +0 -21
  62. package/src/claude/skills/generate-graph/README.md +0 -523
  63. package/src/claude/skills/generate-graph/SKILL.md +0 -429
  64. package/src/claude/skills/generate-graph/agentloop-core.svg +0 -101
  65. package/src/claude/skills/generate-graph/agents/openai.yaml +0 -4
  66. package/src/claude/skills/generate-graph/assets/samples/sample-style1-flat.png +0 -0
  67. package/src/claude/skills/generate-graph/assets/samples/sample-style2-dark.png +0 -0
  68. package/src/claude/skills/generate-graph/assets/samples/sample-style3-blueprint.png +0 -0
  69. package/src/claude/skills/generate-graph/assets/samples/sample-style4-notion.png +0 -0
  70. package/src/claude/skills/generate-graph/assets/samples/sample-style5-glass.png +0 -0
  71. package/src/claude/skills/generate-graph/assets/samples/sample-style6-claude.png +0 -0
  72. package/src/claude/skills/generate-graph/assets/samples/sample-style7-openai.png +0 -0
  73. package/src/claude/skills/generate-graph/fixtures/agent-memory-types-style4.json +0 -181
  74. package/src/claude/skills/generate-graph/fixtures/api-flow-style7.json +0 -40
  75. package/src/claude/skills/generate-graph/fixtures/mem0-style1.json +0 -297
  76. package/src/claude/skills/generate-graph/fixtures/microservices-style3.json +0 -64
  77. package/src/claude/skills/generate-graph/fixtures/multi-agent-style5.json +0 -45
  78. package/src/claude/skills/generate-graph/fixtures/system-architecture-style6.json +0 -48
  79. package/src/claude/skills/generate-graph/fixtures/tool-call-style2.json +0 -182
  80. package/src/claude/skills/generate-graph/package.json +0 -42
  81. package/src/claude/skills/generate-graph/references/icons.md +0 -281
  82. package/src/claude/skills/generate-graph/references/style-1-flat-icon.md +0 -108
  83. package/src/claude/skills/generate-graph/references/style-2-dark-terminal.md +0 -107
  84. package/src/claude/skills/generate-graph/references/style-3-blueprint.md +0 -113
  85. package/src/claude/skills/generate-graph/references/style-4-notion-clean.md +0 -94
  86. package/src/claude/skills/generate-graph/references/style-5-glassmorphism.md +0 -125
  87. package/src/claude/skills/generate-graph/references/style-6-claude-official.md +0 -209
  88. package/src/claude/skills/generate-graph/references/style-7-openai.md +0 -215
  89. package/src/claude/skills/generate-graph/references/style-diagram-matrix.md +0 -135
  90. package/src/claude/skills/generate-graph/references/svg-layout-best-practices.md +0 -100
  91. package/src/claude/skills/generate-graph/scripts/generate-diagram.sh +0 -157
  92. package/src/claude/skills/generate-graph/scripts/generate-from-template.py +0 -1556
  93. package/src/claude/skills/generate-graph/scripts/test-all-styles.sh +0 -135
  94. package/src/claude/skills/generate-graph/scripts/validate-svg.sh +0 -292
  95. package/src/claude/skills/generate-graph/templates/agent-architecture.svg +0 -28
  96. package/src/claude/skills/generate-graph/templates/architecture.svg +0 -23
  97. package/src/claude/skills/generate-graph/templates/comparison-matrix.svg +0 -14
  98. package/src/claude/skills/generate-graph/templates/data-flow.svg +0 -28
  99. package/src/claude/skills/generate-graph/templates/er-diagram.svg +0 -21
  100. package/src/claude/skills/generate-graph/templates/flowchart.svg +0 -21
  101. package/src/claude/skills/generate-graph/templates/sequence.svg +0 -20
  102. package/src/claude/skills/generate-graph/templates/state-machine.svg +0 -20
  103. package/src/claude/skills/generate-graph/templates/timeline.svg +0 -19
  104. package/src/claude/skills/generate-graph/templates/use-case.svg +0 -21
  105. package/src/claude/skills/impact-analysis/SKILL.md +0 -276
  106. package/src/claude/skills/impact-analysis/references/change-detection.md +0 -270
  107. package/src/claude/skills/impact-analysis/references/dependency-scouting.md +0 -338
  108. package/src/claude/skills/impact-analysis/references/edge-case-identification.md +0 -439
  109. package/src/claude/skills/impact-analysis/references/industry-techniques.md +0 -695
  110. package/src/claude/skills/impact-analysis/references/practical-techniques-guide.md +0 -753
  111. package/src/claude/skills/impact-analysis/references/project-detection.md +0 -704
  112. package/src/claude/skills/impact-analysis/references/react-native-customization.md +0 -508
  113. package/src/claude/skills/impact-analysis/references/report-template.md +0 -604
  114. package/src/claude/skills/impact-analysis/references/test-scenario-generation.md +0 -459
  115. package/src/claude/skills/impact-analysis/scripts/README.md +0 -476
  116. package/src/claude/skills/impact-analysis/scripts/ast-analyze.js +0 -403
  117. package/src/claude/skills/impact-analysis/scripts/calculate-risk.js +0 -475
  118. package/src/claude/skills/impact-analysis/scripts/find-dependencies.sh +0 -202
  119. package/src/claude/skills/impact-analysis/scripts/run-analysis.sh +0 -312
@@ -7,7 +7,7 @@ when_to_use: "Invoke to read, extract, or generate PDF documents."
7
7
  category: document-skills
8
8
  keywords: [pdf, extract, document, generate]
9
9
  metadata:
10
- author: haposoft
10
+ author: "Anthropic, PBC — adapted by Haposoft"
11
11
  version: "1.0.0"
12
12
  ---
13
13
  # PDF Processing Guide
@@ -7,7 +7,7 @@ when_to_use: "Invoke to read, create, or edit PowerPoint .pptx slides."
7
7
  category: document-skills
8
8
  keywords: [pptx, powerpoint, slides, office]
9
9
  metadata:
10
- author: haposoft
10
+ author: "Anthropic, PBC — adapted by Haposoft"
11
11
  version: "1.0.0"
12
12
  ---
13
13
  # PPTX creation, editing, and analysis
@@ -161,7 +161,7 @@ Load `references/translation-mirror.md`. This is the only language feature in th
161
161
  ### When called WITH `--validate` argument
162
162
 
163
163
  System IMMEDIATELY jumps to **Step 8: Validation Review**.
164
- The system MUST NOT execute Steps 1-8. Instead, load `references/review.md` and follow it **step-by-step**.
164
+ The system MUST NOT execute Steps 1-7. Instead, load `references/review.md` and follow it **step-by-step**.
165
165
 
166
166
  #### `--validate` Guardrails (NON-NEGOTIABLE)
167
167
 
@@ -7,7 +7,7 @@ when_to_use: "Invoke to read, create, or edit Excel .xlsx spreadsheets."
7
7
  category: document-skills
8
8
  keywords: [xlsx, excel, spreadsheet, office]
9
9
  metadata:
10
- author: haposoft
10
+ author: "Anthropic, PBC — adapted by Haposoft"
11
11
  version: "1.0.0"
12
12
  ---
13
13
  # Requirements for Outputs
@@ -25,7 +25,6 @@ Use these OpenCode commands:
25
25
  - `/hotfix <issue>`: apply a narrow, scout-first production fix.
26
26
  - `/docs [--init|--update|--summarize|--reconstruct]`: maintain or reconstruct project docs.
27
27
  - `/inspect <target>`: inspect source, artifacts, or external context.
28
- - `/generate-graph <request>`: generate technical diagrams.
29
28
 
30
29
  The underlying CafeKit skills are still named `hapo:*` in their documentation. In OpenCode, use the commands above; each command reads the matching skill from `.opencode/skills/<skill>/SKILL.md`.
31
30
 
@@ -82,7 +81,7 @@ Use this loop for non-trivial work:
82
81
 
83
82
  - Claude Code hooks/statusline/settings do not run in OpenCode.
84
83
  - Treat `.claude/rules/*` as explicit reference material, not injected hook context.
85
- - When a skill says to use unavailable Claude-specific tools such as `Task`, `TodoWrite`, or `AskUserQuestion`, use OpenCode's agent/subtask flow, a concise markdown checklist, or ask the user directly.
84
+ - Map Claude-specific tools to OpenCode built-ins: `TodoWrite` → the `todowrite` tool; `AskUserQuestion` → the `question` tool; `Task` → OpenCode's agent/subtask flow.
86
85
  - Privacy and inspect gates are instruction-level gates in OpenCode unless a project-specific OpenCode plugin adds hard enforcement.
87
86
 
88
87
  ## Operating Discipline
@@ -153,9 +153,9 @@ function handleSessionCreated(cwd: string): void {
153
153
  function handleSessionCompacted(cwd: string): void {
154
154
  const lines = [
155
155
  "🚨 SESSION COMPRESSED — VERIFY PENDING AUTHORIZATIONS:",
156
- "Any pending confirmations requested via AskUserQuestion might have been lost.",
156
+ "Any pending confirmations requested via the question tool might have been lost.",
157
157
  "Do not proceed without explicitly asking the user again to ensure safety.",
158
- 'Use AskUserQuestion: "The chat context was compressed. Do I still have permission to proceed?"',
158
+ 'Use the question tool: "The chat context was compressed. Do I still have permission to proceed?"',
159
159
  ];
160
160
  writeBannerSection(cwd, lines.join("\n"));
161
161
  console.error(lines.join("\n"));
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Copyright (c) 2026 Haposoft. MIT License.
3
+ *
4
+ * OpenCode Plugin — spec-state.ts
5
+ *
6
+ * Port of .claude/hooks/spec-state.cjs to OpenCode plugin protocol.
7
+ *
8
+ * Behavior:
9
+ * - On chat.message (before the model processes the user turn), scan
10
+ * project specs dirs for the first in_progress / in-progress spec.json.
11
+ * - Inject a tollgate reminder into the turn by pushing a text part onto
12
+ * output.parts (OpenCode equivalent of Claude UserPromptSubmit stdout inject).
13
+ * - State-change gate: fingerprint phase|done/total in plugins/.logs/tollgate-last.txt;
14
+ * unchanged -> one-line reminder; changed -> full URGENT block + refresh fingerprint.
15
+ *
16
+ * Disable via `.opencode/runtime.json` { "spec": { "tollgate": false } }.
17
+ * Fail-open: never throw — a tollgate crash must not break the turn.
18
+ */
19
+
20
+ import type { Plugin } from "@opencode-ai/plugin";
21
+ import {
22
+ existsSync,
23
+ readFileSync,
24
+ writeFileSync,
25
+ mkdirSync,
26
+ appendFileSync,
27
+ readdirSync,
28
+ } from "node:fs";
29
+ import { join, dirname } from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+
32
+ const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
33
+ const CACHE_FILE = join(PLUGIN_DIR, ".logs", "tollgate-last.txt");
34
+
35
+ type SpecJson = {
36
+ status?: string;
37
+ current_phase?: string;
38
+ phase?: string;
39
+ task_registry?: Record<
40
+ string,
41
+ {
42
+ status?: string;
43
+ dependencies?: string[];
44
+ }
45
+ >;
46
+ };
47
+
48
+ function readRuntime(cwd: string): Record<string, unknown> {
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 logCrash(error: unknown): void {
58
+ try {
59
+ const logDir = join(PLUGIN_DIR, ".logs");
60
+ if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
61
+ appendFileSync(
62
+ join(logDir, "hook-log.jsonl"),
63
+ JSON.stringify({
64
+ ts: new Date().toISOString(),
65
+ hook: "spec-state",
66
+ status: "crash",
67
+ error: error instanceof Error ? error.message : String(error),
68
+ }) + "\n",
69
+ );
70
+ } catch {
71
+ // fail-open
72
+ }
73
+ }
74
+
75
+ function findActiveSpec(
76
+ specsPath: string,
77
+ ): { featureName: string; activeSpec: SpecJson } | null {
78
+ if (!existsSync(specsPath)) return null;
79
+
80
+ let entries;
81
+ try {
82
+ entries = readdirSync(specsPath, { withFileTypes: true });
83
+ } catch {
84
+ return null;
85
+ }
86
+
87
+ for (const entry of entries) {
88
+ if (!entry.isDirectory()) continue;
89
+ const specFile = join(specsPath, entry.name, "spec.json");
90
+ if (!existsSync(specFile)) continue;
91
+ try {
92
+ const specData = JSON.parse(readFileSync(specFile, "utf8")) as SpecJson;
93
+ if (specData.status === "in_progress" || specData.status === "in-progress") {
94
+ return { featureName: entry.name, activeSpec: specData };
95
+ }
96
+ } catch {
97
+ // skip bad JSON
98
+ }
99
+ }
100
+ return null;
101
+ }
102
+
103
+ function buildTollgateText(
104
+ featureName: string,
105
+ activeSpec: SpecJson,
106
+ ): { stateKey: string; fullBlock: string; reminder: string } {
107
+ const phase = activeSpec.current_phase || activeSpec.phase || "unknown";
108
+ const taskRegistry = activeSpec.task_registry || {};
109
+ const taskEntries = Object.entries(taskRegistry);
110
+ const taskCounts = taskEntries.reduce<Record<string, number>>((acc, [, task]) => {
111
+ const status = task?.status || "pending";
112
+ acc[status] = (acc[status] || 0) + 1;
113
+ return acc;
114
+ }, {});
115
+ const taskStatusByPath = new Map(
116
+ taskEntries.map(([taskPath, task]) => [taskPath, task?.status || "pending"]),
117
+ );
118
+ const nextUnblocked = taskEntries.find(([, task]) => {
119
+ const status = task?.status || "pending";
120
+ const deps = Array.isArray(task?.dependencies) ? task.dependencies : [];
121
+ return status === "pending" && deps.every((dep) => taskStatusByPath.get(dep) === "done");
122
+ });
123
+
124
+ const done = taskCounts.done || 0;
125
+ const total = taskEntries.length;
126
+ const stateKey = `${phase}|${done}/${total}`;
127
+
128
+ const reminder =
129
+ `\n> 🔵 Spec \`${featureName}\` @ \`${phase}\` (${done}/${total} tasks done). ` +
130
+ "Tollgate active — sync `spec.json` when state changes.\n";
131
+
132
+ const lines: string[] = [
133
+ "",
134
+ "### 🔴 URGENT SYSTEM TOLLGATE (STATE SYNC) 🔴",
135
+ `- **Active Feature:** \`${featureName}\``,
136
+ `- **Current Phase:** \`${phase}\``,
137
+ ];
138
+ if (total > 0) {
139
+ lines.push(
140
+ `- **Task Registry:** \`${total} total | ${done} done | ${taskCounts.in_progress || 0} in_progress | ${taskCounts.blocked || 0} blocked | ${taskCounts.pending || 0} pending\``,
141
+ );
142
+ if (nextUnblocked) {
143
+ lines.push(`- **Next Unblocked Task:** \`${nextUnblocked[0]}\``);
144
+ }
145
+ }
146
+ lines.push("");
147
+ lines.push(
148
+ "> BẮT BUỘC (MANDATORY): Nếu bạn vừa hoàn thành một bước, bạn KHÔNG ĐƯỢC báo cáo \"Đã xong\" ngay.",
149
+ );
150
+ lines.push(
151
+ "> Bạn PHẢI sử dụng công cụ Edit để cập nhật trạng thái vật lý sau khi đã có bằng chứng verify thật (build/test/runtime/artifact), không phải chỉ vì code đã viết xong.",
152
+ );
153
+ lines.push(
154
+ "> 1. Sửa file `spec.json` (status, phase/current_phase, timestamps, `task_files`, `task_registry`, validation state nếu có thay đổi).",
155
+ );
156
+ lines.push(
157
+ "> 2. Chỉ khi verify xong mới sửa file `tasks/task-*.md` (status + tick '[x]' các sub-task và completion criteria liên quan).",
158
+ );
159
+ lines.push(
160
+ `> 3. Trước khi set \`ready_for_implementation = true\`, PHẢI chạy \`node .opencode/scripts/validate-spec-output.cjs specs/${featureName}\` và sửa mọi lỗi.`,
161
+ );
162
+ lines.push(
163
+ "> 4. NẾU VỪA HOÀN THÀNH 1 TASK CÓ SỬA SOURCE CODE, BẮT BUỘC cập nhật ngay tài liệu trong `docs/` (`system-architecture.md` hoặc Changelog) cho đồng bộ.",
164
+ );
165
+ lines.push(
166
+ "> CẤM VI PHẠM LUẬT TOLLGATE NÀY NHẰM ĐẢM BẢO TÍNH ĐỒNG BỘ CỦA HỆ THỐNG.",
167
+ );
168
+ lines.push("");
169
+
170
+ return { stateKey, fullBlock: lines.join("\n"), reminder };
171
+ }
172
+
173
+ type MessageRef = { id?: string; sessionID?: string };
174
+
175
+ function injectText(
176
+ output: { message?: MessageRef; parts?: unknown[] },
177
+ text: string,
178
+ ): void {
179
+ // OpenCode persists user parts and validates their schema: a part MUST carry
180
+ // id + sessionID + messageID or the whole turn fails with
181
+ // "invalid user part before save" (verified on opencode 1.17.15).
182
+ const message = output.message ?? {};
183
+ if (!Array.isArray(output.parts)) {
184
+ (output as { parts: unknown[] }).parts = [];
185
+ }
186
+ output.parts!.push({
187
+ id: `prt_ck${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`,
188
+ sessionID: message.sessionID ?? "",
189
+ messageID: message.id ?? "",
190
+ type: "text",
191
+ text,
192
+ synthetic: true,
193
+ });
194
+ }
195
+
196
+ export const SpecState: Plugin = async ({ directory }) => ({
197
+ "chat.message": async (_input, output) => {
198
+ try {
199
+ const runtime = readRuntime(directory);
200
+ const spec = runtime.spec as Record<string, unknown> | undefined;
201
+ if (spec?.tollgate === false) return;
202
+
203
+ const paths = runtime.paths as Record<string, unknown> | undefined;
204
+ const specsRel =
205
+ typeof paths?.specs === "string" && paths.specs.trim()
206
+ ? paths.specs.trim()
207
+ : "specs";
208
+ const specsPath = join(directory, specsRel);
209
+
210
+ const found = findActiveSpec(specsPath);
211
+ if (!found) return;
212
+
213
+ const { featureName, activeSpec } = found;
214
+ const { stateKey, fullBlock, reminder } = buildTollgateText(
215
+ featureName,
216
+ activeSpec,
217
+ );
218
+
219
+ let lastKey = "";
220
+ try {
221
+ lastKey = readFileSync(CACHE_FILE, "utf8").trim();
222
+ } catch {
223
+ // first run
224
+ }
225
+
226
+ if (lastKey === stateKey) {
227
+ injectText(output as { message?: MessageRef; parts?: unknown[] }, reminder);
228
+ return;
229
+ }
230
+
231
+ try {
232
+ mkdirSync(dirname(CACHE_FILE), { recursive: true });
233
+ writeFileSync(CACHE_FILE, stateKey);
234
+ } catch {
235
+ // fail-open: still inject full block
236
+ }
237
+
238
+ injectText(output as { message?: MessageRef; parts?: unknown[] }, fullBlock);
239
+ } catch (error) {
240
+ logCrash(error);
241
+ // never throw — tollgate must not break the turn
242
+ }
243
+ },
244
+ });
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Copyright (c) 2026 Haposoft. MIT License.
3
+ *
4
+ * OpenCode Plugin — task-scaffold-guard.ts
5
+ *
6
+ * Port of .claude/hooks/task-scaffold-guard.cjs to OpenCode plugin protocol.
7
+ * Also covers the OpenCode-only `apply_patch` file-creation vector
8
+ * (`*** Add File: <path>` in `output.args.patchText`).
9
+ *
10
+ * Behavior:
11
+ * - Block `write` and `apply_patch` that create `specs/<feature>/tasks/task-*.md`.
12
+ * - Task files must be generated via `spec-scaffold.cjs`, then Edit-filled.
13
+ *
14
+ * Safety valves:
15
+ * 1. Escape hatch: `.opencode/runtime.json` `{ "spec": { "scaffold_guard": false } }`
16
+ * (fail-closed: missing/broken runtime keeps the guard ON).
17
+ * 2. Fail-open if `.opencode/scripts/spec-scaffold.cjs` is absent.
18
+ * 3. Actionable block message with the exact scaffold command.
19
+ */
20
+
21
+ import type { Plugin } from "@opencode-ai/plugin";
22
+ import { existsSync, readFileSync, mkdirSync, appendFileSync } from "node:fs";
23
+ import { join, dirname } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ const TASK_FILE_RE = /(^|\/)specs\/[^/]+\/tasks\/task-[^/]+\.md$/;
27
+ const TASK_FILE_REL_RE = /(?:^|\/)(specs\/[^/]+\/tasks\/task-[^/]+\.md)$/;
28
+ const FEATURE_RE = /(^|\/)specs\/([^/]+)\/tasks\//;
29
+ const ADD_FILE_RE = /^\*\*\* Add File:\s*(.+?)\s*$/gm;
30
+
31
+ const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
32
+
33
+ function readRuntime(cwd: string): Record<string, unknown> {
34
+ try {
35
+ const file = join(cwd, ".opencode", "runtime.json");
36
+ return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
37
+ } catch {
38
+ return {};
39
+ }
40
+ }
41
+
42
+ function isTaskFile(filePath: string): boolean {
43
+ const norm = filePath.replace(/\\/g, "/");
44
+ return TASK_FILE_RE.test(norm);
45
+ }
46
+
47
+ function extractFeature(filePath: string): string {
48
+ const norm = filePath.replace(/\\/g, "/");
49
+ const m = norm.match(FEATURE_RE);
50
+ return m ? m[2] : "<feature>";
51
+ }
52
+
53
+ function pathsFromApplyPatch(patchText: string): string[] {
54
+ const paths: string[] = [];
55
+ let match: RegExpExecArray | null;
56
+ const re = new RegExp(ADD_FILE_RE.source, ADD_FILE_RE.flags);
57
+ while ((match = re.exec(patchText)) !== null) {
58
+ const p = match[1]?.trim();
59
+ if (p) paths.push(p);
60
+ }
61
+ return paths;
62
+ }
63
+
64
+ function collectTargetPaths(tool: string, args: Record<string, unknown>): string[] {
65
+ if (tool === "write") {
66
+ const filePath = typeof args.filePath === "string" ? args.filePath.trim() : "";
67
+ return filePath ? [filePath] : [];
68
+ }
69
+ if (tool === "apply_patch") {
70
+ const patchText = typeof args.patchText === "string" ? args.patchText : "";
71
+ return patchText ? pathsFromApplyPatch(patchText) : [];
72
+ }
73
+ return [];
74
+ }
75
+
76
+ /**
77
+ * Resolve a (possibly sandbox-virtual) tool path to the real on-disk task file.
78
+ * OpenCode may present paths like /home/user/specs/... to the model; the
79
+ * project-relative `specs/...` suffix joined onto the plugin `directory` is
80
+ * the reliable location for existence checks.
81
+ */
82
+ function resolveTaskFile(directory: string, filePath: string): string {
83
+ const norm = filePath.replace(/\\/g, "/");
84
+ const m = norm.match(TASK_FILE_REL_RE);
85
+ return m ? join(directory, m[1]) : filePath;
86
+ }
87
+
88
+ function formatBlockMessage(filePath: string): string {
89
+ const feature = extractFeature(filePath);
90
+ // NOTE: deliberately does NOT mention the runtime.json escape hatch.
91
+ // Smoke-tested on opencode 1.17.15: when the block message advertised the
92
+ // override, the model simply wrote runtime.json and disabled the guard.
93
+ // The hatch stays functional for humans; it is documented in the docs only.
94
+ return [
95
+ "TASK SCAFFOLD REQUIRED: task files must be generated, not hand-written.",
96
+ `Blocked Write: ${filePath}`,
97
+ "",
98
+ "Generate the stub(s), then Edit-fill the {{...}} placeholders:",
99
+ ` node .opencode/scripts/spec-scaffold.cjs ${feature} --tasks "R0-01-slug,R1-01-slug,..." --tasks-only`,
100
+ "Then use Edit (not Write) on each tasks/task-*.md stub.",
101
+ ].join("\n");
102
+ }
103
+
104
+ function logCrash(error: unknown): void {
105
+ try {
106
+ const logDir = join(PLUGIN_DIR, ".logs");
107
+ if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
108
+ appendFileSync(
109
+ join(logDir, "hook-log.jsonl"),
110
+ JSON.stringify({
111
+ ts: new Date().toISOString(),
112
+ hook: "task-scaffold-guard",
113
+ status: "crash",
114
+ error: error instanceof Error ? error.message : String(error),
115
+ }) + "\n",
116
+ );
117
+ } catch {
118
+ // fail-open
119
+ }
120
+ }
121
+
122
+ export const TaskScaffoldGuard: Plugin = async ({ directory }) => ({
123
+ "tool.execute.before": async (input, output) => {
124
+ try {
125
+ if (input.tool !== "write" && input.tool !== "apply_patch" && input.tool !== "edit") return;
126
+
127
+ const runtime = readRuntime(directory);
128
+ const spec = runtime.spec as Record<string, unknown> | undefined;
129
+ if (spec?.scaffold_guard === false) return;
130
+
131
+ const scaffold = join(directory, ".opencode", "scripts", "spec-scaffold.cjs");
132
+ if (!existsSync(scaffold)) return;
133
+
134
+ const args = (output.args ?? {}) as Record<string, unknown>;
135
+
136
+ // OpenCode's `edit` CAN create new files (unlike Claude's Edit, which
137
+ // requires an existing file — the original guard's design premise).
138
+ // Verified on opencode 1.17.15. Gate: edit on a NON-existent task file
139
+ // is creation (block); edit on an existing stub is legitimate filling.
140
+ if (input.tool === "edit") {
141
+ const filePath = typeof args.filePath === "string" ? args.filePath.trim() : "";
142
+ if (filePath && isTaskFile(filePath) && !existsSync(resolveTaskFile(directory, filePath))) {
143
+ throw new Error(formatBlockMessage(filePath));
144
+ }
145
+ return;
146
+ }
147
+
148
+ const paths = collectTargetPaths(input.tool, args);
149
+ for (const filePath of paths) {
150
+ if (!isTaskFile(filePath)) continue;
151
+ throw new Error(formatBlockMessage(filePath));
152
+ }
153
+ } catch (error) {
154
+ // Re-throw intentional scaffold blocks; swallow programming errors.
155
+ if (error instanceof Error && error.message.includes("TASK SCAFFOLD REQUIRED")) {
156
+ throw error;
157
+ }
158
+ logCrash(error);
159
+ }
160
+ },
161
+ });
@@ -1,138 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Hapo Native Browser Multi-Tool (Puppeteer Bundler)
4
- * Gồm: screenshot, aria-snapshot, và console logs. Gọn gàng nén trong 1 script.
5
- *
6
- * Usage:
7
- * node browser-tool.cjs --action screenshot --url http://localhost:3000 --output ./shot.png
8
- * node browser-tool.cjs --action console --url http://localhost:3000
9
- * node browser-tool.cjs --action aria --url http://localhost:3000
10
- */
11
-
12
- const fs = require('fs');
13
- const path = require('path');
14
-
15
- let puppeteer;
16
- try {
17
- puppeteer = require('puppeteer-core');
18
- } catch (e) {
19
- try {
20
- puppeteer = require('puppeteer');
21
- } catch (err) {
22
- console.error('\n[LỖI] Hapo Browser Tool yêu cầu gói thư viện Puppeteer!');
23
- console.error('Do tôn trọng dung lượng nhẹ (Clean-room), Hapo không tự nhồi nhét Puppeteer vào dự án.');
24
- console.error('-> HÃY BẢO LLM (hoặc Dev) CHẠY LỆNH: `npm i -D puppeteer` TRONG PROJECT TRƯỚC KHI DÙNG CÔNG CỤ NÀY!\n');
25
- process.exit(1);
26
- }
27
- }
28
-
29
- // Hàm phân tích Argument đơn giản
30
- function parseArgs() {
31
- const args = process.argv.slice(2);
32
- const config = { action: 'screenshot', url: '', output: './hapo-debug.png' };
33
- for (let i = 0; i < args.length; i++) {
34
- if (args[i] === '--action') config.action = args[++i];
35
- if (args[i] === '--url') config.url = args[++i];
36
- if (args[i] === '--output') config.output = args[++i];
37
- }
38
- return config;
39
- }
40
-
41
- async function getBrowser() {
42
- // Ưu tiên nỗ lực dùng Chromium của máy nếu xài puppeteer-core (Edge / Chrome)
43
- let executablePath = process.env.CHROME_BIN || process.env.PUPPETEER_EXECUTABLE_PATH;
44
-
45
- if (!executablePath && puppeteer.executablePath) {
46
- try {
47
- executablePath = puppeteer.executablePath();
48
- } catch(e) {}
49
- }
50
-
51
- // Fallback map cơ bản cho Mac/Linux
52
- if (!executablePath) {
53
- if (process.platform === 'darwin') executablePath = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
54
- else if (process.platform === 'linux') executablePath = '/usr/bin/google-chrome';
55
- else if (process.platform === 'win32') executablePath = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
56
- }
57
-
58
- console.log(`[Hapo Browser] Khởi tạo trình duyệt tại: ${executablePath}`);
59
-
60
- return puppeteer.launch({
61
- executablePath: executablePath || undefined,
62
- headless: "new",
63
- args: ['--no-sandbox', '--disable-setuid-sandbox']
64
- });
65
- }
66
-
67
- async function takeScreenshot(page, output) {
68
- console.log(`[Hapo Browser] Đang chụp hình dồn vào file: ${output}`);
69
- await fs.promises.mkdir(path.dirname(path.resolve(output)), { recursive: true });
70
- await page.screenshot({ path: output, fullPage: true });
71
- console.log(`✅ [THÀNH CÔNG] Ảnh chụp full-page đã được nướng chín! LLM hãy gửi cho User lệnh mở ảnh nhé.`);
72
- }
73
-
74
- async function captureConsole(page) {
75
- console.log(`[Hapo Browser] Đang rình Console Log trong 5 giây...`);
76
- const logs = [];
77
- page.on('console', msg => {
78
- if (msg.type() === 'error' || msg.type() === 'warning') {
79
- logs.push(`[${msg.type().toUpperCase()}] ${msg.text()}`);
80
- }
81
- });
82
- // Gài thêm bẫy vớt lỗi Page Error (JS Crash)
83
- page.on('pageerror', err => logs.push(`[CRASH] ${err.message}`));
84
-
85
- // Cho thời gian sống 5 giấy để lắng nghe event
86
- await new Promise(r => setTimeout(r, 5000));
87
-
88
- console.log('\n=== KẾT QUẢ RÌNH RẬP DÀNH CHO LLM ===');
89
- if (logs.length === 0) console.log('Sạch bóng quân thù! Không có Warning hay Error nào gào thét trên Console cả.');
90
- else logs.forEach(l => console.log(l));
91
- console.log('=====================================\n');
92
- }
93
-
94
- async function captureAria(page) {
95
- console.log(`[Hapo Browser] Đang quét cấu trúc xẻ thịt HTML (ARIA DOM)...`);
96
- // Truy xuất cây Accessibility đơn giản
97
- const snapshot = await page.accessibility.snapshot();
98
- console.log('\n=== CẤU TRÚC DOM ẨN DƯỚI GÓC NHÌN ARIA ===');
99
- console.log(JSON.stringify(snapshot, null, 2));
100
- console.log('=============================================\n');
101
- }
102
-
103
- async function main() {
104
- const config = parseArgs();
105
- if (!config.url || !config.url.startsWith('http')) {
106
- console.error('[LỖI] Thiếu --url hợp lệ (Ví dụ: --url http://localhost:3000)');
107
- process.exit(1);
108
- }
109
-
110
- let browser;
111
- try {
112
- browser = await getBrowser();
113
- const page = await browser.newPage();
114
-
115
- // Set viewport như Macbook Pro
116
- await page.setViewport({ width: 1440, height: 900 });
117
-
118
- console.log(`[Hapo Browser] Định vị tọa độ: ${config.url}`);
119
- await page.goto(config.url, { waitUntil: 'networkidle2', timeout: 15000 });
120
-
121
- if (config.action === 'screenshot') {
122
- await takeScreenshot(page, config.output);
123
- } else if (config.action === 'console') {
124
- await captureConsole(page);
125
- } else if (config.action === 'aria') {
126
- await captureAria(page);
127
- } else {
128
- console.error(`[LỖI] Không tồn tại Action tên là: ${config.action}`);
129
- }
130
-
131
- } catch (error) {
132
- console.error('\n[LỖI SẬP NGUỒN]', error.message);
133
- } finally {
134
- if (browser) await browser.close();
135
- }
136
- }
137
-
138
- main();