opencode-goal-mode 0.1.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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/agents/goal-api-reviewer.md +52 -0
  4. package/agents/goal-architect.md +52 -0
  5. package/agents/goal-commentator.md +45 -0
  6. package/agents/goal-completion-guard.md +35 -0
  7. package/agents/goal-coordinator.md +47 -0
  8. package/agents/goal-data-reviewer.md +51 -0
  9. package/agents/goal-deep-researcher.md +58 -0
  10. package/agents/goal-diff-reviewer.md +49 -0
  11. package/agents/goal-doc-reviewer.md +33 -0
  12. package/agents/goal-doc-writer.md +46 -0
  13. package/agents/goal-explorer.md +35 -0
  14. package/agents/goal-final-auditor.md +39 -0
  15. package/agents/goal-implementer.md +34 -0
  16. package/agents/goal-mapper.md +53 -0
  17. package/agents/goal-ops-reviewer.md +33 -0
  18. package/agents/goal-perf-reviewer.md +51 -0
  19. package/agents/goal-planner.md +40 -0
  20. package/agents/goal-prompt-auditor.md +39 -0
  21. package/agents/goal-quality-gate.md +51 -0
  22. package/agents/goal-researcher.md +34 -0
  23. package/agents/goal-reviewer.md +61 -0
  24. package/agents/goal-security-reviewer.md +33 -0
  25. package/agents/goal-test-reviewer.md +48 -0
  26. package/agents/goal-ux-reviewer.md +33 -0
  27. package/agents/goal-verifier.md +49 -0
  28. package/agents/goal-web-researcher.md +58 -0
  29. package/agents/goal.md +179 -0
  30. package/commands/goal-contract.md +14 -0
  31. package/commands/goal-final.md +15 -0
  32. package/commands/goal-repair.md +12 -0
  33. package/commands/goal-review.md +15 -0
  34. package/commands/goal-status.md +23 -0
  35. package/commands/goal.md +12 -0
  36. package/docs/research-report.md +37 -0
  37. package/package.json +61 -0
  38. package/plugins/goal-guard.js +426 -0
  39. package/scripts/check-npm-publish-ready.mjs +54 -0
  40. package/scripts/install.mjs +108 -0
  41. package/scripts/validate-opencode-config.mjs +82 -0
  42. package/tests/agents.test.mjs +70 -0
  43. package/tests/commands.test.mjs +23 -0
  44. package/tests/helpers.mjs +23 -0
  45. package/tests/install.test.mjs +64 -0
  46. package/tests/plugin.test.mjs +195 -0
