work-kit-cli 0.2.6 → 0.2.7

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 green(name);
175
+ case "in-progress": return tick % 2 === 0 ? boldCyan(name) : cyan(name);
176
+ case "waiting": return yellow(name);
177
+ case "failed": return 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,185 @@ 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 renderPhasePipeline(
205
+ phases: WorkItemView["phases"],
206
+ tick: number
207
+ ): string {
208
+ const segments: string[] = [];
209
+ for (const p of phases) {
210
+ const icon = phaseIndicator(p.status, tick);
211
+ const name = phaseName(p.name, p.status, tick);
212
+ segments.push(`${icon} ${name}`);
213
+ }
214
+ return segments.join(dim(" ── "));
215
+ }
216
+
217
+ // ── Sub-Stage Detail Box ────────────────────────────────────────────
218
+
219
+ function renderSubStageBox(
220
+ item: WorkItemView,
221
+ innerWidth: number,
222
+ tick: number
223
+ ): string[] {
224
+ const subs = item.phaseSubStages;
225
+ if (!subs || subs.length === 0 || !item.currentPhase) return [];
226
+
227
+ const lines: string[] = [];
228
+ const label = dim(item.currentPhase);
229
+ const boxInner = innerWidth - 8; // indent + border padding
230
+
231
+ // Top border with phase label
232
+ const labelLen = stripAnsi(label).length;
233
+ const topRule = dim("┌─ ") + label + dim(" " + "─".repeat(Math.max(0, boxInner - labelLen - 2)) + "┐");
234
+ lines.push(" " + topRule);
235
+
236
+ // Render sub-stages in rows that fit the width
237
+ const entries: string[] = [];
238
+ for (const ss of subs) {
239
+ const icon = subStageIndicator(ss.status, tick);
240
+ let nameStr: string;
241
+ switch (ss.status) {
242
+ case "completed": nameStr = green(ss.name); break;
243
+ case "in-progress": nameStr = boldCyan(ss.name); break;
244
+ case "waiting": nameStr = yellow(ss.name); break;
245
+ case "failed": nameStr = red(ss.name); break;
246
+ default: nameStr = dim(ss.name);
247
+ }
248
+ // Add duration for completed or in-progress
249
+ let duration = "";
250
+ if (ss.status === "completed" && ss.startedAt && ss.completedAt) {
251
+ const ms = new Date(ss.completedAt).getTime() - new Date(ss.startedAt).getTime();
252
+ const sec = Math.floor(ms / 1000);
253
+ if (sec < 60) duration = dim(` ${sec}s`);
254
+ else duration = dim(` ${Math.floor(sec / 60)}m`);
255
+ } else if (ss.status === "in-progress" && ss.startedAt) {
256
+ duration = dim(` ${formatDuration(ss.startedAt)}`);
257
+ }
258
+ entries.push(`${icon} ${nameStr}${duration}`);
259
+ }
260
+
261
+ // Flow entries into rows
262
+ let currentRow = "";
263
+ let currentRowLen = 0;
264
+ for (const entry of entries) {
265
+ const entryLen = stripAnsi(entry).length;
266
+ const separator = currentRowLen > 0 ? " " : "";
267
+ const sepLen = currentRowLen > 0 ? 2 : 0;
268
+
269
+ if (currentRowLen + sepLen + entryLen > boxInner && currentRowLen > 0) {
270
+ // Wrap to new row
271
+ const padded = padRight(currentRow, boxInner);
272
+ lines.push(" " + dim("│ ") + padded + dim(" │"));
273
+ currentRow = entry;
274
+ currentRowLen = entryLen;
275
+ } else {
276
+ currentRow += separator + entry;
277
+ currentRowLen += sepLen + entryLen;
278
+ }
279
+ }
280
+ // Last row
281
+ if (currentRowLen > 0) {
282
+ const padded = padRight(currentRow, boxInner);
283
+ lines.push(" " + dim("│ ") + padded + dim(" │"));
284
+ }
285
+
286
+ // Bottom border
287
+ lines.push(" " + dim("└" + "─".repeat(boxInner + 2) + "┘"));
288
+
289
+ return lines;
290
+ }
291
+
292
+ // ── Render Work Item ────────────────────────────────────────────────
293
+
294
+ function renderWorkItem(item: WorkItemView, innerWidth: number, tick: number): string[] {
123
295
  const lines: string[] = [];
124
296
 
125
- // Line 1: slug + branch (right-aligned)
297
+ // Line 1: status dot + bold slug + elapsed time (right)
126
298
  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
299
  const elapsed = formatTimeAgo(item.startedAt);
137
- let timingRight = `Elapsed: ${elapsed}`;
138
- if (item.currentPhaseStartedAt) {
139
- timingRight += ` Phase: ${formatTimeAgo(item.currentPhaseStartedAt)}`;
300
+ const elapsedText = dim(`⏱ ${elapsed}`);
301
+ const slugLen = stripAnsi(slugText).length;
302
+ const elapsedLen = stripAnsi(elapsedText).length;
303
+ const gap1 = Math.max(2, innerWidth - slugLen - elapsedLen);
304
+ lines.push(slugText + " ".repeat(gap1) + elapsedText);
305
+
306
+ // Line 2: branch + mode badge + gated badge + classification
307
+ const branchText = dim(item.branch);
308
+ let badges = " " + renderModeBadge(item.mode);
309
+ if (item.gated) badges += " " + renderGatedBadge();
310
+ if (item.status === "paused") badges += " " + bgYellow(" PAUSED ");
311
+ if (item.status === "failed") badges += " " + bgRed(" FAILED ");
312
+ if (item.classification) badges += " " + dim(item.classification);
313
+ lines.push(" " + branchText + badges);
314
+
315
+ // Line 3: timing — phase elapsed + sub-stage elapsed
316
+ const timingParts: string[] = [];
317
+ if (item.currentPhase && item.currentPhaseStartedAt) {
318
+ timingParts.push(cyan("phase") + dim(`: ${formatDuration(item.currentPhaseStartedAt)}`));
140
319
  }
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));
320
+ if (item.currentSubStage && item.currentSubStageStartedAt) {
321
+ timingParts.push(cyan("step") + dim(`: ${formatDuration(item.currentSubStageStartedAt)}`));
322
+ }
323
+ if (timingParts.length > 0) {
324
+ lines.push(" " + timingParts.join(dim(" │ ")));
325
+ }
326
+
327
+ // Line 4: progress bar with animated head
328
+ const barMaxWidth = Math.max(20, Math.min(40, innerWidth - 20));
151
329
  lines.push(" " + renderProgressBar(
152
330
  item.progress.completed,
153
331
  item.progress.total,
154
332
  item.progress.percent,
155
- phaseLabel,
156
- barMaxWidth
333
+ barMaxWidth,
334
+ tick
157
335
  ));
