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,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
|
+
});
|