work-kit-cli 0.2.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +24 -13
  2. package/cli/src/commands/bootstrap.test.ts +40 -0
  3. package/cli/src/commands/bootstrap.ts +77 -13
  4. package/cli/src/commands/cancel.ts +1 -16
  5. package/cli/src/commands/complete.ts +92 -98
  6. package/cli/src/commands/completions.ts +2 -2
  7. package/cli/src/commands/doctor.ts +1 -1
  8. package/cli/src/commands/extract.ts +217 -0
  9. package/cli/src/commands/init.test.ts +50 -0
  10. package/cli/src/commands/init.ts +70 -35
  11. package/cli/src/commands/learn.test.ts +217 -0
  12. package/cli/src/commands/learn.ts +104 -0
  13. package/cli/src/commands/loopback.ts +8 -11
  14. package/cli/src/commands/next.ts +93 -60
  15. package/cli/src/commands/observe.ts +16 -21
  16. package/cli/src/commands/pause-resume.test.ts +142 -0
  17. package/cli/src/commands/pause.ts +34 -0
  18. package/cli/src/commands/report.ts +217 -0
  19. package/cli/src/commands/resume.ts +126 -0
  20. package/cli/src/commands/setup.ts +280 -0
  21. package/cli/src/commands/status.ts +8 -6
  22. package/cli/src/commands/uninstall.ts +8 -3
  23. package/cli/src/commands/workflow.ts +43 -33
  24. package/cli/src/config/agent-map.ts +9 -9
  25. package/cli/src/config/constants.ts +54 -0
  26. package/cli/src/config/loopback-routes.ts +13 -13
  27. package/cli/src/config/model-routing.test.ts +190 -0
  28. package/cli/src/config/model-routing.ts +208 -0
  29. package/cli/src/config/project-config.test.ts +127 -0
  30. package/cli/src/config/project-config.ts +106 -0
  31. package/cli/src/config/{phases.ts → workflow.ts} +40 -23
  32. package/cli/src/context/prompt-builder.ts +10 -9
  33. package/cli/src/index.ts +130 -9
  34. package/cli/src/observer/data.ts +196 -65
  35. package/cli/src/observer/renderer.ts +127 -107
  36. package/cli/src/observer/watcher.ts +28 -16
  37. package/cli/src/state/helpers.test.ts +28 -28
  38. package/cli/src/state/helpers.ts +37 -25
  39. package/cli/src/state/schema.ts +135 -45
  40. package/cli/src/state/store.ts +127 -7
  41. package/cli/src/state/validators.test.ts +13 -13
  42. package/cli/src/state/validators.ts +3 -4
  43. package/cli/src/utils/colors.ts +2 -0
  44. package/cli/src/utils/fs.ts +13 -0
  45. package/cli/src/utils/json.ts +20 -0
  46. package/cli/src/utils/knowledge.ts +471 -0
  47. package/cli/src/utils/time.ts +27 -0
  48. package/cli/src/{engine → workflow}/loopbacks.test.ts +2 -2
  49. package/cli/src/workflow/loopbacks.ts +42 -0
  50. package/cli/src/workflow/parallel.ts +64 -0
  51. package/cli/src/workflow/transitions.test.ts +129 -0
  52. package/cli/src/{engine → workflow}/transitions.ts +18 -22
  53. package/package.json +2 -2
  54. package/skills/auto-kit/SKILL.md +44 -27
  55. package/skills/cancel-kit/SKILL.md +4 -4
  56. package/skills/full-kit/SKILL.md +45 -28
  57. package/skills/pause-kit/SKILL.md +25 -0
  58. package/skills/resume-kit/SKILL.md +64 -0
  59. package/skills/wk-bootstrap/SKILL.md +11 -5
  60. package/skills/wk-build/SKILL.md +12 -11
  61. package/skills/wk-build/{stages → steps}/commit.md +1 -1
  62. package/skills/wk-build/{stages → steps}/core.md +3 -3
  63. package/skills/wk-build/{stages → steps}/integration.md +2 -2
  64. package/skills/wk-build/{stages → steps}/migration.md +1 -1
  65. package/skills/wk-build/{stages → steps}/red.md +1 -1
  66. package/skills/wk-build/{stages → steps}/refactor.md +1 -1
  67. package/skills/wk-build/{stages → steps}/setup.md +1 -1
  68. package/skills/wk-build/{stages → steps}/ui.md +1 -1
  69. package/skills/wk-deploy/SKILL.md +7 -6
  70. package/skills/wk-deploy/{stages → steps}/merge.md +1 -1
  71. package/skills/wk-deploy/{stages → steps}/monitor.md +1 -1
  72. package/skills/wk-deploy/{stages → steps}/remediate.md +1 -1
  73. package/skills/wk-plan/SKILL.md +15 -14
  74. package/skills/wk-plan/{stages → steps}/architecture.md +1 -1
  75. package/skills/wk-plan/{stages → steps}/audit.md +2 -2
  76. package/skills/wk-plan/{stages → steps}/blueprint.md +2 -2
  77. package/skills/wk-plan/{stages → steps}/clarify.md +1 -1
  78. package/skills/wk-plan/{stages → steps}/investigate.md +1 -1
  79. package/skills/wk-plan/{stages → steps}/scope.md +1 -1
  80. package/skills/wk-plan/{stages → steps}/sketch.md +1 -1
  81. package/skills/wk-plan/{stages → steps}/ux-flow.md +1 -1
  82. package/skills/wk-review/SKILL.md +11 -10
  83. package/skills/wk-review/{stages → steps}/compliance.md +1 -1
  84. package/skills/wk-review/{stages → steps}/handoff.md +2 -2
  85. package/skills/wk-review/{stages → steps}/performance.md +1 -1
  86. package/skills/wk-review/{stages → steps}/security.md +1 -1
  87. package/skills/wk-review/{stages → steps}/self-review.md +1 -1
  88. package/skills/wk-test/SKILL.md +9 -8
  89. package/skills/wk-test/steps/e2e.md +56 -0
  90. package/skills/wk-test/{stages → steps}/validate.md +1 -1
  91. package/skills/wk-test/{stages → steps}/verify.md +1 -1
  92. package/skills/wk-wrap-up/SKILL.md +19 -5
  93. package/skills/wk-wrap-up/steps/knowledge.md +76 -0
  94. package/skills/wk-wrap-up/steps/summary.md +86 -0
  95. package/cli/src/engine/loopbacks.ts +0 -32
  96. package/cli/src/engine/parallel.ts +0 -60
  97. package/cli/src/engine/transitions.test.ts +0 -129
  98. package/skills/wk-test/stages/e2e.md +0 -53
  99. /package/cli/src/{engine/phases.ts → workflow/gates.ts} +0 -0
