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,583 @@
1
+ // Port of golden-hoop-spell/plugin/shared/scripts/archive_sprint.py.
2
+ //
3
+ // Behavior source-of-truth:
4
+ // /Users/tom/github/golden-hoop-spell/plugin/shared/scripts/archive_sprint.py
5
+ //
6
+ // Faithful port notes:
7
+ // - JSON output uses `JSON.stringify(obj, null, 2)` — matches Python's
8
+ // `json.dump(obj, f, indent=2)` for ASCII content (the source template
9
+ // is pure ASCII; archive data is derived from it).
10
+ // - Timestamps use the local timezone (Python `datetime.now().strftime(...)`
11
+ // is naive local time). See `formatTimestamp()` / `formatArchiveDate()`.
12
+ // - The H2 splitter mirrors Python `re.split(r"^## ", content, flags=re.
13
+ // MULTILINE)` via JS `/^## /m`.
14
+ // - This module exports pure functions returning structured results. No
15
+ // stdout writes — the CLI layer (s1-feat-009) renders text using
16
+ // `formatArchiveReport`.
17
+
18
+ import { existsSync, realpathSync } from "node:fs";
19
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
20
+ import { dirname, join, resolve } from "node:path";
21
+
22
+ type JsonObject = Record<string, unknown>;
23
+ type Sprint = JsonObject;
24
+ type Feature = JsonObject;
25
+ type FeaturesData = JsonObject;
26
+
27
+ const ARCHIVED_DIR = ".ghs/archived";
28
+ const GHS_DIR = ".ghs";
29
+
30
+ /** All sprints. Mirrors Python `get_all_sprints`. */
31
+ export function getAllSprints(featuresData: FeaturesData): Sprint[] {
32
+ return (featuresData.sprints ?? []) as Sprint[];
33
+ }
34
+
35
+ /** Sprints with status == "completed". Mirrors Python `get_completed_sprints`. */
36
+ export function getCompletedSprints(featuresData: FeaturesData): Sprint[] {
37
+ const sprints = (featuresData.sprints ?? []) as Sprint[];
38
+ return sprints.filter((s) => s.status === "completed");
39
+ }
40
+
41
+ /**
42
+ * First sprint whose status is `in_progress` or `planning`, or null.
43
+ * Mirrors Python `get_in_progress_sprint`.
44
+ */
45
+ export function getInProgressSprint(
46
+ featuresData: FeaturesData,
47
+ ): Sprint | null {
48
+ const sprints = (featuresData.sprints ?? []) as Sprint[];
49
+ for (const sprint of sprints) {
50
+ const status = sprint.status;
51
+ if (status === "in_progress" || status === "planning") {
52
+ return sprint;
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /** Create `.ghs/archived/` if missing; return its absolute path. */
59
+ export async function createArchiveStructure(
60
+ projectDir: string,
61
+ ): Promise<string> {
62
+ const archivedPath = join(projectDir, ARCHIVED_DIR);
63
+ await mkdir(archivedPath, { recursive: true });
64
+ return archivedPath;
65
+ }
66
+
67
+ /** Info record about an archived sprint (one per sprint). */
68
+ export interface ArchivedSprintInfo {
69
+ sprint_id: string;
70
+ sprint_name: string;
71
+ sprint_status: string;
72
+ /** Set when dry_run is true; otherwise archive_path is set. */
73
+ dry_run?: boolean;
74
+ /** Absolute path of the created archive folder (omitted when dry_run). */
75
+ archive_path?: string;
76
+ }
77
+
78
+ /**
79
+ * Archive a single sprint's data: write `features.json` (containing project +
80
+ * archived_sprint + metadata) and — when relevant — `progress.md` (containing
81
+ * the sessions that matched this sprint ID).
82
+ *
83
+ * Mirrors Python `archive_sprint_files`. Returns `[archiveFeaturesPath,
84
+ * archiveProgressPath]`.
85
+ *
86
+ * Faithfulness note: the Python source captures `datetime.now()` three times
87
+ * (once for the folder timestamp, once for `metadata.archived_at`, once for
88
+ * the progress.md `Archived:` header). To stay byte-faithful at the second
89
+ * granularity — while still allowing deterministic tests — we accept up to
90
+ * three Date values via optional parameters; each defaults to `new Date()`
91
+ * at call time so production callers see the same behaviour as Python.
92
+ */
93
+ export async function archiveSprintFiles(
94
+ sprint: Sprint,
95
+ featuresData: FeaturesData,
96
+ projectDir: string,
97
+ archivedPath: string,
98
+ /**
99
+ * Dates used for the various timestamp outputs. Production callers can
100
+ * leave this undefined to mirror Python's repeated `datetime.now()` calls;
101
+ * tests can pass fixed dates for deterministic output.
102
+ */
103
+ dates: {
104
+ /** Used for the archive folder name (`<sprint>_<name>_<YYYYMMDD_HHMMSS>`). */
105
+ folder?: Date;
106
+ /** Used for `metadata.archived_at`. */
107
+ archivedAt?: Date;
108
+ /** Used for the progress.md `Archived:` header. */
109
+ progressArchivedAt?: Date;
110
+ } = {},
111
+ ): Promise<[string, string]> {
112
+ const sprintId = (sprint.id as string | undefined) ?? "unknown";
113
+ const rawName = (sprint.name as string | undefined) ?? "unnamed";
114
+ const sprintName = rawName.replace(/ /g, "_").toLowerCase();
115
+ const timestamp = formatTimestamp(dates.folder ?? new Date());
116
+
117
+ const archiveFolder = join(
118
+ archivedPath,
119
+ `${sprintId}_${sprintName}_${timestamp}`,
120
+ );
121
+ await mkdir(archiveFolder, { recursive: true });
122
+
123
+ const archiveFeatures = join(archiveFolder, "features.json");
124
+ const archiveProgress = join(archiveFolder, "progress.md");
125
+
126
+ const archivedAt = formatArchiveDate(dates.archivedAt ?? new Date());
127
+ const archivedSprintData = {
128
+ project: featuresData.project ?? {},
129
+ archived_sprint: sprint,
130
+ metadata: {
131
+ archived_at: archivedAt,
132
+ original_sprint_id: sprintId,
133
+ },
134
+ };
135
+
136
+ await writeFile(
137
+ archiveFeatures,
138
+ JSON.stringify(archivedSprintData, null, 2),
139
+ "utf8",
140
+ );
141
+
142
+ const progressPath = join(projectDir, GHS_DIR, "progress.md");
143
+ if (existsSync(progressPath)) {
144
+ const sessions = await extractSprintSessions(progressPath, sprintId);
145
+ if (sessions.length > 0) {
146
+ const progressArchivedAt = formatArchiveDate(
147
+ dates.progressArchivedAt ?? new Date(),
148
+ );
149
+ const lines: string[] = [];
150
+ lines.push(`# Progress Log - ${(sprint.name as string | undefined) ?? sprintId}`);
151
+ lines.push("");
152
+ lines.push(`Archived: ${progressArchivedAt}`);
153
+ lines.push("");
154
+ lines.push("---");
155
+ lines.push("");
156
+ lines.push(sessions);
157
+ await writeFile(archiveProgress, lines.join("\n"), "utf8");
158
+ }
159
+ }
160
+
161
+ return [archiveFeatures, archiveProgress];
162
+ }
163
+
164
+ /**
165
+ * Check whether a session entry belongs to a given sprint by inspecting only
166
+ * the title line and the first ~10 metadata lines — NOT the full body text.
167
+ *
168
+ * Mirrors Python `_entry_matches_sprint`: title-case-insensitive substring
169
+ * match of `sprint_id` within the first 11 lines.
170
+ */
171
+ export function entryMatchesSprint(entry: string, sprintId: string): boolean {
172
+ const lines = entry.trim().split("\n");
173
+ const headerLines = lines.slice(0, 11);
174
+ const headerText = headerLines.join("\n").toLowerCase();
175
+ return headerText.includes(sprintId.toLowerCase());
176
+ }
177
+
178
+ /**
179
+ * Split progress.md content by `## ` H2 headings, returning individual
180
+ * entries (each prefixed with `## `).
181
+ *
182
+ * Mirrors Python `_split_entries`: drops everything before the first H2.
183
+ */
184
+ export function splitEntries(content: string): string[] {
185
+ const parts = content.split(/^## /m);
186
+ const entries: string[] = [];
187
+ // Skip parts[0] (everything before the first H2 heading).
188
+ for (let i = 1; i < parts.length; i++) {
189
+ entries.push("## " + parts[i]);
190
+ }
191
+ return entries;
192
+ }
193
+
194
+ /**
195
+ * Extract sessions related to a specific sprint from progress.md.
196
+ *
197
+ * Mirrors Python `extract_sprint_sessions`: returns the matching entries
198
+ * joined by `\n\n`.
199
+ */
200
+ export async function extractSprintSessions(
201
+ progressPath: string,
202
+ sprintId: string,
203
+ ): Promise<string> {
204
+ const content = await readFile(progressPath, "utf8");
205
+ const entries = splitEntries(content);
206
+ const relevant: string[] = [];
207
+ for (const entry of entries) {
208
+ if (entryMatchesSprint(entry, sprintId)) {
209
+ relevant.push(entry);
210
+ }
211
+ }
212
+ return relevant.join("\n\n");
213
+ }
214
+
215
+ /**
216
+ * Remove an archived sprint from features.json (mutates a copy and returns it).
217
+ * Sets `metadata.last_updated` to today's date.
218
+ *
219
+ * Mirrors Python `remove_archived_sprint`.
220
+ */
221
+ export function removeArchivedSprint(
222
+ featuresData: FeaturesData,
223
+ sprintId: string,
224
+ now: Date = new Date(),
225
+ ): FeaturesData {
226
+ const sprints = (featuresData.sprints ?? []) as Sprint[];
227
+ featuresData.sprints = sprints.filter((s) => s.id !== sprintId);
228
+ const metadata = (featuresData.metadata ?? {}) as JsonObject;
229
+ metadata.last_updated = formatLocalDate(now);
230
+ featuresData.metadata = metadata;
231
+ return featuresData;
232
+ }
233
+
234
+ /**
235
+ * Read the default progress.md template from `shared/assets/progress.md`.
236
+ *
237
+ * Mirrors Python `get_progress_template`. Throws when the template is missing.
238
+ */
239
+ export async function getProgressTemplate(
240
+ pluginRootPath: string = defaultPluginRoot(),
241
+ ): Promise<string> {
242
+ const templatePath = join(pluginRootPath, "shared", "assets", "progress.md");
243
+ if (!existsSync(templatePath)) {
244
+ throw new Error(`Progress template not found: ${templatePath}`);
245
+ }
246
+ return readFile(templatePath, "utf8");
247
+ }
248
+
249
+ /** Reset progress.md to the default template. Mirrors Python `reset_progress_md`. */
250
+ export async function resetProgressMd(
251
+ progressPath: string,
252
+ pluginRootPath: string = defaultPluginRoot(),
253
+ ): Promise<void> {
254
+ const template = await getProgressTemplate(pluginRootPath);
255
+ await writeFile(progressPath, template, "utf8");
256
+ }
257
+
258
+ /**
259
+ * Remove sessions belonging to any of the given sprint IDs from progress.md.
260
+ *
261
+ * Mirrors Python `remove_sprint_sessions`: keeps entries that don't match any
262
+ * of the archived sprint IDs; preserves the pre-`## ` header verbatim and
263
+ * inserts a `\n\n` separator between header and remaining entries.
264
+ */
265
+ export async function removeSprintSessions(
266
+ progressPath: string,
267
+ sprintIds: string[],
268
+ ): Promise<void> {
269
+ const content = await readFile(progressPath, "utf8");
270
+ const entries = splitEntries(content);
271
+ // Everything before the first H2 heading is the header.
272
+ const parts = content.split(/^## /m);
273
+ const header = parts[0];
274
+
275
+ const remaining: string[] = [];
276
+ for (const entry of entries) {
277
+ if (!sprintIds.some((sid) => entryMatchesSprint(entry, sid))) {
278
+ remaining.push(entry);
279
+ }
280
+ }
281
+
282
+ let next = header;
283
+ if (remaining.length > 0) {
284
+ if (!header.endsWith("\n\n")) {
285
+ next += "\n\n";
286
+ }
287
+ next += remaining.join("\n\n");
288
+ }
289
+ await writeFile(progressPath, next, "utf8");
290
+ }
291
+
292
+ /** Options accepted by `archiveSprints`. */
293
+ export interface ArchiveOptions {
294
+ projectDir: string;
295
+ /** When true, no files are modified; the result lists what would be archived. */
296
+ dryRun?: boolean;
297
+ /** When true, archive all sprints regardless of status. */
298
+ force?: boolean;
299
+ /**
300
+ * Override the plugin root used to locate the progress.md template.
301
+ * Defaults to the plugin root resolved from `import.meta.dir`.
302
+ */
303
+ pluginRootPath?: string;
304
+ }
305
+
306
+ /**
307
+ * Archive completed sprints (or all sprints when `force` is true).
308
+ *
309
+ * Mirrors Python `archive_completed_sprints`. Returns the list of archived
310
+ * sprint info records. When `dryRun` is true, no files are touched and each
311
+ * record carries `dry_run: true` instead of `archive_path`.
312
+ */
313
+ export async function archiveSprints(
314
+ options: ArchiveOptions,
315
+ ): Promise<ArchivedSprintInfo[]> {
316
+ const projectDir = pyResolve(options.projectDir);
317
+ const featuresPath = join(projectDir, GHS_DIR, "features.json");
318
+ const progressPath = join(projectDir, GHS_DIR, "progress.md");
319
+ const dryRun = options.dryRun === true;
320
+ const force = options.force === true;
321
+ const pluginRootPath = options.pluginRootPath ?? defaultPluginRoot();
322
+
323
+ if (!existsSync(featuresPath)) {
324
+ return [];
325
+ }
326
+
327
+ const featuresText = await readFile(featuresPath, "utf8");
328
+ let featuresData = JSON.parse(featuresText) as FeaturesData;
329
+
330
+ let sprintsToArchive: Sprint[];
331
+ if (force) {
332
+ sprintsToArchive = getAllSprints(featuresData);
333
+ } else {
334
+ sprintsToArchive = getCompletedSprints(featuresData);
335
+ }
336
+
337
+ if (sprintsToArchive.length === 0) {
338
+ return [];
339
+ }
340
+
341
+ const archivedPath = await createArchiveStructure(projectDir);
342
+ const archivedInfo: ArchivedSprintInfo[] = [];
343
+
344
+ for (const sprint of sprintsToArchive) {
345
+ const sprintId = (sprint.id as string | undefined) ?? "unknown";
346
+ const sprintName = (sprint.name as string | undefined) ?? "unknown";
347
+ const sprintStatus = (sprint.status as string | undefined) ?? "unknown";
348
+
349
+ if (!dryRun) {
350
+ const [featPath] = await archiveSprintFiles(
351
+ sprint,
352
+ featuresData,
353
+ projectDir,
354
+ archivedPath,
355
+ );
356
+ featuresData = removeArchivedSprint(featuresData, sprintId);
357
+
358
+ archivedInfo.push({
359
+ sprint_id: sprintId,
360
+ sprint_name: sprintName,
361
+ sprint_status: sprintStatus,
362
+ archive_path: dirname(featPath),
363
+ });
364
+ } else {
365
+ archivedInfo.push({
366
+ sprint_id: sprintId,
367
+ sprint_name: sprintName,
368
+ sprint_status: sprintStatus,
369
+ dry_run: true,
370
+ });
371
+ }
372
+ }
373
+
374
+ if (!dryRun && archivedInfo.length > 0) {
375
+ await writeFile(featuresPath, JSON.stringify(featuresData, null, 2), "utf8");
376
+
377
+ const remainingSprints = (featuresData.sprints ?? []) as Sprint[];
378
+ if (remainingSprints.length === 0) {
379
+ await resetProgressMd(progressPath, pluginRootPath);
380
+ } else {
381
+ const archivedSprintIds = archivedInfo.map((info) => info.sprint_id);
382
+ await removeSprintSessions(progressPath, archivedSprintIds);
383
+ }
384
+ }
385
+
386
+ return archivedInfo;
387
+ }
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // CLI-friendly formatters. The Python script prints a lot of human-facing
391
+ // status lines; the tool layer (s1-feat-009) renders them via these helpers
392
+ // to keep byte-identical stdout.
393
+
394
+ /** "Project directory: <dir>" header used at the top of every archive run. */
395
+ export function formatProjectHeader(projectDir: string): string {
396
+ return `Project directory: ${projectDir}`;
397
+ }
398
+
399
+ /** "Force archiving ALL <N> sprint(s)" line. */
400
+ export function formatForceArchivingAll(count: number): string {
401
+ return `Force archiving ALL ${count} sprint(s)`;
402
+ }
403
+
404
+ /** Per-sprint "Archiving sprint: <name> (<id>)" + status block. */
405
+ export function formatArchivingSprint(sprint: Sprint): string {
406
+ const name = (sprint.name as string | undefined) ?? "unknown";
407
+ const id = (sprint.id as string | undefined) ?? "unknown";
408
+ const status = (sprint.status as string | undefined) ?? "unknown";
409
+ return [`Archiving sprint: ${name} (${id})`, ` Status: ${status}`].join("\n");
410
+ }
411
+
412
+ /**
413
+ * Render the full archive report — byte-identical to what Python's `main()`
414
+ * prints to stdout for a given set of inputs.
415
+ *
416
+ * This is a pure function over structured inputs; it does NOT touch the
417
+ * filesystem. The caller is expected to have invoked `archiveSprints` (or
418
+ * `listSprints`) and pass the results here.
419
+ */
420
+ export function formatArchiveReport(args: {
421
+ projectDir: string;
422
+ mode: "archive" | "dry-run";
423
+ force: boolean;
424
+ sprintsConsidered: Sprint[];
425
+ archived: ArchivedSprintInfo[];
426
+ remainingCount: number;
427
+ resetProgress: boolean;
428
+ }): string {
429
+ const lines: string[] = [];
430
+ lines.push("=== Sprint Archiver ===");
431
+ lines.push("");
432
+ lines.push(formatProjectHeader(args.projectDir));
433
+ lines.push("");
434
+
435
+ if (args.force && args.sprintsConsidered.length > 0) {
436
+ lines.push(formatForceArchivingAll(args.sprintsConsidered.length));
437
+ lines.push("");
438
+ } else if (args.sprintsConsidered.length === 0) {
439
+ lines.push(args.force ? "No sprints found to archive." : "No completed sprints to archive.");
440
+ return lines.join("\n") + "\n";
441
+ }
442
+
443
+ const archivedPath = join(args.projectDir, ARCHIVED_DIR);
444
+ for (const info of args.archived) {
445
+ lines.push(`Archiving sprint: ${info.sprint_name} (${info.sprint_id})`);
446
+ lines.push(` Status: ${info.sprint_status}`);
447
+ if (info.dry_run) {
448
+ lines.push(` [DRY RUN] Would archive to: ${archivedPath}/${info.sprint_id}_...`);
449
+ } else if (info.archive_path) {
450
+ lines.push(` Created: ${info.archive_path}`);
451
+ }
452
+ }
453
+
454
+ if (args.archived.length === 0) {
455
+ return lines.join("\n") + "\n";
456
+ }
457
+
458
+ if (args.mode === "archive") {
459
+ lines.push("");
460
+ lines.push(
461
+ `Updated features.json - removed ${args.archived.length} archived sprint(s)`,
462
+ );
463
+ if (args.resetProgress) {
464
+ lines.push("Reset progress.md to default template");
465
+ } else {
466
+ lines.push(
467
+ `Removed ${args.archived.length} archived sprint session(s) from progress.md ` +
468
+ `(${args.remainingCount} sprint(s) remaining)`,
469
+ );
470
+ }
471
+ }
472
+
473
+ lines.push("");
474
+ lines.push(`Archived ${args.archived.length} sprint(s)`);
475
+ return lines.join("\n") + "\n";
476
+ }
477
+
478
+ /**
479
+ * List sprints without archiving. Returns the rendered text identical to
480
+ * Python's `--list` branch.
481
+ */
482
+ export function formatListReport(args: {
483
+ projectDir: string;
484
+ force: boolean;
485
+ sprints: Sprint[];
486
+ }): string {
487
+ const lines: string[] = [];
488
+ lines.push("=== Sprint Archiver ===");
489
+ lines.push("");
490
+ lines.push(formatProjectHeader(args.projectDir));
491
+ lines.push("");
492
+
493
+ if (args.sprints.length === 0) {
494
+ lines.push(args.force ? "No sprints found." : "No completed sprints found.");
495
+ return lines.join("\n") + "\n";
496
+ }
497
+
498
+ lines.push(args.force ? "All sprints:" : "Completed sprints:");
499
+ lines.push("");
500
+
501
+ for (const sprint of args.sprints) {
502
+ const features = (sprint.features ?? []) as Feature[];
503
+ const completedFeatures = features.filter((f) => f.status === "completed").length;
504
+ const status = (sprint.status as string | undefined) ?? "unknown";
505
+ lines.push(
506
+ ` - ${(sprint.name as string | undefined) ?? "unknown"} (${sprint.id ?? ""}) [${status}]`,
507
+ );
508
+ lines.push(` Features: ${completedFeatures}/${features.length} completed`);
509
+ lines.push(` Goal: ${(sprint.goal as string | undefined) ?? "No goal defined"}`);
510
+ lines.push("");
511
+ }
512
+
513
+ return lines.join("\n") + "\n";
514
+ }
515
+
516
+ // ---------------------------------------------------------------------------
517
+ // Datetime helpers. Python uses naive local time throughout.
518
+
519
+ /** Format like Python's `datetime.now().strftime("%Y%m%d_%H%M%S")`. */
520
+ export function formatTimestamp(now: Date = new Date()): string {
521
+ const y = now.getFullYear();
522
+ const mo = String(now.getMonth() + 1).padStart(2, "0");
523
+ const d = String(now.getDate()).padStart(2, "0");
524
+ const h = String(now.getHours()).padStart(2, "0");
525
+ const mi = String(now.getMinutes()).padStart(2, "0");
526
+ const s = String(now.getSeconds()).padStart(2, "0");
527
+ return `${y}${mo}${d}_${h}${mi}${s}`;
528
+ }
529
+
530
+ /** Format like Python's `datetime.now().strftime("%Y-%m-%d %H:%M:%S")`. */
531
+ export function formatArchiveDate(now: Date = new Date()): string {
532
+ const y = now.getFullYear();
533
+ const mo = String(now.getMonth() + 1).padStart(2, "0");
534
+ const d = String(now.getDate()).padStart(2, "0");
535
+ const h = String(now.getHours()).padStart(2, "0");
536
+ const mi = String(now.getMinutes()).padStart(2, "0");
537
+ const s = String(now.getSeconds()).padStart(2, "0");
538
+ return `${y}-${mo}-${d} ${h}:${mi}:${s}`;
539
+ }
540
+
541
+ /** Format like Python's `datetime.now().strftime("%Y-%m-%d")`. */
542
+ export function formatLocalDate(now: Date = new Date()): string {
543
+ const y = now.getFullYear();
544
+ const mo = String(now.getMonth() + 1).padStart(2, "0");
545
+ const d = String(now.getDate()).padStart(2, "0");
546
+ return `${y}-${mo}-${d}`;
547
+ }
548
+
549
+ /**
550
+ * Resolve the plugin root. Local copy kept self-contained (s1-feat-008 has no
551
+ * dependency on s1-feat-006).
552
+ */
553
+ function defaultPluginRoot(): string {
554
+ return resolve(import.meta.dir, "..", "..", "..");
555
+ }
556
+
557
+ /**
558
+ * Resolve a path the way Python's `pathlib.Path.resolve(strict=False)` does:
559
+ * apply `realpathSync` to the longest existing prefix, then append the
560
+ * remaining components verbatim. Necessary on macOS where `/tmp` is a
561
+ * symlink to `/private/tmp`. See resolve-project-dir.ts for the same helper.
562
+ */
563
+ function pyResolve(p: string): string {
564
+ const absolute = resolve(p);
565
+ let existing = absolute;
566
+ while (existing !== parentOf(existing) && !existsSync(existing)) {
567
+ existing = parentOf(existing);
568
+ }
569
+ if (!existsSync(existing)) {
570
+ return absolute;
571
+ }
572
+ const real = realpathSync(existing);
573
+ if (existing === absolute) {
574
+ return real;
575
+ }
576
+ return real + absolute.slice(existing.length);
577
+ }
578
+
579
+ /** Parent directory of `dir`, or `dir` itself at the filesystem root. */
580
+ function parentOf(dir: string): string {
581
+ const parent = resolve(dir, "..");
582
+ return parent === dir ? dir : parent;
583
+ }