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