work-kit-cli 0.2.8 → 0.3.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 (86) hide show
  1. package/README.md +13 -13
  2. package/cli/src/commands/bootstrap.ts +39 -13
  3. package/cli/src/commands/cancel.ts +1 -16
  4. package/cli/src/commands/complete.ts +92 -98
  5. package/cli/src/commands/completions.ts +2 -2
  6. package/cli/src/commands/doctor.ts +1 -1
  7. package/cli/src/commands/init.ts +40 -32
  8. package/cli/src/commands/loopback.ts +8 -11
  9. package/cli/src/commands/next.ts +64 -51
  10. package/cli/src/commands/pause-resume.test.ts +142 -0
  11. package/cli/src/commands/pause.ts +34 -0
  12. package/cli/src/commands/report.ts +217 -0
  13. package/cli/src/commands/resume.ts +38 -0
  14. package/cli/src/commands/setup.ts +136 -0
  15. package/cli/src/commands/status.ts +6 -6
  16. package/cli/src/commands/uninstall.ts +8 -3
  17. package/cli/src/commands/workflow.ts +27 -27
  18. package/cli/src/config/agent-map.ts +9 -9
  19. package/cli/src/config/constants.ts +44 -0
  20. package/cli/src/config/loopback-routes.ts +13 -13
  21. package/cli/src/config/project-config.test.ts +127 -0
  22. package/cli/src/config/project-config.ts +106 -0
  23. package/cli/src/config/{phases.ts → workflow.ts} +40 -23
  24. package/cli/src/context/prompt-builder.ts +10 -9
  25. package/cli/src/index.ts +63 -7
  26. package/cli/src/observer/data.ts +64 -56
  27. package/cli/src/observer/renderer.ts +101 -79
  28. package/cli/src/state/helpers.test.ts +28 -28
  29. package/cli/src/state/helpers.ts +37 -25
  30. package/cli/src/state/schema.ts +88 -45
  31. package/cli/src/state/store.ts +92 -7
  32. package/cli/src/state/validators.test.ts +13 -13
  33. package/cli/src/state/validators.ts +3 -4
  34. package/cli/src/utils/colors.ts +2 -0
  35. package/cli/src/utils/json.ts +20 -0
  36. package/cli/src/utils/time.ts +27 -0
  37. package/cli/src/{engine → workflow}/loopbacks.test.ts +2 -2
  38. package/cli/src/workflow/loopbacks.ts +42 -0
  39. package/cli/src/workflow/parallel.ts +64 -0
  40. package/cli/src/workflow/transitions.test.ts +129 -0
  41. package/cli/src/{engine → workflow}/transitions.ts +18 -22
  42. package/package.json +2 -2
  43. package/skills/auto-kit/SKILL.md +22 -22
  44. package/skills/cancel-kit/SKILL.md +4 -4
  45. package/skills/full-kit/SKILL.md +23 -23
  46. package/skills/pause-kit/SKILL.md +25 -0
  47. package/skills/resume-kit/SKILL.md +28 -0
  48. package/skills/wk-bootstrap/SKILL.md +5 -5
  49. package/skills/wk-build/SKILL.md +10 -10
  50. package/skills/wk-build/{stages → steps}/commit.md +1 -1
  51. package/skills/wk-build/{stages → steps}/core.md +3 -3
  52. package/skills/wk-build/{stages → steps}/integration.md +2 -2
  53. package/skills/wk-build/{stages → steps}/migration.md +1 -1
  54. package/skills/wk-build/{stages → steps}/red.md +1 -1
  55. package/skills/wk-build/{stages → steps}/refactor.md +1 -1
  56. package/skills/wk-build/{stages → steps}/setup.md +1 -1
  57. package/skills/wk-build/{stages → steps}/ui.md +1 -1
  58. package/skills/wk-deploy/SKILL.md +6 -6
  59. package/skills/wk-deploy/{stages → steps}/merge.md +1 -1
  60. package/skills/wk-deploy/{stages → steps}/monitor.md +1 -1
  61. package/skills/wk-deploy/{stages → steps}/remediate.md +1 -1
  62. package/skills/wk-plan/SKILL.md +13 -13
  63. package/skills/wk-plan/{stages → steps}/architecture.md +1 -1
  64. package/skills/wk-plan/{stages → steps}/audit.md +2 -2
  65. package/skills/wk-plan/{stages → steps}/blueprint.md +2 -2
  66. package/skills/wk-plan/{stages → steps}/clarify.md +1 -1
  67. package/skills/wk-plan/{stages → steps}/investigate.md +1 -1
  68. package/skills/wk-plan/{stages → steps}/scope.md +1 -1
  69. package/skills/wk-plan/{stages → steps}/sketch.md +1 -1
  70. package/skills/wk-plan/{stages → steps}/ux-flow.md +1 -1
  71. package/skills/wk-review/SKILL.md +10 -10
  72. package/skills/wk-review/{stages → steps}/compliance.md +1 -1
  73. package/skills/wk-review/{stages → steps}/handoff.md +2 -2
  74. package/skills/wk-review/{stages → steps}/performance.md +1 -1
  75. package/skills/wk-review/{stages → steps}/security.md +1 -1
  76. package/skills/wk-review/{stages → steps}/self-review.md +1 -1
  77. package/skills/wk-test/SKILL.md +8 -8
  78. package/skills/wk-test/{stages → steps}/e2e.md +1 -1
  79. package/skills/wk-test/{stages → steps}/validate.md +1 -1
  80. package/skills/wk-test/{stages → steps}/verify.md +1 -1
  81. package/skills/wk-wrap-up/SKILL.md +6 -5
  82. package/skills/wk-wrap-up/steps/summary.md +86 -0
  83. package/cli/src/engine/loopbacks.ts +0 -32
  84. package/cli/src/engine/parallel.ts +0 -60
  85. package/cli/src/engine/transitions.test.ts +0 -129
  86. /package/cli/src/{engine/phases.ts → workflow/gates.ts} +0 -0
