@abdullahsahmad/work-kit 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 (76) hide show
  1. package/README.md +147 -0
  2. package/cli/bin/work-kit.mjs +18 -0
  3. package/cli/src/commands/complete.ts +123 -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 +248 -0
  31. package/cli/src/observer/data.ts +237 -0
  32. package/cli/src/observer/renderer.ts +316 -0
  33. package/cli/src/observer/watcher.ts +99 -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 +49 -0
  42. package/skills/auto-kit/SKILL.md +214 -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 +47 -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 +195 -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 +107 -0
@@ -0,0 +1,316 @@
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
+ const diffMs = now - then;
12
+ const minutes = Math.floor(diffMs / 60000);
13
+ const hours = Math.floor(diffMs / 3600000);
14
+ const days = Math.floor(diffMs / 86400000);
15
+ const weeks = Math.floor(days / 7);
16
+
17
+ if (minutes < 1) return "just now";
18
+ if (minutes < 60) return `${minutes}m ago`;
19
+ if (hours < 24) {
20
+ const remainMin = minutes % 60;
21
+ return remainMin > 0 ? `${hours}h ${remainMin}m ago` : `${hours}h ago`;
22
+ }
23
+ if (days < 7) return `${days}d ago`;
24
+ return `${weeks}w ago`;
25
+ }
26
+
27
+ // ── Box Drawing ─────────────────────────────────────────────────────
28
+
29
+ function horizontalLine(width: number): string {
30
+ return "═".repeat(Math.max(0, width - 2));
31
+ }
32
+
33
+ function padRight(text: string, width: number): string {
34
+ // Strip ANSI codes for length calculation
35
+ const plainLen = stripAnsi(text).length;
36
+ const padding = Math.max(0, width - plainLen);
37
+ return text + " ".repeat(padding);
38
+ }
39
+
40
+ function stripAnsi(s: string): string {
41
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
42
+ }
43
+
44
+ function centerText(text: string, width: number): string {
45
+ const plainLen = stripAnsi(text).length;
46
+ const totalPad = Math.max(0, width - plainLen);
47
+ const leftPad = Math.floor(totalPad / 2);
48
+ const rightPad = totalPad - leftPad;
49
+ return " ".repeat(leftPad) + text + " ".repeat(rightPad);
50
+ }
51
+
52
+ function boxLine(content: string, innerWidth: number): string {
53
+ return `║ ${padRight(content, innerWidth)} ║`;
54
+ }
55
+
56
+ function emptyBoxLine(innerWidth: number): string {
57
+ return `║ ${" ".repeat(innerWidth)} ║`;
58
+ }
59
+
60
+ // ── Progress Bar ────────────────────────────────────────────────────
61
+
62
+ function renderProgressBar(
63
+ completed: number,
64
+ total: number,
65
+ percent: number,
66
+ label: string,
67
+ maxBarWidth: number
68
+ ): string {
69
+ const barWidth = Math.max(20, Math.min(40, maxBarWidth));
70
+ const filled = total > 0 ? Math.round((completed / total) * barWidth) : 0;
71
+ const empty = barWidth - filled;
72
+
73
+ const filledStr = green("█".repeat(filled));
74
+ const emptyStr = dim("░".repeat(empty));
75
+ const stats = `${label} ${completed}/${total} ${percent}%`;
76
+
77
+ return `${filledStr}${emptyStr} ${stats}`;
78
+ }
79
+
80
+ // ── Phase Status Indicators ─────────────────────────────────────────
81
+
82
+ function phaseIndicator(status: string): string {
83
+ switch (status) {
84
+ case "completed": return green("✓");
85
+ case "in-progress": return cyan("▶");
86
+ case "pending": return dim("·");
87
+ case "skipped": return dim("⊘");
88
+ case "failed": return red("✗");
89
+ default: return dim("·");
90
+ }
91
+ }
92
+
93
+ function statusDot(status: string): string {
94
+ switch (status) {
95
+ case "in-progress": return green("●");
96
+ case "paused": return yellow("○");
97
+ case "completed": return green("✓");
98
+ case "failed": return red("✗");
99
+ default: return dim("·");
100
+ }
101
+ }
102
+
103
+ // ── Render Work Item ────────────────────────────────────────────────
104
+
105
+ function renderWorkItem(item: WorkItemView, innerWidth: number): string[] {
106
+ const lines: string[] = [];
107
+
108
+ const slugText = `${statusDot(item.status)} ${bold(item.slug)}`;
109
+ const branchText = dim(item.branch);
110
+ const slugPlainLen = stripAnsi(slugText).length;
111
+ const branchPlainLen = stripAnsi(branchText).length;
112
+ const gap1 = Math.max(2, innerWidth - slugPlainLen - branchPlainLen);
113
+ lines.push(slugText + " ".repeat(gap1) + branchText);
114
+
115
+ const modeText = item.mode + (item.classification ? ` (${item.classification})` : "");
116
+ const pausedBadge = item.status === "paused" ? " " + bgYellow(" PAUSED ") : "";
117
+ const startedText = dim(`Started: ${formatTimeAgo(item.startedAt)}`);
118
+ const modeStr = ` ${modeText}${pausedBadge}`;
119
+ const modePlainLen = stripAnsi(modeStr).length;
120
+ const startedPlainLen = stripAnsi(startedText).length;
121
+ const gap2 = Math.max(2, innerWidth - modePlainLen - startedPlainLen);
122
+ lines.push(modeStr + " ".repeat(gap2) + startedText);
123
+
124
+ const phaseLabel = item.currentPhase
125
+ ? (item.currentSubStage ? `${item.currentPhase}/${item.currentSubStage}` : item.currentPhase)
126
+ : "—";
127
+ const barMaxWidth = Math.max(20, Math.min(40, innerWidth - 30));
128
+ lines.push(" " + renderProgressBar(
129
+ item.progress.completed,
130
+ item.progress.total,
131
+ item.progress.percent,
132
+ phaseLabel,
133
+ barMaxWidth
134
+ ));
135
+
136
+ const phaseStrs = item.phases.map(p => `${p.name} ${phaseIndicator(p.status)}`);
137
+ lines.push(" " + phaseStrs.join(" "));
138
+
139
+ if (item.loopbacks.count > 0) {
140
+ const lb = item.loopbacks;
141
+ let loopStr = ` ${cyan("⟳")} ${lb.count} loopback${lb.count > 1 ? "s" : ""}`;
142
+ if (lb.lastFrom && lb.lastTo) {
143
+ loopStr += `: ${lb.lastFrom} → ${lb.lastTo}`;
144
+ }
145
+ if (lb.lastReason) {
146
+ loopStr += ` (${lb.lastReason})`;
147
+ }
148
+ lines.push(loopStr);
149
+ }
150
+
151
+ return lines;
152
+ }
153
+
154
+ // ── Render Completed Item ───────────────────────────────────────────
155
+
156
+ function renderCompletedItem(item: CompletedItemView, innerWidth: number): string {
157
+ const check = green("✓");
158
+ const slug = item.slug;
159
+ const pr = item.pr ? ` ${dim(item.pr)}` : "";
160
+ const date = item.completedAt ? ` ${dim(item.completedAt)}` : "";
161
+ const phases = item.phases ? ` ${dim(item.phases)}` : "";
162
+ const content = `${check} ${slug}${pr}${date}${phases}`;
163
+ return content;
164
+ }
165
+
166
+ // ── Main Render Function ────────────────────────────────────────────
167
+
168
+ export function renderDashboard(
169
+ data: DashboardData,
170
+ width: number,
171
+ height: number,
172
+ scrollOffset: number = 0
173
+ ): string {
174
+ const maxWidth = Math.min(width, 120);
175
+ const innerWidth = maxWidth - 4; // account for "║ " and " ║"
176
+
177
+ const allLines: string[] = [];
178
+
179
+ // Top border
180
+ allLines.push(`╔${horizontalLine(maxWidth)}╗`);
181
+
182
+ let activeCount = 0, pausedCount = 0, failedCount = 0;
183
+ for (const item of data.activeItems) {
184
+ if (item.status === "in-progress") activeCount++;
185
+ else if (item.status === "paused") pausedCount++;
186
+ else if (item.status === "failed") failedCount++;
187
+ }
188
+
189
+ let headerRight = "";
190
+ if (activeCount > 0) headerRight += `${green("●")} ${activeCount} active`;
191
+ if (pausedCount > 0) headerRight += ` ${yellow("○")} ${pausedCount} paused`;
192
+ if (failedCount > 0) headerRight += ` ${red("✗")} ${failedCount} failed`;
193
+
194
+ const headerLeft = bold(" WORK-KIT OBSERVER");
195
+ const headerLeftLen = stripAnsi(headerLeft).length;
196
+ const headerRightLen = stripAnsi(headerRight).length;
197
+ const headerGap = Math.max(2, innerWidth - headerLeftLen - headerRightLen);
198
+ allLines.push(boxLine(headerLeft + " ".repeat(headerGap) + headerRight, innerWidth));
199
+
200
+ // Separator
201
+ allLines.push(`╠${horizontalLine(maxWidth)}╣`);
202
+
203
+ if (data.activeItems.length === 0 && data.completedItems.length === 0) {
204
+ // Empty state
205
+ allLines.push(emptyBoxLine(innerWidth));
206
+ allLines.push(boxLine(dim(" No active work items found."), innerWidth));
207
+ allLines.push(boxLine(dim(" Start a new work item with: work-kit init"), innerWidth));
208
+ allLines.push(emptyBoxLine(innerWidth));
209
+ } else {
210
+ // Active items
211
+ if (data.activeItems.length > 0) {
212
+ allLines.push(emptyBoxLine(innerWidth));
213
+
214
+ for (let i = 0; i < data.activeItems.length; i++) {
215
+ const item = data.activeItems[i];
216
+ const itemLines = renderWorkItem(item, innerWidth);
217
+ for (const line of itemLines) {
218
+ allLines.push(boxLine(line, innerWidth));
219
+ }
220
+ if (i < data.activeItems.length - 1) {
221
+ allLines.push(emptyBoxLine(innerWidth));
222
+ }
223
+ }
224
+
225
+ allLines.push(emptyBoxLine(innerWidth));
226
+ }
227
+
228
+ // Completed section
229
+ if (data.completedItems.length > 0) {
230
+ allLines.push(`╠${horizontalLine(maxWidth)}╣`);
231
+ allLines.push(boxLine(bold(" COMPLETED"), innerWidth));
232
+
233
+ const maxCompleted = 5;
234
+ const displayed = data.completedItems.slice(0, maxCompleted);
235
+ for (const item of displayed) {
236
+ const content = renderCompletedItem(item, innerWidth);
237
+ allLines.push(boxLine(" " + content, innerWidth));
238
+ }
239
+ if (data.completedItems.length > maxCompleted) {
240
+ allLines.push(boxLine(
241
+ dim(` ... and ${data.completedItems.length - maxCompleted} more`),
242
+ innerWidth
243
+ ));
244
+ }
245
+ }
246
+ }
247
+
248
+ // Footer separator
249
+ allLines.push(`╠${horizontalLine(maxWidth)}╣`);
250
+
251
+ // Footer
252
+ const footerLeft = ` ${dim("q")} quit ${dim("↑↓")} scroll ${dim("r")} refresh`;
253
+ const timeStr = data.lastUpdated.toLocaleTimeString("en-US", {
254
+ hour: "2-digit",
255
+ minute: "2-digit",
256
+ hour12: false,
257
+ });
258
+ const footerRight = dim(`Updated: ${timeStr}`);
259
+ const footerLeftLen = stripAnsi(footerLeft).length;
260
+ const footerRightLen = stripAnsi(footerRight).length;
261
+ const footerGap = Math.max(2, innerWidth - footerLeftLen - footerRightLen);
262
+ allLines.push(boxLine(footerLeft + " ".repeat(footerGap) + footerRight, innerWidth));
263
+
264
+ // Bottom border
265
+ allLines.push(`╚${horizontalLine(maxWidth)}╝`);
266
+
267
+ // Apply scrolling: figure out how many content lines we have vs available height
268
+ const totalLines = allLines.length;
269
+ const availableHeight = height;
270
+
271
+ if (totalLines <= availableHeight) {
272
+ // Everything fits, no scrolling needed
273
+ return allLines.join("\n") + "\n";
274
+ }
275
+
276
+ // Apply scroll offset
277
+ const maxScroll = Math.max(0, totalLines - availableHeight);
278
+ const clampedOffset = Math.min(scrollOffset, maxScroll);
279
+ const visibleLines = allLines.slice(clampedOffset, clampedOffset + availableHeight);
280
+
281
+ // Add scroll indicator if not showing everything
282
+ if (clampedOffset > 0 || clampedOffset + availableHeight < totalLines) {
283
+ const scrollPct = Math.round((clampedOffset / maxScroll) * 100);
284
+ const indicator = dim(` [${scrollPct}% scrolled]`);
285
+ if (visibleLines.length > 0) {
286
+ visibleLines[visibleLines.length - 1] = visibleLines[visibleLines.length - 1] + indicator;
287
+ }
288
+ }
289
+
290
+ return visibleLines.join("\n") + "\n";
291
+ }
292
+
293
+ // ── Terminal Control ────────────────────────────────────────────────
294
+
295
+ export function enterAlternateScreen(): void {
296
+ process.stdout.write("\x1b[?1049h"); // enter alternate screen
297
+ process.stdout.write("\x1b[?25l"); // hide cursor
298
+ }
299
+
300
+ export function exitAlternateScreen(): void {
301
+ process.stdout.write("\x1b[?25h"); // show cursor
302
+ process.stdout.write("\x1b[?1049l"); // exit alternate screen
303
+ }
304
+
305
+ export function clearAndHome(): string {
306
+ return "\x1b[H\x1b[2J"; // move to top-left + clear screen
307
+ }
308
+
309
+ export function moveCursorHome(): string {
310
+ return "\x1b[H";
311
+ }
312
+
313
+ export function renderTooSmall(width: number, height: number): string {
314
+ const msg = `Terminal too small (${width}x${height}). Need at least 60x10.`;
315
+ return clearAndHome() + msg + "\n";
316
+ }
@@ -0,0 +1,99 @@
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 stateFile = path.join(worktreeRoot, ".work-kit", "state.json");
31
+ if (!fs.existsSync(stateFile)) return;
32
+
33
+ try {
34
+ const watcher = fs.watch(stateFile, { persistent: false }, () => {
35
+ debouncedUpdate();
36
+ });
37
+ watcher.on("error", () => {
38
+ watcher.close();
39
+ watchers.delete(worktreeRoot);
40
+ });
41
+ watchers.set(worktreeRoot, watcher);
42
+ } catch {
43
+ // File might not exist yet
44
+ }
45
+ }
46
+
47
+ function unwatchRemoved(currentSet: Set<string>): void {
48
+ for (const [wt, watcher] of watchers) {
49
+ if (!currentSet.has(wt)) {
50
+ watcher.close();
51
+ watchers.delete(wt);
52
+ }
53
+ }
54
+ }
55
+
56
+ function refreshWorktrees(): void {
57
+ if (stopped) return;
58
+ const current = discoverWorktrees(mainRepoRoot);
59
+ const currentSet = new Set(current);
60
+
61
+ // Only trigger update if worktree list actually changed
62
+ const changed = current.length !== cachedWorktrees.length
63
+ || current.some((wt, i) => wt !== cachedWorktrees[i]);
64
+
65
+ for (const wt of current) {
66
+ watchStateFile(wt);
67
+ }
68
+ unwatchRemoved(currentSet);
69
+
70
+ cachedWorktrees = current;
71
+
72
+ if (changed) {
73
+ debouncedUpdate();
74
+ }
75
+ }
76
+
77
+ // Initial setup
78
+ refreshWorktrees();
79
+
80
+ // Poll for new/removed worktrees every 5 seconds
81
+ pollTimer = setInterval(() => {
82
+ if (!stopped) refreshWorktrees();
83
+ }, 5000);
84
+
85
+ return {
86
+ stop() {
87
+ stopped = true;
88
+ if (debounceTimer) clearTimeout(debounceTimer);
89
+ if (pollTimer) clearInterval(pollTimer);
90
+ for (const watcher of watchers.values()) {
91
+ watcher.close();
92
+ }
93
+ watchers.clear();
94
+ },
95
+ getWorktrees() {
96
+ return cachedWorktrees;
97
+ },
98
+ };
99
+ }
@@ -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
+ }
@@ -0,0 +1,113 @@
1
+ // ── Phase & Sub-stage Types ──────────────────────────────────────────
2
+
3
+ export const PHASE_NAMES = ["plan", "build", "test", "review", "deploy", "wrap-up"] as const;
4
+ export type PhaseName = (typeof PHASE_NAMES)[number];
5
+
6
+ export const PLAN_SUBSTAGES = ["clarify", "investigate", "sketch", "scope", "ux-flow", "architecture", "blueprint", "audit"] as const;
7
+ export const BUILD_SUBSTAGES = ["setup", "migration", "red", "core", "ui", "refactor", "integration", "commit"] as const;
8
+ export const TEST_SUBSTAGES = ["verify", "e2e", "validate"] as const;
9
+ export const REVIEW_SUBSTAGES = ["self-review", "security", "performance", "compliance", "handoff"] as const;
10
+ export const DEPLOY_SUBSTAGES = ["merge", "monitor", "remediate"] as const;
11
+ export const WRAPUP_SUBSTAGES = ["wrap-up"] as const;
12
+
13
+ export type PlanSubStage = (typeof PLAN_SUBSTAGES)[number];
14
+ export type BuildSubStage = (typeof BUILD_SUBSTAGES)[number];
15
+ export type TestSubStage = (typeof TEST_SUBSTAGES)[number];
16
+ export type ReviewSubStage = (typeof REVIEW_SUBSTAGES)[number];
17
+ export type DeploySubStage = (typeof DEPLOY_SUBSTAGES)[number];
18
+ export type WrapUpSubStage = (typeof WRAPUP_SUBSTAGES)[number];
19
+
20
+ export type SubStageName = PlanSubStage | BuildSubStage | TestSubStage | ReviewSubStage | DeploySubStage | WrapUpSubStage;
21
+
22
+ export const SUBSTAGES_BY_PHASE: Record<PhaseName, readonly string[]> = {
23
+ plan: PLAN_SUBSTAGES,
24
+ build: BUILD_SUBSTAGES,
25
+ test: TEST_SUBSTAGES,
26
+ review: REVIEW_SUBSTAGES,
27
+ deploy: DEPLOY_SUBSTAGES,
28
+ "wrap-up": WRAPUP_SUBSTAGES,
29
+ };
30
+
31
+ // ── Classification ───────────────────────────────────────────────────
32
+
33
+ export type Classification = "bug-fix" | "small-change" | "refactor" | "feature" | "large-feature";
34
+
35
+ // ── Phase & Sub-stage State ──────────────────────────────────────────
36
+
37
+ export type PhaseStatus = "pending" | "in-progress" | "completed" | "skipped";
38
+ export type SubStageStatus = "pending" | "in-progress" | "completed" | "skipped";
39
+
40
+ export interface SubStageState {
41
+ status: SubStageStatus;
42
+ outcome?: string;
43
+ startedAt?: string;
44
+ completedAt?: string;
45
+ }
46
+
47
+ export interface PhaseState {
48
+ status: PhaseStatus;
49
+ subStages: Record<string, SubStageState>;
50
+ startedAt?: string;
51
+ completedAt?: string;
52
+ }
53
+
54
+ // ── Loopback ─────────────────────────────────────────────────────────
55
+
56
+ export interface Location {
57
+ phase: PhaseName;
58
+ subStage: string;
59
+ }
60
+
61
+ export interface LoopbackRecord {
62
+ from: Location;
63
+ to: Location;
64
+ reason: string;
65
+ timestamp: string;
66
+ }
67
+
68
+ // ── Workflow (auto-kit) ──────────────────────────────────────────────
69
+
70
+ export interface WorkflowStep {
71
+ phase: PhaseName;
72
+ subStage: string;
73
+ included: boolean;
74
+ }
75
+
76
+ // ── Main State ───────────────────────────────────────────────────────
77
+
78
+ export interface WorkKitState {
79
+ version: 1;
80
+ slug: string;
81
+ branch: string;
82
+ started: string;
83
+ mode: "full-kit" | "auto-kit";
84
+ classification?: Classification;
85
+ status: "in-progress" | "paused" | "completed" | "failed";
86
+ currentPhase: PhaseName | null;
87
+ currentSubStage: string | null;
88
+ phases: Record<PhaseName, PhaseState>;
89
+ workflow?: WorkflowStep[];
90
+ loopbacks: LoopbackRecord[];
91
+ metadata: {
92
+ worktreeRoot: string;
93
+ mainRepoRoot: string;
94
+ };
95
+ }
96
+
97
+ // ── Actions (CLI → Claude) ───────────────────────────────────────────
98
+
99
+ export interface AgentSpec {
100
+ phase: PhaseName;
101
+ subStage: string;
102
+ skillFile: string;
103
+ agentPrompt: string;
104
+ outputFile?: string; // for parallel agents writing to separate files
105
+ }
106
+
107
+ export type Action =
108
+ | { action: "spawn_agent"; phase: PhaseName; subStage: string; skillFile: string; agentPrompt: string; onComplete: string }
109
+ | { action: "spawn_parallel_agents"; agents: AgentSpec[]; thenSequential?: AgentSpec; onComplete: string }
110
+ | { action: "wait_for_user"; message: string }
111
+ | { action: "loopback"; from: Location; to: Location; reason: string }
112
+ | { action: "complete"; message: string }
113
+ | { action: "error"; message: string; suggestion?: string };