158
336
 
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(" "));
337
+ // Line 5: phase pipeline with connectors and spinner
338
+ lines.push(" " + renderPhasePipeline(item.phases, tick));
162
339
 
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)));
340
+ // Line 6: sub-stage detail box (all sub-stages of current phase)
341
+ const subStageBox = renderSubStageBox(item, innerWidth, tick);
342
+ if (subStageBox.length > 0) {
343
+ for (const line of subStageBox) {
344
+ lines.push(line);
175
345
  }
176
346
  }
177
347
 
178
- // Line 5 (optional): loopbacks
348
+ // Loopbacks
179
349
  if (item.loopbacks.count > 0) {
180
350
  const lb = item.loopbacks;
181
- let loopStr = ` ${cyan("⟳")} ${lb.count} loopback${lb.count > 1 ? "s" : ""}`;
351
+ let loopStr = ` ${yellow("⟳")} ${lb.count} loopback${lb.count > 1 ? "s" : ""}`;
182
352
  if (lb.lastFrom && lb.lastTo) {
183
- loopStr += `: ${lb.lastFrom} → ${lb.lastTo}`;
353
+ loopStr += dim(`: ${lb.lastFrom} → ${lb.lastTo}`);
184
354
  }
185
355
  if (lb.lastReason) {
186
- loopStr += ` (${lb.lastReason})`;
356
+ loopStr += dim(` (${lb.lastReason})`);
187
357
  }
188
358
  lines.push(loopStr);
189
359
  }
