work-kit-cli 0.2.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 (76) hide show
  1. package/README.md +147 -0
  2. package/cli/bin/work-kit.mjs +21 -0
  3. package/cli/src/commands/complete.ts +163 -0
  4. package/cli/src/commands/completions.ts +137 -0
  5. package/cli/src/commands/context.ts +41 -0
  6. package/cli/src/commands/doctor.ts +79 -0
  7. package/cli/src/commands/init.test.ts +116 -0
  8. package/cli/src/commands/init.ts +184 -0
  9. package/cli/src/commands/loopback.ts +64 -0
  10. package/cli/src/commands/next.ts +172 -0
  11. package/cli/src/commands/observe.ts +144 -0
  12. package/cli/src/commands/setup.ts +159 -0
  13. package/cli/src/commands/status.ts +50 -0
  14. package/cli/src/commands/uninstall.ts +89 -0
  15. package/cli/src/commands/upgrade.ts +12 -0
  16. package/cli/src/commands/validate.ts +34 -0
  17. package/cli/src/commands/workflow.ts +125 -0
  18. package/cli/src/config/agent-map.ts +62 -0
  19. package/cli/src/config/loopback-routes.ts +45 -0
  20. package/cli/src/config/phases.ts +119 -0
  21. package/cli/src/context/extractor.test.ts +77 -0
  22. package/cli/src/context/extractor.ts +73 -0
  23. package/cli/src/context/prompt-builder.ts +70 -0
  24. package/cli/src/engine/loopbacks.test.ts +33 -0
  25. package/cli/src/engine/loopbacks.ts +32 -0
  26. package/cli/src/engine/parallel.ts +60 -0
  27. package/cli/src/engine/phases.ts +23 -0
  28. package/cli/src/engine/transitions.test.ts +117 -0
  29. package/cli/src/engine/transitions.ts +97 -0
  30. package/cli/src/index.ts +253 -0
  31. package/cli/src/observer/data.ts +267 -0
  32. package/cli/src/observer/renderer.ts +364 -0
  33. package/cli/src/observer/watcher.ts +104 -0
  34. package/cli/src/state/helpers.test.ts +91 -0
  35. package/cli/src/state/helpers.ts +65 -0
  36. package/cli/src/state/schema.ts +113 -0
  37. package/cli/src/state/store.ts +82 -0
  38. package/cli/src/state/validators.test.ts +105 -0
  39. package/cli/src/state/validators.ts +81 -0
  40. package/cli/src/utils/colors.ts +12 -0
  41. package/package.json +50 -0
  42. package/skills/auto-kit/SKILL.md +216 -0
  43. package/skills/build/SKILL.md +88 -0
  44. package/skills/build/stages/commit.md +43 -0
  45. package/skills/build/stages/core.md +48 -0
  46. package/skills/build/stages/integration.md +44 -0
  47. package/skills/build/stages/migration.md +41 -0
  48. package/skills/build/stages/red.md +44 -0
  49. package/skills/build/stages/refactor.md +48 -0
  50. package/skills/build/stages/setup.md +42 -0
  51. package/skills/build/stages/ui.md +51 -0
  52. package/skills/deploy/SKILL.md +62 -0
  53. package/skills/deploy/stages/merge.md +59 -0
  54. package/skills/deploy/stages/monitor.md +39 -0
  55. package/skills/deploy/stages/remediate.md +54 -0
  56. package/skills/full-kit/SKILL.md +197 -0
  57. package/skills/plan/SKILL.md +77 -0
  58. package/skills/plan/stages/architecture.md +53 -0
  59. package/skills/plan/stages/audit.md +58 -0
  60. package/skills/plan/stages/blueprint.md +60 -0
  61. package/skills/plan/stages/clarify.md +61 -0
  62. package/skills/plan/stages/investigate.md +47 -0
  63. package/skills/plan/stages/scope.md +46 -0
  64. package/skills/plan/stages/sketch.md +44 -0
  65. package/skills/plan/stages/ux-flow.md +49 -0
  66. package/skills/review/SKILL.md +104 -0
  67. package/skills/review/stages/compliance.md +48 -0
  68. package/skills/review/stages/handoff.md +59 -0
  69. package/skills/review/stages/performance.md +45 -0
  70. package/skills/review/stages/security.md +49 -0
  71. package/skills/review/stages/self-review.md +41 -0
  72. package/skills/test/SKILL.md +83 -0
  73. package/skills/test/stages/e2e.md +44 -0
  74. package/skills/test/stages/validate.md +51 -0
  75. package/skills/test/stages/verify.md +41 -0
  76. package/skills/wrap-up/SKILL.md +81 -0
