work-kit-cli 0.2.8 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -13
- package/cli/src/commands/bootstrap.test.ts +40 -0
- package/cli/src/commands/bootstrap.ts +77 -13
- package/cli/src/commands/cancel.ts +1 -16
- package/cli/src/commands/complete.ts +92 -98
- package/cli/src/commands/completions.ts +2 -2
- package/cli/src/commands/doctor.ts +1 -1
- package/cli/src/commands/extract.ts +217 -0
- package/cli/src/commands/init.test.ts +50 -0
- package/cli/src/commands/init.ts +70 -35
- package/cli/src/commands/learn.test.ts +217 -0
- package/cli/src/commands/learn.ts +104 -0
- package/cli/src/commands/loopback.ts +8 -11
- package/cli/src/commands/next.ts +93 -60
- package/cli/src/commands/observe.ts +16 -21
- package/cli/src/commands/pause-resume.test.ts +142 -0
- package/cli/src/commands/pause.ts +34 -0
- package/cli/src/commands/report.ts +217 -0
- package/cli/src/commands/resume.ts +126 -0
- package/cli/src/commands/setup.ts +280 -0
- package/cli/src/commands/status.ts +8 -6
- package/cli/src/commands/uninstall.ts +8 -3
- package/cli/src/commands/workflow.ts +43 -33
- package/cli/src/config/agent-map.ts +9 -9
- package/cli/src/config/constants.ts +54 -0
- package/cli/src/config/loopback-routes.ts +13 -13
- package/cli/src/config/model-routing.test.ts +190 -0
- package/cli/src/config/model-routing.ts +208 -0
- package/cli/src/config/project-config.test.ts +127 -0
- package/cli/src/config/project-config.ts +106 -0
- package/cli/src/config/{phases.ts → workflow.ts} +40 -23
- package/cli/src/context/prompt-builder.ts +10 -9
- package/cli/src/index.ts +130 -9
- package/cli/src/observer/data.ts +196 -65
- package/cli/src/observer/renderer.ts +127 -107
- package/cli/src/observer/watcher.ts +28 -16
- package/cli/src/state/helpers.test.ts +28 -28
- package/cli/src/state/helpers.ts +37 -25
- package/cli/src/state/schema.ts +135 -45
- package/cli/src/state/store.ts +127 -7
- package/cli/src/state/validators.test.ts +13 -13
- package/cli/src/state/validators.ts +3 -4
- package/cli/src/utils/colors.ts +2 -0
- package/cli/src/utils/fs.ts +13 -0
- package/cli/src/utils/json.ts +20 -0
- package/cli/src/utils/knowledge.ts +471 -0
- package/cli/src/utils/time.ts +27 -0
- package/cli/src/{engine → workflow}/loopbacks.test.ts +2 -2
- package/cli/src/workflow/loopbacks.ts +42 -0
- package/cli/src/workflow/parallel.ts +64 -0
- package/cli/src/workflow/transitions.test.ts +129 -0
- package/cli/src/{engine → workflow}/transitions.ts +18 -22
- package/package.json +2 -2
- package/skills/auto-kit/SKILL.md +44 -27
- package/skills/cancel-kit/SKILL.md +4 -4
- package/skills/full-kit/SKILL.md +45 -28
- package/skills/pause-kit/SKILL.md +25 -0
- package/skills/resume-kit/SKILL.md +64 -0
- package/skills/wk-bootstrap/SKILL.md +11 -5
- package/skills/wk-build/SKILL.md +12 -11
- package/skills/wk-build/{stages → steps}/commit.md +1 -1
- package/skills/wk-build/{stages → steps}/core.md +3 -3
- package/skills/wk-build/{stages → steps}/integration.md +2 -2
- package/skills/wk-build/{stages → steps}/migration.md +1 -1
- package/skills/wk-build/{stages → steps}/red.md +1 -1
- package/skills/wk-build/{stages → steps}/refactor.md +1 -1
- package/skills/wk-build/{stages → steps}/setup.md +1 -1
- package/skills/wk-build/{stages → steps}/ui.md +1 -1
- package/skills/wk-deploy/SKILL.md +7 -6
- package/skills/wk-deploy/{stages → steps}/merge.md +1 -1
- package/skills/wk-deploy/{stages → steps}/monitor.md +1 -1
- package/skills/wk-deploy/{stages → steps}/remediate.md +1 -1
- package/skills/wk-plan/SKILL.md +15 -14
- package/skills/wk-plan/{stages → steps}/architecture.md +1 -1
- package/skills/wk-plan/{stages → steps}/audit.md +2 -2
- package/skills/wk-plan/{stages → steps}/blueprint.md +2 -2
- package/skills/wk-plan/{stages → steps}/clarify.md +1 -1
- package/skills/wk-plan/{stages → steps}/investigate.md +1 -1
- package/skills/wk-plan/{stages → steps}/scope.md +1 -1
- package/skills/wk-plan/{stages → steps}/sketch.md +1 -1
- package/skills/wk-plan/{stages → steps}/ux-flow.md +1 -1
- package/skills/wk-review/SKILL.md +11 -10
- package/skills/wk-review/{stages → steps}/compliance.md +1 -1
- package/skills/wk-review/{stages → steps}/handoff.md +2 -2
- package/skills/wk-review/{stages → steps}/performance.md +1 -1
- package/skills/wk-review/{stages → steps}/security.md +1 -1
- package/skills/wk-review/{stages → steps}/self-review.md +1 -1
- package/skills/wk-test/SKILL.md +9 -8
- package/skills/wk-test/steps/e2e.md +56 -0
- package/skills/wk-test/{stages → steps}/validate.md +1 -1
- package/skills/wk-test/{stages → steps}/verify.md +1 -1
- package/skills/wk-wrap-up/SKILL.md +19 -5
- package/skills/wk-wrap-up/steps/knowledge.md +76 -0
- package/skills/wk-wrap-up/steps/summary.md +86 -0
- package/cli/src/engine/loopbacks.ts +0 -32
- package/cli/src/engine/parallel.ts +0 -60
- package/cli/src/engine/transitions.test.ts +0 -129
- package/skills/wk-test/stages/e2e.md +0 -53
- /package/cli/src/{engine/phases.ts → workflow/gates.ts} +0 -0
|
@@ -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]?.
|
|
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,12 @@
|
|
|
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 { 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
|
-
|
|
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 (
|
|
41
|
+
switch (nextStep.type) {
|
|
28
42
|
case "complete":
|
|
29
|
-
return { action: "complete", message:
|
|
43
|
+
return { action: "complete", message: nextStep.message! };
|
|
30
44
|
|
|
31
45
|
case "wait-for-user":
|
|
32
|
-
return { action: "wait_for_user", message:
|
|
46
|
+
return { action: "wait_for_user", message: nextStep.message! };
|
|
33
47
|
|
|
34
48
|
case "phase-boundary": {
|
|
35
|
-
const 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
|
|
51
|
-
const firstActive =
|
|
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
|
|
68
|
+
return { action: "error", message: `No pending steps in ${phase}` };
|
|
55
69
|
}
|
|
56
70
|
|
|
57
|
-
const [
|
|
58
|
-
state.
|
|
59
|
-
state.phases[phase].
|
|
60
|
-
state.phases[phase].
|
|
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,
|
|
77
|
+
return buildSpawnAction(root, state, phase, step);
|
|
64
78
|
}
|
|
65
79
|
|
|
66
|
-
case "
|
|
67
|
-
const phase =
|
|
68
|
-
const
|
|
80
|
+
case "step": {
|
|
81
|
+
const phase = nextStep.phase!;
|
|
82
|
+
const step = nextStep.step!;
|
|
69
83
|
|
|
70
84
|
state.currentPhase = phase;
|
|
71
|
-
state.
|
|
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].
|
|
77
|
-
state.phases[phase].
|
|
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,
|
|
94
|
+
return buildSpawnAction(root, state, phase, step);
|
|
81
95
|
}
|
|
82
96
|
|
|
83
97
|
default:
|
|
84
|
-
return { action: "error", message: `Unknown 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,
|
|
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,
|
|
105
|
+
const parallelGroup = getParallelGroup(phase, step, state);
|
|
92
106
|
|
|
93
107
|
if (parallelGroup) {
|
|
94
108
|
const agents = parallelGroup.parallel
|
|
95
|
-
.filter((
|
|
96
|
-
const
|
|
97
|
-
return
|
|
109
|
+
.filter((s) => {
|
|
110
|
+
const sState = state.phases[phase].steps[s];
|
|
111
|
+
return sState && sState.status !== "skipped" && sState.status !== "completed";
|
|
98
112
|
})
|
|
99
|
-
.map((
|
|
113
|
+
.map((s) => withModel({
|
|
100
114
|
phase,
|
|
101
|
-
|
|
102
|
-
skillFile: skillFilePath(phase,
|
|
103
|
-
agentPrompt: buildAgentPrompt(root, state, phase,
|
|
104
|
-
outputFile: `.work-kit/${phase}-${
|
|
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
|
|
112
|
-
return {
|
|
125
|
+
const seqStep = parallelGroup.thenSequential;
|
|
126
|
+
return withModelAction({
|
|
113
127
|
action: "spawn_agent",
|
|
114
128
|
phase,
|
|
115
|
-
|
|
116
|
-
skillFile: skillFilePath(phase,
|
|
117
|
-
agentPrompt: buildAgentPrompt(root, state, phase,
|
|
118
|
-
onComplete:
|
|
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
|
|
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
|
-
|
|
144
|
+
step: agent.step,
|
|
131
145
|
skillFile: agent.skillFile,
|
|
132
146
|
agentPrompt: agent.agentPrompt,
|
|
133
|
-
onComplete:
|
|
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].
|
|
139
|
-
state.phases[phase].
|
|
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
|
-
|
|
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:
|
|
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,
|
|
162
|
-
const prompt = buildAgentPrompt(root, state, phase,
|
|
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
|
-
|
|
181
|
+
step,
|
|
168
182
|
skillFile: skill,
|
|
169
183
|
agentPrompt: prompt,
|
|
170
|
-
onComplete:
|
|
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
|
}
|