opencode-goal-mode 0.1.0 → 0.2.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/ARCHITECTURE.md +180 -0
- package/README.md +158 -52
- package/agents/goal-api-reviewer.md +0 -2
- package/agents/goal-architect.md +0 -2
- package/agents/goal-commentator.md +0 -2
- package/agents/goal-completion-guard.md +0 -2
- package/agents/goal-coordinator.md +0 -2
- package/agents/goal-data-reviewer.md +0 -2
- package/agents/goal-deep-researcher.md +0 -2
- package/agents/goal-diff-reviewer.md +0 -2
- package/agents/goal-doc-reviewer.md +0 -2
- package/agents/goal-doc-writer.md +0 -2
- package/agents/goal-explorer.md +9 -8
- package/agents/goal-final-auditor.md +0 -2
- package/agents/goal-implementer.md +0 -2
- package/agents/goal-mapper.md +0 -2
- package/agents/goal-ops-reviewer.md +0 -2
- package/agents/goal-perf-reviewer.md +0 -2
- package/agents/goal-planner.md +10 -5
- package/agents/goal-prompt-auditor.md +0 -2
- package/agents/goal-quality-gate.md +0 -2
- package/agents/goal-researcher.md +8 -7
- package/agents/goal-reviewer.md +0 -2
- package/agents/goal-security-reviewer.md +0 -2
- package/agents/goal-test-reviewer.md +0 -2
- package/agents/goal-ux-reviewer.md +0 -2
- package/agents/goal-verifier.md +0 -2
- package/agents/goal-web-researcher.md +0 -2
- package/agents/goal.md +9 -8
- package/package.json +13 -9
- package/plugins/goal-guard/agents.js +132 -0
- package/plugins/goal-guard/completion.js +64 -0
- package/plugins/goal-guard/config.js +87 -0
- package/plugins/goal-guard/events.js +65 -0
- package/plugins/goal-guard/gates.js +85 -0
- package/plugins/goal-guard/logger.js +36 -0
- package/plugins/goal-guard/persistence.js +122 -0
- package/plugins/goal-guard/shell.js +1159 -0
- package/plugins/goal-guard/state.js +182 -0
- package/plugins/goal-guard/summary.js +46 -0
- package/plugins/goal-guard/system.js +43 -0
- package/plugins/goal-guard/tools.js +129 -0
- package/plugins/goal-guard/verdicts.js +87 -0
- package/plugins/goal-guard.js +267 -379
- package/plugins/package.json +3 -0
- package/scripts/install.mjs +170 -36
- package/docs/research-report.md +0 -37
- package/scripts/check-npm-publish-ready.mjs +0 -54
- package/scripts/validate-opencode-config.mjs +0 -82
- package/tests/agents.test.mjs +0 -70
- package/tests/commands.test.mjs +0 -23
- package/tests/helpers.mjs +0 -23
- package/tests/install.test.mjs +0 -64
- package/tests/plugin.test.mjs +0 -195
package/scripts/install.mjs
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
mkdirSync,
|
|
5
|
+
copyFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
statSync,
|
|
8
|
+
lstatSync,
|
|
9
|
+
rmdirSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { join, resolve, relative, dirname } from "node:path";
|
|
5
16
|
import { fileURLToPath } from "node:url";
|
|
6
17
|
import { createHash } from "node:crypto";
|
|
7
18
|
import { parseArgs } from "node:util";
|
|
@@ -11,6 +22,7 @@ const { values } = parseArgs({
|
|
|
11
22
|
global: { type: "boolean", default: false },
|
|
12
23
|
force: { type: "boolean", default: false },
|
|
13
24
|
"dry-run": { type: "boolean", default: false },
|
|
25
|
+
uninstall: { type: "boolean", default: false },
|
|
14
26
|
target: { type: "string" },
|
|
15
27
|
help: { type: "boolean", short: "h", default: false },
|
|
16
28
|
},
|
|
@@ -18,19 +30,30 @@ const { values } = parseArgs({
|
|
|
18
30
|
});
|
|
19
31
|
|
|
20
32
|
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
33
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
34
|
+
|
|
35
|
+
/** Component directories installed into an OpenCode config dir. */
|
|
36
|
+
const COMPONENT_DIRS = ["agents", "commands", "plugins"];
|
|
37
|
+
const MANIFEST_NAME = ".goal-mode-manifest.json";
|
|
21
38
|
|
|
22
39
|
if (values.help) {
|
|
23
|
-
console.log(`Install OpenCode Goal Mode components.
|
|
40
|
+
console.log(`Install or remove OpenCode Goal Mode components.
|
|
24
41
|
|
|
25
42
|
Usage:
|
|
26
43
|
node scripts/install.mjs [--global | --target <dir>] [--force] [--dry-run]
|
|
44
|
+
node scripts/install.mjs --uninstall [--global | --target <dir>] [--dry-run]
|
|
27
45
|
|
|
28
46
|
Options:
|
|
29
47
|
--global Install into ~/.config/opencode.
|
|
30
48
|
--target DIR Install into a specific OpenCode config directory.
|
|
31
|
-
--force Replace
|
|
32
|
-
--
|
|
33
|
-
-
|
|
49
|
+
--force Replace destination files even if locally modified.
|
|
50
|
+
--uninstall Remove files this installer previously wrote (per manifest).
|
|
51
|
+
--dry-run Show planned changes without writing.
|
|
52
|
+
-h, --help Show this help text.
|
|
53
|
+
|
|
54
|
+
The installer records a manifest of the files it writes so that a later
|
|
55
|
+
upgrade can distinguish files it owns (safe to replace) from files you have
|
|
56
|
+
locally customized (left untouched unless --force).`);
|
|
34
57
|
process.exit(0);
|
|
35
58
|
}
|
|
36
59
|
|
|
@@ -49,48 +72,152 @@ function resolveTarget() {
|
|
|
49
72
|
}
|
|
50
73
|
|
|
51
74
|
function fileHash(path) {
|
|
52
|
-
|
|
53
|
-
|
|
75
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex").slice(0, 16);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Recursively list regular files under a directory, returning paths relative to
|
|
79
|
+
* `base`. Uses lstat and skips symlinks so the installer only copies files it can
|
|
80
|
+
* reason about (no following links outside the package tree). */
|
|
81
|
+
function listFiles(dir, base = dir, out = []) {
|
|
82
|
+
if (!existsSync(dir)) return out;
|
|
83
|
+
for (const entry of readdirSync(dir)) {
|
|
84
|
+
const abs = join(dir, entry);
|
|
85
|
+
const st = lstatSync(abs);
|
|
86
|
+
if (st.isSymbolicLink()) continue;
|
|
87
|
+
if (st.isDirectory()) listFiles(abs, base, out);
|
|
88
|
+
else if (st.isFile()) out.push(relative(base, abs));
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Remove directories left empty by file removal, bottom-up, within the target. */
|
|
94
|
+
function pruneEmptyDirs(targetRoot, relFiles) {
|
|
95
|
+
const dirs = new Set();
|
|
96
|
+
for (const rel of relFiles) {
|
|
97
|
+
let d = dirname(rel);
|
|
98
|
+
while (d && d !== "." && d !== "/") {
|
|
99
|
+
dirs.add(d);
|
|
100
|
+
d = dirname(d);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Deepest first so parents become empty after their children are removed.
|
|
104
|
+
for (const rel of [...dirs].sort((a, b) => b.length - a.length)) {
|
|
105
|
+
const abs = join(targetRoot, rel);
|
|
106
|
+
// Containment guard: never touch anything resolving outside the target.
|
|
107
|
+
if (relative(targetRoot, abs).startsWith("..")) continue;
|
|
108
|
+
try {
|
|
109
|
+
if (existsSync(abs) && statSync(abs).isDirectory() && readdirSync(abs).length === 0) rmdirSync(abs);
|
|
110
|
+
} catch {
|
|
111
|
+
/* ignore */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const target = resolveTarget();
|
|
117
|
+
const manifestPath = join(target, MANIFEST_NAME);
|
|
118
|
+
|
|
119
|
+
function loadManifest() {
|
|
120
|
+
try {
|
|
121
|
+
const data = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
122
|
+
return data && typeof data === "object" && data.files ? data : { version: null, files: {} };
|
|
123
|
+
} catch {
|
|
124
|
+
return { version: null, files: {} };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Uninstall
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
if (values.uninstall) {
|
|
133
|
+
const manifest = loadManifest();
|
|
134
|
+
const removed = [];
|
|
135
|
+
const kept = [];
|
|
136
|
+
for (const [rel, hash] of Object.entries(manifest.files)) {
|
|
137
|
+
const dest = join(target, rel);
|
|
138
|
+
if (!existsSync(dest)) continue;
|
|
139
|
+
if (fileHash(dest) === hash) {
|
|
140
|
+
if (!values["dry-run"]) rmSync(dest, { force: true });
|
|
141
|
+
removed.push(rel);
|
|
142
|
+
} else {
|
|
143
|
+
kept.push(rel);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!values["dry-run"] && existsSync(manifestPath)) rmSync(manifestPath, { force: true });
|
|
147
|
+
if (!values["dry-run"]) pruneEmptyDirs(target, Object.keys(manifest.files));
|
|
148
|
+
const verb = values["dry-run"] ? "Would remove" : "Removed";
|
|
149
|
+
console.log(`${verb} ${removed.length} Goal Mode files from ${target}.`);
|
|
150
|
+
if (kept.length) {
|
|
151
|
+
console.log(`Left ${kept.length} locally-modified file(s) in place:`);
|
|
152
|
+
for (const rel of kept) console.log(`- ${rel}`);
|
|
153
|
+
}
|
|
154
|
+
console.log("Restart OpenCode to unload the components.");
|
|
155
|
+
process.exit(0);
|
|
54
156
|
}
|
|
55
157
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Install
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
const manifest = loadManifest();
|
|
163
|
+
const summary = { copied: [], unchanged: [], conflicts: [], pruned: [] };
|
|
164
|
+
const newManifestFiles = {};
|
|
165
|
+
|
|
166
|
+
for (const dir of COMPONENT_DIRS) {
|
|
167
|
+
const from = join(root, dir);
|
|
168
|
+
for (const rel of listFiles(from)) {
|
|
169
|
+
const relKey = join(dir, rel);
|
|
170
|
+
const source = join(from, rel);
|
|
171
|
+
const dest = join(target, relKey);
|
|
172
|
+
const srcHash = fileHash(source);
|
|
173
|
+
newManifestFiles[relKey] = srcHash;
|
|
174
|
+
|
|
62
175
|
if (existsSync(dest) && !statSync(dest).isFile()) {
|
|
63
176
|
summary.conflicts.push(`${dest} exists but is not a file`);
|
|
64
177
|
continue;
|
|
65
178
|
}
|
|
66
|
-
|
|
67
|
-
|
|
179
|
+
|
|
180
|
+
if (!existsSync(dest)) {
|
|
181
|
+
if (!values["dry-run"]) {
|
|
182
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
183
|
+
copyFileSync(source, dest);
|
|
184
|
+
}
|
|
68
185
|
summary.copied.push(dest);
|
|
69
186
|
continue;
|
|
70
187
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
summary.unchanged.push(dest);
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
summary.conflicts.push(`${dest} differs from packaged ${entry}`);
|
|
188
|
+
|
|
189
|
+
const dstHash = fileHash(dest);
|
|
190
|
+
if (dstHash === srcHash) {
|
|
191
|
+
summary.unchanged.push(dest);
|
|
79
192
|
continue;
|
|
80
193
|
}
|
|
81
|
-
|
|
82
|
-
|
|
194
|
+
|
|
195
|
+
const ownedHash = manifest.files[relKey];
|
|
196
|
+
const weOwnIt = ownedHash !== undefined && ownedHash === dstHash;
|
|
197
|
+
if (values.force || weOwnIt) {
|
|
198
|
+
if (!values["dry-run"]) {
|
|
199
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
200
|
+
copyFileSync(source, dest);
|
|
201
|
+
}
|
|
202
|
+
summary.copied.push(dest);
|
|
203
|
+
continue;
|
|
83
204
|
}
|
|
84
|
-
summary.
|
|
205
|
+
summary.conflicts.push(`${dest} differs from packaged ${relKey} and was locally modified`);
|
|
85
206
|
}
|
|
86
207
|
}
|
|
87
208
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
209
|
+
// Prune files we installed in a previous version that no longer ship (e.g. a
|
|
210
|
+
// plugin split into modules), but only if the user hasn't modified them.
|
|
211
|
+
for (const [relKey, oldHash] of Object.entries(manifest.files)) {
|
|
212
|
+
if (newManifestFiles[relKey] !== undefined) continue;
|
|
213
|
+
const dest = join(target, relKey);
|
|
214
|
+
if (!existsSync(dest)) continue;
|
|
215
|
+
if (fileHash(dest) === oldHash) {
|
|
216
|
+
if (!values["dry-run"]) rmSync(dest, { force: true });
|
|
217
|
+
summary.pruned.push(relKey);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (!values["dry-run"] && summary.pruned.length) pruneEmptyDirs(target, summary.pruned);
|
|
94
221
|
|
|
95
222
|
if (summary.conflicts.length) {
|
|
96
223
|
throw new Error(
|
|
@@ -98,11 +225,18 @@ if (summary.conflicts.length) {
|
|
|
98
225
|
"Refusing to overwrite changed OpenCode component files.",
|
|
99
226
|
...summary.conflicts.map((conflict) => `- ${conflict}`),
|
|
100
227
|
"Use --force to replace them or remove the conflicting files manually.",
|
|
101
|
-
].join("\n")
|
|
228
|
+
].join("\n"),
|
|
102
229
|
);
|
|
103
230
|
}
|
|
104
231
|
|
|
232
|
+
if (!values["dry-run"]) {
|
|
233
|
+
mkdirSync(target, { recursive: true });
|
|
234
|
+
writeFileSync(manifestPath, JSON.stringify({ version: pkg.version, files: newManifestFiles }, null, 2), "utf8");
|
|
235
|
+
}
|
|
236
|
+
|
|
105
237
|
const verb = values["dry-run"] ? "Would install" : "Installed";
|
|
106
|
-
console.log(`${verb} OpenCode Goal Mode into ${target}`);
|
|
107
|
-
console.log(
|
|
238
|
+
console.log(`${verb} OpenCode Goal Mode ${pkg.version} into ${target}`);
|
|
239
|
+
console.log(
|
|
240
|
+
`Files copied: ${summary.copied.length}; unchanged: ${summary.unchanged.length}; pruned: ${summary.pruned.length}`,
|
|
241
|
+
);
|
|
108
242
|
console.log("Restart OpenCode for agents, commands, and plugins to load.");
|
package/docs/research-report.md
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
# Research Report: Goal Mode Hardening
|
|
2
|
-
|
|
3
|
-
## Sources Checked
|
|
4
|
-
|
|
5
|
-
- OpenCode agents, commands, permissions, plugins, and skills documentation.
|
|
6
|
-
- Claude Code subagents and hooks documentation.
|
|
7
|
-
- Codex CLI, Codex Web, subagents/review/sandbox concepts, and repository overview.
|
|
8
|
-
|
|
9
|
-
## Findings
|
|
10
|
-
|
|
11
|
-
Prompt-only Goal Mode is not enough. A strong mode needs separate context windows for exploration and reviews, explicit review handoffs, stale-review invalidation after edits, state preservation through compaction, safer permissions, and repeatable commands.
|
|
12
|
-
|
|
13
|
-
## Claude-Inspired Patterns
|
|
14
|
-
|
|
15
|
-
- Use subagents to protect main context from search logs and broad file reads.
|
|
16
|
-
- Give subagents narrow descriptions and narrow permissions.
|
|
17
|
-
- Keep orchestration in the main agent; subagents return summaries only.
|
|
18
|
-
- Use hooks/lifecycle events to enforce safety and preserve state.
|
|
19
|
-
|
|
20
|
-
## Codex-Inspired Patterns
|
|
21
|
-
|
|
22
|
-
- Run local code review through a separate agent before claiming completion.
|
|
23
|
-
- Use explicit approval/safety modes and sandbox thinking for risky operations.
|
|
24
|
-
- Treat review and verification as a repair loop, not a final paragraph.
|
|
25
|
-
- Track outcomes and repeated failures as part of an improvement loop.
|
|
26
|
-
|
|
27
|
-
## OpenCode Implementation Choices
|
|
28
|
-
|
|
29
|
-
- Agents are Markdown files in `agents/`.
|
|
30
|
-
- Commands are Markdown files in `commands/`.
|
|
31
|
-
- Guardrails live in a local plugin with OpenCode hooks.
|
|
32
|
-
- Tests validate the installable package without copying secrets.
|
|
33
|
-
|
|
34
|
-
## Remaining Platform Limits
|
|
35
|
-
|
|
36
|
-
- OpenCode plugins cannot fully prevent every possible final answer, but `experimental.text.complete` can inject blocking warnings when `Goal Completed` appears while the session is dirty.
|
|
37
|
-
- Review cycle state is runtime in-memory per OpenCode process; compaction preserves it in prompt context, but restart resets plugin memory.
|
|
@@ -1,54 +0,0 @@
|
|
|
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`);
|
|
@@ -1,82 +0,0 @@
|
|
|
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");
|
package/tests/agents.test.mjs
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { filesIn, readRepo, frontmatter, hasLine } from "./helpers.mjs";
|
|
4
|
-
|
|
5
|
-
const requiredAgents = [
|
|
6
|
-
"goal.md",
|
|
7
|
-
"goal-explorer.md",
|
|
8
|
-
"goal-researcher.md",
|
|
9
|
-
"goal-implementer.md",
|
|
10
|
-
"goal-reviewer.md",
|
|
11
|
-
"goal-prompt-auditor.md",
|
|
12
|
-
"goal-diff-reviewer.md",
|
|
13
|
-
"goal-verifier.md",
|
|
14
|
-
"goal-test-reviewer.md",
|
|
15
|
-
"goal-security-reviewer.md",
|
|
16
|
-
"goal-ux-reviewer.md",
|
|
17
|
-
"goal-ops-reviewer.md",
|
|
18
|
-
"goal-doc-reviewer.md",
|
|
19
|
-
"goal-final-auditor.md",
|
|
20
|
-
"goal-deep-researcher.md",
|
|
21
|
-
"goal-web-researcher.md",
|
|
22
|
-
"goal-architect.md",
|
|
23
|
-
"goal-mapper.md",
|
|
24
|
-
"goal-planner.md",
|
|
25
|
-
"goal-coordinator.md",
|
|
26
|
-
"goal-doc-writer.md",
|
|
27
|
-
"goal-commentator.md",
|
|
28
|
-
"goal-api-reviewer.md",
|
|
29
|
-
"goal-data-reviewer.md",
|
|
30
|
-
"goal-perf-reviewer.md",
|
|
31
|
-
"goal-quality-gate.md",
|
|
32
|
-
"goal-completion-guard.md",
|
|
33
|
-
];
|
|
34
|
-
|
|
35
|
-
test("all required agents exist", () => {
|
|
36
|
-
const files = filesIn("agents");
|
|
37
|
-
for (const agent of requiredAgents) assert.ok(files.includes(agent), `${agent} missing`);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test("agents have required frontmatter", () => {
|
|
41
|
-
for (const file of requiredAgents) {
|
|
42
|
-
const fm = frontmatter(readRepo(`agents/${file}`));
|
|
43
|
-
assert.ok(hasLine(fm, "description"), `${file} missing description`);
|
|
44
|
-
assert.ok(hasLine(fm, "mode"), `${file} missing mode`);
|
|
45
|
-
assert.ok(hasLine(fm, "permission"), `${file} missing permission`);
|
|
46
|
-
}
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("primary goal enforces review artifacts and final contract", () => {
|
|
50
|
-
const text = readRepo("agents/goal.md");
|
|
51
|
-
for (const phrase of [
|
|
52
|
-
"Goal Contract",
|
|
53
|
-
"Verification Ledger",
|
|
54
|
-
"Review Ledger",
|
|
55
|
-
"Required review matrix",
|
|
56
|
-
"Review handoff template",
|
|
57
|
-
"Goal Completed",
|
|
58
|
-
"Review cycles: N",
|
|
59
|
-
]) {
|
|
60
|
-
assert.ok(text.includes(phrase), `goal.md missing ${phrase}`);
|
|
61
|
-
}
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test("reviewers are read-only", () => {
|
|
65
|
-
for (const file of requiredAgents.filter((name) => name.includes("reviewer") || name.includes("auditor") || name.includes("verifier"))) {
|
|
66
|
-
const fm = frontmatter(readRepo(`agents/${file}`));
|
|
67
|
-
assert.match(fm, /edit:\s+deny/, `${file} must deny edit`);
|
|
68
|
-
assert.match(fm, /task:\s+deny/, `${file} must deny task nesting`);
|
|
69
|
-
}
|
|
70
|
-
});
|
package/tests/commands.test.mjs
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { filesIn, readRepo, frontmatter, hasLine } from "./helpers.mjs";
|
|
4
|
-
|
|
5
|
-
const requiredCommands = ["goal.md", "goal-contract.md", "goal-review.md", "goal-status.md", "goal-repair.md", "goal-final.md"];
|
|
6
|
-
|
|
7
|
-
test("all required commands exist", () => {
|
|
8
|
-
const files = filesIn("commands");
|
|
9
|
-
for (const command of requiredCommands) assert.ok(files.includes(command), `${command} missing`);
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
test("commands have descriptions and agent bindings", () => {
|
|
13
|
-
for (const file of requiredCommands) {
|
|
14
|
-
const fm = frontmatter(readRepo(`commands/${file}`));
|
|
15
|
-
assert.ok(hasLine(fm, "description"), `${file} missing description`);
|
|
16
|
-
assert.ok(hasLine(fm, "agent"), `${file} missing agent`);
|
|
17
|
-
}
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
test("review and final commands run as subtasks", () => {
|
|
21
|
-
assert.match(frontmatter(readRepo("commands/goal-review.md")), /subtask:\s+true/);
|
|
22
|
-
assert.match(frontmatter(readRepo("commands/goal-final.md")), /subtask:\s+true/);
|
|
23
|
-
});
|
package/tests/helpers.mjs
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
-
import { fileURLToPath } from "node:url";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
|
|
5
|
-
export const root = fileURLToPath(new URL("..", import.meta.url));
|
|
6
|
-
|
|
7
|
-
export function filesIn(dir) {
|
|
8
|
-
return readdirSync(join(root, dir)).filter((file) => file.endsWith(".md") || file.endsWith(".js"));
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function readRepo(path) {
|
|
12
|
-
return readFileSync(join(root, path), "utf8");
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function frontmatter(text) {
|
|
16
|
-
const match = text.match(/^---\n([\s\S]*?)\n---\n/);
|
|
17
|
-
if (!match) throw new Error("Missing YAML frontmatter");
|
|
18
|
-
return match[1];
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function hasLine(fm, key) {
|
|
22
|
-
return new RegExp(`^${key}:`, "m").test(fm);
|
|
23
|
-
}
|
package/tests/install.test.mjs
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { mkdtempSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { tmpdir } from "node:os";
|
|
7
|
-
import { execFileSync } from "node:child_process";
|
|
8
|
-
import { readRepo } from "./helpers.mjs";
|
|
9
|
-
|
|
10
|
-
const repoRoot = fileURLToPath(new URL("..", import.meta.url));
|
|
11
|
-
|
|
12
|
-
test("installer copies only safe OpenCode component directories", () => {
|
|
13
|
-
const text = readRepo("scripts/install.mjs");
|
|
14
|
-
assert.match(text, /copyDirFiles\(join\(root, "agents"\)/);
|
|
15
|
-
assert.match(text, /copyDirFiles\(join\(root, "commands"\)/);
|
|
16
|
-
assert.match(text, /copyDirFiles\(join\(root, "plugins"\)/);
|
|
17
|
-
assert.doesNotMatch(text, /auth|sessions|preauth|failures|hosts\.yml/);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
test("gitignore excludes secrets and dependencies", () => {
|
|
21
|
-
const text = readRepo(".gitignore");
|
|
22
|
-
for (const pattern of ["node_modules/", ".env", ".env.*"]) assert.ok(text.includes(pattern));
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test("installer functionally copies components to project .opencode", () => {
|
|
26
|
-
const temp = mkdtempSync(join(tmpdir(), "goal-install-"));
|
|
27
|
-
execFileSync("node", [join(repoRoot, "scripts", "install.mjs")], { cwd: temp, stdio: "pipe" });
|
|
28
|
-
assert.equal(existsSync(join(temp, ".opencode", "agents", "goal.md")), true);
|
|
29
|
-
assert.equal(existsSync(join(temp, ".opencode", "commands", "goal.md")), true);
|
|
30
|
-
assert.equal(existsSync(join(temp, ".opencode", "plugins", "goal-guard.js")), true);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test("installer dry run does not write files", () => {
|
|
34
|
-
const temp = mkdtempSync(join(tmpdir(), "goal-install-dry-"));
|
|
35
|
-
execFileSync("node", [join(repoRoot, "scripts", "install.mjs"), "--dry-run"], { cwd: temp, stdio: "pipe" });
|
|
36
|
-
assert.equal(existsSync(join(temp, ".opencode")), false);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("installer refuses to overwrite changed destination files", () => {
|
|
40
|
-
const temp = mkdtempSync(join(tmpdir(), "goal-install-conflict-"));
|
|
41
|
-
mkdirSync(join(temp, ".opencode", "agents"), { recursive: true });
|
|
42
|
-
writeFileSync(join(temp, ".opencode", "agents", "goal.md"), "local change\n");
|
|
43
|
-
assert.throws(
|
|
44
|
-
() => execFileSync("node", [join(repoRoot, "scripts", "install.mjs")], { cwd: temp, stdio: "pipe" }),
|
|
45
|
-
/Refusing to overwrite changed OpenCode component files/
|
|
46
|
-
);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("installer force replaces changed destination files", () => {
|
|
50
|
-
const temp = mkdtempSync(join(tmpdir(), "goal-install-force-"));
|
|
51
|
-
mkdirSync(join(temp, ".opencode", "agents"), { recursive: true });
|
|
52
|
-
const dest = join(temp, ".opencode", "agents", "goal.md");
|
|
53
|
-
writeFileSync(dest, "local change\n");
|
|
54
|
-
execFileSync("node", [join(repoRoot, "scripts", "install.mjs"), "--force"], { cwd: temp, stdio: "pipe" });
|
|
55
|
-
assert.equal(readFileSync(dest, "utf8"), readFileSync(join(repoRoot, "agents", "goal.md"), "utf8"));
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test("installer supports explicit target directory", () => {
|
|
59
|
-
const temp = mkdtempSync(join(tmpdir(), "goal-install-target-"));
|
|
60
|
-
const target = join(temp, "custom-opencode");
|
|
61
|
-
execFileSync("node", [join(repoRoot, "scripts", "install.mjs"), "--target", target], { cwd: temp, stdio: "pipe" });
|
|
62
|
-
assert.equal(existsSync(join(target, "agents", "goal.md")), true);
|
|
63
|
-
assert.equal(existsSync(join(target, "plugins", "goal-guard.js")), true);
|
|
64
|
-
});
|