work-kit-cli 0.2.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 +147 -0
- package/cli/bin/work-kit.mjs +21 -0
- package/cli/src/commands/complete.ts +163 -0
- package/cli/src/commands/completions.ts +137 -0
- package/cli/src/commands/context.ts +41 -0
- package/cli/src/commands/doctor.ts +79 -0
- package/cli/src/commands/init.test.ts +116 -0
- package/cli/src/commands/init.ts +184 -0
- package/cli/src/commands/loopback.ts +64 -0
- package/cli/src/commands/next.ts +172 -0
- package/cli/src/commands/observe.ts +144 -0
- package/cli/src/commands/setup.ts +159 -0
- package/cli/src/commands/status.ts +50 -0
- package/cli/src/commands/uninstall.ts +89 -0
- package/cli/src/commands/upgrade.ts +12 -0
- package/cli/src/commands/validate.ts +34 -0
- package/cli/src/commands/workflow.ts +125 -0
- package/cli/src/config/agent-map.ts +62 -0
- package/cli/src/config/loopback-routes.ts +45 -0
- package/cli/src/config/phases.ts +119 -0
- package/cli/src/context/extractor.test.ts +77 -0
- package/cli/src/context/extractor.ts +73 -0
- package/cli/src/context/prompt-builder.ts +70 -0
- package/cli/src/engine/loopbacks.test.ts +33 -0
- package/cli/src/engine/loopbacks.ts +32 -0
- package/cli/src/engine/parallel.ts +60 -0
- package/cli/src/engine/phases.ts +23 -0
- package/cli/src/engine/transitions.test.ts +117 -0
- package/cli/src/engine/transitions.ts +97 -0
- package/cli/src/index.ts +253 -0
- package/cli/src/observer/data.ts +267 -0
- package/cli/src/observer/renderer.ts +364 -0
- package/cli/src/observer/watcher.ts +104 -0
- package/cli/src/state/helpers.test.ts +91 -0
- package/cli/src/state/helpers.ts +65 -0
- package/cli/src/state/schema.ts +113 -0
- package/cli/src/state/store.ts +82 -0
- package/cli/src/state/validators.test.ts +105 -0
- package/cli/src/state/validators.ts +81 -0
- package/cli/src/utils/colors.ts +12 -0
- package/package.json +50 -0
- package/skills/auto-kit/SKILL.md +216 -0
- package/skills/build/SKILL.md +88 -0
- package/skills/build/stages/commit.md +43 -0
- package/skills/build/stages/core.md +48 -0
- package/skills/build/stages/integration.md +44 -0
- package/skills/build/stages/migration.md +41 -0
- package/skills/build/stages/red.md +44 -0
- package/skills/build/stages/refactor.md +48 -0
- package/skills/build/stages/setup.md +42 -0
- package/skills/build/stages/ui.md +51 -0
- package/skills/deploy/SKILL.md +62 -0
- package/skills/deploy/stages/merge.md +59 -0
- package/skills/deploy/stages/monitor.md +39 -0
- package/skills/deploy/stages/remediate.md +54 -0
- package/skills/full-kit/SKILL.md +197 -0
- package/skills/plan/SKILL.md +77 -0
- package/skills/plan/stages/architecture.md +53 -0
- package/skills/plan/stages/audit.md +58 -0
- package/skills/plan/stages/blueprint.md +60 -0
- package/skills/plan/stages/clarify.md +61 -0
- package/skills/plan/stages/investigate.md +47 -0
- package/skills/plan/stages/scope.md +46 -0
- package/skills/plan/stages/sketch.md +44 -0
- package/skills/plan/stages/ux-flow.md +49 -0
- package/skills/review/SKILL.md +104 -0
- package/skills/review/stages/compliance.md +48 -0
- package/skills/review/stages/handoff.md +59 -0
- package/skills/review/stages/performance.md +45 -0
- package/skills/review/stages/security.md +49 -0
- package/skills/review/stages/self-review.md +41 -0
- package/skills/test/SKILL.md +83 -0
- package/skills/test/stages/e2e.md +44 -0
- package/skills/test/stages/validate.md +51 -0
- package/skills/test/stages/verify.md +41 -0
- package/skills/wrap-up/SKILL.md +81 -0
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
|
|
9
|
+
function makeTmpDir(): string {
|
|
10
|
+
const dir = path.join(os.tmpdir(), `work-kit-test-${randomUUID()}`);
|
|
11
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let tmpDirs: string[] = [];
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
for (const dir of tmpDirs) {
|
|
19
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
tmpDirs = [];
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("initCommand", () => {
|
|
25
|
+
it("creates state.json and state.md", () => {
|
|
26
|
+
const tmp = makeTmpDir();
|
|
27
|
+
tmpDirs.push(tmp);
|
|
28
|
+
|
|
29
|
+
initCommand({
|
|
30
|
+
mode: "full",
|
|
31
|
+
description: "Add user login",
|
|
32
|
+
worktreeRoot: tmp,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
assert.ok(fs.existsSync(path.join(tmp, ".work-kit", "state.json")));
|
|
36
|
+
assert.ok(fs.existsSync(path.join(tmp, ".work-kit", "state.md")));
|
|
37
|
+
|
|
38
|
+
const state = JSON.parse(
|
|
39
|
+
fs.readFileSync(path.join(tmp, ".work-kit", "state.json"), "utf-8")
|
|
40
|
+
);
|
|
41
|
+
assert.equal(state.slug, "add-user-login");
|
|
42
|
+
assert.equal(state.status, "in-progress");
|
|
43
|
+
assert.equal(state.currentPhase, "plan");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns spawn_agent action", () => {
|
|
47
|
+
const tmp = makeTmpDir();
|
|
48
|
+
tmpDirs.push(tmp);
|
|
49
|
+
|
|
50
|
+
const result = initCommand({
|
|
51
|
+
mode: "full",
|
|
52
|
+
description: "Add user login",
|
|
53
|
+
worktreeRoot: tmp,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
assert.equal(result.action, "spawn_agent");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("blocks double init", () => {
|
|
60
|
+
const tmp = makeTmpDir();
|
|
61
|
+
tmpDirs.push(tmp);
|
|
62
|
+
|
|
63
|
+
initCommand({
|
|
64
|
+
mode: "full",
|
|
65
|
+
description: "First init",
|
|
66
|
+
worktreeRoot: tmp,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const result = initCommand({
|
|
70
|
+
mode: "full",
|
|
71
|
+
description: "Second init",
|
|
72
|
+
worktreeRoot: tmp,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
assert.equal(result.action, "error");
|
|
76
|
+
if (result.action === "error") {
|
|
77
|
+
assert.ok(result.message.includes("already exists"));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("auto mode requires classification", () => {
|
|
82
|
+
const tmp = makeTmpDir();
|
|
83
|
+
tmpDirs.push(tmp);
|
|
84
|
+
|
|
85
|
+
const result = initCommand({
|
|
86
|
+
mode: "auto",
|
|
87
|
+
description: "Some task",
|
|
88
|
+
worktreeRoot: tmp,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
assert.equal(result.action, "error");
|
|
92
|
+
if (result.action === "error") {
|
|
93
|
+
assert.ok(result.message.includes("classification"));
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("auto mode with classification succeeds", () => {
|
|
98
|
+
const tmp = makeTmpDir();
|
|
99
|
+
tmpDirs.push(tmp);
|
|
100
|
+
|
|
101
|
+
const result = initCommand({
|
|
102
|
+
mode: "auto",
|
|
103
|
+
description: "Fix login bug",
|
|
104
|
+
classification: "bug-fix",
|
|
105
|
+
worktreeRoot: tmp,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
assert.equal(result.action, "spawn_agent");
|
|
109
|
+
|
|
110
|
+
const state = JSON.parse(
|
|
111
|
+
fs.readFileSync(path.join(tmp, ".work-kit", "state.json"), "utf-8")
|
|
112
|
+
);
|
|
113
|
+
assert.equal(state.mode, "auto-kit");
|
|
114
|
+
assert.equal(state.classification, "bug-fix");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
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";
|
|
6
|
+
import type { Action } from "../state/schema.js";
|
|
7
|
+
|
|
8
|
+
function toSlug(description: string): string {
|
|
9
|
+
return description
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[^a-z0-9\s-]/g, "")
|
|
12
|
+
.replace(/\s+/g, "-")
|
|
13
|
+
.slice(0, 40)
|
|
14
|
+
.replace(/-+$/, "");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function buildPhases(workflow?: WorkflowStep[]): Record<PhaseName, PhaseState> {
|
|
18
|
+
const phases = {} as Record<PhaseName, PhaseState>;
|
|
19
|
+
|
|
20
|
+
for (const phase of PHASE_NAMES) {
|
|
21
|
+
const subStages: Record<string, { status: "pending" | "skipped" }> = {};
|
|
22
|
+
const allSubStages = SUBSTAGES_BY_PHASE[phase];
|
|
23
|
+
|
|
24
|
+
for (const ss of allSubStages) {
|
|
25
|
+
if (workflow) {
|
|
26
|
+
const step = workflow.find((s) => s.phase === phase && s.subStage === ss);
|
|
27
|
+
subStages[ss] = { status: step?.included ? "pending" : "skipped" };
|
|
28
|
+
} else {
|
|
29
|
+
subStages[ss] = { status: "pending" };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Check if entire phase is skipped (all sub-stages skipped)
|
|
34
|
+
const allSkipped = Object.values(subStages).every((s) => s.status === "skipped");
|
|
35
|
+
phases[phase] = {
|
|
36
|
+
status: allSkipped ? "skipped" : "pending",
|
|
37
|
+
subStages,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return phases;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function generateStateMd(slug: string, branch: string, mode: string, description: string, classification?: string, workflow?: WorkflowStep[]): string {
|
|
45
|
+
const title = slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
46
|
+
const date = new Date().toISOString().split("T")[0];
|
|
47
|
+
|
|
48
|
+
let md = `# ${title}
|
|
49
|
+
|
|
50
|
+
**Slug:** ${slug}
|
|
51
|
+
**Branch:** ${branch}
|
|
52
|
+
**Started:** ${date}
|
|
53
|
+
**Mode:** ${mode}
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
if (classification) {
|
|
57
|
+
md += `**Classification:** ${classification}\n`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
md += `**Phase:** plan
|
|
61
|
+
**Sub-stage:** clarify
|
|
62
|
+
**Status:** in-progress
|
|
63
|
+
|
|
64
|
+
## Description
|
|
65
|
+
${description}
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
if (workflow) {
|
|
69
|
+
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)}`;
|
|
73
|
+
md += `- [ ] ${label}\n`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
md += `
|
|
79
|
+
## Criteria
|
|
80
|
+
<!-- Added during Plan/Clarify, checked off during test/review -->
|
|
81
|
+
|
|
82
|
+
## Decisions
|
|
83
|
+
<!-- Append here whenever you choose between real alternatives -->
|
|
84
|
+
<!-- Format: **<context>**: chose <X> over <Y> — <why> -->
|
|
85
|
+
|
|
86
|
+
## Deviations
|
|
87
|
+
<!-- Append here whenever implementation diverges from the Blueprint -->
|
|
88
|
+
<!-- Format: **<Blueprint step>**: <what changed> — <why> -->
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
return md;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function initCommand(options: {
|
|
95
|
+
mode: "full" | "auto";
|
|
96
|
+
description: string;
|
|
97
|
+
classification?: Classification;
|
|
98
|
+
worktreeRoot?: string;
|
|
99
|
+
}): Action {
|
|
100
|
+
const { mode, description, classification } = options;
|
|
101
|
+
const worktreeRoot = options.worktreeRoot || process.cwd();
|
|
102
|
+
|
|
103
|
+
// Guard: don't overwrite existing state
|
|
104
|
+
if (stateExists(worktreeRoot)) {
|
|
105
|
+
return {
|
|
106
|
+
action: "error",
|
|
107
|
+
message: "State already exists in this directory. Use `work-kit status` to check current state.",
|
|
108
|
+
suggestion: "To start fresh, delete .work-kit/state.json first.",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Auto mode requires classification
|
|
113
|
+
if (mode === "auto" && !classification) {
|
|
114
|
+
return {
|
|
115
|
+
action: "error",
|
|
116
|
+
message: "Auto mode requires --classification (bug-fix, small-change, refactor, feature, large-feature).",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Validate mode
|
|
121
|
+
if (mode !== "full" && mode !== "auto") {
|
|
122
|
+
return {
|
|
123
|
+
action: "error",
|
|
124
|
+
message: `Invalid mode "${mode}". Use "full" or "auto".`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const slug = toSlug(description);
|
|
129
|
+
const branch = `feature/${slug}`;
|
|
130
|
+
const modeLabel = mode === "full" ? "full-kit" : "auto-kit";
|
|
131
|
+
|
|
132
|
+
// Build workflow
|
|
133
|
+
let workflow: WorkflowStep[] | undefined;
|
|
134
|
+
if (mode === "auto" && classification) {
|
|
135
|
+
workflow = buildDefaultWorkflow(classification);
|
|
136
|
+
} else if (mode === "full") {
|
|
137
|
+
workflow = buildFullWorkflow();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Find first active sub-stage
|
|
141
|
+
let firstPhase: PhaseName = "plan";
|
|
142
|
+
let firstSubStage = "clarify";
|
|
143
|
+
|
|
144
|
+
if (workflow) {
|
|
145
|
+
const first = workflow.find((s) => s.included);
|
|
146
|
+
if (first) {
|
|
147
|
+
firstPhase = first.phase;
|
|
148
|
+
firstSubStage = first.subStage;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Build state
|
|
153
|
+
const state: WorkKitState = {
|
|
154
|
+
version: 1,
|
|
155
|
+
slug,
|
|
156
|
+
branch,
|
|
157
|
+
started: new Date().toISOString(),
|
|
158
|
+
mode: modeLabel,
|
|
159
|
+
...(classification && { classification }),
|
|
160
|
+
status: "in-progress",
|
|
161
|
+
currentPhase: firstPhase,
|
|
162
|
+
currentSubStage: firstSubStage,
|
|
163
|
+
phases: buildPhases(workflow),
|
|
164
|
+
...(mode === "auto" && workflow && { workflow }),
|
|
165
|
+
loopbacks: [],
|
|
166
|
+
metadata: {
|
|
167
|
+
worktreeRoot,
|
|
168
|
+
mainRepoRoot: worktreeRoot, // will be set properly by caller
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Write state files
|
|
173
|
+
writeState(worktreeRoot, state);
|
|
174
|
+
writeStateMd(worktreeRoot, generateStateMd(slug, branch, modeLabel, description, classification, workflow));
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
action: "spawn_agent",
|
|
178
|
+
phase: firstPhase,
|
|
179
|
+
subStage: firstSubStage,
|
|
180
|
+
skillFile: skillFilePath(firstPhase, firstSubStage),
|
|
181
|
+
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.`,
|
|
182
|
+
onComplete: `npx work-kit-cli complete ${firstPhase}/${firstSubStage}`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readState, writeState, findWorktreeRoot } from "../state/store.js";
|
|
2
|
+
import { parseLocation, resetToLocation } from "../state/helpers.js";
|
|
3
|
+
import type { Action } from "../state/schema.js";
|
|
4
|
+
|
|
5
|
+
const MAX_LOOPBACKS_PER_ROUTE = 2;
|
|
6
|
+
|
|
7
|
+
export function loopbackCommand(opts: {
|
|
8
|
+
from: string;
|
|
9
|
+
to: string;
|
|
10
|
+
reason: string;
|
|
11
|
+
worktreeRoot?: string;
|
|
12
|
+
}): Action {
|
|
13
|
+
const root = opts.worktreeRoot || findWorktreeRoot();
|
|
14
|
+
if (!root) {
|
|
15
|
+
return { action: "error", message: "No work-kit state found." };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const state = readState(root);
|
|
19
|
+
const from = parseLocation(opts.from);
|
|
20
|
+
const to = parseLocation(opts.to);
|
|
21
|
+
|
|
22
|
+
if (!state.phases[from.phase]?.subStages[from.subStage]) {
|
|
23
|
+
return { action: "error", message: `Invalid source: ${opts.from}` };
|
|
24
|
+
}
|
|
25
|
+
if (!state.phases[to.phase]?.subStages[to.subStage]) {
|
|
26
|
+
return { action: "error", message: `Invalid target: ${opts.to}` };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Can't loop back to a skipped sub-stage
|
|
30
|
+
if (state.phases[to.phase].subStages[to.subStage].status === "skipped") {
|
|
31
|
+
return { action: "error", message: `Cannot loop back to ${opts.to} — it is skipped.` };
|
|
32
|
+
}
|
|
33
|
+
|
|
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;
|
|
39
|
+
if (sameRouteCount >= MAX_LOOPBACKS_PER_ROUTE) {
|
|
40
|
+
return {
|
|
41
|
+
action: "error",
|
|
42
|
+
message: `Max loopback count (${MAX_LOOPBACKS_PER_ROUTE}) reached for ${opts.from} → ${opts.to}. Proceeding with noted caveats.`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
state.loopbacks.push({
|
|
47
|
+
from,
|
|
48
|
+
to,
|
|
49
|
+
reason: opts.reason,
|
|
50
|
+
timestamp: new Date().toISOString(),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
resetToLocation(state, to);
|
|
54
|
+
state.currentPhase = to.phase;
|
|
55
|
+
state.currentSubStage = to.subStage;
|
|
56
|
+
writeState(root, state);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
action: "loopback",
|
|
60
|
+
from,
|
|
61
|
+
to,
|
|
62
|
+
reason: opts.reason,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { readState, writeState, findWorktreeRoot, readStateMd } from "../state/store.js";
|
|
2
|
+
import { determineNextStep } from "../engine/transitions.js";
|
|
3
|
+
import { validatePhasePrerequisites } from "../state/validators.js";
|
|
4
|
+
import { buildAgentPrompt } from "../context/prompt-builder.js";
|
|
5
|
+
import { getParallelGroup } from "../engine/parallel.js";
|
|
6
|
+
import { skillFilePath } from "../config/phases.js";
|
|
7
|
+
import type { Action, PhaseName, WorkKitState } from "../state/schema.js";
|
|
8
|
+
|
|
9
|
+
export function nextCommand(worktreeRoot?: string): Action {
|
|
10
|
+
const root = worktreeRoot || findWorktreeRoot();
|
|
11
|
+
if (!root) {
|
|
12
|
+
return { action: "error", message: "No work-kit state found. Run `work-kit init` first." };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const state = readState(root);
|
|
16
|
+
|
|
17
|
+
if (state.status === "completed") {
|
|
18
|
+
return { action: "complete", message: "Work-kit is already complete." };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (state.status === "failed") {
|
|
22
|
+
return { action: "error", message: "Work-kit is in failed state.", suggestion: "Review the state and restart." };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const step = determineNextStep(state);
|
|
26
|
+
|
|
27
|
+
switch (step.type) {
|
|
28
|
+
case "complete":
|
|
29
|
+
return { action: "complete", message: step.message! };
|
|
30
|
+
|
|
31
|
+
case "wait-for-user":
|
|
32
|
+
return { action: "wait_for_user", message: step.message! };
|
|
33
|
+
|
|
34
|
+
case "phase-boundary": {
|
|
35
|
+
const phase = step.phase!;
|
|
36
|
+
|
|
37
|
+
const validation = validatePhasePrerequisites(state, phase);
|
|
38
|
+
if (!validation.valid) {
|
|
39
|
+
return {
|
|
40
|
+
action: "error",
|
|
41
|
+
message: validation.message,
|
|
42
|
+
suggestion: `Complete ${validation.missingPrerequisite} first.`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
state.currentPhase = phase;
|
|
47
|
+
state.phases[phase].status = "in-progress";
|
|
48
|
+
state.phases[phase].startedAt = new Date().toISOString();
|
|
49
|
+
|
|
50
|
+
const subStages = Object.entries(state.phases[phase].subStages);
|
|
51
|
+
const firstActive = subStages.find(([_, ss]) => ss.status === "pending");
|
|
52
|
+
|
|
53
|
+
if (!firstActive) {
|
|
54
|
+
return { action: "error", message: `No pending sub-stages in ${phase}` };
|
|
55
|
+
}
|
|
56
|
+
|
|
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();
|
|
61
|
+
writeState(root, state);
|
|
62
|
+
|
|
63
|
+
return buildSpawnAction(root, state, phase, subStage);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
case "sub-stage": {
|
|
67
|
+
const phase = step.phase!;
|
|
68
|
+
const subStage = step.subStage!;
|
|
69
|
+
|
|
70
|
+
state.currentPhase = phase;
|
|
71
|
+
state.currentSubStage = subStage;
|
|
72
|
+
if (state.phases[phase].status === "pending") {
|
|
73
|
+
state.phases[phase].status = "in-progress";
|
|
74
|
+
state.phases[phase].startedAt = new Date().toISOString();
|
|
75
|
+
}
|
|
76
|
+
state.phases[phase].subStages[subStage].status = "in-progress";
|
|
77
|
+
state.phases[phase].subStages[subStage].startedAt = new Date().toISOString();
|
|
78
|
+
writeState(root, state);
|
|
79
|
+
|
|
80
|
+
return buildSpawnAction(root, state, phase, subStage);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
default:
|
|
84
|
+
return { action: "error", message: `Unknown step type: ${step.type}` };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildSpawnAction(root: string, state: WorkKitState, phase: PhaseName, subStage: string): Action {
|
|
89
|
+
// Read state.md once for all prompt builds
|
|
90
|
+
const stateMd = readStateMd(root);
|
|
91
|
+
const parallelGroup = getParallelGroup(phase, subStage, state);
|
|
92
|
+
|
|
93
|
+
if (parallelGroup) {
|
|
94
|
+
const agents = parallelGroup.parallel
|
|
95
|
+
.filter((ss) => {
|
|
96
|
+
const ssState = state.phases[phase].subStages[ss];
|
|
97
|
+
return ssState && ssState.status !== "skipped" && ssState.status !== "completed";
|
|
98
|
+
})
|
|
99
|
+
.map((ss) => ({
|
|
100
|
+
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
|
+
}));
|
|
106
|
+
|
|
107
|
+
// If all parallel members were filtered out, fall through to single agent
|
|
108
|
+
if (agents.length === 0) {
|
|
109
|
+
// Skip to thenSequential if it exists, otherwise nothing to do
|
|
110
|
+
if (parallelGroup.thenSequential) {
|
|
111
|
+
const seqSS = parallelGroup.thenSequential;
|
|
112
|
+
return {
|
|
113
|
+
action: "spawn_agent",
|
|
114
|
+
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
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return { action: "error", message: `No active sub-stages in parallel group for ${phase}` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// If only 1 agent remains, run as single agent (no need for parallel)
|
|
125
|
+
if (agents.length === 1 && !parallelGroup.thenSequential) {
|
|
126
|
+
const agent = agents[0];
|
|
127
|
+
return {
|
|
128
|
+
action: "spawn_agent",
|
|
129
|
+
phase: agent.phase,
|
|
130
|
+
subStage: agent.subStage,
|
|
131
|
+
skillFile: agent.skillFile,
|
|
132
|
+
agentPrompt: agent.agentPrompt,
|
|
133
|
+
onComplete: `npx work-kit-cli complete ${agent.phase}/${agent.subStage}`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
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();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const thenSequential = parallelGroup.thenSequential
|
|
143
|
+
? {
|
|
144
|
+
phase,
|
|
145
|
+
subStage: parallelGroup.thenSequential,
|
|
146
|
+
skillFile: skillFilePath(phase, parallelGroup.thenSequential),
|
|
147
|
+
agentPrompt: buildAgentPrompt(root, state, phase, parallelGroup.thenSequential, stateMd),
|
|
148
|
+
}
|
|
149
|
+
: undefined;
|
|
150
|
+
|
|
151
|
+
writeState(root, state);
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
action: "spawn_parallel_agents",
|
|
155
|
+
agents,
|
|
156
|
+
thenSequential,
|
|
157
|
+
onComplete: `npx work-kit-cli complete ${phase}/${parallelGroup.thenSequential || parallelGroup.parallel[parallelGroup.parallel.length - 1]}`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const skill = skillFilePath(phase, subStage);
|
|
162
|
+
const prompt = buildAgentPrompt(root, state, phase, subStage, stateMd);
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
action: "spawn_agent",
|
|
166
|
+
phase,
|
|
167
|
+
subStage,
|
|
168
|
+
skillFile: skill,
|
|
169
|
+
agentPrompt: prompt,
|
|
170
|
+
onComplete: `npx work-kit-cli complete ${phase}/${subStage}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
renderDashboard,
|
|
5
|
+
enterAlternateScreen,
|
|
6
|
+
exitAlternateScreen,
|
|
7
|
+
moveCursorHome,
|
|
8
|
+
renderTooSmall,
|
|
9
|
+
} from "../observer/renderer.js";
|
|
10
|
+
import { collectDashboardData } from "../observer/data.js";
|
|
11
|
+
import { startWatching } from "../observer/watcher.js";
|
|
12
|
+
|
|
13
|
+
function findMainRepoRoot(startDir: string): string {
|
|
14
|
+
// Find the git toplevel
|
|
15
|
+
try {
|
|
16
|
+
const result = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
17
|
+
cwd: startDir,
|
|
18
|
+
encoding: "utf-8",
|
|
19
|
+
timeout: 5000,
|
|
20
|
+
});
|
|
21
|
+
return result.trim();
|
|
22
|
+
} catch {
|
|
23
|
+
return startDir;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function observeCommand(opts: { mainRepo?: string }): Promise<void> {
|
|
28
|
+
const mainRepoRoot = opts.mainRepo
|
|
29
|
+
? path.resolve(opts.mainRepo)
|
|
30
|
+
: findMainRepoRoot(process.cwd());
|
|
31
|
+
|
|
32
|
+
let scrollOffset = 0;
|
|
33
|
+
let cleanedUp = false;
|
|
34
|
+
|
|
35
|
+
function cleanup(): void {
|
|
36
|
+
if (cleanedUp) return;
|
|
37
|
+
cleanedUp = true;
|
|
38
|
+
|
|
39
|
+
// Restore terminal
|
|
40
|
+
exitAlternateScreen();
|
|
41
|
+
if (process.stdin.isTTY && process.stdin.setRawMode) {
|
|
42
|
+
try { process.stdin.setRawMode(false); } catch { /* ignore */ }
|
|
43
|
+
}
|
|
44
|
+
process.stdin.removeAllListeners("data");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function render(): void {
|
|
48
|
+
const width = process.stdout.columns || 80;
|
|
49
|
+
const height = process.stdout.rows || 24;
|
|
50
|
+
|
|
51
|
+
if (width < 60 || height < 10) {
|
|
52
|
+
process.stdout.write(renderTooSmall(width, height));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const data = collectDashboardData(mainRepoRoot, watcher.getWorktrees());
|
|
57
|
+
const frame = moveCursorHome() + renderDashboard(data, width, height, scrollOffset);
|
|
58
|
+
process.stdout.write(frame);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Enter alternate screen
|
|
62
|
+
enterAlternateScreen();
|
|
63
|
+
|
|
64
|
+
// Set up signal handlers
|
|
65
|
+
const onSignal = () => {
|
|
66
|
+
cleanup();
|
|
67
|
+
process.exit(0);
|
|
68
|
+
};
|
|
69
|
+
process.on("SIGINT", onSignal);
|
|
70
|
+
process.on("SIGTERM", onSignal);
|
|
71
|
+
|
|
72
|
+
let watcher!: ReturnType<typeof startWatching>;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
// Set up file watching (before initial render so worktrees are cached)
|
|
76
|
+
watcher = startWatching(mainRepoRoot, () => {
|
|
77
|
+
render();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Initial render
|
|
81
|
+
render();
|
|
82
|
+
|
|
83
|
+
// Handle terminal resize
|
|
84
|
+
process.stdout.on("resize", () => {
|
|
85
|
+
render();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Set up keyboard input
|
|
89
|
+
if (process.stdin.isTTY && process.stdin.setRawMode) {
|
|
90
|
+
process.stdin.setRawMode(true);
|
|
91
|
+
process.stdin.resume();
|
|
92
|
+
process.stdin.setEncoding("utf-8");
|
|
93
|
+
|
|
94
|
+
await new Promise<void>((resolve) => {
|
|
95
|
+
process.stdin.on("data", (key: string) => {
|
|
96
|
+
// Ctrl+C
|
|
97
|
+
if (key === "\x03") {
|
|
98
|
+
watcher.stop();
|
|
99
|
+
resolve();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 'q' to quit
|
|
104
|
+
if (key === "q" || key === "Q") {
|
|
105
|
+
watcher.stop();
|
|
106
|
+
resolve();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 'r' to refresh
|
|
111
|
+
if (key === "r" || key === "R") {
|
|
112
|
+
scrollOffset = 0;
|
|
113
|
+
render();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Up arrow: \x1b[A
|
|
118
|
+
if (key === "\x1b[A") {
|
|
119
|
+
scrollOffset = Math.max(0, scrollOffset - 1);
|
|
120
|
+
render();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Down arrow: \x1b[B
|
|
125
|
+
if (key === "\x1b[B") {
|
|
126
|
+
scrollOffset++;
|
|
127
|
+
render();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
} else {
|
|
133
|
+
// Non-TTY: just keep running until interrupted
|
|
134
|
+
await new Promise<void>((resolve) => {
|
|
135
|
+
process.on("SIGINT", () => {
|
|
136
|
+
watcher.stop();
|
|
137
|
+
resolve();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
} finally {
|
|
142
|
+
cleanup();
|
|
143
|
+
}
|
|
144
|
+
}
|