work-kit-cli 0.2.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +24 -13
  2. package/cli/src/commands/bootstrap.test.ts +40 -0
  3. package/cli/src/commands/bootstrap.ts +77 -13
  4. package/cli/src/commands/cancel.ts +1 -16
  5. package/cli/src/commands/complete.ts +92 -98
  6. package/cli/src/commands/completions.ts +2 -2
  7. package/cli/src/commands/doctor.ts +1 -1
  8. package/cli/src/commands/extract.ts +217 -0
  9. package/cli/src/commands/init.test.ts +50 -0
  10. package/cli/src/commands/init.ts +70 -35
  11. package/cli/src/commands/learn.test.ts +217 -0
  12. package/cli/src/commands/learn.ts +104 -0
  13. package/cli/src/commands/loopback.ts +8 -11
  14. package/cli/src/commands/next.ts +93 -60
  15. package/cli/src/commands/observe.ts +16 -21
  16. package/cli/src/commands/pause-resume.test.ts +142 -0
  17. package/cli/src/commands/pause.ts +34 -0
  18. package/cli/src/commands/report.ts +217 -0
  19. package/cli/src/commands/resume.ts +126 -0
  20. package/cli/src/commands/setup.ts +280 -0
  21. package/cli/src/commands/status.ts +8 -6
  22. package/cli/src/commands/uninstall.ts +8 -3
  23. package/cli/src/commands/workflow.ts +43 -33
  24. package/cli/src/config/agent-map.ts +9 -9
  25. package/cli/src/config/constants.ts +54 -0
  26. package/cli/src/config/loopback-routes.ts +13 -13
  27. package/cli/src/config/model-routing.test.ts +190 -0
  28. package/cli/src/config/model-routing.ts +208 -0
  29. package/cli/src/config/project-config.test.ts +127 -0
  30. package/cli/src/config/project-config.ts +106 -0
  31. package/cli/src/config/{phases.ts → workflow.ts} +40 -23
  32. package/cli/src/context/prompt-builder.ts +10 -9
  33. package/cli/src/index.ts +130 -9
  34. package/cli/src/observer/data.ts +196 -65
  35. package/cli/src/observer/renderer.ts +127 -107
  36. package/cli/src/observer/watcher.ts +28 -16
  37. package/cli/src/state/helpers.test.ts +28 -28
  38. package/cli/src/state/helpers.ts +37 -25
  39. package/cli/src/state/schema.ts +135 -45
  40. package/cli/src/state/store.ts +127 -7
  41. package/cli/src/state/validators.test.ts +13 -13
  42. package/cli/src/state/validators.ts +3 -4
  43. package/cli/src/utils/colors.ts +2 -0
  44. package/cli/src/utils/fs.ts +13 -0
  45. package/cli/src/utils/json.ts +20 -0
  46. package/cli/src/utils/knowledge.ts +471 -0
  47. package/cli/src/utils/time.ts +27 -0
  48. package/cli/src/{engine → workflow}/loopbacks.test.ts +2 -2
  49. package/cli/src/workflow/loopbacks.ts +42 -0
  50. package/cli/src/workflow/parallel.ts +64 -0
  51. package/cli/src/workflow/transitions.test.ts +129 -0
  52. package/cli/src/{engine → workflow}/transitions.ts +18 -22
  53. package/package.json +2 -2
  54. package/skills/auto-kit/SKILL.md +44 -27
  55. package/skills/cancel-kit/SKILL.md +4 -4
  56. package/skills/full-kit/SKILL.md +45 -28
  57. package/skills/pause-kit/SKILL.md +25 -0
  58. package/skills/resume-kit/SKILL.md +64 -0
  59. package/skills/wk-bootstrap/SKILL.md +11 -5
  60. package/skills/wk-build/SKILL.md +12 -11
  61. package/skills/wk-build/{stages → steps}/commit.md +1 -1
  62. package/skills/wk-build/{stages → steps}/core.md +3 -3
  63. package/skills/wk-build/{stages → steps}/integration.md +2 -2
  64. package/skills/wk-build/{stages → steps}/migration.md +1 -1
  65. package/skills/wk-build/{stages → steps}/red.md +1 -1
  66. package/skills/wk-build/{stages → steps}/refactor.md +1 -1
  67. package/skills/wk-build/{stages → steps}/setup.md +1 -1
  68. package/skills/wk-build/{stages → steps}/ui.md +1 -1
  69. package/skills/wk-deploy/SKILL.md +7 -6
  70. package/skills/wk-deploy/{stages → steps}/merge.md +1 -1
  71. package/skills/wk-deploy/{stages → steps}/monitor.md +1 -1
  72. package/skills/wk-deploy/{stages → steps}/remediate.md +1 -1
  73. package/skills/wk-plan/SKILL.md +15 -14
  74. package/skills/wk-plan/{stages → steps}/architecture.md +1 -1
  75. package/skills/wk-plan/{stages → steps}/audit.md +2 -2
  76. package/skills/wk-plan/{stages → steps}/blueprint.md +2 -2
  77. package/skills/wk-plan/{stages → steps}/clarify.md +1 -1
  78. package/skills/wk-plan/{stages → steps}/investigate.md +1 -1
  79. package/skills/wk-plan/{stages → steps}/scope.md +1 -1
  80. package/skills/wk-plan/{stages → steps}/sketch.md +1 -1
  81. package/skills/wk-plan/{stages → steps}/ux-flow.md +1 -1
  82. package/skills/wk-review/SKILL.md +11 -10
  83. package/skills/wk-review/{stages → steps}/compliance.md +1 -1
  84. package/skills/wk-review/{stages → steps}/handoff.md +2 -2
  85. package/skills/wk-review/{stages → steps}/performance.md +1 -1
  86. package/skills/wk-review/{stages → steps}/security.md +1 -1
  87. package/skills/wk-review/{stages → steps}/self-review.md +1 -1
  88. package/skills/wk-test/SKILL.md +9 -8
  89. package/skills/wk-test/steps/e2e.md +56 -0
  90. package/skills/wk-test/{stages → steps}/validate.md +1 -1
  91. package/skills/wk-test/{stages → steps}/verify.md +1 -1
  92. package/skills/wk-wrap-up/SKILL.md +19 -5
  93. package/skills/wk-wrap-up/steps/knowledge.md +76 -0
  94. package/skills/wk-wrap-up/steps/summary.md +86 -0
  95. package/cli/src/engine/loopbacks.ts +0 -32
  96. package/cli/src/engine/parallel.ts +0 -60
  97. package/cli/src/engine/transitions.test.ts +0 -129
  98. package/skills/wk-test/stages/e2e.md +0 -53
  99. /package/cli/src/{engine/phases.ts → workflow/gates.ts} +0 -0
