golden-hoop-spell-opencode 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 (55) hide show
  1. package/README.md +184 -0
  2. package/package.json +51 -0
  3. package/shared/SPIKE_RESULTS.md +597 -0
  4. package/shared/agents/ghs-context-haiku.md.template +124 -0
  5. package/shared/agents/ghs-plan-designer.md.template +128 -0
  6. package/shared/agents/ghs-plan-reviewer.md.template +170 -0
  7. package/shared/assets/features.json +67 -0
  8. package/shared/assets/progress.md +35 -0
  9. package/shared/ghs.default.json +7 -0
  10. package/shared/ghs.default.json.notes.md +34 -0
  11. package/shared/ghs.json.example +7 -0
  12. package/shared/opencode.json.example +11 -0
  13. package/shared/references/coding-agent.md +533 -0
  14. package/shared/references/context-snapshot-guide.md +98 -0
  15. package/shared/references/examples.md +299 -0
  16. package/shared/references/plan-designer.md +163 -0
  17. package/shared/references/plan-reviewer.md +193 -0
  18. package/shared/references/sprint-agent.md +261 -0
  19. package/src/index.ts +9 -0
  20. package/src/lib/assets.ts +31 -0
  21. package/src/lib/codegraph.ts +66 -0
  22. package/src/lib/config.ts +278 -0
  23. package/src/lib/nonce.ts +56 -0
  24. package/src/lib/parse.ts +175 -0
  25. package/src/lib/paths.ts +26 -0
  26. package/src/lib/project.ts +28 -0
  27. package/src/lib/scripts/append-progress-session.ts +178 -0
  28. package/src/lib/scripts/append-sprint.ts +121 -0
  29. package/src/lib/scripts/archive-sprint.ts +583 -0
  30. package/src/lib/scripts/init-project.ts +291 -0
  31. package/src/lib/scripts/parallel-utils.ts +380 -0
  32. package/src/lib/scripts/parse-completion-signal.ts +584 -0
  33. package/src/lib/scripts/parse-delimited-output.ts +632 -0
  34. package/src/lib/scripts/resolve-project-dir.ts +130 -0
  35. package/src/lib/scripts/status.ts +292 -0
  36. package/src/lib/scripts/update-feature-status.ts +169 -0
  37. package/src/lib/scripts/validate-structure.ts +290 -0
  38. package/src/lib/state.ts +305 -0
  39. package/src/plugin.ts +76 -0
  40. package/src/prompts/context-codegraph.ts +65 -0
  41. package/src/prompts/context-grep.ts +68 -0
  42. package/src/prompts/feature-impl.ts +78 -0
  43. package/src/prompts/plan-designer.ts +59 -0
  44. package/src/prompts/plan-reviewer.ts +61 -0
  45. package/src/prompts/sprint-planning.ts +47 -0
  46. package/src/tools/archive.ts +278 -0
  47. package/src/tools/code.ts +448 -0
  48. package/src/tools/config.ts +182 -0
  49. package/src/tools/force-archive.ts +195 -0
  50. package/src/tools/init.ts +193 -0
  51. package/src/tools/plan-finalize.ts +333 -0
  52. package/src/tools/plan-review.ts +759 -0
  53. package/src/tools/plan-start.ts +232 -0
  54. package/src/tools/sprint.ts +213 -0
  55. package/src/tools/status.ts +51 -0
