@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.
- package/README.md +147 -0
- package/cli/bin/work-kit.mjs +18 -0
- package/cli/src/commands/complete.ts +123 -0
- package/cli/src/commands/completions.ts +137 -0
- package/cli/src/commands/context.ts +41 -0
- package/cli/src/commands/doctor.ts +79 -0
- package/cli/src/commands/init.test.ts +116 -0
- package/cli/src/commands/init.ts +184 -0
- package/cli/src/commands/loopback.ts +64 -0
- package/cli/src/commands/next.ts +172 -0
- package/cli/src/commands/observe.ts +144 -0
- package/cli/src/commands/setup.ts +159 -0
- package/cli/src/commands/status.ts +50 -0
- package/cli/src/commands/uninstall.ts +89 -0
- package/cli/src/commands/upgrade.ts +12 -0
- package/cli/src/commands/validate.ts +34 -0
- package/cli/src/commands/workflow.ts +125 -0
- package/cli/src/config/agent-map.ts +62 -0
- package/cli/src/config/loopback-routes.ts +45 -0
- package/cli/src/config/phases.ts +119 -0
- package/cli/src/context/extractor.test.ts +77 -0
- package/cli/src/context/extractor.ts +73 -0
- package/cli/src/context/prompt-builder.ts +70 -0
- package/cli/src/engine/loopbacks.test.ts +33 -0
- package/cli/src/engine/loopbacks.ts +32 -0
- package/cli/src/engine/parallel.ts +60 -0
- package/cli/src/engine/phases.ts +23 -0
- package/cli/src/engine/transitions.test.ts +117 -0
- package/cli/src/engine/transitions.ts +97 -0
- package/cli/src/index.ts +248 -0
- package/cli/src/observer/data.ts +237 -0
- package/cli/src/observer/renderer.ts +316 -0
- package/cli/src/observer/watcher.ts +99 -0
- package/cli/src/state/helpers.test.ts +91 -0
- package/cli/src/state/helpers.ts +65 -0
- package/cli/src/state/schema.ts +113 -0
- package/cli/src/state/store.ts +82 -0
- package/cli/src/state/validators.test.ts +105 -0
- package/cli/src/state/validators.ts +81 -0
- package/cli/src/utils/colors.ts +12 -0
- package/package.json +49 -0
- package/skills/auto-kit/SKILL.md +214 -0
- package/skills/build/SKILL.md +88 -0
- package/skills/build/stages/commit.md +43 -0
- package/skills/build/stages/core.md +48 -0
- package/skills/build/stages/integration.md +44 -0
- package/skills/build/stages/migration.md +41 -0
- package/skills/build/stages/red.md +44 -0
- package/skills/build/stages/refactor.md +48 -0
- package/skills/build/stages/setup.md +42 -0
- package/skills/build/stages/ui.md +51 -0
- package/skills/deploy/SKILL.md +62 -0
- package/skills/deploy/stages/merge.md +47 -0
- package/skills/deploy/stages/monitor.md +39 -0
- package/skills/deploy/stages/remediate.md +54 -0
- package/skills/full-kit/SKILL.md +195 -0
- package/skills/plan/SKILL.md +77 -0
- package/skills/plan/stages/architecture.md +53 -0
- package/skills/plan/stages/audit.md +58 -0
- package/skills/plan/stages/blueprint.md +60 -0
- package/skills/plan/stages/clarify.md +61 -0
- package/skills/plan/stages/investigate.md +47 -0
- package/skills/plan/stages/scope.md +46 -0
- package/skills/plan/stages/sketch.md +44 -0
- package/skills/plan/stages/ux-flow.md +49 -0
- package/skills/review/SKILL.md +104 -0
- package/skills/review/stages/compliance.md +48 -0
- package/skills/review/stages/handoff.md +59 -0
- package/skills/review/stages/performance.md +45 -0
- package/skills/review/stages/security.md +49 -0
- package/skills/review/stages/self-review.md +41 -0
- package/skills/test/SKILL.md +83 -0
- package/skills/test/stages/e2e.md +44 -0
- package/skills/test/stages/validate.md +51 -0
- package/skills/test/stages/verify.md +41 -0
- 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 };
|