@@ -1,8 +1,10 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { WorkKitState, PhaseState, PhaseName, PHASE_NAMES, SUBSTAGES_BY_PHASE, WorkflowStep, Classification } from "../state/schema.js";
4
- import { writeState, writeStateMd, stateExists } from "../state/store.js";
5
- import { buildFullWorkflow, buildDefaultWorkflow, skillFilePath } from "../config/phases.js";
3
+ import { WorkKitState, PhaseState, PhaseName, PHASE_NAMES, STEPS_BY_PHASE, WorkflowStep, Classification, MODE_FULL, MODE_AUTO } from "../state/schema.js";
4
+ import { writeState, writeStateMd, stateExists, STATE_DIR, resolveMainRepoRoot } from "../state/store.js";
5
+ import { buildFullWorkflow, buildDefaultWorkflow, skillFilePath } from "../config/workflow.js";
6
+ import { BRANCH_PREFIX, CLI_BINARY } from "../config/constants.js";
7
+ import { loadProjectConfig } from "../config/project-config.js";
6
8
  import type { Action } from "../state/schema.js";
7
9
 
8
10
  function toSlug(description: string): string {
@@ -18,23 +20,23 @@ function buildPhases(workflow?: WorkflowStep[]): Record<PhaseName, PhaseState> {
18
20
  const phases = {} as Record<PhaseName, PhaseState>;
19
21
 
20
22
  for (const phase of PHASE_NAMES) {
21
- const subStages: Record<string, { status: "pending" | "skipped" }> = {};
22
- const allSubStages = SUBSTAGES_BY_PHASE[phase];
23
+ const steps: Record<string, { status: "pending" | "skipped" }> = {};
24
+ const allSteps = STEPS_BY_PHASE[phase];
23
25
 
24
- for (const ss of allSubStages) {
26
+ for (const s of allSteps) {
25
27
  if (workflow) {
26
- const step = workflow.find((s) => s.phase === phase && s.subStage === ss);
27
- subStages[ss] = { status: step?.included ? "pending" : "skipped" };
28
+ const ws = workflow.find((w) => w.phase === phase && w.step === s);
29
+ steps[s] = { status: ws?.included ? "pending" : "skipped" };
28
30
  } else {
29
- subStages[ss] = { status: "pending" };
31
+ steps[s] = { status: "pending" };
30
32
  }
31
33
  }
32
34
 
33
- // Check if entire phase is skipped (all sub-stages skipped)
34
- const allSkipped = Object.values(subStages).every((s) => s.status === "skipped");
35
+ // Check if entire phase is skipped (all steps skipped)
36
+ const allSkipped = Object.values(steps).every((s) => s.status === "skipped");
35
37
  phases[phase] = {
36
38
  status: allSkipped ? "skipped" : "pending",
37
- subStages,
39
+ steps,
38
40
  };
39
41
  }
40
42
 
@@ -58,7 +60,7 @@ function generateStateMd(slug: string, branch: string, mode: string, description
58
60
  }
59
61
 
60
62
  md += `**Phase:** plan
61
- **Sub-stage:** clarify
63
+ **Step:** clarify
62
64
  **Status:** in-progress
63
65
 
64
66
  ## Description
@@ -67,9 +69,9 @@ ${description}
67
69
 
68
70
  if (workflow) {
69
71
  md += `\n## Workflow\n`;
70
- for (const step of workflow) {
71
- if (step.included) {
72
- const label = `${step.phase.charAt(0).toUpperCase() + step.phase.slice(1)}: ${step.subStage.charAt(0).toUpperCase() + step.subStage.slice(1)}`;
72
+ for (const ws of workflow) {
73
+ if (ws.included) {
74
+ const label = `${ws.phase.charAt(0).toUpperCase() + ws.phase.slice(1)}: ${ws.step.charAt(0).toUpperCase() + ws.step.slice(1)}`;
73
75
  md += `- [ ] ${label}\n`;
74
76
  }
75
77
  }
@@ -93,7 +95,7 @@ ${description}
93
95
 
94
96
  function ensureGitignored(worktreeRoot: string): void {
95
97
  const gitignorePath = path.join(worktreeRoot, ".gitignore");
96
- const entry = ".work-kit/";
98
+ const entry = `${STATE_DIR}/`;
97
99
 
98
100
  if (fs.existsSync(gitignorePath)) {
99
101
  const content = fs.readFileSync(gitignorePath, "utf-8");
@@ -105,14 +107,20 @@ function ensureGitignored(worktreeRoot: string): void {
105
107
  }
106
108
 
107
109
  export function initCommand(options: {
108
- mode: "full" | "auto";
110
+ mode?: "full" | "auto";
109
111
  description: string;
110
112
  classification?: Classification;
111
113
  gated?: boolean;
112
114
  worktreeRoot?: string;
113
115
  }): Action {
114
- const { mode, description, classification, gated } = options;
115
116
  const worktreeRoot = options.worktreeRoot || process.cwd();
117
+ const mainRepoRoot = resolveMainRepoRoot(worktreeRoot);
118
+ const projectConfig = loadProjectConfig(mainRepoRoot);
119
+
120
+ const mode = options.mode ?? projectConfig.defaults?.mode ?? "full";
121
+ const classification = options.classification ?? projectConfig.defaults?.classification;
122
+ const gated = options.gated ?? projectConfig.defaults?.gated ?? false;
123
+ const { description } = options;
116
124
 
117
125
  // Guard: don't overwrite existing state
118
126
  if (stateExists(worktreeRoot)) {
@@ -140,32 +148,32 @@ export function initCommand(options: {
140
148
  }
141
149
 
142
150
  const slug = toSlug(description);
143
- const branch = `feature/${slug}`;
144
- const modeLabel = mode === "full" ? "full-kit" : "auto-kit";
151
+ const branch = `${BRANCH_PREFIX}${slug}`;
152
+ const modeLabel = mode === "full" ? MODE_FULL : MODE_AUTO;
145
153
 
146
154
  // Build workflow
147
155
  let workflow: WorkflowStep[] | undefined;
148
156
  if (mode === "auto" && classification) {
149
- workflow = buildDefaultWorkflow(classification);
157
+ workflow = buildDefaultWorkflow(classification, projectConfig.workflow);
150
158
  } else if (mode === "full") {
151
159
  workflow = buildFullWorkflow();
152
160
  }
153
161
 
154
- // Find first active sub-stage
162
+ // Find first active step
155
163
  let firstPhase: PhaseName = "plan";
156
- let firstSubStage = "clarify";
164
+ let firstStep = "clarify";
157
165
 
158
166
  if (workflow) {
159
167
  const first = workflow.find((s) => s.included);
160
168
  if (first) {
161
169
  firstPhase = first.phase;
162
- firstSubStage = first.subStage;
170
+ firstStep = first.step;
163
171
  }
164
172
  }
165
173
 
166
174
  // Build state
167
175
  const state: WorkKitState = {
168
- version: 1,
176
+ version: 2,
169
177
  slug,
170
178
  branch,
171
179
  started: new Date().toISOString(),
@@ -174,13 +182,13 @@ export function initCommand(options: {
174
182
  ...(classification && { classification }),
175
183
  status: "in-progress",
176
184
  currentPhase: firstPhase,
177
- currentSubStage: firstSubStage,
185
+ currentStep: firstStep,
178
186
  phases: buildPhases(workflow),
179
187
  ...(mode === "auto" && workflow && { workflow }),
180
188
  loopbacks: [],
181
189
  metadata: {
182
190
  worktreeRoot,
183
- mainRepoRoot: worktreeRoot, // will be set properly by caller
191
+ mainRepoRoot,
184
192
  },
185
193
  };
186
194
 
@@ -194,9 +202,9 @@ export function initCommand(options: {
194
202
  return {
195
203
  action: "spawn_agent",
196
204
  phase: firstPhase,
197
- subStage: firstSubStage,
198
- skillFile: skillFilePath(firstPhase, firstSubStage),
199
- agentPrompt: `You are starting the ${firstPhase} phase. Begin with the ${firstSubStage} sub-stage. Read the skill file and follow its instructions. Write outputs to .work-kit/state.md.`,
200
- onComplete: `npx work-kit-cli complete ${firstPhase}/${firstSubStage}`,
205
+ step: firstStep,
206
+ skillFile: skillFilePath(firstPhase, firstStep),
207
+ agentPrompt: `You are starting the ${firstPhase} phase. Begin with the ${firstStep} step. Read the skill file and follow its instructions. Write outputs to .work-kit/state.md.`,
208
+ onComplete: `${CLI_BINARY} complete ${firstPhase}/${firstStep}`,
201
209
  };
202
210
  }
@@ -1,9 +1,9 @@
1
1
  import { readState, writeState, findWorktreeRoot } from "../state/store.js";
2
2
  import { parseLocation, resetToLocation } from "../state/helpers.js";
3
+ import { countLoopbacksForRoute } from "../workflow/loopbacks.js";
4
+ import { MAX_LOOPBACKS_PER_ROUTE } from "../config/constants.js";
3
5
  import type { Action } from "../state/schema.js";
4
6
 
5
- const MAX_LOOPBACKS_PER_ROUTE = 2;
6
-
7
7
  export function loopbackCommand(opts: {
8
8
  from: string;
9
9
  to: string;
@@ -19,23 +19,20 @@ export function loopbackCommand(opts: {
19
19
  const from = parseLocation(opts.from);
20
20
  const to = parseLocation(opts.to);
21
21
 
22
- if (!state.phases[from.phase]?.subStages[from.subStage]) {
22
+ if (!state.phases[from.phase]?.steps[from.step]) {
23
23
  return { action: "error", message: `Invalid source: ${opts.from}` };
24
24
  }
25
- if (!state.phases[to.phase]?.subStages[to.subStage]) {
25
+ if (!state.phases[to.phase]?.steps[to.step]) {
26
26
  return { action: "error", message: `Invalid target: ${opts.to}` };
27
27
  }
28
28
 
29
- // Can't loop back to a skipped sub-stage
30
- if (state.phases[to.phase].subStages[to.subStage].status === "skipped") {
29
+ // Can't loop back to a skipped step
30
+ if (state.phases[to.phase].steps[to.step].status === "skipped") {
31
31
  return { action: "error", message: `Cannot loop back to ${opts.to} — it is skipped.` };
32
32
  }
33
33
 
34
34
  // Enforce max loopback count per route
35
- const sameRouteCount = state.loopbacks.filter(
36
- (lb) => lb.from.phase === from.phase && lb.from.subStage === from.subStage
37
- && lb.to.phase === to.phase && lb.to.subStage === to.subStage
38
- ).length;
35
+ const sameRouteCount = countLoopbacksForRoute(state.loopbacks, from, to);
39
36
  if (sameRouteCount >= MAX_LOOPBACKS_PER_ROUTE) {
40
37
  return {
41
38
  action: "error",
@@ -52,7 +49,7 @@ export function loopbackCommand(opts: {
52
49
 
53
50
  resetToLocation(state, to);
54
51
  state.currentPhase = to.phase;
55
- state.currentSubStage = to.subStage;
52
+ state.currentStep = to.step;
56
53
  writeState(root, state);
57
54
 
58
55
  return {
@@ -1,9 +1,11 @@
1
- import { readState, writeState, findWorktreeRoot, readStateMd } from "../state/store.js";
2
- import { determineNextStep } from "../engine/transitions.js";
1
+ import { readState, writeState, findWorktreeRoot, readStateMd, clearBlockingMarkers } from "../state/store.js";
2
+ import { determineNextStep } from "../workflow/transitions.js";
3
3
  import { validatePhasePrerequisites } from "../state/validators.js";
4
4
  import { buildAgentPrompt } from "../context/prompt-builder.js";
5
- import { getParallelGroup } from "../engine/parallel.js";
6
- import { skillFilePath } from "../config/phases.js";
5
+ import { getParallelGroup } from "../workflow/parallel.js";
6
+ import { skillFilePath } from "../config/workflow.js";
7
+ import { CLI_BINARY } from "../config/constants.js";
8
+
7
9
  import type { Action, PhaseName, WorkKitState } from "../state/schema.js";
8
10
 
9
11
  export function nextCommand(worktreeRoot?: string): Action {
@@ -12,6 +14,9 @@ export function nextCommand(worktreeRoot?: string): Action {
12
14
  return { action: "error", message: "No work-kit state found. Run `work-kit init` first." };
13
15
  }
14
16
 
17
+ // Forward state transition → clear any stale "blocked on user" markers
18
+ clearBlockingMarkers(root);
19
+
15
20
  const state = readState(root);
16
21
 
17
22
  if (state.status === "completed") {
@@ -22,17 +27,25 @@ export function nextCommand(worktreeRoot?: string): Action {
22
27
  return { action: "error", message: "Work-kit is in failed state.", suggestion: "Review the state and restart." };
23
28
  }
24
29
 
25
- const step = determineNextStep(state);
30
+ if (state.status === "paused") {
31
+ return {
32
+ action: "error",
33
+ message: `Work-kit is paused (since ${state.pausedAt ?? "earlier"}).`,
34
+ suggestion: `Run \`${CLI_BINARY} resume\` to continue.`,
35
+ };
36
+ }
37
+
38
+ const nextStep = determineNextStep(state);
26
39
 
27
- switch (step.type) {
40
+ switch (nextStep.type) {
28
41
  case "complete":
29
- return { action: "complete", message: step.message! };
42
+ return { action: "complete", message: nextStep.message! };
30
43
 
31
44
  case "wait-for-user":
32
- return { action: "wait_for_user", message: step.message! };
45
+ return { action: "wait_for_user", message: nextStep.message! };
33
46
 
34
47
  case "phase-boundary": {
35
- const phase = step.phase!;
48
+ const phase = nextStep.phase!;
36
49
 
37
50
  const validation = validatePhasePrerequisites(state, phase);
38
51
  if (!validation.valid) {
@@ -47,78 +60,78 @@ export function nextCommand(worktreeRoot?: string): Action {
47
60
  state.phases[phase].status = "in-progress";
48
61
  state.phases[phase].startedAt = new Date().toISOString();
49
62
 
50
- const subStages = Object.entries(state.phases[phase].subStages);
51
- const firstActive = subStages.find(([_, ss]) => ss.status === "pending" || ss.status === "waiting");
63
+ const entries = Object.entries(state.phases[phase].steps);
64
+ const firstActive = entries.find(([_, s]) => s.status === "pending" || s.status === "waiting");
52
65
 
53
66
  if (!firstActive) {
54
- return { action: "error", message: `No pending sub-stages in ${phase}` };
67
+ return { action: "error", message: `No pending steps in ${phase}` };
55
68
  }
56
69
 
57
- const [subStage] = firstActive;
58
- state.currentSubStage = subStage;
59
- state.phases[phase].subStages[subStage].status = "in-progress";
60
- state.phases[phase].subStages[subStage].startedAt = new Date().toISOString();
70
+ const [step] = firstActive;
71
+ state.currentStep = step;
72
+ state.phases[phase].steps[step].status = "in-progress";
73
+ state.phases[phase].steps[step].startedAt = new Date().toISOString();
61
74
  writeState(root, state);
62
75
 
63
- return buildSpawnAction(root, state, phase, subStage);
76
+ return buildSpawnAction(root, state, phase, step);
64
77
  }
65
78
 
66
- case "sub-stage": {
67
- const phase = step.phase!;
68
- const subStage = step.subStage!;
79
+ case "step": {
80
+ const phase = nextStep.phase!;
81
+ const step = nextStep.step!;
69
82
 
70
83
  state.currentPhase = phase;
71
- state.currentSubStage = subStage;
84
+ state.currentStep = step;
72
85
  if (state.phases[phase].status === "pending") {
73
86
  state.phases[phase].status = "in-progress";
74
87
  state.phases[phase].startedAt = new Date().toISOString();
75
88
  }
76
- state.phases[phase].subStages[subStage].status = "in-progress";
77
- state.phases[phase].subStages[subStage].startedAt = new Date().toISOString();
89
+ state.phases[phase].steps[step].status = "in-progress";
90
+ state.phases[phase].steps[step].startedAt = new Date().toISOString();
78
91
  writeState(root, state);
79
92
 
80
- return buildSpawnAction(root, state, phase, subStage);
93
+ return buildSpawnAction(root, state, phase, step);
81
94
  }
82
95
 
83
96
  default:
84
- return { action: "error", message: `Unknown step type: ${step.type}` };
97
+ return { action: "error", message: `Unknown step type: ${nextStep.type}` };
85
98
  }
86
99
  }
87
100
 
88
- function buildSpawnAction(root: string, state: WorkKitState, phase: PhaseName, subStage: string): Action {
101
+ function buildSpawnAction(root: string, state: WorkKitState, phase: PhaseName, step: string): Action {
89
102
  // Read state.md once for all prompt builds
90
103
  const stateMd = readStateMd(root);
91
- const parallelGroup = getParallelGroup(phase, subStage, state);
104
+ const parallelGroup = getParallelGroup(phase, step, state);
92
105
 
93
106
  if (parallelGroup) {
94
107
  const agents = parallelGroup.parallel
95
- .filter((ss) => {
96
- const ssState = state.phases[phase].subStages[ss];
97
- return ssState && ssState.status !== "skipped" && ssState.status !== "completed";
108
+ .filter((s) => {
109
+ const sState = state.phases[phase].steps[s];
110
+ return sState && sState.status !== "skipped" && sState.status !== "completed";
98
111
  })
99
- .map((ss) => ({
112
+ .map((s) => ({
100
113
  phase,
101
- subStage: ss,
102
- skillFile: skillFilePath(phase, ss),
103
- agentPrompt: buildAgentPrompt(root, state, phase, ss, stateMd),
104
- outputFile: `.work-kit/${phase}-${ss}.md`,
114
+ step: s,
115
+ skillFile: skillFilePath(phase, s),
116
+ agentPrompt: buildAgentPrompt(root, state, phase, s, stateMd),
117
+ outputFile: `.work-kit/${phase}-${s}.md`,
105
118
  }));
106
119
 
107
120
  // If all parallel members were filtered out, fall through to single agent
108
121
  if (agents.length === 0) {
109
122
  // Skip to thenSequential if it exists, otherwise nothing to do
110
123
  if (parallelGroup.thenSequential) {
111
- const seqSS = parallelGroup.thenSequential;
124
+ const seqStep = parallelGroup.thenSequential;
112
125
  return {
113
126
  action: "spawn_agent",
114
127
  phase,
115
- subStage: seqSS,
116
- skillFile: skillFilePath(phase, seqSS),
117
- agentPrompt: buildAgentPrompt(root, state, phase, seqSS, stateMd),
118
- onComplete: `npx work-kit-cli complete ${phase}/${seqSS}`,
128
+ step: seqStep,
129
+ skillFile: skillFilePath(phase, seqStep),
130
+ agentPrompt: buildAgentPrompt(root, state, phase, seqStep, stateMd),
131
+ onComplete: `${CLI_BINARY} complete ${phase}/${seqStep}`,
119
132
  };
120
133
  }
121
- return { action: "error", message: `No active sub-stages in parallel group for ${phase}` };
134
+ return { action: "error", message: `No active steps in parallel group for ${phase}` };
122
135
  }
123
136
 
124
137
  // If only 1 agent remains, run as single agent (no need for parallel)
@@ -127,22 +140,22 @@ function buildSpawnAction(root: string, state: WorkKitState, phase: PhaseName, s
127
140
  return {
128
141
  action: "spawn_agent",
129
142
  phase: agent.phase,
130
- subStage: agent.subStage,
143
+ step: agent.step,
131
144
  skillFile: agent.skillFile,
132
145
  agentPrompt: agent.agentPrompt,
133
- onComplete: `npx work-kit-cli complete ${agent.phase}/${agent.subStage}`,
146
+ onComplete: `${CLI_BINARY} complete ${agent.phase}/${agent.step}`,
134
147
  };
135
148
  }
136
149
 
137
150
  for (const agent of agents) {
138
- state.phases[phase].subStages[agent.subStage].status = "in-progress";
139
- state.phases[phase].subStages[agent.subStage].startedAt = new Date().toISOString();
151
+ state.phases[phase].steps[agent.step].status = "in-progress";
152
+ state.phases[phase].steps[agent.step].startedAt = new Date().toISOString();
140
153
  }
141
154
 
142
155
  const thenSequential = parallelGroup.thenSequential
143
156
  ? {
144
157
  phase,
145
- subStage: parallelGroup.thenSequential,
158
+ step: parallelGroup.thenSequential,
146
159
  skillFile: skillFilePath(phase, parallelGroup.thenSequential),
147
160
  agentPrompt: buildAgentPrompt(root, state, phase, parallelGroup.thenSequential, stateMd),
148
161
  }
@@ -154,19 +167,19 @@ function buildSpawnAction(root: string, state: WorkKitState, phase: PhaseName, s
154
167
  action: "spawn_parallel_agents",
155
168
  agents,
156
169
  thenSequential,
157
- onComplete: `npx work-kit-cli complete ${phase}/${parallelGroup.thenSequential || parallelGroup.parallel[parallelGroup.parallel.length - 1]}`,
170
+ onComplete: `${CLI_BINARY} complete ${phase}/${parallelGroup.thenSequential || parallelGroup.parallel[parallelGroup.parallel.length - 1]}`,
158
171
  };
159
172
  }
160
173
 
161
- const skill = skillFilePath(phase, subStage);
162
- const prompt = buildAgentPrompt(root, state, phase, subStage, stateMd);
174
+ const skill = skillFilePath(phase, step);
175
+ const prompt = buildAgentPrompt(root, state, phase, step, stateMd);
163
176
 
164
177
  return {
165
178
  action: "spawn_agent",
166
179
  phase,
167
- subStage,
180
+ step,
168
181
  skillFile: skill,
169
182
  agentPrompt: prompt,
170
- onComplete: `npx work-kit-cli complete ${phase}/${subStage}`,
183
+ onComplete: `${CLI_BINARY} complete ${phase}/${step}`,
171
184
  };
172
185
  }
@@ -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(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(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
+ }