@@ -0,0 +1,426 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ const WRITE_TOOLS = new Set(["edit", "write", "apply_patch"]);
4
+
5
+ const MUTATING_BASH_PATTERNS = [
6
+ /(^|&&|;|\|\|)\s*(sudo\s+)?(rm|mv|cp|mkdir|rmdir|touch|ln)\b/i,
7
+ /(^|&&|;|\|\|)\s*(sudo\s+)?(tee|xargs\s+(rm|mv|cp))\b/i,
8
+ /(^|&&|;|\|\|)\s*[^|]*\s(>|>>)\s*(?!\/dev\/null\b)\S+/i,
9
+ /(^|&&|;|\|\|)\s*(perl\s+-pi|sed\s+-i)\b/i,
10
+ /(^|&&|;|\|\|)\s*(npm|pnpm|yarn|bun)\s+(install|ci|add|remove|update)\b/i,
11
+ /(^|&&|;|\|\|)\s*(npm|pnpm|yarn|bun)\s+(run\s+)?(format|fix|lint:fix)\b/i,
12
+ /\b((npx|pnpm\s+exec|yarn)\s+)?(prettier|eslint)\b.*\s(--write|--fix)\b/i,
13
+ /\b(node|python3?)\b.*\b(writeFile|appendFile|copyFile|rename|unlink|rmSync|mkdir|rmdir|openSync)\b/i,
14
+ ];
15
+
16
+ const REVIEW_AGENTS = new Set([
17
+ "goal-reviewer",
18
+ "goal-prompt-auditor",
19
+ "goal-diff-reviewer",
20
+ "goal-verifier",
21
+ "goal-test-reviewer",
22
+ "goal-security-reviewer",
23
+ "goal-ux-reviewer",
24
+ "goal-ops-reviewer",
25
+ "goal-doc-reviewer",
26
+ "goal-final-auditor",
27
+ "goal-api-reviewer",
28
+ "goal-data-reviewer",
29
+ "goal-perf-reviewer",
30
+ "goal-quality-gate",
31
+ ]);
32
+
33
+ const GOAL_AGENTS = new Set([
34
+ "goal",
35
+ "goal-implementer",
36
+ "goal-reviewer",
37
+ "goal-prompt-auditor",
38
+ "goal-diff-reviewer",
39
+ "goal-verifier",
40
+ "goal-test-reviewer",
41
+ "goal-security-reviewer",
42
+ "goal-ux-reviewer",
43
+ "goal-ops-reviewer",
44
+ "goal-doc-reviewer",
45
+ "goal-final-auditor",
46
+ "goal-deep-researcher",
47
+ "goal-web-researcher",
48
+ "goal-architect",
49
+ "goal-mapper",
50
+ "goal-planner",
51
+ "goal-coordinator",
52
+ "goal-doc-writer",
53
+ "goal-commentator",
54
+ "goal-api-reviewer",
55
+ "goal-data-reviewer",
56
+ "goal-perf-reviewer",
57
+ "goal-quality-gate",
58
+ ]);
59
+
60
+ function normalizedAgent(input) {
61
+ if (!input) return undefined;
62
+ const agent = String(input.agent || input.args?.subagent_type || "").trim();
63
+ return agent || undefined;
64
+ }
65
+
66
+ function createState() {
67
+ return {
68
+ active: false,
69
+ dirty: false,
70
+ dirtyReasons: [],
71
+ reviewCycles: 0,
72
+ lastReviewAt: null,
73
+ lastEditAt: null,
74
+ lastVerificationAt: null,
75
+ verdicts: [],
76
+ latestVerdict: {},
77
+ currentAgent: undefined,
78
+ completedBlocked: 0,
79
+ verificationSeen: false,
80
+ lastCompletionRejectAt: null,
81
+ };
82
+ }
83
+
84
+ const sessions = new Map();
85
+ const MAX_SESSIONS = 200;
86
+
87
+ function evictOldestSession() {
88
+ if (sessions.size < MAX_SESSIONS) return;
89
+ let oldestKey = null;
90
+ let oldestTime = Infinity;
91
+ for (const [key, state] of sessions) {
92
+ const t = new Date(state.lastEditAt || state.lastReviewAt || 0).getTime();
93
+ if (t < oldestTime) {
94
+ oldestTime = t;
95
+ oldestKey = key;
96
+ }
97
+ }
98
+ if (oldestKey) sessions.delete(oldestKey);
99
+ }
100
+
101
+ function stateFor(sessionID) {
102
+ const key = String(sessionID || "default").trim() || "default";
103
+ if (!sessions.has(key)) {
104
+ while (sessions.size >= MAX_SESSIONS) evictOldestSession();
105
+ sessions.set(key, createState());
106
+ }
107
+ return sessions.get(key);
108
+ }
109
+
110
+ function nowIso() {
111
+ return new Date().toISOString();
112
+ }
113
+
114
+ function textOf(output) {
115
+ const raw = output?.output || output?.text || output?.message || "";
116
+ if (typeof raw === "string") return raw;
117
+ if (typeof raw === "object" && raw?.output) return String(raw.output);
118
+ if (typeof raw === "object" && raw?.text) return String(raw.text);
119
+ return JSON.stringify(raw || "");
120
+ }
121
+
122
+ function isPass(text) {
123
+ return /Verdict:\s*PASS\b/i.test(text);
124
+ }
125
+
126
+ function isFail(text) {
127
+ return /Verdict:\s*FAIL\b/i.test(text);
128
+ }
129
+
130
+ function isVerification(command) {
131
+ const normalized = String(command || "").trim();
132
+ return [
133
+ /\bnpm\s+test\b/,
134
+ /\bnpm\s+run\s+test\b/,
135
+ /\bnpm\s+run\s+validate\b/,
136
+ /\bnpm\s+run\s+check\b/,
137
+ /\bnpm\s+run\s+lint\b/,
138
+ /\bnpm\s+run\s+typecheck\b/,
139
+ /\bnpm\s+run\s+build\b/,
140
+ /\bnpm\s+run\s+unit\b/,
141
+ /\bnpm\s+run\s+integration\b/,
142
+ /\bjest\b/,
143
+ /\bmocha\b/,
144
+ /\bvite\s+test\b/,
145
+ /\bvitest\b/,
146
+ /\bpnpm\s+test\b/,
147
+ /\byarn\s+test\b/,
148
+ /\bbun\s+test\b/,
149
+ /\bgo\s+test\b/,
150
+ /\bcargo\s+test\b/,
151
+ /\bpytest\b/,
152
+ /\bpython\s+-m\s+pytest\b/,
153
+ /\bpython\s+-m\s+unittest\b/,
154
+ /\bphpunit\b/,
155
+ /\bmake\s+test\b/,
156
+ /\bmake\s+check\b/,
157
+ /\bmake\s+validate\b/,
158
+ ].some((pattern) => pattern.test(normalized));
159
+ }
160
+
161
+ function looksLikeDestructiveBash(command) {
162
+ const normalized = String(command || "").trim();
163
+ return [
164
+ /(^|&&|;|\|\|)\s*(sudo\s+)?rm\s+-[a-zA-Z]*[rR][a-zA-Z]*[rfRF]?\b/,
165
+ /(^|&&|;|\|\|)\s*(sudo\s+)?rm\s+(--recursive|--force|--recursive\s+--force|-rf|-fr|-r)\b/,
166
+ /(^|&&|;|\|\|)\s*git\s+reset\b/,
167
+ /(^|&&|;|\|\|)\s*git\s+clean\b/,
168
+ /(^|&&|;|\|\|)\s*git\s+checkout\b/,
169
+ /(^|&&|;|\|\|)\s*git\s+restore\b/,
170
+ /(^|&&|;|\|\|)\s*git\s+switch\b/,
171
+ /(^|&&|;|\|\|)\s*git\s+push\b/,
172
+ /(^|&&|;|\|\|)\s*(sudo\s+)?find\b.*\s-delete\b/,
173
+ /(^|&&|;|\|\|)\s*(sudo\s+)?find\b.*\s-exec\s+rm\b/,
174
+ /(^|&&|;|\|\|)\s*(sudo\s+)?dd\b.*\bof=\/dev\//,
175
+ /(^|&&|;|\|\|)\s*(sudo\s+)?mkfs(\.|\s|$)/,
176
+ /(^|&&|;|\|\|)\s*(sudo\s+)?shred\b/,
177
+ /(^|&&|;|\|\|)\s*(sudo\s+)?truncate\b/,
178
+ /(^|&&|;|\|\|)\s*(sudo\s+)?chmod\s+-[a-zA-Z]*[rR][a-zA-Z]*[wW][a-zA-Z]*[xX][a-zA-Z]*\s+\/\b/,
179
+ ].some((pattern) => pattern.test(normalized));
180
+ }
181
+
182
+ function looksLikeMutatingBash(command) {
183
+ const normalized = String(command || "").trim();
184
+ if (!normalized) return false;
185
+ if (looksLikeDestructiveBash(normalized)) return true;
186
+ return MUTATING_BASH_PATTERNS.some((pattern) => pattern.test(normalized));
187
+ }
188
+
189
+ function commandFingerprint(command) {
190
+ return createHash("sha256").update(String(command || "")).digest("hex").slice(0, 12);
191
+ }
192
+
193
+ function latestVerdictFor(state, agent) {
194
+ const entries = state.verdicts.filter((entry) => entry.agent === agent);
195
+ if (!entries.length) return null;
196
+ return entries.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0))[0];
197
+ }
198
+
199
+ function recordReviewVerdict(state, agent, verdict, at) {
200
+ state.verdicts.push({ agent, verdict, at });
201
+ state.latestVerdict[agent] = { verdict, at };
202
+ state.lastReviewAt = at;
203
+ if (agent === "goal-final-auditor") {
204
+ state.reviewCycles += 1;
205
+ }
206
+ }
207
+
208
+ function verdictAfter(state, agent, since) {
209
+ const latest = latestVerdictFor(state, agent);
210
+ if (!latest) return false;
211
+ if (latest.verdict !== "PASS") return false;
212
+ if (!since) return true;
213
+ return latest.at >= since;
214
+ }
215
+
216
+ const BASE_GATES = [
217
+ "goal-prompt-auditor",
218
+ "goal-reviewer",
219
+ "goal-diff-reviewer",
220
+ "goal-verifier",
221
+ "goal-final-auditor",
222
+ ];
223
+
224
+ const CONTEXTUAL_GATES = {
225
+ security: "goal-security-reviewer",
226
+ permissions: "goal-security-reviewer",
227
+ auth: "goal-security-reviewer",
228
+ shell: "goal-security-reviewer",
229
+ test: "goal-test-reviewer",
230
+ coverage: "goal-test-reviewer",
231
+ ops: "goal-ops-reviewer",
232
+ restart: "goal-ops-reviewer",
233
+ install: "goal-ops-reviewer",
234
+ api: "goal-api-reviewer",
235
+ endpoint: "goal-api-reviewer",
236
+ schema: "goal-api-reviewer",
237
+ data: "goal-data-reviewer",
238
+ database: "goal-data-reviewer",
239
+ migration: "goal-data-reviewer",
240
+ performance: "goal-perf-reviewer",
241
+ latency: "goal-perf-reviewer",
242
+ quality: "goal-quality-gate",
243
+ standard: "goal-quality-gate",
244
+ };
245
+
246
+ function requiredGates(state, promptText, changedFilesText) {
247
+ const since = [state.lastEditAt, state.lastVerificationAt].filter(Boolean).sort().at(-1);
248
+ const text = `${promptText || ""} ${(changedFilesText || state.dirtyReasons.join(" ") || "")}`.toLowerCase();
249
+ const gates = [...BASE_GATES];
250
+ for (const [keyword, agent] of Object.entries(CONTEXTUAL_GATES)) {
251
+ if (text.includes(keyword) && !gates.includes(agent)) gates.push(agent);
252
+ }
253
+ return { since, gates };
254
+ }
255
+
256
+ function missingGates(state) {
257
+ const { since, gates } = requiredGates(state);
258
+ return gates.filter((agent) => !verdictAfter(state, agent, since));
259
+ }
260
+
261
+ function completionAllowed(state) {
262
+ return state.active && missingGates(state).length === 0;
263
+ }
264
+
265
+ function summarizeState(state) {
266
+ const verdictSummary = state.verdicts.slice(-8).map((v) => `${v.agent}:${v.verdict}`).join(", ") || "none";
267
+ return [
268
+ `dirty=${state.dirty}`,
269
+ `reviewCycles=${state.reviewCycles}`,
270
+ `lastEditAt=${state.lastEditAt || "none"}`,
271
+ `lastReviewAt=${state.lastReviewAt || "none"}`,
272
+ `recentVerdicts=${verdictSummary}`,
273
+ `dirtyReasons=${state.dirtyReasons.slice(-5).join(" | ") || "none"}`,
274
+ ].join("; ");
275
+ }
276
+
277
+ export async function GoalGuardPlugin({ client }) {
278
+ return {
279
+ async "chat.params"(input) {
280
+ if (!input?.sessionID || typeof input.sessionID !== "string") return;
281
+ const normalized = input.sessionID.trim();
282
+ if (!normalized) return;
283
+ const state = stateFor(normalized);
284
+ state.currentAgent = input.agent;
285
+ if (GOAL_AGENTS.has(input.agent)) state.active = true;
286
+ },
287
+
288
+ async "tool.execute.before"(input, output) {
289
+ const state = stateFor(input.sessionID);
290
+ const command = output?.args?.command || input?.args?.command;
291
+ if (input.tool === "bash" && looksLikeDestructiveBash(command)) {
292
+ state.active = true;
293
+ state.dirtyReasons.push(`blocked risky bash fingerprint:${commandFingerprint(command)}`);
294
+ throw new Error(
295
+ "Goal Guard blocked a destructive or high-risk bash command. Ask the user or use a safer command."
296
+ );
297
+ }
298
+ if (input.tool === "write" || input.tool === "edit" || input.tool === "apply_patch") {
299
+ state.dirty = true;
300
+ state.lastEditAt = nowIso();
301
+ state.dirtyReasons.push(`${input.tool} at ${state.lastEditAt}`);
302
+ }
303
+ },
304
+
305
+ async "tool.execute.after"(input, output) {
306
+ const state = stateFor(input.sessionID);
307
+ const agent = state.currentAgent;
308
+ const invokedReviewAgent = normalizedAgent(input);
309
+ const at = nowIso();
310
+ let recordedReviewAgent = null;
311
+
312
+ if (agent && GOAL_AGENTS.has(agent)) state.active = true;
313
+
314
+ if (WRITE_TOOLS.has(input.tool)) {
315
+ state.dirty = true;
316
+ state.lastEditAt = at;
317
+ state.dirtyReasons.push(`${input.tool} at ${at}`);
318
+ }
319
+
320
+ const isReviewing = REVIEW_AGENTS.has(state.currentAgent);
321
+ if (input.tool === "bash") {
322
+ const command = String(input?.args?.command || "");
323
+ if (isVerification(command) && !isReviewing) {
324
+ state.verificationSeen = true;
325
+ state.lastVerificationAt = at;
326
+ }
327
+ if (!looksLikeDestructiveBash(command) && looksLikeMutatingBash(command) && !isReviewing) {
328
+ state.dirty = true;
329
+ state.lastEditAt = at;
330
+ state.dirtyReasons.push(`bash mutation fingerprint:${commandFingerprint(command)}`);
331
+ }
332
+ }
333
+
334
+ if (input.tool === "task" && REVIEW_AGENTS.has(invokedReviewAgent)) {
335
+ const out = textOf(output);
336
+ const failFirst = isFail(out);
337
+ const passAfter = isPass(out) && !failFirst;
338
+ if (!failFirst && !passAfter) return;
339
+ const verdict = passAfter ? "PASS" : "FAIL";
340
+ recordReviewVerdict(state, invokedReviewAgent, verdict, at);
341
+ recordedReviewAgent = invokedReviewAgent;
342
+ }
343
+
344
+ if (agent && REVIEW_AGENTS.has(agent)) {
345
+ const out = textOf(output);
346
+ if (/Verdict:\s*(PASS|FAIL)\b/i.test(out)) {
347
+ const failFirst = isFail(out);
348
+ const passAfter = isPass(out) && !failFirst;
349
+ if (!failFirst && !passAfter) return;
350
+ const verdict = passAfter ? "PASS" : "FAIL";
351
+ recordReviewVerdict(state, agent, verdict, at);
352
+ recordedReviewAgent = agent;
353
+ }
354
+ }
355
+
356
+ if (
357
+ recordedReviewAgent === "goal-final-auditor" &&
358
+ latestVerdictFor(state, recordedReviewAgent)?.verdict === "PASS" &&
359
+ completionAllowed(state)
360
+ ) {
361
+ state.dirty = false;
362
+ state.dirtyReasons = [];
363
+ }
364
+ },
365
+
366
+ async "experimental.session.compacting"(input, output) {
367
+ const state = stateFor(input.sessionID);
368
+ output.context.push(`Goal Guard state: ${summarizeState(state)}. Preserve Goal Contract, Verification Ledger, Review Ledger, review cycle count, dirty state, and open findings across compaction.`);
369
+ },
370
+
371
+ async "experimental.text.complete"(input, output) {
372
+ const state = stateFor(input.sessionID);
373
+ const text = output.text || "";
374
+ const claimsCompletion = /Goal Completed/i.test(text);
375
+ const completedMatch = text.match(/Review cycles:\s*(\d+)/i);
376
+ const claimedCycles = completedMatch ? parseInt(completedMatch[1], 10) : -1;
377
+
378
+ if (!claimsCompletion) return;
379
+
380
+ if (claimedCycles < 0) {
381
+ state.completedBlocked += 1;
382
+ output.text = text.replace(/Goal Completed/i, "Goal Not Completed");
383
+ output.text += `\n\nGoal Guard blocked completion: missing required Review cycles line. State: ${summarizeState(state)}`;
384
+ } else if (state.reviewCycles === 0) {
385
+ state.completedBlocked += 1;
386
+ output.text = text.replace(/Goal Completed/i, "Goal Not Completed");
387
+ output.text += `\n\nGoal Guard blocked completion: no review cycles recorded. State: ${summarizeState(state)}`;
388
+ } else if (claimedCycles !== state.reviewCycles) {
389
+ state.completedBlocked += 1;
390
+ output.text = text.replace(/Goal Completed/i, "Goal Not Completed");
391
+ output.text += `\n\nGoal Guard blocked completion: claimed review cycles (${claimedCycles}) do not match recorded review cycles (${state.reviewCycles}). State: ${summarizeState(state)}`;
392
+ } else if (!completionAllowed(state)) {
393
+ state.completedBlocked += 1;
394
+ output.text = text.replace(/Goal Completed/i, "Goal Not Completed");
395
+ output.text += `\n\nGoal Guard blocked completion: required review gates are missing or stale (${missingGates(state).join(", ") || "goal session not active"}). State: ${summarizeState(state)}`;
396
+ }
397
+ },
398
+
399
+ async event({ event }) {
400
+ if (event?.type === "session.idle" && event?.properties?.sessionID) {
401
+ const state = stateFor(event.properties.sessionID);
402
+ if (state.dirty) {
403
+ await client.app.log({
404
+ body: {
405
+ service: "goal-guard",
406
+ level: "warn",
407
+ message: "Goal session idle while dirty or review-stale",
408
+ extra: { state: summarizeState(state) },
409
+ },
410
+ });
411
+ }
412
+ }
413
+ },
414
+ };
415
+ }
416
+
417
+ export default GoalGuardPlugin;
418
+ export const __test = {
419
+ createState,
420
+ stateFor,
421
+ sessions,
422
+ looksLikeDestructiveBash,
423
+ looksLikeMutatingBash,
424
+ isVerification,
425
+ summarizeState,
426
+ };
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { parseArgs } from "node:util";
7
+
8
+ const { values } = parseArgs({
9
+ options: {
10
+ "skip-registry": { type: "boolean", default: false },
11
+ "skip-tag": { type: "boolean", default: false },
12
+ },
13
+ allowPositionals: false,
14
+ });
15
+
16
+ const root = fileURLToPath(new URL("..", import.meta.url));
17
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
18
+
19
+ if (!pkg.name) throw new Error("package.json missing package name");
20
+ if (!pkg.version) throw new Error("package.json missing package version");
21
+ if (pkg.private) throw new Error("Refusing to publish a private package");
22
+ if (pkg.publishConfig?.access !== "public") throw new Error("publishConfig.access must be public");
23
+ if (pkg.publishConfig?.registry !== "https://registry.npmjs.org/") {
24
+ throw new Error("publishConfig.registry must be https://registry.npmjs.org/");
25
+ }
26
+
27
+ const releaseTag = process.env.GITHUB_REF_TYPE === "tag" ? process.env.GITHUB_REF_NAME : "";
28
+ if (!values["skip-tag"] && releaseTag) {
29
+ const normalizedTag = releaseTag.startsWith("v") ? releaseTag.slice(1) : releaseTag;
30
+ if (normalizedTag !== pkg.version) {
31
+ throw new Error(`Release tag ${releaseTag} does not match package version ${pkg.version}`);
32
+ }
33
+ }
34
+
35
+ function packageMetadataUrl(name) {
36
+ const registry = pkg.publishConfig.registry.replace(/\/$/, "");
37
+ return `${registry}/${encodeURIComponent(name)}`;
38
+ }
39
+
40
+ if (!values["skip-registry"]) {
41
+ const response = await fetch(packageMetadataUrl(pkg.name), {
42
+ headers: { accept: "application/vnd.npm.install-v1+json" },
43
+ });
44
+
45
+ if (response.status !== 404) {
46
+ if (!response.ok) throw new Error(`npm registry check failed with HTTP ${response.status}`);
47
+ const metadata = await response.json();
48
+ if (metadata?.versions?.[pkg.version]) {
49
+ throw new Error(`${pkg.name}@${pkg.version} already exists on npm`);
50
+ }
51
+ }
52
+ }
53
+
54
+ console.log(`${pkg.name}@${pkg.version} is ready for npm publishing`);
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { mkdirSync, copyFileSync, readdirSync, statSync, existsSync, readFileSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { createHash } from "node:crypto";
7
+ import { parseArgs } from "node:util";
8
+
9
+ const { values } = parseArgs({
10
+ options: {
11
+ global: { type: "boolean", default: false },
12
+ force: { type: "boolean", default: false },
13
+ "dry-run": { type: "boolean", default: false },
14
+ target: { type: "string" },
15
+ help: { type: "boolean", short: "h", default: false },
16
+ },
17
+ allowPositionals: false,
18
+ });
19
+
20
+ const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
21
+
22
+ if (values.help) {
23
+ console.log(`Install OpenCode Goal Mode components.
24
+
25
+ Usage:
26
+ node scripts/install.mjs [--global | --target <dir>] [--force] [--dry-run]
27
+
28
+ Options:
29
+ --global Install into ~/.config/opencode.
30
+ --target DIR Install into a specific OpenCode config directory.
31
+ --force Replace changed destination files.
32
+ --dry-run Show planned copies without writing files.
33
+ -h, --help Show this help text.`);
34
+ process.exit(0);
35
+ }
36
+
37
+ if (values.global && values.target) {
38
+ throw new Error("Use either --global or --target, not both");
39
+ }
40
+
41
+ function resolveTarget() {
42
+ if (values.target) return resolve(String(values.target));
43
+ if (values.global) {
44
+ const home = process.env.HOME;
45
+ if (!home) throw new Error("Cannot resolve HOME for --global install");
46
+ return join(home, ".config", "opencode");
47
+ }
48
+ return resolve(process.cwd(), ".opencode");
49
+ }
50
+
51
+ function fileHash(path) {
52
+ const data = readFileSync(path);
53
+ return createHash("sha256").update(data).digest("hex").slice(0, 16);
54
+ }
55
+
56
+ function copyDirFiles(from, to, summary) {
57
+ if (!values["dry-run"]) mkdirSync(to, { recursive: true });
58
+ for (const entry of readdirSync(from)) {
59
+ const source = join(from, entry);
60
+ const dest = join(to, entry);
61
+ if (!statSync(source).isFile()) continue;
62
+ if (existsSync(dest) && !statSync(dest).isFile()) {
63
+ summary.conflicts.push(`${dest} exists but is not a file`);
64
+ continue;
65
+ }
66
+ if (values.force) {
67
+ if (!values["dry-run"]) copyFileSync(source, dest);
68
+ summary.copied.push(dest);
69
+ continue;
70
+ }
71
+ if (existsSync(dest)) {
72
+ const srcHash = fileHash(source);
73
+ const dstHash = fileHash(dest);
74
+ if (srcHash === dstHash) {
75
+ summary.unchanged.push(dest);
76
+ continue;
77
+ }
78
+ summary.conflicts.push(`${dest} differs from packaged ${entry}`);
79
+ continue;
80
+ }
81
+ if (!values["dry-run"]) {
82
+ copyFileSync(source, dest);
83
+ }
84
+ summary.copied.push(dest);
85
+ }
86
+ }
87
+
88
+ const target = resolveTarget();
89
+ const summary = { copied: [], unchanged: [], conflicts: [] };
90
+
91
+ copyDirFiles(join(root, "agents"), join(target, "agents"), summary);
92
+ copyDirFiles(join(root, "commands"), join(target, "commands"), summary);
93
+ copyDirFiles(join(root, "plugins"), join(target, "plugins"), summary);
94
+
95
+ if (summary.conflicts.length) {
96
+ throw new Error(
97
+ [
98
+ "Refusing to overwrite changed OpenCode component files.",
99
+ ...summary.conflicts.map((conflict) => `- ${conflict}`),
100
+ "Use --force to replace them or remove the conflicting files manually.",
101
+ ].join("\n")
102
+ );
103
+ }
104
+
105
+ const verb = values["dry-run"] ? "Would install" : "Installed";
106
+ console.log(`${verb} OpenCode Goal Mode into ${target}`);
107
+ console.log(`Files copied: ${summary.copied.length}; unchanged: ${summary.unchanged.length}`);
108
+ console.log("Restart OpenCode for agents, commands, and plugins to load.");
@@ -0,0 +1,82 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const root = fileURLToPath(new URL("..", import.meta.url));
6
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
7
+
8
+ if (!pkg.type || pkg.type !== "module") throw new Error("package.json must use ESM type module");
9
+ if (pkg.private) throw new Error("package.json must not be private when npm publishing is enabled");
10
+ if (!pkg.engines?.node) throw new Error("package.json missing Node engine requirement");
11
+ if (!pkg.bin?.["opencode-goal-mode-install"]) throw new Error("package.json missing installer bin");
12
+ if (pkg.publishConfig?.access !== "public") throw new Error("package.json publishConfig.access must be public");
13
+ if (pkg.publishConfig?.registry !== "https://registry.npmjs.org/") {
14
+ throw new Error("package.json publishConfig.registry must target npmjs.org");
15
+ }
16
+ if (!pkg.files?.includes("agents/") || !pkg.files?.includes("commands/") || !pkg.files?.includes("plugins/")) {
17
+ throw new Error("package.json files must include installable OpenCode component directories");
18
+ }
19
+ for (const script of [
20
+ "test",
21
+ "validate",
22
+ "ci",
23
+ "prepublishOnly",
24
+ "install:local",
25
+ "install:global",
26
+ "pack:check",
27
+ "publish:check",
28
+ "audit",
29
+ ]) {
30
+ if (!pkg.scripts?.[script]) throw new Error(`package.json missing ${script} script`);
31
+ }
32
+ for (const file of ["README.md", "LICENSE", ".npmignore", ".nvmrc"]) {
33
+ if (!existsSync(join(root, file))) throw new Error(`${file} missing`);
34
+ }
35
+
36
+ const agentFiles = readdirSync(join(root, "agents")).filter((file) => file.endsWith(".md"));
37
+ const commandFiles = readdirSync(join(root, "commands")).filter((file) => file.endsWith(".md"));
38
+ const pluginFiles = readdirSync(join(root, "plugins")).filter((file) => file.endsWith(".js"));
39
+
40
+ if (!agentFiles.includes("goal.md")) throw new Error("primary goal agent missing");
41
+ if (!commandFiles.includes("goal.md")) throw new Error("primary goal command missing");
42
+ if (!pluginFiles.includes("goal-guard.js")) throw new Error("goal guard plugin missing");
43
+
44
+ const forbiddenComponentName = /(auth|session|token|secret|preauth|failures|hosts\.ya?ml)/i;
45
+ for (const dir of ["agents", "commands", "plugins"]) {
46
+ for (const file of readdirSync(join(root, dir))) {
47
+ const path = join(root, dir, file);
48
+ if (!statSync(path).isFile()) continue;
49
+ if (forbiddenComponentName.test(file)) throw new Error(`forbidden component filename: ${dir}/${file}`);
50
+ }
51
+ }
52
+
53
+ for (const file of agentFiles) {
54
+ const text = readFileSync(join(root, "agents", file), "utf8");
55
+ if (!text.startsWith("---\n")) throw new Error(`${file} missing frontmatter`);
56
+ if (!/^description:/m.test(text)) throw new Error(`${file} missing description`);
57
+ if (!/^mode:\s+(primary|subagent|all)$/m.test(text)) throw new Error(`${file} has invalid mode`);
58
+ if (!/^permission:/m.test(text)) throw new Error(`${file} missing permission`);
59
+ }
60
+
61
+ for (const file of commandFiles) {
62
+ const text = readFileSync(join(root, "commands", file), "utf8");
63
+ if (!text.startsWith("---\n")) throw new Error(`${file} missing frontmatter`);
64
+ if (!/^description:/m.test(text)) throw new Error(`${file} missing description`);
65
+ if (!/^agent:/m.test(text)) throw new Error(`${file} missing agent`);
66
+ }
67
+
68
+ const plugin = await import(join(root, "plugins", "goal-guard.js"));
69
+ if (typeof plugin.default !== "function") throw new Error("goal-guard plugin must default-export a function");
70
+ const hooks = await plugin.default({ client: { app: { log: async () => undefined } } });
71
+ for (const hook of [
72
+ "chat.params",
73
+ "tool.execute.before",
74
+ "tool.execute.after",
75
+ "experimental.session.compacting",
76
+ "experimental.text.complete",
77
+ "event",
78
+ ]) {
79
+ if (typeof hooks?.[hook] !== "function") throw new Error(`goal-guard plugin missing ${hook} hook`);
80
+ }
81
+
82
+ console.log("OpenCode Goal Mode package validation passed");