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,56 @@
1
+ // Transcription nonce gate for `ghs-force-archive`.
2
+ //
3
+ // `ghs-force-archive` archives ALL sprints regardless of status — destructive.
4
+ // The source Claude Code plugin (see
5
+ // `plugin/skills/ghs-force-archive/SKILL.md`) gates the archive behind an
6
+ // explicit user confirmation: the agent shows the nonce and asks the user to
7
+ // transcribe it back before proceeding. We replicate that gate here.
8
+ //
9
+ // Comparison semantics (must match the source behaviour):
10
+ // - Case-insensitive: `AbC123` matches `abc123`.
11
+ // - Whitespace-trimmed: leading/trailing whitespace on either side is
12
+ // stripped before comparison. Internal whitespace is preserved.
13
+ //
14
+ // The nonce is a random alphanumeric string — short enough to transcribe,
15
+ // long enough that a careless "yes" / "confirmed" won't accidentally match.
16
+
17
+ const NONCE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
18
+ const NONCE_LENGTH = 8;
19
+
20
+ /**
21
+ * Generate a random alphanumeric nonce suitable for transcription comparison.
22
+ *
23
+ * Uses `crypto.getRandomValues` for cryptographic randomness. The result is
24
+ * 8 characters drawn from `[A-Za-z0-9]`.
25
+ */
26
+ export function generateNonce(): string {
27
+ const bytes = new Uint8Array(NONCE_LENGTH);
28
+ crypto.getRandomValues(bytes);
29
+ let out = "";
30
+ for (let i = 0; i < NONCE_LENGTH; i++) {
31
+ out += NONCE_ALPHABET[bytes[i] % NONCE_ALPHABET.length];
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /**
37
+ * Verify that a user-supplied transcription matches the issued nonce.
38
+ *
39
+ * Comparison is case-insensitive and trims leading/trailing whitespace on
40
+ * both inputs before comparing. Returns `true` only if the normalised forms
41
+ * are byte-identical.
42
+ *
43
+ * @param nonce - the nonce originally issued by `generateNonce()`.
44
+ * @param transcription - the string the user typed back.
45
+ */
46
+ export function verifyTranscribeNonce(nonce: string, transcription: string): boolean {
47
+ if (typeof nonce !== "string" || typeof transcription !== "string") {
48
+ return false;
49
+ }
50
+ const a = nonce.trim().toLowerCase();
51
+ const b = transcription.trim().toLowerCase();
52
+ if (a.length === 0 || b.length === 0) {
53
+ return false;
54
+ }
55
+ return a === b;
56
+ }
@@ -0,0 +1,175 @@
1
+ // Thin convenience wrappers around `src/lib/scripts/parse-delimited-output.ts`.
2
+ //
3
+ // The delimited-output parser is the core extraction primitive the plan
4
+ // dispatcher uses to pull structured content out of subagent responses. The
5
+ // underlying `parseDelimitedOutput()` is fully generic (arbitrary `kind`,
6
+ // custom tokens, configurable min length). The three plan tools
7
+ // (`ghs-plan-review` in its three modes) all want the *same* canned
8
+ // configuration — one per subagent output family — so this module centralises
9
+ // those presets. That keeps the tool layer readable and makes the delimiter /
10
+ // signal / min-length contract a single source of truth.
11
+ //
12
+ // This file deliberately adds NO new parsing logic — every behaviour path
13
+ // delegates to the ported parser, preserving the equivalence guarantee
14
+ // established by `test/equivalence/parse-delimited-output.test.ts`. Style
15
+ // follows s2-feat-001: pure re-exports + thin wrappers, no I/O, no
16
+ // `process.exit`, no `console.log`.
17
+
18
+ import {
19
+ parseDelimitedOutput,
20
+ type ParseDelimitedOutputArgs,
21
+ type ParseResult,
22
+ type Verdict,
23
+ } from "./scripts/parse-delimited-output.ts";
24
+
25
+ // -----------------------------------------------------------------------------
26
+ // Re-exports — give the plan tools a single import surface for the parser.
27
+ // -----------------------------------------------------------------------------
28
+
29
+ export {
30
+ parseDelimitedOutput,
31
+ serializeResult,
32
+ type ParseDelimitedOutputArgs,
33
+ type ParseResult,
34
+ type ParseStatus,
35
+ type ParseStrategy,
36
+ type Verdict,
37
+ } from "./scripts/parse-delimited-output.ts";
38
+
39
+ // -----------------------------------------------------------------------------
40
+ // Completion-signal constants per subagent family.
41
+ // -----------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Completion-signal line the `plan-designer` subagent prints at the end of its
45
+ * response. The parser strips this line (and anything after) from the
46
+ * extracted content and surfaces it in `ParseResult.completion_signal`.
47
+ *
48
+ * Mirrors the source skill's `PLAN DESIGN COMPLETE` signal (the parser's
49
+ * `stripCompletionSignal` helper is line-anchored and word-boundary aware, so
50
+ * trailing variables like `| Verdict: PASS` ride along on the same line).
51
+ */
52
+ export const PLAN_COMPLETION_SIGNAL = "PLAN DESIGN COMPLETE";
53
+
54
+ /**
55
+ * Completion-signal line the `plan-reviewer` subagent prints. Carries the
56
+ * `Verdict: PASS|FAIL` marker the dispatcher keys off.
57
+ */
58
+ export const REVIEW_COMPLETION_SIGNAL = "PLAN REVIEW COMPLETE";
59
+
60
+ /**
61
+ * Completion-signal line the `ghs-context-haiku` subagent prints when it
62
+ * finishes emitting the architecture snapshot.
63
+ */
64
+ export const CONTEXT_SNAPSHOT_COMPLETION_SIGNAL = "CONTEXT SNAPSHOT COMPLETE";
65
+
66
+ // -----------------------------------------------------------------------------
67
+ // Preset wrappers — one per subagent output family.
68
+ // -----------------------------------------------------------------------------
69
+
70
+ /**
71
+ * Default minimum stripped-content length. Subagent outputs shorter than this
72
+ * after extraction are classified as `empty` (the parser found *something* but
73
+ * it was too short to be a real artefact) rather than `ok`. Mirrors the
74
+ * parser's own `DEFAULT_MIN_LENGTH`; re-exposed here so tool-layer callers can
75
+ * reference a named constant instead of magic-numbering `200`.
76
+ */
77
+ export const DEFAULT_MIN_LENGTH = 200;
78
+
79
+ /**
80
+ * Parse a `plan-designer` subagent response.
81
+ *
82
+ * Pre-configures the `kind: "plan"` delimiter family (`<<<PLAN_START>>>` /
83
+ * `<<<PLAN_END>>>`), the `PLAN DESIGN COMPLETE` completion signal, and the
84
+ * default min length. The caller just hands over the raw subagent text.
85
+ *
86
+ * @param text - raw response from the `ghs-plan-designer` subagent.
87
+ * @param overrides - optional per-call overrides (e.g. a larger `minLength`
88
+ * for a plan expected to be long). Merged into the preset.
89
+ */
90
+ export function parsePlan(
91
+ text: string,
92
+ overrides: Partial<ParseDelimitedOutputArgs> = {},
93
+ ): ParseResult {
94
+ return parseDelimitedOutput(text, {
95
+ kind: "plan",
96
+ completionSignal: PLAN_COMPLETION_SIGNAL,
97
+ minLength: DEFAULT_MIN_LENGTH,
98
+ ...overrides,
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Parse a `plan-reviewer` subagent response.
104
+ *
105
+ * Pre-configures the `kind: "review"` delimiter family
106
+ * (`<<<REVIEW_START>>>` / `<<<REVIEW_END>>>`) and the `PLAN REVIEW COMPLETE`
107
+ * completion signal. The parser automatically extracts the `Verdict: PASS|FAIL`
108
+ * marker for review-kind input and surfaces it on `ParseResult.verdict`; use
109
+ * {@link extractVerdict} to pull it out cleanly.
110
+ *
111
+ * @param text - raw response from the `ghs-plan-reviewer` subagent.
112
+ * @param overrides - optional per-call overrides.
113
+ */
114
+ export function parseReview(
115
+ text: string,
116
+ overrides: Partial<ParseDelimitedOutputArgs> = {},
117
+ ): ParseResult {
118
+ return parseDelimitedOutput(text, {
119
+ kind: "review",
120
+ completionSignal: REVIEW_COMPLETION_SIGNAL,
121
+ minLength: DEFAULT_MIN_LENGTH,
122
+ ...overrides,
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Parse a `ghs-context-haiku` subagent response.
128
+ *
129
+ * Pre-configures the `kind: "context_snapshot"` delimiter family
130
+ * (`<<<CONTEXT_SNAPSHOT_START>>>` / `<<<CONTEXT_SNAPSHOT_END>>>`) and the
131
+ * `CONTEXT SNAPSHOT COMPLETE` completion signal.
132
+ *
133
+ * @param text - raw response from the `ghs-context-haiku` subagent.
134
+ * @param overrides - optional per-call overrides.
135
+ */
136
+ export function parseContextSnapshot(
137
+ text: string,
138
+ overrides: Partial<ParseDelimitedOutputArgs> = {},
139
+ ): ParseResult {
140
+ return parseDelimitedOutput(text, {
141
+ kind: "context_snapshot",
142
+ completionSignal: CONTEXT_SNAPSHOT_COMPLETION_SIGNAL,
143
+ minLength: DEFAULT_MIN_LENGTH,
144
+ ...overrides,
145
+ });
146
+ }
147
+
148
+ // -----------------------------------------------------------------------------
149
+ // Verdict helper
150
+ // -----------------------------------------------------------------------------
151
+
152
+ /**
153
+ * Extract a `PASS` / `FAIL` verdict from a review parse result.
154
+ *
155
+ * The parser already populates `ParseResult.verdict` for `kind: "review"`
156
+ * input; this helper just narrows the type and gives callers a single
157
+ * expression (`extractVerdict(result)`) instead of reaching into the result
158
+ * object directly. Returns `null` when no verdict marker was found — the
159
+ * dispatcher treats that as "reviewer did not emit a verdict" and prompts for
160
+ * a retry.
161
+ *
162
+ * Accepts either a full `ParseResult` or a bare `Verdict` so callers that
163
+ * already destructured the field can pass it through unchanged.
164
+ */
165
+ export function extractVerdict(
166
+ input: ParseResult | Verdict,
167
+ ): Verdict {
168
+ if (input === null) {
169
+ return null;
170
+ }
171
+ if (typeof input === "string") {
172
+ return input;
173
+ }
174
+ return input.verdict;
175
+ }
@@ -0,0 +1,26 @@
1
+ // Plugin root resolution.
2
+ //
3
+ // `import.meta.dir` is Bun's canonical primitive for "the directory of the
4
+ // current source file". This file lives at `src/lib/paths.ts`, so two levels
5
+ // up is the plugin package root (the directory containing `src/index.ts` and
6
+ // `shared/`). We use this instead of `process.cwd()` or `__dirname` because:
7
+ //
8
+ // - `process.cwd()` is the *host project's* working directory, not the
9
+ // plugin's install location — using it would resolve assets relative to
10
+ // the wrong tree entirely.
11
+ // - `__dirname` is a CommonJS construct; this package is `"type": "module"`.
12
+ //
13
+ // Spike 001 confirmed `import.meta.dir` works under Bun + opencode runtime.
14
+
15
+ import { resolve } from "node:path";
16
+
17
+ /**
18
+ * Returns the absolute path to the directory containing `src/index.ts` —
19
+ * i.e. the plugin package root. Under that root live `src/`, `shared/`,
20
+ * `package.json`, etc.
21
+ */
22
+ export function pluginRoot(): string {
23
+ // `import.meta.dir` = absolute path to `src/lib/` (this file's directory).
24
+ // Two `..` segments climb to the plugin package root.
25
+ return resolve(import.meta.dir, "..", "..");
26
+ }
@@ -0,0 +1,28 @@
1
+ // Resolve the active project directory from an OpenCode tool context.
2
+ //
3
+ // `ToolContext` exposes two path fields:
4
+ // - `worktree`: the project worktree root (stable across agent working dirs)
5
+ // - `directory`: the per-session working directory (may be a subdir)
6
+ //
7
+ // Per the feature spec we prefer `worktree` when set so writes land in a
8
+ // stable location regardless of which subdirectory the agent is currently
9
+ // operating in. We fall back to `directory` when `worktree` is empty.
10
+ //
11
+ // Note: this is the OpenCode TS port. The source Python script
12
+ // `plugin/shared/scripts/resolve_project_dir.py` walks up from a start dir
13
+ // looking for `.ghs/features.json`. Here we don't walk — we trust the
14
+ // context's fields, which the runtime has already resolved for us. The
15
+ // walk-up behaviour is implemented separately in `scripts/resolve-project-dir.ts`
16
+ // (s1-feat-008) for parity with the Python entry-point script.
17
+
18
+ import type { ToolContext } from "@opencode-ai/plugin/tool";
19
+
20
+ /**
21
+ * Resolve the project directory the plugin should read/write under.
22
+ *
23
+ * @param ctx - the OpenCode tool execution context.
24
+ * @returns `ctx.worktree` when non-empty, otherwise `ctx.directory`.
25
+ */
26
+ export function resolveProjectDir(ctx: ToolContext): string {
27
+ return ctx.worktree || ctx.directory;
28
+ }
@@ -0,0 +1,178 @@
1
+ // Insert a new progress session entry into a progress.md document (in-memory).
2
+ //
3
+ // This is one of the three "writer" modules introduced in s2-feat-001. Like
4
+ // its siblings it has no source-plugin Python equivalent — the source
5
+ // `ghs-sprint` skill had the AI edit progress.md directly. This module
6
+ // refactors that into a pure function that returns the updated markdown.
7
+ //
8
+ // Design principles match append-sprint.ts / update-feature-status.ts: pure
9
+ // function, no I/O, no stdout, no process.exit, immutable return, Zod-validated
10
+ // session object.
11
+
12
+ import { z } from "zod";
13
+
14
+ // -----------------------------------------------------------------------------
15
+ // Schema
16
+ // -----------------------------------------------------------------------------
17
+
18
+ /**
19
+ * Zod schema for a single progress session.
20
+ *
21
+ * The rendered markdown mirrors the `## Session Template` block in
22
+ * `shared/assets/progress.md`. Required fields are the ones that make a session
23
+ * identifiable and useful; the rest are optional so a minimal session still
24
+ * renders cleanly.
25
+ *
26
+ * - `title`: the `## Session N - YYYY-MM-DD` heading line (without the leading
27
+ * `## `). Required.
28
+ * - `agent`: rendered as `**Agent**: <value>`.
29
+ * - `sprint`, `feature`: optional metadata lines.
30
+ * - `work_completed`, `tests_performed`, `issues`, `decisions`, `next_steps`:
31
+ * optional string arrays rendered as `- <item>` bullet lists under their
32
+ * respective `### ` sub-headings.
33
+ */
34
+ const stringList = z.array(z.string()).default([]);
35
+
36
+ export const ProgressSessionSchema = z.object({
37
+ title: z.string().min(1, "title is required"),
38
+ agent: z.string().min(1, "agent is required"),
39
+ sprint: z.string().optional(),
40
+ feature: z.string().optional(),
41
+ work_completed: stringList,
42
+ tests_performed: stringList,
43
+ issues: stringList,
44
+ decisions: stringList,
45
+ next_steps: stringList,
46
+ });
47
+
48
+ export type ProgressSession = z.infer<typeof ProgressSessionSchema>;
49
+
50
+ // -----------------------------------------------------------------------------
51
+ // Renderer
52
+ // -----------------------------------------------------------------------------
53
+
54
+ /**
55
+ * Render a {@link ProgressSession} as a markdown block (without a trailing
56
+ * newline beyond the single one that ends the block).
57
+ *
58
+ * Section sub-headings are always emitted (even when the list is empty) so the
59
+ * shape matches the `## Session Template` in `shared/assets/progress.md` — this
60
+ * keeps the file skimmable and gives the AI consistent anchors to fill in
61
+ * later.
62
+ */
63
+ export function renderSession(session: ProgressSession): string {
64
+ const lines: string[] = [];
65
+ lines.push(`## ${session.title}`);
66
+ lines.push(`**Agent**: ${session.agent}`);
67
+ if (session.sprint !== undefined && session.sprint !== "") {
68
+ lines.push(`**Sprint**: ${session.sprint}`);
69
+ }
70
+ if (session.feature !== undefined && session.feature !== "") {
71
+ lines.push(`**Feature**: ${session.feature}`);
72
+ }
73
+ lines.push("");
74
+ lines.push("### Work Completed");
75
+ if (session.work_completed.length > 0) {
76
+ for (const item of session.work_completed) lines.push(`- ${item}`);
77
+ }
78
+ lines.push("");
79
+ lines.push("### Tests Performed");
80
+ if (session.tests_performed.length > 0) {
81
+ for (const item of session.tests_performed) lines.push(`- ${item}`);
82
+ }
83
+ lines.push("");
84
+ lines.push("### Issues Encountered");
85
+ if (session.issues.length > 0) {
86
+ for (const item of session.issues) lines.push(`- ${item}`);
87
+ }
88
+ lines.push("");
89
+ lines.push("### Decisions Made");
90
+ if (session.decisions.length > 0) {
91
+ for (const item of session.decisions) lines.push(`- ${item}`);
92
+ }
93
+ lines.push("");
94
+ lines.push("### Next Steps");
95
+ if (session.next_steps.length > 0) {
96
+ for (const item of session.next_steps) lines.push(`- ${item}`);
97
+ }
98
+ return lines.join("\n");
99
+ }
100
+
101
+ // -----------------------------------------------------------------------------
102
+ // Writer
103
+ // -----------------------------------------------------------------------------
104
+
105
+ /**
106
+ * Insert `session` into `progressMd` immediately after the `## Sessions`
107
+ * heading and before the first existing session entry, so the newest session
108
+ * stays at the top.
109
+ *
110
+ * Behavior:
111
+ * 1. Validate `session` against {@link ProgressSessionSchema} (throws
112
+ * ZodError on invalid input).
113
+ * 2. Locate the `## Sessions` heading (matched case-sensitively as a line
114
+ * starting with `## Sessions`). If missing, throw a descriptive Error —
115
+ * appending to a progress.md without the anchor would silently corrupt
116
+ * the document structure.
117
+ * 3. Find the position of the first *existing session entry*: the next
118
+ * `## ` heading after `## Sessions` that is NOT `## Sessions` itself. This
119
+ * skips template scaffolding (HTML comments, the `## Session Template`
120
+ * block that lives above `## Sessions`, etc.) and lands the new entry
121
+ * directly above the previous newest session.
122
+ * 4. If no existing session entry exists, append at the end of the document.
123
+ * 5. Return the new markdown string. The new session is separated from
124
+ * surrounding content by blank lines.
125
+ *
126
+ * This function does NOT write to disk. The caller (tool layer) is responsible
127
+ * for persistence.
128
+ */
129
+ export function appendProgressSession(
130
+ progressMd: string,
131
+ session: ProgressSession,
132
+ ): string {
133
+ const validated = ProgressSessionSchema.parse(session);
134
+ const rendered = renderSession(validated);
135
+
136
+ const lines = progressMd.split("\n");
137
+
138
+ // Locate the `## Sessions` heading line. Matched case-sensitively and
139
+ // anchored so `## Session Template` (which lives above this heading in the
140
+ // default template) is not confused with it.
141
+ let sessionsHeadingIndex = -1;
142
+ for (let i = 0; i < lines.length; i++) {
143
+ if (/^##\s+Sessions\s*$/.test(lines[i])) {
144
+ sessionsHeadingIndex = i;
145
+ break;
146
+ }
147
+ }
148
+ if (sessionsHeadingIndex === -1) {
149
+ throw new Error(
150
+ "progress.md is missing the '## Sessions' heading — cannot insert session",
151
+ );
152
+ }
153
+
154
+ // Find the position immediately before the first existing session entry.
155
+ // An "existing session entry" is any `## ` heading that appears AFTER the
156
+ // `## Sessions` heading. Content between the heading and that first entry
157
+ // (e.g. the `<!-- New sessions should be added above this line -->`
158
+ // comment in the default template) is preserved verbatim and ends up below
159
+ // the newly inserted session — which is exactly the "newest on top"
160
+ // invariant the acceptance criterion asks for.
161
+ let firstEntryIndex = -1;
162
+ for (let i = sessionsHeadingIndex + 1; i < lines.length; i++) {
163
+ if (/^##\s+\S/.test(lines[i])) {
164
+ firstEntryIndex = i;
165
+ break;
166
+ }
167
+ }
168
+ // When there is no existing session entry, append at the end of the file.
169
+ const insertAt = firstEntryIndex === -1 ? lines.length : firstEntryIndex;
170
+
171
+ // Build the insertion: a blank line, the rendered session, a blank line.
172
+ // The trailing blank line guarantees separation from whatever follows
173
+ // (the first existing entry, or end-of-document).
174
+ const block = ["", rendered, ""];
175
+
176
+ const next = lines.slice(0, insertAt).concat(block, lines.slice(insertAt));
177
+ return next.join("\n");
178
+ }
@@ -0,0 +1,121 @@
1
+ // Append a new sprint to a features.json object (in-memory; no I/O).
2
+ //
3
+ // This is one of the three "writer" modules introduced in s2-feat-001. The
4
+ // source plugin had no equivalent Python script — its `ghs-sprint` skill
5
+ // instructed the AI to edit features.json directly with the Edit tool. This
6
+ // module refactors that into "AI provides a spec, a pure function returns the
7
+ // updated object" so the tool layer (s2-feat-003) controls disk persistence.
8
+ //
9
+ // Design principles (from s2-feat-001 technical_notes + s1-feat-008 style):
10
+ // - Pure function: no Bun.write / fs.writeFileSync. Persistence is the
11
+ // caller's responsibility.
12
+ // - No stdout / console.log.
13
+ // - No process.exit.
14
+ // - Zod-validated input spec.
15
+ // - Immutable: returns a NEW object; the input is not modified.
16
+ //
17
+ // Style mirrors archive-sprint.ts / init-project.ts.
18
+
19
+ import { z } from "zod";
20
+
21
+ // -----------------------------------------------------------------------------
22
+ // Types
23
+ // -----------------------------------------------------------------------------
24
+
25
+ type JsonObject = Record<string, unknown>;
26
+ export type FeaturesData = JsonObject;
27
+ export type Sprint = JsonObject;
28
+
29
+ // -----------------------------------------------------------------------------
30
+ // Schema
31
+ // -----------------------------------------------------------------------------
32
+
33
+ /**
34
+ * Zod schema for the "append a sprint" spec.
35
+ *
36
+ * - `id`: matches the sprint ID format enforced by validate-structure.ts
37
+ * (`^s\d{1,4}$`). We re-declare the pattern here rather than importing a
38
+ * shared constant, to keep this module self-contained (consistent with the
39
+ * no-cross-module-dependency style of the s1-feat-008 ports).
40
+ * - `name`, `goal`: non-empty strings.
41
+ * - `created_at`: `YYYY-MM-DD` (the same format init-project.ts emits via
42
+ * `formatLocalDate`).
43
+ */
44
+ const SPRINT_ID_PATTERN = /^s\d{1,4}$/;
45
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
46
+
47
+ export const AppendSprintSpecSchema = z.object({
48
+ id: z
49
+ .string()
50
+ .regex(
51
+ SPRINT_ID_PATTERN,
52
+ "sprint id must match ^s\\d{1,4}$ (e.g. s1, s12, s1234)",
53
+ ),
54
+ name: z.string().min(1, "name is required"),
55
+ goal: z.string().min(1, "goal is required"),
56
+ created_at: z
57
+ .string()
58
+ .regex(DATE_PATTERN, "created_at must be YYYY-MM-DD"),
59
+ });
60
+
61
+ export type AppendSprintSpec = z.infer<typeof AppendSprintSpecSchema>;
62
+
63
+ // -----------------------------------------------------------------------------
64
+ // Writer
65
+ // -----------------------------------------------------------------------------
66
+
67
+ /**
68
+ * Append a new (empty) sprint to `featuresData.sprints` and return a NEW
69
+ * featuresData object. The input is not modified.
70
+ *
71
+ * Behavior:
72
+ * 1. Validate `spec` against {@link AppendSprintSpecSchema} (throws ZodError
73
+ * on invalid input).
74
+ * 2. Scan existing `sprints[].id` (treats a missing/empty array as "no
75
+ * sprints") and throw a descriptive Error if `spec.id` already exists.
76
+ * 3. Return a shallow-cloned featuresData with a shallow-cloned `sprints`
77
+ * array that has the new sprint appended. The new sprint carries an empty
78
+ * `features: []` array and `status: "planning"` (matches the source
79
+ * plugin's convention — a freshly created sprint starts in planning until
80
+ * the AI finishes decomposing it into features).
81
+ *
82
+ * The clone strategy is intentionally shallow at the top level: existing
83
+ * sprint/feature objects are shared by reference (they are not mutated), while
84
+ * the container arrays and the new sprint object are fresh. This satisfies the
85
+ * "do not modify the input object" acceptance criterion without paying for a
86
+ * deep clone of potentially large feature trees.
87
+ */
88
+ export function appendSprint(
89
+ featuresData: FeaturesData,
90
+ spec: AppendSprintSpec,
91
+ ): FeaturesData {
92
+ const validated = AppendSprintSpecSchema.parse(spec);
93
+
94
+ const sprints = Array.isArray(featuresData.sprints)
95
+ ? (featuresData.sprints as Sprint[])
96
+ : [];
97
+
98
+ // Uniqueness check — cross all existing sprints.
99
+ const clash = sprints.find((s) => s.id === validated.id);
100
+ if (clash !== undefined) {
101
+ throw new Error(
102
+ `Sprint id '${validated.id}' already exists ` +
103
+ `(name: ${JSON.stringify(clash.name ?? "<unnamed>")})`,
104
+ );
105
+ }
106
+
107
+ const newSprint: Sprint = {
108
+ id: validated.id,
109
+ name: validated.name,
110
+ goal: validated.goal,
111
+ status: "planning",
112
+ created_at: validated.created_at,
113
+ features: [],
114
+ };
115
+
116
+ // Shallow-clone the container so callers can safely keep using the original.
117
+ return {
118
+ ...featuresData,
119
+ sprints: [...sprints, newSprint],
120
+ };
121
+ }