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.
- package/README.md +13 -13
- package/cli/src/commands/bootstrap.ts +39 -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/init.ts +40 -32
- package/cli/src/commands/loopback.ts +8 -11
- package/cli/src/commands/next.ts +64 -51
- 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 +38 -0
- package/cli/src/commands/setup.ts +136 -0
- package/cli/src/commands/status.ts +6 -6
- package/cli/src/commands/uninstall.ts +8 -3
- package/cli/src/commands/workflow.ts +27 -27
- package/cli/src/config/agent-map.ts +9 -9
- package/cli/src/config/constants.ts +44 -0
- package/cli/src/config/loopback-routes.ts +13 -13
- 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 +63 -7
- package/cli/src/observer/data.ts +64 -56
- package/cli/src/observer/renderer.ts +101 -79
- package/cli/src/state/helpers.test.ts +28 -28
- package/cli/src/state/helpers.ts +37 -25
- package/cli/src/state/schema.ts +88 -45
- package/cli/src/state/store.ts +92 -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/json.ts +20 -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 +22 -22
- package/skills/cancel-kit/SKILL.md +4 -4
- package/skills/full-kit/SKILL.md +23 -23
- package/skills/pause-kit/SKILL.md +25 -0
- package/skills/resume-kit/SKILL.md +28 -0
- package/skills/wk-bootstrap/SKILL.md +5 -5
- package/skills/wk-build/SKILL.md +10 -10
- 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 +6 -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 +13 -13
- 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 +10 -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 +8 -8
- package/skills/wk-test/{stages → steps}/e2e.md +1 -1
- 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 +6 -5
- 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/cli/src/{engine/phases.ts → workflow/gates.ts} +0 -0
package/cli/src/commands/init.ts
CHANGED
|
@@ -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,
|
|
4
|
-
import { writeState, writeStateMd, stateExists } from "../state/store.js";
|
|
5
|
-
import { buildFullWorkflow, buildDefaultWorkflow, skillFilePath } from "../config/
|
|
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
|
|
22
|
-
const
|
|
23
|
+
const steps: Record<string, { status: "pending" | "skipped" }> = {};
|
|
24
|
+
const allSteps = STEPS_BY_PHASE[phase];
|
|
23
25
|
|
|
24
|
-
for (const
|
|
26
|
+
for (const s of allSteps) {
|
|
25
27
|
if (workflow) {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
+
const ws = workflow.find((w) => w.phase === phase && w.step === s);
|
|
29
|
+
steps[s] = { status: ws?.included ? "pending" : "skipped" };
|
|
28
30
|
} else {
|
|
29
|
-
|
|
31
|
+
steps[s] = { status: "pending" };
|
|
30
32
|
}
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
// Check if entire phase is skipped (all
|
|
34
|
-
const allSkipped = Object.values(
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
|
71
|
-
if (
|
|
72
|
-
const label = `${
|
|
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 =
|
|
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
|
|
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 =
|
|
144
|
-
const modeLabel = mode === "full" ?
|
|
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
|
|
162
|
+
// Find first active step
|
|
155
163
|
let firstPhase: PhaseName = "plan";
|
|
156
|
-
let
|
|
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
|
-
|
|
170
|
+
firstStep = first.step;
|
|
163
171
|
}
|
|
164
172
|
}
|
|
165
173
|
|
|
166
174
|
// Build state
|
|
167
175
|
const state: WorkKitState = {
|
|
168
|
-
version:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
198
|
-
skillFile: skillFilePath(firstPhase,
|
|
199
|
-
agentPrompt: `You are starting the ${firstPhase} phase. Begin with the ${
|
|
200
|
-
onComplete:
|
|
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]?.
|
|
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]?.
|
|
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
|
|
30
|
-
if (state.phases[to.phase].
|
|
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
|
|
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.
|
|
52
|
+
state.currentStep = to.step;
|
|
56
53
|
writeState(root, state);
|
|
57
54
|
|
|
58
55
|
return {
|
package/cli/src/commands/next.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { readState, writeState, findWorktreeRoot, readStateMd } from "../state/store.js";
|
|
2
|
-
import { determineNextStep } from "../
|
|
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 "../
|
|
6
|
-
import { skillFilePath } from "../config/
|
|
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
|
-
|
|
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 (
|
|
40
|
+
switch (nextStep.type) {
|
|
28
41
|
case "complete":
|
|
29
|
-
return { action: "complete", message:
|
|
42
|
+
return { action: "complete", message: nextStep.message! };
|
|
30
43
|
|
|
31
44
|
case "wait-for-user":
|
|
32
|
-
return { action: "wait_for_user", message:
|
|
45
|
+
return { action: "wait_for_user", message: nextStep.message! };
|
|
33
46
|
|
|
34
47
|
case "phase-boundary": {
|
|
35
|
-
const 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
|
|
51
|
-
const firstActive =
|
|
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
|
|
67
|
+
return { action: "error", message: `No pending steps in ${phase}` };
|
|
55
68
|
}
|
|
56
69
|
|
|
57
|
-
const [
|
|
58
|
-
state.
|
|
59
|
-
state.phases[phase].
|
|
60
|
-
state.phases[phase].
|
|
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,
|
|
76
|
+
return buildSpawnAction(root, state, phase, step);
|
|
64
77
|
}
|
|
65
78
|
|
|
66
|
-
case "
|
|
67
|
-
const phase =
|
|
68
|
-
const
|
|
79
|
+
case "step": {
|
|
80
|
+
const phase = nextStep.phase!;
|
|
81
|
+
const step = nextStep.step!;
|
|
69
82
|
|
|
70
83
|
state.currentPhase = phase;
|
|
71
|
-
state.
|
|
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].
|
|
77
|
-
state.phases[phase].
|
|
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,
|
|
93
|
+
return buildSpawnAction(root, state, phase, step);
|
|
81
94
|
}
|
|
82
95
|
|
|
83
96
|
default:
|
|
84
|
-
return { action: "error", message: `Unknown 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,
|
|
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,
|
|
104
|
+
const parallelGroup = getParallelGroup(phase, step, state);
|
|
92
105
|
|
|
93
106
|
if (parallelGroup) {
|
|
94
107
|
const agents = parallelGroup.parallel
|
|
95
|
-
.filter((
|
|
96
|
-
const
|
|
97
|
-
return
|
|
108
|
+
.filter((s) => {
|
|
109
|
+
const sState = state.phases[phase].steps[s];
|
|
110
|
+
return sState && sState.status !== "skipped" && sState.status !== "completed";
|
|
98
111
|
})
|
|
99
|
-
.map((
|
|
112
|
+
.map((s) => ({
|
|
100
113
|
phase,
|
|
101
|
-
|
|
102
|
-
skillFile: skillFilePath(phase,
|
|
103
|
-
agentPrompt: buildAgentPrompt(root, state, phase,
|
|
104
|
-
outputFile: `.work-kit/${phase}-${
|
|
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
|
|
124
|
+
const seqStep = parallelGroup.thenSequential;
|
|
112
125
|
return {
|
|
113
126
|
action: "spawn_agent",
|
|
114
127
|
phase,
|
|
115
|
-
|
|
116
|
-
skillFile: skillFilePath(phase,
|
|
117
|
-
agentPrompt: buildAgentPrompt(root, state, phase,
|
|
118
|
-
onComplete:
|
|
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
|
|
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
|
-
|
|
143
|
+
step: agent.step,
|
|
131
144
|
skillFile: agent.skillFile,
|
|
132
145
|
agentPrompt: agent.agentPrompt,
|
|
133
|
-
onComplete:
|
|
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].
|
|
139
|
-
state.phases[phase].
|
|
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
|
-
|
|
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:
|
|
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,
|
|
162
|
-
const prompt = buildAgentPrompt(root, state, phase,
|
|
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
|
-
|
|
180
|
+
step,
|
|
168
181
|
skillFile: skill,
|
|
169
182
|
agentPrompt: prompt,
|
|
170
|
-
onComplete:
|
|
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
|
+
}
|