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.
- package/README.md +24 -13
- package/cli/src/commands/bootstrap.test.ts +40 -0
- package/cli/src/commands/bootstrap.ts +77 -13
- package/cli/src/commands/cancel.ts +1 -16
- package/cli/src/commands/complete.ts +92 -98
- package/cli/src/commands/completions.ts +2 -2
- package/cli/src/commands/doctor.ts +1 -1
- package/cli/src/commands/extract.ts +217 -0
- package/cli/src/commands/init.test.ts +50 -0
- package/cli/src/commands/init.ts +70 -35
- package/cli/src/commands/learn.test.ts +217 -0
- package/cli/src/commands/learn.ts +104 -0
- package/cli/src/commands/loopback.ts +8 -11
- package/cli/src/commands/next.ts +93 -60
- package/cli/src/commands/observe.ts +16 -21
- package/cli/src/commands/pause-resume.test.ts +142 -0
- package/cli/src/commands/pause.ts +34 -0
- package/cli/src/commands/report.ts +217 -0
- package/cli/src/commands/resume.ts +126 -0
- package/cli/src/commands/setup.ts +280 -0
- package/cli/src/commands/status.ts +8 -6
- package/cli/src/commands/uninstall.ts +8 -3
- package/cli/src/commands/workflow.ts +43 -33
- package/cli/src/config/agent-map.ts +9 -9
- package/cli/src/config/constants.ts +54 -0
- package/cli/src/config/loopback-routes.ts +13 -13
- package/cli/src/config/model-routing.test.ts +190 -0
- package/cli/src/config/model-routing.ts +208 -0
- package/cli/src/config/project-config.test.ts +127 -0
- package/cli/src/config/project-config.ts +106 -0
- package/cli/src/config/{phases.ts → workflow.ts} +40 -23
- package/cli/src/context/prompt-builder.ts +10 -9
- package/cli/src/index.ts +130 -9
- package/cli/src/observer/data.ts +196 -65
- package/cli/src/observer/renderer.ts +127 -107
- package/cli/src/observer/watcher.ts +28 -16
- package/cli/src/state/helpers.test.ts +28 -28
- package/cli/src/state/helpers.ts +37 -25
- package/cli/src/state/schema.ts +135 -45
- package/cli/src/state/store.ts +127 -7
- package/cli/src/state/validators.test.ts +13 -13
- package/cli/src/state/validators.ts +3 -4
- package/cli/src/utils/colors.ts +2 -0
- package/cli/src/utils/fs.ts +13 -0
- package/cli/src/utils/json.ts +20 -0
- package/cli/src/utils/knowledge.ts +471 -0
- package/cli/src/utils/time.ts +27 -0
- package/cli/src/{engine → workflow}/loopbacks.test.ts +2 -2
- package/cli/src/workflow/loopbacks.ts +42 -0
- package/cli/src/workflow/parallel.ts +64 -0
- package/cli/src/workflow/transitions.test.ts +129 -0
- package/cli/src/{engine → workflow}/transitions.ts +18 -22
- package/package.json +2 -2
- package/skills/auto-kit/SKILL.md +44 -27
- package/skills/cancel-kit/SKILL.md +4 -4
- package/skills/full-kit/SKILL.md +45 -28
- package/skills/pause-kit/SKILL.md +25 -0
- package/skills/resume-kit/SKILL.md +64 -0
- package/skills/wk-bootstrap/SKILL.md +11 -5
- package/skills/wk-build/SKILL.md +12 -11
- package/skills/wk-build/{stages → steps}/commit.md +1 -1
- package/skills/wk-build/{stages → steps}/core.md +3 -3
- package/skills/wk-build/{stages → steps}/integration.md +2 -2
- package/skills/wk-build/{stages → steps}/migration.md +1 -1
- package/skills/wk-build/{stages → steps}/red.md +1 -1
- package/skills/wk-build/{stages → steps}/refactor.md +1 -1
- package/skills/wk-build/{stages → steps}/setup.md +1 -1
- package/skills/wk-build/{stages → steps}/ui.md +1 -1
- package/skills/wk-deploy/SKILL.md +7 -6
- package/skills/wk-deploy/{stages → steps}/merge.md +1 -1
- package/skills/wk-deploy/{stages → steps}/monitor.md +1 -1
- package/skills/wk-deploy/{stages → steps}/remediate.md +1 -1
- package/skills/wk-plan/SKILL.md +15 -14
- package/skills/wk-plan/{stages → steps}/architecture.md +1 -1
- package/skills/wk-plan/{stages → steps}/audit.md +2 -2
- package/skills/wk-plan/{stages → steps}/blueprint.md +2 -2
- package/skills/wk-plan/{stages → steps}/clarify.md +1 -1
- package/skills/wk-plan/{stages → steps}/investigate.md +1 -1
- package/skills/wk-plan/{stages → steps}/scope.md +1 -1
- package/skills/wk-plan/{stages → steps}/sketch.md +1 -1
- package/skills/wk-plan/{stages → steps}/ux-flow.md +1 -1
- package/skills/wk-review/SKILL.md +11 -10
- package/skills/wk-review/{stages → steps}/compliance.md +1 -1
- package/skills/wk-review/{stages → steps}/handoff.md +2 -2
- package/skills/wk-review/{stages → steps}/performance.md +1 -1
- package/skills/wk-review/{stages → steps}/security.md +1 -1
- package/skills/wk-review/{stages → steps}/self-review.md +1 -1
- package/skills/wk-test/SKILL.md +9 -8
- package/skills/wk-test/steps/e2e.md +56 -0
- package/skills/wk-test/{stages → steps}/validate.md +1 -1
- package/skills/wk-test/{stages → steps}/verify.md +1 -1
- package/skills/wk-wrap-up/SKILL.md +19 -5
- package/skills/wk-wrap-up/steps/knowledge.md +76 -0
- package/skills/wk-wrap-up/steps/summary.md +86 -0
- package/cli/src/engine/loopbacks.ts +0 -32
- package/cli/src/engine/parallel.ts +0 -60
- package/cli/src/engine/transitions.test.ts +0 -129
- package/skills/wk-test/stages/e2e.md +0 -53
- /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
|
|
14
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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(
|
|
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(
|
|
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
|
+
}
|