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.
- package/README.md +184 -0
- package/package.json +51 -0
- package/shared/SPIKE_RESULTS.md +597 -0
- package/shared/agents/ghs-context-haiku.md.template +124 -0
- package/shared/agents/ghs-plan-designer.md.template +128 -0
- package/shared/agents/ghs-plan-reviewer.md.template +170 -0
- package/shared/assets/features.json +67 -0
- package/shared/assets/progress.md +35 -0
- package/shared/ghs.default.json +7 -0
- package/shared/ghs.default.json.notes.md +34 -0
- package/shared/ghs.json.example +7 -0
- package/shared/opencode.json.example +11 -0
- package/shared/references/coding-agent.md +533 -0
- package/shared/references/context-snapshot-guide.md +98 -0
- package/shared/references/examples.md +299 -0
- package/shared/references/plan-designer.md +163 -0
- package/shared/references/plan-reviewer.md +193 -0
- package/shared/references/sprint-agent.md +261 -0
- package/src/index.ts +9 -0
- package/src/lib/assets.ts +31 -0
- package/src/lib/codegraph.ts +66 -0
- package/src/lib/config.ts +278 -0
- package/src/lib/nonce.ts +56 -0
- package/src/lib/parse.ts +175 -0
- package/src/lib/paths.ts +26 -0
- package/src/lib/project.ts +28 -0
- package/src/lib/scripts/append-progress-session.ts +178 -0
- package/src/lib/scripts/append-sprint.ts +121 -0
- package/src/lib/scripts/archive-sprint.ts +583 -0
- package/src/lib/scripts/init-project.ts +291 -0
- package/src/lib/scripts/parallel-utils.ts +380 -0
- package/src/lib/scripts/parse-completion-signal.ts +584 -0
- package/src/lib/scripts/parse-delimited-output.ts +632 -0
- package/src/lib/scripts/resolve-project-dir.ts +130 -0
- package/src/lib/scripts/status.ts +292 -0
- package/src/lib/scripts/update-feature-status.ts +169 -0
- package/src/lib/scripts/validate-structure.ts +290 -0
- package/src/lib/state.ts +305 -0
- package/src/plugin.ts +76 -0
- package/src/prompts/context-codegraph.ts +65 -0
- package/src/prompts/context-grep.ts +68 -0
- package/src/prompts/feature-impl.ts +78 -0
- package/src/prompts/plan-designer.ts +59 -0
- package/src/prompts/plan-reviewer.ts +61 -0
- package/src/prompts/sprint-planning.ts +47 -0
- package/src/tools/archive.ts +278 -0
- package/src/tools/code.ts +448 -0
- package/src/tools/config.ts +182 -0
- package/src/tools/force-archive.ts +195 -0
- package/src/tools/init.ts +193 -0
- package/src/tools/plan-finalize.ts +333 -0
- package/src/tools/plan-review.ts +759 -0
- package/src/tools/plan-start.ts +232 -0
- package/src/tools/sprint.ts +213 -0
- 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
|
+
}
|