@@ -1,47 +1,59 @@
1
- import { PHASE_NAMES, SUBSTAGES_BY_PHASE } from "./schema.js";
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
- * Parse "phase/sub-stage" string into a Location object.
7
- * Validates that the phase is a known phase name and the sub-stage exists.
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/sub-stage (e.g., plan/clarify)`);
24
+ throw new Error(`Invalid location "${input}". Expected format: phase/step (e.g., plan/clarify)`);
13
25
  }
14
- const [phase, subStage] = parts;
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 validSubStages = SUBSTAGES_BY_PHASE[phase as PhaseName];
19
- if (!validSubStages.includes(subStage)) {
20
- throw new Error(`Unknown sub-stage "${subStage}" in phase "${phase}". Valid: ${validSubStages.join(", ")}`);
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, subStage };
34
+ return { phase: phase as PhaseName, step };
23
35
  }
24
36
 
25
37
  /**
26
- * Reset state from a target location forward: marks the target sub-stage
27
- * and all subsequent sub-stages/phases as pending.
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.subStages[location.subStage]) {
35
- throw new Error(`Sub-stage "${location.subStage}" not found in phase "${location.phase}"`);
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 [ss, ssState] of Object.entries(targetPhaseState.subStages)) {
40
- if (ss === location.subStage) reset = true;
41
- if (reset && (ssState.status === "completed" || ssState.status === "waiting")) {
42
- ssState.status = "pending";
43
- delete ssState.completedAt;
44
- delete ssState.outcome;
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 ssState of Object.values(laterPhaseState.subStages)) {
57
- if (ssState.status === "completed") {
58
- ssState.status = "pending";
59
- delete ssState.completedAt;
60
- delete ssState.outcome;
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
  }
@@ -1,61 +1,125 @@
1
- // ── Phase & Sub-stage Types ──────────────────────────────────────────
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 PLAN_SUBSTAGES = ["clarify", "investigate", "sketch", "scope", "ux-flow", "architecture", "blueprint", "audit"] as const;
7
- export const BUILD_SUBSTAGES = ["setup", "migration", "red", "core", "ui", "refactor", "integration", "commit"] as const;
8
- export const TEST_SUBSTAGES = ["verify", "e2e", "validate"] as const;
9
- export const REVIEW_SUBSTAGES = ["self-review", "security", "performance", "compliance", "handoff"] as const;
10
- export const DEPLOY_SUBSTAGES = ["merge", "monitor", "remediate"] as const;
11
- export const WRAPUP_SUBSTAGES = ["wrap-up"] as const;
12
-
13
- export type PlanSubStage = (typeof PLAN_SUBSTAGES)[number];
14
- export type BuildSubStage = (typeof BUILD_SUBSTAGES)[number];
15
- export type TestSubStage = (typeof TEST_SUBSTAGES)[number];
16
- export type ReviewSubStage = (typeof REVIEW_SUBSTAGES)[number];
17
- export type DeploySubStage = (typeof DEPLOY_SUBSTAGES)[number];
18
- export type WrapUpSubStage = (typeof WRAPUP_SUBSTAGES)[number];
19
-
20
- export type SubStageName = PlanSubStage | BuildSubStage | TestSubStage | ReviewSubStage | DeploySubStage | WrapUpSubStage;
21
-
22
- export const SUBSTAGES_BY_PHASE: Record<PhaseName, readonly string[]> = {
23
- plan: PLAN_SUBSTAGES,
24
- build: BUILD_SUBSTAGES,
25
- test: TEST_SUBSTAGES,
26
- review: REVIEW_SUBSTAGES,
27
- deploy: DEPLOY_SUBSTAGES,
28
- "wrap-up": WRAPUP_SUBSTAGES,
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
- // ── Classification ───────────────────────────────────────────────────
31
+ // ── Mode Constants ──────────────────────────────────────────────────
32
32
 
33
- export type Classification = "bug-fix" | "small-change" | "refactor" | "feature" | "large-feature";
33
+ export const MODE_FULL = "full-kit" as const;
34
+ export const MODE_AUTO = "auto-kit" as const;
34
35
 
35
- // ── Phase & Sub-stage State ──────────────────────────────────────────
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 SubStageStatus = "pending" | "in-progress" | "completed" | "skipped" | "waiting";
102
+ export type StepStatus = "pending" | "in-progress" | "completed" | "skipped" | "waiting";
39
103
 
40
- export interface SubStageState {
41
- status: SubStageStatus;
42
- outcome?: string;
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
- subStages: Record<string, SubStageState>;
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
- subStage: string;
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
- subStage: string;
136
+ step: string;
73
137
  included: boolean;
74
138
  }
75
139
 
76
- // ── Main State ───────────────────────────────────────────────────────
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: 1;
147
+ version: 2;
80
148
  slug: string;
81
149
  branch: string;
82
150
  started: string;
83
- mode: "full-kit" | "auto-kit";
151
+ mode: typeof MODE_FULL | typeof MODE_AUTO;
84
152
  gated?: boolean;
85
153
  classification?: Classification;
86
- status: "in-progress" | "paused" | "completed" | "failed";
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
- currentSubStage: string | null;
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
- subStage: string;
174
+ step: string;
103
175
  skillFile: string;
104
176
  agentPrompt: string;
105
- outputFile?: string; // for parallel agents writing to separate files
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; subStage: string; skillFile: string; agentPrompt: string; onComplete: string }
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
+ }
@@ -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
- // ── Worktree Discovery ───────────────────────────────────────────────
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, "state.md");
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
- return JSON.parse(raw) as WorkKitState;
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, SubStageState } from "./schema.js";
5
- import { PHASE_NAMES, SUBSTAGES_BY_PHASE } from "./schema.js";
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 subStages: Record<string, SubStageState> = {};
11
- for (const ss of SUBSTAGES_BY_PHASE[phase]) {
12
- subStages[ss] = { status: "pending" };
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", subStages };
14
+ phases[phase] = { status: "pending", steps };
15
15
  }
16
16
  return {
17
- version: 1,
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
- currentSubStage: "clarify",
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 ss of Object.values(state.phases[phase].subStages)) {
35
- ss.status = "completed";
36
- ss.completedAt = "2026-01-01";
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.subStages.handoff.outcome = "pending_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.subStages.handoff.outcome = "approved";
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/phases.js";
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.subStages["handoff"];
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 sub-stage not found.`,
55
+ message: `deploy requires review/handoff to exist and be approved. Handoff step not found.`,
57
56
  missingPrerequisite: "review",
58
57
  };
59
58
  }
@@ -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
+ }