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
package/cli/src/state/helpers.ts
CHANGED
|
@@ -1,47 +1,59 @@
|
|
|
1
|
-
import { PHASE_NAMES,
|
|
1
|
+
import { PHASE_NAMES, STEPS_BY_PHASE } from "./schema.js";
|
|
2
2
|
import type { Location, PhaseName, WorkKitState } from "./schema.js";
|
|
3
|
-
import { PHASE_ORDER } from "../config/phases.js";
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Mutate a state object to flip a paused session into in-progress.
|
|
6
|
+
* Caller is responsible for persisting via writeState. Returns true
|
|
7
|
+
* when state changed, false when no transition was applicable.
|
|
8
|
+
*/
|
|
9
|
+
export function unpause(state: WorkKitState): boolean {
|
|
10
|
+
if (state.status !== "paused") return false;
|
|
11
|
+
state.status = "in-progress";
|
|
12
|
+
delete state.pausedAt;
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
import { PHASE_ORDER } from "../config/workflow.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse "phase/step" string into a Location object.
|
|
19
|
+
* Validates that the phase is a known phase name and the step exists.
|
|
8
20
|
*/
|
|
9
21
|
export function parseLocation(input: string): Location {
|
|
10
22
|
const parts = input.split("/");
|
|
11
23
|
if (parts.length !== 2) {
|
|
12
|
-
throw new Error(`Invalid location "${input}". Expected format: phase/
|
|
24
|
+
throw new Error(`Invalid location "${input}". Expected format: phase/step (e.g., plan/clarify)`);
|
|
13
25
|
}
|
|
14
|
-
const [phase,
|
|
26
|
+
const [phase, step] = parts;
|
|
15
27
|
if (!PHASE_NAMES.includes(phase as PhaseName)) {
|
|
16
28
|
throw new Error(`Unknown phase "${phase}". Valid phases: ${PHASE_NAMES.join(", ")}`);
|
|
17
29
|
}
|
|
18
|
-
const
|
|
19
|
-
if (!
|
|
20
|
-
throw new Error(`Unknown
|
|
30
|
+
const validSteps = STEPS_BY_PHASE[phase as PhaseName];
|
|
31
|
+
if (!validSteps.includes(step)) {
|
|
32
|
+
throw new Error(`Unknown step "${step}" in phase "${phase}". Valid: ${validSteps.join(", ")}`);
|
|
21
33
|
}
|
|
22
|
-
return { phase: phase as PhaseName,
|
|
34
|
+
return { phase: phase as PhaseName, step };
|
|
23
35
|
}
|
|
24
36
|
|
|
25
37
|
/**
|
|
26
|
-
* Reset state from a target location forward: marks the target
|
|
27
|
-
* and all subsequent
|
|
38
|
+
* Reset state from a target location forward: marks the target step
|
|
39
|
+
* and all subsequent steps/phases as pending.
|
|
28
40
|
*/
|
|
29
41
|
export function resetToLocation(state: WorkKitState, location: Location): void {
|
|
30
42
|
const targetPhaseState = state.phases[location.phase];
|
|
31
43
|
if (!targetPhaseState) {
|
|
32
44
|
throw new Error(`Phase "${location.phase}" not found in state`);
|
|
33
45
|
}
|
|
34
|
-
if (!targetPhaseState.
|
|
35
|
-
throw new Error(`
|
|
46
|
+
if (!targetPhaseState.steps[location.step]) {
|
|
47
|
+
throw new Error(`Step "${location.step}" not found in phase "${location.phase}"`);
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
let reset = false;
|
|
39
|
-
for (const [
|
|
40
|
-
if (
|
|
41
|
-
if (reset && (
|
|
42
|
-
|
|
43
|
-
delete
|
|
44
|
-
delete
|
|
51
|
+
for (const [s, sState] of Object.entries(targetPhaseState.steps)) {
|
|
52
|
+
if (s === location.step) reset = true;
|
|
53
|
+
if (reset && (sState.status === "completed" || sState.status === "waiting")) {
|
|
54
|
+
sState.status = "pending";
|
|
55
|
+
delete sState.completedAt;
|
|
56
|
+
delete sState.outcome;
|
|
45
57
|
}
|
|
46
58
|
}
|
|
47
59
|
targetPhaseState.status = "in-progress";
|
|
@@ -53,11 +65,11 @@ export function resetToLocation(state: WorkKitState, location: Location): void {
|
|
|
53
65
|
if (laterPhaseState.status === "completed") {
|
|
54
66
|
laterPhaseState.status = "pending";
|
|
55
67
|
delete laterPhaseState.completedAt;
|
|
56
|
-
for (const
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
delete
|
|
60
|
-
delete
|
|
68
|
+
for (const sState of Object.values(laterPhaseState.steps)) {
|
|
69
|
+
if (sState.status === "completed") {
|
|
70
|
+
sState.status = "pending";
|
|
71
|
+
delete sState.completedAt;
|
|
72
|
+
delete sState.outcome;
|
|
61
73
|
}
|
|
62
74
|
}
|
|
63
75
|
}
|
package/cli/src/state/schema.ts
CHANGED
|
@@ -1,61 +1,125 @@
|
|
|
1
|
-
// ── Phase &
|
|
1
|
+
// ── Phase & Step Types ──────────────────────────────────────────────
|
|
2
2
|
|
|
3
3
|
export const PHASE_NAMES = ["plan", "build", "test", "review", "deploy", "wrap-up"] as const;
|
|
4
4
|
export type PhaseName = (typeof PHASE_NAMES)[number];
|
|
5
5
|
|
|
6
|
-
export const
|
|
7
|
-
export const
|
|
8
|
-
export const
|
|
9
|
-
export const
|
|
10
|
-
export const
|
|
11
|
-
export const
|
|
12
|
-
|
|
13
|
-
export type
|
|
14
|
-
export type
|
|
15
|
-
export type
|
|
16
|
-
export type
|
|
17
|
-
export type
|
|
18
|
-
export type
|
|
19
|
-
|
|
20
|
-
export type
|
|
21
|
-
|
|
22
|
-
export const
|
|
23
|
-
plan:
|
|
24
|
-
build:
|
|
25
|
-
test:
|
|
26
|
-
review:
|
|
27
|
-
deploy:
|
|
28
|
-
"wrap-up":
|
|
6
|
+
export const PLAN_STEPS = ["clarify", "investigate", "sketch", "scope", "ux-flow", "architecture", "blueprint", "audit"] as const;
|
|
7
|
+
export const BUILD_STEPS = ["setup", "migration", "red", "core", "ui", "refactor", "integration", "commit"] as const;
|
|
8
|
+
export const TEST_STEPS = ["verify", "e2e", "validate"] as const;
|
|
9
|
+
export const REVIEW_STEPS = ["self-review", "security", "performance", "compliance", "handoff"] as const;
|
|
10
|
+
export const DEPLOY_STEPS = ["merge", "monitor", "remediate"] as const;
|
|
11
|
+
export const WRAPUP_STEPS = ["summary", "knowledge"] as const;
|
|
12
|
+
|
|
13
|
+
export type PlanStep = (typeof PLAN_STEPS)[number];
|
|
14
|
+
export type BuildStep = (typeof BUILD_STEPS)[number];
|
|
15
|
+
export type TestStep = (typeof TEST_STEPS)[number];
|
|
16
|
+
export type ReviewStep = (typeof REVIEW_STEPS)[number];
|
|
17
|
+
export type DeployStep = (typeof DEPLOY_STEPS)[number];
|
|
18
|
+
export type WrapUpStep = (typeof WRAPUP_STEPS)[number];
|
|
19
|
+
|
|
20
|
+
export type StepName = PlanStep | BuildStep | TestStep | ReviewStep | DeployStep | WrapUpStep;
|
|
21
|
+
|
|
22
|
+
export const STEPS_BY_PHASE: Record<PhaseName, readonly string[]> = {
|
|
23
|
+
plan: PLAN_STEPS,
|
|
24
|
+
build: BUILD_STEPS,
|
|
25
|
+
test: TEST_STEPS,
|
|
26
|
+
review: REVIEW_STEPS,
|
|
27
|
+
deploy: DEPLOY_STEPS,
|
|
28
|
+
"wrap-up": WRAPUP_STEPS,
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
// ──
|
|
31
|
+
// ── Mode Constants ──────────────────────────────────────────────────
|
|
32
32
|
|
|
33
|
-
export
|
|
33
|
+
export const MODE_FULL = "full-kit" as const;
|
|
34
|
+
export const MODE_AUTO = "auto-kit" as const;
|
|
34
35
|
|
|
35
|
-
// ──
|
|
36
|
+
// ── Classification ──────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export const CLASSIFICATIONS = ["bug-fix", "small-change", "refactor", "feature", "large-feature"] as const;
|
|
39
|
+
export type Classification = (typeof CLASSIFICATIONS)[number];
|
|
40
|
+
|
|
41
|
+
export function isClassification(value: string): value is Classification {
|
|
42
|
+
return (CLASSIFICATIONS as readonly string[]).includes(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Model Routing ───────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Concrete model tier a phase/step can be routed to.
|
|
49
|
+
* "inherit" is not a tier — see ModelPolicy for that.
|
|
50
|
+
*/
|
|
51
|
+
export const MODEL_TIERS = ["haiku", "sonnet", "opus"] as const;
|
|
52
|
+
export type ModelTier = (typeof MODEL_TIERS)[number];
|
|
53
|
+
|
|
54
|
+
export function isModelTier(value: string): value is ModelTier {
|
|
55
|
+
return (MODEL_TIERS as readonly string[]).includes(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Session-wide model policy set once at init time.
|
|
60
|
+
*
|
|
61
|
+
* - "auto" — use work-kit's step-level routing (BY_STEP + BY_PHASE + classification)
|
|
62
|
+
* - "opus" | "sonnet" | "haiku" — force that tier for every agent, no exceptions
|
|
63
|
+
* - "inherit" — emit no model field; let Claude Code's default pick (pre-change behavior)
|
|
64
|
+
*
|
|
65
|
+
* Per-step workspace/user JSON overrides still win over the policy.
|
|
66
|
+
*/
|
|
67
|
+
export const MODEL_POLICIES = ["auto", "opus", "sonnet", "haiku", "inherit"] as const;
|
|
68
|
+
export type ModelPolicy = (typeof MODEL_POLICIES)[number];
|
|
69
|
+
|
|
70
|
+
export function isModelPolicy(value: string): value is ModelPolicy {
|
|
71
|
+
return (MODEL_POLICIES as readonly string[]).includes(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Step Outcomes ───────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Closed set of outcomes a step can report when completing.
|
|
78
|
+
* Outcomes drive loop-back routing (see config/loopback-routes.ts).
|
|
79
|
+
*/
|
|
80
|
+
export const STEP_OUTCOMES = [
|
|
81
|
+
"done", // generic success
|
|
82
|
+
"ok", // alias for done
|
|
83
|
+
"proceed", // explicit "no loopback, advance"
|
|
84
|
+
"approved", // review/handoff cleared for deploy
|
|
85
|
+
"revise", // audit / review found gaps; loop back to fix
|
|
86
|
+
"broken", // refactor or change broke something downstream
|
|
87
|
+
"changes_requested", // review handoff requested changes
|
|
88
|
+
"fix_needed", // deploy merge blocked, fix required
|
|
89
|
+
"fix_and_redeploy", // remediation requires another deploy cycle
|
|
90
|
+
"blocked", // step cannot proceed without external input
|
|
91
|
+
"skipped", // step intentionally skipped at runtime
|
|
92
|
+
] as const;
|
|
93
|
+
export type StepOutcome = (typeof STEP_OUTCOMES)[number];
|
|
94
|
+
|
|
95
|
+
export function isStepOutcome(value: string): value is StepOutcome {
|
|
96
|
+
return (STEP_OUTCOMES as readonly string[]).includes(value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Phase & Step State ──────────────────────────────────────────────
|
|
36
100
|
|
|
37
101
|
export type PhaseStatus = "pending" | "in-progress" | "completed" | "skipped";
|
|
38
|
-
export type
|
|
102
|
+
export type StepStatus = "pending" | "in-progress" | "completed" | "skipped" | "waiting";
|
|
39
103
|
|
|
40
|
-
export interface
|
|
41
|
-
status:
|
|
42
|
-
outcome?:
|
|
104
|
+
export interface StepState {
|
|
105
|
+
status: StepStatus;
|
|
106
|
+
outcome?: StepOutcome;
|
|
43
107
|
startedAt?: string;
|
|
44
108
|
completedAt?: string;
|
|
45
109
|
}
|
|
46
110
|
|
|
47
111
|
export interface PhaseState {
|
|
48
112
|
status: PhaseStatus;
|
|
49
|
-
|
|
113
|
+
steps: Record<string, StepState>;
|
|
50
114
|
startedAt?: string;
|
|
51
115
|
completedAt?: string;
|
|
52
116
|
}
|
|
53
117
|
|
|
54
|
-
// ── Loopback
|
|
118
|
+
// ── Loopback ────────────────────────────────────────────────────────
|
|
55
119
|
|
|
56
120
|
export interface Location {
|
|
57
121
|
phase: PhaseName;
|
|
58
|
-
|
|
122
|
+
step: string;
|
|
59
123
|
}
|
|
60
124
|
|
|
61
125
|
export interface LoopbackRecord {
|
|
@@ -65,27 +129,35 @@ export interface LoopbackRecord {
|
|
|
65
129
|
timestamp: string;
|
|
66
130
|
}
|
|
67
131
|
|
|
68
|
-
// ── Workflow (auto-kit)
|
|
132
|
+
// ── Workflow (auto-kit) ─────────────────────────────────────────────
|
|
69
133
|
|
|
70
134
|
export interface WorkflowStep {
|
|
71
135
|
phase: PhaseName;
|
|
72
|
-
|
|
136
|
+
step: string;
|
|
73
137
|
included: boolean;
|
|
74
138
|
}
|
|
75
139
|
|
|
76
|
-
// ──
|
|
140
|
+
// ── Work Status ─────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
export type WorkStatus = "in-progress" | "paused" | "completed" | "failed";
|
|
143
|
+
|
|
144
|
+
// ── Main State ──────────────────────────────────────────────────────
|
|
77
145
|
|
|
78
146
|
export interface WorkKitState {
|
|
79
|
-
version:
|
|
147
|
+
version: 2;
|
|
80
148
|
slug: string;
|
|
81
149
|
branch: string;
|
|
82
150
|
started: string;
|
|
83
|
-
mode:
|
|
151
|
+
mode: typeof MODE_FULL | typeof MODE_AUTO;
|
|
84
152
|
gated?: boolean;
|
|
85
153
|
classification?: Classification;
|
|
86
|
-
status:
|
|
154
|
+
status: WorkStatus;
|
|
155
|
+
/** Session-wide model policy, set once at init. Defaults to "auto". */
|
|
156
|
+
modelPolicy?: ModelPolicy;
|
|
157
|
+
/** ISO timestamp the work was paused; cleared on resume. */
|
|
158
|
+
pausedAt?: string;
|
|
87
159
|
currentPhase: PhaseName | null;
|
|
88
|
-
|
|
160
|
+
currentStep: string | null;
|
|
89
161
|
phases: Record<PhaseName, PhaseState>;
|
|
90
162
|
workflow?: WorkflowStep[];
|
|
91
163
|
loopbacks: LoopbackRecord[];
|
|
@@ -95,20 +167,38 @@ export interface WorkKitState {
|
|
|
95
167
|
};
|
|
96
168
|
}
|
|
97
169
|
|
|
98
|
-
// ── Actions (CLI → Claude)
|
|
170
|
+
// ── Actions (CLI → Claude) ──────────────────────────────────────────
|
|
99
171
|
|
|
100
172
|
export interface AgentSpec {
|
|
101
173
|
phase: PhaseName;
|
|
102
|
-
|
|
174
|
+
step: string;
|
|
103
175
|
skillFile: string;
|
|
104
176
|
agentPrompt: string;
|
|
105
|
-
outputFile?: string;
|
|
177
|
+
outputFile?: string;
|
|
178
|
+
/** Resolved model tier for this agent. Omitted when policy is "inherit". */
|
|
179
|
+
model?: ModelTier;
|
|
106
180
|
}
|
|
107
181
|
|
|
108
182
|
export type Action =
|
|
109
|
-
| { action: "spawn_agent"; phase: PhaseName;
|
|
183
|
+
| { action: "spawn_agent"; phase: PhaseName; step: string; skillFile: string; agentPrompt: string; onComplete: string; model?: ModelTier }
|
|
110
184
|
| { action: "spawn_parallel_agents"; agents: AgentSpec[]; thenSequential?: AgentSpec; onComplete: string }
|
|
111
185
|
| { action: "wait_for_user"; message: string }
|
|
112
186
|
| { action: "loopback"; from: Location; to: Location; reason: string }
|
|
113
187
|
| { action: "complete"; message: string }
|
|
188
|
+
| { action: "paused"; message: string }
|
|
189
|
+
| { action: "resumed"; message: string; phase: PhaseName | null; step: string | null; worktreeRoot?: string }
|
|
190
|
+
| { action: "select_session"; message: string; sessions: ResumableSessionSummary[] }
|
|
114
191
|
| { action: "error"; message: string; suggestion?: string };
|
|
192
|
+
|
|
193
|
+
export interface ResumableSessionSummary {
|
|
194
|
+
slug: string;
|
|
195
|
+
branch: string;
|
|
196
|
+
worktreeRoot: string;
|
|
197
|
+
status: Extract<WorkStatus, "paused" | "in-progress">;
|
|
198
|
+
pausedAt?: string;
|
|
199
|
+
currentPhase: string | null;
|
|
200
|
+
currentStep: string | null;
|
|
201
|
+
// Snapshot of how long ago tracker.json was last written, captured at
|
|
202
|
+
// CLI invocation. Lets the agent surface "closed by mistake" sessions.
|
|
203
|
+
lastUpdatedAgoMs: number;
|
|
204
|
+
}
|
package/cli/src/state/store.ts
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
4
5
|
import { WorkKitState } from "./schema.js";
|
|
5
6
|
|
|
6
|
-
const STATE_DIR = ".work-kit";
|
|
7
|
-
const STATE_FILE = "tracker.json";
|
|
7
|
+
export const STATE_DIR = ".work-kit";
|
|
8
|
+
export const STATE_FILE = "tracker.json";
|
|
9
|
+
export const STATE_MD_FILE = "state.md";
|
|
10
|
+
export const AWAITING_INPUT_MARKER_FILE = "awaiting-input";
|
|
11
|
+
export const IDLE_MARKER_FILE = "idle";
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Remove any "blocked on user" marker files (awaiting-input, idle).
|
|
15
|
+
*
|
|
16
|
+
* Hook-installed cleanup normally handles these (PostToolUse/Stop),
|
|
17
|
+
* but edge cases (denied permission, killed session, sentinel drift)
|
|
18
|
+
* can leave stale markers. Any forward state transition should clear
|
|
19
|
+
* them as a belt-and-suspenders safety net.
|
|
20
|
+
*/
|
|
21
|
+
export function clearBlockingMarkers(worktreeRoot: string): void {
|
|
22
|
+
const dir = path.join(worktreeRoot, STATE_DIR);
|
|
23
|
+
fs.rmSync(path.join(dir, AWAITING_INPUT_MARKER_FILE), { force: true });
|
|
24
|
+
fs.rmSync(path.join(dir, IDLE_MARKER_FILE), { force: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── Worktree Discovery ──────────────────────────────────────────────
|
|
10
28
|
|
|
11
29
|
export function findWorktreeRoot(startDir?: string): string | null {
|
|
12
30
|
let dir = startDir || process.cwd();
|
|
@@ -29,10 +47,10 @@ export function statePath(worktreeRoot: string): string {
|
|
|
29
47
|
}
|
|
30
48
|
|
|
31
49
|
export function stateMdPath(worktreeRoot: string): string {
|
|
32
|
-
return path.join(worktreeRoot, STATE_DIR,
|
|
50
|
+
return path.join(worktreeRoot, STATE_DIR, STATE_MD_FILE);
|
|
33
51
|
}
|
|
34
52
|
|
|
35
|
-
// ── Read / Write
|
|
53
|
+
// ── Read / Write ────────────────────────────────────────────────────
|
|
36
54
|
|
|
37
55
|
export function stateExists(worktreeRoot: string): boolean {
|
|
38
56
|
return fs.existsSync(statePath(worktreeRoot));
|
|
@@ -44,11 +62,13 @@ export function readState(worktreeRoot: string): WorkKitState {
|
|
|
44
62
|
throw new Error(`No tracker.json found at ${filePath}`);
|
|
45
63
|
}
|
|
46
64
|
const raw = fs.readFileSync(filePath, "utf-8");
|
|
65
|
+
let parsed: any;
|
|
47
66
|
try {
|
|
48
|
-
|
|
67
|
+
parsed = JSON.parse(raw);
|
|
49
68
|
} catch {
|
|
50
69
|
throw new Error(`Corrupted tracker.json at ${filePath}. File contains invalid JSON.`);
|
|
51
70
|
}
|
|
71
|
+
return migrateState(parsed, worktreeRoot);
|
|
52
72
|
}
|
|
53
73
|
|
|
54
74
|
export function writeState(worktreeRoot: string, state: WorkKitState): void {
|
|
@@ -62,7 +82,7 @@ export function writeState(worktreeRoot: string, state: WorkKitState): void {
|
|
|
62
82
|
fs.renameSync(tmp, target);
|
|
63
83
|
}
|
|
64
84
|
|
|
65
|
-
// ── State.md
|
|
85
|
+
// ── State.md ────────────────────────────────────────────────────────
|
|
66
86
|
|
|
67
87
|
export function writeStateMd(worktreeRoot: string, content: string): void {
|
|
68
88
|
const dir = stateDir(worktreeRoot);
|
|
@@ -80,3 +100,103 @@ export function readStateMd(worktreeRoot: string): string | null {
|
|
|
80
100
|
if (!fs.existsSync(filePath)) return null;
|
|
81
101
|
return fs.readFileSync(filePath, "utf-8");
|
|
82
102
|
}
|
|
103
|
+
|
|
104
|
+
// ── Git Helpers ─────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
// Returns the main repo root for a given path inside a git repo, or null
|
|
107
|
+
// if the path is not in a git repo. Unlike `git rev-parse --show-toplevel`,
|
|
108
|
+
// this returns the *main* repo even when called from inside a worktree —
|
|
109
|
+
// `git worktree list --porcelain` always lists the main repo first.
|
|
110
|
+
export function gitMainRepoRoot(cwd: string): string | null {
|
|
111
|
+
try {
|
|
112
|
+
const output = execFileSync("git", ["worktree", "list", "--porcelain"], {
|
|
113
|
+
cwd,
|
|
114
|
+
encoding: "utf-8",
|
|
115
|
+
timeout: 5000,
|
|
116
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
117
|
+
});
|
|
118
|
+
const firstLine = output.split("\n").find(l => l.startsWith("worktree "));
|
|
119
|
+
if (firstLine) return firstLine.slice("worktree ".length).trim();
|
|
120
|
+
return null;
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function resolveMainRepoRoot(worktreeRoot: string): string {
|
|
127
|
+
return gitMainRepoRoot(worktreeRoot) ?? worktreeRoot;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Per-process cache. The HEAD SHA can drift mid-session in theory, but
|
|
131
|
+
// every callsite either tags a stable artifact at session end (wrap-up)
|
|
132
|
+
// or stamps an event during normal phase work — close enough for telemetry.
|
|
133
|
+
const headShaCache = new Map<string, string>();
|
|
134
|
+
|
|
135
|
+
/** Resolve the current HEAD commit SHA for `cwd`. Returns undefined outside a git repo. */
|
|
136
|
+
export function gitHeadSha(cwd: string): string | undefined {
|
|
137
|
+
const cached = headShaCache.get(cwd);
|
|
138
|
+
if (cached !== undefined) return cached;
|
|
139
|
+
try {
|
|
140
|
+
const out = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
141
|
+
cwd,
|
|
142
|
+
encoding: "utf-8",
|
|
143
|
+
timeout: 5000,
|
|
144
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
145
|
+
}).trim();
|
|
146
|
+
if (out) {
|
|
147
|
+
headShaCache.set(cwd, out);
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// ignore
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Migration ───────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
function migrateState(raw: any, worktreeRoot: string): WorkKitState {
|
|
159
|
+
if (raw.version === 2) return raw as WorkKitState;
|
|
160
|
+
|
|
161
|
+
// v1 → v2: rename subStage fields to step
|
|
162
|
+
if (raw.version === 1 || !raw.version) {
|
|
163
|
+
if ("currentSubStage" in raw) {
|
|
164
|
+
raw.currentStep = raw.currentSubStage;
|
|
165
|
+
delete raw.currentSubStage;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (const phase of Object.values(raw.phases) as any[]) {
|
|
169
|
+
if (phase.subStages) {
|
|
170
|
+
phase.steps = phase.subStages;
|
|
171
|
+
delete phase.subStages;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (raw.workflow) {
|
|
176
|
+
for (const ws of raw.workflow) {
|
|
177
|
+
if ("subStage" in ws) {
|
|
178
|
+
ws.step = ws.subStage;
|
|
179
|
+
delete ws.subStage;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (raw.loopbacks) {
|
|
185
|
+
for (const lb of raw.loopbacks) {
|
|
186
|
+
if (lb.from?.subStage !== undefined) {
|
|
187
|
+
lb.from.step = lb.from.subStage;
|
|
188
|
+
delete lb.from.subStage;
|
|
189
|
+
}
|
|
190
|
+
if (lb.to?.subStage !== undefined) {
|
|
191
|
+
lb.to.step = lb.to.subStage;
|
|
192
|
+
delete lb.to.subStage;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
raw.version = 2;
|
|
198
|
+
writeState(worktreeRoot, raw as WorkKitState);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return raw as WorkKitState;
|
|
202
|
+
}
|
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
import { describe, it } from "node:test";
|
|
2
2
|
import * as assert from "node:assert/strict";
|
|
3
3
|
import { validatePhasePrerequisites } from "./validators.js";
|
|
4
|
-
import type { WorkKitState, PhaseName, PhaseState,
|
|
5
|
-
import { PHASE_NAMES,
|
|
4
|
+
import type { WorkKitState, PhaseName, PhaseState, StepState } from "./schema.js";
|
|
5
|
+
import { PHASE_NAMES, STEPS_BY_PHASE } from "./schema.js";
|
|
6
6
|
|
|
7
7
|
function makeState(): WorkKitState {
|
|
8
8
|
const phases = {} as Record<PhaseName, PhaseState>;
|
|
9
9
|
for (const phase of PHASE_NAMES) {
|
|
10
|
-
const
|
|
11
|
-
for (const
|
|
12
|
-
|
|
10
|
+
const steps: Record<string, StepState> = {};
|
|
11
|
+
for (const s of STEPS_BY_PHASE[phase]) {
|
|
12
|
+
steps[s] = { status: "pending" };
|
|
13
13
|
}
|
|
14
|
-
phases[phase] = { status: "pending",
|
|
14
|
+
phases[phase] = { status: "pending", steps };
|
|
15
15
|
}
|
|
16
16
|
return {
|
|
17
|
-
version:
|
|
17
|
+
version: 2,
|
|
18
18
|
slug: "test",
|
|
19
19
|
branch: "feature/test",
|
|
20
20
|
started: "2026-01-01",
|
|
21
21
|
mode: "full-kit",
|
|
22
22
|
status: "in-progress",
|
|
23
23
|
currentPhase: "plan",
|
|
24
|
-
|
|
24
|
+
currentStep: "clarify",
|
|
25
25
|
phases,
|
|
26
26
|
loopbacks: [],
|
|
27
27
|
metadata: { worktreeRoot: "/tmp/test", mainRepoRoot: "/tmp/test" },
|
|
@@ -31,9 +31,9 @@ function makeState(): WorkKitState {
|
|
|
31
31
|
function completePhase(state: WorkKitState, phase: PhaseName): void {
|
|
32
32
|
state.phases[phase].status = "completed";
|
|
33
33
|
state.phases[phase].completedAt = "2026-01-01";
|
|
34
|
-
for (const
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
for (const s of Object.values(state.phases[phase].steps)) {
|
|
35
|
+
s.status = "completed";
|
|
36
|
+
s.completedAt = "2026-01-01";
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -65,7 +65,7 @@ describe("validatePhasePrerequisites", () => {
|
|
|
65
65
|
completePhase(state, "test");
|
|
66
66
|
completePhase(state, "review");
|
|
67
67
|
// handoff completed but without "approved" outcome
|
|
68
|
-
state.phases.review.
|
|
68
|
+
state.phases.review.steps.handoff.outcome = "blocked";
|
|
69
69
|
const result = validatePhasePrerequisites(state, "deploy");
|
|
70
70
|
assert.equal(result.valid, false);
|
|
71
71
|
});
|
|
@@ -76,7 +76,7 @@ describe("validatePhasePrerequisites", () => {
|
|
|
76
76
|
completePhase(state, "build");
|
|
77
77
|
completePhase(state, "test");
|
|
78
78
|
completePhase(state, "review");
|
|
79
|
-
state.phases.review.
|
|
79
|
+
state.phases.review.steps.handoff.outcome = "approved";
|
|
80
80
|
const result = validatePhasePrerequisites(state, "deploy");
|
|
81
81
|
assert.equal(result.valid, true);
|
|
82
82
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { WorkKitState, PhaseName } from "./schema.js";
|
|
2
|
-
import { PHASE_PREREQUISITES } from "../config/
|
|
2
|
+
import { PHASE_PREREQUISITES } from "../config/workflow.js";
|
|
3
3
|
|
|
4
4
|
export interface ValidationResult {
|
|
5
5
|
valid: boolean;
|
|
@@ -35,7 +35,6 @@ export function validatePhasePrerequisites(state: WorkKitState, phase: PhaseName
|
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
// deploy completed, skipped, or pending (never started) — all ok if review is done
|
|
39
38
|
return { valid: true, message: "Prerequisites met for wrap-up" };
|
|
40
39
|
}
|
|
41
40
|
|
|
@@ -49,11 +48,11 @@ export function validatePhasePrerequisites(state: WorkKitState, phase: PhaseName
|
|
|
49
48
|
missingPrerequisite: "review",
|
|
50
49
|
};
|
|
51
50
|
}
|
|
52
|
-
const handoff = reviewState.
|
|
51
|
+
const handoff = reviewState.steps["handoff"];
|
|
53
52
|
if (!handoff) {
|
|
54
53
|
return {
|
|
55
54
|
valid: false,
|
|
56
|
-
message: `deploy requires review/handoff to exist and be approved. Handoff
|
|
55
|
+
message: `deploy requires review/handoff to exist and be approved. Handoff step not found.`,
|
|
57
56
|
missingPrerequisite: "review",
|
|
58
57
|
};
|
|
59
58
|
}
|
package/cli/src/utils/colors.ts
CHANGED
|
@@ -17,7 +17,9 @@ export const bgCyan = (s: string) => `${code(46)}${code(30)}${s}${reset}`;
|
|
|
17
17
|
export const bgRed = (s: string) => `${code(41)}${code(97)}${s}${reset}`;
|
|
18
18
|
export const bgDim = (s: string) => `${code(100)}${code(97)}${s}${reset}`;
|
|
19
19
|
export const bgMagenta = (s: string) => `${code(45)}${code(97)}${s}${reset}`;
|
|
20
|
+
export const bgBlue = (s: string) => `${code(44)}${code(97)}${s}${reset}`;
|
|
20
21
|
export const boldCyan = (s: string) => `${code(1)}${code(36)}${s}${reset}`;
|
|
21
22
|
export const boldGreen = (s: string) => `${code(1)}${code(32)}${s}${reset}`;
|
|
22
23
|
export const boldYellow = (s: string) => `${code(1)}${code(33)}${s}${reset}`;
|
|
23
24
|
export const boldRed = (s: string) => `${code(1)}${code(31)}${s}${reset}`;
|
|
25
|
+
export const boldMagenta = (s: string) => `${code(1)}${code(35)}${s}${reset}`;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Crash-safe write: write to a temp file in the same directory, then rename.
|
|
6
|
+
* The rename is atomic on POSIX, so a partial file never appears at `target`.
|
|
7
|
+
* Used for any file that may be read concurrently or that must survive a crash.
|
|
8
|
+
*/
|
|
9
|
+
export function atomicWriteFile(target: string, content: string): void {
|
|
10
|
+
const tmp = target + "." + randomUUID().slice(0, 8) + ".tmp";
|
|
11
|
+
fs.writeFileSync(tmp, content, "utf-8");
|
|
12
|
+
fs.renameSync(tmp, target);
|
|
13
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read and parse a JSON file. Returns null when the file is missing,
|
|
5
|
+
* unreadable, or contains invalid JSON. Use only when null is a meaningful
|
|
6
|
+
* "no config" answer; use direct fs/JSON for hard-required files.
|
|
7
|
+
*/
|
|
8
|
+
export function readJsonFile<T>(filePath: string): T | null {
|
|
9
|
+
let raw: string;
|
|
10
|
+
try {
|
|
11
|
+
raw = fs.readFileSync(filePath, "utf-8");
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(raw) as T;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|