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 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
- - **state.json** — state machine (current phase, sub-stage, transitions, loop-back counts)
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
- 2026-04-03-avatar-upload.md # distilled summary
108
+ index.md # log of all completed work (links to summaries + archives)
109
109
  archive/
110
- 2026-04-03-avatar-upload.md # full state.md copy
111
- index.md # log of all completed work
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 state.json file to 3 hours ago
65
- const stateFile = path.join(tmp, ".work-kit", "state.json");
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", "state.json");
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", "state.json");
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 archiveDir = path.join(wkDir, "archive");
168
+ const folderName = `${slug}-${date}`;
169
+ const archiveDir = path.join(wkDir, "archive", folderName);
169
170
 
170
- // Ensure directories exist
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
- const archivePath = path.join(archiveDir, `${date}-${slug}.md`);
177
- fs.writeFileSync(archivePath, stateMd, "utf-8");
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
- indexContent += `| ${date} | ${slug} | n/a | completed | ${completedPhases} |\n`;
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: "state.json exists but has unexpected structure" });
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: `state.json error: ${e.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 state.json and state.md", () => {
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", "state.json")));
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", "state.json"), "utf-8")
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", "state.json"), "utf-8")
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");
@@ -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/state.json first.",
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 stateFile = path.join(projectDir, ".work-kit", "state.json");
56
- if (fs.existsSync(stateFile)) {
57
- console.error(yellow("\nWarning: Active work-kit state found (.work-kit/state.json)."));
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
 
@@ -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 { bold, dim, green, yellow, red, cyan, bgYellow } from "../utils/colors.js";
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
- label: string,
78
- maxBarWidth: number
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 filledStr = green("█".repeat(filled));
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 = `${label} ${completed}/${total} ${percent}%`;
141
+ const stats = dim(`${completed}/${total}`) + " " + colorFn(`${percent}%`);
87
142
 
88
- return `${filledStr}${emptyStr} ${stats}`;
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 % 2 === 0 ? cyan("▶") : dim("▶");
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
- // ── Render Work Item ────────────────────────────────────────────────
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 formatMode(mode: string, classification?: string): string {
118
- const label = mode === "full-kit" ? "Full Kit" : "Auto Kit";
119
- return classification ? `${label} · ${classification}` : label;
198
+ function renderGatedBadge(): string {
199
+ return bgMagenta(" GATED ");
120
200
  }
121
201
 
122
- function renderWorkItem(item: WorkItemView, innerWidth: number, tick: number = 0): string[] {
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 + branch (right-aligned)
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
- let timingRight = `Elapsed: ${elapsed}`;
138
- if (item.currentPhaseStartedAt) {
139
- timingRight += ` Phase: ${formatTimeAgo(item.currentPhaseStartedAt)}`;
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
- const timingText = dim(timingRight);
142
- const modeStr = ` ${modeText}${pausedBadge}`;
143
- const modePlainLen = stripAnsi(modeStr).length;
144
- const timingPlainLen = stripAnsi(timingText).length;
145
- const gap2 = Math.max(2, innerWidth - modePlainLen - timingPlainLen);
146
- lines.push(modeStr + " ".repeat(gap2) + timingText);
147
-
148
- // Line 3: progress bar with phase label only (no sub-stage inline)
149
- const phaseLabel = item.currentPhase || "—";
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
- phaseLabel,
156
- barMaxWidth
395
+ barMaxWidth,
396
+ tick
157
397
  ));
158
398
 
159
- // Line 4: phase indicators with sub-stage shown under current phase
160
- const phaseStrs = item.phases.map(p => `${p.name} ${phaseIndicator(p.status, tick)}`);
161
- lines.push(" " + phaseStrs.join(" "));
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 5 (optional): current sub-stage detail under the phase line
164
- if (item.currentSubStage && item.currentPhase) {
165
- const isWaiting = item.currentSubStageStatus === "waiting";
166
- let subLabel = `↳ ${item.currentSubStage}`;
167
- if (item.currentSubStageIndex != null && item.currentPhaseTotal != null) {
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
- // Line 5 (optional): loopbacks
413
+ // Loopbacks
179
414
  if (item.loopbacks.count > 0) {
180
415
  const lb = item.loopbacks;
181
- let loopStr = ` ${cyan("⟳")} ${lb.count} loopback${lb.count > 1 ? "s" : ""}`;
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; // minimums
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; // account for "║ " and " ║"
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
- let activeCount = 0, pausedCount = 0, failedCount = 0;
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 headerLeft = bold(" WORK-KIT OBSERVER");
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(bold(" COMPLETED"), innerWidth));
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(` ... and ${data.completedItems.length - maxCompleted} more`),
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
- // Apply scrolling: figure out how many content lines we have vs available height
583
+ // Scrolling
325
584
  const totalLines = allLines.length;
326
585
  const availableHeight = height;
327
586
 
328
587
  if (totalLines <= availableHeight) {
329
- // Everything fits, no scrolling needed
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"); // enter alternate screen
354
- process.stdout.write("\x1b[?25l"); // hide cursor
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"); // show cursor
359
- process.stdout.write("\x1b[?1049l"); // exit alternate screen
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"; // move to top-left + clear screen
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 === "state.json") {
38
+ if (filename === "tracker.json") {
39
39
  debouncedUpdate();
40
40
  }
41
41
  });
@@ -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 = "state.json";
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 state.json found at ${filePath}`);
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 state.json at ${filePath}. File contains invalid JSON.`);
50
+ throw new Error(`Corrupted tracker.json at ${filePath}. File contains invalid JSON.`);
51
51
  }
52
52
  }
53
53
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "work-kit-cli",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Structured development workflow for Claude Code. Two modes, 6 phases, 27 sub-stages.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,16 +12,16 @@ allowed-tools: Bash, Read, Write, Edit, Glob, Grep
12
12
 
13
13
  ## Instructions
14
14
 
15
- > **Note:** Archiving state.md and appending to `.work-kit-tracker/index.md` are handled automatically by the CLI when you run `work-kit complete` on the last sub-stage. You do NOT need to do these manually.
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 work-kit log entry** — not a copy-paste of state, but a distilled record that a future developer (or agent) would find useful
19
- 3. **Write the summary file** to `.work-kit-tracker/<date>-<slug>.md` on the **main branch** (not the worktree)
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
- ## Work-Kit Log Entry Format
22
+ ## Summary File Format
23
23
 
24
- Write to `.work-kit-tracker/<YYYY-MM-DD>-<slug>.md`:
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:**