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,448 @@
1
+ // `ghs-code` tool — entry point of the feature-implementation workflow.
2
+ //
3
+ // This is the s4-feat-004 productisation of the source plugin's coding
4
+ // workflow (plan §3.4 D2 / §3.5 / §3.7 step 5: code). It is a *thin wrapper*
5
+ // composing three Wave-1 modules:
6
+ // - `getReadyFeatures` / `buildBatches` (s4-feat-002 port of parallel_utils)
7
+ // — finds the current sprint's ready features (status pending AND deps
8
+ // completed) and groups them into conflict-free parallel batches.
9
+ // - `FEATURE_IMPL_PROMPT` (s4-feat-003) — the dispatch prompt the main chat
10
+ // AI hands to the Task tool to spawn an isolated coding subagent that
11
+ // implements ONE feature end-to-end. The template carries two
12
+ // placeholders (`<PROJECT_DIR>` / `<feature_id>`) that we substitute here.
13
+ // - `resolveProjectDir(ctx)` (s1-feat-006) — explicit `project_dir` arg
14
+ // overrides the opencode session's worktree/directory.
15
+ //
16
+ // What the tool does NOT do (by design — see the feature's technical_notes):
17
+ // - It does NOT spawn the coding subagent itself. The main AI does that via
18
+ // the Task tool using the dispatch text this tool returns.
19
+ // - It does NOT call `parseCompletionSignal`. The main AI invokes the parser
20
+ // after the subagent returns, then updates features.json status itself
21
+ // (e.g. via `ghs-status` / the `update-feature-status` writer).
22
+ // - It does NOT write features.json or touch the agent registry. It only
23
+ // READS features.json and returns dispatch guidance.
24
+ //
25
+ // Style follows s2-feat-003's `sprint.ts` and s3-feat-006's `plan-start.ts`
26
+ // (thin `tool({...})` wrapper, descriptive LLM-facing result prose) and
27
+ // s1-feat-008's I/O style (`Bun.file(...).text()` + `JSON.parse`, no
28
+ // `process.exit`, no `console.log`). Per CLAUDE.md the returned text mixes
29
+ // 中文 prose with English identifiers / field names / paths.
30
+
31
+ import { tool } from "@opencode-ai/plugin";
32
+ import type { ToolContext } from "@opencode-ai/plugin/tool";
33
+ import { resolve, join } from "node:path";
34
+
35
+ import { resolveProjectDir } from "../lib/project.ts";
36
+ import {
37
+ getReadyFeatures,
38
+ buildBatches,
39
+ summarizeFeature,
40
+ type FeaturesData,
41
+ type Feature,
42
+ } from "../lib/scripts/parallel-utils.ts";
43
+ import { FEATURE_IMPL_PROMPT } from "../prompts/feature-impl.ts";
44
+
45
+ /**
46
+ * Render `FEATURE_IMPL_PROMPT` with its two placeholders substituted.
47
+ *
48
+ * The template (s4-feat-003) carries `<PROJECT_DIR>` and `<feature_id>`
49
+ * placeholders that the main AI expects to be already-filled when it reads the
50
+ * tool result — it then hands the rendered text verbatim to the Task tool to
51
+ * spawn the coding subagent. We substitute defensively so a placeholder that
52
+ * somehow appears inside `projectDir` or `featureId` can't recurse.
53
+ */
54
+ function renderFeatureImplPrompt(
55
+ projectDir: string,
56
+ featureId: string,
57
+ ): string {
58
+ return FEATURE_IMPL_PROMPT.replace(/<PROJECT_DIR>/g, projectDir).replace(
59
+ /<feature_id>/g,
60
+ featureId,
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Project a feature dict onto the small summary the dispatch text needs.
66
+ *
67
+ * Mirrors `summarizeFeature` from parallel-utils but also surfaces the
68
+ * `acceptance_criteria` list, which the dispatch guidance shows the AI inline
69
+ * so it can sanity-check the selected feature before dispatching (the subagent
70
+ * re-reads the full record from features.json itself, but a one-glance summary
71
+ * in the tool result keeps the main chat oriented). Defensive on every field
72
+ * — features.json is validated upstream but we never want a malformed entry to
73
+ * crash the tool.
74
+ */
75
+ interface FeatureBrief {
76
+ id: string;
77
+ title: string;
78
+ status: string;
79
+ files_affected: string[];
80
+ dependencies: string[];
81
+ acceptance_criteria: string[];
82
+ }
83
+
84
+ function toBrief(feat: Feature): FeatureBrief {
85
+ const base = summarizeFeature(feat);
86
+ const ac = feat["acceptance_criteria"];
87
+ return {
88
+ ...base,
89
+ acceptance_criteria: Array.isArray(ac) ? (ac as string[]) : [],
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Format a feature brief as the compact multi-line block the dispatch
95
+ * guidance embeds (id / title / status / files / deps / AC bullets). Kept
96
+ * short — the subagent reads the full record itself; this is just the at-a-
97
+ * glance orientation the main AI needs to pick a target and confirm.
98
+ */
99
+ function formatBrief(feat: FeatureBrief): string {
100
+ const lines: string[] = [];
101
+ lines.push(`id: ${feat.id}`);
102
+ lines.push(`title: ${feat.title}`);
103
+ lines.push(`status: ${feat.status}`);
104
+ lines.push(
105
+ `files: ${feat.files_affected.length > 0 ? feat.files_affected.join(", ") : "(none)"}`,
106
+ );
107
+ lines.push(
108
+ `deps: ${feat.dependencies.length > 0 ? feat.dependencies.join(", ") : "(none)"}`,
109
+ );
110
+ if (feat.acceptance_criteria.length > 0) {
111
+ lines.push("acceptance_criteria:");
112
+ for (const ac of feat.acceptance_criteria) {
113
+ lines.push(` - ${ac}`);
114
+ }
115
+ }
116
+ return lines.join("\n");
117
+ }
118
+
119
+ /**
120
+ * The `ghs-code` tool definition. Registered by the plugin entry point
121
+ * (s4-feat-005) under the hyphenated `ghs-code` key — the 10th and final tool
122
+ * in the plan-§3.4-D2 surface.
123
+ */
124
+ export const codeTool = tool({
125
+ description:
126
+ "Entry point of the feature-implementation workflow. Reads features.json, finds the current " +
127
+ "sprint's ready features (status pending AND all dependencies completed), and returns LLM-facing " +
128
+ "dispatch guidance embedding the FEATURE_IMPL_PROMPT plus the selected feature's id/title/AC " +
129
+ "summary — telling the main AI to spawn an isolated coding subagent via the Task tool. " +
130
+ "Pass `parallel=true` to also get conflict-free parallel batches (dispatch plan). Pin a specific " +
131
+ "feature with `feature_id`. The tool does NOT spawn the subagent or write features.json status " +
132
+ "itself — the main AI dispatches via Task, then parses the subagent's completion signal " +
133
+ "(parse-completion-signal) and updates the feature status.",
134
+ args: {
135
+ feature_id: tool.schema
136
+ .string()
137
+ .optional()
138
+ .describe(
139
+ "Pin a single feature by id. The feature must exist in the current sprint and be ready " +
140
+ "(status pending + deps completed); otherwise the tool returns an error text.",
141
+ ),
142
+ parallel: tool.schema
143
+ .boolean()
144
+ .optional()
145
+ .describe(
146
+ "Parallel mode. When true, the tool returns ALL ready features plus conflict-free " +
147
+ "parallel batches (a dispatch plan) instead of pinning a single target.",
148
+ ),
149
+ project_dir: tool.schema
150
+ .string()
151
+ .optional()
152
+ .describe(
153
+ "Absolute path of the project root. Defaults to the opencode session's worktree/directory.",
154
+ ),
155
+ },
156
+ async execute(
157
+ args: {
158
+ feature_id?: string;
159
+ parallel?: boolean;
160
+ project_dir?: string;
161
+ },
162
+ ctx: ToolContext,
163
+ ): Promise<string> {
164
+ // (1) Resolve the project dir. Explicit arg wins; otherwise read it off
165
+ // the opencode session context (worktree > directory).
166
+ const projectDir = args.project_dir
167
+ ? resolve(args.project_dir)
168
+ : resolveProjectDir(ctx);
169
+
170
+ // (2) Read features.json. Same defensive read as sprint.ts / status.ts:
171
+ // if the file is missing we return a指向 ghs-init 的错误文本而不是抛
172
+ // 异常 —— AI 可以据此引导用户初始化。
173
+ const featuresPath = join(projectDir, ".ghs", "features.json");
174
+ const featuresFile = Bun.file(featuresPath);
175
+ if (!(await featuresFile.exists())) {
176
+ return [
177
+ `❌ features.json not found at ${featuresPath}.`,
178
+ "",
179
+ "Run `ghs-init` first to bootstrap the .ghs/ tracking files.",
180
+ ].join("\n");
181
+ }
182
+
183
+ let featuresData: FeaturesData;
184
+ try {
185
+ const text = await featuresFile.text();
186
+ featuresData = JSON.parse(text) as FeaturesData;
187
+ } catch (err) {
188
+ return [
189
+ `❌ Failed to parse ${featuresPath}:`,
190
+ "",
191
+ (err as Error).message,
192
+ "",
193
+ "Fix the JSON (or re-run `ghs-init`) before invoking `ghs-code` again.",
194
+ ].join("\n");
195
+ }
196
+
197
+ // (3) Find the current sprint's ready features. We pass NO sprint_id so
198
+ // getReadyFeatures mirrors the Python source: the first sprint with
199
+ // status === "in_progress", else the first sprint in the array. A feature
200
+ // is "ready" iff status === "pending", it is not in a dependency cycle,
201
+ // and every entry in its `dependencies` is in the completed set.
202
+ const result = getReadyFeatures(featuresData);
203
+ const ready = result.ready;
204
+ const skipped = result.skipped;
205
+ const cycles = result.cycles;
206
+
207
+ // (3a) Surface any detected dependency cycle as a loud warning block at
208
+ // the top of the result. Cycles make those features permanently un-ready,
209
+ // so ignoring them would silently drop work the user expects to see.
210
+ const cycleWarning =
211
+ cycles.length > 0
212
+ ? [
213
+ `⚠️ Detected ${cycles.length} dependency cycle(s) — these features are NOT ready until the cycle is broken:`,
214
+ ...cycles.map((c) => ` - ${c.join(" → ")}`),
215
+ "",
216
+ ].join("\n")
217
+ : "";
218
+
219
+ // (4) No-ready-feature short-circuit. AC: "无 ready feature 时返回 'no
220
+ // pending features' 提示". We keep the message informative (count of
221
+ // skipped + any cycle) so the AI can tell "sprint done" apart from
222
+ // "blocked by unmet deps".
223
+ if (ready.length === 0) {
224
+ const lines: string[] = [];
225
+ lines.push("=== ghs-code: no ready features ===");
226
+ lines.push("");
227
+ lines.push(`Project directory: ${projectDir}`);
228
+ if (cycleWarning) {
229
+ lines.push(cycleWarning.trimEnd());
230
+ lines.push("");
231
+ }
232
+ if (skipped.length === 0) {
233
+ lines.push("当前 sprint 没有 pending feature(已全部完成,或 sprint 为空)。");
234
+ } else {
235
+ lines.push(
236
+ `当前 sprint 有 ${skipped.length} 个 feature 但无一 ready(依赖未完成、状态非 pending、或处于依赖环中)。`,
237
+ );
238
+ lines.push("用 `ghs-status` 查看各 feature 状态与依赖。");
239
+ }
240
+ return lines.join("\n");
241
+ }
242
+
243
+ // (5) Branch on args.
244
+ //
245
+ // `feature_id` pin (highest priority) → validate + single-feature dispatch.
246
+ // `parallel=true` → multi-feature dispatch plan (batches).
247
+ // default → pick the first ready feature (stable, deterministic order —
248
+ // getReadyFeatures preserves features.json order) and dispatch it.
249
+ const parallelMode = args.parallel === true;
250
+
251
+ if (args.feature_id) {
252
+ return dispatchPinnedFeature(
253
+ args.feature_id,
254
+ ready,
255
+ skipped,
256
+ projectDir,
257
+ cycleWarning,
258
+ );
259
+ }
260
+
261
+ if (parallelMode) {
262
+ return dispatchParallelPlan(ready, projectDir, cycleWarning);
263
+ }
264
+
265
+ return dispatchSingleFeature(ready[0], projectDir, cycleWarning);
266
+ },
267
+ });
268
+
269
+ /**
270
+ * Build the dispatch text for a single pinned feature.
271
+ *
272
+ * Validates the `feature_id` arg: it must (a) exist somewhere in the current
273
+ * sprint's features AND (b) be in the ready set. If it's in `skipped`, we
274
+ * explain why (wrong status or unmet deps); if it's not in the sprint at all,
275
+ * we say so. On success we return the FEATURE_IMPL_PROMPT (placeholders
276
+ * substituted) plus the feature brief.
277
+ */
278
+ function dispatchPinnedFeature(
279
+ featureId: string,
280
+ ready: Feature[],
281
+ skipped: Feature[],
282
+ projectDir: string,
283
+ cycleWarning: string,
284
+ ): string {
285
+ // Is it ready?
286
+ const readyMatch = ready.find(
287
+ (f) => (f["id"] as string | undefined) === featureId,
288
+ );
289
+ if (readyMatch) {
290
+ const brief = toBrief(readyMatch);
291
+ const lines: string[] = [];
292
+ lines.push("=== ghs-code: feature pinned & ready ===");
293
+ lines.push("");
294
+ lines.push(`Project directory: ${projectDir}`);
295
+ if (cycleWarning) {
296
+ lines.push(cycleWarning.trimEnd());
297
+ lines.push("");
298
+ }
299
+ lines.push("Selected feature:");
300
+ lines.push(formatBrief(brief));
301
+ lines.push("");
302
+ lines.push(
303
+ "Next: 用 Task tool 派发 coding subagent(派发 prompt 见下,已注入 project dir 与 feature_id),",
304
+ );
305
+ lines.push(
306
+ "subagent 返回后用 parse-completion-signal 解析其完成信号,再调 update-feature-status 更新该 feature 的 status。",
307
+ );
308
+ lines.push("");
309
+ lines.push("--- feature-impl dispatch prompt ---");
310
+ lines.push(renderFeatureImplPrompt(projectDir, brief.id));
311
+ return lines.join("\n");
312
+ }
313
+
314
+ // Not ready — is it in skipped (i.e. exists in this sprint but not ready)?
315
+ const skippedMatch = skipped.find(
316
+ (f) => (f["id"] as string | undefined) === featureId,
317
+ );
318
+ if (skippedMatch) {
319
+ const brief = toBrief(skippedMatch);
320
+ const lines: string[] = [];
321
+ lines.push(`❌ feature ${featureId} 存在但 NOT ready。`);
322
+ lines.push("");
323
+ lines.push(`Project directory: ${projectDir}`);
324
+ lines.push("");
325
+ lines.push("Feature 状态:");
326
+ lines.push(formatBrief(brief));
327
+ lines.push("");
328
+ if (brief.status !== "pending") {
329
+ lines.push(
330
+ `原因:status 为 "${brief.status}"(仅 "pending" 才可派发)。`,
331
+ );
332
+ } else if (brief.dependencies.length > 0) {
333
+ lines.push("原因:存在未完成的依赖(依赖 feature 须先 completed)。");
334
+ } else {
335
+ lines.push("原因:未通过 ready 判定(可能处于依赖环中)。");
336
+ }
337
+ lines.push("");
338
+ lines.push("用 `ghs-status` 查看依赖与状态详情。");
339
+ return lines.join("\n");
340
+ }
341
+
342
+ // Not in the current sprint at all.
343
+ const lines: string[] = [];
344
+ lines.push(`❌ feature ${featureId} 不在当前 sprint 中。`);
345
+ lines.push("");
346
+ lines.push(`Project directory: ${projectDir}`);
347
+ lines.push("");
348
+ lines.push("请核对 feature_id,或调用 `ghs-code`(不带 feature_id)让工具按依赖顺序选一个 ready feature。");
349
+ return lines.join("\n");
350
+ }
351
+
352
+ /**
353
+ * Build the dispatch text for parallel mode: all ready features + conflict-
354
+ * free batches from `buildBatches`. Each batch is a set of features that touch
355
+ * disjoint `files_affected` sets, so the main AI can dispatch them
356
+ * concurrently without merge conflicts.
357
+ *
358
+ * The dispatch plan lists every ready feature (so nothing is silently
359
+ * dropped), groups them into batches, and ends with the shared FEATURE_IMPL
360
+ * PROMPT (placeholders left as `<feature_id>` / `<PROJECT_DIR>`-filled —
361
+ * rendered once per target in the per-feature block) so the AI has the exact
362
+ * subagent prompt to hand to Task for each target.
363
+ */
364
+ function dispatchParallelPlan(
365
+ ready: Feature[],
366
+ projectDir: string,
367
+ cycleWarning: string,
368
+ ): string {
369
+ const batches = buildBatches(ready);
370
+ const briefs = ready.map(toBrief);
371
+
372
+ const lines: string[] = [];
373
+ lines.push("=== ghs-code: parallel dispatch plan ===");
374
+ lines.push("");
375
+ lines.push(`Project directory: ${projectDir}`);
376
+ if (cycleWarning) {
377
+ lines.push(cycleWarning.trimEnd());
378
+ lines.push("");
379
+ }
380
+ lines.push(
381
+ `当前 sprint 有 ${ready.length} 个 ready feature,分成 ${batches.length} 个无文件冲突批次:`,
382
+ );
383
+ lines.push("");
384
+
385
+ batches.forEach((batch, batchIdx) => {
386
+ lines.push(`## Batch ${batchIdx + 1}(${batch.length} feature,文件无冲突,可并发派发)`);
387
+ lines.push("");
388
+ for (const feat of batch) {
389
+ const brief = briefs.find((b) => b.id === (feat["id"] as string | undefined));
390
+ if (!brief) continue;
391
+ lines.push(`### ${brief.id} — ${brief.title}`);
392
+ lines.push(formatBrief(brief));
393
+ lines.push("");
394
+ lines.push("--- feature-impl dispatch prompt ---");
395
+ lines.push(renderFeatureImplPrompt(projectDir, brief.id));
396
+ lines.push("");
397
+ }
398
+ });
399
+
400
+ lines.push(
401
+ "每个 feature 独立派发 coding subagent(各 Task call 互不依赖)。所有 subagent 返回后,",
402
+ );
403
+ lines.push(
404
+ "用 parse-completion-signal 逐个解析完成信号,再调 update-feature-status 更新对应 feature 的 status。",
405
+ );
406
+ lines.push(
407
+ "并行 git 守则:每个 subagent 显式 `git add <实现文件路径>` 做**恰好一次** commit(禁 `git add -A`/`add .`/`reset`,禁提交 `.ghs/*`),避免兄弟 commit 被 orphan。",
408
+ );
409
+ return lines.join("\n");
410
+ }
411
+
412
+ /**
413
+ * Build the dispatch text for the default single-feature path: pick the first
414
+ * ready feature (stable order) and return its brief + the rendered
415
+ * FEATURE_IMPL_PROMPT.
416
+ */
417
+ function dispatchSingleFeature(
418
+ feat: Feature,
419
+ projectDir: string,
420
+ cycleWarning: string,
421
+ ): string {
422
+ const brief = toBrief(feat);
423
+ const lines: string[] = [];
424
+ lines.push("=== ghs-code: feature ready ===");
425
+ lines.push("");
426
+ lines.push(`Project directory: ${projectDir}`);
427
+ if (cycleWarning) {
428
+ lines.push(cycleWarning.trimEnd());
429
+ lines.push("");
430
+ }
431
+ lines.push(
432
+ "已按依赖顺序选取第一个 ready feature(如需并发派发多个,用 `ghs-code` 并传 `parallel: true`):",
433
+ );
434
+ lines.push("");
435
+ lines.push("Selected feature:");
436
+ lines.push(formatBrief(brief));
437
+ lines.push("");
438
+ lines.push(
439
+ "Next: 用 Task tool 派发 coding subagent(派发 prompt 见下,已注入 project dir 与 feature_id),",
440
+ );
441
+ lines.push(
442
+ "subagent 返回后用 parse-completion-signal 解析其完成信号,再调 update-feature-status 更新该 feature 的 status。",
443
+ );
444
+ lines.push("");
445
+ lines.push("--- feature-impl dispatch prompt ---");
446
+ lines.push(renderFeatureImplPrompt(projectDir, brief.id));
447
+ return lines.join("\n");
448
+ }
@@ -0,0 +1,182 @@
1
+ // `ghs-config` tool — render agent markdown templates from the user's
2
+ // `.ghs/ghs.json` config (with field-level fallback to plugin defaults) and
3
+ // write them to `<projectDir>/.opencode/agents/ghs-*.md`.
4
+ //
5
+ // This is the R3 (Round 6) user-facing entry point: users edit
6
+ // `.ghs/ghs.json`, invoke `ghs-config`, then restart OpenCode to pick up the
7
+ // new agent definitions. The substitution-then-restart mechanism was
8
+ // validated by Phase 0 spike 004 (see shared/SPIKE_RESULTS.md).
9
+ //
10
+ // The tool is a thin wrapper around `syncAgents` / `renderAgentTemplate` /
11
+ // `loadGhsConfig` from `src/lib/config.ts`. Responsibilities unique to the
12
+ // tool layer:
13
+ // - Resolve `project_dir` from the tool context when not supplied.
14
+ // - Refuse to run before `ghs-init` (i.e. when `.ghs/` is absent).
15
+ // - Support `dry_run: true` to preview without writing.
16
+ // - Catch Zod parse errors on a malformed `.ghs/ghs.json` and return the
17
+ // error text instead of writing files (acceptance criterion #5).
18
+ // - Emit the "Restart your OpenCode session" hint on every successful run,
19
+ // because OpenCode loads agent markdown only at process startup (no
20
+ // hot-reload — see spike 004 + spike 002).
21
+
22
+ import { tool } from "@opencode-ai/plugin";
23
+ import { resolve } from "node:path";
24
+ import {
25
+ loadGhsConfig,
26
+ renderAgentTemplate,
27
+ syncAgents,
28
+ fileExists,
29
+ } from "../lib/config.js";
30
+ import { pluginRoot } from "../lib/paths.js";
31
+ import { resolveProjectDir } from "../lib/project.js";
32
+
33
+ // Authoritative agent names — must match `AGENT_NAMES` in src/lib/config.ts.
34
+ // Kept in sync so the dry-run preview enumerates the exact same files that a
35
+ // real sync would write.
36
+ const AGENTS = ["ghs-context-haiku", "ghs-plan-designer", "ghs-plan-reviewer"] as const;
37
+
38
+ /**
39
+ * Build the structured success message returned by a real (non-dry-run)
40
+ * sync. Lists the written file paths, the resolved model IDs, whether any
41
+ * defaults leaked through, and the mandatory restart hint.
42
+ */
43
+ function formatSyncResult(result: {
44
+ written: string[];
45
+ models: { context: string; designer: string; reviewer: string };
46
+ defaults_used: boolean;
47
+ }): string {
48
+ const lines: string[] = [];
49
+ lines.push("Agent markdown files synced:");
50
+ for (const path of result.written) {
51
+ lines.push(` - ${path}`);
52
+ }
53
+ lines.push("");
54
+ lines.push("Resolved model IDs:");
55
+ lines.push(` - context: ${result.models.context}`);
56
+ lines.push(` - designer: ${result.models.designer}`);
57
+ lines.push(` - reviewer: ${result.models.reviewer}`);
58
+ lines.push("");
59
+ lines.push(`Defaults used: ${result.defaults_used ? "yes" : "no"}`);
60
+ if (result.defaults_used) {
61
+ lines.push(
62
+ "Some model IDs fell back to plugin defaults. To customize, edit `.ghs/ghs.json` and re-run ghs-config.",
63
+ );
64
+ }
65
+ lines.push("");
66
+ // Critical: OpenCode loads agent markdown only at process startup. Spike
67
+ // 004 confirmed writes don't hot-reload — users MUST restart for changes
68
+ // to take effect.
69
+ lines.push("Restart your OpenCode session for the new agent definitions to take effect.");
70
+ return lines.join("\n");
71
+ }
72
+
73
+ /**
74
+ * Build the dry-run preview message. Renders every template (so malformed
75
+ * templates still surface as errors) but writes nothing.
76
+ */
77
+ async function formatDryRun(
78
+ projectDir: string,
79
+ root: string,
80
+ ): Promise<string> {
81
+ const { config, defaults_used } = await loadGhsConfig(projectDir, root);
82
+ const lines: string[] = [];
83
+ lines.push("Dry run — no files will be written.");
84
+ lines.push("");
85
+ lines.push("Resolved model IDs:");
86
+ lines.push(` - context: ${config.models.context}`);
87
+ lines.push(` - designer: ${config.models.designer}`);
88
+ lines.push(` - reviewer: ${config.models.reviewer}`);
89
+ lines.push(`Defaults used: ${defaults_used ? "yes" : "no"}`);
90
+ lines.push("");
91
+ lines.push("Files that would be written:");
92
+ for (const name of AGENTS) {
93
+ const rendered = await renderAgentTemplate(name, config, root);
94
+ const outPath = resolve(projectDir, ".opencode", "agents", `${name}.md`);
95
+ lines.push(`--- ${outPath} ---`);
96
+ lines.push(rendered);
97
+ lines.push("");
98
+ }
99
+ // Even in dry-run we surface the restart hint, because a subsequent real
100
+ // invocation will still require a restart.
101
+ lines.push("Restart your OpenCode session for the new agent definitions to take effect.");
102
+ return lines.join("\n");
103
+ }
104
+
105
+ /**
106
+ * The `ghs-config` tool definition. Registered under the hyphenated key
107
+ * `ghs-config` (Phase 0 spike 001 confirmed hyphenated keys load and
108
+ * round-trip correctly).
109
+ */
110
+ export const configTool = tool({
111
+ description:
112
+ "Regenerate the ghs-* subagent markdown files (.opencode/agents/ghs-{context-haiku,plan-designer,plan-reviewer}.md) " +
113
+ "from the user's .ghs/ghs.json config with field-level fallback to plugin defaults. " +
114
+ "Use this after editing .ghs/ghs.json to change the model IDs used by the plan dispatcher's three roles. " +
115
+ "OpenCode loads agents only at startup, so you must restart your session after running this. " +
116
+ "Requires .ghs/ to exist (run ghs-init first).",
117
+ args: {
118
+ project_dir: tool.schema
119
+ .string()
120
+ .optional()
121
+ .describe(
122
+ "Project directory containing .ghs/. Defaults to the current session's worktree/directory.",
123
+ ),
124
+ dry_run: tool.schema
125
+ .boolean()
126
+ .optional()
127
+ .describe(
128
+ "If true, render templates and return a preview WITHOUT writing any files.",
129
+ ),
130
+ },
131
+ async execute(args, ctx) {
132
+ const projectDir = args.project_dir ?? resolveProjectDir(ctx);
133
+ const root = pluginRoot();
134
+
135
+ // Gate 1: refuse to run before `ghs-init`. We check for `.ghs/ghs.json`
136
+ // (the file ghs-init actually creates) rather than the `.ghs/` directory,
137
+ // because `Bun.file().exists()` returns false for directories — checking
138
+ // the directory would always fail.
139
+ const ghsJsonExists = await fileExists(resolve(projectDir, ".ghs", "ghs.json"));
140
+ if (!ghsJsonExists) {
141
+ return "Run ghs-init first.";
142
+ }
143
+
144
+ if (args.dry_run) {
145
+ // Dry-run path: render + preview, no writes. Malformed ghs.json
146
+ // surfaces a Zod error here (loadGhsConfig throws) and the catch
147
+ // below formats it without writing.
148
+ try {
149
+ return await formatDryRun(projectDir, root);
150
+ } catch (err) {
151
+ return formatConfigError(err);
152
+ }
153
+ }
154
+
155
+ // Real path: load + render + write. We split the load step out so we
156
+ // can catch Zod parse errors on a malformed .ghs/ghs.json and return
157
+ // the error text WITHOUT having already written any partial files
158
+ // (acceptance criterion #5: "returns the Zod parse error and does NOT
159
+ // write any files"). syncAgents internally calls loadGhsConfig again
160
+ // — a redundant parse, but cheap, and keeps the tool layer's
161
+ // error-gate logic explicit.
162
+ try {
163
+ await loadGhsConfig(projectDir, root);
164
+ } catch (err) {
165
+ return formatConfigError(err);
166
+ }
167
+
168
+ const result = await syncAgents(projectDir, root);
169
+ return formatSyncResult(result);
170
+ },
171
+ });
172
+
173
+ /**
174
+ * Format a config-load / template-render error as a readable string. Zod
175
+ * errors get their `.message` (a JSON string listing every issue); other
176
+ * errors get their `.message` verbatim. Used in both dry-run and real
177
+ * paths so malformed configs never silently succeed.
178
+ */
179
+ function formatConfigError(err: unknown): string {
180
+ const msg = err instanceof Error ? err.message : String(err);
181
+ return `Failed to load ghs config: ${msg}\n\nNo files were written. Fix the error above and re-run ghs-config.`;
182
+ }