@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.
- package/README.md +2 -23
- package/bin/lib/i18n.js +2 -2
- package/bin/lib/manifest.js +15 -0
- package/bin/lib/opencode-install.js +0 -14
- package/bin/phases/claude-runtime.js +4 -1
- package/bin/phases/claude-settings.js +38 -7
- package/bin/phases/root-config.js +5 -24
- package/package.json +1 -1
- package/src/claude/agents/ui-ux-designer.md +4 -4
- package/src/claude/gitignore +4 -11
- package/src/claude/hooks/__tests__/privacy-block.test.js +79 -0
- package/src/claude/hooks/__tests__/session-writeenv.test.js +68 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +270 -0
- package/src/claude/hooks/privacy-block.cjs +22 -2
- package/src/claude/hooks/session.cjs +5 -1
- package/src/claude/hooks/spec-gate.cjs +173 -0
- package/src/claude/hooks/spec-state.cjs +10 -17
- package/src/claude/hooks/task-scaffold-guard.cjs +4 -2
- package/src/claude/migration-manifest.json +5 -5
- package/src/claude/rules/skill-domain-routing.md +2 -3
- package/src/claude/rules/skill-workflow-routing.md +2 -11
- package/src/claude/rules/state-sync.md +4 -0
- package/src/claude/runtime.json +4 -0
- package/src/claude/scripts/validate-docs.cjs +4 -7
- package/src/claude/scripts/validate-spec-output.cjs +29 -9
- package/src/claude/settings/settings.json +4 -0
- package/src/claude/skills/ai-multimodal/.env.example +11 -13
- package/src/claude/skills/ai-multimodal/SKILL.md +1 -1
- package/src/claude/skills/ai-multimodal/references/audio-processing.md +25 -27
- package/src/claude/skills/ai-multimodal/references/video-analysis.md +23 -27
- package/src/claude/skills/ai-multimodal/references/vision-understanding.md +26 -26
- package/src/claude/skills/ai-multimodal/scripts/check_setup.py +1 -1
- package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +7 -9
- package/src/claude/skills/brainstorm/SKILL.md +1 -2
- package/src/claude/skills/docx/SKILL.md +1 -1
- package/src/claude/skills/frontend-design/SKILL.md +1 -1
- package/src/claude/skills/frontend-design/references/ai-multimodal-overview.md +4 -4
- package/src/claude/skills/frontend-design/references/analysis-techniques.md +3 -3
- package/src/claude/skills/frontend-design/references/asset-generation.md +2 -2
- package/src/claude/skills/frontend-design/references/design-extraction-overview.md +4 -4
- package/src/claude/skills/frontend-design/references/extraction-best-practices.md +3 -3
- package/src/claude/skills/frontend-design/references/technical-accessibility.md +1 -1
- package/src/claude/skills/frontend-design/references/technical-optimization.md +1 -1
- package/src/claude/skills/frontend-design/references/technical-overview.md +2 -2
- package/src/claude/skills/frontend-design/references/technical-workflows.md +6 -6
- package/src/claude/skills/frontend-design/references/visual-analysis-overview.md +3 -3
- package/src/claude/skills/inspect/SKILL.md +3 -3
- package/src/claude/skills/inspect/references/external-gemini-inspection.md +9 -9
- package/src/claude/skills/pdf/SKILL.md +1 -1
- package/src/claude/skills/pptx/SKILL.md +1 -1
- package/src/claude/skills/specs/SKILL.md +1 -1
- package/src/claude/skills/xlsx/SKILL.md +1 -1
- package/src/opencode/AGENTS.md +1 -2
- package/src/opencode/plugins/session.ts +2 -2
- package/src/opencode/plugins/spec-state.ts +244 -0
- package/src/opencode/plugins/task-scaffold-guard.ts +161 -0
- package/src/claude/scripts/browser-tool.cjs +0 -138
- package/src/claude/skills/delegate/SKILL.md +0 -124
- package/src/claude/skills/delegate/references/codex-delegation.md +0 -71
- package/src/claude/skills/delegate/references/grok-delegation.md +0 -84
- package/src/claude/skills/generate-graph/LICENSE +0 -21
- package/src/claude/skills/generate-graph/README.md +0 -523
- package/src/claude/skills/generate-graph/SKILL.md +0 -429
- package/src/claude/skills/generate-graph/agentloop-core.svg +0 -101
- package/src/claude/skills/generate-graph/agents/openai.yaml +0 -4
- package/src/claude/skills/generate-graph/assets/samples/sample-style1-flat.png +0 -0
- package/src/claude/skills/generate-graph/assets/samples/sample-style2-dark.png +0 -0
- package/src/claude/skills/generate-graph/assets/samples/sample-style3-blueprint.png +0 -0
- package/src/claude/skills/generate-graph/assets/samples/sample-style4-notion.png +0 -0
- package/src/claude/skills/generate-graph/assets/samples/sample-style5-glass.png +0 -0
- package/src/claude/skills/generate-graph/assets/samples/sample-style6-claude.png +0 -0
- package/src/claude/skills/generate-graph/assets/samples/sample-style7-openai.png +0 -0
- package/src/claude/skills/generate-graph/fixtures/agent-memory-types-style4.json +0 -181
- package/src/claude/skills/generate-graph/fixtures/api-flow-style7.json +0 -40
- package/src/claude/skills/generate-graph/fixtures/mem0-style1.json +0 -297
- package/src/claude/skills/generate-graph/fixtures/microservices-style3.json +0 -64
- package/src/claude/skills/generate-graph/fixtures/multi-agent-style5.json +0 -45
- package/src/claude/skills/generate-graph/fixtures/system-architecture-style6.json +0 -48
- package/src/claude/skills/generate-graph/fixtures/tool-call-style2.json +0 -182
- package/src/claude/skills/generate-graph/package.json +0 -42
- package/src/claude/skills/generate-graph/references/icons.md +0 -281
- package/src/claude/skills/generate-graph/references/style-1-flat-icon.md +0 -108
- package/src/claude/skills/generate-graph/references/style-2-dark-terminal.md +0 -107
- package/src/claude/skills/generate-graph/references/style-3-blueprint.md +0 -113
- package/src/claude/skills/generate-graph/references/style-4-notion-clean.md +0 -94
- package/src/claude/skills/generate-graph/references/style-5-glassmorphism.md +0 -125
- package/src/claude/skills/generate-graph/references/style-6-claude-official.md +0 -209
- package/src/claude/skills/generate-graph/references/style-7-openai.md +0 -215
- package/src/claude/skills/generate-graph/references/style-diagram-matrix.md +0 -135
- package/src/claude/skills/generate-graph/references/svg-layout-best-practices.md +0 -100
- package/src/claude/skills/generate-graph/scripts/generate-diagram.sh +0 -157
- package/src/claude/skills/generate-graph/scripts/generate-from-template.py +0 -1556
- package/src/claude/skills/generate-graph/scripts/test-all-styles.sh +0 -135
- package/src/claude/skills/generate-graph/scripts/validate-svg.sh +0 -292
- package/src/claude/skills/generate-graph/templates/agent-architecture.svg +0 -28
- package/src/claude/skills/generate-graph/templates/architecture.svg +0 -23
- package/src/claude/skills/generate-graph/templates/comparison-matrix.svg +0 -14
- package/src/claude/skills/generate-graph/templates/data-flow.svg +0 -28
- package/src/claude/skills/generate-graph/templates/er-diagram.svg +0 -21
- package/src/claude/skills/generate-graph/templates/flowchart.svg +0 -21
- package/src/claude/skills/generate-graph/templates/sequence.svg +0 -20
- package/src/claude/skills/generate-graph/templates/state-machine.svg +0 -20
- package/src/claude/skills/generate-graph/templates/timeline.svg +0 -19
- package/src/claude/skills/generate-graph/templates/use-case.svg +0 -21
- package/src/claude/skills/impact-analysis/SKILL.md +0 -276
- package/src/claude/skills/impact-analysis/references/change-detection.md +0 -270
- package/src/claude/skills/impact-analysis/references/dependency-scouting.md +0 -338
- package/src/claude/skills/impact-analysis/references/edge-case-identification.md +0 -439
- package/src/claude/skills/impact-analysis/references/industry-techniques.md +0 -695
- package/src/claude/skills/impact-analysis/references/practical-techniques-guide.md +0 -753
- package/src/claude/skills/impact-analysis/references/project-detection.md +0 -704
- package/src/claude/skills/impact-analysis/references/react-native-customization.md +0 -508
- package/src/claude/skills/impact-analysis/references/report-template.md +0 -604
- package/src/claude/skills/impact-analysis/references/test-scenario-generation.md +0 -459
- package/src/claude/skills/impact-analysis/scripts/README.md +0 -476
- package/src/claude/skills/impact-analysis/scripts/ast-analyze.js +0 -403
- package/src/claude/skills/impact-analysis/scripts/calculate-risk.js +0 -475
- package/src/claude/skills/impact-analysis/scripts/find-dependencies.sh +0 -202
- 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:
|
|
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:
|
|
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-
|
|
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:
|
|
10
|
+
author: "Anthropic, PBC — adapted by Haposoft"
|
|
11
11
|
version: "1.0.0"
|
|
12
12
|
---
|
|
13
13
|
# Requirements for Outputs
|
package/src/opencode/AGENTS.md
CHANGED
|
@@ -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
|
-
-
|
|
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
|
|
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
|
|
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();
|