work-kit-cli 0.2.7 → 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 +162 -75
- 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
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { findWorktreeRoot, resolveMainRepoRoot } from "../state/store.js";
|
|
4
|
+
import { TRACKER_DIR, ARCHIVE_DIR, INDEX_FILE } from "../config/constants.js";
|
|
5
|
+
import {
|
|
6
|
+
PHASE_NAMES,
|
|
7
|
+
MODE_FULL,
|
|
8
|
+
MODE_AUTO,
|
|
9
|
+
type PhaseName,
|
|
10
|
+
type Classification,
|
|
11
|
+
type WorkKitState,
|
|
12
|
+
} from "../state/schema.js";
|
|
13
|
+
import { readJsonFile } from "../utils/json.js";
|
|
14
|
+
import { durationMs, formatDurationMs } from "../utils/time.js";
|
|
15
|
+
import { bold, cyan, dim, green, yellow } from "../utils/colors.js";
|
|
16
|
+
|
|
17
|
+
const RECENT_LIMIT = 10;
|
|
18
|
+
|
|
19
|
+
export interface PhaseStats {
|
|
20
|
+
runs: number;
|
|
21
|
+
avgDurationMs: number;
|
|
22
|
+
totalDurationMs: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RecentEntry {
|
|
26
|
+
slug: string;
|
|
27
|
+
completedAt: string;
|
|
28
|
+
classification?: Classification;
|
|
29
|
+
mode: typeof MODE_FULL | typeof MODE_AUTO;
|
|
30
|
+
durationMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ReportData {
|
|
34
|
+
totalCompleted: number;
|
|
35
|
+
byClassification: Partial<Record<Classification | "unclassified", number>>;
|
|
36
|
+
byMode: Partial<Record<typeof MODE_FULL | typeof MODE_AUTO, number>>;
|
|
37
|
+
avgDurationMs: number;
|
|
38
|
+
totalLoopbacks: number;
|
|
39
|
+
loopbackRate: number;
|
|
40
|
+
perPhase: Record<PhaseName, PhaseStats>;
|
|
41
|
+
recent: RecentEntry[];
|
|
42
|
+
source: { mainRepoRoot: string; trackerDir: string };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function emptyPhaseStats(): PhaseStats {
|
|
46
|
+
return { runs: 0, avgDurationMs: 0, totalDurationMs: 0 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function collectReport(mainRepoRoot: string): ReportData {
|
|
50
|
+
const trackerDir = path.join(mainRepoRoot, TRACKER_DIR);
|
|
51
|
+
const archiveDir = path.join(trackerDir, ARCHIVE_DIR);
|
|
52
|
+
|
|
53
|
+
const data: ReportData = {
|
|
54
|
+
totalCompleted: 0,
|
|
55
|
+
byClassification: {},
|
|
56
|
+
byMode: {},
|
|
57
|
+
avgDurationMs: 0,
|
|
58
|
+
totalLoopbacks: 0,
|
|
59
|
+
loopbackRate: 0,
|
|
60
|
+
perPhase: PHASE_NAMES.reduce((acc, p) => {
|
|
61
|
+
acc[p] = emptyPhaseStats();
|
|
62
|
+
return acc;
|
|
63
|
+
}, {} as Record<PhaseName, PhaseStats>),
|
|
64
|
+
recent: [],
|
|
65
|
+
source: { mainRepoRoot, trackerDir },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
let folders: string[];
|
|
69
|
+
try {
|
|
70
|
+
folders = fs.readdirSync(archiveDir, { withFileTypes: true })
|
|
71
|
+
.filter(d => d.isDirectory())
|
|
72
|
+
.map(d => d.name);
|
|
73
|
+
} catch {
|
|
74
|
+
return data;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let totalRunDurationMs = 0;
|
|
78
|
+
let runsWithDuration = 0;
|
|
79
|
+
// Bounded min-heap-by-completedAt would be ideal; for ≤10 entries
|
|
80
|
+
// a simple sorted insertion is cheaper than a full sort at the end.
|
|
81
|
+
const keepRecent = (entry: RecentEntry) => {
|
|
82
|
+
data.recent.push(entry);
|
|
83
|
+
data.recent.sort((a, b) => (a.completedAt < b.completedAt ? 1 : -1));
|
|
84
|
+
if (data.recent.length > RECENT_LIMIT) data.recent.length = RECENT_LIMIT;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
for (const folder of folders) {
|
|
88
|
+
const state = readJsonFile<WorkKitState>(path.join(archiveDir, folder, "tracker.json"));
|
|
89
|
+
if (!state) continue;
|
|
90
|
+
|
|
91
|
+
data.totalCompleted++;
|
|
92
|
+
|
|
93
|
+
const cls = state.classification ?? "unclassified";
|
|
94
|
+
data.byClassification[cls] = (data.byClassification[cls] ?? 0) + 1;
|
|
95
|
+
data.byMode[state.mode] = (data.byMode[state.mode] ?? 0) + 1;
|
|
96
|
+
data.totalLoopbacks += state.loopbacks?.length ?? 0;
|
|
97
|
+
|
|
98
|
+
let runDuration = 0;
|
|
99
|
+
let lastEnd: string | undefined;
|
|
100
|
+
for (const phase of PHASE_NAMES) {
|
|
101
|
+
const ps = state.phases[phase];
|
|
102
|
+
if (!ps || ps.status !== "completed") continue;
|
|
103
|
+
const d = durationMs(ps.startedAt, ps.completedAt);
|
|
104
|
+
data.perPhase[phase].runs++;
|
|
105
|
+
data.perPhase[phase].totalDurationMs += d;
|
|
106
|
+
runDuration += d;
|
|
107
|
+
if (ps.completedAt && (!lastEnd || ps.completedAt > lastEnd)) lastEnd = ps.completedAt;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (runDuration > 0) {
|
|
111
|
+
totalRunDurationMs += runDuration;
|
|
112
|
+
runsWithDuration++;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
keepRecent({
|
|
116
|
+
slug: state.slug,
|
|
117
|
+
completedAt: lastEnd ?? state.started,
|
|
118
|
+
classification: state.classification,
|
|
119
|
+
mode: state.mode,
|
|
120
|
+
durationMs: runDuration > 0 ? runDuration : undefined,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const phase of PHASE_NAMES) {
|
|
125
|
+
const s = data.perPhase[phase];
|
|
126
|
+
s.avgDurationMs = s.runs > 0 ? Math.round(s.totalDurationMs / s.runs) : 0;
|
|
127
|
+
}
|
|
128
|
+
data.avgDurationMs = runsWithDuration > 0 ? Math.round(totalRunDurationMs / runsWithDuration) : 0;
|
|
129
|
+
data.loopbackRate = data.totalCompleted > 0 ? data.totalLoopbacks / data.totalCompleted : 0;
|
|
130
|
+
|
|
131
|
+
return data;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface ReportOptions {
|
|
135
|
+
json?: boolean;
|
|
136
|
+
worktreeRoot?: string;
|
|
137
|
+
repo?: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function reportCommand(options: ReportOptions = {}): ReportData {
|
|
141
|
+
const mainRepoRoot = options.repo
|
|
142
|
+
? path.resolve(options.repo)
|
|
143
|
+
: resolveMainRepoRoot(options.worktreeRoot || findWorktreeRoot() || process.cwd());
|
|
144
|
+
|
|
145
|
+
const data = collectReport(mainRepoRoot);
|
|
146
|
+
|
|
147
|
+
if (options.json) {
|
|
148
|
+
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
149
|
+
return data;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const out: string[] = [];
|
|
153
|
+
out.push("");
|
|
154
|
+
out.push(bold(" WORK-KIT REPORT"));
|
|
155
|
+
out.push(dim(` ${data.source.trackerDir}`));
|
|
156
|
+
out.push("");
|
|
157
|
+
|
|
158
|
+
if (data.totalCompleted === 0) {
|
|
159
|
+
out.push(dim(" No completed work-kits found."));
|
|
160
|
+
out.push("");
|
|
161
|
+
process.stderr.write(out.join("\n") + "\n");
|
|
162
|
+
return data;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
out.push(` ${cyan("Completed")} ${bold(String(data.totalCompleted))}`);
|
|
166
|
+
out.push(` ${cyan("Avg run")} ${formatDurationMs(data.avgDurationMs)}`);
|
|
167
|
+
out.push(` ${cyan("Loopbacks")} ${data.totalLoopbacks} ${dim(`(${(data.loopbackRate * 100).toFixed(0)}% rate)`)}`);
|
|
168
|
+
out.push("");
|
|
169
|
+
|
|
170
|
+
const byClassEntries = Object.entries(data.byClassification);
|
|
171
|
+
if (byClassEntries.length > 0) {
|
|
172
|
+
out.push(bold(" By Classification"));
|
|
173
|
+
for (const [cls, count] of byClassEntries.sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0))) {
|
|
174
|
+
out.push(` ${cls.padEnd(16)} ${count}`);
|
|
175
|
+
}
|
|
176
|
+
out.push("");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const byModeEntries = Object.entries(data.byMode);
|
|
180
|
+
if (byModeEntries.length > 0) {
|
|
181
|
+
out.push(bold(" By Mode"));
|
|
182
|
+
for (const [mode, count] of byModeEntries.sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0))) {
|
|
183
|
+
out.push(` ${mode.padEnd(16)} ${count}`);
|
|
184
|
+
}
|
|
185
|
+
out.push("");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
out.push(bold(" Avg Phase Duration"));
|
|
189
|
+
for (const phase of PHASE_NAMES) {
|
|
190
|
+
const s = data.perPhase[phase];
|
|
191
|
+
if (s.runs === 0) continue;
|
|
192
|
+
out.push(` ${phase.padEnd(10)} ${formatDurationMs(s.avgDurationMs).padStart(7)} ${dim(`(${s.runs} runs)`)}`);
|
|
193
|
+
}
|
|
194
|
+
out.push("");
|
|
195
|
+
|
|
196
|
+
if (data.recent.length > 0) {
|
|
197
|
+
out.push(bold(" Recent"));
|
|
198
|
+
for (const r of data.recent) {
|
|
199
|
+
const date = r.completedAt.split("T")[0];
|
|
200
|
+
const cls = r.classification ? dim(` [${r.classification}]`) : "";
|
|
201
|
+
const dur = r.durationMs ? dim(` ${formatDurationMs(r.durationMs)}`) : "";
|
|
202
|
+
out.push(` ${green("✓")} ${date} ${r.slug}${cls}${dur}`);
|
|
203
|
+
}
|
|
204
|
+
out.push("");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const indexPath = path.join(data.source.trackerDir, INDEX_FILE);
|
|
208
|
+
if (fs.existsSync(indexPath)) {
|
|
209
|
+
out.push(dim(` Full index: ${indexPath}`));
|
|
210
|
+
} else {
|
|
211
|
+
out.push(yellow(` Note: ${INDEX_FILE} not found.`));
|
|
212
|
+
}
|
|
213
|
+
out.push("");
|
|
214
|
+
|
|
215
|
+
process.stderr.write(out.join("\n") + "\n");
|
|
216
|
+
return data;
|
|
217
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readState, writeState, findWorktreeRoot } from "../state/store.js";
|
|
2
|
+
import { unpause } from "../state/helpers.js";
|
|
3
|
+
import { CLI_BINARY } from "../config/constants.js";
|
|
4
|
+
import type { Action } from "../state/schema.js";
|
|
5
|
+
|
|
6
|
+
export function resumeCommand(worktreeRoot?: string): Action {
|
|
7
|
+
const root = worktreeRoot || findWorktreeRoot();
|
|
8
|
+
if (!root) {
|
|
9
|
+
return { action: "error", message: "No work-kit state found." };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const state = readState(root);
|
|
13
|
+
|
|
14
|
+
if (state.status === "completed") {
|
|
15
|
+
return { action: "error", message: `${state.slug} is already completed.` };
|
|
16
|
+
}
|
|
17
|
+
if (state.status === "in-progress") {
|
|
18
|
+
return {
|
|
19
|
+
action: "resumed",
|
|
20
|
+
message: `${state.slug} is already in progress. Run \`${CLI_BINARY} next\` to continue.`,
|
|
21
|
+
phase: state.currentPhase,
|
|
22
|
+
step: state.currentStep,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
if (state.status === "failed") {
|
|
26
|
+
return { action: "error", message: `${state.slug} is in failed state; cannot resume.` };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
unpause(state);
|
|
30
|
+
writeState(root, state);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
action: "resumed",
|
|
34
|
+
message: `Resumed ${state.slug}. Run \`${CLI_BINARY} next\` to continue.`,
|
|
35
|
+
phase: state.currentPhase,
|
|
36
|
+
step: state.currentStep,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -69,6 +69,132 @@ function copySkills(targetDir: string): { copied: string[]; skipped: string[] }
|
|
|
69
69
|
return { copied, skipped };
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// ── Hooks installation ──────────────────────────────────────────────
|
|
73
|
+
//
|
|
74
|
+
// Writes marker files the observer polls to detect "blocked on user" state.
|
|
75
|
+
// Each hook command carries a sentinel comment so setup can be re-run
|
|
76
|
+
// idempotently — existing work-kit entries are stripped and re-added.
|
|
77
|
+
|
|
78
|
+
const HOOK_SENTINEL = "# work-kit-hook";
|
|
79
|
+
|
|
80
|
+
type HookEntry = { type: "command"; command: string };
|
|
81
|
+
type HookMatcherGroup = { matcher?: string; hooks: HookEntry[] };
|
|
82
|
+
type HookSettings = Record<string, HookMatcherGroup[]>;
|
|
83
|
+
|
|
84
|
+
interface WorkKitHookSpec {
|
|
85
|
+
event: string;
|
|
86
|
+
matcher: string;
|
|
87
|
+
command: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Markers live under the per-worktree `.work-kit/` state dir.
|
|
91
|
+
// Hook commands run from Claude Code's CWD, which is the project/worktree root.
|
|
92
|
+
const WK_HOOKS: WorkKitHookSpec[] = [
|
|
93
|
+
// PermissionRequest → agent blocked on a tool permission prompt
|
|
94
|
+
{
|
|
95
|
+
event: "PermissionRequest",
|
|
96
|
+
matcher: "",
|
|
97
|
+
command: `mkdir -p .work-kit && date -u +%s > .work-kit/awaiting-input ${HOOK_SENTINEL}`,
|
|
98
|
+
},
|
|
99
|
+
// AskUserQuestion tool call → agent explicitly asking the user
|
|
100
|
+
{
|
|
101
|
+
event: "PreToolUse",
|
|
102
|
+
matcher: "AskUserQuestion",
|
|
103
|
+
command: `mkdir -p .work-kit && date -u +%s > .work-kit/awaiting-input ${HOOK_SENTINEL}`,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
event: "PostToolUse",
|
|
107
|
+
matcher: "AskUserQuestion",
|
|
108
|
+
command: `rm -f .work-kit/awaiting-input ${HOOK_SENTINEL}`,
|
|
109
|
+
},
|
|
110
|
+
// Any tool call → clear idle marker (agent is active again)
|
|
111
|
+
{
|
|
112
|
+
event: "PreToolUse",
|
|
113
|
+
matcher: "",
|
|
114
|
+
command: `rm -f .work-kit/idle ${HOOK_SENTINEL}`,
|
|
115
|
+
},
|
|
116
|
+
// Stop → turn ended. Write idle marker (soft signal); also clear
|
|
117
|
+
// any stale awaiting-input marker that wasn't paired with PostToolUse
|
|
118
|
+
// (e.g. permission prompt was denied).
|
|
119
|
+
{
|
|
120
|
+
event: "Stop",
|
|
121
|
+
matcher: "",
|
|
122
|
+
command: `mkdir -p .work-kit && date -u +%s > .work-kit/idle && rm -f .work-kit/awaiting-input ${HOOK_SENTINEL}`,
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
function stripWorkKitHooks(hooks: HookSettings): HookSettings {
|
|
127
|
+
const cleaned: HookSettings = {};
|
|
128
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
129
|
+
if (!Array.isArray(groups)) continue;
|
|
130
|
+
const cleanedGroups: HookMatcherGroup[] = [];
|
|
131
|
+
for (const group of groups) {
|
|
132
|
+
const cleanedEntries = (group.hooks || []).filter(
|
|
133
|
+
(h) => !(h.type === "command" && typeof h.command === "string" && h.command.includes(HOOK_SENTINEL))
|
|
134
|
+
);
|
|
135
|
+
if (cleanedEntries.length > 0) {
|
|
136
|
+
cleanedGroups.push({ ...group, hooks: cleanedEntries });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (cleanedGroups.length > 0) {
|
|
140
|
+
cleaned[event] = cleanedGroups;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return cleaned;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function addWorkKitHooks(hooks: HookSettings): HookSettings {
|
|
147
|
+
const out: HookSettings = { ...hooks };
|
|
148
|
+
for (const spec of WK_HOOKS) {
|
|
149
|
+
const groups = out[spec.event] ? [...out[spec.event]] : [];
|
|
150
|
+
const entry: HookEntry = { type: "command", command: spec.command };
|
|
151
|
+
// Try to reuse an existing matcher group with the same matcher
|
|
152
|
+
const existingIdx = groups.findIndex((g) => (g.matcher ?? "") === spec.matcher);
|
|
153
|
+
if (existingIdx >= 0) {
|
|
154
|
+
groups[existingIdx] = {
|
|
155
|
+
...groups[existingIdx],
|
|
156
|
+
hooks: [...(groups[existingIdx].hooks || []), entry],
|
|
157
|
+
};
|
|
158
|
+
} else {
|
|
159
|
+
groups.push({ matcher: spec.matcher, hooks: [entry] });
|
|
160
|
+
}
|
|
161
|
+
out[spec.event] = groups;
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function installHooks(projectDir: string): { added: number; file: string } {
|
|
167
|
+
const settingsDir = path.join(projectDir, ".claude");
|
|
168
|
+
const settingsFile = path.join(settingsDir, "settings.json");
|
|
169
|
+
|
|
170
|
+
if (!fs.existsSync(settingsDir)) {
|
|
171
|
+
fs.mkdirSync(settingsDir, { recursive: true });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let settings: Record<string, unknown> = {};
|
|
175
|
+
if (fs.existsSync(settingsFile)) {
|
|
176
|
+
try {
|
|
177
|
+
const raw = fs.readFileSync(settingsFile, "utf-8");
|
|
178
|
+
settings = raw.trim() ? JSON.parse(raw) : {};
|
|
179
|
+
} catch (err) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`Failed to parse ${settingsFile}: ${(err as Error).message}. Fix or remove the file and re-run setup.`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const existingHooks: HookSettings =
|
|
187
|
+
(settings.hooks && typeof settings.hooks === "object" ? (settings.hooks as HookSettings) : {}) ?? {};
|
|
188
|
+
|
|
189
|
+
const stripped = stripWorkKitHooks(existingHooks);
|
|
190
|
+
const merged = addWorkKitHooks(stripped);
|
|
191
|
+
|
|
192
|
+
settings.hooks = merged;
|
|
193
|
+
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + "\n");
|
|
194
|
+
|
|
195
|
+
return { added: WK_HOOKS.length, file: settingsFile };
|
|
196
|
+
}
|
|
197
|
+
|
|
72
198
|
async function promptUser(question: string): Promise<string> {
|
|
73
199
|
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
74
200
|
return new Promise((resolve) => {
|
|
@@ -142,6 +268,16 @@ export async function setupCommand(targetPath?: string): Promise<void> {
|
|
|
142
268
|
console.error(` ${dim("Already up to date.")}`);
|
|
143
269
|
}
|
|
144
270
|
|
|
271
|
+
// Install Claude Code hooks so the observer can detect "blocked on user" state
|
|
272
|
+
console.error(`\nInstalling Claude Code hooks into ${projectDir}/.claude/settings.json...`);
|
|
273
|
+
try {
|
|
274
|
+
const { added, file } = installHooks(projectDir);
|
|
275
|
+
console.error(` ${green("+")} ${added} hook${added === 1 ? "" : "s"} merged into ${path.relative(projectDir, file) || file}`);
|
|
276
|
+
console.error(` ${dim("Observer will now detect permission prompts and AskUserQuestion calls.")}`);
|
|
277
|
+
} catch (err) {
|
|
278
|
+
console.error(` ${red("✗")} ${(err as Error).message}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
145
281
|
// Run doctor against the target project
|
|
146
282
|
console.error("\nRunning doctor...");
|
|
147
283
|
const result = doctorCommand(projectDir);
|
|
@@ -8,7 +8,7 @@ interface StatusOutput {
|
|
|
8
8
|
classification?: string;
|
|
9
9
|
status: string;
|
|
10
10
|
currentPhase: string | null;
|
|
11
|
-
|
|
11
|
+
currentStep: string | null;
|
|
12
12
|
started: string;
|
|
13
13
|
phases: Record<string, { status: string; completed: number; total: number; active: number }>;
|
|
14
14
|
loopbackCount: number;
|
|
@@ -26,11 +26,11 @@ export function statusCommand(worktreeRoot?: string): StatusOutput {
|
|
|
26
26
|
for (const phase of PHASE_NAMES) {
|
|
27
27
|
const ps = state.phases[phase];
|
|
28
28
|
let completed = 0, total = 0, active = 0;
|
|
29
|
-
for (const
|
|
30
|
-
if (
|
|
29
|
+
for (const s of Object.values(ps.steps)) {
|
|
30
|
+
if (s.status === "skipped") continue;
|
|
31
31
|
total++;
|
|
32
|
-
if (
|
|
33
|
-
else if (
|
|
32
|
+
if (s.status === "completed") completed++;
|
|
33
|
+
else if (s.status === "in-progress" || s.status === "waiting") active++;
|
|
34
34
|
}
|
|
35
35
|
phases[phase] = { status: ps.status, completed, total, active };
|
|
36
36
|
}
|
|
@@ -42,7 +42,7 @@ export function statusCommand(worktreeRoot?: string): StatusOutput {
|
|
|
42
42
|
...(state.classification && { classification: state.classification }),
|
|
43
43
|
status: state.status,
|
|
44
44
|
currentPhase: state.currentPhase,
|
|
45
|
-
|
|
45
|
+
currentStep: state.currentStep,
|
|
46
46
|
started: state.started,
|
|
47
47
|
phases,
|
|
48
48
|
loopbackCount: state.loopbacks.length,
|
|
@@ -2,9 +2,14 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import * as readline from "node:readline";
|
|
4
4
|
import { bold, dim, green, red, yellow } from "../utils/colors.js";
|
|
5
|
+
import { PHASE_NAMES } from "../state/schema.js";
|
|
6
|
+
import { SKILL_DIR_PREFIX } from "../config/constants.js";
|
|
7
|
+
import { STATE_DIR, STATE_FILE } from "../state/store.js";
|
|
5
8
|
|
|
6
9
|
const WORK_KIT_SKILLS = [
|
|
7
|
-
"full-kit", "auto-kit", "
|
|
10
|
+
"full-kit", "auto-kit", "cancel-kit", "pause-kit", "resume-kit",
|
|
11
|
+
...PHASE_NAMES.map(p => `${SKILL_DIR_PREFIX}${p}`),
|
|
12
|
+
`${SKILL_DIR_PREFIX}bootstrap`,
|
|
8
13
|
];
|
|
9
14
|
|
|
10
15
|
async function promptUser(question: string): Promise<string> {
|
|
@@ -52,9 +57,9 @@ export async function uninstallCommand(targetPath?: string): Promise<void> {
|
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
// Check for active state
|
|
55
|
-
const trackerFile = path.join(projectDir,
|
|
60
|
+
const trackerFile = path.join(projectDir, STATE_DIR, STATE_FILE);
|
|
56
61
|
if (fs.existsSync(trackerFile)) {
|
|
57
|
-
console.error(yellow(
|
|
62
|
+
console.error(yellow(`\nWarning: Active work-kit state found (${STATE_DIR}/${STATE_FILE}).`));
|
|
58
63
|
console.error(yellow("Uninstalling will not remove in-progress state files."));
|
|
59
64
|
}
|
|
60
65
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readState, writeState, findWorktreeRoot } from "../state/store.js";
|
|
2
|
-
import {
|
|
2
|
+
import { STEPS_BY_PHASE, PHASE_NAMES, MODE_AUTO } from "../state/schema.js";
|
|
3
3
|
import { parseLocation } from "../state/helpers.js";
|
|
4
4
|
import type { Action } from "../state/schema.js";
|
|
5
5
|
|
|
@@ -22,7 +22,7 @@ export function workflowCommand(opts: {
|
|
|
22
22
|
|
|
23
23
|
const state = readState(root);
|
|
24
24
|
|
|
25
|
-
if (state.mode !==
|
|
25
|
+
if (state.mode !== MODE_AUTO) {
|
|
26
26
|
return { action: "error", message: "Workflow management is only available in auto-kit mode." };
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -31,13 +31,13 @@ export function workflowCommand(opts: {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
if (opts.add) {
|
|
34
|
-
const { phase,
|
|
34
|
+
const { phase, step } = parseLocation(opts.add);
|
|
35
35
|
|
|
36
|
-
if (!PHASE_NAMES.includes(phase) || !
|
|
36
|
+
if (!PHASE_NAMES.includes(phase) || !STEPS_BY_PHASE[phase].includes(step)) {
|
|
37
37
|
return { action: "error", message: `Invalid step: ${opts.add}` };
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
const existing = state.workflow.find((s) => s.phase === phase && s.
|
|
40
|
+
const existing = state.workflow.find((s) => s.phase === phase && s.step === step);
|
|
41
41
|
if (existing) {
|
|
42
42
|
if (existing.included) {
|
|
43
43
|
return { action: "error", message: `${opts.add} is already in the workflow.` };
|
|
@@ -45,31 +45,31 @@ export function workflowCommand(opts: {
|
|
|
45
45
|
existing.included = true;
|
|
46
46
|
} else {
|
|
47
47
|
const phaseIdx = PHASE_NAMES.indexOf(phase);
|
|
48
|
-
const
|
|
48
|
+
const stepIdx = STEPS_BY_PHASE[phase].indexOf(step);
|
|
49
49
|
|
|
50
50
|
let insertIdx = state.workflow.length;
|
|
51
51
|
for (let i = 0; i < state.workflow.length; i++) {
|
|
52
52
|
const wi = state.workflow[i];
|
|
53
53
|
const wiPhaseIdx = PHASE_NAMES.indexOf(wi.phase);
|
|
54
|
-
const
|
|
54
|
+
const wiStepIdx = STEPS_BY_PHASE[wi.phase].indexOf(wi.step);
|
|
55
55
|
|
|
56
|
-
if (wiPhaseIdx > phaseIdx || (wiPhaseIdx === phaseIdx &&
|
|
56
|
+
if (wiPhaseIdx > phaseIdx || (wiPhaseIdx === phaseIdx && wiStepIdx > stepIdx)) {
|
|
57
57
|
insertIdx = i;
|
|
58
58
|
break;
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
state.workflow.splice(insertIdx, 0, { phase,
|
|
62
|
+
state.workflow.splice(insertIdx, 0, { phase, step, included: true });
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
const
|
|
66
|
-
if (
|
|
65
|
+
const currentStep = state.phases[phase].steps[step];
|
|
66
|
+
if (currentStep?.status === "completed") {
|
|
67
67
|
return { action: "error", message: `Cannot add ${opts.add} — it's already completed.` };
|
|
68
68
|
}
|
|
69
|
-
if (!
|
|
70
|
-
state.phases[phase].
|
|
71
|
-
} else if (
|
|
72
|
-
|
|
69
|
+
if (!currentStep) {
|
|
70
|
+
state.phases[phase].steps[step] = { status: "pending" };
|
|
71
|
+
} else if (currentStep.status === "skipped") {
|
|
72
|
+
currentStep.status = "pending";
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
if (state.phases[phase].status === "skipped") {
|
|
@@ -81,28 +81,28 @@ export function workflowCommand(opts: {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
if (opts.remove) {
|
|
84
|
-
const { phase,
|
|
84
|
+
const { phase, step } = parseLocation(opts.remove);
|
|
85
85
|
|
|
86
|
-
const
|
|
87
|
-
if (!
|
|
86
|
+
const ws = state.workflow.find((s) => s.phase === phase && s.step === step);
|
|
87
|
+
if (!ws) {
|
|
88
88
|
return { action: "error", message: `${opts.remove} is not in the workflow.` };
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
91
|
+
const stepState = state.phases[phase]?.steps[step];
|
|
92
|
+
if (stepState?.status === "completed") {
|
|
93
93
|
return { action: "error", message: `Cannot remove ${opts.remove} — it's already completed.` };
|
|
94
94
|
}
|
|
95
|
-
if (
|
|
95
|
+
if (stepState?.status === "in-progress") {
|
|
96
96
|
return { action: "error", message: `Cannot remove ${opts.remove} — it's currently in progress.` };
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
|
|
99
|
+
ws.included = false;
|
|
100
100
|
|
|
101
|
-
if (
|
|
102
|
-
|
|
101
|
+
if (stepState) {
|
|
102
|
+
stepState.status = "skipped";
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
const allSkipped = Object.values(state.phases[phase].
|
|
105
|
+
const allSkipped = Object.values(state.phases[phase].steps).every(
|
|
106
106
|
(s) => s.status === "skipped"
|
|
107
107
|
);
|
|
108
108
|
if (allSkipped) {
|
|
@@ -117,8 +117,8 @@ export function workflowCommand(opts: {
|
|
|
117
117
|
const workflow = state.workflow
|
|
118
118
|
.filter((s) => s.included)
|
|
119
119
|
.map((s) => ({
|
|
120
|
-
step: `${s.phase}/${s.
|
|
121
|
-
status: state.phases[s.phase]?.
|
|
120
|
+
step: `${s.phase}/${s.step}`,
|
|
121
|
+
status: state.phases[s.phase]?.steps[s.step]?.status || "unknown",
|
|
122
122
|
}));
|
|
123
123
|
|
|
124
124
|
return { action: "workflow_status", workflow };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { PhaseName } from "../state/schema.js";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Maps each phase/
|
|
4
|
+
* Maps each phase/step to the sections it needs from state.md.
|
|
5
5
|
* "##" prefix = top-level section, "###" prefix = Final section.
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -32,14 +32,14 @@ export const PHASE_CONTEXT: Record<PhaseName, AgentContext> = {
|
|
|
32
32
|
},
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
-
//
|
|
36
|
-
export const
|
|
37
|
-
// Test
|
|
35
|
+
// Step-level context (for parallel sub-agents that need specific sections)
|
|
36
|
+
export const STEP_CONTEXT: Record<string, AgentContext> = {
|
|
37
|
+
// Test steps
|
|
38
38
|
"test/verify": { sections: ["### Build: Final", "## Criteria"] },
|
|
39
39
|
"test/e2e": { sections: ["### Build: Final", "### Plan: Final"] },
|
|
40
40
|
"test/validate": { sections: ["### Test: Verify", "### Test: E2E", "## Criteria"] },
|
|
41
41
|
|
|
42
|
-
// Review
|
|
42
|
+
// Review steps
|
|
43
43
|
"review/self-review": { sections: ["### Build: Final"], needsGitDiff: true },
|
|
44
44
|
"review/security": { sections: ["### Build: Final"], needsGitDiff: true },
|
|
45
45
|
"review/performance": { sections: ["### Build: Final"], needsGitDiff: true },
|
|
@@ -53,10 +53,10 @@ export const SUBSTAGE_CONTEXT: Record<string, AgentContext> = {
|
|
|
53
53
|
},
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
-
export function getContextFor(phase: PhaseName,
|
|
57
|
-
if (
|
|
58
|
-
const key = `${phase}/${
|
|
59
|
-
if (
|
|
56
|
+
export function getContextFor(phase: PhaseName, step?: string): AgentContext {
|
|
57
|
+
if (step) {
|
|
58
|
+
const key = `${phase}/${step}`;
|
|
59
|
+
if (STEP_CONTEXT[key]) return STEP_CONTEXT[key];
|
|
60
60
|
}
|
|
61
61
|
return PHASE_CONTEXT[phase];
|
|
62
62
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// ── Archive Paths ───────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export const TRACKER_DIR = ".work-kit-tracker";
|
|
4
|
+
export const ARCHIVE_DIR = "archive";
|
|
5
|
+
export const INDEX_FILE = "index.md";
|
|
6
|
+
export const SUMMARY_FILE = "summary.md";
|
|
7
|
+
|
|
8
|
+
// ── Git ─────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export const BRANCH_PREFIX = "feature/";
|
|
11
|
+
|
|
12
|
+
// ── Skills ──────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export const SKILL_DIR_PREFIX = "wk-";
|
|
15
|
+
|
|
16
|
+
// ── CLI ─────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Binary used in onComplete actions emitted to the orchestrator.
|
|
20
|
+
* Resolves on PATH after `npm install -g work-kit-cli`.
|
|
21
|
+
*/
|
|
22
|
+
export const CLI_BINARY = "work-kit";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Fallback / install messaging form. Used in error and setup output.
|
|
26
|
+
*/
|
|
27
|
+
export const CLI_NPX_BINARY = "npx work-kit-cli";
|
|
28
|
+
|
|
29
|
+
// ── Project Config ──────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Optional project-level config file. Lives at the main repo root and
|
|
33
|
+
* lets a project override workflow defaults, parallel groups, etc.
|
|
34
|
+
*/
|
|
35
|
+
export const PROJECT_CONFIG_FILE = ".work-kit-config.json";
|
|
36
|
+
|
|
37
|
+
// ── Limits ──────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export const MAX_LOOPBACKS_PER_ROUTE = 2;
|
|
40
|
+
|
|
41
|
+
// ── Staleness ───────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/** Threshold (ms) after which an in-progress state is considered stale. */
|
|
44
|
+
export const STALE_THRESHOLD_MS = 60 * 60 * 1000;
|