190
360
 
361
+ // Worktree path
362
+ if (item.worktreePath) {
363
+ let displayPath = item.worktreePath;
364
+ const maxPathLen = innerWidth - 4;
365
+ if (displayPath.length > maxPathLen) {
366
+ displayPath = "…" + displayPath.slice(displayPath.length - maxPathLen + 1);
367
+ }
368
+ lines.push(" " + dim(displayPath));
369
+ }
370
+
191
371
  return lines;
192
372
  }
193
373
 
@@ -200,7 +380,7 @@ interface CompletedColumnWidths {
200
380
  }
201
381
 
202
382
  function computeCompletedWidths(items: CompletedItemView[]): CompletedColumnWidths {
203
- let slug = 4, pr = 2, date = 4; // minimums
383
+ let slug = 4, pr = 2, date = 4;
204
384
  for (const item of items) {
205
385
  slug = Math.max(slug, item.slug.length);
206
386
  pr = Math.max(pr, (item.pr || "—").length);
@@ -228,26 +408,34 @@ export function renderDashboard(
228
408
  tick: number = 0
229
409
  ): string {
230
410
  const maxWidth = Math.min(width, 120);
231
- const innerWidth = maxWidth - 4; // account for "║ " and " ║"
411
+ const innerWidth = maxWidth - 4;
232
412
 
233
413
  const allLines: string[] = [];
234
414
 
235
415
  // Top border
236
416
  allLines.push(`╔${horizontalLine(maxWidth)}╗`);
237
417
 
238
- let activeCount = 0, pausedCount = 0, failedCount = 0;
418
+ // Header counts
419
+ let activeCount = 0, pausedCount = 0, failedCount = 0, waitingCount = 0;
239
420
  for (const item of data.activeItems) {
240
421
  if (item.status === "in-progress") activeCount++;
241
422
  else if (item.status === "paused") pausedCount++;
242
423
  else if (item.status === "failed") failedCount++;
424
+ if (item.currentSubStageStatus === "waiting") waitingCount++;
243
425
  }
426
+ const completedCount = data.completedItems.length;
427
+ const hasActive = activeCount > 0;
244
428
 
429
+ // Header: mascot + title + counts
245
430
  let headerRight = "";
246
431
  if (activeCount > 0) headerRight += `${green("●")} ${activeCount} active`;
432
+ if (waitingCount > 0) headerRight += ` ${yellow("◉")} ${waitingCount} waiting`;
247
433
  if (pausedCount > 0) headerRight += ` ${yellow("○")} ${pausedCount} paused`;
248
434
  if (failedCount > 0) headerRight += ` ${red("✗")} ${failedCount} failed`;
435
+ if (completedCount > 0) headerRight += ` ${green("✓")} ${completedCount} done`;
249
436
 
250
- const headerLeft = bold(" WORK-KIT OBSERVER");
437
+ const mascotStr = mascot(tick, hasActive);
438
+ const headerLeft = ` ${mascotStr} ${bold("WORK-KIT OBSERVER")}`;
251
439
  const headerLeftLen = stripAnsi(headerLeft).length;
252
440
  const headerRightLen = stripAnsi(headerRight).length;
253
441
  const headerGap = Math.max(2, innerWidth - headerLeftLen - headerRightLen);
@@ -257,7 +445,7 @@ export function renderDashboard(
257
445
  allLines.push(`╠${horizontalLine(maxWidth)}╣`);
258
446
 
259
447
  if (data.activeItems.length === 0 && data.completedItems.length === 0) {
260
- // Empty state
448
+ // Empty state with idle mascot
261
449
  allLines.push(emptyBoxLine(innerWidth));
262
450
  allLines.push(boxLine(dim(" No active work items found."), innerWidth));
263
451
  allLines.push(boxLine(dim(" Start a new work item with: work-kit init"), innerWidth));
@@ -275,6 +463,8 @@ export function renderDashboard(
275
463
  }
276
464
  if (i < data.activeItems.length - 1) {
277
465
  allLines.push(emptyBoxLine(innerWidth));
466
+ allLines.push(boxLine(dim(" " + thinLine(innerWidth - 4)), innerWidth));
467
+ allLines.push(emptyBoxLine(innerWidth));
278
468
  }
279
469
  }
280
470
 
@@ -284,7 +474,10 @@ export function renderDashboard(
284
474
  // Completed section
285
475
  if (data.completedItems.length > 0) {
286
476
  allLines.push(`╠${horizontalLine(maxWidth)}╣`);
287
- allLines.push(boxLine(bold(" COMPLETED"), innerWidth));
477
+ allLines.push(boxLine(
478
+ bold(" COMPLETED") + dim(` (${data.completedItems.length})`),
479
+ innerWidth
480
+ ));
288
481
 
289
482
  const maxCompleted = 5;
290
483
  const displayed = data.completedItems.slice(0, maxCompleted);
@@ -295,7 +488,7 @@ export function renderDashboard(
295
488
  }
296
489
  if (data.completedItems.length > maxCompleted) {
297
490
  allLines.push(boxLine(
298
- dim(` ... and ${data.completedItems.length - maxCompleted} more`),
491
+ dim(` and ${data.completedItems.length - maxCompleted} more`),
299
492
  innerWidth
300
493
  ));
301
494
  }
@@ -310,6 +503,7 @@ export function renderDashboard(
310
503
  const timeStr = data.lastUpdated.toLocaleTimeString("en-US", {
311
504
  hour: "2-digit",
312
505
  minute: "2-digit",
506
+ second: "2-digit",
313
507
  hour12: false,
314
508
  });
315
509
  const footerRight = dim(`Updated: ${timeStr}`);
@@ -321,21 +515,18 @@ export function renderDashboard(
321
515
  // Bottom border
322
516
  allLines.push(`╚${horizontalLine(maxWidth)}╝`);
323
517
 
324
- // Apply scrolling: figure out how many content lines we have vs available height
518
+ // Scrolling
325
519
  const totalLines = allLines.length;
326
520
  const availableHeight = height;
327
521
 
328
522
  if (totalLines <= availableHeight) {
329
- // Everything fits, no scrolling needed
330
- return allLines.join("\n") + "\n";
523
+ return allLines.join("\n") + "\n\x1b[J";
331
524
  }
332
525
 
333
- // Apply scroll offset
334
526
  const maxScroll = Math.max(0, totalLines - availableHeight);
335
527
  const clampedOffset = Math.min(scrollOffset, maxScroll);
336
528
  const visibleLines = allLines.slice(clampedOffset, clampedOffset + availableHeight);
337
529
 
338
- // Add scroll indicator if not showing everything
339
530
  if (clampedOffset > 0 || clampedOffset + availableHeight < totalLines) {
340
531
  const scrollPct = Math.round((clampedOffset / maxScroll) * 100);
341
532
  const indicator = dim(` [${scrollPct}% scrolled]`);
@@ -344,23 +535,23 @@ export function renderDashboard(
344
535
  }
345
536
  }
346
537
 
347
- return visibleLines.join("\n") + "\n";
538
+ return visibleLines.join("\n") + "\n\x1b[J";
348
539
  }
349
540
 
350
541
  // ── Terminal Control ────────────────────────────────────────────────
351
542
 
352
543
  export function enterAlternateScreen(): void {
353
- process.stdout.write("\x1b[?1049h"); // enter alternate screen
354
- process.stdout.write("\x1b[?25l"); // hide cursor
544
+ process.stdout.write("\x1b[?1049h");
545
+ process.stdout.write("\x1b[?25l");
355
546
  }
356
547
 
357
548
  export function exitAlternateScreen(): void {
358
- process.stdout.write("\x1b[?25h"); // show cursor
359
- process.stdout.write("\x1b[?1049l"); // exit alternate screen
549
+ process.stdout.write("\x1b[?25h");
550
+ process.stdout.write("\x1b[?1049l");
360
551
  }
361
552
 
362
553
  export function clearAndHome(): string {
363
- return "\x1b[H\x1b[2J"; // move to top-left + clear screen
554
+ return "\x1b[H\x1b[2J";
364
555
  }
365
556
 
366
557
  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.7",
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:**