work-kit-cli 0.3.0 → 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 (40) hide show
  1. package/README.md +11 -0
  2. package/cli/src/commands/bootstrap.test.ts +40 -0
  3. package/cli/src/commands/bootstrap.ts +38 -0
  4. package/cli/src/commands/extract.ts +217 -0
  5. package/cli/src/commands/init.test.ts +50 -0
  6. package/cli/src/commands/init.ts +32 -5
  7. package/cli/src/commands/learn.test.ts +217 -0
  8. package/cli/src/commands/learn.ts +104 -0
  9. package/cli/src/commands/next.ts +30 -10
  10. package/cli/src/commands/observe.ts +16 -21
  11. package/cli/src/commands/pause-resume.test.ts +2 -2
  12. package/cli/src/commands/resume.ts +95 -7
  13. package/cli/src/commands/setup.ts +144 -0
  14. package/cli/src/commands/status.ts +2 -0
  15. package/cli/src/commands/workflow.ts +19 -9
  16. package/cli/src/config/constants.ts +10 -0
  17. package/cli/src/config/model-routing.test.ts +190 -0
  18. package/cli/src/config/model-routing.ts +208 -0
  19. package/cli/src/config/workflow.ts +5 -5
  20. package/cli/src/index.ts +70 -5
  21. package/cli/src/observer/data.ts +132 -9
  22. package/cli/src/observer/renderer.ts +34 -36
  23. package/cli/src/observer/watcher.ts +28 -16
  24. package/cli/src/state/schema.ts +50 -3
  25. package/cli/src/state/store.ts +39 -4
  26. package/cli/src/utils/fs.ts +13 -0
  27. package/cli/src/utils/knowledge.ts +471 -0
  28. package/package.json +1 -1
  29. package/skills/auto-kit/SKILL.md +27 -10
  30. package/skills/full-kit/SKILL.md +25 -8
  31. package/skills/resume-kit/SKILL.md +44 -8
  32. package/skills/wk-bootstrap/SKILL.md +6 -0
  33. package/skills/wk-build/SKILL.md +3 -2
  34. package/skills/wk-deploy/SKILL.md +1 -0
  35. package/skills/wk-plan/SKILL.md +3 -2
  36. package/skills/wk-review/SKILL.md +1 -0
  37. package/skills/wk-test/SKILL.md +1 -0
  38. package/skills/wk-test/steps/e2e.md +15 -12
  39. package/skills/wk-wrap-up/SKILL.md +15 -2
  40. package/skills/wk-wrap-up/steps/knowledge.md +76 -0
