work-kit-cli 0.2.6 → 0.2.8
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 +6 -4
- package/cli/src/commands/bootstrap.test.ts +4 -4
- package/cli/src/commands/complete.ts +26 -9
- package/cli/src/commands/doctor.ts +2 -2
- package/cli/src/commands/init.test.ts +4 -4
- package/cli/src/commands/init.ts +1 -1
- package/cli/src/commands/uninstall.ts +3 -3
- package/cli/src/observer/data.ts +21 -0
- package/cli/src/observer/renderer.ts +342 -86
- package/cli/src/observer/watcher.ts +1 -1
- package/cli/src/state/store.ts +3 -3
- package/cli/src/utils/colors.ts +11 -0
- package/package.json +1 -1
- package/skills/wk-wrap-up/SKILL.md +16 -5
package/README.md
CHANGED
|
@@ -81,7 +81,7 @@ Phases communicate through **Final sections** in `.work-kit/state.md`. Each phas
|
|
|
81
81
|
|
|
82
82
|
Dual state files in `.work-kit/`:
|
|
83
83
|
|
|
84
|
-
- **
|
|
84
|
+
- **tracker.json** — state machine (current phase, sub-stage, transitions, loop-back counts)
|
|
85
85
|
- **state.md** — content (working notes, Final sections, accumulated context)
|
|
86
86
|
|
|
87
87
|
All writes are atomic to prevent state corruption.
|
|
@@ -105,10 +105,12 @@ Any stage can route back to a previous stage. Each route is enforced with a max
|
|
|
105
105
|
|
|
106
106
|
```
|
|
107
107
|
.work-kit-tracker/
|
|
108
|
-
|
|
108
|
+
index.md # log of all completed work (links to summaries + archives)
|
|
109
109
|
archive/
|
|
110
|
-
2026-04-03
|
|
111
|
-
|
|
110
|
+
avatar-upload-2026-04-03/
|
|
111
|
+
state.md # full phase outputs
|
|
112
|
+
tracker.json # full JSON tracker (phases, timing, status)
|
|
113
|
+
summary.md # distilled wrap-up summary
|
|
112
114
|
```
|
|
113
115
|
|
|
114
116
|
## Repo structure
|
|
@@ -61,8 +61,8 @@ describe("bootstrapCommand", () => {
|
|
|
61
61
|
worktreeRoot: tmp,
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
// Backdate the
|
|
65
|
-
const stateFile = path.join(tmp, ".work-kit", "
|
|
64
|
+
// Backdate the tracker.json file to 3 hours ago
|
|
65
|
+
const stateFile = path.join(tmp, ".work-kit", "tracker.json");
|
|
66
66
|
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000);
|
|
67
67
|
fs.utimesSync(stateFile, threeHoursAgo, threeHoursAgo);
|
|
68
68
|
|
|
@@ -83,7 +83,7 @@ describe("bootstrapCommand", () => {
|
|
|
83
83
|
});
|
|
84
84
|
|
|
85
85
|
// Manually set status to completed
|
|
86
|
-
const stateFile = path.join(tmp, ".work-kit", "
|
|
86
|
+
const stateFile = path.join(tmp, ".work-kit", "tracker.json");
|
|
87
87
|
const state = JSON.parse(fs.readFileSync(stateFile, "utf-8"));
|
|
88
88
|
state.status = "completed";
|
|
89
89
|
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
@@ -104,7 +104,7 @@ describe("bootstrapCommand", () => {
|
|
|
104
104
|
worktreeRoot: tmp,
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
-
const stateFile = path.join(tmp, ".work-kit", "
|
|
107
|
+
const stateFile = path.join(tmp, ".work-kit", "tracker.json");
|
|
108
108
|
const state = JSON.parse(fs.readFileSync(stateFile, "utf-8"));
|
|
109
109
|
state.status = "failed";
|
|
110
110
|
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
|
-
import { readState, writeState, findWorktreeRoot, readStateMd } from "../state/store.js";
|
|
4
|
+
import { readState, writeState, findWorktreeRoot, readStateMd, statePath } from "../state/store.js";
|
|
5
5
|
import { isPhaseComplete, nextSubStageInPhase } from "../engine/transitions.js";
|
|
6
6
|
import { checkLoopback } from "../engine/loopbacks.js";
|
|
7
7
|
import { PHASE_ORDER } from "../config/phases.js";
|
|
@@ -165,16 +165,31 @@ function archiveCompleted(worktreeRoot: string, state: WorkKitState): void {
|
|
|
165
165
|
const date = new Date().toISOString().split("T")[0];
|
|
166
166
|
const slug = state.slug;
|
|
167
167
|
const wkDir = path.join(mainRoot, ".work-kit-tracker");
|
|
168
|
-
const
|
|
168
|
+
const folderName = `${slug}-${date}`;
|
|
169
|
+
const archiveDir = path.join(wkDir, "archive", folderName);
|
|
169
170
|
|
|
170
|
-
// Ensure
|
|
171
|
+
// Ensure archive folder exists
|
|
171
172
|
fs.mkdirSync(archiveDir, { recursive: true });
|
|
172
173
|
|
|
173
|
-
// Archive state.md
|
|
174
|
+
// Archive state.md (full phase outputs)
|
|
174
175
|
const stateMd = readStateMd(worktreeRoot);
|
|
175
176
|
if (stateMd) {
|
|
176
|
-
|
|
177
|
-
|
|
177
|
+
fs.writeFileSync(path.join(archiveDir, "state.md"), stateMd, "utf-8");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Archive tracker.json (full JSON with phases, timing, status)
|
|
181
|
+
const trackerPath = statePath(worktreeRoot);
|
|
182
|
+
if (fs.existsSync(trackerPath)) {
|
|
183
|
+
fs.copyFileSync(trackerPath, path.join(archiveDir, "tracker.json"));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Write placeholder summary.md (wrap-up skill will overwrite with distilled summary)
|
|
187
|
+
const summaryPath = path.join(archiveDir, "summary.md");
|
|
188
|
+
if (!fs.existsSync(summaryPath)) {
|
|
189
|
+
const completedPhases = PHASE_ORDER
|
|
190
|
+
.filter(p => state.phases[p].status === "completed")
|
|
191
|
+
.join("→");
|
|
192
|
+
fs.writeFileSync(summaryPath, `---\nslug: ${slug}\nbranch: ${state.branch}\nstarted: ${state.started.split("T")[0]}\ncompleted: ${date}\nstatus: completed\n---\n\n## Summary\n\nPhases: ${completedPhases}\n\n_Pending wrap-up summary._\n`, "utf-8");
|
|
178
193
|
}
|
|
179
194
|
|
|
180
195
|
// Compute completed phases
|
|
@@ -182,15 +197,17 @@ function archiveCompleted(worktreeRoot: string, state: WorkKitState): void {
|
|
|
182
197
|
.filter(p => state.phases[p].status === "completed")
|
|
183
198
|
.join("→");
|
|
184
199
|
|
|
185
|
-
// Append to index.md
|
|
200
|
+
// Append to index.md with links to summary and archive folder
|
|
186
201
|
const indexPath = path.join(wkDir, "index.md");
|
|
187
202
|
let indexContent = "";
|
|
188
203
|
if (fs.existsSync(indexPath)) {
|
|
189
204
|
indexContent = fs.readFileSync(indexPath, "utf-8");
|
|
190
205
|
}
|
|
191
206
|
if (!indexContent.includes("| Date ")) {
|
|
192
|
-
indexContent = "| Date | Slug | PR | Status | Phases |\n| --- | --- | --- | --- | --- |\n";
|
|
207
|
+
indexContent = "| Date | Slug | PR | Status | Phases | Summary | Archive |\n| --- | --- | --- | --- | --- | --- | --- |\n";
|
|
193
208
|
}
|
|
194
|
-
|
|
209
|
+
const summaryLink = `[summary](archive/${folderName}/summary.md)`;
|
|
210
|
+
const archiveLink = `[archive](archive/${folderName}/)`;
|
|
211
|
+
indexContent += `| ${date} | ${slug} | n/a | completed | ${completedPhases} | ${summaryLink} | ${archiveLink} |\n`;
|
|
195
212
|
fs.writeFileSync(indexPath, indexContent, "utf-8");
|
|
196
213
|
}
|
|
@@ -65,10 +65,10 @@ export function doctorCommand(worktreeRoot?: string): { ok: boolean; checks: Che
|
|
|
65
65
|
if (state.version === 1 && state.slug && state.status) {
|
|
66
66
|
checks.push({ name: "state", status: "pass", message: `Active work-kit: "${state.slug}" (${state.status})` });
|
|
67
67
|
} else {
|
|
68
|
-
checks.push({ name: "state", status: "warn", message: "
|
|
68
|
+
checks.push({ name: "state", status: "warn", message: "tracker.json exists but has unexpected structure" });
|
|
69
69
|
}
|
|
70
70
|
} catch (e: any) {
|
|
71
|
-
checks.push({ name: "state", status: "warn", message: `
|
|
71
|
+
checks.push({ name: "state", status: "warn", message: `tracker.json error: ${e.message}` });
|
|
72
72
|
}
|
|
73
73
|
} else {
|
|
74
74
|
checks.push({ name: "state", status: "pass", message: "No active work-kit (OK — run `work-kit init` to start)" });
|
|
@@ -22,7 +22,7 @@ afterEach(() => {
|
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
describe("initCommand", () => {
|
|
25
|
-
it("creates
|
|
25
|
+
it("creates tracker.json and state.md", () => {
|
|
26
26
|
const tmp = makeTmpDir();
|
|
27
27
|
tmpDirs.push(tmp);
|
|
28
28
|
|
|
@@ -32,11 +32,11 @@ describe("initCommand", () => {
|
|
|
32
32
|
worktreeRoot: tmp,
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
assert.ok(fs.existsSync(path.join(tmp, ".work-kit", "
|
|
35
|
+
assert.ok(fs.existsSync(path.join(tmp, ".work-kit", "tracker.json")));
|
|
36
36
|
assert.ok(fs.existsSync(path.join(tmp, ".work-kit", "state.md")));
|
|
37
37
|
|
|
38
38
|
const state = JSON.parse(
|
|
39
|
-
fs.readFileSync(path.join(tmp, ".work-kit", "
|
|
39
|
+
fs.readFileSync(path.join(tmp, ".work-kit", "tracker.json"), "utf-8")
|
|
40
40
|
);
|
|
41
41
|
assert.equal(state.slug, "add-user-login");
|
|
42
42
|
assert.equal(state.status, "in-progress");
|
|
@@ -108,7 +108,7 @@ describe("initCommand", () => {
|
|
|
108
108
|
assert.equal(result.action, "spawn_agent");
|
|
109
109
|
|
|
110
110
|
const state = JSON.parse(
|
|
111
|
-
fs.readFileSync(path.join(tmp, ".work-kit", "
|
|
111
|
+
fs.readFileSync(path.join(tmp, ".work-kit", "tracker.json"), "utf-8")
|
|
112
112
|
);
|
|
113
113
|
assert.equal(state.mode, "auto-kit");
|
|
114
114
|
assert.equal(state.classification, "bug-fix");
|
package/cli/src/commands/init.ts
CHANGED
|
@@ -119,7 +119,7 @@ export function initCommand(options: {
|
|
|
119
119
|
return {
|
|
120
120
|
action: "error",
|
|
121
121
|
message: "State already exists in this directory. Use `work-kit status` to check current state.",
|
|
122
|
-
suggestion: "To start fresh, delete .work-kit/
|
|
122
|
+
suggestion: "To start fresh, delete .work-kit/tracker.json first.",
|
|
123
123
|
};
|
|
124
124
|
}
|
|
125
125
|
|
|
@@ -52,9 +52,9 @@ export async function uninstallCommand(targetPath?: string): Promise<void> {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
// Check for active state
|
|
55
|
-
const
|
|
56
|
-
if (fs.existsSync(
|
|
57
|
-
console.error(yellow("\nWarning: Active work-kit state found (.work-kit/
|
|
55
|
+
const trackerFile = path.join(projectDir, ".work-kit", "tracker.json");
|
|
56
|
+
if (fs.existsSync(trackerFile)) {
|
|
57
|
+
console.error(yellow("\nWarning: Active work-kit state found (.work-kit/tracker.json)."));
|
|
58
58
|
console.error(yellow("Uninstalling will not remove in-progress state files."));
|
|
59
59
|
}
|
|
60
60
|
|
package/cli/src/observer/data.ts
CHANGED
|
@@ -18,7 +18,11 @@ export interface WorkItemView {
|
|
|
18
18
|
currentPhaseStartedAt?: string;
|
|
19
19
|
currentSubStageStatus?: string;
|
|
20
20
|
currentSubStageIndex?: number;
|
|
21
|
+
currentSubStageStartedAt?: string;
|
|
21
22
|
currentPhaseTotal?: number;
|
|
23
|
+
gated: boolean;
|
|
24
|
+
worktreePath: string;
|
|
25
|
+
phaseSubStages: { name: string; status: string; startedAt?: string; completedAt?: string; outcome?: string }[];
|
|
22
26
|
startedAt: string;
|
|
23
27
|
progress: { completed: number; total: number; percent: number };
|
|
24
28
|
phases: { name: string; status: string; startedAt?: string; completedAt?: string }[];
|
|
@@ -95,7 +99,9 @@ export function collectWorkItem(worktreeRoot: string): WorkItemView | null {
|
|
|
95
99
|
let currentPhaseStartedAt: string | undefined;
|
|
96
100
|
let currentSubStageStatus: string | undefined;
|
|
97
101
|
let currentSubStageIndex: number | undefined;
|
|
102
|
+
let currentSubStageStartedAt: string | undefined;
|
|
98
103
|
let currentPhaseTotal: number | undefined;
|
|
104
|
+
let phaseSubStages: WorkItemView["phaseSubStages"] = [];
|
|
99
105
|
|
|
100
106
|
for (const phaseName of phaseList) {
|
|
101
107
|
const phase = state.phases[phaseName];
|
|
@@ -143,7 +149,18 @@ export function collectWorkItem(worktreeRoot: string): WorkItemView | null {
|
|
|
143
149
|
currentSubStageIndex = idx >= 0 ? idx + 1 : undefined;
|
|
144
150
|
const ss = phase.subStages[state.currentSubStage];
|
|
145
151
|
currentSubStageStatus = ss?.status;
|
|
152
|
+
currentSubStageStartedAt = ss?.startedAt;
|
|
146
153
|
}
|
|
154
|
+
phaseSubStages = activeKeys.map(key => {
|
|
155
|
+
const ss = phase.subStages[key];
|
|
156
|
+
return {
|
|
157
|
+
name: key,
|
|
158
|
+
status: ss.status,
|
|
159
|
+
startedAt: ss.startedAt,
|
|
160
|
+
completedAt: ss.completedAt,
|
|
161
|
+
outcome: ss.outcome,
|
|
162
|
+
};
|
|
163
|
+
});
|
|
147
164
|
}
|
|
148
165
|
}
|
|
149
166
|
}
|
|
@@ -174,7 +191,11 @@ export function collectWorkItem(worktreeRoot: string): WorkItemView | null {
|
|
|
174
191
|
currentPhaseStartedAt,
|
|
175
192
|
currentSubStageStatus,
|
|
176
193
|
currentSubStageIndex,
|
|
194
|
+
currentSubStageStartedAt,
|
|
177
195
|
currentPhaseTotal,
|
|
196
|
+
gated: state.gated ?? false,
|
|
197
|
+
worktreePath: state.metadata.worktreeRoot,
|
|
198
|
+
phaseSubStages,
|
|
178
199
|
startedAt: state.started,
|
|
179
200
|
progress: { completed, total, percent },
|
|
180
201
|
phases: phaseViews,
|
|
@@ -1,6 +1,37 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
bold, dim, green, yellow, red, cyan, magenta,
|
|
3
|
+
bgYellow, bgCyan, bgRed, bgMagenta,
|
|
4
|
+
boldCyan, boldGreen,
|
|
5
|
+
} from "../utils/colors.js";
|
|
2
6
|
import type { DashboardData, WorkItemView, CompletedItemView } from "./data.js";
|
|
3
7
|
|
|
8
|
+
// ── Spinners & Animation Frames ─────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
11
|
+
const PULSE = ["◐", "◓", "◑", "◒"];
|
|
12
|
+
|
|
13
|
+
function spinner(tick: number): string {
|
|
14
|
+
return cyan(SPINNER[tick % SPINNER.length]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function pulse(tick: number): string {
|
|
18
|
+
return PULSE[tick % PULSE.length];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── Mascot ──────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function mascot(tick: number, hasActive: boolean): string {
|
|
24
|
+
if (!hasActive) {
|
|
25
|
+
// Idle — sleeping wrench
|
|
26
|
+
const faces = ["⚙ zzZ", "⚙ zZ", "⚙ z"];
|
|
27
|
+
return dim(faces[tick % faces.length]);
|
|
28
|
+
}
|
|
29
|
+
// Working — animated gear
|
|
30
|
+
const gears = ["⚙", "⚙", "⚙", "⚙"];
|
|
31
|
+
const sparks = ["·", "✦", "·", "✧"];
|
|
32
|
+
return cyan(gears[tick % gears.length]) + yellow(sparks[tick % sparks.length]);
|
|
33
|
+
}
|
|
34
|
+
|
|
4
35
|
// ── Time Formatting ─────────────────────────────────────────────────
|
|
5
36
|
|
|
6
37
|
function formatTimeAgo(dateStr: string): string {
|
|
@@ -8,10 +39,7 @@ function formatTimeAgo(dateStr: string): string {
|
|
|
8
39
|
const then = new Date(dateStr).getTime();
|
|
9
40
|
if (isNaN(then)) return "unknown";
|
|
10
41
|
|
|
11
|
-
// If only a date (no time component), show the date string as-is
|
|
12
|
-
// to avoid misleading hour-level precision
|
|
13
42
|
const isDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(dateStr);
|
|
14
|
-
|
|
15
43
|
const diffMs = now - then;
|
|
16
44
|
const minutes = Math.floor(diffMs / 60000);
|
|
17
45
|
const hours = Math.floor(diffMs / 3600000);
|
|
@@ -35,14 +63,31 @@ function formatTimeAgo(dateStr: string): string {
|
|
|
35
63
|
return `${weeks}w ago`;
|
|
36
64
|
}
|
|
37
65
|
|
|
66
|
+
function formatDuration(startStr: string): string {
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
const start = new Date(startStr).getTime();
|
|
69
|
+
if (isNaN(start)) return "";
|
|
70
|
+
const diffMs = now - start;
|
|
71
|
+
const sec = Math.floor(diffMs / 1000);
|
|
72
|
+
if (sec < 60) return `${sec}s`;
|
|
73
|
+
const min = Math.floor(sec / 60);
|
|
74
|
+
if (min < 60) return `${min}m`;
|
|
75
|
+
const hr = Math.floor(min / 60);
|
|
76
|
+
const remMin = min % 60;
|
|
77
|
+
return remMin > 0 ? `${hr}h${remMin}m` : `${hr}h`;
|
|
78
|
+
}
|
|
79
|
+
|
|
38
80
|
// ── Box Drawing ─────────────────────────────────────────────────────
|
|
39
81
|
|
|
40
82
|
function horizontalLine(width: number): string {
|
|
41
83
|
return "═".repeat(Math.max(0, width - 2));
|
|
42
84
|
}
|
|
43
85
|
|
|
86
|
+
function thinLine(width: number): string {
|
|
87
|
+
return "─".repeat(Math.max(0, width));
|
|
88
|
+
}
|
|
89
|
+
|
|
44
90
|
function padRight(text: string, width: number): string {
|
|
45
|
-
// Strip ANSI codes for length calculation
|
|
46
91
|
const plainLen = stripAnsi(text).length;
|
|
47
92
|
const padding = Math.max(0, width - plainLen);
|
|
48
93
|
return text + " ".repeat(padding);
|
|
@@ -52,14 +97,6 @@ function stripAnsi(s: string): string {
|
|
|
52
97
|
return s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
53
98
|
}
|
|
54
99
|
|
|
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
100
|
function boxLine(content: string, innerWidth: number): string {
|
|
64
101
|
return `║ ${padRight(content, innerWidth)} ║`;
|
|
65
102
|
}
|
|
@@ -70,22 +107,40 @@ function emptyBoxLine(innerWidth: number): string {
|
|
|
70
107
|
|
|
71
108
|
// ── Progress Bar ────────────────────────────────────────────────────
|
|
72
109
|
|
|
110
|
+
function progressColor(percent: number): (s: string) => string {
|
|
111
|
+
if (percent >= 75) return green;
|
|
112
|
+
if (percent >= 50) return cyan;
|
|
113
|
+
if (percent >= 25) return yellow;
|
|
114
|
+
return red;
|
|
115
|
+
}
|
|
116
|
+
|
|
73
117
|
function renderProgressBar(
|
|
74
118
|
completed: number,
|
|
75
119
|
total: number,
|
|
76
120
|
percent: number,
|
|
77
|
-
|
|
78
|
-
|
|
121
|
+
maxBarWidth: number,
|
|
122
|
+
tick: number
|
|
79
123
|
): string {
|
|
80
124
|
const barWidth = Math.max(20, Math.min(40, maxBarWidth));
|
|
81
125
|
const filled = total > 0 ? Math.round((completed / total) * barWidth) : 0;
|
|
82
126
|
const empty = barWidth - filled;
|
|
83
127
|
|
|
84
|
-
const
|
|
128
|
+
const colorFn = progressColor(percent);
|
|
129
|
+
|
|
130
|
+
// Animated head on the progress bar
|
|
131
|
+
let filledStr: string;
|
|
132
|
+
if (filled > 0 && filled < barWidth) {
|
|
133
|
+
const body = colorFn("█".repeat(filled - 1));
|
|
134
|
+
const head = tick % 2 === 0 ? colorFn("▓") : colorFn("█");
|
|
135
|
+
filledStr = body + head;
|
|
136
|
+
} else {
|
|
137
|
+
filledStr = colorFn("█".repeat(filled));
|
|
138
|
+
}
|
|
139
|
+
|
|
85
140
|
const emptyStr = dim("░".repeat(empty));
|
|
86
|
-
const stats = `${
|
|
141
|
+
const stats = dim(`${completed}/${total}`) + " " + colorFn(`${percent}%`);
|
|
87
142
|
|
|
88
|
-
return `${filledStr}${emptyStr}
|
|
143
|
+
return `${filledStr}${emptyStr} ${stats}`;
|
|
89
144
|
}
|
|
90
145
|
|
|
91
146
|
// ── Phase Status Indicators ─────────────────────────────────────────
|
|
@@ -93,7 +148,7 @@ function renderProgressBar(
|
|
|
93
148
|
function phaseIndicator(status: string, tick: number = 0): string {
|
|
94
149
|
switch (status) {
|
|
95
150
|
case "completed": return green("✓");
|
|
96
|
-
case "in-progress": return tick
|
|
151
|
+
case "in-progress": return spinner(tick);
|
|
97
152
|
case "waiting": return tick % 2 === 0 ? yellow("◉") : dim("◉");
|
|
98
153
|
case "pending": return dim("·");
|
|
99
154
|
case "skipped": return dim("⊘");
|
|
@@ -102,6 +157,28 @@ function phaseIndicator(status: string, tick: number = 0): string {
|
|
|
102
157
|
}
|
|
103
158
|
}
|
|
104
159
|
|
|
160
|
+
function subStageIndicator(status: string, tick: number): string {
|
|
161
|
+
switch (status) {
|
|
162
|
+
case "completed": return green("●");
|
|
163
|
+
case "in-progress": return cyan(pulse(tick));
|
|
164
|
+
case "waiting": return yellow("○");
|
|
165
|
+
case "pending": return dim("○");
|
|
166
|
+
case "skipped": return dim("⊘");
|
|
167
|
+
case "failed": return red("●");
|
|
168
|
+
default: return dim("○");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function phaseName(name: string, status: string, tick: number): string {
|
|
173
|
+
switch (status) {
|
|
174
|
+
case "completed": return boldGreen(name);
|
|
175
|
+
case "in-progress": return tick % 2 === 0 ? boldCyan(name) : bold(cyan(name));
|
|
176
|
+
case "waiting": return bold(yellow(name));
|
|
177
|
+
case "failed": return bold(red(name));
|
|
178
|
+
default: return dim(name);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
105
182
|
function statusDot(status: string): string {
|
|
106
183
|
switch (status) {
|
|
107
184
|
case "in-progress": return green("●");
|
|
@@ -112,82 +189,250 @@ function statusDot(status: string): string {
|
|
|
112
189
|
}
|
|
113
190
|
}
|
|
114
191
|
|
|
115
|
-
// ──
|
|
192
|
+
// ── Badges ──────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
function renderModeBadge(mode: string): string {
|
|
195
|
+
return mode === "full-kit" ? bgCyan(" Full Kit ") : bgYellow(" Auto Kit ");
|
|
196
|
+
}
|
|
116
197
|
|
|
117
|
-
function
|
|
118
|
-
|
|
119
|
-
return classification ? `${label} · ${classification}` : label;
|
|
198
|
+
function renderGatedBadge(): string {
|
|
199
|
+
return bgMagenta(" GATED ");
|
|
120
200
|
}
|
|
121
201
|
|
|
122
|
-
|
|
202
|
+
// ── Phase Pipeline ──────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
function phaseDuration(p: { status: string; startedAt?: string; completedAt?: string }): string {
|
|
205
|
+
if (p.status === "completed" && p.startedAt && p.completedAt) {
|
|
206
|
+
const ms = new Date(p.completedAt).getTime() - new Date(p.startedAt).getTime();
|
|
207
|
+
const sec = Math.floor(ms / 1000);
|
|
208
|
+
if (sec < 60) return `${sec}s`;
|
|
209
|
+
const min = Math.floor(sec / 60);
|
|
210
|
+
if (min < 60) return `${min}m`;
|
|
211
|
+
const hr = Math.floor(min / 60);
|
|
212
|
+
const remMin = min % 60;
|
|
213
|
+
return remMin > 0 ? `${hr}h${remMin}m` : `${hr}h`;
|
|
214
|
+
}
|
|
215
|
+
if (p.status === "in-progress" && p.startedAt) {
|
|
216
|
+
return formatDuration(p.startedAt);
|
|
217
|
+
}
|
|
218
|
+
if (p.status === "waiting" && p.startedAt) {
|
|
219
|
+
return formatDuration(p.startedAt);
|
|
220
|
+
}
|
|
221
|
+
return "";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function renderPhasePipeline(
|
|
225
|
+
phases: WorkItemView["phases"],
|
|
226
|
+
tick: number
|
|
227
|
+
): string[] {
|
|
228
|
+
const connector = dim(" ── ");
|
|
229
|
+
const connectorLen = 4; // " ── "
|
|
230
|
+
|
|
231
|
+
// Build top line (icons + names) and bottom line (timing, aligned)
|
|
232
|
+
const topParts: string[] = [];
|
|
233
|
+
const bottomParts: string[] = [];
|
|
234
|
+
|
|
235
|
+
for (let i = 0; i < phases.length; i++) {
|
|
236
|
+
const p = phases[i];
|
|
237
|
+
const icon = phaseIndicator(p.status, tick);
|
|
238
|
+
const name = phaseName(p.name, p.status, tick);
|
|
239
|
+
const segment = `${icon} ${name}`;
|
|
240
|
+
topParts.push(segment);
|
|
241
|
+
|
|
242
|
+
// Calculate plain width of this segment for alignment
|
|
243
|
+
const segPlainLen = stripAnsi(segment).length;
|
|
244
|
+
const dur = phaseDuration(p);
|
|
245
|
+
|
|
246
|
+
if (dur) {
|
|
247
|
+
// Color the duration based on status
|
|
248
|
+
let durStyled: string;
|
|
249
|
+
if (p.status === "completed") durStyled = dim(dur);
|
|
250
|
+
else if (p.status === "in-progress") durStyled = cyan(dur);
|
|
251
|
+
else if (p.status === "waiting") durStyled = yellow(dur);
|
|
252
|
+
else durStyled = dim(dur);
|
|
253
|
+
|
|
254
|
+
// Center the duration under the segment
|
|
255
|
+
const durPlainLen = stripAnsi(durStyled).length;
|
|
256
|
+
const pad = Math.max(0, Math.floor((segPlainLen - durPlainLen) / 2));
|
|
257
|
+
bottomParts.push(" ".repeat(pad) + durStyled + " ".repeat(Math.max(0, segPlainLen - durPlainLen - pad)));
|
|
258
|
+
} else {
|
|
259
|
+
bottomParts.push(" ".repeat(segPlainLen));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Add connector spacing to bottom line too
|
|
263
|
+
if (i < phases.length - 1) {
|
|
264
|
+
bottomParts.push(" ".repeat(connectorLen));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const topLine = topParts.join(connector);
|
|
269
|
+
const bottomLine = bottomParts.join("");
|
|
270
|
+
|
|
271
|
+
// Only show bottom line if there's at least one duration
|
|
272
|
+
const hasAnyDuration = phases.some(p => phaseDuration(p) !== "");
|
|
273
|
+
if (hasAnyDuration) {
|
|
274
|
+
return [topLine, bottomLine];
|
|
275
|
+
}
|
|
276
|
+
return [topLine];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Sub-Stage Detail Box ────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
function renderSubStageBox(
|
|
282
|
+
item: WorkItemView,
|
|
283
|
+
innerWidth: number,
|
|
284
|
+
tick: number
|
|
285
|
+
): string[] {
|
|
286
|
+
const subs = item.phaseSubStages;
|
|
287
|
+
if (!subs || subs.length === 0 || !item.currentPhase) return [];
|
|
288
|
+
|
|
289
|
+
const lines: string[] = [];
|
|
290
|
+
const label = dim(item.currentPhase);
|
|
291
|
+
const boxInner = innerWidth - 8; // indent + border padding
|
|
292
|
+
|
|
293
|
+
// Top border with phase label
|
|
294
|
+
const labelLen = stripAnsi(label).length;
|
|
295
|
+
const topRule = dim("┌─ ") + label + dim(" " + "─".repeat(Math.max(0, boxInner - labelLen - 2)) + "┐");
|
|
296
|
+
lines.push(" " + topRule);
|
|
297
|
+
|
|
298
|
+
// Render sub-stages in rows that fit the width
|
|
299
|
+
const entries: string[] = [];
|
|
300
|
+
for (const ss of subs) {
|
|
301
|
+
const icon = subStageIndicator(ss.status, tick);
|
|
302
|
+
let nameStr: string;
|
|
303
|
+
switch (ss.status) {
|
|
304
|
+
case "completed": nameStr = green(ss.name); break;
|
|
305
|
+
case "in-progress": nameStr = boldCyan(ss.name); break;
|
|
306
|
+
case "waiting": nameStr = yellow(ss.name); break;
|
|
307
|
+
case "failed": nameStr = red(ss.name); break;
|
|
308
|
+
default: nameStr = dim(ss.name);
|
|
309
|
+
}
|
|
310
|
+
// Add duration for completed or in-progress
|
|
311
|
+
let duration = "";
|
|
312
|
+
if (ss.status === "completed" && ss.startedAt && ss.completedAt) {
|
|
313
|
+
const ms = new Date(ss.completedAt).getTime() - new Date(ss.startedAt).getTime();
|
|
314
|
+
const sec = Math.floor(ms / 1000);
|
|
315
|
+
if (sec < 60) duration = dim(` ${sec}s`);
|
|
316
|
+
else duration = dim(` ${Math.floor(sec / 60)}m`);
|
|
317
|
+
} else if (ss.status === "in-progress" && ss.startedAt) {
|
|
318
|
+
duration = dim(` ${formatDuration(ss.startedAt)}`);
|
|
319
|
+
}
|
|
320
|
+
entries.push(`${icon} ${nameStr}${duration}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Flow entries into rows
|
|
324
|
+
let currentRow = "";
|
|
325
|
+
let currentRowLen = 0;
|
|
326
|
+
for (const entry of entries) {
|
|
327
|
+
const entryLen = stripAnsi(entry).length;
|
|
328
|
+
const separator = currentRowLen > 0 ? " " : "";
|
|
329
|
+
const sepLen = currentRowLen > 0 ? 2 : 0;
|
|
330
|
+
|
|
331
|
+
if (currentRowLen + sepLen + entryLen > boxInner && currentRowLen > 0) {
|
|
332
|
+
// Wrap to new row
|
|
333
|
+
const padded = padRight(currentRow, boxInner);
|
|
334
|
+
lines.push(" " + dim("│ ") + padded + dim(" │"));
|
|
335
|
+
currentRow = entry;
|
|
336
|
+
currentRowLen = entryLen;
|
|
337
|
+
} else {
|
|
338
|
+
currentRow += separator + entry;
|
|
339
|
+
currentRowLen += sepLen + entryLen;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// Last row
|
|
343
|
+
if (currentRowLen > 0) {
|
|
344
|
+
const padded = padRight(currentRow, boxInner);
|
|
345
|
+
lines.push(" " + dim("│ ") + padded + dim(" │"));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Bottom border
|
|
349
|
+
lines.push(" " + dim("└" + "─".repeat(boxInner + 2) + "┘"));
|
|
350
|
+
|
|
351
|
+
return lines;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── Render Work Item ────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
function renderWorkItem(item: WorkItemView, innerWidth: number, tick: number): string[] {
|
|
123
357
|
const lines: string[] = [];
|
|
124
358
|
|
|
125
|
-
// Line 1: slug +
|
|
359
|
+
// Line 1: status dot + bold slug + elapsed time (right)
|
|
126
360
|
const slugText = `${statusDot(item.status)} ${bold(item.slug)}`;
|
|
127
|
-
const branchText = dim(item.branch);
|
|
128
|
-
const slugPlainLen = stripAnsi(slugText).length;
|
|
129
|
-
const branchPlainLen = stripAnsi(branchText).length;
|
|
130
|
-
const gap1 = Math.max(2, innerWidth - slugPlainLen - branchPlainLen);
|
|
131
|
-
lines.push(slugText + " ".repeat(gap1) + branchText);
|
|
132
|
-
|
|
133
|
-
// Line 2: mode + timing (right-aligned)
|
|
134
|
-
const modeText = formatMode(item.mode, item.classification);
|
|
135
|
-
const pausedBadge = item.status === "paused" ? " " + bgYellow(" PAUSED ") : "";
|
|
136
361
|
const elapsed = formatTimeAgo(item.startedAt);
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
362
|
+
const elapsedText = dim(`⏱ ${elapsed}`);
|
|
363
|
+
const slugLen = stripAnsi(slugText).length;
|
|
364
|
+
const elapsedLen = stripAnsi(elapsedText).length;
|
|
365
|
+
const gap1 = Math.max(2, innerWidth - slugLen - elapsedLen);
|
|
366
|
+
lines.push(slugText + " ".repeat(gap1) + elapsedText);
|
|
367
|
+
|
|
368
|
+
// Line 2: branch + mode badge + gated badge + classification
|
|
369
|
+
const branchText = dim("⎇ " + item.branch);
|
|
370
|
+
let badges = " " + renderModeBadge(item.mode);
|
|
371
|
+
if (item.gated) badges += " " + renderGatedBadge();
|
|
372
|
+
if (item.status === "paused") badges += " " + bgYellow(" PAUSED ");
|
|
373
|
+
if (item.status === "failed") badges += " " + bgRed(" FAILED ");
|
|
374
|
+
if (item.classification) badges += " " + dim(item.classification);
|
|
375
|
+
lines.push(" " + branchText + badges);
|
|
376
|
+
|
|
377
|
+
// Line 3: timing — phase elapsed + sub-stage elapsed
|
|
378
|
+
const timingParts: string[] = [];
|
|
379
|
+
if (item.currentPhase && item.currentPhaseStartedAt) {
|
|
380
|
+
timingParts.push(cyan("phase") + dim(`: ${formatDuration(item.currentPhaseStartedAt)}`));
|
|
140
381
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
// Line
|
|
149
|
-
const
|
|
150
|
-
const barMaxWidth = Math.max(20, Math.min(40, innerWidth - 30));
|
|
382
|
+
if (item.currentSubStage && item.currentSubStageStartedAt) {
|
|
383
|
+
timingParts.push(cyan("step") + dim(`: ${formatDuration(item.currentSubStageStartedAt)}`));
|
|
384
|
+
}
|
|
385
|
+
if (timingParts.length > 0) {
|
|
386
|
+
lines.push(" " + timingParts.join(dim(" │ ")));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Line 4: progress bar with animated head
|
|
390
|
+
const barMaxWidth = Math.max(20, Math.min(40, innerWidth - 20));
|
|
151
391
|
lines.push(" " + renderProgressBar(
|
|
152
392
|
item.progress.completed,
|
|
153
393
|
item.progress.total,
|
|
154
394
|
item.progress.percent,
|
|
155
|
-
|
|
156
|
-
|
|
395
|
+
barMaxWidth,
|
|
396
|
+
tick
|
|
157
397
|
));
|
|
158
398
|
|
|
159
|
-
// Line
|
|
160
|
-
const
|
|
161
|
-
|
|
399
|
+
// Line 5-6: phase pipeline with connectors, spinner, and timing row
|
|
400
|
+
const pipelineLines = renderPhasePipeline(item.phases, tick);
|
|
401
|
+
for (const pl of pipelineLines) {
|
|
402
|
+
lines.push(" " + pl);
|
|
403
|
+
}
|
|
162
404
|
|
|
163
|
-
// Line
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
subLabel += ` (${item.currentSubStageIndex}/${item.currentPhaseTotal})`;
|
|
169
|
-
}
|
|
170
|
-
if (isWaiting) {
|
|
171
|
-
const badge = tick % 2 === 0 ? bgYellow(" WAITING ") : dim(" WAITING ");
|
|
172
|
-
lines.push(" " + yellow(subLabel) + " " + badge);
|
|
173
|
-
} else {
|
|
174
|
-
lines.push(" " + (tick % 2 === 0 ? cyan(subLabel) : dim(subLabel)));
|
|
405
|
+
// Line 6: sub-stage detail box (all sub-stages of current phase)
|
|
406
|
+
const subStageBox = renderSubStageBox(item, innerWidth, tick);
|
|
407
|
+
if (subStageBox.length > 0) {
|
|
408
|
+
for (const line of subStageBox) {
|
|
409
|
+
lines.push(line);
|
|
175
410
|
}
|
|
176
411
|
}
|
|
177
412
|
|
|
178
|
-
//
|
|
413
|
+
// Loopbacks
|
|
179
414
|
if (item.loopbacks.count > 0) {
|
|
180
415
|
const lb = item.loopbacks;
|
|
181
|
-
let loopStr = ` ${
|
|
416
|
+
let loopStr = ` ${yellow("⟳")} ${lb.count} loopback${lb.count > 1 ? "s" : ""}`;
|
|
182
417
|
if (lb.lastFrom && lb.lastTo) {
|
|
183
|
-
loopStr += `: ${lb.lastFrom} → ${lb.lastTo}
|
|
418
|
+
loopStr += dim(`: ${lb.lastFrom} → ${lb.lastTo}`);
|
|
184
419
|
}
|
|
185
420
|
if (lb.lastReason) {
|
|
186
|
-
loopStr += ` (${lb.lastReason})
|
|
421
|
+
loopStr += dim(` (${lb.lastReason})`);
|
|
187
422
|
}
|
|
188
423
|
lines.push(loopStr);
|
|
189
424
|
}
|
|
190
425
|
|
|
426
|
+
// Worktree path
|
|
427
|
+
if (item.worktreePath) {
|
|
428
|
+
let displayPath = item.worktreePath;
|
|
429
|
+
const maxPathLen = innerWidth - 8;
|
|
430
|
+
if (displayPath.length > maxPathLen) {
|
|
431
|
+
displayPath = "…" + displayPath.slice(displayPath.length - maxPathLen + 1);
|
|
432
|
+
}
|
|
433
|
+
lines.push(" " + dim("⌂ " + displayPath));
|
|
434
|
+
}
|
|
435
|
+
|
|
191
436
|
return lines;
|
|
192
437
|
}
|
|
193
438
|
|
|
@@ -200,7 +445,7 @@ interface CompletedColumnWidths {
|
|
|
200
445
|
}
|
|
201
446
|
|
|
202
447
|
function computeCompletedWidths(items: CompletedItemView[]): CompletedColumnWidths {
|
|
203
|
-
let slug = 4, pr = 2, date = 4;
|
|
448
|
+
let slug = 4, pr = 2, date = 4;
|
|
204
449
|
for (const item of items) {
|
|
205
450
|
slug = Math.max(slug, item.slug.length);
|
|
206
451
|
pr = Math.max(pr, (item.pr || "—").length);
|
|
@@ -228,26 +473,34 @@ export function renderDashboard(
|
|
|
228
473
|
tick: number = 0
|
|
229
474
|
): string {
|
|
230
475
|
const maxWidth = Math.min(width, 120);
|
|
231
|
-
const innerWidth = maxWidth - 4;
|
|
476
|
+
const innerWidth = maxWidth - 4;
|
|
232
477
|
|
|
233
478
|
const allLines: string[] = [];
|
|
234
479
|
|
|
235
480
|
// Top border
|
|
236
481
|
allLines.push(`╔${horizontalLine(maxWidth)}╗`);
|
|
237
482
|
|
|
238
|
-
|
|
483
|
+
// Header counts
|
|
484
|
+
let activeCount = 0, pausedCount = 0, failedCount = 0, waitingCount = 0;
|
|
239
485
|
for (const item of data.activeItems) {
|
|
240
486
|
if (item.status === "in-progress") activeCount++;
|
|
241
487
|
else if (item.status === "paused") pausedCount++;
|
|
242
488
|
else if (item.status === "failed") failedCount++;
|
|
489
|
+
if (item.currentSubStageStatus === "waiting") waitingCount++;
|
|
243
490
|
}
|
|
491
|
+
const completedCount = data.completedItems.length;
|
|
492
|
+
const hasActive = activeCount > 0;
|
|
244
493
|
|
|
494
|
+
// Header: mascot + title + counts
|
|
245
495
|
let headerRight = "";
|
|
246
496
|
if (activeCount > 0) headerRight += `${green("●")} ${activeCount} active`;
|
|
497
|
+
if (waitingCount > 0) headerRight += ` ${yellow("◉")} ${waitingCount} waiting`;
|
|
247
498
|
if (pausedCount > 0) headerRight += ` ${yellow("○")} ${pausedCount} paused`;
|
|
248
499
|
if (failedCount > 0) headerRight += ` ${red("✗")} ${failedCount} failed`;
|
|
500
|
+
if (completedCount > 0) headerRight += ` ${green("✓")} ${completedCount} done`;
|
|
249
501
|
|
|
250
|
-
const
|
|
502
|
+
const mascotStr = mascot(tick, hasActive);
|
|
503
|
+
const headerLeft = ` ${mascotStr} ${bold("WORK-KIT OBSERVER")}`;
|
|
251
504
|
const headerLeftLen = stripAnsi(headerLeft).length;
|
|
252
505
|
const headerRightLen = stripAnsi(headerRight).length;
|
|
253
506
|
const headerGap = Math.max(2, innerWidth - headerLeftLen - headerRightLen);
|
|
@@ -257,7 +510,7 @@ export function renderDashboard(
|
|
|
257
510
|
allLines.push(`╠${horizontalLine(maxWidth)}╣`);
|
|
258
511
|
|
|
259
512
|
if (data.activeItems.length === 0 && data.completedItems.length === 0) {
|
|
260
|
-
// Empty state
|
|
513
|
+
// Empty state with idle mascot
|
|
261
514
|
allLines.push(emptyBoxLine(innerWidth));
|
|
262
515
|
allLines.push(boxLine(dim(" No active work items found."), innerWidth));
|
|
263
516
|
allLines.push(boxLine(dim(" Start a new work item with: work-kit init"), innerWidth));
|
|
@@ -275,6 +528,8 @@ export function renderDashboard(
|
|
|
275
528
|
}
|
|
276
529
|
if (i < data.activeItems.length - 1) {
|
|
277
530
|
allLines.push(emptyBoxLine(innerWidth));
|
|
531
|
+
allLines.push(boxLine(dim(" " + thinLine(innerWidth - 4)), innerWidth));
|
|
532
|
+
allLines.push(emptyBoxLine(innerWidth));
|
|
278
533
|
}
|
|
279
534
|
}
|
|
280
535
|
|
|
@@ -284,7 +539,10 @@ export function renderDashboard(
|
|
|
284
539
|
// Completed section
|
|
285
540
|
if (data.completedItems.length > 0) {
|
|
286
541
|
allLines.push(`╠${horizontalLine(maxWidth)}╣`);
|
|
287
|
-
allLines.push(boxLine(
|
|
542
|
+
allLines.push(boxLine(
|
|
543
|
+
bold(" COMPLETED") + dim(` (${data.completedItems.length})`),
|
|
544
|
+
innerWidth
|
|
545
|
+
));
|
|
288
546
|
|
|
289
547
|
const maxCompleted = 5;
|
|
290
548
|
const displayed = data.completedItems.slice(0, maxCompleted);
|
|
@@ -295,7 +553,7 @@ export function renderDashboard(
|
|
|
295
553
|
}
|
|
296
554
|
if (data.completedItems.length > maxCompleted) {
|
|
297
555
|
allLines.push(boxLine(
|
|
298
|
-
dim(`
|
|
556
|
+
dim(` … and ${data.completedItems.length - maxCompleted} more`),
|
|
299
557
|
innerWidth
|
|
300
558
|
));
|
|
301
559
|
}
|
|
@@ -310,6 +568,7 @@ export function renderDashboard(
|
|
|
310
568
|
const timeStr = data.lastUpdated.toLocaleTimeString("en-US", {
|
|
311
569
|
hour: "2-digit",
|
|
312
570
|
minute: "2-digit",
|
|
571
|
+
second: "2-digit",
|
|
313
572
|
hour12: false,
|
|
314
573
|
});
|
|
315
574
|
const footerRight = dim(`Updated: ${timeStr}`);
|
|
@@ -321,21 +580,18 @@ export function renderDashboard(
|
|
|
321
580
|
// Bottom border
|
|
322
581
|
allLines.push(`╚${horizontalLine(maxWidth)}╝`);
|
|
323
582
|
|
|
324
|
-
//
|
|
583
|
+
// Scrolling
|
|
325
584
|
const totalLines = allLines.length;
|
|
326
585
|
const availableHeight = height;
|
|
327
586
|
|
|
328
587
|
if (totalLines <= availableHeight) {
|
|
329
|
-
|
|
330
|
-
return allLines.join("\n") + "\n";
|
|
588
|
+
return allLines.join("\n") + "\n\x1b[J";
|
|
331
589
|
}
|
|
332
590
|
|
|
333
|
-
// Apply scroll offset
|
|
334
591
|
const maxScroll = Math.max(0, totalLines - availableHeight);
|
|
335
592
|
const clampedOffset = Math.min(scrollOffset, maxScroll);
|
|
336
593
|
const visibleLines = allLines.slice(clampedOffset, clampedOffset + availableHeight);
|
|
337
594
|
|
|
338
|
-
// Add scroll indicator if not showing everything
|
|
339
595
|
if (clampedOffset > 0 || clampedOffset + availableHeight < totalLines) {
|
|
340
596
|
const scrollPct = Math.round((clampedOffset / maxScroll) * 100);
|
|
341
597
|
const indicator = dim(` [${scrollPct}% scrolled]`);
|
|
@@ -344,23 +600,23 @@ export function renderDashboard(
|
|
|
344
600
|
}
|
|
345
601
|
}
|
|
346
602
|
|
|
347
|
-
return visibleLines.join("\n") + "\n";
|
|
603
|
+
return visibleLines.join("\n") + "\n\x1b[J";
|
|
348
604
|
}
|
|
349
605
|
|
|
350
606
|
// ── Terminal Control ────────────────────────────────────────────────
|
|
351
607
|
|
|
352
608
|
export function enterAlternateScreen(): void {
|
|
353
|
-
process.stdout.write("\x1b[?1049h");
|
|
354
|
-
process.stdout.write("\x1b[?25l");
|
|
609
|
+
process.stdout.write("\x1b[?1049h");
|
|
610
|
+
process.stdout.write("\x1b[?25l");
|
|
355
611
|
}
|
|
356
612
|
|
|
357
613
|
export function exitAlternateScreen(): void {
|
|
358
|
-
process.stdout.write("\x1b[?25h");
|
|
359
|
-
process.stdout.write("\x1b[?1049l");
|
|
614
|
+
process.stdout.write("\x1b[?25h");
|
|
615
|
+
process.stdout.write("\x1b[?1049l");
|
|
360
616
|
}
|
|
361
617
|
|
|
362
618
|
export function clearAndHome(): string {
|
|
363
|
-
return "\x1b[H\x1b[2J";
|
|
619
|
+
return "\x1b[H\x1b[2J";
|
|
364
620
|
}
|
|
365
621
|
|
|
366
622
|
export function moveCursorHome(): string {
|
|
@@ -35,7 +35,7 @@ export function startWatching(
|
|
|
35
35
|
// rename (write tmp + rename), which replaces the inode and
|
|
36
36
|
// breaks fs.watch on the file on Linux.
|
|
37
37
|
const watcher = fs.watch(stateDir, { persistent: false }, (_event, filename) => {
|
|
38
|
-
if (filename === "
|
|
38
|
+
if (filename === "tracker.json") {
|
|
39
39
|
debouncedUpdate();
|
|
40
40
|
}
|
|
41
41
|
});
|
package/cli/src/state/store.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
|
|
|
4
4
|
import { WorkKitState } from "./schema.js";
|
|
5
5
|
|
|
6
6
|
const STATE_DIR = ".work-kit";
|
|
7
|
-
const STATE_FILE = "
|
|
7
|
+
const STATE_FILE = "tracker.json";
|
|
8
8
|
|
|
9
9
|
// ── Worktree Discovery ───────────────────────────────────────────────
|
|
10
10
|
|
|
@@ -41,13 +41,13 @@ export function stateExists(worktreeRoot: string): boolean {
|
|
|
41
41
|
export function readState(worktreeRoot: string): WorkKitState {
|
|
42
42
|
const filePath = statePath(worktreeRoot);
|
|
43
43
|
if (!fs.existsSync(filePath)) {
|
|
44
|
-
throw new Error(`No
|
|
44
|
+
throw new Error(`No tracker.json found at ${filePath}`);
|
|
45
45
|
}
|
|
46
46
|
const raw = fs.readFileSync(filePath, "utf-8");
|
|
47
47
|
try {
|
|
48
48
|
return JSON.parse(raw) as WorkKitState;
|
|
49
49
|
} catch {
|
|
50
|
-
throw new Error(`Corrupted
|
|
50
|
+
throw new Error(`Corrupted tracker.json at ${filePath}. File contains invalid JSON.`);
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
package/cli/src/utils/colors.ts
CHANGED
|
@@ -9,4 +9,15 @@ export const green = (s: string) => `${code(32)}${s}${reset}`;
|
|
|
9
9
|
export const yellow = (s: string) => `${code(33)}${s}${reset}`;
|
|
10
10
|
export const red = (s: string) => `${code(31)}${s}${reset}`;
|
|
11
11
|
export const cyan = (s: string) => `${code(36)}${s}${reset}`;
|
|
12
|
+
export const blue = (s: string) => `${code(34)}${s}${reset}`;
|
|
13
|
+
export const magenta = (s: string) => `${code(35)}${s}${reset}`;
|
|
14
|
+
export const bgGreen = (s: string) => `${code(42)}${code(30)}${s}${reset}`;
|
|
12
15
|
export const bgYellow = (s: string) => `${code(43)}${code(30)}${s}${reset}`;
|
|
16
|
+
export const bgCyan = (s: string) => `${code(46)}${code(30)}${s}${reset}`;
|
|
17
|
+
export const bgRed = (s: string) => `${code(41)}${code(97)}${s}${reset}`;
|
|
18
|
+
export const bgDim = (s: string) => `${code(100)}${code(97)}${s}${reset}`;
|
|
19
|
+
export const bgMagenta = (s: string) => `${code(45)}${code(97)}${s}${reset}`;
|
|
20
|
+
export const boldCyan = (s: string) => `${code(1)}${code(36)}${s}${reset}`;
|
|
21
|
+
export const boldGreen = (s: string) => `${code(1)}${code(32)}${s}${reset}`;
|
|
22
|
+
export const boldYellow = (s: string) => `${code(1)}${code(33)}${s}${reset}`;
|
|
23
|
+
export const boldRed = (s: string) => `${code(1)}${code(31)}${s}${reset}`;
|
package/package.json
CHANGED
|
@@ -12,16 +12,16 @@ allowed-tools: Bash, Read, Write, Edit, Glob, Grep
|
|
|
12
12
|
|
|
13
13
|
## Instructions
|
|
14
14
|
|
|
15
|
-
> **Note:**
|
|
15
|
+
> **Note:** The CLI automatically archives `state.md`, `tracker.json`, and a placeholder `summary.md` into `.work-kit-tracker/archive/<slug>-<date>/` when the last sub-stage completes. It also appends a row to `.work-kit-tracker/index.md`. Your job is to **replace the placeholder summary.md** with a real distilled summary.
|
|
16
16
|
|
|
17
17
|
1. **Read the full `.work-kit/state.md`** — every phase output from Plan through the last completed phase
|
|
18
|
-
2. **Synthesize the
|
|
19
|
-
3. **
|
|
18
|
+
2. **Synthesize the summary** — not a copy-paste of state, but a distilled record that a future developer (or agent) would find useful
|
|
19
|
+
3. **Overwrite the summary file** at `.work-kit-tracker/archive/<slug>-<date>/summary.md` on the **main branch** (not the worktree)
|
|
20
20
|
4. **Ask the user** if they want the worktree and branch removed
|
|
21
21
|
|
|
22
|
-
##
|
|
22
|
+
## Summary File Format
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
Overwrite `.work-kit-tracker/archive/<slug>-<date>/summary.md`:
|
|
25
25
|
|
|
26
26
|
```markdown
|
|
27
27
|
---
|
|
@@ -48,6 +48,17 @@ status: <completed | partial | rolled-back>
|
|
|
48
48
|
- <what changed and why>
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
+
## Archive Folder Structure
|
|
52
|
+
|
|
53
|
+
The CLI creates this automatically — you only need to overwrite `summary.md`:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
.work-kit-tracker/archive/<slug>-<date>/
|
|
57
|
+
├── state.md # full phase outputs (raw, from .work-kit/state.md)
|
|
58
|
+
├── tracker.json # full JSON tracker (phases, timing, status)
|
|
59
|
+
└── summary.md # distilled wrap-up summary (YOU write this)
|
|
60
|
+
```
|
|
61
|
+
|
|
51
62
|
## What to Include vs. Exclude
|
|
52
63
|
|
|
53
64
|
**Include:**
|