@@ -0,0 +1,261 @@
1
+ # Sprint Agent Reference
2
+
3
+ ## Table of Contents
4
+ 1. [Workflow](#workflow)
5
+ 2. [Feature Breakdown](#feature-breakdown)
6
+ 3. [File Schemas](#file-schemas)
7
+ 4. [Examples](#examples)
8
+
9
+ ## Workflow
10
+
11
+ ### When Invoked
12
+
13
+ 1. **New Project**: After initialization
14
+ 2. **New Sprint**: User requests new iteration
15
+ 3. **Requirement Update**: User wants to modify planned features
16
+
17
+ ### Archive Completed Sprints First
18
+
19
+ Before creating a new sprint, archive completed sprints:
20
+
21
+ ```bash
22
+ command python3 ${CLAUDE_PLUGIN_ROOT}/shared/scripts/archive_sprint.py --list --project-dir "<PROJECT_DIR>" # List completed sprints
23
+ command python3 ${CLAUDE_PLUGIN_ROOT}/shared/scripts/archive_sprint.py --dry-run --project-dir "<PROJECT_DIR>" # Preview archive
24
+ command python3 ${CLAUDE_PLUGIN_ROOT}/shared/scripts/archive_sprint.py --project-dir "<PROJECT_DIR>" # Archive
25
+ ```
26
+
27
+ Archived sprints move to `.ghs/archived/`.
28
+
29
+ ### Planning Process
30
+
31
+ #### Step 1: Analyze Requirements
32
+
33
+ Break down requirements into categories:
34
+
35
+ - **Core Features** - Essential for MVP/sprint goal
36
+ - **Supporting Features** - Enhance core functionality
37
+ - **Technical Enablers** - Infrastructure, refactoring
38
+
39
+ Context sources:
40
+ - User's high-level requirements
41
+ - Existing `.ghs/features.json`
42
+ - Previous sprint learnings from `.ghs/progress.md`
43
+
44
+ #### Step 2: Create Atomic Features
45
+
46
+ Each feature must be:
47
+
48
+ - **Atomic**: Completable in one session (2-4 hours)
49
+ - **Independent**: Minimal dependencies
50
+ - **Testable**: Clear acceptance criteria
51
+ - **Valuable**: Delivers user value
52
+
53
+ #### Step 3: Categorize and Prioritize
54
+
55
+ Categories: `core`, `ui`, `api`, `auth`, `data`, `infra`
56
+
57
+ Priorities:
58
+ - `high`: Sprint blockers, core functionality
59
+ - `medium`: Important but not blocking
60
+ - `low`: Nice to have, can be deferred
61
+
62
+ #### Step 4: Define Acceptance Criteria
63
+
64
+ Format: `Given [context], when [action], then [outcome]`
65
+
66
+ Example: `Given a user is logged in, when they click "Add to Cart", then the item should appear in their cart with correct quantity.`
67
+
68
+ #### Step 5: Order by Dependencies
69
+
70
+ Rules:
71
+ 1. Infrastructure first, then features
72
+ 2. Core before supporting features
73
+ 3. UI after backend support
74
+ 4. Features with dependencies must wait
75
+
76
+ ## Feature Breakdown
77
+
78
+ ### Feature Definition
79
+
80
+ ```json
81
+ {
82
+ "id": "s1-feat-001",
83
+ "category": "core | ui | api | auth | data | infra",
84
+ "priority": "high | medium | low",
85
+ "title": "Short feature title",
86
+ "description": "Detailed description",
87
+ "acceptance_criteria": ["Criterion 1", "Criterion 2"],
88
+ "technical_notes": "Implementation hints",
89
+ "status": "pending",
90
+ "blocked_reason": "Optional. Explanation of why the feature is blocked. Only present when status is 'blocked'.",
91
+ "dependencies": [],
92
+ "estimated_complexity": "small | medium | large",
93
+ "files_affected": ["path/to/file.ts"]
94
+ }
95
+ ```
96
+
97
+ Note: Feature IDs follow the format `s{N}-feat-{NNN}` where `N` matches the parent sprint number and `NNN` is a zero-padded sequential number.
98
+
99
+ ### Complexity Estimation
100
+
101
+ - **small**: < 2 hours, simple changes
102
+ - **medium**: 2-4 hours, moderate complexity
103
+ - **large**: 4+ hours, break into smaller features
104
+
105
+ ### Dependencies
106
+
107
+ Mark dependencies with feature IDs:
108
+
109
+ ```json
110
+ "dependencies": ["s1-feat-001", "s1-feat-002"]
111
+ ```
112
+
113
+ ## File Schemas
114
+
115
+ ### ID Format Rules
116
+
117
+ Sprint IDs and feature IDs must follow strict naming conventions for consistency and tooling compatibility:
118
+
119
+ - **Sprint ID**: matches `^s\d{1,4}$` — e.g., `s1`, `s2`, `s10`, `s9999`
120
+ - **Feature ID**: matches `^s\d{1,4}-feat-\d{3}$` — e.g., `s1-feat-001`, `s2-feat-010`, `s10-feat-003`
121
+
122
+ The sprint number in the feature ID must match its parent sprint. Feature numbers are zero-padded to 3 digits and sequential within each sprint.
123
+
124
+ ### features.json Structure
125
+
126
+ ```json
127
+ {
128
+ "project": {
129
+ "name": "string (required)",
130
+ "description": "string (required)",
131
+ "tech_stack": ["string"],
132
+ "created_at": "YYYY-MM-DD (required)"
133
+ },
134
+ "sprints": [
135
+ {
136
+ "id": "string (required, unique, format: s{number})",
137
+ "name": "string (required)",
138
+ "goal": "string",
139
+ "status": "planning | in_progress | completed | on_hold",
140
+ "created_at": "YYYY-MM-DD",
141
+ "features": [ /* feature objects */ ]
142
+ }
143
+ ],
144
+ "metadata": {
145
+ "version": "1.0.0",
146
+ "last_updated": "YYYY-MM-DD"
147
+ }
148
+ }
149
+ ```
150
+
151
+ ### Sprint Status Values
152
+
153
+ | Status | Description |
154
+ |--------|-------------|
155
+ | `planning` | Being defined |
156
+ | `in_progress` | Features being implemented |
157
+ | `completed` | All features done |
158
+ | `on_hold` | Temporarily paused |
159
+
160
+ ### Feature Status Values
161
+
162
+ | Status | Description |
163
+ |--------|-------------|
164
+ | `pending` | Not started |
165
+ | `in_progress` | Currently being worked on |
166
+ | `completed` | Fully implemented and tested |
167
+ | `blocked` | Cannot proceed |
168
+
169
+ ### Category Definitions
170
+
171
+ | Category | Description |
172
+ |----------|-------------|
173
+ | `core` | Business logic, main features |
174
+ | `ui` | User interface, components |
175
+ | `api` | API routes, data fetching |
176
+ | `auth` | Authentication, authorization |
177
+ | `data` | Database, models, migrations |
178
+ | `infra` | Configuration, deployment, tooling |
179
+
180
+ ## Examples
181
+
182
+ See [examples.md](examples.md) for complete examples.
183
+
184
+ ## Output Requirements
185
+
186
+ ### File Management
187
+
188
+ Only modify `.ghs/features.json` and `.ghs/progress.md`. Do NOT create additional files like planning summaries, architecture docs, or data model documents. All planning information goes into these two files.
189
+
190
+ Before writing, resolve the project directory with `command python3 ${CLAUDE_PLUGIN_ROOT}/shared/scripts/resolve_project_dir.py` and use the returned absolute path for all file reads/writes. This prevents files from being written to the wrong location (e.g., inside `.ghs/`) if the working directory shifts during the session.
191
+
192
+ ### Update features.json
193
+
194
+ Add new sprint with structured features following schema above.
195
+
196
+ ### Update progress.md
197
+
198
+ Add planning session entry at top:
199
+
200
+ ```markdown
201
+ ## Sprint Planning - YYYY-MM-DD
202
+ **Agent**: Sprint Agent
203
+ **Sprint**: [Sprint ID and Name]
204
+
205
+ ### Requirements Received
206
+ - [User's requirement summary]
207
+
208
+ ### Features Planned
209
+ - Total: N features
210
+ - High priority: N
211
+ - Medium priority: N
212
+ - Low priority: N
213
+
214
+ ### Sprint Goal
215
+ [Clear goal statement]
216
+
217
+ ### Implementation Order
218
+ 1. [feature-id] - [title]
219
+ 2. [feature-id] - [title]
220
+
221
+ ### Notes
222
+ [Any context or decisions]
223
+ ```
224
+
225
+ ### Summary Output Format
226
+
227
+ Display this summary in the terminal and ask the user to confirm before finalizing the sprint:
228
+
229
+ ```markdown
230
+ ## Sprint Planning Complete
231
+
232
+ ### Sprint: [Name]
233
+ **Goal**: [Sprint goal]
234
+
235
+ ### Feature Summary
236
+ - Total features: N
237
+ - High priority: N (list IDs)
238
+ - Medium priority: N
239
+ - Low priority: N
240
+
241
+ ### Recommended Implementation Order
242
+ 1. [id] [title] - [complexity]
243
+ 2. [id] [title] - [complexity]
244
+
245
+ ### Dependencies
246
+ - [id] depends on [id]
247
+ - No blockers for: [ids]
248
+
249
+ ### Ready for Development
250
+ Run the Coding Agent with the first pending feature: [first-feature-id]
251
+ ```
252
+
253
+ After displaying the summary, ask the user to confirm. No git commit needed — `.ghs/` tracking files are local metadata (gitignored by `ghs:init`).
254
+
255
+ ## Critical Rules
256
+
257
+ 1. **Never Remove Features** - Only add or change status
258
+ 2. **Unique IDs** - Each feature must have a unique ID
259
+ 3. **Respect Tech Stack** - Features must be achievable
260
+ 4. **Balance Sprint** - Mix of complexity levels
261
+ 5. **Document Decisions** - Explain prioritization rationale
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ // Plugin entry — default-exported re-export of the `ghsPlugin` Plugin function
2
+ // defined in `src/plugin.ts`.
3
+ //
4
+ // `package.json` points `main` at this file (`src/index.ts`); OpenCode's
5
+ // plugin loader resolves the module, takes the default export, and invokes
6
+ // it as the plugin's `server` function. The actual Plugin implementation
7
+ // lives in `plugin.ts` to keep this entry focused on module resolution.
8
+
9
+ export { ghsPlugin as default } from "./plugin.ts";
@@ -0,0 +1,31 @@
1
+ // Read plugin-bundled text assets (templates, default config, fixtures) from
2
+ // `<pluginRoot>/shared/<relativePath>`.
3
+ //
4
+ // Assets are shipped with the npm package (see `package.json` `files:
5
+ // ["src","shared"]`). They are read at runtime — not inlined — so a plugin
6
+ // upgrade picks up new asset content without a rebuild.
7
+ //
8
+ // `loadAsset` is the single entry point for reading these files; callers
9
+ // should not reach for `Bun.file`/`fs.readFile` against the shared tree
10
+ // directly, so that the root-resolution strategy stays in one place.
11
+
12
+ import { resolve } from "node:path";
13
+ import { pluginRoot } from "./paths";
14
+
15
+ /**
16
+ * Read a text asset relative to `<pluginRoot>/shared/`.
17
+ *
18
+ * @param name - asset path relative to `shared/`, e.g. `"assets/features.json"`
19
+ * or `"ghs.default.json"`.
20
+ * @returns the file's UTF-8 contents.
21
+ * @throws {Error} if the file does not exist or cannot be read.
22
+ */
23
+ export async function loadAsset(name: string): Promise<string> {
24
+ const filePath = resolve(pluginRoot(), "shared", name);
25
+ const file = Bun.file(filePath);
26
+ const exists = await file.exists();
27
+ if (!exists) {
28
+ throw new Error(`Asset not found: ${filePath}`);
29
+ }
30
+ return file.text();
31
+ }
@@ -0,0 +1,66 @@
1
+ // Runtime probe for the codegraph MCP integration.
2
+ //
3
+ // codegraph (R1) is an *optional* knowledge-graph backend. The host project
4
+ // opts in by running the codegraph MCP server, which materialises a
5
+ // `.codegraph/` directory at the project root. The plan dispatcher's
6
+ // `ghs-plan-start` tool calls `detectCodegraph()` to decide which Context
7
+ // Subagent prompt to use:
8
+ //
9
+ // - `.codegraph/` present → `context-codegraph` prompt (graph-aware).
10
+ // - `.codegraph/` absent → `context-grep` prompt (grep fallback).
11
+ //
12
+ // This function is intentionally a *pure* probe: it inspects the filesystem
13
+ // and returns a boolean. It does NOT start the MCP server — that is declared
14
+ // statically in `opencode.json` (`mcp.codegraph`) and loaded by the OpenCode
15
+ // core once at startup (plan §3.4 D3, §3.5). Running-time availability is a
16
+ // separate concern (the dispatcher additionally asks the main AI to issue a
17
+ // `codegraph_status` call); here we only answer "was codegraph ever
18
+ // initialised for this project?".
19
+ //
20
+ // Style follows s1-feat-008's `resolve-project-dir.ts` / `paths.ts`: no
21
+ // `process.exit`, no `console.log`, defensive on bad input.
22
+
23
+ import { statSync } from "node:fs";
24
+ import { resolve } from "node:path";
25
+
26
+ /**
27
+ * Probe whether the codegraph backend is available for a project.
28
+ *
29
+ * Returns `true` iff `<projectDir>/.codegraph/` exists and is a directory.
30
+ *
31
+ * Defensive contract (AC #3): any failure mode returns `false` rather than
32
+ * throwing:
33
+ * - empty / whitespace-only `projectDir`
34
+ * - non-string input (coerced via the string guard)
35
+ * - path that does not exist
36
+ * - path that exists but is a file (not a directory)
37
+ * - any underlying `statSync` error (permissions, broken symlink, EIO, …)
38
+ *
39
+ * The probe specifically checks for a *directory* — a stray `.codegraph`
40
+ * file does not count as "codegraph initialised".
41
+ *
42
+ * @param projectDir - absolute or relative path to the host project root.
43
+ * @returns `true` when the codegraph directory is present, `false` otherwise.
44
+ */
45
+ export function detectCodegraph(projectDir: string): boolean {
46
+ // Guard against empty / non-string input. A missing or blank projectDir
47
+ // means we have nothing meaningful to probe — report "not available" and
48
+ // let the dispatcher fall back to the grep path rather than crash.
49
+ if (typeof projectDir !== "string" || projectDir.trim().length === 0) {
50
+ return false;
51
+ }
52
+
53
+ // `resolve` normalises relative paths against process.cwd() and collapses
54
+ // `.`/`..` segments. We resolve defensively inside try/catch because
55
+ // `resolve` itself is pure but the subsequent `statSync` touches the FS.
56
+ try {
57
+ const codegraphPath = resolve(projectDir, ".codegraph");
58
+ const stats = statSync(codegraphPath);
59
+ return stats.isDirectory();
60
+ } catch {
61
+ // Any stat failure — ENOENT (missing), ENOTDIR (a parent is a file),
62
+ // EACCES (permissions), or a dangling symlink — means codegraph is not
63
+ // usable. Suppress and report `false` per the defensive contract.
64
+ return false;
65
+ }
66
+ }
@@ -0,0 +1,278 @@
1
+ // Load `.ghs/ghs.json`, merge with defaults, and render agent markdown
2
+ // templates with the resolved model IDs.
3
+ //
4
+ // This module is the load-bearing implementation for R3 (Round 6): user-
5
+ // configurable model IDs. The user's `.ghs/ghs.json` overrides
6
+ // `shared/ghs.default.json` on a per-field basis. The merged config drives
7
+ // `syncAgents()`, which renders the three subagent markdown templates
8
+ // (`ghs-context-haiku`, `ghs-plan-designer`, `ghs-plan-reviewer`) into
9
+ // `<projectDir>/.opencode/agents/` so opencode picks them up on next start.
10
+ //
11
+ // Spike 004 verified that `String.replaceAll()` over the template body +
12
+ // fresh opencode process is sufficient — no template engine needed.
13
+
14
+ import { z } from "zod";
15
+ import { resolve } from "node:path";
16
+ import { mkdir } from "node:fs/promises";
17
+
18
+ // -----------------------------------------------------------------------------
19
+ // Schema
20
+ // -----------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Zod schema for `.ghs/ghs.json`. `strict()` rejects unknown top-level fields
24
+ * (e.g. a typo like `"model"` instead of `"models"`) so misconfiguration is
25
+ * surfaced loudly rather than silently ignored.
26
+ */
27
+ export const GhsConfigSchema = z.strictObject({
28
+ models: z.strictObject({
29
+ context: z.string(),
30
+ designer: z.string(),
31
+ reviewer: z.string(),
32
+ }),
33
+ });
34
+
35
+ export type GhsConfig = z.infer<typeof GhsConfigSchema>;
36
+
37
+ // -----------------------------------------------------------------------------
38
+ // Internal helpers
39
+ // -----------------------------------------------------------------------------
40
+
41
+ /**
42
+ * The three placeholders recognised inside `*.md.template` files. Each is
43
+ * substituted with the corresponding model ID from the resolved config.
44
+ */
45
+ const PLACEHOLDERS = {
46
+ context: "__GHS_MODEL_CONTEXT__",
47
+ designer: "__GHS_MODEL_DESIGNER__",
48
+ reviewer: "__GHS_MODEL_REVIEWER__",
49
+ } as const;
50
+
51
+ /**
52
+ * Map from agent name → the placeholder it cares about. All three templates
53
+ * share the same substitution pass (every placeholder is replaced on every
54
+ * template), but this map documents which placeholder each template is
55
+ * *expected* to contain. Templates without placeholders pass through
56
+ * unchanged (acceptance criterion #7).
57
+ */
58
+ const AGENT_NAMES = ["ghs-context-haiku", "ghs-plan-designer", "ghs-plan-reviewer"] as const;
59
+ type AgentName = (typeof AGENT_NAMES)[number];
60
+
61
+ /** Resolve the default config path under the plugin root. */
62
+ function defaultConfigPath(pluginRootDir: string): string {
63
+ return resolve(pluginRootDir, "shared", "ghs.default.json");
64
+ }
65
+
66
+ /** Resolve the user config path under the project directory. */
67
+ function userConfigPath(projectDir: string): string {
68
+ return resolve(projectDir, ".ghs", "ghs.json");
69
+ }
70
+
71
+ /** Resolve a template path under `<pluginRoot>/shared/agents/<name>.md.template`. */
72
+ function templatePath(pluginRootDir: string, name: string): string {
73
+ return resolve(pluginRootDir, "shared", "agents", `${name}.md.template`);
74
+ }
75
+
76
+ /** Resolve an output agent markdown path under `<projectDir>/.opencode/agents/`. */
77
+ function outputPath(projectDir: string, name: string): string {
78
+ return resolve(projectDir, ".opencode", "agents", `${name}.md`);
79
+ }
80
+
81
+ /**
82
+ * Check whether a file exists. Thin wrapper over `Bun.file().exists()` so
83
+ * callers can `await fileExists(p)` without juggling a BunFile object.
84
+ */
85
+ export async function fileExists(path: string): Promise<boolean> {
86
+ return Bun.file(path).exists();
87
+ }
88
+
89
+ /** Read + JSON-parse a file, throwing a descriptive error on any failure. */
90
+ async function readJsonFile(path: string, label: string): Promise<unknown> {
91
+ const file = Bun.file(path);
92
+ const exists = await file.exists();
93
+ if (!exists) {
94
+ throw new Error(`Failed to read ${label}: file not found at ${path}`);
95
+ }
96
+ let text: string;
97
+ try {
98
+ text = await file.text();
99
+ } catch (err) {
100
+ throw new Error(`Failed to read ${label} at ${path}: ${(err as Error).message}`);
101
+ }
102
+ try {
103
+ return JSON.parse(text);
104
+ } catch (err) {
105
+ throw new Error(
106
+ `Failed to parse ${label} at ${path}: invalid JSON — ${(err as Error).message}`,
107
+ );
108
+ }
109
+ }
110
+
111
+ // -----------------------------------------------------------------------------
112
+ // Public API
113
+ // -----------------------------------------------------------------------------
114
+
115
+ /**
116
+ * Load and validate the GHS config, merging the user's `.ghs/ghs.json` with
117
+ * the plugin's `shared/ghs.default.json` on a per-field basis.
118
+ *
119
+ * Field-level fallback rules:
120
+ * - If `.ghs/ghs.json` does not exist → all three model fields come from
121
+ * defaults; `defaults_used` is `true`.
122
+ * - If `.ghs/ghs.json` exists and is missing one or more model fields →
123
+ * those fields fall back to defaults; `defaults_used` is `true`.
124
+ * - If `.ghs/ghs.json` exists with all three model fields set → no
125
+ * fallback; `defaults_used` is `false`.
126
+ *
127
+ * `defaults_used` is therefore `true` whenever ANY field fell back, and
128
+ * `false` only when the user's file fully specified all three models.
129
+ *
130
+ * Unknown top-level fields in either file are rejected by Zod `.strict()`.
131
+ *
132
+ * @param projectDir - absolute path to the host project (where `.ghs/` lives).
133
+ * @param pluginRootDir - absolute path to this plugin's package root.
134
+ * @returns `{ config, defaults_used }` where `config` matches `GhsConfig`.
135
+ * @throws {Error} if either file is missing (defaults must exist), unparseable,
136
+ * or schema-invalid.
137
+ */
138
+ export async function loadGhsConfig(
139
+ projectDir: string,
140
+ pluginRootDir: string,
141
+ ): Promise<{ config: GhsConfig; defaults_used: boolean }> {
142
+ // Defaults are always required — they ship with the plugin.
143
+ const defaultRaw = await readJsonFile(defaultConfigPath(pluginRootDir), "ghs.default.json");
144
+ const defaultParsed = GhsConfigSchema.parse(defaultRaw);
145
+
146
+ // User file is optional; if present, overlay per-field.
147
+ const userFile = userConfigPath(projectDir);
148
+ const userExists = await fileExists(userFile);
149
+
150
+ if (!userExists) {
151
+ return { config: defaultParsed, defaults_used: true };
152
+ }
153
+
154
+ const userRaw = await readJsonFile(userFile, "ghs.json");
155
+
156
+ // Validate user file shape with the same strict schema — this is what
157
+ // surfaces "extra top-level field" as a hard error (AC #5) and what
158
+ // catches malformed structures before the per-field merge below.
159
+ const userParsed = GhsConfigSchema.parse(userRaw);
160
+
161
+ // Per-field merge. A field falls back to its default when the user's
162
+ // value is absent *or* the empty string. Empty-string fallback matters
163
+ // because the feature's AC #3 explicitly tests `models.context: ""`.
164
+ //
165
+ // `defaults_used` is true if ANY of the three fields fell back — i.e. if
166
+ // the user's config did not fully specify all three models. This matches
167
+ // the feature's task notes:
168
+ // - all 3 from default → defaults_used = true
169
+ // - some fields missing → defaults_used = true (partial fallback)
170
+ // - all 3 set by user → defaults_used = false
171
+ let contextFellBack = false;
172
+ let designerFellBack = false;
173
+ let reviewerFellBack = false;
174
+
175
+ const context =
176
+ userParsed.models.context && userParsed.models.context.length > 0
177
+ ? userParsed.models.context
178
+ : ((contextFellBack = true), defaultParsed.models.context);
179
+ const designer =
180
+ userParsed.models.designer && userParsed.models.designer.length > 0
181
+ ? userParsed.models.designer
182
+ : ((designerFellBack = true), defaultParsed.models.designer);
183
+ const reviewer =
184
+ userParsed.models.reviewer && userParsed.models.reviewer.length > 0
185
+ ? userParsed.models.reviewer
186
+ : ((reviewerFellBack = true), defaultParsed.models.reviewer);
187
+
188
+ const merged: GhsConfig = { models: { context, designer, reviewer } };
189
+ const defaults_used = contextFellBack || designerFellBack || reviewerFellBack;
190
+
191
+ return { config: merged, defaults_used };
192
+ }
193
+
194
+ /**
195
+ * Render a single agent template by substituting the three model-ID
196
+ * placeholders with values from `config`.
197
+ *
198
+ * Templates without placeholders pass through unchanged (AC #7) —
199
+ * `String.replaceAll()` is a no-op when the target string is absent.
200
+ *
201
+ * @param name - agent template name (without `.md.template`).
202
+ * @param config - resolved config providing the substitution values.
203
+ * @param pluginRootDir - plugin package root (where `shared/agents/` lives).
204
+ * @returns the rendered template body.
205
+ * @throws {Error} if the template file is missing or unreadable.
206
+ */
207
+ export async function renderAgentTemplate(
208
+ name: string,
209
+ config: GhsConfig,
210
+ pluginRootDir: string,
211
+ ): Promise<string> {
212
+ const path = templatePath(pluginRootDir, name);
213
+ const file = Bun.file(path);
214
+ const exists = await file.exists();
215
+ if (!exists) {
216
+ throw new Error(`Agent template not found: ${path}`);
217
+ }
218
+ const body = await file.text();
219
+ return body
220
+ .replaceAll(PLACEHOLDERS.context, config.models.context)
221
+ .replaceAll(PLACEHOLDERS.designer, config.models.designer)
222
+ .replaceAll(PLACEHOLDERS.reviewer, config.models.reviewer);
223
+ }
224
+
225
+ /**
226
+ * Result of `syncAgents()`. Returned to tool callers so they can report
227
+ * which files were written, which models were applied, and whether any
228
+ * defaults leaked through.
229
+ */
230
+ export interface SyncAgentsResult {
231
+ /** Absolute paths of the 3 rendered agent markdown files written. */
232
+ written: string[];
233
+ /** The model IDs that were substituted into the templates. */
234
+ models: GhsConfig["models"];
235
+ /** `true` if any of the 3 model fields fell back to the default config. */
236
+ defaults_used: boolean;
237
+ }
238
+
239
+ /**
240
+ * Load config + render all three agent templates + write them to
241
+ * `<projectDir>/.opencode/agents/ghs-*.md`.
242
+ *
243
+ * Creates `<projectDir>/.opencode/agents/` if missing. Does NOT touch the
244
+ * opencode runtime — the caller (e.g. the `ghs-config` tool) is responsible
245
+ * for telling the user to restart opencode (per spike 004's finding that
246
+ * agent markdown requires a fresh process).
247
+ *
248
+ * @param projectDir - host project directory (target of `.opencode/agents/`).
249
+ * @param pluginRootDir - plugin package root (source of templates + defaults).
250
+ * @returns `SyncAgentsResult`.
251
+ */
252
+ export async function syncAgents(
253
+ projectDir: string,
254
+ pluginRootDir: string,
255
+ ): Promise<SyncAgentsResult> {
256
+ const { config, defaults_used } = await loadGhsConfig(projectDir, pluginRootDir);
257
+
258
+ const written: string[] = [];
259
+ // Ensure `<projectDir>/.opencode/agents/` exists before we write into it.
260
+ // Bun.write creates parent dirs automatically in recent versions, but we
261
+ // mkdir explicitly so behaviour is stable across versions and obvious to
262
+ // readers (and so AC #8 "creates .opencode/agents/ if missing" holds even
263
+ // on a clean project dir).
264
+ await mkdir(resolve(projectDir, ".opencode", "agents"), { recursive: true });
265
+
266
+ for (const name of AGENT_NAMES) {
267
+ const rendered = await renderAgentTemplate(name, config, pluginRootDir);
268
+ const out = outputPath(projectDir, name);
269
+ await Bun.write(out, rendered);
270
+ written.push(out);
271
+ }
272
+
273
+ return {
274
+ written,
275
+ models: config.models,
276
+ defaults_used,
277
+ };
278
+ }