@@ -0,0 +1,364 @@
1
+ import { bold, dim, green, yellow, red, cyan, bgYellow } from "../utils/colors.js";
2
+ import type { DashboardData, WorkItemView, CompletedItemView } from "./data.js";
3
+
4
+ // ── Time Formatting ─────────────────────────────────────────────────
5
+
6
+ function formatTimeAgo(dateStr: string): string {
7
+ const now = Date.now();
8
+ const then = new Date(dateStr).getTime();
9
+ if (isNaN(then)) return "unknown";
10
+
11
+ // If only a date (no time component), show the date string as-is
12
+ // to avoid misleading hour-level precision
13
+ const isDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(dateStr);
14
+
15
+ const diffMs = now - then;
16
+ const minutes = Math.floor(diffMs / 60000);
17
+ const hours = Math.floor(diffMs / 3600000);
18
+ const days = Math.floor(diffMs / 86400000);
19
+ const weeks = Math.floor(days / 7);
20
+
21
+ if (isDateOnly) {
22
+ if (days < 1) return "today";
23
+ if (days === 1) return "yesterday";
24
+ if (days < 7) return `${days}d ago`;
25
+ return `${weeks}w ago`;
26
+ }
27
+
28
+ if (minutes < 1) return "just now";
29
+ if (minutes < 60) return `${minutes}m ago`;
30
+ if (hours < 24) {
31
+ const remainMin = minutes % 60;
32
+ return remainMin > 0 ? `${hours}h ${remainMin}m ago` : `${hours}h ago`;
33
+ }
34
+ if (days < 7) return `${days}d ago`;
35
+ return `${weeks}w ago`;
36
+ }
37
+
38
+ // ── Box Drawing ─────────────────────────────────────────────────────
39
+
40
+ function horizontalLine(width: number): string {
41
+ return "═".repeat(Math.max(0, width - 2));
42
+ }
43
+
44
+ function padRight(text: string, width: number): string {
45
+ // Strip ANSI codes for length calculation
46
+ const plainLen = stripAnsi(text).length;
47
+ const padding = Math.max(0, width - plainLen);
48
+ return text + " ".repeat(padding);
49
+ }
50
+
51
+ function stripAnsi(s: string): string {
52
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
53
+ }
54
+
55
+ function centerText(text: string, width: number): string {
56
+ const plainLen = stripAnsi(text).length;
57
+ const totalPad = Math.max(0, width - plainLen);
58
+ const leftPad = Math.floor(totalPad / 2);
59
+ const rightPad = totalPad - leftPad;
60
+ return " ".repeat(leftPad) + text + " ".repeat(rightPad);
61
+ }
62
+
63
+ function boxLine(content: string, innerWidth: number): string {
64
+ return `║ ${padRight(content, innerWidth)} ║`;
65
+ }
66
+
67
+ function emptyBoxLine(innerWidth: number): string {
68
+ return `║ ${" ".repeat(innerWidth)} ║`;
69
+ }
70
+
71
+ // ── Progress Bar ────────────────────────────────────────────────────
72
+
73
+ function renderProgressBar(
74
+ completed: number,
75
+ total: number,
76
+ percent: number,
77
+ label: string,
78
+ maxBarWidth: number
79
+ ): string {
80
+ const barWidth = Math.max(20, Math.min(40, maxBarWidth));
81
+ const filled = total > 0 ? Math.round((completed / total) * barWidth) : 0;
82
+ const empty = barWidth - filled;
83
+
84
+ const filledStr = green("█".repeat(filled));
85
+ const emptyStr = dim("░".repeat(empty));
86
+ const stats = `${label} ${completed}/${total} ${percent}%`;
87
+
88
+ return `${filledStr}${emptyStr} ${stats}`;
89
+ }
90
+
91
+ // ── Phase Status Indicators ─────────────────────────────────────────
92
+
93
+ function phaseIndicator(status: string): string {
94
+ switch (status) {
95
+ case "completed": return green("✓");
96
+ case "in-progress": return cyan("▶");
97
+ case "pending": return dim("·");
98
+ case "skipped": return dim("⊘");
99
+ case "failed": return red("✗");
100
+ default: return dim("·");
101
+ }
102
+ }
103
+
104
+ function statusDot(status: string): string {
105
+ switch (status) {
106
+ case "in-progress": return green("●");
107
+ case "paused": return yellow("○");
108
+ case "completed": return green("✓");
109
+ case "failed": return red("✗");
110
+ default: return dim("·");
111
+ }
112
+ }
113
+
114
+ // ── Render Work Item ────────────────────────────────────────────────
115
+
116
+ function formatMode(mode: string, classification?: string): string {
117
+ const label = mode === "full-kit" ? "Full Kit" : "Auto Kit";
118
+ return classification ? `${label} · ${classification}` : label;
119
+ }
120
+
121
+ function renderWorkItem(item: WorkItemView, innerWidth: number): string[] {
122
+ const lines: string[] = [];
123
+
124
+ // Line 1: slug + branch (right-aligned)
125
+ const slugText = `${statusDot(item.status)} ${bold(item.slug)}`;
126
+ const branchText = dim(item.branch);
127
+ const slugPlainLen = stripAnsi(slugText).length;
128
+ const branchPlainLen = stripAnsi(branchText).length;
129
+ const gap1 = Math.max(2, innerWidth - slugPlainLen - branchPlainLen);
130
+ lines.push(slugText + " ".repeat(gap1) + branchText);
131
+
132
+ // Line 2: mode + timing (right-aligned)
133
+ const modeText = formatMode(item.mode, item.classification);
134
+ const pausedBadge = item.status === "paused" ? " " + bgYellow(" PAUSED ") : "";
135
+ const elapsed = formatTimeAgo(item.startedAt);
136
+ let timingRight = `Elapsed: ${elapsed}`;
137
+ if (item.currentPhaseStartedAt) {
138
+ timingRight += ` Phase: ${formatTimeAgo(item.currentPhaseStartedAt)}`;
139
+ }
140
+ const timingText = dim(timingRight);
141
+ const modeStr = ` ${modeText}${pausedBadge}`;
142
+ const modePlainLen = stripAnsi(modeStr).length;
143
+ const timingPlainLen = stripAnsi(timingText).length;
144
+ const gap2 = Math.max(2, innerWidth - modePlainLen - timingPlainLen);
145
+ lines.push(modeStr + " ".repeat(gap2) + timingText);
146
+
147
+ // Line 3: progress bar with phase label + substage position
148
+ let phaseLabel = "—";
149
+ if (item.currentPhase) {
150
+ phaseLabel = item.currentSubStage
151
+ ? `${item.currentPhase}/${item.currentSubStage}`
152
+ : item.currentPhase;
153
+ if (item.currentSubStageIndex != null && item.currentPhaseTotal != null) {
154
+ phaseLabel += ` (${item.currentSubStageIndex}/${item.currentPhaseTotal})`;
155
+ }
156
+ }
157
+ const barMaxWidth = Math.max(20, Math.min(40, innerWidth - 30));
158
+ lines.push(" " + renderProgressBar(
159
+ item.progress.completed,
160
+ item.progress.total,
161
+ item.progress.percent,
162
+ phaseLabel,
163
+ barMaxWidth
164
+ ));
165
+
166
+ // Line 4: phase indicators
167
+ const phaseStrs = item.phases.map(p => `${p.name} ${phaseIndicator(p.status)}`);
168
+ lines.push(" " + phaseStrs.join(" "));
169
+
170
+ // Line 5 (optional): loopbacks
171
+ if (item.loopbacks.count > 0) {
172
+ const lb = item.loopbacks;
173
+ let loopStr = ` ${cyan("⟳")} ${lb.count} loopback${lb.count > 1 ? "s" : ""}`;
174
+ if (lb.lastFrom && lb.lastTo) {
175
+ loopStr += `: ${lb.lastFrom} → ${lb.lastTo}`;
176
+ }
177
+ if (lb.lastReason) {
178
+ loopStr += ` (${lb.lastReason})`;
179
+ }
180
+ lines.push(loopStr);
181
+ }
182
+
183
+ return lines;
184
+ }
185
+
186
+ // ── Render Completed Item ───────────────────────────────────────────
187
+
188
+ interface CompletedColumnWidths {
189
+ slug: number;
190
+ pr: number;
191
+ date: number;
192
+ }
193
+
194
+ function computeCompletedWidths(items: CompletedItemView[]): CompletedColumnWidths {
195
+ let slug = 4, pr = 2, date = 4; // minimums
196
+ for (const item of items) {
197
+ slug = Math.max(slug, item.slug.length);
198
+ pr = Math.max(pr, (item.pr || "—").length);
199
+ date = Math.max(date, (item.completedAt || "").length);
200
+ }
201
+ return { slug, pr, date };
202
+ }
203
+
204
+ function renderCompletedItem(item: CompletedItemView, cols: CompletedColumnWidths): string {
205
+ const check = green("✓");
206
+ const slug = padRight(item.slug, cols.slug);
207
+ const pr = padRight(dim(item.pr || "—"), cols.pr);
208
+ const date = padRight(dim(item.completedAt || ""), cols.date);
209
+ const phases = item.phases ? dim(item.phases) : "";
210
+ return `${check} ${slug} ${pr} ${date} ${phases}`;
211
+ }
212
+
213
+ // ── Main Render Function ────────────────────────────────────────────
214
+
215
+ export function renderDashboard(
216
+ data: DashboardData,
217
+ width: number,
218
+ height: number,
219
+ scrollOffset: number = 0
220
+ ): string {
221
+ const maxWidth = Math.min(width, 120);
222
+ const innerWidth = maxWidth - 4; // account for "║ " and " ║"
223
+
224
+ const allLines: string[] = [];
225
+
226
+ // Top border
227
+ allLines.push(`╔${horizontalLine(maxWidth)}╗`);
228
+
229
+ let activeCount = 0, pausedCount = 0, failedCount = 0;
230
+ for (const item of data.activeItems) {
231
+ if (item.status === "in-progress") activeCount++;
232
+ else if (item.status === "paused") pausedCount++;
233
+ else if (item.status === "failed") failedCount++;
234
+ }
235
+
236
+ let headerRight = "";
237
+ if (activeCount > 0) headerRight += `${green("●")} ${activeCount} active`;
238
+ if (pausedCount > 0) headerRight += ` ${yellow("○")} ${pausedCount} paused`;
239
+ if (failedCount > 0) headerRight += ` ${red("✗")} ${failedCount} failed`;
240
+
241
+ const headerLeft = bold(" WORK-KIT OBSERVER");
242
+ const headerLeftLen = stripAnsi(headerLeft).length;
243
+ const headerRightLen = stripAnsi(headerRight).length;
244
+ const headerGap = Math.max(2, innerWidth - headerLeftLen - headerRightLen);
245
+ allLines.push(boxLine(headerLeft + " ".repeat(headerGap) + headerRight, innerWidth));
246
+
247
+ // Separator
248
+ allLines.push(`╠${horizontalLine(maxWidth)}╣`);
249
+
250
+ if (data.activeItems.length === 0 && data.completedItems.length === 0) {
251
+ // Empty state
252
+ allLines.push(emptyBoxLine(innerWidth));
253
+ allLines.push(boxLine(dim(" No active work items found."), innerWidth));
254
+ allLines.push(boxLine(dim(" Start a new work item with: work-kit init"), innerWidth));
255
+ allLines.push(emptyBoxLine(innerWidth));
256
+ } else {
257
+ // Active items
258
+ if (data.activeItems.length > 0) {
259
+ allLines.push(emptyBoxLine(innerWidth));
260
+
261
+ for (let i = 0; i < data.activeItems.length; i++) {
262
+ const item = data.activeItems[i];
263
+ const itemLines = renderWorkItem(item, innerWidth);
264
+ for (const line of itemLines) {
265
+ allLines.push(boxLine(line, innerWidth));
266
+ }
267
+ if (i < data.activeItems.length - 1) {
268
+ allLines.push(emptyBoxLine(innerWidth));
269
+ }
270
+ }
271
+
272
+ allLines.push(emptyBoxLine(innerWidth));
273
+ }
274
+
275
+ // Completed section
276
+ if (data.completedItems.length > 0) {
277
+ allLines.push(`╠${horizontalLine(maxWidth)}╣`);
278
+ allLines.push(boxLine(bold(" COMPLETED"), innerWidth));
279
+
280
+ const maxCompleted = 5;
281
+ const displayed = data.completedItems.slice(0, maxCompleted);
282
+ const cols = computeCompletedWidths(displayed);
283
+ for (const item of displayed) {
284
+ const content = renderCompletedItem(item, cols);
285
+ allLines.push(boxLine(" " + content, innerWidth));
286
+ }
287
+ if (data.completedItems.length > maxCompleted) {
288
+ allLines.push(boxLine(
289
+ dim(` ... and ${data.completedItems.length - maxCompleted} more`),
290
+ innerWidth
291
+ ));
292
+ }
293
+ }
294
+ }
295
+
296
+ // Footer separator
297
+ allLines.push(`╠${horizontalLine(maxWidth)}╣`);
298
+
299
+ // Footer
300
+ const footerLeft = ` ${dim("q")} quit ${dim("↑↓")} scroll ${dim("r")} refresh`;
301
+ const timeStr = data.lastUpdated.toLocaleTimeString("en-US", {
302
+ hour: "2-digit",
303
+ minute: "2-digit",
304
+ hour12: false,
305
+ });
306
+ const footerRight = dim(`Updated: ${timeStr}`);
307
+ const footerLeftLen = stripAnsi(footerLeft).length;
308
+ const footerRightLen = stripAnsi(footerRight).length;
309
+ const footerGap = Math.max(2, innerWidth - footerLeftLen - footerRightLen);
310
+ allLines.push(boxLine(footerLeft + " ".repeat(footerGap) + footerRight, innerWidth));
311
+
312
+ // Bottom border
313
+ allLines.push(`╚${horizontalLine(maxWidth)}╝`);
314
+
315
+ // Apply scrolling: figure out how many content lines we have vs available height
316
+ const totalLines = allLines.length;
317
+ const availableHeight = height;
318
+
319
+ if (totalLines <= availableHeight) {
320
+ // Everything fits, no scrolling needed
321
+ return allLines.join("\n") + "\n";
322
+ }
323
+
324
+ // Apply scroll offset
325
+ const maxScroll = Math.max(0, totalLines - availableHeight);
326
+ const clampedOffset = Math.min(scrollOffset, maxScroll);
327
+ const visibleLines = allLines.slice(clampedOffset, clampedOffset + availableHeight);
328
+
329
+ // Add scroll indicator if not showing everything
330
+ if (clampedOffset > 0 || clampedOffset + availableHeight < totalLines) {
331
+ const scrollPct = Math.round((clampedOffset / maxScroll) * 100);
332
+ const indicator = dim(` [${scrollPct}% scrolled]`);
333
+ if (visibleLines.length > 0) {
334
+ visibleLines[visibleLines.length - 1] = visibleLines[visibleLines.length - 1] + indicator;
335
+ }
336
+ }
337
+
338
+ return visibleLines.join("\n") + "\n";
339
+ }
340
+
341
+ // ── Terminal Control ────────────────────────────────────────────────
342
+
343
+ export function enterAlternateScreen(): void {
344
+ process.stdout.write("\x1b[?1049h"); // enter alternate screen
345
+ process.stdout.write("\x1b[?25l"); // hide cursor
346
+ }
347
+
348
+ export function exitAlternateScreen(): void {
349
+ process.stdout.write("\x1b[?25h"); // show cursor
350
+ process.stdout.write("\x1b[?1049l"); // exit alternate screen
351
+ }
352
+
353
+ export function clearAndHome(): string {
354
+ return "\x1b[H\x1b[2J"; // move to top-left + clear screen
355
+ }
356
+
357
+ export function moveCursorHome(): string {
358
+ return "\x1b[H";
359
+ }
360
+
361
+ export function renderTooSmall(width: number, height: number): string {
362
+ const msg = `Terminal too small (${width}x${height}). Need at least 60x10.`;
363
+ return clearAndHome() + msg + "\n";
364
+ }
@@ -0,0 +1,104 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { discoverWorktrees } from "./data.js";
4
+
5
+ export interface WatcherHandle {
6
+ stop: () => void;
7
+ getWorktrees: () => string[];
8
+ }
9
+
10
+ export function startWatching(
11
+ mainRepoRoot: string,
12
+ onUpdate: () => void
13
+ ): WatcherHandle {
14
+ const watchers = new Map<string, fs.FSWatcher>();
15
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
16
+ let pollTimer: ReturnType<typeof setInterval> | null = null;
17
+ let stopped = false;
18
+ let cachedWorktrees: string[] = [];
19
+
20
+ function debouncedUpdate(): void {
21
+ if (stopped) return;
22
+ if (debounceTimer) clearTimeout(debounceTimer);
23
+ debounceTimer = setTimeout(() => {
24
+ if (!stopped) onUpdate();
25
+ }, 50);
26
+ }
27
+
28
+ function watchStateFile(worktreeRoot: string): void {
29
+ if (watchers.has(worktreeRoot)) return;
30
+ const stateDir = path.join(worktreeRoot, ".work-kit");
31
+ if (!fs.existsSync(stateDir)) return;
32
+
33
+ try {
34
+ // Watch the directory, not the file — writeState uses atomic
35
+ // rename (write tmp + rename), which replaces the inode and
36
+ // breaks fs.watch on the file on Linux.
37
+ const watcher = fs.watch(stateDir, { persistent: false }, (_event, filename) => {
38
+ if (filename === "state.json") {
39
+ debouncedUpdate();
40
+ }
41
+ });
42
+ watcher.on("error", () => {
43
+ watcher.close();
44
+ watchers.delete(worktreeRoot);
45
+ });
46
+ watchers.set(worktreeRoot, watcher);
47
+ } catch {
48
+ // Directory might not exist yet
49
+ }
50
+ }
51
+
52
+ function unwatchRemoved(currentSet: Set<string>): void {
53
+ for (const [wt, watcher] of watchers) {
54
+ if (!currentSet.has(wt)) {
55
+ watcher.close();
56
+ watchers.delete(wt);
57
+ }
58
+ }
59
+ }
60
+
61
+ function refreshWorktrees(): void {
62
+ if (stopped) return;
63
+ const current = discoverWorktrees(mainRepoRoot);
64
+ const currentSet = new Set(current);
65
+
66
+ // Only trigger update if worktree list actually changed
67
+ const changed = current.length !== cachedWorktrees.length
68
+ || current.some((wt, i) => wt !== cachedWorktrees[i]);
69
+
70
+ for (const wt of current) {
71
+ watchStateFile(wt);
72
+ }
73
+ unwatchRemoved(currentSet);
74
+
75
+ cachedWorktrees = current;
76
+
77
+ if (changed) {
78
+ debouncedUpdate();
79
+ }
80
+ }
81
+
82
+ // Initial setup
83
+ refreshWorktrees();
84
+
85
+ // Poll for new/removed worktrees every 5 seconds
86
+ pollTimer = setInterval(() => {
87
+ if (!stopped) refreshWorktrees();
88
+ }, 5000);
89
+
90
+ return {
91
+ stop() {
92
+ stopped = true;
93
+ if (debounceTimer) clearTimeout(debounceTimer);
94
+ if (pollTimer) clearInterval(pollTimer);
95
+ for (const watcher of watchers.values()) {
96
+ watcher.close();
97
+ }
98
+ watchers.clear();
99
+ },
100
+ getWorktrees() {
101
+ return cachedWorktrees;
102
+ },
103
+ };
104
+ }
@@ -0,0 +1,91 @@
1
+ import { describe, it } from "node:test";
2
+ import * as assert from "node:assert/strict";
3
+ import { parseLocation, resetToLocation } from "./helpers.js";
4
+ import type { WorkKitState, PhaseName, PhaseState, SubStageState } from "./schema.js";
5
+ import { PHASE_NAMES, SUBSTAGES_BY_PHASE } from "./schema.js";
6
+
7
+ function makeState(): WorkKitState {
8
+ const phases = {} as Record<PhaseName, PhaseState>;
9
+ for (const phase of PHASE_NAMES) {
10
+ const subStages: Record<string, SubStageState> = {};
11
+ for (const ss of SUBSTAGES_BY_PHASE[phase]) {
12
+ subStages[ss] = { status: "pending" };
13
+ }
14
+ phases[phase] = { status: "pending", subStages };
15
+ }
16
+ return {
17
+ version: 1,
18
+ slug: "test",
19
+ branch: "feature/test",
20
+ started: "2026-01-01",
21
+ mode: "full-kit",
22
+ status: "in-progress",
23
+ currentPhase: "plan",
24
+ currentSubStage: "clarify",
25
+ phases,
26
+ loopbacks: [],
27
+ metadata: { worktreeRoot: "/tmp/test", mainRepoRoot: "/tmp/test" },
28
+ };
29
+ }
30
+
31
+ describe("parseLocation", () => {
32
+ it("parses plan/clarify correctly", () => {
33
+ const loc = parseLocation("plan/clarify");
34
+ assert.deepStrictEqual(loc, { phase: "plan", subStage: "clarify" });
35
+ });
36
+
37
+ it("throws on invalid format (no slash)", () => {
38
+ assert.throws(() => parseLocation("invalid"), /Invalid location/);
39
+ });
40
+
41
+ it("throws on unknown phase", () => {
42
+ assert.throws(() => parseLocation("foobar/baz"), /Unknown phase/);
43
+ });
44
+
45
+ it("throws on unknown sub-stage", () => {
46
+ assert.throws(() => parseLocation("plan/nonexistent"), /Unknown sub-stage/);
47
+ });
48
+ });
49
+
50
+ describe("resetToLocation", () => {
51
+ it("resets target and later phases to pending", () => {
52
+ const state = makeState();
53
+
54
+ // Mark plan and build as completed
55
+ for (const ss of Object.values(state.phases.plan.subStages)) {
56
+ ss.status = "completed";
57
+ ss.completedAt = "2026-01-01";
58
+ }
59
+ state.phases.plan.status = "completed";
60
+ state.phases.plan.completedAt = "2026-01-01";
61
+
62
+ for (const ss of Object.values(state.phases.build.subStages)) {
63
+ ss.status = "completed";
64
+ ss.completedAt = "2026-01-02";
65
+ }
66
+ state.phases.build.status = "completed";
67
+ state.phases.build.completedAt = "2026-01-02";
68
+
69
+ // Reset to plan/blueprint
70
+ resetToLocation(state, { phase: "plan", subStage: "blueprint" });
71
+
72
+ // Sub-stages before blueprint should stay completed
73
+ assert.equal(state.phases.plan.subStages.clarify.status, "completed");
74
+ assert.equal(state.phases.plan.subStages.investigate.status, "completed");
75
+ assert.equal(state.phases.plan.subStages.sketch.status, "completed");
76
+ assert.equal(state.phases.plan.subStages.scope.status, "completed");
77
+ assert.equal(state.phases.plan.subStages["ux-flow"].status, "completed");
78
+ assert.equal(state.phases.plan.subStages.architecture.status, "completed");
79
+
80
+ // Blueprint and audit should be reset
81
+ assert.equal(state.phases.plan.subStages.blueprint.status, "pending");
82
+ assert.equal(state.phases.plan.subStages.audit.status, "pending");
83
+
84
+ // Plan phase should be in-progress
85
+ assert.equal(state.phases.plan.status, "in-progress");
86
+
87
+ // Build (later phase) should be reset
88
+ assert.equal(state.phases.build.status, "pending");
89
+ assert.equal(state.phases.build.subStages.core.status, "pending");
90
+ });
91
+ });
@@ -0,0 +1,65 @@
1
+ import { PHASE_NAMES, SUBSTAGES_BY_PHASE } from "./schema.js";
2
+ import type { Location, PhaseName, WorkKitState } from "./schema.js";
3
+ import { PHASE_ORDER } from "../config/phases.js";
4
+
5
+ /**
6
+ * Parse "phase/sub-stage" string into a Location object.
7
+ * Validates that the phase is a known phase name and the sub-stage exists.
8
+ */
9
+ export function parseLocation(input: string): Location {
10
+ const parts = input.split("/");
11
+ if (parts.length !== 2) {
12
+ throw new Error(`Invalid location "${input}". Expected format: phase/sub-stage (e.g., plan/clarify)`);
13
+ }
14
+ const [phase, subStage] = parts;
15
+ if (!PHASE_NAMES.includes(phase as PhaseName)) {
16
+ throw new Error(`Unknown phase "${phase}". Valid phases: ${PHASE_NAMES.join(", ")}`);
17
+ }
18
+ const validSubStages = SUBSTAGES_BY_PHASE[phase as PhaseName];
19
+ if (!validSubStages.includes(subStage)) {
20
+ throw new Error(`Unknown sub-stage "${subStage}" in phase "${phase}". Valid: ${validSubStages.join(", ")}`);
21
+ }
22
+ return { phase: phase as PhaseName, subStage };
23
+ }
24
+
25
+ /**
26
+ * Reset state from a target location forward: marks the target sub-stage
27
+ * and all subsequent sub-stages/phases as pending.
28
+ */
29
+ export function resetToLocation(state: WorkKitState, location: Location): void {
30
+ const targetPhaseState = state.phases[location.phase];
31
+ if (!targetPhaseState) {
32
+ throw new Error(`Phase "${location.phase}" not found in state`);
33
+ }
34
+ if (!targetPhaseState.subStages[location.subStage]) {
35
+ throw new Error(`Sub-stage "${location.subStage}" not found in phase "${location.phase}"`);
36
+ }
37
+
38
+ let reset = false;
39
+ for (const [ss, ssState] of Object.entries(targetPhaseState.subStages)) {
40
+ if (ss === location.subStage) reset = true;
41
+ if (reset && ssState.status === "completed") {
42
+ ssState.status = "pending";
43
+ delete ssState.completedAt;
44
+ delete ssState.outcome;
45
+ }
46
+ }
47
+ targetPhaseState.status = "in-progress";
48
+
49
+ const targetPhaseIdx = PHASE_ORDER.indexOf(location.phase);
50
+ for (let i = targetPhaseIdx + 1; i < PHASE_ORDER.length; i++) {
51
+ const laterPhase = PHASE_ORDER[i];
52
+ const laterPhaseState = state.phases[laterPhase];
53
+ if (laterPhaseState.status === "completed") {
54
+ laterPhaseState.status = "pending";
55
+ delete laterPhaseState.completedAt;
56
+ for (const ssState of Object.values(laterPhaseState.subStages)) {
57
+ if (ssState.status === "completed") {
58
+ ssState.status = "pending";
59
+ delete ssState.completedAt;
60
+ delete ssState.outcome;
61
+ }
62
+ }
63
+ }
64
+ }
65
+ }