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,130 @@
1
+ // Port of golden-hoop-spell/plugin/shared/scripts/resolve_project_dir.py.
2
+ //
3
+ // Behavior source-of-truth:
4
+ // /Users/tom/github/golden-hoop-spell/plugin/shared/scripts/resolve_project_dir.py
5
+ //
6
+ // Faithful port notes:
7
+ // - Walks up from the start directory looking for a `.ghs/` directory that
8
+ // contains at least one marker file (`features.json` or `progress.md`).
9
+ // - Mirrors Python's loop-termination: the loop `while current != current.parent`
10
+ // stops one level before the filesystem root, then the root is checked
11
+ // separately. We do the same with `current === current.parent` as the
12
+ // termination sentinel.
13
+
14
+ import { existsSync, realpathSync, statSync } from "node:fs";
15
+ import { join, resolve, sep } from "node:path";
16
+
17
+ const GHS_DIR = ".ghs";
18
+ const MARKER_FILES = ["features.json", "progress.md"];
19
+
20
+ /**
21
+ * Resolve a path the way Python's `pathlib.Path.resolve(strict=False)` does:
22
+ * apply `realpathSync` to the longest existing prefix, then append the
23
+ * remaining components verbatim. This matters on macOS where `/tmp` is a
24
+ * symlink to `/private/tmp` — Node's `path.resolve()` does NOT follow
25
+ * symlinks, but Python's `Path.resolve()` does. To stay byte-faithful with
26
+ * the source script we mirror the Python behaviour.
27
+ *
28
+ * Falls back to `path.resolve(...)` when no prefix of the path exists.
29
+ */
30
+ function pyResolve(p: string): string {
31
+ const absolute = resolve(p);
32
+ // Walk from the full path upward, find the longest existing prefix.
33
+ let existing = absolute;
34
+ while (existing !== parentOf(existing) && !existsSync(existing)) {
35
+ existing = parentOf(existing);
36
+ }
37
+ if (!existsSync(existing)) {
38
+ // Nothing on disk matches — fall back to lexical resolution.
39
+ return absolute;
40
+ }
41
+ const real = realpathSync(existing);
42
+ if (existing === absolute) {
43
+ return real;
44
+ }
45
+ const tail = absolute.slice(existing.length);
46
+ return real + tail;
47
+ }
48
+
49
+ /**
50
+ * Walk up from `startDir` to find the directory whose `.ghs/` subdirectory
51
+ * contains at least one marker file.
52
+ *
53
+ * Returns `null` if no marker files are found in any ancestor — mirrors
54
+ * Python's `find_project_dir`.
55
+ */
56
+ export function findProjectDir(startDir: string): string | null {
57
+ let current = pyResolve(startDir);
58
+
59
+ while (current !== parentOf(current)) {
60
+ if (hasMarkerFiles(current)) {
61
+ return current;
62
+ }
63
+ current = parentOf(current);
64
+ }
65
+
66
+ // Check the root directory too (mirrors Python's post-loop check).
67
+ if (hasMarkerFiles(current)) {
68
+ return current;
69
+ }
70
+
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * Resolve the ghs project directory or throw a descriptive error.
76
+ *
77
+ * Mirrors Python's `main()` minus the stderr print: instead of printing +
78
+ * exit(1), we throw `ProjectDirNotFoundError` so the tool layer can format
79
+ * the user-facing message.
80
+ */
81
+ export function resolveProjectDir(startDir?: string): string {
82
+ const start = startDir ? pyResolve(startDir) : pyResolve(process.cwd());
83
+ const projectDir = findProjectDir(start);
84
+
85
+ if (projectDir === null) {
86
+ throw new ProjectDirNotFoundError(start);
87
+ }
88
+
89
+ return projectDir;
90
+ }
91
+
92
+ /** Return the parent directory of `dir`, or `dir` itself when already at root. */
93
+ function parentOf(dir: string): string {
94
+ const parent = resolve(dir, "..");
95
+ // On the filesystem root, resolve(dir, "..") === dir.
96
+ return parent === dir ? dir : parent;
97
+ }
98
+
99
+ /** True iff `<dir>/.ghs/` is a directory that contains a marker file. */
100
+ function hasMarkerFiles(dir: string): boolean {
101
+ const ghs = join(dir, GHS_DIR);
102
+ if (!existsSync(ghs) || !statSync(ghs).isDirectory()) {
103
+ return false;
104
+ }
105
+ for (const marker of MARKER_FILES) {
106
+ if (existsSync(join(ghs, marker))) {
107
+ return true;
108
+ }
109
+ }
110
+ return false;
111
+ }
112
+
113
+ /** Error thrown when no `.ghs/` directory is found in any ancestor. */
114
+ export class ProjectDirNotFoundError extends Error {
115
+ readonly startDir: string;
116
+
117
+ constructor(startDir: string) {
118
+ super(
119
+ "Error: No project directory found. No .ghs/features.json or .ghs/progress.md in any parent directory. " +
120
+ "Run /ghs:init to create a new project.",
121
+ );
122
+ this.name = "ProjectDirNotFoundError";
123
+ this.startDir = startDir;
124
+ }
125
+ }
126
+
127
+ // Re-exported to keep the `sep` import meaningful for downstream tooling
128
+ // that may want to know the platform separator (currently unused at runtime
129
+ // but referenced by tests).
130
+ export const pathSeparator: typeof sep = sep;
@@ -0,0 +1,292 @@
1
+ // Port of golden-hoop-spell/plugin/shared/scripts/status.py.
2
+ //
3
+ // Behavior source-of-truth:
4
+ // /Users/tom/github/golden-hoop-spell/plugin/shared/scripts/status.py
5
+ //
6
+ // Faithful port notes:
7
+ // - The Python script prints the formatted status to stdout. Here we
8
+ // return the same text via `formatStatus()` so the tool layer can render
9
+ // or post-process it. The text is byte-for-byte identical to what Python
10
+ // would have printed (verified by walking through each `print()` call).
11
+ // - The H2-section splitter mirrors Python's `re.split(r"^## ", content,
12
+ // flags=re.MULTILINE)`: in JS we use `/^## /m` and keep only sections
13
+ // whose first line contains a `\d{4}-\d{2}-\d{2}` date.
14
+
15
+ import { existsSync, realpathSync } from "node:fs";
16
+ import { readFile } from "node:fs/promises";
17
+ import { join, resolve } from "node:path";
18
+
19
+ type JsonObject = Record<string, unknown>;
20
+ type Feature = JsonObject;
21
+ type Sprint = JsonObject;
22
+ type FeaturesData = JsonObject;
23
+
24
+ /** Read and parse features.json. Returns `null` when the file does not exist. */
25
+ export async function readFeaturesJson(
26
+ filepath: string,
27
+ ): Promise<FeaturesData | null> {
28
+ if (!existsSync(filepath)) {
29
+ return null;
30
+ }
31
+ const text = await readFile(filepath, "utf8");
32
+ return JSON.parse(text) as FeaturesData;
33
+ }
34
+
35
+ /** Read the last `lastN` sessions from progress.md. Returns `null` if the file is missing. */
36
+ export async function readProgressMd(
37
+ filepath: string,
38
+ lastN = 5,
39
+ ): Promise<string[] | null> {
40
+ if (!existsSync(filepath)) {
41
+ return null;
42
+ }
43
+ const content = await readFile(filepath, "utf8");
44
+ return extractSessions(content, lastN);
45
+ }
46
+
47
+ /**
48
+ * Split content on `^## ` headings (multiline) and keep only sections whose
49
+ * first line contains a `\d{4}-\d{2}-\d{2}` date.
50
+ *
51
+ * Mirrors Python:
52
+ * sections = re.split(r"^## ", content, flags=re.MULTILINE)
53
+ * sessions = [s for s in sections if re.search(r"\d{4}-\d{2}-\d{2}", s.split("\n", 1)[0])]
54
+ * return sessions[:last_n]
55
+ */
56
+ export function extractSessions(content: string, lastN = 5): string[] {
57
+ const sections = content.split(/^## /m);
58
+ const sessions: string[] = [];
59
+ for (const section of sections) {
60
+ const firstLine = section.split("\n", 1)[0];
61
+ if (/\d{4}-\d{2}-\d{2}/.test(firstLine)) {
62
+ sessions.push(section);
63
+ }
64
+ }
65
+ return sessions.slice(0, lastN);
66
+ }
67
+
68
+ /** Compute per-status feature counts. Mirrors Python `format_feature_status`. */
69
+ export function formatFeatureStatus(
70
+ features: Feature[],
71
+ ): Record<"pending" | "in_progress" | "completed" | "blocked", number> {
72
+ const statusCounts = {
73
+ pending: 0,
74
+ in_progress: 0,
75
+ completed: 0,
76
+ blocked: 0,
77
+ } as Record<"pending" | "in_progress" | "completed" | "blocked", number>;
78
+
79
+ for (const feature of features) {
80
+ const status = ((feature.status as string | undefined) ?? "pending") as
81
+ | "pending"
82
+ | "in_progress"
83
+ | "completed"
84
+ | "blocked";
85
+ if (status in statusCounts) {
86
+ statusCounts[status] += 1;
87
+ } else {
88
+ // Mirrors Python's `.get(status, 0) + 1` — we don't track unknown
89
+ // statuses in the counts dict but we do keep the call side-effect-free.
90
+ // Python silently drops unknown statuses; we mirror that.
91
+ }
92
+ }
93
+
94
+ return statusCounts;
95
+ }
96
+
97
+ /** Options accepted by formatStatus / status. */
98
+ export interface StatusOptions {
99
+ /** Project directory (the directory containing `.ghs/`). Defaults to cwd. */
100
+ projectDir?: string;
101
+ }
102
+
103
+ /** Result returned by `status()`. */
104
+ export interface StatusResult {
105
+ /** Byte-identical to what status.py would have printed to stdout. */
106
+ text: string;
107
+ /** 0 on success, 1 when features.json is missing (matches Python exit code). */
108
+ exitCode: 0 | 1;
109
+ }
110
+
111
+ /**
112
+ * Format the project status as a text block.
113
+ *
114
+ * The returned string is byte-identical to Python `main()`'s stdout, including
115
+ * the trailing blank lines. Mirrors the early-return when features.json is
116
+ * missing (text = `❌ features.json not found. Run init-project.py first.\n`).
117
+ */
118
+ export async function formatStatus(options: StatusOptions = {}): Promise<string> {
119
+ const projectDir = pyResolve(options.projectDir ?? process.cwd());
120
+ const featuresPath = join(projectDir, ".ghs", "features.json");
121
+ const progressPath = join(projectDir, ".ghs", "progress.md");
122
+
123
+ const lines: string[] = [];
124
+
125
+ if (!existsSync(featuresPath)) {
126
+ lines.push("❌ features.json not found. Run init-project.py first.");
127
+ return lines.join("\n") + "\n";
128
+ }
129
+
130
+ lines.push("=== Project Status ===");
131
+ lines.push("");
132
+
133
+ const featuresData = (await readFeaturesJson(featuresPath)) as FeaturesData | null;
134
+
135
+ if (featuresData) {
136
+ const project = (featuresData.project ?? {}) as JsonObject;
137
+ lines.push(`📦 Project: ${(project.name as string | undefined) ?? "Unknown"}`);
138
+ lines.push(
139
+ `📝 Description: ${(project.description as string | undefined) ?? "No description"}`,
140
+ );
141
+ lines.push(`📅 Created: ${(project.created_at as string | undefined) ?? "Unknown"}`);
142
+ lines.push("");
143
+
144
+ const sprints = (featuresData.sprints ?? []) as Sprint[];
145
+
146
+ if (sprints.length === 0) {
147
+ lines.push("⚠️ No sprints defined yet. Run Sprint Agent to plan features.");
148
+ // Python `return 0` here — no further output (no progress sessions).
149
+ return lines.join("\n") + "\n";
150
+ }
151
+
152
+ for (const sprint of sprints) {
153
+ const sprintId = (sprint.id as string | undefined) ?? "unknown";
154
+ const sprintName = (sprint.name as string | undefined) ?? "Unnamed Sprint";
155
+ const sprintStatus = (sprint.status as string | undefined) ?? "planning";
156
+ const features = (sprint.features ?? []) as Feature[];
157
+
158
+ const statusEmoji = sprintStatusEmoji(sprintStatus);
159
+ lines.push(`${statusEmoji} Sprint: ${sprintName} (${sprintId})`);
160
+ lines.push(` Status: ${sprintStatus}`);
161
+ lines.push(` Goal: ${(sprint.goal as string | undefined) ?? "No goal defined"}`);
162
+ lines.push("");
163
+
164
+ const statusCounts = formatFeatureStatus(features);
165
+
166
+ lines.push(" Features:");
167
+ lines.push(` ✅ Completed: ${statusCounts.completed}`);
168
+ lines.push(` 🚧 In Progress: ${statusCounts.in_progress}`);
169
+ lines.push(` ⏳ Pending: ${statusCounts.pending}`);
170
+ lines.push(` 🚫 Blocked: ${statusCounts.blocked}`);
171
+ lines.push(` ━━━━━━━━━━━━━━━━━━━━`);
172
+ lines.push(` 📊 Total: ${features.length}`);
173
+ lines.push("");
174
+
175
+ if (statusCounts.in_progress > 0) {
176
+ const inProgress = features.filter((f) => f.status === "in_progress");
177
+ for (const feat of inProgress) {
178
+ lines.push(
179
+ ` 🔨 Working on: ${(feat.title as string | undefined) ?? "Unknown"} (${(feat.id as string | undefined) ?? ""})`,
180
+ );
181
+ }
182
+ }
183
+
184
+ if (statusCounts.pending > 0) {
185
+ const pending = features.filter((f) => f.status === "pending");
186
+ const completedIds = new Set(
187
+ features.filter((f) => f.status === "completed").map((f) => f.id as string),
188
+ );
189
+ const ready = pending.filter((f) => {
190
+ const deps = (f.dependencies ?? []) as string[];
191
+ return deps.every((dep) => completedIds.has(dep));
192
+ });
193
+ if (ready.length > 0) {
194
+ const nextFeature = ready[0];
195
+ lines.push(
196
+ ` ▶️ Next up: ${(nextFeature.title as string | undefined) ?? "Unknown"} (${(nextFeature.id as string | undefined) ?? ""})`,
197
+ );
198
+ } else {
199
+ lines.push(
200
+ " ⏸️ No ready features — all pending features have unmet dependencies",
201
+ );
202
+ }
203
+ }
204
+
205
+ lines.push("");
206
+ }
207
+ }
208
+
209
+ const sessions = await readProgressMd(progressPath);
210
+ if (sessions && sessions.length > 0) {
211
+ lines.push("📜 Recent Sessions:");
212
+ for (const session of sessions.slice(0, 3)) {
213
+ const sessionLines = session.trim().split("\n").slice(0, 3);
214
+ for (const line of sessionLines) {
215
+ if (line.trim()) {
216
+ lines.push(` ${line.trim()}`);
217
+ }
218
+ }
219
+ }
220
+ lines.push("");
221
+ }
222
+
223
+ return lines.join("\n") + "\n";
224
+ }
225
+
226
+ /**
227
+ * Resolve the sprint status emoji. Mirrors Python's `status_emoji.get(..., "❓")`.
228
+ */
229
+ function sprintStatusEmoji(status: string): string {
230
+ switch (status) {
231
+ case "planning":
232
+ return "📋";
233
+ case "in_progress":
234
+ return "🚀";
235
+ case "completed":
236
+ return "✅";
237
+ case "on_hold":
238
+ return "⏸️";
239
+ default:
240
+ return "❓";
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Resolve a path the way Python's `pathlib.Path.resolve(strict=False)` does:
246
+ * apply `realpathSync` to the longest existing prefix, then append the
247
+ * remaining components verbatim. Necessary on macOS where `/tmp` is a
248
+ * symlink to `/private/tmp`. See resolve-project-dir.ts for the same helper.
249
+ */
250
+ function pyResolve(p: string): string {
251
+ const absolute = resolve(p);
252
+ let existing = absolute;
253
+ while (existing !== parentOf(existing) && !existsSync(existing)) {
254
+ existing = parentOf(existing);
255
+ }
256
+ if (!existsSync(existing)) {
257
+ return absolute;
258
+ }
259
+ const real = realpathSync(existing);
260
+ if (existing === absolute) {
261
+ return real;
262
+ }
263
+ return real + absolute.slice(existing.length);
264
+ }
265
+
266
+ /** Parent directory of `dir`, or `dir` itself at the filesystem root. */
267
+ function parentOf(dir: string): string {
268
+ const parent = resolve(dir, "..");
269
+ return parent === dir ? dir : parent;
270
+ }
271
+
272
+ /**
273
+ * Compute the project status and return `{ text, exitCode }`.
274
+ *
275
+ * `exitCode` mirrors Python's `main()` return value: 0 on success, 1 when
276
+ * features.json is missing. The tool layer decides whether to surface the
277
+ * non-zero code as a tool error.
278
+ */
279
+ export async function status(options: StatusOptions = {}): Promise<StatusResult> {
280
+ const projectDir = pyResolve(options.projectDir ?? process.cwd());
281
+ const featuresPath = join(projectDir, ".ghs", "features.json");
282
+
283
+ if (!existsSync(featuresPath)) {
284
+ return {
285
+ text: "❌ features.json not found. Run init-project.py first.\n",
286
+ exitCode: 1,
287
+ };
288
+ }
289
+
290
+ const text = await formatStatus(options);
291
+ return { text, exitCode: 0 };
292
+ }
@@ -0,0 +1,169 @@
1
+ // Update a feature's status inside a features.json object (in-memory; no I/O).
2
+ //
3
+ // This is one of the three "writer" modules introduced in s2-feat-001. Like
4
+ // append-sprint.ts, it has no source-plugin Python equivalent — the source
5
+ // `ghs-sprint` / `ghs-code` skills had the AI edit features.json directly.
6
+ // This module refactors that into a pure, Zod-validated function.
7
+ //
8
+ // Design principles match append-sprint.ts: pure function, no I/O, no stdout,
9
+ // no process.exit, immutable return, Zod-validated spec.
10
+
11
+ import { z } from "zod";
12
+
13
+ // -----------------------------------------------------------------------------
14
+ // Types
15
+ // -----------------------------------------------------------------------------
16
+
17
+ type JsonObject = Record<string, unknown>;
18
+ export type FeaturesData = JsonObject;
19
+ export type Feature = JsonObject;
20
+ export type Sprint = JsonObject;
21
+
22
+ // -----------------------------------------------------------------------------
23
+ // Schema
24
+ // -----------------------------------------------------------------------------
25
+
26
+ /**
27
+ * Allowed feature statuses. Kept in sync with validate-structure.ts
28
+ * `VALID_FEATURE_STATUSES`. Re-declared locally to avoid a cross-module
29
+ * dependency (see the rationale in append-sprint.ts).
30
+ */
31
+ export const VALID_FEATURE_STATUSES = [
32
+ "pending",
33
+ "in_progress",
34
+ "completed",
35
+ "blocked",
36
+ ] as const;
37
+
38
+ /**
39
+ * Zod schema for the "update feature status" spec.
40
+ *
41
+ * - `feature_id`: matches the feature ID format enforced by
42
+ * validate-structure.ts (`^s\d{1,4}-feat-\d{3}$`).
43
+ * - `status`: one of {@link VALID_FEATURE_STATUSES}.
44
+ * - `blocked_reason`: required when `status === "blocked"` (matches the
45
+ * optional-field convention documented in features.json's `_schema_docs`).
46
+ */
47
+ const FEATURE_ID_PATTERN = /^s\d{1,4}-feat-\d{3}$/;
48
+
49
+ export const UpdateFeatureStatusSpecSchema = z
50
+ .object({
51
+ feature_id: z
52
+ .string()
53
+ .regex(
54
+ FEATURE_ID_PATTERN,
55
+ "feature_id must match ^s\\d{1,4}-feat-\\d{3}$ (e.g. s1-feat-001)",
56
+ ),
57
+ status: z.enum(VALID_FEATURE_STATUSES),
58
+ blocked_reason: z.string().min(1).optional(),
59
+ })
60
+ .refine(
61
+ (spec) => spec.status !== "blocked" || (spec.blocked_reason ?? "") !== "",
62
+ {
63
+ message:
64
+ "blocked_reason is required when status is 'blocked'",
65
+ path: ["blocked_reason"],
66
+ },
67
+ );
68
+
69
+ export type UpdateFeatureStatusSpec = z.infer<
70
+ typeof UpdateFeatureStatusSpecSchema
71
+ >;
72
+
73
+ // -----------------------------------------------------------------------------
74
+ // Writer
75
+ // -----------------------------------------------------------------------------
76
+
77
+ /** Result of locating a feature: the sprint it lives in and the feature itself. */
78
+ interface LocatedFeature {
79
+ sprintIndex: number;
80
+ featureIndex: number;
81
+ }
82
+
83
+ /**
84
+ * Find a feature by ID across all sprints. Returns the sprint/feature indices
85
+ * or `null` when not found.
86
+ */
87
+ function locateFeature(
88
+ featuresData: FeaturesData,
89
+ featureId: string,
90
+ ): LocatedFeature | null {
91
+ const sprints = Array.isArray(featuresData.sprints)
92
+ ? (featuresData.sprints as Sprint[])
93
+ : [];
94
+ for (let si = 0; si < sprints.length; si++) {
95
+ const features = Array.isArray(sprints[si].features)
96
+ ? (sprints[si].features as Feature[])
97
+ : [];
98
+ for (let fi = 0; fi < features.length; fi++) {
99
+ if (features[fi].id === featureId) {
100
+ return { sprintIndex: si, featureIndex: fi };
101
+ }
102
+ }
103
+ }
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * Update the status of a single feature (located by ID, searched across ALL
109
+ * sprints) and return a NEW featuresData object. The input is not modified.
110
+ *
111
+ * Behavior:
112
+ * 1. Validate `spec` against {@link UpdateFeatureStatusSpecSchema}. This
113
+ * enforces the feature_id format, the status enum, and the rule that
114
+ * `status === "blocked"` requires a non-empty `blocked_reason`. A ZodError
115
+ * is thrown on invalid input.
116
+ * 2. Locate the feature by `spec.feature_id`. Throw a descriptive Error if no
117
+ * sprint contains it.
118
+ * 3. Return a shallow-cloned featuresData where the located sprint and its
119
+ * `features` array are shallow-cloned, and the target feature is replaced
120
+ * with a cloned object carrying the new `status` (and `blocked_reason`
121
+ * when applicable). Other features/sprints are shared by reference.
122
+ *
123
+ * When transitioning OUT of "blocked", any pre-existing `blocked_reason` field
124
+ * is removed from the updated feature (so the file does not carry a stale
125
+ * reason for a non-blocked feature).
126
+ */
127
+ export function updateFeatureStatus(
128
+ featuresData: FeaturesData,
129
+ spec: UpdateFeatureStatusSpec,
130
+ ): FeaturesData {
131
+ const validated = UpdateFeatureStatusSpecSchema.parse(spec);
132
+
133
+ const located = locateFeature(featuresData, validated.feature_id);
134
+ if (located === null) {
135
+ throw new Error(
136
+ `Feature '${validated.feature_id}' not found in any sprint`,
137
+ );
138
+ }
139
+
140
+ const sprints = Array.isArray(featuresData.sprints)
141
+ ? (featuresData.sprints as Sprint[])
142
+ : [];
143
+
144
+ const { sprintIndex, featureIndex } = located;
145
+ const targetSprint = sprints[sprintIndex];
146
+ const features = (targetSprint.features as Feature[]) ?? [];
147
+ const targetFeature = features[featureIndex];
148
+
149
+ // Build the updated feature. Start from a shallow clone of the original so
150
+ // unrelated fields are preserved.
151
+ const updatedFeature: Feature = { ...targetFeature };
152
+ updatedFeature.status = validated.status;
153
+ if (validated.status === "blocked") {
154
+ updatedFeature.blocked_reason = validated.blocked_reason;
155
+ } else if ("blocked_reason" in updatedFeature) {
156
+ delete updatedFeature.blocked_reason;
157
+ }
158
+
159
+ // Rebuild the path from the root to the updated feature with shallow clones
160
+ // at each container level. Everything else is shared by reference.
161
+ const updatedFeatures = features.slice();
162
+ updatedFeatures[featureIndex] = updatedFeature;
163
+
164
+ const updatedSprint: Sprint = { ...targetSprint, features: updatedFeatures };
165
+ const updatedSprints = sprints.slice();
166
+ updatedSprints[sprintIndex] = updatedSprint;
167
+
168
+ return { ...featuresData, sprints: updatedSprints };
169
+ }