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,333 @@
1
+ // `ghs-plan-finalize` tool — the plan dispatcher's exit step.
2
+ //
3
+ // After the review-revise loop converges on a reviewer-approved plan, the
4
+ // primary AI calls this tool with the final plan text. This tool:
5
+ // 1. Resolves the target project dir (explicit `project_dir` arg wins;
6
+ // otherwise `resolveProjectDir(ctx)` reads the opencode session's
7
+ // worktree/directory).
8
+ // 2. Derives a slug from the plan content's first meaningful line (the
9
+ // title / H1), sanitising it to `[a-z0-9-]+` so the file name is
10
+ // filesystem-safe and matches the source skill's convention.
11
+ // 3. Writes the plan to `<projectDir>/.ghs/plans/<YYYY-MM-DD>-<slug>.md`.
12
+ // This is the canonical, user-facing artefact — the file `features.json`
13
+ // sprint entries reference via their `plan_ref` field, and the file
14
+ // `ghs-sprint` / `ghs-code` consult for downstream context.
15
+ // 4. Updates the dispatcher's per-plan `status.json` (located at
16
+ // `<projectDir>/.ghs/plans/<plan_id>-status.json`) to mark the plan as
17
+ // `approved` — provided a status file exists for the plan being
18
+ // finalised. We look up the status file by `plan_id` when the caller
19
+ // passes one, otherwise by the `{date}-{slug}` identifier we just minted
20
+ // (which is what `ghs-plan-start` would have used).
21
+ // 5. Returns a success string that tells the AI / user the plan is written
22
+ // AND that the next workflow step is `ghs-sprint` (to break the plan into
23
+ // atomic features). Per the feature spec's acceptance criteria verbatim.
24
+ //
25
+ // All file I/O is pure — no LLM calls. The returned string is what the AI
26
+ // sees as the tool result. Style follows `src/tools/init.ts` (Bun.file /
27
+ // Bun.write, no process.exit, descriptive thrown Errors) and `src/tools/
28
+ // sprint.ts` (tool() helper + hyphenated registry key, resolved project dir,
29
+ // human-readable result lines).
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
+ import { mkdir } from "node:fs/promises";
35
+
36
+ import {
37
+ plansDir,
38
+ readPlanStatus,
39
+ writePlanStatus,
40
+ formatLocalTimestamp,
41
+ type PlanStatus,
42
+ } from "../lib/state.ts";
43
+ import { resolveProjectDir } from "../lib/project.ts";
44
+
45
+ // -----------------------------------------------------------------------------
46
+ // Slug generation
47
+ // -----------------------------------------------------------------------------
48
+
49
+ /**
50
+ * Maximum length of a generated slug. Keeps file names manageable and matches
51
+ * the rough width of the source skill's slug derivation (which truncates long
52
+ * titles). 60 chars is generous enough for real plan titles yet short enough
53
+ * that the resulting `<date>-<slug>.md` file name stays readable in a
54
+ * terminal tab + `ls` output.
55
+ */
56
+ const MAX_SLUG_LENGTH = 60;
57
+
58
+ /**
59
+ * Derive a filesystem-safe slug from a plan's text content.
60
+ *
61
+ * Strategy (matches the source skill's `derive_slug` intent):
62
+ * 1. Take the first non-empty, non-frontmatter line of the plan. We prefer
63
+ * a Markdown H1 (`# Title`) when present since plan designers conventionally
64
+ * lead with one; otherwise we fall back to the first heading or the first
65
+ * non-blank line.
66
+ * 2. Strip leading `#` heading markers, trailing punctuation, and
67
+ * surrounding whitespace.
68
+ * 3. Lowercase, collapse internal whitespace into single hyphens, drop
69
+ * every character outside `[a-z0-9-]`.
70
+ * 4. Collapse runs of hyphens, trim leading/trailing hyphens.
71
+ * 5. Truncate to {@link MAX_SLUG_LENGTH} on a hyphen boundary (so we don't
72
+ * cut a word in half).
73
+ * 6. Fall back to `"plan"` when the result is empty (e.g. the plan content
74
+ * was only whitespace or punctuation) — we MUST return a non-empty slug
75
+ * because it forms part of the file name.
76
+ *
77
+ * The output is guaranteed to match `/^[a-z0-9]+(-[a-z0-9]+)*$/` or be the
78
+ * literal `"plan"`, which keeps the resulting file name safe across every
79
+ * filesystem OpenCode users are likely to run on.
80
+ */
81
+ export function deriveSlug(planContent: string): string {
82
+ // Locate the first meaningful line. Skip blank lines and YAML-ish frontmatter
83
+ // delimiters (`---`) so a plan that opens with frontmatter doesn't produce a
84
+ // slug of empty string.
85
+ const lines = planContent.split(/\r?\n/);
86
+ let title = "";
87
+ for (const raw of lines) {
88
+ const line = raw.trim();
89
+ if (!line) continue;
90
+ if (line === "---") continue; // frontmatter delimiter
91
+ title = line;
92
+ break;
93
+ }
94
+
95
+ // Strip leading Markdown heading hashes (`#`, `##`, ...).
96
+ let cleaned = title.replace(/^#+\s*/, "");
97
+ // Trim trailing punctuation that would otherwise become dangling hyphens.
98
+ cleaned = cleaned.replace(/[\s._:=#-]+$/g, "");
99
+
100
+ const slug = cleaned
101
+ .toLowerCase()
102
+ .trim()
103
+ // Replace any run of whitespace with a single hyphen.
104
+ .replace(/\s+/g, "-")
105
+ // Drop every character that is not a lowercase letter, digit, or hyphen.
106
+ .replace(/[^a-z0-9-]/g, "")
107
+ // Collapse runs of hyphens produced by the two replacements above.
108
+ .replace(/-+/g, "-")
109
+ // Trim leading/trailing hyphens.
110
+ .replace(/^-+|-+$/g, "");
111
+
112
+ if (!slug) {
113
+ return "plan";
114
+ }
115
+
116
+ // Truncate on a hyphen boundary so we never cut a word in half.
117
+ if (slug.length <= MAX_SLUG_LENGTH) {
118
+ return slug;
119
+ }
120
+ const truncated = slug.slice(0, MAX_SLUG_LENGTH);
121
+ const lastHyphen = truncated.lastIndexOf("-");
122
+ return (lastHyphen > 0 ? truncated.slice(0, lastHyphen) : truncated).replace(
123
+ /-+$/,
124
+ "",
125
+ );
126
+ }
127
+
128
+ // -----------------------------------------------------------------------------
129
+ // Status update
130
+ // -----------------------------------------------------------------------------
131
+
132
+ /**
133
+ * Mark the dispatcher state for `planId` as `approved`, preserving every other
134
+ * field. Bumps `updated_at` to "now".
135
+ *
136
+ * Returns the absolute path the updated status was written to, or `null` when
137
+ * no status file exists for `planId` (which is a legitimate state — the user
138
+ * may be calling `ghs-plan-finalize` with a hand-written plan that never went
139
+ * through `ghs-plan-start`'s state machine). In that case the caller simply
140
+ * reports that no state file was updated rather than failing the whole
141
+ * finalisation.
142
+ *
143
+ * `acceptedWithFail` propagates onto the status object so post-hoc auditing
144
+ * (`grep '"accepted_with_fail": true'`) still works exactly as in the source
145
+ * plugin — a plan that shipped with unfixed reviewer findings stays flagged.
146
+ */
147
+ async function markPlanApproved(
148
+ projectDir: string,
149
+ planId: string,
150
+ acceptedWithFail: boolean,
151
+ now: Date,
152
+ ): Promise<string | null> {
153
+ const existing = await readPlanStatus(projectDir, planId);
154
+ if (!existing) {
155
+ return null;
156
+ }
157
+
158
+ const updated: PlanStatus = {
159
+ ...existing,
160
+ status: "approved",
161
+ accepted_with_fail: acceptedWithFail,
162
+ updated_at: formatLocalTimestamp(now),
163
+ };
164
+ return writePlanStatus(projectDir, updated);
165
+ }
166
+
167
+ // -----------------------------------------------------------------------------
168
+ // Tool definition
169
+ // -----------------------------------------------------------------------------
170
+
171
+ /**
172
+ * The `ghs-plan-finalize` tool definition. Registered by the plugin entry
173
+ * point under the `ghs-plan-finalize` key (hyphenated, per spike 001 / D1).
174
+ *
175
+ * This is the exit step of the 3-role plan dispatcher (plan §3.5 / §3.7). The
176
+ * primary AI invokes it once the `ghs-plan-reviewer` subagent has returned a
177
+ * `Verdict: PASS`, handing over the final, reviewer-approved plan text.
178
+ */
179
+ export const planFinalizeTool = tool({
180
+ description:
181
+ "Finalise a plan: write the reviewer-approved plan content to " +
182
+ "`.ghs/plans/<YYYY-MM-DD>-<slug>.md` (slug derived from the plan title), " +
183
+ "mark the dispatcher's status.json as `approved`, and return the next-step " +
184
+ "instruction (invoke `ghs-sprint` to break the plan into atomic features). " +
185
+ "This is the exit step of the 3-role plan dispatcher.",
186
+ args: {
187
+ plan_content: tool.schema
188
+ .string()
189
+ .min(1)
190
+ .describe(
191
+ "The final, reviewer-approved plan content (Markdown). Written verbatim to " +
192
+ "`<projectDir>/.ghs/plans/<YYYY-MM-DD>-<slug>.md`.",
193
+ ),
194
+ project_dir: tool.schema
195
+ .string()
196
+ .optional()
197
+ .describe(
198
+ "Absolute path of the project root. Defaults to the opencode session's worktree/directory.",
199
+ ),
200
+ plan_id: tool.schema
201
+ .string()
202
+ .optional()
203
+ .describe(
204
+ "Optional plan identifier (the `{date}-{slug}` string emitted by ghs-plan-start) " +
205
+ "used to locate the dispatcher's status.json. When omitted, the tool derives " +
206
+ "the id from the current date + the plan's slug.",
207
+ ),
208
+ accepted_with_fail: tool.schema
209
+ .boolean()
210
+ .optional()
211
+ .describe(
212
+ "When true, the plan shipped with unfixed reviewer findings. Sets the " +
213
+ "`accepted_with_fail` audit flag on status.json (status still flips to `approved`). " +
214
+ "Default false.",
215
+ ),
216
+ },
217
+ async execute(
218
+ args: {
219
+ plan_content: string;
220
+ project_dir?: string;
221
+ plan_id?: string;
222
+ accepted_with_fail?: boolean;
223
+ },
224
+ ctx: ToolContext,
225
+ ): Promise<string> {
226
+ const projectDir = args.project_dir
227
+ ? resolve(args.project_dir)
228
+ : resolveProjectDir(ctx);
229
+
230
+ const now = new Date();
231
+
232
+ // (a) Derive the plan identifier + file name. The convention is
233
+ // `<YYYY-MM-DD>-<slug>.md`, matching the plan_ref field on this very
234
+ // sprint (`2026-06-20-opencode-port.md`) and the source skill's "File
235
+ // Conventions" table. The plan_id (used to locate status.json) shares the
236
+ // same `{date}-{slug}` form, minus the `.md` extension.
237
+ const slug = deriveSlug(args.plan_content);
238
+ const datePart = formatLocalDate(now);
239
+ const derivedPlanId = `${datePart}-${slug}`;
240
+ const planId = args.plan_id ?? derivedPlanId;
241
+ const planFileName = `${derivedPlanId}.md`;
242
+ const planFilePath = join(plansDir(projectDir), planFileName);
243
+
244
+ // (b) Ensure `.ghs/plans/` exists. `ghs-init` / `ghs-plan-start` usually
245
+ // create it, but a user calling finalize directly on a fresh project
246
+ // (e.g. with a hand-authored plan) shouldn't have to pre-create it.
247
+ // `recursive: true` makes this a no-op when the dir already exists.
248
+ await mkdir(plansDir(projectDir), { recursive: true });
249
+
250
+ // (c) Write the plan content verbatim. We do not prepend a timestamp or
251
+ // any metadata — the plan text is the user-visible artefact and the
252
+ // designer already formatted it. The file name carries the date.
253
+ await Bun.write(planFilePath, args.plan_content);
254
+
255
+ // (d) Flip the dispatcher status to `approved` if a status file exists for
256
+ // this plan. `markPlanApproved` returns null when there is no status file
257
+ // (e.g. a hand-authored plan that never ran through ghs-plan-start) — we
258
+ // report that honestly instead of failing the whole finalisation.
259
+ const acceptedWithFail = args.accepted_with_fail === true;
260
+ let statusPath: string | null = null;
261
+ try {
262
+ statusPath = await markPlanApproved(
263
+ projectDir,
264
+ planId,
265
+ acceptedWithFail,
266
+ now,
267
+ );
268
+ } catch (err) {
269
+ // A corrupt status.json (unparseable JSON / schema failure) should NOT
270
+ // silently abort a finalisation that already wrote the plan file. We
271
+ // surface the error in the result text so the AI/user can diagnose,
272
+ // while the plan artefact itself is safely on disk.
273
+ return [
274
+ "=== ghs-plan-finalize PARTIAL ===",
275
+ "",
276
+ `Plan written to: ${planFilePath}`,
277
+ "",
278
+ "⚠️ Failed to update status.json:",
279
+ ` ${(err as Error).message}`,
280
+ "",
281
+ "The plan artefact is safely on disk, but the dispatcher state was not",
282
+ "flipped to `approved`. Inspect the status file referenced above.",
283
+ ].join("\n");
284
+ }
285
+
286
+ // (e) Compose the result. Lead with the success marker, the file path, the
287
+ // plan id, and the status-update outcome, then the explicit next-step
288
+ // instruction per the acceptance criteria.
289
+ const lines: string[] = [];
290
+ lines.push("=== ghs-plan-finalize complete ===");
291
+ lines.push("");
292
+ lines.push(`Project directory: ${projectDir}`);
293
+ lines.push(`Plan written to: ${planFilePath}`);
294
+ lines.push(`Plan id: ${planId}`);
295
+ if (statusPath) {
296
+ lines.push(`Status updated: ${statusPath} (status: approved)`);
297
+ if (acceptedWithFail) {
298
+ lines.push(
299
+ "Audit flag: accepted_with_fail=true (plan shipped with unfixed findings)",
300
+ );
301
+ }
302
+ } else {
303
+ lines.push(
304
+ "Status: no status.json found for this plan id — skipped approval flip",
305
+ );
306
+ lines.push(
307
+ " (this is expected when finalising a hand-authored plan)",
308
+ );
309
+ }
310
+ lines.push("");
311
+ lines.push("Next: invoke ghs-sprint to break this plan into features.");
312
+ return lines.join("\n");
313
+ },
314
+ });
315
+
316
+ // -----------------------------------------------------------------------------
317
+ // Local helpers re-exported so this module is self-contained for tests that
318
+ // want to exercise the slug derivation without going through the tool layer.
319
+ // `formatLocalDate` lives in src/lib/scripts/init-project.ts (and is mirrored
320
+ // in archive-sprint.ts); we re-implement a tiny local copy here to avoid a
321
+ // cross-sprint-file import into scripts/ that would pull init-project's full
322
+ // surface into this thin tool module. The implementation is a verbatim copy
323
+ // of init-project.ts's `formatLocalDate` so the output stays byte-identical.
324
+ // -----------------------------------------------------------------------------
325
+
326
+ /** Format a Date as `YYYY-MM-DD` in the local timezone (mirrors Python's
327
+ * `datetime.now().strftime("%Y-%m-%d")`). */
328
+ function formatLocalDate(now: Date = new Date()): string {
329
+ const y = now.getFullYear();
330
+ const m = String(now.getMonth() + 1).padStart(2, "0");
331
+ const d = String(now.getDate()).padStart(2, "0");
332
+ return `${y}-${m}-${d}`;
333
+ }