work-kit-cli 0.2.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +24 -13
  2. package/cli/src/commands/bootstrap.test.ts +40 -0
  3. package/cli/src/commands/bootstrap.ts +77 -13
  4. package/cli/src/commands/cancel.ts +1 -16
  5. package/cli/src/commands/complete.ts +92 -98
  6. package/cli/src/commands/completions.ts +2 -2
  7. package/cli/src/commands/doctor.ts +1 -1
  8. package/cli/src/commands/extract.ts +217 -0
  9. package/cli/src/commands/init.test.ts +50 -0
  10. package/cli/src/commands/init.ts +70 -35
  11. package/cli/src/commands/learn.test.ts +217 -0
  12. package/cli/src/commands/learn.ts +104 -0
  13. package/cli/src/commands/loopback.ts +8 -11
  14. package/cli/src/commands/next.ts +93 -60
  15. package/cli/src/commands/observe.ts +16 -21
  16. package/cli/src/commands/pause-resume.test.ts +142 -0
  17. package/cli/src/commands/pause.ts +34 -0
  18. package/cli/src/commands/report.ts +217 -0
  19. package/cli/src/commands/resume.ts +126 -0
  20. package/cli/src/commands/setup.ts +280 -0
  21. package/cli/src/commands/status.ts +8 -6
  22. package/cli/src/commands/uninstall.ts +8 -3
  23. package/cli/src/commands/workflow.ts +43 -33
  24. package/cli/src/config/agent-map.ts +9 -9
  25. package/cli/src/config/constants.ts +54 -0
  26. package/cli/src/config/loopback-routes.ts +13 -13
  27. package/cli/src/config/model-routing.test.ts +190 -0
  28. package/cli/src/config/model-routing.ts +208 -0
  29. package/cli/src/config/project-config.test.ts +127 -0
  30. package/cli/src/config/project-config.ts +106 -0
  31. package/cli/src/config/{phases.ts → workflow.ts} +40 -23
  32. package/cli/src/context/prompt-builder.ts +10 -9
  33. package/cli/src/index.ts +130 -9
  34. package/cli/src/observer/data.ts +196 -65
  35. package/cli/src/observer/renderer.ts +127 -107
  36. package/cli/src/observer/watcher.ts +28 -16
  37. package/cli/src/state/helpers.test.ts +28 -28
  38. package/cli/src/state/helpers.ts +37 -25
  39. package/cli/src/state/schema.ts +135 -45
  40. package/cli/src/state/store.ts +127 -7
  41. package/cli/src/state/validators.test.ts +13 -13
  42. package/cli/src/state/validators.ts +3 -4
  43. package/cli/src/utils/colors.ts +2 -0
  44. package/cli/src/utils/fs.ts +13 -0
  45. package/cli/src/utils/json.ts +20 -0
  46. package/cli/src/utils/knowledge.ts +471 -0
  47. package/cli/src/utils/time.ts +27 -0
  48. package/cli/src/{engine → workflow}/loopbacks.test.ts +2 -2
  49. package/cli/src/workflow/loopbacks.ts +42 -0
  50. package/cli/src/workflow/parallel.ts +64 -0
  51. package/cli/src/workflow/transitions.test.ts +129 -0
  52. package/cli/src/{engine → workflow}/transitions.ts +18 -22
  53. package/package.json +2 -2
  54. package/skills/auto-kit/SKILL.md +44 -27
  55. package/skills/cancel-kit/SKILL.md +4 -4
  56. package/skills/full-kit/SKILL.md +45 -28
  57. package/skills/pause-kit/SKILL.md +25 -0
  58. package/skills/resume-kit/SKILL.md +64 -0
  59. package/skills/wk-bootstrap/SKILL.md +11 -5
  60. package/skills/wk-build/SKILL.md +12 -11
  61. package/skills/wk-build/{stages → steps}/commit.md +1 -1
  62. package/skills/wk-build/{stages → steps}/core.md +3 -3
  63. package/skills/wk-build/{stages → steps}/integration.md +2 -2
  64. package/skills/wk-build/{stages → steps}/migration.md +1 -1
  65. package/skills/wk-build/{stages → steps}/red.md +1 -1
  66. package/skills/wk-build/{stages → steps}/refactor.md +1 -1
  67. package/skills/wk-build/{stages → steps}/setup.md +1 -1
  68. package/skills/wk-build/{stages → steps}/ui.md +1 -1
  69. package/skills/wk-deploy/SKILL.md +7 -6
  70. package/skills/wk-deploy/{stages → steps}/merge.md +1 -1
  71. package/skills/wk-deploy/{stages → steps}/monitor.md +1 -1
  72. package/skills/wk-deploy/{stages → steps}/remediate.md +1 -1
  73. package/skills/wk-plan/SKILL.md +15 -14
  74. package/skills/wk-plan/{stages → steps}/architecture.md +1 -1
  75. package/skills/wk-plan/{stages → steps}/audit.md +2 -2
  76. package/skills/wk-plan/{stages → steps}/blueprint.md +2 -2
  77. package/skills/wk-plan/{stages → steps}/clarify.md +1 -1
  78. package/skills/wk-plan/{stages → steps}/investigate.md +1 -1
  79. package/skills/wk-plan/{stages → steps}/scope.md +1 -1
  80. package/skills/wk-plan/{stages → steps}/sketch.md +1 -1
  81. package/skills/wk-plan/{stages → steps}/ux-flow.md +1 -1
  82. package/skills/wk-review/SKILL.md +11 -10
  83. package/skills/wk-review/{stages → steps}/compliance.md +1 -1
  84. package/skills/wk-review/{stages → steps}/handoff.md +2 -2
  85. package/skills/wk-review/{stages → steps}/performance.md +1 -1
  86. package/skills/wk-review/{stages → steps}/security.md +1 -1
  87. package/skills/wk-review/{stages → steps}/self-review.md +1 -1
  88. package/skills/wk-test/SKILL.md +9 -8
  89. package/skills/wk-test/steps/e2e.md +56 -0
  90. package/skills/wk-test/{stages → steps}/validate.md +1 -1
  91. package/skills/wk-test/{stages → steps}/verify.md +1 -1
  92. package/skills/wk-wrap-up/SKILL.md +19 -5
  93. package/skills/wk-wrap-up/steps/knowledge.md +76 -0
  94. package/skills/wk-wrap-up/steps/summary.md +86 -0
  95. package/cli/src/engine/loopbacks.ts +0 -32
  96. package/cli/src/engine/parallel.ts +0 -60
  97. package/cli/src/engine/transitions.test.ts +0 -129
  98. package/skills/wk-test/stages/e2e.md +0 -53
  99. /package/cli/src/{engine/phases.ts → workflow/gates.ts} +0 -0