@@ -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
+ }
@@ -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,12 @@
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 { resolveModel } from "../config/model-routing.js";
8
+ import { CLI_BINARY } from "../config/constants.js";
9
+
7
10
  import type { Action, PhaseName, WorkKitState } from "../state/schema.js";
8
11
 
9
12
  export function nextCommand(worktreeRoot?: string): Action {
@@ -12,6 +15,9 @@ export function nextCommand(worktreeRoot?: string): Action {
12
15
  return { action: "error", message: "No work-kit state found. Run `work-kit init` first." };
13
16
  }
14
17
 
18
+ // Forward state transition → clear any stale "blocked on user" markers
19
+ clearBlockingMarkers(root);
20
+
15
21
  const state = readState(root);
16
22
 
17
23
  if (state.status === "completed") {
@@ -22,17 +28,25 @@ export function nextCommand(worktreeRoot?: string): Action {
22
28
  return { action: "error", message: "Work-kit is in failed state.", suggestion: "Review the state and restart." };
23
29
  }
24
30
 
25
- const step = determineNextStep(state);
31
+ if (state.status === "paused") {
32
+ return {
33
+ action: "error",
34
+ message: `Work-kit is paused (since ${state.pausedAt ?? "earlier"}).`,
35
+ suggestion: `Run \`${CLI_BINARY} resume\` to continue.`,
36
+ };
37
+ }
38
+
39
+ const nextStep = determineNextStep(state);
26
40
 
27
- switch (step.type) {
41
+ switch (nextStep.type) {
28
42
  case "complete":
29
- return { action: "complete", message: step.message! };
43
+ return { action: "complete", message: nextStep.message! };
30
44
 
31
45
  case "wait-for-user":
32
- return { action: "wait_for_user", message: step.message! };
46
+ return { action: "wait_for_user", message: nextStep.message! };
33
47
 
34
48
  case "phase-boundary": {
35
- const phase = step.phase!;
49
+ const phase = nextStep.phase!;
36
50
 
37
51
  const validation = validatePhasePrerequisites(state, phase);
38
52
  if (!validation.valid) {
@@ -47,105 +61,105 @@ export function nextCommand(worktreeRoot?: string): Action {
47
61
  state.phases[phase].status = "in-progress";
48
62
  state.phases[phase].startedAt = new Date().toISOString();
49
63
 
50
- const subStages = Object.entries(state.phases[phase].subStages);
51
- const firstActive = subStages.find(([_, ss]) => ss.status === "pending" || ss.status === "waiting");
64
+ const entries = Object.entries(state.phases[phase].steps);
65
+ const firstActive = entries.find(([_, s]) => s.status === "pending" || s.status === "waiting");
52
66
 
53
67
  if (!firstActive) {
54
- return { action: "error", message: `No pending sub-stages in ${phase}` };
68
+ return { action: "error", message: `No pending steps in ${phase}` };
55
69
  }
56
70
 
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();
71
+ const [step] = firstActive;
72
+ state.currentStep = step;
73
+ state.phases[phase].steps[step].status = "in-progress";
74
+ state.phases[phase].steps[step].startedAt = new Date().toISOString();
61
75
  writeState(root, state);
62
76
 
63
- return buildSpawnAction(root, state, phase, subStage);
77
+ return buildSpawnAction(root, state, phase, step);
64
78
  }
65
79
 
66
- case "sub-stage": {
67
- const phase = step.phase!;
68
- const subStage = step.subStage!;
80
+ case "step": {
81
+ const phase = nextStep.phase!;
82
+ const step = nextStep.step!;
69
83
 
70
84
  state.currentPhase = phase;
71
- state.currentSubStage = subStage;
85
+ state.currentStep = step;
72
86
  if (state.phases[phase].status === "pending") {
73
87
  state.phases[phase].status = "in-progress";
74
88
  state.phases[phase].startedAt = new Date().toISOString();
75
89
  }
76
- state.phases[phase].subStages[subStage].status = "in-progress";
77
- state.phases[phase].subStages[subStage].startedAt = new Date().toISOString();
90
+ state.phases[phase].steps[step].status = "in-progress";
91
+ state.phases[phase].steps[step].startedAt = new Date().toISOString();
78
92
  writeState(root, state);
79
93
 
80
- return buildSpawnAction(root, state, phase, subStage);
94
+ return buildSpawnAction(root, state, phase, step);
81
95
  }
82
96
 
83
97
  default:
84
- return { action: "error", message: `Unknown step type: ${step.type}` };
98
+ return { action: "error", message: `Unknown step type: ${nextStep.type}` };
85
99
  }
86
100
  }
87
101
 
88
- function buildSpawnAction(root: string, state: WorkKitState, phase: PhaseName, subStage: string): Action {
102
+ function buildSpawnAction(root: string, state: WorkKitState, phase: PhaseName, step: string): Action {
89
103
  // Read state.md once for all prompt builds
90
104
  const stateMd = readStateMd(root);
91
- const parallelGroup = getParallelGroup(phase, subStage, state);
105
+ const parallelGroup = getParallelGroup(phase, step, state);
92
106
 
93
107
  if (parallelGroup) {
94
108
  const agents = parallelGroup.parallel
95
- .filter((ss) => {
96
- const ssState = state.phases[phase].subStages[ss];
97
- return ssState && ssState.status !== "skipped" && ssState.status !== "completed";
109
+ .filter((s) => {
110
+ const sState = state.phases[phase].steps[s];
111
+ return sState && sState.status !== "skipped" && sState.status !== "completed";
98
112
  })
99
- .map((ss) => ({
113
+ .map((s) => withModel({
100
114
  phase,
101
- subStage: ss,
102
- skillFile: skillFilePath(phase, ss),
103
- agentPrompt: buildAgentPrompt(root, state, phase, ss, stateMd),
104
- outputFile: `.work-kit/${phase}-${ss}.md`,
105
- }));
115
+ step: s,
116
+ skillFile: skillFilePath(phase, s),
117
+ agentPrompt: buildAgentPrompt(root, state, phase, s, stateMd),
118
+ outputFile: `.work-kit/${phase}-${s}.md`,
119
+ }, state));
106
120
 
107
121
  // If all parallel members were filtered out, fall through to single agent
108
122
  if (agents.length === 0) {
109
123
  // Skip to thenSequential if it exists, otherwise nothing to do
110
124
  if (parallelGroup.thenSequential) {
111
- const seqSS = parallelGroup.thenSequential;
112
- return {
125
+ const seqStep = parallelGroup.thenSequential;
126
+ return withModelAction({
113
127
  action: "spawn_agent",
114
128
  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}`,
119
- };
129
+ step: seqStep,
130
+ skillFile: skillFilePath(phase, seqStep),
131
+ agentPrompt: buildAgentPrompt(root, state, phase, seqStep, stateMd),
132
+ onComplete: `${CLI_BINARY} complete ${phase}/${seqStep}`,
133
+ }, state);
120
134
  }
121
- return { action: "error", message: `No active sub-stages in parallel group for ${phase}` };
135
+ return { action: "error", message: `No active steps in parallel group for ${phase}` };
122
136
  }
123
137
 
124
138
  // If only 1 agent remains, run as single agent (no need for parallel)
125
139
  if (agents.length === 1 && !parallelGroup.thenSequential) {
126
140
  const agent = agents[0];
127
- return {
141
+ return withModelAction({
128
142
  action: "spawn_agent",
129
143
  phase: agent.phase,
130
- subStage: agent.subStage,
144
+ step: agent.step,
131
145
  skillFile: agent.skillFile,
132
146
  agentPrompt: agent.agentPrompt,
133
- onComplete: `npx work-kit-cli complete ${agent.phase}/${agent.subStage}`,
134
- };
147
+ onComplete: `${CLI_BINARY} complete ${agent.phase}/${agent.step}`,
148
+ }, state);
135
149
  }
136
150
 
137
151
  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();
152
+ state.phases[phase].steps[agent.step].status = "in-progress";
153
+ state.phases[phase].steps[agent.step].startedAt = new Date().toISOString();
140
154
  }
141
155
 
142
156
  const thenSequential = parallelGroup.thenSequential
143
- ? {
157
+ ? withModel({
144
158
  phase,
145
- subStage: parallelGroup.thenSequential,
159
+ step: parallelGroup.thenSequential,
146
160
  skillFile: skillFilePath(phase, parallelGroup.thenSequential),
147
161
  agentPrompt: buildAgentPrompt(root, state, phase, parallelGroup.thenSequential, stateMd),
148
- }
162
+ }, state)
149
163
  : undefined;
150
164
 
151
165
  writeState(root, state);
@@ -154,19 +168,38 @@ function buildSpawnAction(root: string, state: WorkKitState, phase: PhaseName, s
154
168
  action: "spawn_parallel_agents",
155
169
  agents,
156
170
  thenSequential,
157
- onComplete: `npx work-kit-cli complete ${phase}/${parallelGroup.thenSequential || parallelGroup.parallel[parallelGroup.parallel.length - 1]}`,
171
+ onComplete: `${CLI_BINARY} complete ${phase}/${parallelGroup.thenSequential || parallelGroup.parallel[parallelGroup.parallel.length - 1]}`,
158
172
  };
159
173
  }
160
174
 
161
- const skill = skillFilePath(phase, subStage);
162
- const prompt = buildAgentPrompt(root, state, phase, subStage, stateMd);
175
+ const skill = skillFilePath(phase, step);
176
+ const prompt = buildAgentPrompt(root, state, phase, step, stateMd);
163
177
 
164
- return {
178
+ return withModelAction({
165
179
  action: "spawn_agent",
166
180
  phase,
167
- subStage,
181
+ step,
168
182
  skillFile: skill,
169
183
  agentPrompt: prompt,
170
- onComplete: `npx work-kit-cli complete ${phase}/${subStage}`,
171
- };
184
+ onComplete: `${CLI_BINARY} complete ${phase}/${step}`,
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;
172
205
  }