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
package/src/lib/nonce.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/parse.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/paths.ts
ADDED
|
@@ -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
|
+
}
|