@@ -0,0 +1,217 @@
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 { learnCommand } from "./learn.js";
9
+ import { extractCommand } from "./extract.js";
10
+ import { KNOWLEDGE_DIR, AUTO_BLOCK_START, AUTO_BLOCK_END } from "../utils/knowledge.js";
11
+
12
+ function makeTmpDir(): string {
13
+ const dir = path.join(os.tmpdir(), `work-kit-test-${randomUUID()}`);
14
+ fs.mkdirSync(dir, { recursive: true });
15
+ return dir;
16
+ }
17
+
18
+ let tmpDirs: string[] = [];
19
+
20
+ afterEach(() => {
21
+ for (const dir of tmpDirs) {
22
+ fs.rmSync(dir, { recursive: true, force: true });
23
+ }
24
+ tmpDirs = [];
25
+ });
26
+
27
+ function setupSession(tmp: string) {
28
+ initCommand({
29
+ mode: "full",
30
+ description: "Test feature",
31
+ worktreeRoot: tmp,
32
+ });
33
+ }
34
+
35
+ describe("learnCommand", () => {
36
+ it("rejects invalid type", () => {
37
+ const tmp = makeTmpDir();
38
+ tmpDirs.push(tmp);
39
+ setupSession(tmp);
40
+ const r = learnCommand({ type: "garbage", text: "x", worktreeRoot: tmp });
41
+ assert.equal(r.action, "error");
42
+ });
43
+
44
+ it("rejects empty text", () => {
45
+ const tmp = makeTmpDir();
46
+ tmpDirs.push(tmp);
47
+ setupSession(tmp);
48
+ const r = learnCommand({ type: "lesson", text: " ", worktreeRoot: tmp });
49
+ assert.equal(r.action, "error");
50
+ });
51
+
52
+ it("appends a lesson and creates auto-block markers", () => {
53
+ const tmp = makeTmpDir();
54
+ tmpDirs.push(tmp);
55
+ setupSession(tmp);
56
+
57
+ const r = learnCommand({
58
+ type: "lesson",
59
+ text: "Test fixtures must be reset between Playwright suites.",
60
+ worktreeRoot: tmp,
61
+ });
62
+
63
+ assert.equal(r.action, "learned");
64
+ assert.equal(r.file, "lessons.md");
65
+
66
+ const lessonsPath = path.join(tmp, KNOWLEDGE_DIR, "lessons.md");
67
+ assert.ok(fs.existsSync(lessonsPath));
68
+ const content = fs.readFileSync(lessonsPath, "utf-8");
69
+ assert.ok(content.includes(AUTO_BLOCK_START));
70
+ assert.ok(content.includes(AUTO_BLOCK_END));
71
+ assert.ok(content.includes("Test fixtures must be reset"));
72
+ });
73
+
74
+ it("routes each type to its own file", () => {
75
+ const tmp = makeTmpDir();
76
+ tmpDirs.push(tmp);
77
+ setupSession(tmp);
78
+
79
+ learnCommand({ type: "lesson", text: "L", worktreeRoot: tmp });
80
+ learnCommand({ type: "convention", text: "C", worktreeRoot: tmp });
81
+ learnCommand({ type: "risk", text: "R", worktreeRoot: tmp });
82
+ learnCommand({ type: "workflow", text: "W", worktreeRoot: tmp });
83
+
84
+ const dir = path.join(tmp, KNOWLEDGE_DIR);
85
+ assert.ok(fs.readFileSync(path.join(dir, "lessons.md"), "utf-8").includes("L"));
86
+ assert.ok(fs.readFileSync(path.join(dir, "conventions.md"), "utf-8").includes("C"));
87
+ assert.ok(fs.readFileSync(path.join(dir, "risks.md"), "utf-8").includes("R"));
88
+ assert.ok(fs.readFileSync(path.join(dir, "workflow.md"), "utf-8").includes("W"));
89
+ });
90
+
91
+ it("redacts secrets in text before writing", () => {
92
+ const tmp = makeTmpDir();
93
+ tmpDirs.push(tmp);
94
+ setupSession(tmp);
95
+
96
+ const r = learnCommand({
97
+ type: "lesson",
98
+ text: "API key is sk-abc123def456ghi789jkl012mno345 leaked here",
99
+ worktreeRoot: tmp,
100
+ });
101
+
102
+ assert.equal(r.action, "learned");
103
+ assert.equal(r.redacted, true);
104
+ assert.ok((r.redactedKinds ?? []).includes("openai-style"));
105
+
106
+ const content = fs.readFileSync(
107
+ path.join(tmp, KNOWLEDGE_DIR, "lessons.md"),
108
+ "utf-8"
109
+ );
110
+ assert.ok(content.includes("[REDACTED]"));
111
+ assert.ok(!content.includes("sk-abc123def456ghi789"));
112
+ });
113
+
114
+ it("is idempotent on identical entries (returns duplicate)", () => {
115
+ const tmp = makeTmpDir();
116
+ tmpDirs.push(tmp);
117
+ setupSession(tmp);
118
+
119
+ const first = learnCommand({ type: "risk", text: "Same text", worktreeRoot: tmp });
120
+ const second = learnCommand({ type: "risk", text: "Same text", worktreeRoot: tmp });
121
+
122
+ assert.equal(first.action, "learned");
123
+ assert.equal(second.action, "duplicate");
124
+
125
+ const content = fs.readFileSync(
126
+ path.join(tmp, KNOWLEDGE_DIR, "risks.md"),
127
+ "utf-8"
128
+ );
129
+ // Only one entry should exist
130
+ const matches = content.match(/Same text/g) ?? [];
131
+ assert.equal(matches.length, 1);
132
+ });
133
+
134
+ it("auto-fills phase and step from tracker.json", () => {
135
+ const tmp = makeTmpDir();
136
+ tmpDirs.push(tmp);
137
+ setupSession(tmp);
138
+
139
+ learnCommand({
140
+ type: "lesson",
141
+ text: "Phase auto-fill check",
142
+ worktreeRoot: tmp,
143
+ });
144
+
145
+ const content = fs.readFileSync(
146
+ path.join(tmp, KNOWLEDGE_DIR, "lessons.md"),
147
+ "utf-8"
148
+ );
149
+ // Init starts at plan/clarify
150
+ assert.ok(content.includes("plan/clarify"));
151
+ });
152
+
153
+ it("extracts typed bullets from state.md ## Observations", () => {
154
+ const tmp = makeTmpDir();
155
+ tmpDirs.push(tmp);
156
+ setupSession(tmp);
157
+
158
+ // Inject typed bullets into the existing ## Observations section
159
+ const stateMdPath = path.join(tmp, ".work-kit", "state.md");
160
+ const original = fs.readFileSync(stateMdPath, "utf-8");
161
+ const injected = original.replace(
162
+ "\n## Deviations",
163
+ "\n- [risk] foo.ts is fragile\n- [convention] All inputs validated via Zod\n- [workflow:test/e2e] e2e step needs server\n- [lesson] We discovered something useful\n\n## Deviations"
164
+ );
165
+ fs.writeFileSync(stateMdPath, injected);
166
+
167
+ const r = extractCommand({ worktreeRoot: tmp });
168
+ assert.equal(r.action, "extracted");
169
+ // 4 typed observation bullets, all should route
170
+ assert.equal(r.byType.risk, 1);
171
+ assert.equal(r.byType.convention, 1);
172
+ assert.equal(r.byType.workflow, 1, "workflow:test/e2e bullet should be routed (regression: digit in step name)");
173
+ assert.equal(r.byType.lesson, 1);
174
+
175
+ const workflowMd = fs.readFileSync(path.join(tmp, KNOWLEDGE_DIR, "workflow.md"), "utf-8");
176
+ assert.ok(workflowMd.includes("e2e step needs server"));
177
+ });
178
+
179
+ it("extract is idempotent (re-run produces only duplicates)", () => {
180
+ const tmp = makeTmpDir();
181
+ tmpDirs.push(tmp);
182
+ setupSession(tmp);
183
+
184
+ const stateMdPath = path.join(tmp, ".work-kit", "state.md");
185
+ const original = fs.readFileSync(stateMdPath, "utf-8");
186
+ fs.writeFileSync(
187
+ stateMdPath,
188
+ original.replace("\n## Deviations", "\n- [risk] one\n- [risk] two\n\n## Deviations")
189
+ );
190
+
191
+ const first = extractCommand({ worktreeRoot: tmp });
192
+ const second = extractCommand({ worktreeRoot: tmp });
193
+
194
+ assert.equal(first.written, 2);
195
+ assert.equal(first.duplicates, 0);
196
+ assert.equal(second.written, 0);
197
+ assert.equal(second.duplicates, 2);
198
+ });
199
+
200
+ it("does not contaminate the Manual section", () => {
201
+ const tmp = makeTmpDir();
202
+ tmpDirs.push(tmp);
203
+ setupSession(tmp);
204
+
205
+ learnCommand({ type: "lesson", text: "Auto only", worktreeRoot: tmp });
206
+
207
+ const content = fs.readFileSync(
208
+ path.join(tmp, KNOWLEDGE_DIR, "lessons.md"),
209
+ "utf-8"
210
+ );
211
+ // Find the Manual section and ensure the entry isn't in it
212
+ const manualIdx = content.indexOf("## Manual");
213
+ assert.ok(manualIdx > -1);
214
+ const manualSection = content.slice(manualIdx);
215
+ assert.ok(!manualSection.includes("Auto only"));
216
+ });
217
+ });
@@ -0,0 +1,104 @@
1
+ import { findWorktreeRoot, readState, resolveMainRepoRoot, stateExists, gitHeadSha } from "../state/store.js";
2
+ import {
3
+ appendAutoEntry,
4
+ ensureKnowledgeDir,
5
+ fileForType,
6
+ isKnowledgeType,
7
+ redact,
8
+ type KnowledgeEntry,
9
+ type KnowledgeType,
10
+ } from "../utils/knowledge.js";
11
+ import { skillFilePath } from "../config/workflow.js";
12
+ import type { PhaseName } from "../state/schema.js";
13
+
14
+ export interface LearnOptions {
15
+ type: string;
16
+ text: string;
17
+ scope?: string;
18
+ phase?: string;
19
+ step?: string;
20
+ source?: string;
21
+ worktreeRoot?: string;
22
+ /** When true, suppress the one-time commit warning. Used by extract. */
23
+ quiet?: boolean;
24
+ }
25
+
26
+ export interface LearnResult {
27
+ action: "learned" | "duplicate" | "error";
28
+ type?: KnowledgeType;
29
+ file?: string;
30
+ redacted?: boolean;
31
+ redactedKinds?: string[];
32
+ message?: string;
33
+ }
34
+
35
+ export function learnCommand(opts: LearnOptions): LearnResult {
36
+ if (!opts.type || !isKnowledgeType(opts.type)) {
37
+ return {
38
+ action: "error",
39
+ message: `Invalid --type "${opts.type}". Must be one of: lesson, convention, risk, workflow.`,
40
+ };
41
+ }
42
+ if (!opts.text || opts.text.trim().length === 0) {
43
+ return { action: "error", message: "--text is required and cannot be empty." };
44
+ }
45
+
46
+ const type = opts.type as KnowledgeType;
47
+
48
+ // Try to locate a session for auto-fill. Falls back to manual --phase/--step.
49
+ const root = opts.worktreeRoot || findWorktreeRoot();
50
+ let sessionSlug: string | undefined;
51
+ let phase: string | undefined = opts.phase;
52
+ let step: string | undefined = opts.step;
53
+ let mainRepoRoot: string | undefined;
54
+ let skillPath: string | undefined;
55
+
56
+ if (root && stateExists(root)) {
57
+ const state = readState(root);
58
+ sessionSlug = state.slug;
59
+ mainRepoRoot = state.metadata?.mainRepoRoot ?? resolveMainRepoRoot(root);
60
+ if (!phase) phase = state.currentPhase ?? undefined;
61
+ if (!step) step = state.currentStep ?? undefined;
62
+ if (phase && step) {
63
+ skillPath = skillFilePath(phase as PhaseName, step);
64
+ }
65
+ }
66
+
67
+ if (!mainRepoRoot) {
68
+ // No session — caller must be in a git repo we can resolve
69
+ mainRepoRoot = resolveMainRepoRoot(process.cwd());
70
+ if (!mainRepoRoot) {
71
+ return {
72
+ action: "error",
73
+ message: "No work-kit session found and not inside a git repo. Run from a project directory or provide --worktree-root.",
74
+ };
75
+ }
76
+ }
77
+
78
+ const { text, redacted, matches } = redact(opts.text);
79
+
80
+ ensureKnowledgeDir(mainRepoRoot);
81
+
82
+ const entry: KnowledgeEntry = {
83
+ ts: new Date().toISOString(),
84
+ sessionSlug,
85
+ phase,
86
+ step,
87
+ skillPath,
88
+ gitSha: gitHeadSha(mainRepoRoot),
89
+ source: opts.source ?? "explicit-cli",
90
+ text,
91
+ scope: opts.scope,
92
+ };
93
+
94
+ const file = fileForType(type);
95
+ const wrote = appendAutoEntry(mainRepoRoot, file, entry);
96
+
97
+ return {
98
+ action: wrote ? "learned" : "duplicate",
99
+ type,
100
+ file,
101
+ redacted,
102
+ redactedKinds: matches,
103
+ };
104
+ }
@@ -4,6 +4,7 @@ import { validatePhasePrerequisites } from "../state/validators.js";
4
4
  import { buildAgentPrompt } from "../context/prompt-builder.js";
