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,195 @@
1
+ // `ghs-force-archive` tool — destructive archive of ALL sprints.
2
+ //
3
+ // Unlike `ghs-archive`, which only touches `status: completed` sprints, this
4
+ // tool moves every sprint (including in_progress / planning / blocked ones)
5
+ // into `.ghs/archived/`. It is the "I know what I'm doing, wipe it all"
6
+ // escape hatch.
7
+ //
8
+ // Because that's destructive, we gate it behind a transcription nonce:
9
+ // - The user first calls `ghs-archive` (any mode). When there are any
10
+ // incomplete sprints, `ghs-archive` issues a random alphanumeric nonce
11
+ // and writes it to `.ghs/.force-archive-nonce`, surfacing it in the tool
12
+ // result.
13
+ // - The user then calls this tool with the nonce transcribed into the
14
+ // `transcription` arg. We read the nonce file, verify via
15
+ // `verifyTranscribeNonce`, and only then proceed.
16
+ // - The nonce is consumed (file deleted) on a successful verification so a
17
+ // captured nonce can't be replayed.
18
+ //
19
+ // Per the feature's `technical_notes` this is weaker than the source plugin's
20
+ // `AskUserQuestion` sync-block (a nonce is guessable by a determined LLM),
21
+ // but it satisfies the AC: "missing or incorrect transcription → error +
22
+ // no archive; matching transcription → archive".
23
+
24
+ import { tool } from "@opencode-ai/plugin";
25
+ import type { ToolContext } from "@opencode-ai/plugin/tool";
26
+ import { resolve, join } from "node:path";
27
+ import { existsSync } from "node:fs";
28
+ import { readFile } from "node:fs/promises";
29
+
30
+ import {
31
+ archiveSprints,
32
+ getAllSprints,
33
+ formatArchiveReport,
34
+ type ArchivedSprintInfo,
35
+ } from "../lib/scripts/archive-sprint.ts";
36
+ import { verifyTranscribeNonce } from "../lib/nonce.ts";
37
+ import { resolveProjectDir } from "../lib/project.ts";
38
+ import { readNonce, nonceFilePath } from "./archive.ts";
39
+
40
+ /** Load features.json. Returns null if missing. */
41
+ async function loadFeaturesData(
42
+ projectDir: string,
43
+ ): Promise<Record<string, unknown> | null> {
44
+ const path = join(resolve(projectDir), ".ghs", "features.json");
45
+ if (!existsSync(path)) {
46
+ return null;
47
+ }
48
+ const text = await readFile(path, "utf8");
49
+ return JSON.parse(text) as Record<string, unknown>;
50
+ }
51
+
52
+ /**
53
+ * The `ghs-force-archive` tool definition. Registered under the
54
+ * `ghs-force-archive` key.
55
+ */
56
+ export const forceArchiveTool = tool({
57
+ description:
58
+ "⚠️ Destructive: archive ALL sprints regardless of status (including in_progress / planning / blocked). " +
59
+ "Use `ghs-archive` instead for the normal completed-sprint flow. " +
60
+ "Requires a `transcription` token — call `ghs-archive` first; when incomplete sprints remain it will " +
61
+ "issue a nonce, which you transcribe back here to confirm. Without a matching transcription the tool " +
62
+ "refuses to archive anything.",
63
+ args: {
64
+ project_dir: tool.schema
65
+ .string()
66
+ .optional()
67
+ .describe(
68
+ "Absolute path of the project root. Defaults to the opencode session's worktree/directory.",
69
+ ),
70
+ transcription: tool.schema
71
+ .string()
72
+ .describe(
73
+ "The nonce token issued by a prior `ghs-archive` call (when incomplete sprints existed). " +
74
+ "Must match the issued nonce (case-insensitive, whitespace-trimmed) for the archive to proceed.",
75
+ ),
76
+ },
77
+ async execute(
78
+ args: { project_dir?: string; transcription: string },
79
+ ctx: ToolContext,
80
+ ): Promise<string> {
81
+ const projectDir = args.project_dir
82
+ ? resolve(args.project_dir)
83
+ : resolveProjectDir(ctx);
84
+
85
+ // ----- Pre-flight: does the project even have features.json? -----
86
+ const features = await loadFeaturesData(projectDir);
87
+ if (!features) {
88
+ return [
89
+ "=== Sprint Archiver (force) ===",
90
+ "",
91
+ `Project directory: ${projectDir}`,
92
+ "",
93
+ "❌ features.json not found. Run `ghs-init` first.",
94
+ ].join("\n") + "\n";
95
+ }
96
+
97
+ const allSprints = getAllSprints(features);
98
+ if (allSprints.length === 0) {
99
+ return formatArchiveReport({
100
+ projectDir,
101
+ mode: "archive",
102
+ force: true,
103
+ sprintsConsidered: [],
104
+ archived: [],
105
+ remainingCount: 0,
106
+ resetProgress: false,
107
+ });
108
+ }
109
+
110
+ // ----- Nonce gate -----
111
+ const issuedNonce = await readNonce(projectDir, /* consume: */ false);
112
+ if (issuedNonce === null) {
113
+ return [
114
+ "❌ ghs-force-archive: no transcription nonce on file.",
115
+ "",
116
+ "Call `ghs-archive` first; when there are incomplete sprints it will issue a nonce token.",
117
+ "Then transcribe that token back as the `transcription` arg of this tool.",
118
+ "",
119
+ `Expected nonce file: ${nonceFilePath(projectDir)}`,
120
+ ].join("\n") + "\n";
121
+ }
122
+
123
+ const ok = verifyTranscribeNonce(issuedNonce, args.transcription);
124
+ if (!ok) {
125
+ return [
126
+ "❌ ghs-force-archive: transcription does not match the issued nonce.",
127
+ "",
128
+ "No files were modified. To retry:",
129
+ " 1. Call `ghs-archive` (any mode) to get a fresh nonce, OR",
130
+ ` 2. Re-transcribe the existing nonce (case-insensitive, trimmed) into the \`transcription\` arg.`,
131
+ ].join("\n") + "\n";
132
+ }
133
+
134
+ // Gate passed — consume the nonce so it can't be replayed.
135
+ await readNonce(projectDir, /* consume: */ true);
136
+
137
+ // ----- Force archive -----
138
+ // We collect the preview first (force + dry-run) so the report can show
139
+ // sprintsConsidered, then run the real archive.
140
+ const preview = await archiveSprints({
141
+ projectDir,
142
+ dryRun: true,
143
+ force: true,
144
+ });
145
+
146
+ const archived: ArchivedSprintInfo[] = await archiveSprints({
147
+ projectDir,
148
+ dryRun: false,
149
+ force: true,
150
+ });
151
+
152
+ // After force-archive there are no sprints left → resetProgress is true
153
+ // when archiveSprints actually moved something.
154
+ const featuresAfter = await loadFeaturesData(projectDir);
155
+ const remainingCount = featuresAfter
156
+ ? getAllSprints(featuresAfter).length
157
+ : 0;
158
+ const resetProgress = archived.length > 0 && remainingCount === 0;
159
+
160
+ // sprintsConsidered is `Record<string, unknown>[]` which matches the
161
+ // `Sprint[]` param of formatArchiveReport (Sprint === JsonObject
162
+ // internally).
163
+ const sprintsConsidered = preview.map((info) => ({
164
+ id: info.sprint_id,
165
+ name: info.sprint_name,
166
+ status: info.sprint_status,
167
+ }));
168
+
169
+ if (archived.length === 0) {
170
+ // Shouldn't normally happen (we checked allSprints.length > 0 above)
171
+ // but guard anyway — return the canonical "no sprints" report.
172
+ return formatArchiveReport({
173
+ projectDir,
174
+ mode: "archive",
175
+ force: true,
176
+ sprintsConsidered,
177
+ archived: [],
178
+ remainingCount,
179
+ resetProgress,
180
+ });
181
+ }
182
+
183
+ const report = formatArchiveReport({
184
+ projectDir,
185
+ mode: "archive",
186
+ force: true,
187
+ sprintsConsidered,
188
+ archived,
189
+ remainingCount,
190
+ resetProgress,
191
+ });
192
+
193
+ return report;
194
+ },
195
+ });
@@ -0,0 +1,193 @@
1
+ // `ghs-init` tool — initialise the `.ghs/` tracking files for a host project.
2
+ //
3
+ // This is the entry point a user (or the AI on the user's behalf) calls to
4
+ // bootstrap ghs in a project. It:
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. Calls `initProject` to create `.ghs/features.json` + `.ghs/progress.md`
9
+ // from the shared templates + update `.gitignore`.
10
+ // 3. Calls `validateFeaturesJson` on the freshly-created features.json so
11
+ // the AI sees a confirmation (or, in the unlikely case the shared
12
+ // template is corrupt, an error) inline.
13
+ // 4. Copies `shared/ghs.default.json` to `<projectDir>/.ghs/ghs.json` when
14
+ // the user doesn't already have one — so they have a starting point to
15
+ // customise model IDs (R3).
16
+ // 5. Calls `syncAgents` to render the 3 agent markdown files into
17
+ // `<projectDir>/.opencode/agents/`. This is a direct function import,
18
+ // NOT a tool invocation (per plan §3.4 D2).
19
+ //
20
+ // All file I/O is pure — no LLM calls. The returned string is what the AI
21
+ // sees as the tool result.
22
+
23
+ import { tool } from "@opencode-ai/plugin";
24
+ import type { ToolContext } from "@opencode-ai/plugin/tool";
25
+ import { join, resolve } from "node:path";
26
+ import { copyFile, mkdir } from "node:fs/promises";
27
+
28
+ import {
29
+ initProject,
30
+ InitFilesExistError,
31
+ } from "../lib/scripts/init-project.ts";
32
+ import {
33
+ validateFeaturesJson,
34
+ formatValidationReport,
35
+ } from "../lib/scripts/validate-structure.ts";
36
+ import { syncAgents } from "../lib/config.ts";
37
+ import { pluginRoot } from "../lib/paths.ts";
38
+ import { resolveProjectDir } from "../lib/project.ts";
39
+
40
+ /**
41
+ * Copy the plugin's `shared/ghs.default.json` to `<projectDir>/.ghs/ghs.json`
42
+ * when the user doesn't already have one. Returns true when a copy happened,
43
+ * false when the destination already existed.
44
+ */
45
+ async function seedGhsJsonIfMissing(
46
+ projectDir: string,
47
+ pluginRootDir: string,
48
+ ): Promise<boolean> {
49
+ const dest = join(resolve(projectDir), ".ghs", "ghs.json");
50
+ const destFile = Bun.file(dest);
51
+ if (await destFile.exists()) {
52
+ return false;
53
+ }
54
+ const src = join(pluginRootDir, "shared", "ghs.default.json");
55
+ // Ensure `.ghs/` exists (initProject already created it, but be defensive
56
+ // — this function is also callable on a project where only .opencode/
57
+ // exists).
58
+ await mkdir(join(resolve(projectDir), ".ghs"), { recursive: true });
59
+ await copyFile(src, dest);
60
+ return true;
61
+ }
62
+
63
+ /**
64
+ * The `ghs-init` tool definition. Registered by the plugin entry point under
65
+ * the `ghs-init` key (hyphenated, per spike 001 / D1).
66
+ */
67
+ export const initTool = tool({
68
+ description:
69
+ "Initialise the Golden Hoop Spell (ghs) tracking files for the current project. " +
70
+ "Creates `.ghs/features.json`, `.ghs/progress.md`, `.ghs/ghs.json` (with default model IDs), " +
71
+ "and the 3 subagent markdown files under `.opencode/agents/ghs-*.md`. " +
72
+ "Also appends `.ghs` to `.gitignore`. " +
73
+ "Re-run with `force: true` to overwrite existing `.ghs/features.json` and `.ghs/progress.md`.",
74
+ args: {
75
+ project_name: tool.schema
76
+ .string()
77
+ .describe("Human-readable project name (written into features.json#project.name)."),
78
+ description: tool.schema
79
+ .string()
80
+ .optional()
81
+ .describe(
82
+ "Optional project description. Defaults to '<project_name> project' when omitted.",
83
+ ),
84
+ project_dir: tool.schema
85
+ .string()
86
+ .optional()
87
+ .describe(
88
+ "Absolute path of the project root to initialise. Defaults to the opencode session's worktree/directory.",
89
+ ),
90
+ force: tool.schema
91
+ .boolean()
92
+ .optional()
93
+ .describe(
94
+ "When true, overwrite existing `.ghs/features.json` and `.ghs/progress.md`. Default false.",
95
+ ),
96
+ },
97
+ async execute(
98
+ args: {
99
+ project_name: string;
100
+ description?: string;
101
+ project_dir?: string;
102
+ force?: boolean;
103
+ },
104
+ ctx: ToolContext,
105
+ ): Promise<string> {
106
+ const projectDir = args.project_dir
107
+ ? resolve(args.project_dir)
108
+ : resolveProjectDir(ctx);
109
+ const root = pluginRoot();
110
+
111
+ // Step 1: create .ghs/features.json + .ghs/progress.md + .gitignore.
112
+ let initResult;
113
+ try {
114
+ initResult = await initProject({
115
+ projectName: args.project_name,
116
+ description: args.description,
117
+ projectDir,
118
+ force: args.force === true,
119
+ pluginRootPath: root,
120
+ });
121
+ } catch (err) {
122
+ if (err instanceof InitFilesExistError) {
123
+ return [
124
+ "❌ ghs-init refused to overwrite existing files:",
125
+ "",
126
+ err.message,
127
+ "",
128
+ "Re-run with `force: true` to overwrite.",
129
+ ].join("\n");
130
+ }
131
+ throw err;
132
+ }
133
+
134
+ // Step 2: validate the freshly-created features.json. In the happy path
135
+ // this always passes (we just wrote it from the shared template); running
136
+ // it here surfaces a useful confirmation to the AI and guards against a
137
+ // corrupt shared template.
138
+ const validation = await validateFeaturesJson(initResult.featuresFile);
139
+ const validationReport = formatValidationReport(validation);
140
+
141
+ // Step 3: seed .ghs/ghs.json from the plugin default if the user hasn't
142
+ // placed one yet. Returns whether a copy happened.
143
+ const seededGhsJson = await seedGhsJsonIfMissing(projectDir, root);
144
+
145
+ // Step 4: render the 3 subagent markdowns into .opencode/agents/.
146
+ const sync = await syncAgents(projectDir, root);
147
+
148
+ // Format the result string the AI sees. Keep it human-readable and
149
+ // include the validation outcome + the restart hint (syncAgents writes
150
+ // files but opencode only picks them up on next process start — spike 004).
151
+ const lines: string[] = [];
152
+ lines.push("=== ghs-init complete ===");
153
+ lines.push("");
154
+ lines.push(`Project directory: ${initResult.outputDir}`);
155
+ lines.push(`Project name: ${initResult.projectName}`);
156
+ lines.push(`Description: ${initResult.projectDescription}`);
157
+ lines.push("");
158
+ lines.push("Files created:");
159
+ lines.push(` - ${initResult.featuresFile}`);
160
+ lines.push(` - ${initResult.progressFile}`);
161
+ if (initResult.gitignoreUpdated) {
162
+ lines.push(` - ${initResult.gitignoreFile} (appended \`.ghs\`)`);
163
+ } else {
164
+ lines.push(
165
+ ` - ${initResult.gitignoreFile} (already contained \`.ghs\`)`,
166
+ );
167
+ }
168
+ if (seededGhsJson) {
169
+ lines.push(` - ${join(projectDir, ".ghs", "ghs.json")} (copied from plugin default)`);
170
+ }
171
+ for (const agentPath of sync.written) {
172
+ lines.push(` - ${agentPath}`);
173
+ }
174
+ lines.push("");
175
+ lines.push("Resolved model IDs:");
176
+ lines.push(` context: ${sync.models.context}${sync.defaults_used ? " (default)" : ""}`);
177
+ lines.push(` designer: ${sync.models.designer}${sync.defaults_used ? " (default)" : ""}`);
178
+ lines.push(` reviewer: ${sync.models.reviewer}${sync.defaults_used ? " (default)" : ""}`);
179
+ if (sync.defaults_used) {
180
+ lines.push("");
181
+ lines.push(
182
+ "ℹ️ Model IDs came from the plugin default. Edit `.ghs/ghs.json` to customise, then call `ghs-config`.",
183
+ );
184
+ }
185
+ lines.push("");
186
+ lines.push("Restart your OpenCode session for the new agent definitions to take effect.");
187
+ lines.push("");
188
+ lines.push("--- features.json validation ---");
189
+ lines.push(validationReport);
190
+
191
+ return lines.join("\n");
192
+ },
193
+ });