work-kit-cli 0.2.5 → 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 +6 -4
- package/cli/src/commands/bootstrap.test.ts +4 -4
- package/cli/src/commands/cancel.ts +103 -0
- 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/index.ts +18 -0
- package/cli/src/observer/data.ts +21 -0
- package/cli/src/observer/renderer.ts +277 -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/cancel-kit/SKILL.md +34 -0
- 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));
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { readState, findWorktreeRoot, stateDir } from "../state/store.js";
|
|
5
|
+
|
|
6
|
+
export interface CancelResult {
|
|
7
|
+
action: "cancelled" | "error";
|
|
8
|
+
slug?: string;
|
|
9
|
+
branch?: string;
|
|
10
|
+
worktreeRemoved: boolean;
|
|
11
|
+
branchDeleted: boolean;
|
|
12
|
+
message: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resolveMainRepoRoot(worktreeRoot: string): string {
|
|
16
|
+
try {
|
|
17
|
+
const output = execFileSync("git", ["worktree", "list", "--porcelain"], {
|
|
18
|
+
cwd: worktreeRoot,
|
|
19
|
+
encoding: "utf-8",
|
|
20
|
+
timeout: 5000,
|
|
21
|
+
});
|
|
22
|
+
const firstLine = output.split("\n").find(l => l.startsWith("worktree "));
|
|
23
|
+
if (firstLine) return firstLine.slice("worktree ".length).trim();
|
|
24
|
+
} catch {
|
|
25
|
+
// fallback
|
|
26
|
+
}
|
|
27
|
+
return worktreeRoot;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function cancelCommand(worktreeRoot?: string): CancelResult {
|
|
31
|
+
const root = worktreeRoot || findWorktreeRoot();
|
|
32
|
+
if (!root) {
|
|
33
|
+
return {
|
|
34
|
+
action: "error",
|
|
35
|
+
worktreeRemoved: false,
|
|
36
|
+
branchDeleted: false,
|
|
37
|
+
message: "No work-kit state found. Nothing to cancel.",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const state = readState(root);
|
|
42
|
+
|
|
43
|
+
if (state.status === "completed") {
|
|
44
|
+
return {
|
|
45
|
+
action: "error",
|
|
46
|
+
slug: state.slug,
|
|
47
|
+
worktreeRemoved: false,
|
|
48
|
+
branchDeleted: false,
|
|
49
|
+
message: `${state.slug} is already completed. Nothing to cancel.`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const slug = state.slug;
|
|
54
|
+
const branch = state.branch;
|
|
55
|
+
const mainRoot = resolveMainRepoRoot(root);
|
|
56
|
+
const isWorktree = path.resolve(root) !== path.resolve(mainRoot);
|
|
57
|
+
|
|
58
|
+
let worktreeRemoved = false;
|
|
59
|
+
let branchDeleted = false;
|
|
60
|
+
|
|
61
|
+
// Remove .work-kit/ state directory
|
|
62
|
+
const stDir = stateDir(root);
|
|
63
|
+
if (fs.existsSync(stDir)) {
|
|
64
|
+
fs.rmSync(stDir, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Remove the worktree (if we're in one)
|
|
68
|
+
if (isWorktree) {
|
|
69
|
+
try {
|
|
70
|
+
execFileSync("git", ["worktree", "remove", root, "--force"], {
|
|
71
|
+
cwd: mainRoot,
|
|
72
|
+
encoding: "utf-8",
|
|
73
|
+
timeout: 10000,
|
|
74
|
+
});
|
|
75
|
+
worktreeRemoved = true;
|
|
76
|
+
} catch {
|
|
77
|
+
// Worktree removal failed — may need manual cleanup
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Delete the feature branch
|
|
82
|
+
if (branch) {
|
|
83
|
+
try {
|
|
84
|
+
execFileSync("git", ["branch", "-D", branch], {
|
|
85
|
+
cwd: mainRoot,
|
|
86
|
+
encoding: "utf-8",
|
|
87
|
+
timeout: 5000,
|
|
88
|
+
});
|
|
89
|
+
branchDeleted = true;
|
|
90
|
+
} catch {
|
|
91
|
+
// Branch may not exist or may be checked out elsewhere
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
action: "cancelled",
|
|
97
|
+
slug,
|
|
98
|
+
branch,
|
|
99
|
+
worktreeRemoved,
|
|
100
|
+
branchDeleted,
|
|
101
|
+
message: `Cancelled ${slug}.${worktreeRemoved ? " Worktree removed." : ""}${branchDeleted ? " Branch deleted." : ""}`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -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/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { completionsCommand } from "./commands/completions.js";
|
|
|
16
16
|
import { observeCommand } from "./commands/observe.js";
|
|
17
17
|
import { uninstallCommand } from "./commands/uninstall.js";
|
|
18
18
|
import { bootstrapCommand } from "./commands/bootstrap.js";
|
|
19
|
+
import { cancelCommand } from "./commands/cancel.js";
|
|
19
20
|
import { bold, green, yellow, red } from "./utils/colors.js";
|
|
20
21
|
import type { Classification, PhaseName } from "./state/schema.js";
|
|
21
22
|
|
|
@@ -269,4 +270,21 @@ program
|
|
|
269
270
|
}
|
|
270
271
|
});
|
|
271
272
|
|
|
273
|
+
// ── cancel ──────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
program
|
|
276
|
+
.command("cancel")
|
|
277
|
+
.description("Cancel the active work-kit, remove worktree and branch")
|
|
278
|
+
.option("--worktree-root <path>", "Override worktree root")
|
|
279
|
+
.action((opts) => {
|
|
280
|
+
try {
|
|
281
|
+
const result = cancelCommand(opts.worktreeRoot);
|
|
282
|
+
console.log(JSON.stringify(result, null, 2));
|
|
283
|
+
process.exit(result.action === "error" ? 1 : 0);
|
|
284
|
+
} catch (e: any) {
|
|
285
|
+
console.error(JSON.stringify({ action: "error", message: e.message }));
|
|
286
|
+
process.exit(1);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
272
290
|
program.parse();
|
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 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
|
-
// ──
|
|
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 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 +
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
// Line
|
|
149
|
-
const
|
|
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
|
-
|
|
156
|
-
|
|
333
|
+
barMaxWidth,
|
|
334
|
+
tick
|
|
157
335
|
));
|
|
158
336
|
|
|
159
|
-
// Line
|
|
160
|
-
|
|
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
|
|
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)));
|
|
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
|
-
//
|
|
348
|
+
// Loopbacks
|
|
179
349
|
if (item.loopbacks.count > 0) {
|
|
180
350
|
const lb = item.loopbacks;
|
|
181
|
-
let loopStr = ` ${
|
|
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;
|
|
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;
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(`
|
|
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
|
-
//
|
|
518
|
+
// Scrolling
|
|
325
519
|
const totalLines = allLines.length;
|
|
326
520
|
const availableHeight = height;
|
|
327
521
|
|
|
328
522
|
if (totalLines <= availableHeight) {
|
|
329
|
-
|
|
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");
|
|
354
|
-
process.stdout.write("\x1b[?25l");
|
|
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");
|
|
359
|
-
process.stdout.write("\x1b[?1049l");
|
|
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";
|
|
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 === "
|
|
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
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cancel-kit
|
|
3
|
+
description: "Cancel the active work-kit session. Removes state, worktree, and feature branch."
|
|
4
|
+
user-invocable: true
|
|
5
|
+
allowed-tools: Bash, Read
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Cancel Kit
|
|
9
|
+
|
|
10
|
+
Cancels the active work-kit session and cleans up all artifacts.
|
|
11
|
+
|
|
12
|
+
## What it does
|
|
13
|
+
|
|
14
|
+
1. Finds the active work-kit session (via `bootstrap`)
|
|
15
|
+
2. Confirms with the user before proceeding
|
|
16
|
+
3. Runs `npx work-kit-cli cancel` to:
|
|
17
|
+
- Remove `.work-kit/` state directory
|
|
18
|
+
- Remove the git worktree
|
|
19
|
+
- Delete the feature branch
|
|
20
|
+
4. Reports what was cleaned up
|
|
21
|
+
|
|
22
|
+
## Instructions
|
|
23
|
+
|
|
24
|
+
1. Run `npx work-kit-cli bootstrap` to detect the active session
|
|
25
|
+
2. If no active session: tell the user there's nothing to cancel
|
|
26
|
+
3. If active: show the user what will be cancelled:
|
|
27
|
+
- Slug and branch name
|
|
28
|
+
- Current phase and sub-stage
|
|
29
|
+
- Any uncommitted work in the worktree will be lost
|
|
30
|
+
4. **Ask the user to confirm** — do not proceed without explicit confirmation
|
|
31
|
+
5. `cd` into the worktree directory
|
|
32
|
+
6. Run `npx work-kit-cli cancel`
|
|
33
|
+
7. `cd` back to the main repo root
|
|
34
|
+
8. Report the result to the user
|
|
@@ -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:**
|