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,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
|
+
}
|
package/src/lib/state.ts
ADDED
|
@@ -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
|
+
});
|