@@ -1,5 +1,4 @@
1
1
  import * as path from "node:path";
2
- import { execFileSync } from "node:child_process";
3
2
  import {
4
3
  renderDashboard,
5
4
  enterAlternateScreen,
@@ -7,27 +6,23 @@ import {
7
6
  moveCursorHome,
8
7
  renderTooSmall,
9
8
  } from "../observer/renderer.js";
10
- import { collectDashboardData } from "../observer/data.js";
9
+ import { collectDashboardData, discoverWorkKitProjects } from "../observer/data.js";
11
10
  import { startWatching } from "../observer/watcher.js";
11
+ import { gitMainRepoRoot } from "../state/store.js";
12
12
 
13
- function findMainRepoRoot(startDir: string): string {
14
- // Find the git toplevel
15
- try {
16
- const result = execFileSync("git", ["rev-parse", "--show-toplevel"], {
17
- cwd: startDir,
18
- encoding: "utf-8",
19
- timeout: 5000,
20
- });
21
- return result.trim();
22
- } catch {
23
- return startDir;
24
- }
25
- }
13
+ export async function observeCommand(opts: { mainRepo?: string; all?: boolean }): Promise<void> {
14
+ const cwdRoot = () => gitMainRepoRoot(process.cwd()) ?? process.cwd();
26
15
 
27
- export async function observeCommand(opts: { mainRepo?: string }): Promise<void> {
28
- const mainRepoRoot = opts.mainRepo
29
- ? path.resolve(opts.mainRepo)
30
- : findMainRepoRoot(process.cwd());
16
+ let mainRepoRoots: string[];
17
+ if (opts.all) {
18
+ mainRepoRoots = discoverWorkKitProjects();
19
+ if (mainRepoRoots.length === 0) {
20
+ // Fallback to current repo so the dashboard still has something to show
21
+ mainRepoRoots = [cwdRoot()];
22
+ }
23
+ } else {
24
+ mainRepoRoots = [opts.mainRepo ? path.resolve(opts.mainRepo) : cwdRoot()];
25
+ }
31
26
 
32
27
  let scrollOffset = 0;
33
28
  let tick = 0;
@@ -58,7 +53,7 @@ export async function observeCommand(opts: { mainRepo?: string }): Promise<void>
58
53
  return;
59
54
  }
60
55
 
61
- const data = collectDashboardData(mainRepoRoot, watcher.getWorktrees());
56
+ const data = collectDashboardData(mainRepoRoots, watcher.getWorktrees());
62
57
  const frame = moveCursorHome() + renderDashboard(data, width, height, scrollOffset, tick);
63
58
  process.stdout.write(frame);
64
59
  }
@@ -78,7 +73,7 @@ export async function observeCommand(opts: { mainRepo?: string }): Promise<void>
78
73
 
79
74
  try {
80
75
  // Set up file watching (before initial render so worktrees are cached)
81
- watcher = startWatching(mainRepoRoot, () => {
76
+ watcher = startWatching(mainRepoRoots, () => {
82
77
  render();
83
78
  });
84
79
 
@@ -0,0 +1,142 @@
1
+ import { describe, it, afterEach } from "node:test";
2
+ import * as assert from "node:assert/strict";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import * as os from "node:os";
6
+ import { randomUUID } from "node:crypto";
7
+ import { initCommand } from "./init.js";
8
+ import { pauseCommand } from "./pause.js";
9
+ import { resumeCommand } from "./resume.js";
10
+ import { nextCommand } from "./next.js";
11
+ import { bootstrapCommand } from "./bootstrap.js";
12
+ import { completeCommand } from "./complete.js";
13
+
14
+ function makeTmpDir(): string {
15
+ const dir = path.join(os.tmpdir(), `work-kit-pause-${randomUUID()}`);
16
+ fs.mkdirSync(dir, { recursive: true });
17
+ return dir;
18
+ }
19
+
20
+ let tmpDirs: string[] = [];
21
+
22
+ afterEach(() => {
23
+ for (const dir of tmpDirs) {
24
+ fs.rmSync(dir, { recursive: true, force: true });
25
+ }
26
+ tmpDirs = [];
27
+ });
28
+
29
+ describe("pause / resume", () => {
30
+ it("pause flips status and records timestamp", () => {
31
+ const tmp = makeTmpDir();
32
+ tmpDirs.push(tmp);
33
+ initCommand({ mode: "full", description: "Pause test", worktreeRoot: tmp });
34
+
35
+ const result = pauseCommand("lunch", tmp);
36
+ assert.equal(result.action, "paused");
37
+
38
+ const tracker = JSON.parse(fs.readFileSync(path.join(tmp, ".work-kit", "tracker.json"), "utf-8"));
39
+ assert.equal(tracker.status, "paused");
40
+ assert.ok(tracker.pausedAt);
41
+ });
42
+
43
+ it("pause is idempotent — second pause errors", () => {
44
+ const tmp = makeTmpDir();
45
+ tmpDirs.push(tmp);
46
+ initCommand({ mode: "full", description: "Double pause", worktreeRoot: tmp });
47
+
48
+ pauseCommand(undefined, tmp);
49
+ const result = pauseCommand(undefined, tmp);
50
+ assert.equal(result.action, "error");
51
+ });
52
+
53
+ it("resume flips status back and clears pausedAt", () => {
54
+ const tmp = makeTmpDir();
55
+ tmpDirs.push(tmp);
56
+ initCommand({ mode: "full", description: "Resume test", worktreeRoot: tmp });
57
+ pauseCommand(undefined, tmp);
58
+
59
+ const result = resumeCommand({ worktreeRoot: tmp });
60
+ assert.equal(result.action, "resumed");
61
+
62
+ const tracker = JSON.parse(fs.readFileSync(path.join(tmp, ".work-kit", "tracker.json"), "utf-8"));
63
+ assert.equal(tracker.status, "in-progress");
64
+ assert.equal(tracker.pausedAt, undefined);
65
+ });
66
+
67
+ it("resume on already in-progress is idempotent", () => {
68
+ const tmp = makeTmpDir();
69
+ tmpDirs.push(tmp);
70
+ initCommand({ mode: "full", description: "Already running", worktreeRoot: tmp });
71
+
72
+ const result = resumeCommand({ worktreeRoot: tmp });
73
+ assert.equal(result.action, "resumed");
74
+ });
75
+
76
+ it("next refuses to advance a paused session", () => {
77
+ const tmp = makeTmpDir();
78
+ tmpDirs.push(tmp);
79
+ initCommand({ mode: "full", description: "Paused next", worktreeRoot: tmp });
80
+ pauseCommand(undefined, tmp);
81
+
82
+ const result = nextCommand(tmp);
83
+ assert.equal(result.action, "error");
84
+ if (result.action === "error") {
85
+ assert.ok(result.message.includes("paused"));
86
+ }
87
+ });
88
+
89
+ it("bootstrap --auto-resume flips paused → in-progress", () => {
90
+ const tmp = makeTmpDir();
91
+ tmpDirs.push(tmp);
92
+ initCommand({ mode: "full", description: "Auto resume", worktreeRoot: tmp });
93
+ pauseCommand(undefined, tmp);
94
+
95
+ const result = bootstrapCommand(tmp, { autoResume: true });
96
+ assert.equal(result.status, "in-progress");
97
+ assert.equal(result.resumed, true);
98
+ });
99
+
100
+ it("bootstrap without --auto-resume leaves paused state alone", () => {
101
+ const tmp = makeTmpDir();
102
+ tmpDirs.push(tmp);
103
+ initCommand({ mode: "full", description: "No auto", worktreeRoot: tmp });
104
+ pauseCommand(undefined, tmp);
105
+
106
+ const result = bootstrapCommand(tmp);
107
+ assert.equal(result.status, "paused");
108
+ assert.notEqual(result.resumed, true);
109
+ });
110
+ });
111
+
112
+ describe("complete outcome validation", () => {
113
+ it("rejects invalid outcomes", () => {
114
+ const tmp = makeTmpDir();
115
+ tmpDirs.push(tmp);
116
+ initCommand({ mode: "full", description: "Outcome test", worktreeRoot: tmp });
117
+
118
+ const result = completeCommand("plan/clarify", "totally-bogus", tmp);
119
+ assert.equal(result.action, "error");
120
+ if (result.action === "error") {
121
+ assert.ok(result.message.includes("Invalid outcome"));
122
+ }
123
+ });
124
+
125
+ it("accepts known outcomes", () => {
126
+ const tmp = makeTmpDir();
127
+ tmpDirs.push(tmp);
128
+ initCommand({ mode: "full", description: "Outcome ok", worktreeRoot: tmp });
129
+
130
+ const result = completeCommand("plan/clarify", "done", tmp);
131
+ assert.notEqual(result.action, "error");
132
+ });
133
+
134
+ it("accepts undefined outcome", () => {
135
+ const tmp = makeTmpDir();
136
+ tmpDirs.push(tmp);
137
+ initCommand({ mode: "full", description: "No outcome", worktreeRoot: tmp });
138
+
139
+ const result = completeCommand("plan/clarify", undefined, tmp);
140
+ assert.notEqual(result.action, "error");
141
+ });
142
+ });
@@ -0,0 +1,34 @@
1
+ import { readState, writeState, findWorktreeRoot } from "../state/store.js";
2
+ import type { Action } from "../state/schema.js";
3
+
4
+ export function pauseCommand(reason?: string, worktreeRoot?: string): Action {
5
+ const root = worktreeRoot || findWorktreeRoot();
6
+ if (!root) {
7
+ return { action: "error", message: "No work-kit state found." };
8
+ }
9
+
10
+ const state = readState(root);
11
+
12
+ if (state.status === "completed") {
13
+ return { action: "error", message: `${state.slug} is already completed; nothing to pause.` };
14
+ }
15
+ if (state.status === "paused") {
16
+ return { action: "error", message: `${state.slug} is already paused (since ${state.pausedAt}).` };
17
+ }
18
+ if (state.status === "failed") {
19
+ return { action: "error", message: `${state.slug} is in failed state; cannot pause.` };
20
+ }
21
+
22
+ state.status = "paused";
23
+ state.pausedAt = new Date().toISOString();
24
+ writeState(root, state);
25
+
26
+ const where = state.currentPhase
27
+ ? ` at ${state.currentPhase}${state.currentStep ? "/" + state.currentStep : ""}`
28
+ : "";
29
+
30
+ return {
31
+ action: "paused",
32
+ message: `Paused ${state.slug}${where}.${reason ? ` Reason: ${reason}` : ""} Run \`work-kit resume\` to continue.`,
33
+ };
34
+ }
@@ -0,0 +1,217 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { findWorktreeRoot, resolveMainRepoRoot } from "../state/store.js";
4
+ import { TRACKER_DIR, ARCHIVE_DIR, INDEX_FILE } from "../config/constants.js";
5
+ import {
6
+ PHASE_NAMES,
7
+ MODE_FULL,
8
+ MODE_AUTO,
9
+ type PhaseName,
10
+ type Classification,
11
+ type WorkKitState,
12
+ } from "../state/schema.js";
13
+ import { readJsonFile } from "../utils/json.js";
14
+ import { durationMs, formatDurationMs } from "../utils/time.js";
15
+ import { bold, cyan, dim, green, yellow } from "../utils/colors.js";
16
+
17
+ const RECENT_LIMIT = 10;
18
+
19
+ export interface PhaseStats {
20
+ runs: number;
21
+ avgDurationMs: number;
22
+ totalDurationMs: number;
23
+ }
24
+
25
+ export interface RecentEntry {
26
+ slug: string;
27
+ completedAt: string;
28
+ classification?: Classification;
29
+ mode: typeof MODE_FULL | typeof MODE_AUTO;
30
+ durationMs?: number;
31
+ }
32
+
33
+ export interface ReportData {
34
+ totalCompleted: number;
35
+ byClassification: Partial<Record<Classification | "unclassified", number>>;
36
+ byMode: Partial<Record<typeof MODE_FULL | typeof MODE_AUTO, number>>;
37
+ avgDurationMs: number;
38
+ totalLoopbacks: number;
39
+ loopbackRate: number;
40
+ perPhase: Record<PhaseName, PhaseStats>;
41
+ recent: RecentEntry[];
42
+ source: { mainRepoRoot: string; trackerDir: string };
43
+ }
44
+
45
+ function emptyPhaseStats(): PhaseStats {
46
+ return { runs: 0, avgDurationMs: 0, totalDurationMs: 0 };
47
+ }
48
+
49
+ export function collectReport(mainRepoRoot: string): ReportData {
50
+ const trackerDir = path.join(mainRepoRoot, TRACKER_DIR);
51
+ const archiveDir = path.join(trackerDir, ARCHIVE_DIR);
52
+
53
+ const data: ReportData = {
54
+ totalCompleted: 0,
55
+ byClassification: {},
56
+ byMode: {},
57
+ avgDurationMs: 0,
58
+ totalLoopbacks: 0,
59
+ loopbackRate: 0,
60
+ perPhase: PHASE_NAMES.reduce((acc, p) => {
61
+ acc[p] = emptyPhaseStats();
62
+ return acc;
63
+ }, {} as Record<PhaseName, PhaseStats>),
64
+ recent: [],
65
+ source: { mainRepoRoot, trackerDir },
66
+ };
67
+
68
+ let folders: string[];
69
+ try {
70
+ folders = fs.readdirSync(archiveDir, { withFileTypes: true })
71
+ .filter(d => d.isDirectory())
72
+ .map(d => d.name);
73
+ } catch {
74
+ return data;
75
+ }
76
+
77
+ let totalRunDurationMs = 0;
78
+ let runsWithDuration = 0;
79
+ // Bounded min-heap-by-completedAt would be ideal; for ≤10 entries
80
+ // a simple sorted insertion is cheaper than a full sort at the end.
81
+ const keepRecent = (entry: RecentEntry) => {
82
+ data.recent.push(entry);
83
+ data.recent.sort((a, b) => (a.completedAt < b.completedAt ? 1 : -1));
84
+ if (data.recent.length > RECENT_LIMIT) data.recent.length = RECENT_LIMIT;
85
+ };
86
+
87
+ for (const folder of folders) {
88
+ const state = readJsonFile<WorkKitState>(path.join(archiveDir, folder, "tracker.json"));
89
+ if (!state) continue;
90
+
91
+ data.totalCompleted++;
92
+
93
+ const cls = state.classification ?? "unclassified";
94
+ data.byClassification[cls] = (data.byClassification[cls] ?? 0) + 1;
95
+ data.byMode[state.mode] = (data.byMode[state.mode] ?? 0) + 1;
96
+ data.totalLoopbacks += state.loopbacks?.length ?? 0;
97
+
98
+ let runDuration = 0;
99
+ let lastEnd: string | undefined;
100
+ for (const phase of PHASE_NAMES) {
101
+ const ps = state.phases[phase];
102
+ if (!ps || ps.status !== "completed") continue;
103
+ const d = durationMs(ps.startedAt, ps.completedAt);
104
+ data.perPhase[phase].runs++;
105
+ data.perPhase[phase].totalDurationMs += d;
106
+ runDuration += d;
107
+ if (ps.completedAt && (!lastEnd || ps.completedAt > lastEnd)) lastEnd = ps.completedAt;
108
+ }
109
+
110
+ if (runDuration > 0) {
111
+ totalRunDurationMs += runDuration;
112
+ runsWithDuration++;
113
+ }
114
+
115
+ keepRecent({
116
+ slug: state.slug,
117
+ completedAt: lastEnd ?? state.started,
118
+ classification: state.classification,
119
+ mode: state.mode,
120
+ durationMs: runDuration > 0 ? runDuration : undefined,
121
+ });
122
+ }
123
+
124
+ for (const phase of PHASE_NAMES) {
125
+ const s = data.perPhase[phase];
126
+ s.avgDurationMs = s.runs > 0 ? Math.round(s.totalDurationMs / s.runs) : 0;
127
+ }
128
+ data.avgDurationMs = runsWithDuration > 0 ? Math.round(totalRunDurationMs / runsWithDuration) : 0;
129
+ data.loopbackRate = data.totalCompleted > 0 ? data.totalLoopbacks / data.totalCompleted : 0;
130
+
131
+ return data;
132
+ }
133
+
134
+ export interface ReportOptions {
135
+ json?: boolean;
136
+ worktreeRoot?: string;
137
+ repo?: string;
138
+ }
139
+
140
+ export function reportCommand(options: ReportOptions = {}): ReportData {
141
+ const mainRepoRoot = options.repo
142
+ ? path.resolve(options.repo)
143
+ : resolveMainRepoRoot(options.worktreeRoot || findWorktreeRoot() || process.cwd());
144
+
145
+ const data = collectReport(mainRepoRoot);
146
+
147
+ if (options.json) {
148
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
149
+ return data;
150
+ }
151
+
152
+ const out: string[] = [];
153
+ out.push("");
154
+ out.push(bold(" WORK-KIT REPORT"));
155
+ out.push(dim(` ${data.source.trackerDir}`));
156
+ out.push("");
157
+
158
+ if (data.totalCompleted === 0) {
159
+ out.push(dim(" No completed work-kits found."));
160
+ out.push("");
161
+ process.stderr.write(out.join("\n") + "\n");
162
+ return data;
163
+ }
164
+
165
+ out.push(` ${cyan("Completed")} ${bold(String(data.totalCompleted))}`);
166
+ out.push(` ${cyan("Avg run")} ${formatDurationMs(data.avgDurationMs)}`);
167
+ out.push(` ${cyan("Loopbacks")} ${data.totalLoopbacks} ${dim(`(${(data.loopbackRate * 100).toFixed(0)}% rate)`)}`);
168
+ out.push("");
169
+
170
+ const byClassEntries = Object.entries(data.byClassification);
171
+ if (byClassEntries.length > 0) {
172
+ out.push(bold(" By Classification"));
173
+ for (const [cls, count] of byClassEntries.sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0))) {
174
+ out.push(` ${cls.padEnd(16)} ${count}`);
175
+ }
176
+ out.push("");
177
+ }
178
+
179
+ const byModeEntries = Object.entries(data.byMode);
180
+ if (byModeEntries.length > 0) {
181
+ out.push(bold(" By Mode"));
182
+ for (const [mode, count] of byModeEntries.sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0))) {
183
+ out.push(` ${mode.padEnd(16)} ${count}`);
184
+ }
185
+ out.push("");
186
+ }
187
+
188
+ out.push(bold(" Avg Phase Duration"));
189
+ for (const phase of PHASE_NAMES) {
190
+ const s = data.perPhase[phase];
191
+ if (s.runs === 0) continue;
192
+ out.push(` ${phase.padEnd(10)} ${formatDurationMs(s.avgDurationMs).padStart(7)} ${dim(`(${s.runs} runs)`)}`);
193
+ }
194
+ out.push("");
195
+
196
+ if (data.recent.length > 0) {
197
+ out.push(bold(" Recent"));
198
+ for (const r of data.recent) {
199
+ const date = r.completedAt.split("T")[0];
200
+ const cls = r.classification ? dim(` [${r.classification}]`) : "";
201
+ const dur = r.durationMs ? dim(` ${formatDurationMs(r.durationMs)}`) : "";
202
+ out.push(` ${green("✓")} ${date} ${r.slug}${cls}${dur}`);
203
+ }
204
+ out.push("");
205
+ }
206
+
207
+ const indexPath = path.join(data.source.trackerDir, INDEX_FILE);
208
+ if (fs.existsSync(indexPath)) {
209
+ out.push(dim(` Full index: ${indexPath}`));
210
+ } else {
211
+ out.push(yellow(` Note: ${INDEX_FILE} not found.`));
212
+ }
213
+ out.push("");
214
+
215
+ process.stderr.write(out.join("\n") + "\n");
216
+ return data;
217
+ }
@@ -0,0 +1,126 @@
1
+ import * as fs from "node:fs";
2
+ import { readState, writeState, findWorktreeRoot, statePath, gitMainRepoRoot } from "../state/store.js";
3
+ import { unpause } from "../state/helpers.js";
4
+ import { CLI_BINARY } from "../config/constants.js";
5
+ import { discoverWorktrees } from "../observer/data.js";
6
+ import type { Action, ResumableSessionSummary } from "../state/schema.js";
7
+
8
+ export interface ResumeOptions {
9
+ worktreeRoot?: string;
10
+ slug?: string;
11
+ }
12
+
13
+ function collectResumableSessions(mainRepoRoot: string): ResumableSessionSummary[] {
14
+ const sessions: ResumableSessionSummary[] = [];
15
+ const now = Date.now();
16
+ for (const wt of discoverWorktrees(mainRepoRoot)) {
17
+ let state;
18
+ try {
19
+ state = readState(wt);
20
+ } catch {
21
+ continue;
22
+ }
23
+ if (state.status !== "paused" && state.status !== "in-progress") continue;
24
+
25
+ let mtimeMs = now;
26
+ try {
27
+ mtimeMs = fs.statSync(statePath(wt)).mtimeMs;
28
+ } catch {
29
+ // ignore — keep current time as fallback
30
+ }
31
+
32
+ sessions.push({
33
+ slug: state.slug,
34
+ branch: state.branch,
35
+ worktreeRoot: wt,
36
+ status: state.status,
37
+ pausedAt: state.pausedAt,
38
+ currentPhase: state.currentPhase,
39
+ currentStep: state.currentStep,
40
+ lastUpdatedAgoMs: now - mtimeMs,
41
+ });
42
+ }
43
+ // Sort: most recently updated first — fresh crashes are easy to spot at the top,
44
+ // and a still-running session in another terminal will have the smallest age.
45
+ sessions.sort((a, b) => a.lastUpdatedAgoMs - b.lastUpdatedAgoMs);
46
+ return sessions;
47
+ }
48
+
49
+ function resumeAt(root: string): Action {
50
+ const state = readState(root);
51
+
52
+ if (state.status === "completed") {
53
+ return { action: "error", message: `${state.slug} is already completed.` };
54
+ }
55
+ if (state.status === "in-progress") {
56
+ return {
57
+ action: "resumed",
58
+ message: `${state.slug} is already in progress. Run \`${CLI_BINARY} next\` to continue.`,
59
+ phase: state.currentPhase,
60
+ step: state.currentStep,
61
+ worktreeRoot: root,
62
+ };
63
+ }
64
+ if (state.status === "failed") {
65
+ return { action: "error", message: `${state.slug} is in failed state; cannot resume.` };
66
+ }
67
+
68
+ unpause(state);
69
+ writeState(root, state);
70
+
71
+ return {
72
+ action: "resumed",
73
+ message: `Resumed ${state.slug}. cd into ${root} and run \`${CLI_BINARY} next\` to continue.`,
74
+ phase: state.currentPhase,
75
+ step: state.currentStep,
76
+ worktreeRoot: root,
77
+ };
78
+ }
79
+
80
+ export function resumeCommand(options: ResumeOptions = {}): Action {
81
+ // 1. Explicit worktree path → resume there directly (legacy behavior)
82
+ if (options.worktreeRoot) {
83
+ return resumeAt(options.worktreeRoot);
84
+ }
85
+
86
+ // 2. Determine the main repo we're operating against. Works whether
87
+ // we're called from the main repo or from inside one of its worktrees.
88
+ const mainRepoRoot = gitMainRepoRoot(process.cwd());
89
+ if (!mainRepoRoot) {
90
+ // Non-git context: try the legacy cwd-walking lookup
91
+ const root = findWorktreeRoot();
92
+ if (!root) {
93
+ return { action: "error", message: "No work-kit state found and not inside a git repo." };
94
+ }
95
+ return resumeAt(root);
96
+ }
97
+
98
+ const sessions = collectResumableSessions(mainRepoRoot);
99
+
100
+ // 3. Slug selector → find matching session in this repo (paused OR in-progress)
101
+ if (options.slug) {
102
+ const match = sessions.find(s => s.slug === options.slug);
103
+ if (!match) {
104
+ return {
105
+ action: "error",
106
+ message: `No work-kit session with slug "${options.slug}" found in ${mainRepoRoot}.`,
107
+ };
108
+ }
109
+ return resumeAt(match.worktreeRoot);
110
+ }
111
+
112
+ // 4. No slug → list resumable sessions for the user to pick from
113
+ if (sessions.length === 0) {
114
+ return {
115
+ action: "error",
116
+ message: `No resumable work-kit sessions in ${mainRepoRoot}.`,
117
+ suggestion: `Start a new session with /full-kit or /auto-kit, or run \`${CLI_BINARY} observe\` to see active work.`,
118
+ };
119
+ }
120
+
121
+ return {
122
+ action: "select_session",
123
+ message: `Found ${sessions.length} resumable session${sessions.length === 1 ? "" : "s"}. Re-run with --slug <slug> to continue one.`,
124
+ sessions,
125
+ };
126
+ }