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,291 @@
1
+ // Port of golden-hoop-spell/plugin/shared/scripts/init_project.py.
2
+ //
3
+ // Behavior source-of-truth:
4
+ // /Users/tom/github/golden-hoop-spell/plugin/shared/scripts/init_project.py
5
+ //
6
+ // Faithful port notes:
7
+ // - JSON output uses `JSON.stringify(obj, null, 2)` which matches Python's
8
+ // `json.dump(obj, f, indent=2)` (both use 2-space indent, comma-newline,
9
+ // colon-space). Python's `ensure_ascii=True` default would escape non-ASCII
10
+ // as \uXXXX — but the source features.json template is pure ASCII, so the
11
+ // byte-for-byte equivalence holds for the generated files in practice.
12
+ // - Date format: Python uses `datetime.now().strftime("%Y-%m-%d")` which is
13
+ // a naive local date. We mirror this via `formatLocalDate()`.
14
+ // - This module exports functions — NO console.log to stdout, NO process.exit.
15
+ // The CLI wrapper (s1-feat-009) is responsible for printing the human-facing
16
+ // status lines. Here we return a structured result.
17
+
18
+ import { existsSync, realpathSync } from "node:fs";
19
+ import { mkdir, readFile, writeFile, copyFile } from "node:fs/promises";
20
+ import { dirname, join, resolve } from "node:path";
21
+
22
+ /**
23
+ * Resolve the plugin root (the directory that contains `src/` and `shared/`).
24
+ *
25
+ * Note: This mirrors the behaviour that will be exported from
26
+ * `src/lib/paths.ts` (s1-feat-006). The local copy keeps this module
27
+ * self-contained — s1-feat-008 has no dependency on s1-feat-006, so we
28
+ * duplicate the one-line primitive rather than importing across the
29
+ * not-yet-implemented module boundary. When s1-feat-006 lands and exports
30
+ * `pluginRoot`, callers may pass `pluginRootPath` explicitly to override.
31
+ */
32
+ function defaultPluginRoot(): string {
33
+ // import.meta.dir is the directory of this source file
34
+ // (src/lib/scripts/), so the plugin root is three levels up.
35
+ return resolve(import.meta.dir, "..", "..", "..");
36
+ }
37
+
38
+ /**
39
+ * Resolve a path the way Python's `pathlib.Path.resolve(strict=False)` does:
40
+ * apply `realpathSync` to the longest existing prefix, then append the
41
+ * remaining components verbatim. Necessary on macOS where `/tmp` is a
42
+ * symlink to `/private/tmp`. See resolve-project-dir.ts for the same helper.
43
+ */
44
+ function pyResolve(p: string): string {
45
+ const absolute = resolve(p);
46
+ let existing = absolute;
47
+ while (existing !== parentOf(existing) && !existsSync(existing)) {
48
+ existing = parentOf(existing);
49
+ }
50
+ if (!existsSync(existing)) {
51
+ return absolute;
52
+ }
53
+ const real = realpathSync(existing);
54
+ if (existing === absolute) {
55
+ return real;
56
+ }
57
+ return real + absolute.slice(existing.length);
58
+ }
59
+
60
+ /** Parent directory of `dir`, or `dir` itself at the filesystem root. */
61
+ function parentOf(dir: string): string {
62
+ const parent = resolve(dir, "..");
63
+ return parent === dir ? dir : parent;
64
+ }
65
+
66
+ /** Options accepted by initProject. */
67
+ export interface InitProjectOptions {
68
+ /** Project name (required). */
69
+ projectName: string;
70
+ /** Optional project description; defaults to `<projectName> project`. */
71
+ description?: string;
72
+ /**
73
+ * Output directory (the project root where `.ghs/` will be created).
74
+ * Defaults to the current working directory.
75
+ */
76
+ projectDir?: string;
77
+ /** When true, overwrite existing `.ghs/features.json` or `.ghs/progress.md`. */
78
+ force?: boolean;
79
+ /**
80
+ * Override the plugin root (used to locate `shared/assets/` templates).
81
+ * Defaults to the plugin root resolved from `import.meta.dir`.
82
+ */
83
+ pluginRootPath?: string;
84
+ }
85
+
86
+ /** Result returned by initProject. */
87
+ export interface InitProjectResult {
88
+ /** Absolute path to the project directory the files were written into. */
89
+ outputDir: string;
90
+ /** Absolute path of the created features.json. */
91
+ featuresFile: string;
92
+ /** Absolute path of the created progress.md. */
93
+ progressFile: string;
94
+ /** Absolute path of the touched .gitignore. */
95
+ gitignoreFile: string;
96
+ /** Whether .gitignore was modified (true) or already contained `.ghs` (false). */
97
+ gitignoreUpdated: boolean;
98
+ /** The project name written into features.json. */
99
+ projectName: string;
100
+ /** The project description written into features.json. */
101
+ projectDescription: string;
102
+ }
103
+
104
+ /** Format a Date the way Python's `datetime.now().strftime("%Y-%m-%d")` does. */
105
+ export function formatLocalDate(now: Date = new Date()): string {
106
+ const y = now.getFullYear();
107
+ const m = String(now.getMonth() + 1).padStart(2, "0");
108
+ const d = String(now.getDate()).padStart(2, "0");
109
+ return `${y}-${m}-${d}`;
110
+ }
111
+
112
+ /**
113
+ * Create `.ghs/features.json` from the shared template, substituting
114
+ * `project.name`, `project.description`, `project.created_at`, and
115
+ * `metadata.last_updated`.
116
+ *
117
+ * Mirrors Python `create_features_json`.
118
+ */
119
+ export async function createFeaturesJson(
120
+ projectName: string,
121
+ projectDescription: string,
122
+ outputDir: string,
123
+ pluginRootPath: string = defaultPluginRoot(),
124
+ ): Promise<string> {
125
+ const templatePath = join(pluginRootPath, "shared", "assets", "features.json");
126
+
127
+ if (!existsSync(templatePath)) {
128
+ throw new Error(`Template not found: ${templatePath}`);
129
+ }
130
+
131
+ const templateText = await readFile(templatePath, "utf8");
132
+ const featuresData = JSON.parse(templateText) as Record<string, unknown>;
133
+
134
+ const project = (featuresData.project ?? {}) as Record<string, unknown>;
135
+ project.name = projectName;
136
+ project.description = projectDescription;
137
+ project.created_at = formatLocalDate();
138
+ featuresData.project = project;
139
+
140
+ const metadata = (featuresData.metadata ?? {}) as Record<string, unknown>;
141
+ metadata.last_updated = formatLocalDate();
142
+ featuresData.metadata = metadata;
143
+
144
+ const outputFile = join(outputDir, ".ghs", "features.json");
145
+ await mkdir(dirname(outputFile), { recursive: true });
146
+ await writeFile(outputFile, JSON.stringify(featuresData, null, 2), "utf8");
147
+
148
+ return outputFile;
149
+ }
150
+
151
+ /**
152
+ * Append `.ghs` to `.gitignore` if not already present; create the file if
153
+ * missing. Mirrors Python `ensure_gitignore`.
154
+ *
155
+ * @returns `[absolutePath, updated]` — `updated` is true when the file was
156
+ * created or modified, false when `.ghs` was already present.
157
+ */
158
+ export async function ensureGitignore(
159
+ outputDir: string,
160
+ ): Promise<[string, boolean]> {
161
+ const gitignorePath = join(outputDir, ".gitignore");
162
+ const entry = ".ghs";
163
+
164
+ if (existsSync(gitignorePath)) {
165
+ const content = await readFile(gitignorePath, "utf8");
166
+ const lines = content.split(/\r?\n/).map((l) => l.trim());
167
+ if (lines.includes(entry)) {
168
+ return [gitignorePath, false];
169
+ }
170
+ let next = content;
171
+ if (content.length > 0 && !content.endsWith("\n")) {
172
+ next += "\n";
173
+ }
174
+ next += `${entry}\n`;
175
+ await writeFile(gitignorePath, next, "utf8");
176
+ return [gitignorePath, true];
177
+ }
178
+
179
+ await writeFile(gitignorePath, `${entry}\n`, "utf8");
180
+ return [gitignorePath, true];
181
+ }
182
+
183
+ /**
184
+ * Copy the shared `assets/progress.md` template to `<outputDir>/.ghs/progress.md`.
185
+ * Mirrors Python `create_progress_md`.
186
+ */
187
+ export async function createProgressMd(
188
+ outputDir: string,
189
+ pluginRootPath: string = defaultPluginRoot(),
190
+ ): Promise<string> {
191
+ const templatePath = join(pluginRootPath, "shared", "assets", "progress.md");
192
+
193
+ if (!existsSync(templatePath)) {
194
+ throw new Error(`Template not found: ${templatePath}`);
195
+ }
196
+
197
+ const ghsDir = join(outputDir, ".ghs");
198
+ await mkdir(ghsDir, { recursive: true });
199
+ const outputFile = join(ghsDir, "progress.md");
200
+ await copyFile(templatePath, outputFile);
201
+
202
+ return outputFile;
203
+ }
204
+
205
+ /**
206
+ * Initialize the `.ghs/` tracking files for a project.
207
+ *
208
+ * Mirrors the body of Python `main()` minus the stdout prints: it validates
209
+ * preconditions (existing files vs `force`), creates features.json +
210
+ * progress.md, and updates .gitignore.
211
+ *
212
+ * @throws when the templates cannot be found, or when files already exist and
213
+ * `force` is not set.
214
+ */
215
+ export async function initProject(
216
+ options: InitProjectOptions,
217
+ ): Promise<InitProjectResult> {
218
+ const outputDir = pyResolve(options.projectDir ?? process.cwd());
219
+ await mkdir(outputDir, { recursive: true });
220
+
221
+ const projectName = options.projectName;
222
+ const projectDescription = options.description?.length
223
+ ? options.description
224
+ : `${projectName} project`;
225
+
226
+ // Check for existing .ghs files unless --force is passed.
227
+ if (!options.force) {
228
+ const existingFiles: string[] = [];
229
+ const featuresPath = join(outputDir, ".ghs", "features.json");
230
+ const progressPath = join(outputDir, ".ghs", "progress.md");
231
+ if (existsSync(featuresPath)) {
232
+ existingFiles.push(relativeOrSame(featuresPath, outputDir));
233
+ }
234
+ if (existsSync(progressPath)) {
235
+ existingFiles.push(relativeOrSame(progressPath, outputDir));
236
+ }
237
+ if (existingFiles.length > 0) {
238
+ throw new InitFilesExistError(existingFiles, outputDir);
239
+ }
240
+ }
241
+
242
+ const pluginRootPath = options.pluginRootPath ?? defaultPluginRoot();
243
+ const featuresFile = await createFeaturesJson(
244
+ projectName,
245
+ projectDescription,
246
+ outputDir,
247
+ pluginRootPath,
248
+ );
249
+ const progressFile = await createProgressMd(outputDir, pluginRootPath);
250
+ const [gitignoreFile, gitignoreUpdated] = await ensureGitignore(outputDir);
251
+
252
+ return {
253
+ outputDir,
254
+ featuresFile,
255
+ progressFile,
256
+ gitignoreFile,
257
+ gitignoreUpdated,
258
+ projectName,
259
+ projectDescription,
260
+ };
261
+ }
262
+
263
+ /** Compute path relative to `outputDir`, falling back to the absolute path. */
264
+ function relativeOrSame(target: string, base: string): string {
265
+ const rel = target.startsWith(base + "/") || target.startsWith(base)
266
+ ? target.slice(base.length).replace(/^\/+/, "")
267
+ : target;
268
+ return rel;
269
+ }
270
+
271
+ /**
272
+ * Error thrown when `.ghs/features.json` or `.ghs/progress.md` already exist
273
+ * and the caller did not pass `force: true`. Mirrors Python's diagnostic
274
+ * output: the file list + the `Use --force to overwrite existing files` hint.
275
+ */
276
+ export class InitFilesExistError extends Error {
277
+ readonly existingFiles: string[];
278
+ readonly outputDir: string;
279
+
280
+ constructor(existingFiles: string[], outputDir: string) {
281
+ const lines = [
282
+ "Error: The following .ghs files already exist:",
283
+ ...existingFiles.map((f) => ` - ${f}`),
284
+ "Use --force to overwrite existing files.",
285
+ ].join("\n");
286
+ super(lines);
287
+ this.name = "InitFilesExistError";
288
+ this.existingFiles = existingFiles;
289
+ this.outputDir = outputDir;
290
+ }
291
+ }
@@ -0,0 +1,380 @@
1
+ // Port of golden-hoop-spell/plugin/shared/scripts/parallel_utils.py.
2
+ //
3
+ // Behavior source-of-truth:
4
+ // /Users/tom/.claude/plugins/cache/golden-hoop-spell/golden-hoop-spell/0.4.0/shared/scripts/parallel_utils.py
5
+ //
6
+ // Faithful port notes (plan §3.4 D4 — line-by-line port):
7
+ // - The Python source is both a library (`detect_cycles` /
8
+ // `get_ready_features` / `build_parallel_batches`) and a CLI wrapper
9
+ // (`main()` reads features.json via argparse and prints JSON). We port the
10
+ // *library* core verbatim-by-behavior; the CLI layer (argparse / stdout /
11
+ // `json.dump` / `sys.exit`) is intentionally omitted because the OpenCode
12
+ // plugin consumes this as an in-process TS module — the tool layer
13
+ // (`ghs-code`) calls `getReadyFeatures()` / `buildBatches()` directly and
14
+ // renders the result itself.
15
+ // - The three library functions are pure: no FS, no subprocess, no global
16
+ // mutation. They take already-parsed `features.json` objects. File reading
17
+ // is left to the caller (mirrors how the other ports in this directory
18
+ // split: `readFeaturesJson` lives next to each consumer rather than here,
19
+ // and `status.ts` already exposes one).
20
+ // - Iteration-order hazard (called out in the feature's technical_notes):
21
+ // Python dict preserves insertion order and so does JS object iteration,
22
+ // so the `feature_index` / `completed_ids` lookups behave the same.
23
+ // The one place ordering is *observable* is `build_parallel_batches`'s
24
+ // output: we sort by `files_affected.length` descending (Python
25
+ // `sorted(..., key=lambda f: len(f.get('files_affected', [])),
26
+ // reverse=True)`). JS `Array.prototype.sort` is stable as of ES2019, so
27
+ // ties keep their original relative order — same as Python's stable
28
+ // Timsort. No extra tiebreaker is needed for parity.
29
+ // - Cycle detection is the iterative-but-recursive DFS with white/gray/black
30
+ // coloring from the Python source. We keep it recursive in JS too (the
31
+ // feature graphs in practice are tiny — tens of features per sprint — so
32
+ // stack depth is a non-issue).
33
+ // - Style follows s1-feat-008: no `process.exit`, no `console.log`, all
34
+ // exported functions are pure.
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Types
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /**
41
+ * Loose structural types mirroring the shape of `features.json`. We keep these
42
+ * deliberately permissive (`Record<string, unknown>`) rather than redefining a
43
+ * strict Zod schema here — the file is already validated upstream by
44
+ * `validate-structure.ts` (s1-feat-012) before any tool invokes this module,
45
+ * and the Python original also operated on untyped `Dict`s. Field access uses
46
+ * the same `.get(...)` / `?? []` defensive pattern as the source.
47
+ */
48
+ type JsonObject = Record<string, unknown>;
49
+ export type Feature = JsonObject;
50
+ export type Sprint = JsonObject;
51
+ export type FeaturesData = JsonObject;
52
+
53
+ /** Color used by the DFS cycle finder. Mirrors Python WHITE/GRAY/BLACK. */
54
+ const WHITE = 0;
55
+ const GRAY = 1;
56
+ const BLACK = 2;
57
+
58
+ export interface ReadyFeaturesResult {
59
+ /** Features whose status is `pending`, deps all completed, not in a cycle. */
60
+ ready: Feature[];
61
+ /** Everything else: wrong status, unmet deps, or cycle-participating. */
62
+ skipped: Feature[];
63
+ /** Detected cycles; each is a list of feature IDs forming the loop. */
64
+ cycles: string[][];
65
+ /** IDs of any feature that participates in at least one cycle. */
66
+ cycle_feature_ids: string[];
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // detect_cycles — 1:1 port of the Python DFS coloring algorithm.
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /**
74
+ * Detect circular dependencies in the feature dependency graph.
75
+ *
76
+ * Uses recursive DFS with white/gray/black coloring to find every cycle.
77
+ * Returns a list of cycles, where each cycle is a list of feature IDs forming
78
+ * the loop. A dependency that is not in `feature_index` is skipped (mirrors
79
+ * Python `if dep not in feature_index: continue`).
80
+ *
81
+ * Port of `detect_cycles(features, feature_index)`.
82
+ */
83
+ export function detectCycles(
84
+ features: Feature[],
85
+ featureIndex: Record<string, Feature>,
86
+ ): string[][] {
87
+ const color = new Map<string, number>();
88
+ const cycles: string[][] = [];
89
+ const path: string[] = [];
90
+ const pathSet = new Set<string>();
91
+
92
+ const dfs = (node: string): void => {
93
+ color.set(node, GRAY);
94
+ path.push(node);
95
+ pathSet.add(node);
96
+
97
+ const feat = featureIndex[node] ?? {};
98
+ const deps = (feat["dependencies"] as string[] | undefined) ?? [];
99
+ for (const dep of deps) {
100
+ if (!(dep in featureIndex)) {
101
+ continue;
102
+ }
103
+ if (color.get(dep) === GRAY) {
104
+ // Found a cycle — extract it from the current DFS path.
105
+ const cycleStart = path.indexOf(dep);
106
+ cycles.push(path.slice(cycleStart));
107
+ } else if ((color.get(dep) ?? WHITE) === WHITE) {
108
+ dfs(dep);
109
+ }
110
+ }
111
+
112
+ path.pop();
113
+ pathSet.delete(node);
114
+ color.set(node, BLACK);
115
+ };
116
+
117
+ for (const feat of features) {
118
+ const fid = (feat["id"] as string | undefined) ?? "";
119
+ if ((color.get(fid) ?? WHITE) === WHITE) {
120
+ dfs(fid);
121
+ }
122
+ }
123
+
124
+ return cycles;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // get_ready_features — 1:1 port of get_ready_features.
129
+ // ---------------------------------------------------------------------------
130
+
131
+ /**
132
+ * Identify features whose dependencies are all completed.
133
+ *
134
+ * Selection of the sprint to analyze mirrors Python exactly:
135
+ * - If `sprintId` is given, the first sprint with that `id` is used.
136
+ * - Otherwise the first sprint with `status === "in_progress"` is used; if
137
+ * none, the first sprint in the array.
138
+ * - If no sprint matches (empty list, missing id), all four result fields
139
+ * are empty arrays.
140
+ *
141
+ * A feature is `ready` iff:
142
+ * 1. `status === "pending"`,
143
+ * 2. it is not part of any detected dependency cycle, AND
144
+ * 3. every entry in its `dependencies` is in the completed set.
145
+ *
146
+ * Port of `get_ready_features(features_data, sprint_id=None)`.
147
+ */
148
+ export function getReadyFeatures(
149
+ featuresData: FeaturesData,
150
+ sprintId?: string | null,
151
+ ): ReadyFeaturesResult {
152
+ const empty: ReadyFeaturesResult = {
153
+ ready: [],
154
+ skipped: [],
155
+ cycles: [],
156
+ cycle_feature_ids: [],
157
+ };
158
+
159
+ const sprints = (featuresData["sprints"] as Sprint[] | undefined) ?? [];
160
+
161
+ let sprint: Sprint | null = null;
162
+ if (sprintId) {
163
+ for (const s of sprints) {
164
+ if (s["id"] === sprintId) {
165
+ sprint = s;
166
+ break;
167
+ }
168
+ }
169
+ } else {
170
+ // Use the first in_progress sprint, or fall back to the first sprint.
171
+ for (const s of sprints) {
172
+ if (s["status"] === "in_progress") {
173
+ sprint = s;
174
+ break;
175
+ }
176
+ }
177
+ if (sprint === null && sprints.length > 0) {
178
+ sprint = sprints[0];
179
+ }
180
+ }
181
+
182
+ if (sprint === null) {
183
+ return empty;
184
+ }
185
+
186
+ const features = (sprint["features"] as Feature[] | undefined) ?? [];
187
+
188
+ // Build index keyed by id, and the completed-id set.
189
+ const featureIndex: Record<string, Feature> = {};
190
+ for (const f of features) {
191
+ const fid = (f["id"] as string | undefined) ?? "";
192
+ if (fid) {
193
+ featureIndex[fid] = f;
194
+ }
195
+ }
196
+ const completedIds = new Set<string>();
197
+ for (const f of features) {
198
+ if (f["status"] === "completed") {
199
+ const fid = (f["id"] as string | undefined) ?? "";
200
+ if (fid) {
201
+ completedIds.add(fid);
202
+ }
203
+ }
204
+ }
205
+
206
+ // Detect cycles and union the participating ids.
207
+ const cycles = detectCycles(features, featureIndex);
208
+ const cycleFeatureIdSet = new Set<string>();
209
+ for (const cycle of cycles) {
210
+ for (const id of cycle) {
211
+ cycleFeatureIdSet.add(id);
212
+ }
213
+ }
214
+
215
+ const ready: Feature[] = [];
216
+ const skipped: Feature[] = [];
217
+
218
+ for (const feat of features) {
219
+ const fid = (feat["id"] as string | undefined) ?? "";
220
+ const status = (feat["status"] as string | undefined) ?? "";
221
+
222
+ // Only pending features can be ready.
223
+ if (status !== "pending") {
224
+ skipped.push(feat);
225
+ continue;
226
+ }
227
+
228
+ // Skip features involved in dependency cycles.
229
+ if (cycleFeatureIdSet.has(fid)) {
230
+ skipped.push(feat);
231
+ continue;
232
+ }
233
+
234
+ // Check all dependencies are completed.
235
+ //
236
+ // Faithful port of the Python conditional — the second disjunct is
237
+ // logically redundant (`dep_id in completed_ids` already covers it) but we
238
+ // keep it verbatim for byte-for-byte behavioral parity:
239
+ // dep_id in completed_ids or
240
+ // (dep_id not in feature_index and dep_id in completed_ids)
241
+ const deps = (feat["dependencies"] as string[] | undefined) ?? [];
242
+ const depsMet = deps.every(
243
+ (depId) =>
244
+ completedIds.has(depId) ||
245
+ (!(depId in featureIndex) && completedIds.has(depId)),
246
+ );
247
+
248
+ if (depsMet) {
249
+ ready.push(feat);
250
+ } else {
251
+ skipped.push(feat);
252
+ }
253
+ }
254
+
255
+ return {
256
+ ready,
257
+ skipped,
258
+ cycles,
259
+ cycle_feature_ids: Array.from(cycleFeatureIdSet),
260
+ };
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // build_parallel_batches — 1:1 port of build_parallel_batches.
265
+ // ---------------------------------------------------------------------------
266
+
267
+ /**
268
+ * Group non-conflicting ready features into parallel batches.
269
+ *
270
+ * Heuristic (verbatim from Python): sort by `files_affected` length descending,
271
+ * then greedily place each feature into the first existing batch that (a) is
272
+ * under `maxParallel` and (b) has no `files_affected` overlap. If none fits,
273
+ * start a new batch.
274
+ *
275
+ * The descending-file-count sort tends to spread high-overlap features across
276
+ * different batches first. Features with overlapping `files_affected` are never
277
+ * placed in the same batch to avoid merge conflicts during parallel execution.
278
+ *
279
+ * Port of `build_parallel_batches(ready_features, max_parallel=5)`.
280
+ */
281
+ export function buildBatches(
282
+ readyFeatures: Feature[],
283
+ maxParallel = 5,
284
+ ): Feature[][] {
285
+ if (readyFeatures.length === 0) {
286
+ return [];
287
+ }
288
+
289
+ // Sort by number of files_affected descending. JS Array.sort is stable
290
+ // (ES2019+), so ties preserve input order — matching Python's Timsort.
291
+ const sortedFeatures = [...readyFeatures].sort(
292
+ (a, b) =>
293
+ (((b["files_affected"] as unknown[] | undefined) ?? []).length) -
294
+ (((a["files_affected"] as unknown[] | undefined) ?? []).length),
295
+ );
296
+
297
+ const batches: Feature[][] = [];
298
+ const assigned = new Set<string>();
299
+
300
+ for (const feat of sortedFeatures) {
301
+ const fid = (feat["id"] as string | undefined) ?? "";
302
+ if (assigned.has(fid)) {
303
+ continue;
304
+ }
305
+
306
+ const featFiles = new Set<string>(
307
+ ((feat["files_affected"] as string[] | undefined) ?? []),
308
+ );
309
+
310
+ // Try to place in an existing batch.
311
+ let placed = false;
312
+ for (const batch of batches) {
313
+ if (batch.length >= maxParallel) {
314
+ continue;
315
+ }
316
+
317
+ // Check for file conflicts with features already in the batch.
318
+ let hasConflict = false;
319
+ for (const existing of batch) {
320
+ const existingFiles = new Set<string>(
321
+ ((existing["files_affected"] as string[] | undefined) ?? []),
322
+ );
323
+ // Set intersection: if any element of featFiles is in existingFiles.
324
+ for (const f of featFiles) {
325
+ if (existingFiles.has(f)) {
326
+ hasConflict = true;
327
+ break;
328
+ }
329
+ }
330
+ if (hasConflict) {
331
+ break;
332
+ }
333
+ }
334
+
335
+ if (!hasConflict) {
336
+ batch.push(feat);
337
+ assigned.add(fid);
338
+ placed = true;
339
+ break;
340
+ }
341
+ }
342
+
343
+ // If no existing batch fits, start a new one.
344
+ if (!placed) {
345
+ batches.push([feat]);
346
+ assigned.add(fid);
347
+ }
348
+ }
349
+
350
+ return batches;
351
+ }
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // summarizeFeature — port of the inline `summarize_feature` used by main().
355
+ // Kept here (not in the CLI) because the ghs-code tool wants the same
356
+ // trimmed projection of a feature for its dispatch-plan output.
357
+ // ---------------------------------------------------------------------------
358
+
359
+ export interface FeatureSummary {
360
+ id: string;
361
+ title: string;
362
+ status: string;
363
+ files_affected: string[];
364
+ dependencies: string[];
365
+ }
366
+
367
+ /**
368
+ * Project a feature dict onto the small summary shape the Python CLI emitted
369
+ * in its JSON output (`id`, `title`, `status`, `files_affected`,
370
+ * `dependencies`). Port of `summarize_feature` inside `main()`.
371
+ */
372
+ export function summarizeFeature(feat: Feature): FeatureSummary {
373
+ return {
374
+ id: (feat["id"] as string | undefined) ?? "",
375
+ title: (feat["title"] as string | undefined) ?? "",
376
+ status: (feat["status"] as string | undefined) ?? "",
377
+ files_affected: (feat["files_affected"] as string[] | undefined) ?? [],
378
+ dependencies: (feat["dependencies"] as string[] | undefined) ?? [],
379
+ };
380
+ }