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,232 @@
1
+ // `ghs-plan-start` tool — entry point of the 3-role plan dispatcher.
2
+ //
3
+ // This is the s3-feat-006 productisation of the source plugin's plan dispatcher
4
+ // Detection phase (plan §3.5 / §3.7 step 1). It is a *thin wrapper* composing:
5
+ // - `resolveProjectDir(ctx)` (s1-feat-006) — explicit arg overrides the
6
+ // opencode session's dir.
7
+ // - `detectCodegraph(projectDir)` (s3-feat-002) — R1 runtime probe for
8
+ // `.codegraph/`.
9
+ // - `writePlanStatus(...)` (s3-feat-005) — persist the initial
10
+ // `status.json` carrying
11
+ // `codegraph_available`.
12
+ // - `CONTEXT_CODEGRAPH_PROMPT` / (s3-feat-004) — the context-collection
13
+ // `CONTEXT_GREP_PROMPT` dispatch directive chosen by the probe.
14
+ //
15
+ // The tool `execute` never calls an LLM and never touches the agent registry.
16
+ // It only:
17
+ // 1. resolves the project dir,
18
+ // 2. probes codegraph availability,
19
+ // 3. generates a `{date}-{slug}` plan_id,
20
+ // 4. writes the initial status.json (round 1, status `designing`,
21
+ // codegraph_available = probe result),
22
+ // 5. returns an LLM-facing dispatch directive telling the main chat AI to
23
+ // spawn the `ghs-context-haiku` subagent via the Task tool, and then —
24
+ // once the snapshot is back — to feed it to `ghs-plan-review(snapshot)`.
25
+ //
26
+ // The dispatch directive carries the codegraph-vs-grep prompt inline so the AI
27
+ // has everything it needs to drive the plan loop forward without a second tool
28
+ // round-trip. Style follows s2-feat-003's `sprint.ts` (thin wrapper, descriptive
29
+ // result text) and s1-feat-008's I/O style (Bun.file / Bun.write, no
30
+ // process.exit, no console.log). The returned string is LLM-facing prose
31
+ // (中文正文 + 英文 identifiers, per CLAUDE.md).
32
+
33
+ import { tool } from "@opencode-ai/plugin";
34
+ import type { ToolContext } from "@opencode-ai/plugin/tool";
35
+ import { resolve } from "node:path";
36
+
37
+ import { resolveProjectDir } from "../lib/project.ts";
38
+ import { detectCodegraph } from "../lib/codegraph.ts";
39
+ import {
40
+ createInitialPlanStatus,
41
+ writePlanStatus,
42
+ DEFAULT_MAX_ROUNDS,
43
+ } from "../lib/state.ts";
44
+ import { CONTEXT_CODEGRAPH_PROMPT } from "../prompts/context-codegraph.ts";
45
+ import { CONTEXT_GREP_PROMPT } from "../prompts/context-grep.ts";
46
+ import { formatLocalDate } from "../lib/scripts/archive-sprint.ts";
47
+
48
+ /**
49
+ * Maximum slug length (in characters) we keep from a sanitised requirement.
50
+ *
51
+ * Slugs end up in on-disk file names (`<plan_id>-status.json` etc.). A bounded
52
+ * length keeps directory listings readable and avoids OS path-length limits
53
+ * even when the AI hands us an unusually long requirement string. 60 chars is
54
+ * comfortably below every common file-name ceiling while still being
55
+ * descriptive enough to disambiguate plans on the same day.
56
+ */
57
+ const MAX_SLUG_LENGTH = 60;
58
+
59
+ /**
60
+ * Sanitise an arbitrary human-readable string into a filesystem-safe slug.
61
+ *
62
+ * The slug is the `<slug>` half of the `plan_id` (`{date}-{slug}`) and ends up
63
+ * as part of every sibling file name. Rules (defensive, idempotent):
64
+ * - Trim + collapse internal whitespace into single `-`.
65
+ * - Lower-case ASCII letters / digits are kept verbatim.
66
+ * - Underscores, hyphens, dots are kept verbatim.
67
+ * - Any other character (punctuation, CJK, emoji, accented Latin, …) is
68
+ * collapsed into a single `-`. We deliberately do NOT try to romanise CJK
69
+ * — the requirement is filesystem-safety, not transliteration; the human
70
+ * description is preserved separately in the dispatch text.
71
+ * - Collapse runs of `-`/`.` separators, strip leading/trailing separators.
72
+ * - Truncate to {@link MAX_SLUG_LENGTH} chars on a `-` boundary.
73
+ * - Empty result falls back to `plan` so we always emit a non-empty slug.
74
+ *
75
+ * Pure function; safe to call with any input (including empty / non-string).
76
+ */
77
+ export function slugifyRequirement(input: string): string {
78
+ const raw = typeof input === "string" ? input : "";
79
+ // Normalise whitespace and strip characters that are not filesystem-safe.
80
+ // We keep ASCII alphanumerics, `-`, `_`, `.`; everything else becomes `-`.
81
+ const sanitised = raw
82
+ .trim()
83
+ .replace(/\s+/g, "-")
84
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
85
+ .replace(/[-.]{2,}/g, "-")
86
+ .replace(/^[-.]+|[-.]+$/g, "")
87
+ .toLowerCase();
88
+
89
+ if (sanitised.length === 0) {
90
+ return "plan";
91
+ }
92
+
93
+ // Truncate on a `-` boundary so we don't end mid-word.
94
+ const truncated =
95
+ sanitised.length <= MAX_SLUG_LENGTH
96
+ ? sanitised
97
+ : sanitised.slice(0, MAX_SLUG_LENGTH).replace(/-[^-]*$/, "");
98
+ return truncated.length === 0 ? "plan" : truncated;
99
+ }
100
+
101
+ /**
102
+ * Build the `{YYYY-MM-DD}-{slug}` plan identifier from a requirement string.
103
+ *
104
+ * Exported so the sibling `ghs-plan-review` / `ghs-plan-finalize` tools (and
105
+ * tests) can re-derive the exact same id from the same inputs without
106
+ * duplicating the date/slug logic. `now` is injectable for deterministic tests.
107
+ */
108
+ export function buildPlanId(
109
+ requirement: string,
110
+ now: Date = new Date(),
111
+ ): string {
112
+ return `${formatLocalDate(now)}-${slugifyRequirement(requirement)}`;
113
+ }
114
+
115
+ /**
116
+ * The `ghs-plan-start` tool definition. Registered by the plugin entry point
117
+ * under the hyphenated `ghs-plan-start` key (per spike 001 / D1).
118
+ */
119
+ export const planStartTool = tool({
120
+ description:
121
+ "Start a new Golden Hoop Spell plan-generation loop (the plan dispatcher's entry point). " +
122
+ "Resolves the project dir, probes whether `.codegraph/` is initialised (R1 runtime detection), " +
123
+ "writes the initial `.ghs/plans/<plan_id>-status.json` carrying `codegraph_available`, " +
124
+ "and returns a Task-tool dispatch directive telling the AI to spawn the `ghs-context-haiku` " +
125
+ "subagent to collect an architecture snapshot (codegraph-aware or grep-fallback prompt) " +
126
+ "and then feed the result to `ghs-plan-review(snapshot)`.",
127
+ args: {
128
+ project_dir: tool.schema
129
+ .string()
130
+ .optional()
131
+ .describe(
132
+ "Absolute path of the project root. Defaults to the opencode session's worktree/directory.",
133
+ ),
134
+ },
135
+ async execute(
136
+ args: { project_dir?: string },
137
+ ctx: ToolContext,
138
+ ): Promise<string> {
139
+ // (1) Resolve the project dir. Explicit arg wins; otherwise read it off
140
+ // the opencode session context (worktree > directory).
141
+ const projectDir = args.project_dir
142
+ ? resolve(args.project_dir)
143
+ : resolveProjectDir(ctx);
144
+
145
+ // (2) Probe codegraph availability (R1). `detectCodegraph` is defensive —
146
+ // empty/invalid paths return `false` rather than throwing, so this call
147
+ // never crashes the tool; the worst case is we take the grep path.
148
+ const codegraphAvailable = detectCodegraph(projectDir);
149
+
150
+ // (3) Generate the plan_id. We have no user-supplied title here (the tool
151
+ // is requirement-driven; the requirement itself is carried by the main
152
+ // chat AI, not by this tool's args), so we derive a placeholder slug from
153
+ // the current date + a stable `plan` stem. The AI / user can rename the
154
+ // final plan file at `ghs-plan-finalize` time; the status file name only
155
+ // needs to be unique per start, which the date prefix guarantees for a
156
+ // single-day loop.
157
+ //
158
+ // NOTE: we deliberately do NOT take a `requirement` arg — the dispatcher
159
+ // loop keeps the requirement in chat context (it is passed verbatim to the
160
+ // context-haiku subagent via the Task tool). The slug is therefore derived
161
+ // from the date alone; collisions within the same day are acceptable
162
+ // because each `ghs-plan-start` write simply overwrites the previous
163
+ // status file for that id (a fresh start resets the loop anyway).
164
+ const now = new Date();
165
+ const planId = buildPlanId("plan", now);
166
+
167
+ // (4) Write the initial status.json. `createInitialPlanStatus` gives us
168
+ // the source-skill defaults (round 1, status `designing`, max_rounds 5,
169
+ // codegraph_available from the probe). `writePlanStatus` validates the
170
+ // object against the Zod schema and `mkdir -p`s `.ghs/plans/` first.
171
+ const status = createInitialPlanStatus({
172
+ planId,
173
+ planFile: `${planId}.md`,
174
+ contextFile: `${planId}-context.md`,
175
+ codegraphAvailable,
176
+ now,
177
+ maxRounds: DEFAULT_MAX_ROUNDS,
178
+ });
179
+
180
+ let statusPath: string;
181
+ try {
182
+ statusPath = await writePlanStatus(projectDir, status);
183
+ } catch (err) {
184
+ // writePlanStatus can throw if mkdir fails (permissions, disk full) or
185
+ // if the Zod schema somehow rejects our object (shouldn't happen for a
186
+ // freshly-built status). Surface the message so the AI/user can diagnose.
187
+ return [
188
+ "❌ ghs-plan-start failed to write initial status.json:",
189
+ "",
190
+ (err as Error).message,
191
+ "",
192
+ `Project directory: ${projectDir}`,
193
+ `Plan id: ${planId}`,
194
+ `Codegraph path: ${codegraphAvailable ? "codegraph" : "grep fallback"}`,
195
+ ].join("\n");
196
+ }
197
+
198
+ // (5) Select the context-collection dispatch directive based on the probe.
199
+ // Both prompts are command-style LLM-facing text (中文 prose + English
200
+ // identifiers) that tell the AI exactly how to spawn `ghs-context-haiku`
201
+ // via the Task tool and what delimiter contract to enforce — they live in
202
+ // `src/prompts/context-{codegraph,grep}.ts`.
203
+ const contextPrompt = codegraphAvailable
204
+ ? CONTEXT_CODEGRAPH_PROMPT
205
+ : CONTEXT_GREP_PROMPT;
206
+
207
+ // (6) Compose the result. Lead with the bookkeeping summary (plan_id,
208
+ // status file path, codegraph path) so the AI can echo it back to the
209
+ // user, then the dispatch directive. The directive ends with an explicit
210
+ // "next step = ghs-plan-review(snapshot)" instruction so the loop's first
211
+ // transition is unambiguous.
212
+ const lines: string[] = [];
213
+ lines.push("=== ghs-plan-start complete ===");
214
+ lines.push("");
215
+ lines.push(`Project directory: ${projectDir}`);
216
+ lines.push(`Plan id: ${planId}`);
217
+ lines.push(`Status file: ${statusPath}`);
218
+ lines.push(`Round: 1 / ${status.max_rounds}`);
219
+ lines.push(
220
+ `Codegraph path: ${codegraphAvailable ? "codegraph (.codegraph/ detected)" : "grep fallback (.codegraph/ absent)"}`,
221
+ );
222
+ lines.push("");
223
+ lines.push(
224
+ "Next step: dispatch the context-haiku subagent (directive below), then call",
225
+ );
226
+ lines.push("`ghs-plan-review(snapshot=...)` with its delimited output.");
227
+ lines.push("");
228
+ lines.push("--- context-haiku dispatch directive ---");
229
+ lines.push(contextPrompt);
230
+ return lines.join("\n");
231
+ },
232
+ });
@@ -0,0 +1,213 @@
1
+ // `ghs-sprint` tool — create a new sprint skeleton in features.json.
2
+ //
3
+ // This is the Sprint 2 (s2-feat-003) productisation of the source plugin's
4
+ // purely-instruction-driven `ghs-sprint` skill. Instead of the AI editing
5
+ // features.json by hand, this tool:
6
+ // 1. Resolves the project dir (explicit `project_dir` arg wins; otherwise
7
+ // `resolveProjectDir(ctx)` reads the opencode session's worktree/dir).
8
+ // 2. Reads features.json.
9
+ // 3. If any sprint has `status === "completed"`, auto-archives it first
10
+ // (via `archiveSprints({ projectDir, dryRun: false })`). This mirrors
11
+ // the source plugin's "archive finished sprints before creating a new
12
+ // one" convention and keeps features.json focused on the active sprint.
13
+ // 4. Auto-generates the next sprint id (`s{N+1}`) by scanning BOTH the
14
+ // current `sprints[]` array AND the `.ghs/archived/` folders, so an id
15
+ // is never reused after archiving.
16
+ // 5. Calls `appendSprint` (s2-feat-001) to build the new featuresData — a
17
+ // pure function; this tool owns the disk write.
18
+ // 6. Writes the updated featuresData back to disk.
19
+ // 7. Returns the `SPRINT_PLANNING_PROMPT` (s2-feat-002) plus a short
20
+ // summary, so the AI immediately knows how to decompose the sprint
21
+ // goal into atomic features and append them via `update-feature-status`.
22
+ //
23
+ // The tool is a thin wrapper composing three existing modules:
24
+ // - archive-sprint.ts (s1-feat-008 port)
25
+ // - append-sprint.ts (s2-feat-001 writer)
26
+ // - sprint-planning.ts (s2-feat-002 prompt)
27
+
28
+ import { tool } from "@opencode-ai/plugin";
29
+ import type { ToolContext } from "@opencode-ai/plugin/tool";
30
+ import { readdirSync, existsSync } from "node:fs";
31
+ import { join, resolve } from "node:path";
32
+
33
+ import { archiveSprints, formatLocalDate } from "../lib/scripts/archive-sprint.ts";
34
+ import { appendSprint } from "../lib/scripts/append-sprint.ts";
35
+ import { SPRINT_PLANNING_PROMPT } from "../prompts/sprint-planning.ts";
36
+ import { resolveProjectDir } from "../lib/project.ts";
37
+
38
+ /**
39
+ * Scan a parsed features.json for the largest sprint numeric id.
40
+ * Returns 0 when there are no sprints.
41
+ */
42
+ function maxSprintNumber(sprints: unknown): number {
43
+ if (!Array.isArray(sprints)) return 0;
44
+ let max = 0;
45
+ for (const s of sprints as Array<Record<string, unknown>>) {
46
+ const id = typeof s.id === "string" ? s.id : "";
47
+ const m = /^s(\d{1,4})$/.exec(id);
48
+ if (m) {
49
+ const n = Number.parseInt(m[1], 10);
50
+ if (Number.isFinite(n) && n > max) max = n;
51
+ }
52
+ }
53
+ return max;
54
+ }
55
+
56
+ /**
57
+ * Scan `.ghs/archived/` folder names for sprint ids.
58
+ *
59
+ * Archived folders are named `<sprintId>_<sprintName>_<timestamp>` (see
60
+ * archive-sprint.ts `archiveSprintFiles`). We parse the leading `s<N>_`
61
+ * prefix to recover the sprint number, so that creating a fresh sprint
62
+ * after an archive never reuses a retired id (the source plugin's
63
+ * uniqueness guarantee is over the lifetime of the project, not just the
64
+ * active sprints array).
65
+ */
66
+ function maxArchivedSprintNumber(projectDir: string): number {
67
+ const archivedPath = join(resolve(projectDir), ".ghs", "archived");
68
+ if (!existsSync(archivedPath)) return 0;
69
+ let max = 0;
70
+ let entries: string[];
71
+ try {
72
+ entries = readdirSync(archivedPath);
73
+ } catch {
74
+ return 0;
75
+ }
76
+ for (const name of entries) {
77
+ const m = /^s(\d{1,4})_/.exec(name);
78
+ if (m) {
79
+ const n = Number.parseInt(m[1], 10);
80
+ if (Number.isFinite(n) && n > max) max = n;
81
+ }
82
+ }
83
+ return max;
84
+ }
85
+
86
+ /**
87
+ * Compute the next sprint id for `projectDir` given the current
88
+ * `featuresData`. Considers both active sprints and archived folders.
89
+ */
90
+ export function nextSprintId(
91
+ projectDir: string,
92
+ featuresData: Record<string, unknown>,
93
+ ): string {
94
+ const fromActive = maxSprintNumber(featuresData.sprints);
95
+ const fromArchived = maxArchivedSprintNumber(projectDir);
96
+ const next = Math.max(fromActive, fromArchived) + 1;
97
+ return `s${next}`;
98
+ }
99
+
100
+ /**
101
+ * The `ghs-sprint` tool definition. Registered by the plugin entry point
102
+ * under the `ghs-sprint` key (hyphenated, per spike 001 / D1).
103
+ */
104
+ export const sprintTool = tool({
105
+ description:
106
+ "Create a new sprint skeleton in features.json. Auto-archives any already-completed sprints first, " +
107
+ "auto-generates the next sprint id (s{N+1}, scanning active + archived sprints so ids never collide), " +
108
+ "appends an empty sprint with status 'planning', writes it back to disk, and returns the sprint-planning " +
109
+ "prompt so the AI can decompose the goal into atomic features (which it then appends via update-feature-status).",
110
+ args: {
111
+ sprint_name: tool.schema
112
+ .string()
113
+ .min(1)
114
+ .describe("Human-readable sprint name (written into features.json#sprints[].name)."),
115
+ goal: tool.schema
116
+ .string()
117
+ .min(1)
118
+ .describe(
119
+ "The sprint goal — what 'done' looks like for this sprint. Written into features.json#sprints[].goal.",
120
+ ),
121
+ project_dir: tool.schema
122
+ .string()
123
+ .optional()
124
+ .describe(
125
+ "Absolute path of the project root. Defaults to the opencode session's worktree/directory.",
126
+ ),
127
+ },
128
+ async execute(
129
+ args: {
130
+ sprint_name: string;
131
+ goal: string;
132
+ project_dir?: string;
133
+ },
134
+ ctx: ToolContext,
135
+ ): Promise<string> {
136
+ const projectDir = args.project_dir
137
+ ? resolve(args.project_dir)
138
+ : resolveProjectDir(ctx);
139
+
140
+ const featuresPath = join(projectDir, ".ghs", "features.json");
141
+ const featuresFile = Bun.file(featuresPath);
142
+ if (!(await featuresFile.exists())) {
143
+ return [
144
+ `❌ features.json not found at ${featuresPath}.`,
145
+ "",
146
+ "Run `ghs-init` first to bootstrap the .ghs/ tracking files.",
147
+ ].join("\n");
148
+ }
149
+
150
+ // (c) Auto-archive completed sprints before creating a new one. If
151
+ // archiveSprints throws (e.g. corrupt features.json), surface the error
152
+ // rather than proceeding — appending on top of a corrupt file would
153
+ // mask the real problem.
154
+ let archivedSummary = "";
155
+ let archivedCount = 0;
156
+ const archived = await archiveSprints({ projectDir });
157
+ if (archived.length > 0) {
158
+ archivedCount = archived.length;
159
+ archivedSummary =
160
+ archived
161
+ .map(
162
+ (info) =>
163
+ ` - ${info.sprint_name} (${info.sprint_id}) → ${info.archive_path ?? "(no path)"}`,
164
+ )
165
+ .join("\n") + "\n\n";
166
+ }
167
+
168
+ // Re-read features.json AFTER archiving — archiveSprints mutates the
169
+ // file on disk (removes the archived sprint, updates metadata). Reading
170
+ // the fresh content keeps the in-memory copy in sync.
171
+ const text = await (Bun.file(featuresPath)).text();
172
+ const featuresData = JSON.parse(text) as Record<string, unknown>;
173
+
174
+ // (d) Auto-generate the next sprint id and append the skeleton.
175
+ const newSprintId = nextSprintId(projectDir, featuresData);
176
+ const updated = appendSprint(featuresData, {
177
+ id: newSprintId,
178
+ name: args.sprint_name,
179
+ goal: args.goal,
180
+ created_at: formatLocalDate(),
181
+ });
182
+
183
+ // (e) Write back to disk. 2-space indent matches every other features.json
184
+ // writer in the project (init-project.ts / archive-sprint.ts).
185
+ await Bun.write(featuresPath, JSON.stringify(updated, null, 2) + "\n");
186
+
187
+ // (f) Compose the result. Lead with the short summary, then the planning
188
+ // prompt so the AI can immediately start decomposing the goal.
189
+ const lines: string[] = [];
190
+ lines.push("=== ghs-sprint complete ===");
191
+ lines.push("");
192
+ lines.push(`Project directory: ${projectDir}`);
193
+ lines.push(`New sprint: ${args.sprint_name} (${newSprintId})`);
194
+ lines.push(`Created at: ${formatLocalDate()}`);
195
+ if (archivedCount > 0) {
196
+ lines.push("");
197
+ lines.push(
198
+ `Auto-archived ${archivedCount} completed sprint(s) before creating ${newSprintId}:`,
199
+ );
200
+ lines.push(archivedSummary.trimEnd());
201
+ }
202
+ lines.push("");
203
+ lines.push(`Sprint skeleton written to ${featuresPath} (status: planning, features: []).`);
204
+ lines.push("");
205
+ lines.push("Next: decompose the sprint goal into atomic features and append each via");
206
+ lines.push("`update-feature-status` (initial status: pending). Then flip the sprint status");
207
+ lines.push("to in_progress once you start coding.");
208
+ lines.push("");
209
+ lines.push("--- sprint-planning prompt ---");
210
+ lines.push(SPRINT_PLANNING_PROMPT);
211
+ return lines.join("\n");
212
+ },
213
+ });
@@ -0,0 +1,51 @@
1
+ // `ghs-status` tool — show the current project's status.
2
+ //
3
+ // Thin wrapper around `status()` from `src/lib/scripts/status.ts`. Resolves
4
+ // the project dir (explicit arg wins; otherwise `resolveProjectDir(ctx)`),
5
+ // invokes the formatter, and returns the formatted text for the AI to read.
6
+ //
7
+ // The returned string is byte-identical to what the Python `status.py`
8
+ // script would have printed to stdout (verified by the equivalence tests
9
+ // in `test/equivalence/status.test.ts`).
10
+
11
+ import { tool } from "@opencode-ai/plugin";
12
+ import type { ToolContext } from "@opencode-ai/plugin/tool";
13
+ import { resolve } from "node:path";
14
+
15
+ import { status } from "../lib/scripts/status.ts";
16
+ import { resolveProjectDir } from "../lib/project.ts";
17
+
18
+ /**
19
+ * The `ghs-status` tool definition. Registered under the `ghs-status` key.
20
+ */
21
+ export const statusTool = tool({
22
+ description:
23
+ "Show the current ghs project status: project name/description, per-sprint " +
24
+ "feature counts (completed/in_progress/pending/blocked), the in-progress feature, " +
25
+ "the next ready feature, and recent progress.md session entries. " +
26
+ "Read-only — does not modify any files.",
27
+ args: {
28
+ project_dir: tool.schema
29
+ .string()
30
+ .optional()
31
+ .describe(
32
+ "Absolute path of the project root. Defaults to the opencode session's worktree/directory.",
33
+ ),
34
+ },
35
+ async execute(
36
+ args: { project_dir?: string },
37
+ ctx: ToolContext,
38
+ ): Promise<string> {
39
+ const projectDir = args.project_dir
40
+ ? resolve(args.project_dir)
41
+ : resolveProjectDir(ctx);
42
+
43
+ const result = await status({ projectDir });
44
+ // `status()` returns `{ text, exitCode }`. exitCode is 1 when
45
+ // features.json is missing — the text already carries the "not found"
46
+ // message, so we just return it verbatim. (We don't surface exitCode as
47
+ // a tool error because the message is more useful to the AI than an
48
+ // opaque failure.)
49
+ return result.text;
50
+ },
51
+ });