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,290 @@
1
+ // Port of golden-hoop-spell/plugin/shared/scripts/validate_structure.py.
2
+ //
3
+ // Behavior source-of-truth:
4
+ // /Users/tom/github/golden-hoop-spell/plugin/shared/scripts/validate_structure.py
5
+ //
6
+ // Faithful port notes:
7
+ // - ID-format patterns are anchored regexes ported verbatim:
8
+ // SPRINT_ID_PATTERN = /^s\d{1,4}$/
9
+ // FEATURE_ID_PATTERN = /^s\d{1,4}-feat-\d{3}$/
10
+ // JS regexes are anchored the same way Python's re.compile with ^...$ is.
11
+ // - Validation produces two parallel arrays: `errors` and `warnings`.
12
+ // The order of items matches Python's append/extend order.
13
+ // - This module exports pure functions — no stdout writes. The CLI layer
14
+ // (s1-feat-009) renders the warnings/errors to text using formatReport.
15
+
16
+ import { existsSync } from "node:fs";
17
+ import { readFile } from "node:fs/promises";
18
+ import { join, resolve } from "node:path";
19
+
20
+ const SPRINT_ID_PATTERN = /^s\d{1,4}$/;
21
+ const FEATURE_ID_PATTERN = /^s\d{1,4}-feat-\d{3}$/;
22
+
23
+ // Shared shape for the parsed JSON.
24
+ type JsonObject = Record<string, unknown>;
25
+ type Feature = JsonObject;
26
+ type Sprint = JsonObject;
27
+ type FeaturesData = JsonObject;
28
+
29
+ /** Validation result: a tuple of error strings and warning strings. */
30
+ export interface ValidationResult {
31
+ errors: string[];
32
+ warnings: string[];
33
+ }
34
+
35
+ const VALID_FEATURE_STATUSES = ["pending", "in_progress", "completed", "blocked"];
36
+ const VALID_FEATURE_PRIORITIES = ["high", "medium", "low"];
37
+ const VALID_FEATURE_CATEGORIES = ["core", "ui", "api", "auth", "data", "infra"];
38
+ const VALID_SPRINT_STATUSES = ["planning", "in_progress", "completed", "on_hold"];
39
+
40
+ /** Validate the `project` section of features.json. */
41
+ export function validateProjectSection(data: FeaturesData): string[] {
42
+ const errors: string[] = [];
43
+ const project = (data.project ?? {}) as JsonObject;
44
+ const requiredFields = ["name", "description", "created_at"];
45
+ for (const field of requiredFields) {
46
+ if (!(field in project)) {
47
+ errors.push(`Missing project.${field}`);
48
+ }
49
+ }
50
+ return errors;
51
+ }
52
+
53
+ /** Validate that a sprint ID matches `^s\d{1,4}$`. */
54
+ export function validateSprintIdFormat(
55
+ sprintId: string,
56
+ sprintIdx: number,
57
+ ): string[] {
58
+ const errors: string[] = [];
59
+ if (!SPRINT_ID_PATTERN.test(sprintId)) {
60
+ errors.push(
61
+ `Sprint ${sprintIdx}: invalid sprint ID format '${sprintId}' ` +
62
+ `(must match ^s\\d{1,4}$, e.g. s1, s12, s1234)`,
63
+ );
64
+ }
65
+ return errors;
66
+ }
67
+
68
+ /** Validate that a feature ID matches `^s\d{1,4}-feat-\d{3}$`. */
69
+ export function validateFeatureIdFormat(
70
+ featureId: string,
71
+ featureIdx: number,
72
+ ): string[] {
73
+ const errors: string[] = [];
74
+ if (!FEATURE_ID_PATTERN.test(featureId)) {
75
+ errors.push(
76
+ `Feature ${featureIdx}: invalid feature ID format '${featureId}' ` +
77
+ `(must match ^s\\d{1,4}-feat-\\d{3}$, e.g. s1-feat-001)`,
78
+ );
79
+ }
80
+ return errors;
81
+ }
82
+
83
+ /** Validate that the sprint number prefix in a feature ID matches its parent sprint. */
84
+ export function validateFeaturePrefixConsistency(
85
+ featureId: string,
86
+ sprintId: string,
87
+ featureIdx: number,
88
+ ): string[] {
89
+ const errors: string[] = [];
90
+ if (SPRINT_ID_PATTERN.test(sprintId) && FEATURE_ID_PATTERN.test(featureId)) {
91
+ const sprintNum = sprintId.slice(1); // strip leading 's'
92
+ const featurePrefix = featureId.split("-feat-")[0].slice(1);
93
+ if (sprintNum !== featurePrefix) {
94
+ errors.push(
95
+ `Feature ${featureIdx}: feature ID '${featureId}' prefix ` +
96
+ `does not match parent sprint '${sprintId}' ` +
97
+ `(sprint number ${sprintNum} vs feature prefix ${featurePrefix})`,
98
+ );
99
+ }
100
+ }
101
+ return errors;
102
+ }
103
+
104
+ /** Validate a single feature. Returns `{ errors, warnings }`. */
105
+ export function validateFeature(
106
+ feature: Feature,
107
+ featureIdx: number,
108
+ sprintId = "",
109
+ ): ValidationResult {
110
+ const errors: string[] = [];
111
+ const warnings: string[] = [];
112
+
113
+ const requiredFields = ["id", "title", "description", "status"];
114
+ for (const field of requiredFields) {
115
+ if (!(field in feature)) {
116
+ errors.push(`Feature ${featureIdx}: missing '${field}'`);
117
+ }
118
+ }
119
+
120
+ const featureId = (feature.id as string | undefined) ?? "";
121
+ if (featureId) {
122
+ errors.push(...validateFeatureIdFormat(featureId, featureIdx));
123
+ }
124
+
125
+ if (featureId && sprintId) {
126
+ errors.push(
127
+ ...validateFeaturePrefixConsistency(featureId, sprintId, featureIdx),
128
+ );
129
+ }
130
+
131
+ const status = (feature.status as string | undefined) ?? "";
132
+ if (status && !VALID_FEATURE_STATUSES.includes(status)) {
133
+ errors.push(`Feature ${featureIdx}: invalid status '${status}'`);
134
+ }
135
+
136
+ if (status === "blocked" && !("blocked_reason" in feature)) {
137
+ warnings.push(
138
+ `Feature ${featureIdx}: status is 'blocked' but no 'blocked_reason' field ` +
139
+ "(recommended but not required)",
140
+ );
141
+ }
142
+
143
+ const priority = (feature.priority as string | undefined) ?? "";
144
+ if (priority && !VALID_FEATURE_PRIORITIES.includes(priority)) {
145
+ errors.push(`Feature ${featureIdx}: invalid priority '${priority}'`);
146
+ }
147
+
148
+ const category = (feature.category as string | undefined) ?? "";
149
+ if (category && !VALID_FEATURE_CATEGORIES.includes(category)) {
150
+ errors.push(`Feature ${featureIdx}: invalid category '${category}'`);
151
+ }
152
+
153
+ return { errors, warnings };
154
+ }
155
+
156
+ /** Validate a single sprint. Returns `{ errors, warnings }`. */
157
+ export function validateSprint(
158
+ sprint: Sprint,
159
+ sprintIdx: number,
160
+ ): ValidationResult {
161
+ const errors: string[] = [];
162
+ const warnings: string[] = [];
163
+
164
+ const requiredFields = ["id", "name", "status"];
165
+ for (const field of requiredFields) {
166
+ if (!(field in sprint)) {
167
+ errors.push(`Sprint ${sprintIdx}: missing '${field}'`);
168
+ }
169
+ }
170
+
171
+ const sprintId = (sprint.id as string | undefined) ?? "";
172
+ if (sprintId) {
173
+ errors.push(...validateSprintIdFormat(sprintId, sprintIdx));
174
+ }
175
+
176
+ const status = (sprint.status as string | undefined) ?? "";
177
+ if (status && !VALID_SPRINT_STATUSES.includes(status)) {
178
+ errors.push(`Sprint ${sprintIdx}: invalid status '${status}'`);
179
+ }
180
+
181
+ const features = sprint.features;
182
+ if (!Array.isArray(features)) {
183
+ errors.push(`Sprint ${sprintIdx}: 'features' must be an array`);
184
+ } else {
185
+ features.forEach((feature, idx) => {
186
+ const r = validateFeature(feature as Feature, idx, sprintId);
187
+ errors.push(...r.errors);
188
+ warnings.push(...r.warnings);
189
+ });
190
+ }
191
+
192
+ return { errors, warnings };
193
+ }
194
+
195
+ /**
196
+ * Validate an entire features.json structure on disk.
197
+ *
198
+ * Mirrors Python `validate_features_json(Path)`: returns `{ errors, warnings }`.
199
+ * The first error is `"File not found: <path>"` when the file is missing, or
200
+ * `"Invalid JSON: <message>"` when parsing fails.
201
+ */
202
+ export async function validateFeaturesJson(
203
+ filepath: string,
204
+ ): Promise<ValidationResult> {
205
+ const errors: string[] = [];
206
+ const warnings: string[] = [];
207
+
208
+ if (!existsSync(filepath)) {
209
+ return { errors: [`File not found: ${filepath}`], warnings: [] };
210
+ }
211
+
212
+ let data: FeaturesData;
213
+ try {
214
+ const text = await readFile(filepath, "utf8");
215
+ data = JSON.parse(text) as FeaturesData;
216
+ } catch (e) {
217
+ const msg = e instanceof Error ? e.message : String(e);
218
+ return { errors: [`Invalid JSON: ${msg}`], warnings: [] };
219
+ }
220
+
221
+ errors.push(...validateProjectSection(data));
222
+
223
+ const sprints = data.sprints;
224
+ if (!Array.isArray(sprints)) {
225
+ errors.push("'sprints' must be an array");
226
+ } else {
227
+ sprints.forEach((sprint, idx) => {
228
+ const r = validateSprint(sprint as Sprint, idx);
229
+ errors.push(...r.errors);
230
+ warnings.push(...r.warnings);
231
+ });
232
+ }
233
+
234
+ return { errors, warnings };
235
+ }
236
+
237
+ /**
238
+ * Validate features.json for the project at `projectDir`.
239
+ *
240
+ * Convenience wrapper that resolves `<projectDir>/.ghs/features.json` and
241
+ * invokes `validateFeaturesJson`.
242
+ */
243
+ export async function validateProjectStructure(
244
+ projectDir: string,
245
+ ): Promise<ValidationResult> {
246
+ const featuresPath = join(resolve(projectDir), ".ghs", "features.json");
247
+ return validateFeaturesJson(featuresPath);
248
+ }
249
+
250
+ /**
251
+ * Render a validation report as the Python `main()` would print to stdout.
252
+ *
253
+ * This is exported so the CLI wrapper in s1-feat-009 can produce byte-identical
254
+ * output without re-implementing the format. The text matches:
255
+ *
256
+ * === Validating features.json ===\n\n
257
+ * [<warnings block if any>]
258
+ * [<errors block if any> | <success block>]
259
+ */
260
+ export function formatValidationReport(result: ValidationResult): string {
261
+ const lines: string[] = [];
262
+ lines.push("=== Validating features.json ===");
263
+ lines.push("");
264
+
265
+ if (result.warnings.length > 0) {
266
+ lines.push("⚠️ Warnings:");
267
+ lines.push("");
268
+ for (const warning of result.warnings) {
269
+ lines.push(` • ${warning}`);
270
+ }
271
+ lines.push("");
272
+ }
273
+
274
+ if (result.errors.length > 0) {
275
+ lines.push("❌ Validation failed:");
276
+ lines.push("");
277
+ for (const error of result.errors) {
278
+ lines.push(` • ${error}`);
279
+ }
280
+ } else {
281
+ lines.push("✅ Validation passed!");
282
+ lines.push(" All required fields present");
283
+ lines.push(" All status values valid");
284
+ lines.push(" All ID formats valid");
285
+ lines.push(" Feature ID prefixes consistent");
286
+ lines.push(" Structure is correct");
287
+ }
288
+
289
+ return lines.join("\n");
290
+ }
@@ -0,0 +1,305 @@
1
+ // Read/write the plan dispatcher's per-plan `status.json` state file.
2
+ //
3
+ // Each `ghs-plan-*` tool invocation is one step in a multi-round plan
4
+ // generation loop (plan §3.5 / §3.7). Between steps the dispatcher persists
5
+ // its progress to `<projectDir>/.ghs/plans/<plan_id>-status.json` so that:
6
+ // - the next `ghs-plan-review` call can pick up where the previous one left
7
+ // off (round counter, current phase, codegraph path taken);
8
+ // - `ghs-plan-finalize` can flip `status` to `approved` atomically;
9
+ // - post-hoc auditing (`grep '"accepted_with_fail": true'`) still works
10
+ // exactly as in the source plugin.
11
+ //
12
+ // Schema field-by-field parity with the source skill
13
+ // (`plugin/skills/ghs-plan/SKILL.md` → "State Tracking"), plus the R1 addition
14
+ // required by this sprint: `codegraph_available: boolean` records whether the
15
+ // Context Subagent for THIS plan took the codegraph path or the grep fallback
16
+ // (drives status reporting + downstream tooling decisions).
17
+ //
18
+ // Style follows s2-feat-001's writer modules (pure, Zod-validated) + s1-feat-008's
19
+ // I/O style (Bun.file / Bun.write, no process.exit, no console.log, descriptive
20
+ // thrown Errors on failure). The read/write helpers are *not* pure — they touch
21
+ // the filesystem — but each is a thin, single-responsibility wrapper that the
22
+ // plan tools compose.
23
+
24
+ import { z } from "zod";
25
+ import { resolve } from "node:path";
26
+ import { mkdir } from "node:fs/promises";
27
+
28
+ // -----------------------------------------------------------------------------
29
+ // Schema
30
+ // -----------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Lifecycle states a plan moves through, matching the source skill's enum
34
+ * verbatim. We keep this as a literal union (rather than `z.enum(...)`) so
35
+ * callers get precise autocompletion and the type flows into `PlanStatus.status`
36
+ * without a cast.
37
+ */
38
+ export const PLAN_STATUS_VALUES = [
39
+ "designing",
40
+ "reviewing",
41
+ "revising",
42
+ "pending_approval",
43
+ "approved",
44
+ "rejected",
45
+ "aborted",
46
+ ] as const;
47
+ export type PlanStatusValue = (typeof PLAN_STATUS_VALUES)[number];
48
+
49
+ /**
50
+ * Zod schema for `<plan_id>-status.json`.
51
+ *
52
+ * `strict()` rejects unknown fields so a typo in the dispatcher's write path
53
+ * surfaces immediately instead of silently corrupting state. Mirrors the
54
+ * `GhsConfigSchema` discipline in `src/lib/config.ts`.
55
+ *
56
+ * Fields (source: `plugin/skills/ghs-plan/SKILL.md` "State Tracking"):
57
+ * - `plan_id`: the `{date}-{slug}` identifier used to derive
58
+ * every sibling file name (`<plan_id>.md`,
59
+ * `<plan_id>-context.md`, `<plan_id>-review.md`,
60
+ * `<plan_id>-status.json`).
61
+ * - `plan_file`: relative name of the designer's plan markdown.
62
+ * - `context_file`: relative name of the context snapshot markdown.
63
+ * - `review_file`: relative name of the reviewer's review markdown
64
+ * (optional until the reviewer has run at least
65
+ * once — absence is meaningful).
66
+ * - `round`: current review-revise round, 1-indexed.
67
+ * - `status`: lifecycle enum (see {@link PLAN_STATUS_VALUES}).
68
+ * - `codegraph_available`: R1 addition — whether `.codegraph/` was present
69
+ * when `ghs-plan-start` ran. Persists the path
70
+ * choice for the entire plan lifetime so later
71
+ * phases and status reports stay consistent.
72
+ * - `max_rounds`: soft cap on review-revise iterations.
73
+ * - `max_rounds_breaches`: how many times the user overrode the soft cap.
74
+ * - `accepted_with_fail`: true iff the plan passed with unfixed issues
75
+ * (audit flag — `status` stays `approved`).
76
+ * - `keep_raw_on_success`: debug flag — when true, raw subagent responses
77
+ * are kept on the happy path.
78
+ * - `created_at` / `updated_at`: ISO-ish timestamps (`YYYY-MM-DDTHH:mm:ss`).
79
+ */
80
+ export const PlanStatusSchema = z.strictObject({
81
+ plan_id: z.string().min(1),
82
+ plan_file: z.string().min(1),
83
+ context_file: z.string().min(1),
84
+ review_file: z.string().optional(),
85
+ round: z.number().int().nonnegative(),
86
+ status: z.enum(PLAN_STATUS_VALUES),
87
+ codegraph_available: z.boolean(),
88
+ max_rounds: z.number().int().positive(),
89
+ max_rounds_breaches: z.number().int().nonnegative(),
90
+ accepted_with_fail: z.boolean(),
91
+ keep_raw_on_success: z.boolean(),
92
+ created_at: z.string().min(1),
93
+ updated_at: z.string().min(1),
94
+ });
95
+
96
+ export type PlanStatus = z.infer<typeof PlanStatusSchema>;
97
+
98
+ // -----------------------------------------------------------------------------
99
+ // Path helpers
100
+ // -----------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Directory holding every plan-related artefact for a project
104
+ * (`<projectDir>/.ghs/plans/`). The status file and its sibling
105
+ * plan/context/review markdowns all live here per the source skill's
106
+ * "File Conventions" table.
107
+ */
108
+ export function plansDir(projectDir: string): string {
109
+ return resolve(projectDir, ".ghs", "plans");
110
+ }
111
+
112
+ /**
113
+ * Absolute path to a plan's status file
114
+ * (`<projectDir>/.ghs/plans/<plan_id>-status.json`).
115
+ *
116
+ * `planId` is the `{date}-{slug}` identifier emitted by `ghs-plan-start`. The
117
+ * `-status.json` suffix matches the source skill's file convention table
118
+ * verbatim.
119
+ */
120
+ export function statusFilePath(projectDir: string, planId: string): string {
121
+ return resolve(plansDir(projectDir), `${planId}-status.json`);
122
+ }
123
+
124
+ // -----------------------------------------------------------------------------
125
+ // Timestamp helper
126
+ // -----------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Produce a `YYYY-MM-DDTHH:mm:ss` timestamp in the *local* timezone, matching
130
+ * the format the source skill writes. The source uses Python
131
+ * `datetime.now().strftime("%Y-%m-%dT%H:%M:%S")` (no `tzinfo` → local time, no
132
+ * millis). We mirror that exactly so a status file written by the TS port is
133
+ * indistinguishable from one the source plugin would have produced.
134
+ *
135
+ * We deliberately avoid `new Date().toISOString()` — that emits UTC with
136
+ * millis and a `Z` suffix, which would break byte-parity with any future
137
+ * equivalence harness and reads as a different timezone to users diffing
138
+ * `status.json`.
139
+ */
140
+ export function formatLocalTimestamp(now: Date = new Date()): string {
141
+ const pad = (n: number): string => n.toString().padStart(2, "0");
142
+ return (
143
+ `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
144
+ `T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
145
+ );
146
+ }
147
+
148
+ // -----------------------------------------------------------------------------
149
+ // Defaults
150
+ // -----------------------------------------------------------------------------
151
+
152
+ /**
153
+ * Default soft cap on review-revise rounds before the dispatcher asks the user
154
+ * whether to breach. Matches the source skill's default (Phase 2 "Constants"
155
+ * block). Exposed as a named constant so the plan tools can reference the same
156
+ * source of truth instead of re-hardcoding `5`.
157
+ */
158
+ export const DEFAULT_MAX_ROUNDS = 5;
159
+
160
+ /**
161
+ * Build a fresh `PlanStatus` object for a newly-started plan, with sensible
162
+ * defaults pulled from the source skill's "State Tracking" section.
163
+ *
164
+ * The caller supplies the identifying fields (`planId`, `planFile`,
165
+ * `contextFile`, `codegraphAvailable`) plus optional overrides (e.g. a
166
+ * non-default `max_rounds`). Everything else gets the source defaults:
167
+ * - `round: 1` — first review-revise round.
168
+ * - `status: "designing"` — initial lifecycle state.
169
+ * - `max_rounds_breaches: 0`, `accepted_with_fail: false`,
170
+ * `keep_raw_on_success: false`.
171
+ * - `created_at` and `updated_at` set to the same local timestamp.
172
+ *
173
+ * This is a pure function — it does NOT touch the filesystem. Pair with
174
+ * {@link writePlanStatus} to persist.
175
+ */
176
+ export function createInitialPlanStatus(args: {
177
+ planId: string;
178
+ planFile: string;
179
+ contextFile: string;
180
+ codegraphAvailable: boolean;
181
+ maxRounds?: number;
182
+ now?: Date;
183
+ }): PlanStatus {
184
+ const ts = formatLocalTimestamp(args.now);
185
+ return {
186
+ plan_id: args.planId,
187
+ plan_file: args.planFile,
188
+ context_file: args.contextFile,
189
+ round: 1,
190
+ status: "designing",
191
+ codegraph_available: args.codegraphAvailable,
192
+ max_rounds: args.maxRounds ?? DEFAULT_MAX_ROUNDS,
193
+ max_rounds_breaches: 0,
194
+ accepted_with_fail: false,
195
+ keep_raw_on_success: false,
196
+ created_at: ts,
197
+ updated_at: ts,
198
+ };
199
+ }
200
+
201
+ // -----------------------------------------------------------------------------
202
+ // I/O — read / write / existence probe
203
+ // -----------------------------------------------------------------------------
204
+
205
+ /**
206
+ * Read + validate a plan's status file.
207
+ *
208
+ * Behaviour:
209
+ * - If the file does not exist, returns `null` (the caller — typically
210
+ * `ghs-plan-review` — decides whether that is an error, e.g. "no plan in
211
+ * progress, call `ghs-plan-start` first"). We do NOT throw here because
212
+ * "no status yet" is a normal state for the dispatcher's first step.
213
+ * - If the file exists but is unparseable JSON or fails the Zod schema,
214
+ * throws a descriptive `Error` (corrupt state should never be silently
215
+ * ignored — the dispatcher must stop and surface the problem).
216
+ *
217
+ * @param projectDir - absolute host project root.
218
+ * @param planId - the `{date}-{slug}` plan identifier.
219
+ * @returns the validated `PlanStatus`, or `null` when no status file exists.
220
+ */
221
+ export async function readPlanStatus(
222
+ projectDir: string,
223
+ planId: string,
224
+ ): Promise<PlanStatus | null> {
225
+ const path = statusFilePath(projectDir, planId);
226
+ const file = Bun.file(path);
227
+ const exists = await file.exists();
228
+ if (!exists) {
229
+ return null;
230
+ }
231
+
232
+ let text: string;
233
+ try {
234
+ text = await file.text();
235
+ } catch (err) {
236
+ throw new Error(
237
+ `Failed to read plan status at ${path}: ${(err as Error).message}`,
238
+ );
239
+ }
240
+
241
+ let parsed: unknown;
242
+ try {
243
+ parsed = JSON.parse(text);
244
+ } catch (err) {
245
+ throw new Error(
246
+ `Failed to parse plan status at ${path}: invalid JSON — ${(err as Error).message}`,
247
+ );
248
+ }
249
+
250
+ // Zod validation surfaces structural corruption (missing fields, wrong
251
+ // types, unknown fields via `.strict()`) as a thrown ZodError. We let it
252
+ // propagate — the plan tools catch Errors and return the message to the AI.
253
+ return PlanStatusSchema.parse(parsed);
254
+ }
255
+
256
+ /**
257
+ * Write a `PlanStatus` to its status file.
258
+ *
259
+ * Side effects:
260
+ * - Creates `<projectDir>/.ghs/plans/` (recursively) if it does not already
261
+ * exist, so a fresh project that just ran `ghs-init` does not need a
262
+ * separate `mkdir`. Matches the source skill's Phase 0 step 3 behaviour.
263
+ * - Validates `status` against the schema BEFORE writing — a caller that
264
+ * built an invalid object (e.g. forgot `codegraph_available`) fails loudly
265
+ * here rather than persisting corrupt state.
266
+ *
267
+ * The on-disk format is `JSON.stringify(status, null, 2)` (pretty-printed,
268
+ * matching the source skill's `json.dump(indent=2)` convention so diffs stay
269
+ * reviewable). No trailing newline is added — the source does not emit one
270
+ * either (`json.dump` has no trailing newline).
271
+ *
272
+ * @param projectDir - absolute host project root.
273
+ * @param status - the status object to persist (validated + written).
274
+ * @returns the absolute path the status was written to.
275
+ */
276
+ export async function writePlanStatus(
277
+ projectDir: string,
278
+ status: PlanStatus,
279
+ ): Promise<string> {
280
+ // Validate first so we never write a structurally-invalid status to disk.
281
+ // Zod `.parse` throws ZodError on failure; the plan tools surface that.
282
+ const validated = PlanStatusSchema.parse(status);
283
+
284
+ const dir = plansDir(projectDir);
285
+ // mkdir -p the plans dir. `recursive: true` makes this a no-op when the dir
286
+ // already exists, so repeated writes are cheap.
287
+ await mkdir(dir, { recursive: true });
288
+
289
+ const path = statusFilePath(projectDir, validated.plan_id);
290
+ await Bun.write(path, JSON.stringify(validated, null, 2));
291
+ return path;
292
+ }
293
+
294
+ /**
295
+ * Whether a status file exists for the given plan. Convenience wrapper around
296
+ * `Bun.file(...).exists()` so callers don't have to import BunFile plumbing
297
+ * just to do an existence check (mirrors the `fileExists` helper in
298
+ * `src/lib/config.ts`).
299
+ */
300
+ export async function planStatusExists(
301
+ projectDir: string,
302
+ planId: string,
303
+ ): Promise<boolean> {
304
+ return Bun.file(statusFilePath(projectDir, planId)).exists();
305
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,76 @@
1
+ // Plugin entry point — the OpenCode plugin function.
2
+ //
3
+ // This module wires together everything implemented in s1-feat-005 through
4
+ // s4-feat-005:
5
+ // - Registers all 10 tools (ghs-init / ghs-config / ghs-plan-start /
6
+ // ghs-plan-review / ghs-plan-finalize / ghs-sprint / ghs-code /
7
+ // ghs-status / ghs-archive / ghs-force-archive) under their hyphenated
8
+ // keys — the complete plan §3.4 D2 tool surface (Phase 0 spike 001
9
+ // confirmed hyphenated keys load + round-trip correctly).
10
+ // - Pushes a single-line workflow hint into the AI's system prompt via the
11
+ // `experimental.chat.system.transform` hook (Phase 0 spike 001 confirmed
12
+ // strings pushed here land in the system prompt verbatim).
13
+ //
14
+ // The hint lists all 10 implemented tools and the full init → plan → sprint
15
+ // → code → status → archive workflow. Per spike 003 divergence, the hint text
16
+ // uses descriptive phrasing ("codegraph MCP tools") rather than hardcoding
17
+ // double-prefixed tool names (codegraph_codegraph_*) when those tools are
18
+ // introduced — those names depend on the MCP server name and would be brittle.
19
+
20
+ import type { Plugin } from "@opencode-ai/plugin";
21
+
22
+ import { initTool } from "./tools/init.ts";
23
+ import { statusTool } from "./tools/status.ts";
24
+ import { archiveTool } from "./tools/archive.ts";
25
+ import { forceArchiveTool } from "./tools/force-archive.ts";
26
+ import { configTool } from "./tools/config.ts";
27
+ import { sprintTool } from "./tools/sprint.ts";
28
+ import { planStartTool } from "./tools/plan-start.ts";
29
+ import { planReviewTool } from "./tools/plan-review.ts";
30
+ import { planFinalizeTool } from "./tools/plan-finalize.ts";
31
+ import { codeTool } from "./tools/code.ts";
32
+
33
+ /**
34
+ * Single-line hint pushed into the AI's system prompt on every chat. Lists
35
+ * all 10 implemented tool names, the workflow order, and the model-config
36
+ * entry point. The plan-dispatcher subagents (ghs-context-haiku /
37
+ * ghs-plan-designer / ghs-plan-reviewer) are invoked by the plan tools via
38
+ * the Task tool; ghs-code's coding subagent is dispatched the same way
39
+ * after ghs-code returns its feature-impl prompt.
40
+ *
41
+ * Kept to one line so it shows up as a single contiguous block in the
42
+ * rendered system prompt (easier for the AI to spot). The user-facing note
43
+ * about `.ghs/ghs.json` is critical for R3: model IDs are user-configurable
44
+ * but only take effect after a `ghs-config` call + OpenCode restart.
45
+ */
46
+ const SYSTEM_HINT_TEXT =
47
+ "Golden Hoop Spell (ghs) plugin — orchestrates a structured init → plan → sprint → code → status → archive workflow. " +
48
+ "Tools implemented: ghs-init, ghs-config, ghs-plan-start, ghs-plan-review, ghs-plan-finalize, ghs-sprint, ghs-code, ghs-status, ghs-archive, ghs-force-archive. " +
49
+ "Workflow order: ghs-init → ghs-config → ghs-plan-start → ghs-plan-review → ghs-plan-finalize → ghs-sprint → ghs-code → ghs-status → ghs-archive. " +
50
+ "Model IDs for the 3 plan-dispatcher subagents are user-configurable via `.ghs/ghs.json`; after editing run `ghs-config` then restart OpenCode.";
51
+
52
+ /**
53
+ * The ghs OpenCode plugin. Default-exported from `src/index.ts`.
54
+ *
55
+ * Signature conforms to the canonical `Plugin` type from
56
+ * `@opencode-ai/plugin`: `async (input) => Hooks`. We don't currently use
57
+ * the input (session/project context); tools resolve the project dir from
58
+ * their own `ToolContext` (see `src/lib/project.ts`).
59
+ */
60
+ export const ghsPlugin: Plugin = async () => ({
61
+ tool: {
62
+ "ghs-init": initTool,
63
+ "ghs-config": configTool,
64
+ "ghs-plan-start": planStartTool,
65
+ "ghs-plan-review": planReviewTool,
66
+ "ghs-plan-finalize": planFinalizeTool,
67
+ "ghs-sprint": sprintTool,
68
+ "ghs-code": codeTool,
69
+ "ghs-status": statusTool,
70
+ "ghs-archive": archiveTool,
71
+ "ghs-force-archive": forceArchiveTool,
72
+ },
73
+ "experimental.chat.system.transform": async (_input, output) => {
74
+ output.system.push(SYSTEM_HINT_TEXT);
75
+ },
76
+ });