@haposoft/cafekit 0.13.4 → 0.14.1
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 +1 -2
- 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/package.json +1 -1
- package/src/claude/agents/ui-ux-designer.md +4 -4
- 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 -4
- package/src/claude/rules/skill-domain-routing.md +2 -3
- package/src/claude/rules/skill-workflow-routing.md +2 -3
- 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/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
|
@@ -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();
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2025 fireworks-tech-graph contributors
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|