5
5
  import { getParallelGroup } from "../workflow/parallel.js";
6
6
  import { skillFilePath } from "../config/workflow.js";
7
+ import { resolveModel } from "../config/model-routing.js";
7
8
  import { CLI_BINARY } from "../config/constants.js";
8
9
 
9
10
  import type { Action, PhaseName, WorkKitState } from "../state/schema.js";
@@ -109,27 +110,27 @@ function buildSpawnAction(root: string, state: WorkKitState, phase: PhaseName, s
109
110
  const sState = state.phases[phase].steps[s];
110
111
  return sState && sState.status !== "skipped" && sState.status !== "completed";
111
112
  })
112
- .map((s) => ({
113
+ .map((s) => withModel({
113
114
  phase,
114
115
  step: s,
115
116
  skillFile: skillFilePath(phase, s),
116
117
  agentPrompt: buildAgentPrompt(root, state, phase, s, stateMd),
117
118
  outputFile: `.work-kit/${phase}-${s}.md`,
118
- }));
119
+ }, state));
119
120
 
120
121
  // If all parallel members were filtered out, fall through to single agent
121
122
  if (agents.length === 0) {
122
123
  // Skip to thenSequential if it exists, otherwise nothing to do
123
124
  if (parallelGroup.thenSequential) {
124
125
  const seqStep = parallelGroup.thenSequential;
125
- return {
126
+ return withModelAction({
126
127
  action: "spawn_agent",
127
128
  phase,
128
129
  step: seqStep,
129
130
  skillFile: skillFilePath(phase, seqStep),
130
131
  agentPrompt: buildAgentPrompt(root, state, phase, seqStep, stateMd),
131
132
  onComplete: `${CLI_BINARY} complete ${phase}/${seqStep}`,
132
- };
133
+ }, state);
133
134
  }
134
135
  return { action: "error", message: `No active steps in parallel group for ${phase}` };
135
136
  }
