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.
- package/README.md +147 -0
- package/cli/bin/work-kit.mjs +21 -0
- package/cli/src/commands/complete.ts +163 -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 +253 -0
- package/cli/src/observer/data.ts +267 -0
- package/cli/src/observer/renderer.ts +364 -0
- package/cli/src/observer/watcher.ts +104 -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 +50 -0
- package/skills/auto-kit/SKILL.md +216 -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 +59 -0
- package/skills/deploy/stages/monitor.md +39 -0
- package/skills/deploy/stages/remediate.md +54 -0
- package/skills/full-kit/SKILL.md +197 -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 +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
|
+
}
|