@@ -137,14 +138,14 @@ function buildSpawnAction(root: string, state: WorkKitState, phase: PhaseName, s
137
138
  // If only 1 agent remains, run as single agent (no need for parallel)
138
139
  if (agents.length === 1 && !parallelGroup.thenSequential) {
139
140
  const agent = agents[0];
140
- return {
141
+ return withModelAction({
141
142
  action: "spawn_agent",
142
143
  phase: agent.phase,
143
144
  step: agent.step,
144
145
  skillFile: agent.skillFile,
145
146
  agentPrompt: agent.agentPrompt,
146
147
  onComplete: `${CLI_BINARY} complete ${agent.phase}/${agent.step}`,
147
- };
148
+ }, state);
148
149
  }
149
150
 
150
151
  for (const agent of agents) {
@@ -153,12 +154,12 @@ function buildSpawnAction(root: string, state: WorkKitState, phase: PhaseName, s
153
154
  }
154
155
 
155
156
  const thenSequential = parallelGroup.thenSequential
156
- ? {
157
+ ? withModel({
157
158
  phase,
158
159
  step: parallelGroup.thenSequential,
159
160
  skillFile: skillFilePath(phase, parallelGroup.thenSequential),
160
161
  agentPrompt: buildAgentPrompt(root, state, phase, parallelGroup.thenSequential, stateMd),
161
- }
162
+ }, state)
162
163
  : undefined;
163
164
 
164
165
  writeState(root, state);
@@ -174,12 +175,31 @@ function buildSpawnAction(root: string, state: WorkKitState, phase: PhaseName, s
174
175
  const skill = skillFilePath(phase, step);
175
176
  const prompt = buildAgentPrompt(root, state, phase, step, stateMd);
176
177
 
177
- return {
178
+ return withModelAction({
178
179
  action: "spawn_agent",
179
180
  phase,
180
181
  step,
181
182
  skillFile: skill,
182
183
  agentPrompt: prompt,
183
184
  onComplete: `${CLI_BINARY} complete ${phase}/${step}`,
184
- };
185
+ }, state);
186
+ }
187
+
188
+ /**
189
+ * Attach the resolved model tier to an AgentSpec. Omits the field entirely
190
+ * when resolveModel returns undefined (policy "inherit" or hard-default miss),
191
+ * keeping the action JSON compatible with skills that haven't yet been updated
192
+ * to forward a model parameter.
193
+ */
194
+ function withModel<T extends { phase: PhaseName; step: string }>(spec: T, state: WorkKitState): T & { model?: ReturnType<typeof resolveModel> } {
195
+ const model = resolveModel(state, spec.phase, spec.step);
196
+ return model ? { ...spec, model } : spec;
197
+ }
198
+
199
+ function withModelAction(
200
+ action: Extract<Action, { action: "spawn_agent" }>,
201
+ state: WorkKitState
202
+ ): Extract<Action, { action: "spawn_agent" }> {
203
+ const model = resolveModel(state, action.phase, action.step);
204
+ return model ? { ...action, model } : action;
185
205
  }
@@ -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
 
@@ -56,7 +56,7 @@ describe("pause / resume", () => {
56
56
  initCommand({ mode: "full", description: "Resume test", worktreeRoot: tmp });
57
57
  pauseCommand(undefined, tmp);
58
58
 
59
- const result = resumeCommand(tmp);
59
+ const result = resumeCommand({ worktreeRoot: tmp });
60
60
  assert.equal(result.action, "resumed");
61
61
 
62
62
  const tracker = JSON.parse(fs.readFileSync(path.join(tmp, ".work-kit", "tracker.json"), "utf-8"));
@@ -69,7 +69,7 @@ describe("pause / resume", () => {
69
69
  tmpDirs.push(tmp);
70
70
  initCommand({ mode: "full", description: "Already running", worktreeRoot: tmp });
71
71
 
72
- const result = resumeCommand(tmp);
72
+ const result = resumeCommand({ worktreeRoot: tmp });
73
73
  assert.equal(result.action, "resumed");
74
74
  });
75
75
 
@@ -1,14 +1,52 @@
1
- import { readState, writeState, findWorktreeRoot } from "../state/store.js";
1
+ import * as fs from "node:fs";
2
+ import { readState, writeState, findWorktreeRoot, statePath, gitMainRepoRoot } from "../state/store.js";
2
3
  import { unpause } from "../state/helpers.js";
3
4
  import { CLI_BINARY } from "../config/constants.js";
4
- import type { Action } from "../state/schema.js";
5
+ import { discoverWorktrees } from "../observer/data.js";
6
+ import type { Action, ResumableSessionSummary } from "../state/schema.js";
5
7
 
6
- export function resumeCommand(worktreeRoot?: string): Action {
7
- const root = worktreeRoot || findWorktreeRoot();
8
- if (!root) {
9
- return { action: "error", message: "No work-kit state found." };
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
+ });
10
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
+ }
11
48
 
49
+ function resumeAt(root: string): Action {
12
50
  const state = readState(root);
13
51
 
14
52
  if (state.status === "completed") {
@@ -20,6 +58,7 @@ export function resumeCommand(worktreeRoot?: string): Action {
20
58
  message: `${state.slug} is already in progress. Run \`${CLI_BINARY} next\` to continue.`,
21
59
  phase: state.currentPhase,
22
60
  step: state.currentStep,
61
+ worktreeRoot: root,
23
62
  };
24
63
  }
25
64
  if (state.status === "failed") {
@@ -31,8 +70,57 @@ export function resumeCommand(worktreeRoot?: string): Action {
31
70
 
32
71
  return {
33
72
  action: "resumed",
34
- message: `Resumed ${state.slug}. Run \`${CLI_BINARY} next\` to continue.`,
73
+ message: `Resumed ${state.slug}. cd into ${root} and run \`${CLI_BINARY} next\` to continue.`,
35
74
  phase: state.currentPhase,
36
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,
37
125
  };
38
126
  }