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,45 +1,45 @@
1
- import { PhaseName, Location } from "../state/schema.js";
1
+ import { Location, StepOutcome } from "../state/schema.js";
2
2
 
3
3
  /**
4
- * Defines when a completed sub-stage should trigger a loop-back
4
+ * Defines when a completed step should trigger a loop-back
5
5
  * based on its outcome.
6
6
  */
7
7
  export interface LoopbackRoute {
8
8
  from: Location;
9
- triggerOutcome: string;
9
+ triggerOutcome: StepOutcome;
10
10
  to: Location;
11
11
  reason: string;
12
12
  }
13
13
 
14
14
  export const LOOPBACK_ROUTES: LoopbackRoute[] = [
15
15
  {
16
- from: { phase: "plan", subStage: "audit" },
16
+ from: { phase: "plan", step: "audit" },
17
17
  triggerOutcome: "revise",
18
- to: { phase: "plan", subStage: "blueprint" },
18
+ to: { phase: "plan", step: "blueprint" },
19
19
  reason: "Audit found gaps — revising Blueprint",
20
20
  },
21
21
  {
22
- from: { phase: "build", subStage: "refactor" },
22
+ from: { phase: "build", step: "refactor" },
23
23
  triggerOutcome: "broken",
24
- to: { phase: "build", subStage: "core" },
24
+ to: { phase: "build", step: "core" },
25
25
  reason: "Refactor broke tests — re-running Core to fix",
26
26
  },
27
27
  {
28
- from: { phase: "review", subStage: "handoff" },
28
+ from: { phase: "review", step: "handoff" },
29
29
  triggerOutcome: "changes_requested",
30
- to: { phase: "build", subStage: "core" },
30
+ to: { phase: "build", step: "core" },
31
31
  reason: "Review requested changes — looping back to Build/Core",
32
32
  },
33
33
  {
34
- from: { phase: "deploy", subStage: "merge" },
34
+ from: { phase: "deploy", step: "merge" },
35
35
  triggerOutcome: "fix_needed",
36
- to: { phase: "build", subStage: "core" },
36
+ to: { phase: "build", step: "core" },
37
37
  reason: "Merge blocked — fix needed in Build/Core",
38
38
  },
39
39
  {
40
- from: { phase: "deploy", subStage: "remediate" },
40
+ from: { phase: "deploy", step: "remediate" },
41
41
  triggerOutcome: "fix_and_redeploy",
42
- to: { phase: "build", subStage: "core" },
42
+ to: { phase: "build", step: "core" },
43
43
  reason: "Deployment issue — fix and redeploy from Build/Core",
44
44
  },
45
45
  ];
@@ -0,0 +1,190 @@
1
+ import { describe, it, afterEach } from "node:test";
2
+ import * as assert from "node:assert/strict";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import * as os from "node:os";
6
+ import { randomUUID } from "node:crypto";
7
+ import { resolveModel, BY_PHASE, BY_STEP } from "./model-routing.js";
8
+ import type { WorkKitState, ModelPolicy, Classification } from "../state/schema.js";
9
+
10
+ function makeTmpDir(): string {
11
+ const dir = path.join(os.tmpdir(), `wk-model-routing-${randomUUID()}`);
12
+ fs.mkdirSync(path.join(dir, ".work-kit"), { recursive: true });
13
+ return dir;
14
+ }
15
+
16
+ let tmpDirs: string[] = [];
17
+
18
+ afterEach(() => {
19
+ for (const dir of tmpDirs) fs.rmSync(dir, { recursive: true, force: true });
20
+ tmpDirs = [];
21
+ });
22
+
23
+ function fakeState(opts: {
24
+ worktreeRoot: string;
25
+ policy?: ModelPolicy;
26
+ classification?: Classification;
27
+ mode?: "auto-kit" | "full-kit";
28
+ }): Pick<WorkKitState, "modelPolicy" | "classification" | "mode"> & { metadata: { worktreeRoot: string } } {
29
+ return {
30
+ modelPolicy: opts.policy,
31
+ classification: opts.classification,
32
+ mode: (opts.mode ?? "full-kit") as any,
33
+ metadata: { worktreeRoot: opts.worktreeRoot },
34
+ };
35
+ }
36
+
37
+ describe("resolveModel — defaults", () => {
38
+ it("uses step default when no policy or override", () => {
39
+ const tmp = makeTmpDir(); tmpDirs.push(tmp);
40
+ const state = fakeState({ worktreeRoot: tmp });
41
+ assert.equal(resolveModel(state, "plan", "investigate"), "opus");
42
+ assert.equal(resolveModel(state, "build", "commit"), "haiku");
43
+ assert.equal(resolveModel(state, "review", "security"), "opus");
44
+ });
45
+
46
+ it("falls back to phase default for unknown step", () => {
47
+ const tmp = makeTmpDir(); tmpDirs.push(tmp);
48
+ const state = fakeState({ worktreeRoot: tmp });
49
+ // Pick a phase/step that isn't in BY_STEP
50
+ const key = "plan/__nonexistent__";
51
+ assert.ok(!(key in BY_STEP));
52
+ assert.equal(resolveModel(state, "plan", "__nonexistent__"), BY_PHASE.plan);
53
+ });
54
+ });
55
+
56
+ describe("resolveModel — session policy", () => {
57
+ it("policy=opus forces opus for every step, even mechanical ones", () => {
58
+ const tmp = makeTmpDir(); tmpDirs.push(tmp);
59
+ const state = fakeState({ worktreeRoot: tmp, policy: "opus" });
60
+ assert.equal(resolveModel(state, "build", "commit"), "opus");
61
+ assert.equal(resolveModel(state, "deploy", "monitor"), "opus");
62
+ assert.equal(resolveModel(state, "plan", "investigate"), "opus");
63
+ });
64
+
65
+ it("policy=haiku forces haiku everywhere", () => {
66
+ const tmp = makeTmpDir(); tmpDirs.push(tmp);
67
+ const state = fakeState({ worktreeRoot: tmp, policy: "haiku" });
68
+ assert.equal(resolveModel(state, "plan", "investigate"), "haiku");
69
+ assert.equal(resolveModel(state, "review", "security"), "haiku");
70
+ });
71
+
72
+ it("policy=inherit returns undefined so no model is passed", () => {
73
+ const tmp = makeTmpDir(); tmpDirs.push(tmp);
74
+ const state = fakeState({ worktreeRoot: tmp, policy: "inherit" });
75
+ assert.equal(resolveModel(state, "plan", "investigate"), undefined);
76
+ assert.equal(resolveModel(state, "build", "core"), undefined);
77
+ assert.equal(resolveModel(state, "deploy", "merge"), undefined);
78
+ });
79
+
80
+ it("policy=auto is equivalent to omitting it", () => {
81
+ const tmp = makeTmpDir(); tmpDirs.push(tmp);
82
+ const autoState = fakeState({ worktreeRoot: tmp, policy: "auto" });
83
+ const unsetState = fakeState({ worktreeRoot: tmp });
84
+ assert.equal(
85
+ resolveModel(autoState, "plan", "investigate"),
86
+ resolveModel(unsetState, "plan", "investigate")
87
+ );
88
+ });
89
+ });
90
+
91
+ describe("resolveModel — classification", () => {
92
+ it("small-change knocks plan/investigate down to haiku in auto-kit mode", () => {
93
+ const tmp = makeTmpDir(); tmpDirs.push(tmp);
94
+ const state = fakeState({
95
+ worktreeRoot: tmp,
96
+ classification: "small-change",
97
+ mode: "auto-kit",
98
+ });
99
+ assert.equal(resolveModel(state, "plan", "investigate"), "haiku");
100
+ });
101
+
102
+ it("bug-fix keeps plan/investigate on opus (not in its override map)", () => {
103
+ const tmp = makeTmpDir(); tmpDirs.push(tmp);
104
+ const state = fakeState({
105
+ worktreeRoot: tmp,
106
+ classification: "bug-fix",
107
+ mode: "auto-kit",
108
+ });
109
+ assert.equal(resolveModel(state, "plan", "investigate"), "opus");
110
+ assert.equal(resolveModel(state, "plan", "blueprint"), "sonnet");
111
+ });
112
+
113
+ it("refactor promotes review/performance to opus", () => {
114
+ const tmp = makeTmpDir(); tmpDirs.push(tmp);
115
+ const state = fakeState({
116
+ worktreeRoot: tmp,
117
+ classification: "refactor",
118
+ mode: "auto-kit",
119
+ });
120
+ assert.equal(resolveModel(state, "review", "performance"), "opus");
121
+ });
122
+
123
+ it("classification overrides are ignored in full-kit mode", () => {
124
+ const tmp = makeTmpDir(); tmpDirs.push(tmp);
125
+ const state = fakeState({
126
+ worktreeRoot: tmp,
127
+ classification: "small-change",
128
+ mode: "full-kit",
129
+ });
130
+ assert.equal(resolveModel(state, "plan", "investigate"), "opus");
131
+ });
132
+
133
+ it("session policy beats classification override", () => {
134
+ const tmp = makeTmpDir(); tmpDirs.push(tmp);
135
+ const state = fakeState({
136
+ worktreeRoot: tmp,
137
+ classification: "small-change",
138
+ mode: "auto-kit",
139
+ policy: "opus",
140
+ });
141
+ assert.equal(resolveModel(state, "plan", "investigate"), "opus");
142
+ });
143
+ });
144
+
145
+ describe("resolveModel — workspace JSON override", () => {
146
+ it("workspace model-config.json beats session policy", () => {
147
+ const tmp = makeTmpDir(); tmpDirs.push(tmp);
148
+ fs.writeFileSync(
149
+ path.join(tmp, ".work-kit", "model-config.json"),
150
+ JSON.stringify({ "build/commit": "sonnet" })
151
+ );
152
+ const state = fakeState({ worktreeRoot: tmp, policy: "opus" });
153
+ assert.equal(resolveModel(state, "build", "commit"), "sonnet");
154
+ // Other steps still forced to opus by the policy
155
+ assert.equal(resolveModel(state, "plan", "investigate"), "opus");
156
+ });
157
+
158
+ it("workspace JSON beats step default", () => {
159
+ const tmp = makeTmpDir(); tmpDirs.push(tmp);
160
+ fs.writeFileSync(
161
+ path.join(tmp, ".work-kit", "model-config.json"),
162
+ JSON.stringify({ "plan/investigate": "haiku" })
163
+ );
164
+ const state = fakeState({ worktreeRoot: tmp });
165
+ assert.equal(resolveModel(state, "plan", "investigate"), "haiku");
166
+ });
167
+
168
+ it("malformed JSON falls back silently to defaults", () => {
169
+ const tmp = makeTmpDir(); tmpDirs.push(tmp);
170
+ fs.writeFileSync(
171
+ path.join(tmp, ".work-kit", "model-config.json"),
172
+ "{not json"
173
+ );
174
+ const state = fakeState({ worktreeRoot: tmp });
175
+ assert.equal(resolveModel(state, "plan", "investigate"), "opus");
176
+ });
177
+
178
+ it("invalid tier values in JSON are ignored", () => {
179
+ const tmp = makeTmpDir(); tmpDirs.push(tmp);
180
+ fs.writeFileSync(
181
+ path.join(tmp, ".work-kit", "model-config.json"),
182
+ JSON.stringify({ "plan/investigate": "turbo", "build/core": "opus" })
183
+ );
184
+ const state = fakeState({ worktreeRoot: tmp });
185
+ // Bad value ignored → falls back to step default
186
+ assert.equal(resolveModel(state, "plan", "investigate"), "opus");
187
+ // Good value applied
188
+ assert.equal(resolveModel(state, "build", "core"), "opus");
189
+ });
190
+ });
@@ -0,0 +1,208 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+ import {
5
+ ModelTier,
6
+ PhaseName,
7
+ WorkKitState,
8
+ Classification,
9
+ isModelTier,
10
+ } from "../state/schema.js";
11
+ import { STATE_DIR } from "../state/store.js";
12
+
13
+ /**
14
+ * Per-phase/step model routing.
15
+ *
16
+ * Resolution order (highest precedence first):
17
+ * 1. Workspace override .work-kit/model-config.json (per-session, per-step map)
18
+ * 2. User global override ~/.claude/work-kit/models.json (per-user, all projects)
19
+ * 3. Session model policy state.modelPolicy (set at init via skill flag)
20
+ * 4. Classification BY_CLASSIFICATION (auto-kit only)
21
+ * 5. Step default BY_STEP
22
+ * 6. Phase default BY_PHASE
23
+ * 7. Hard default "sonnet"
24
+ *
25
+ * When state.modelPolicy is "inherit" (or layered overrides have not set a
26
+ * value), resolveModel() returns `undefined` so the orchestrator skill omits
27
+ * the `model` parameter on the Agent tool — identical to pre-routing behavior.
28
+ */
29
+
30
+ const HARD_DEFAULT: ModelTier = "sonnet";
31
+
32
+ // ── Phase defaults ──────────────────────────────────────────────────
33
+
34
+ export const BY_PHASE: Record<PhaseName, ModelTier> = {
35
+ plan: "sonnet",
36
+ build: "sonnet",
37
+ test: "sonnet",
38
+ review: "sonnet",
39
+ deploy: "haiku",
40
+ "wrap-up": "sonnet",
41
+ };
42
+
43
+ // ── Step-level overrides (phase/step keys) ──────────────────────────
44
+
45
+ export const BY_STEP: Record<string, ModelTier> = {
46
+ // Plan — research/design-heavy steps benefit from opus
47
+ "plan/clarify": "sonnet",
48
+ "plan/investigate": "opus",
49
+ "plan/sketch": "sonnet",
50
+ "plan/scope": "sonnet",
51
+ "plan/ux-flow": "sonnet",
52
+ "plan/architecture": "opus",
53
+ "plan/blueprint": "opus",
54
+ "plan/audit": "opus",
55
+
56
+ // Build — mechanical steps drop to haiku, implementation stays sonnet
57
+ "build/setup": "haiku",
58
+ "build/migration": "sonnet",
59
+ "build/red": "sonnet",
60
+ "build/core": "sonnet",
61
+ "build/ui": "sonnet",
62
+ "build/refactor": "sonnet",
63
+ "build/integration": "sonnet",
64
+ "build/commit": "haiku",
65
+
66
+ // Test — verify is mechanical, e2e/validate need judgment
67
+ "test/verify": "haiku",
68
+ "test/e2e": "sonnet",
69
+ "test/validate": "sonnet",
70
+
71
+ // Review — security & compliance get opus; rest sonnet
72
+ "review/self-review": "sonnet",
73
+ "review/security": "opus",
74
+ "review/performance": "sonnet",
75
+ "review/compliance": "opus",
76
+ "review/handoff": "sonnet",
77
+
78
+ // Deploy — mostly mechanical
79
+ "deploy/merge": "haiku",
80
+ "deploy/monitor": "haiku",
81
+ "deploy/remediate": "sonnet",
82
+
83
+ // Wrap-up — synthesis
84
+ "wrap-up/summary": "sonnet",
85
+ };
86
+
87
+ // ── Classification overrides (auto-kit only) ────────────────────────
88
+
89
+ export const BY_CLASSIFICATION: Record<Classification, Partial<Record<string, ModelTier>>> = {
90
+ "small-change": {
91
+ // Trivial work: knock plan and reviews down a tier
92
+ "plan/clarify": "haiku",
93
+ "plan/investigate": "haiku",
94
+ "plan/sketch": "haiku",
95
+ "plan/scope": "haiku",
96
+ "plan/ux-flow": "haiku",
97
+ "plan/architecture": "haiku",
98
+ "plan/blueprint": "haiku",
99
+ "plan/audit": "haiku",
100
+ "review/security": "sonnet",
101
+ "review/compliance": "sonnet",
102
+ },
103
+ "bug-fix": {
104
+ // Bug fixes still need opus for investigate; rest can relax
105
+ "plan/clarify": "sonnet",
106
+ "plan/sketch": "sonnet",
107
+ "plan/scope": "sonnet",
108
+ "plan/architecture": "sonnet",
109
+ "plan/blueprint": "sonnet",
110
+ "plan/audit": "sonnet",
111
+ },
112
+ refactor: {
113
+ // Perf review matters most for refactors — promote it
114
+ "review/performance": "opus",
115
+ },
116
+ feature: {},
117
+ "large-feature": {},
118
+ };
119
+
120
+ // ── JSON override loading ───────────────────────────────────────────
121
+
122
+ type OverrideMap = Partial<Record<string, ModelTier>>;
123
+
124
+ interface LoadedOverrides {
125
+ workspace: OverrideMap;
126
+ userGlobal: OverrideMap;
127
+ }
128
+
129
+ /**
130
+ * Read+validate the optional JSON override files. Silently returns empty
131
+ * maps on any read/parse/validation error — overrides are strictly opt-in
132
+ * and must never block the workflow.
133
+ */
134
+ export function loadOverrides(worktreeRoot: string): LoadedOverrides {
135
+ return {
136
+ workspace: readJsonMap(path.join(worktreeRoot, STATE_DIR, "model-config.json")),
137
+ userGlobal: readJsonMap(path.join(os.homedir(), ".claude", "work-kit", "models.json")),
138
+ };
139
+ }
140
+
141
+ function readJsonMap(filePath: string): OverrideMap {
142
+ try {
143
+ if (!fs.existsSync(filePath)) return {};
144
+ const raw = fs.readFileSync(filePath, "utf-8");
145
+ const parsed = JSON.parse(raw);
146
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
147
+ const out: OverrideMap = {};
148
+ for (const [key, value] of Object.entries(parsed)) {
149
+ if (typeof value === "string" && isModelTier(value)) {
150
+ out[key] = value;
151
+ }
152
+ }
153
+ return out;
154
+ } catch {
155
+ return {};
156
+ }
157
+ }
158
+
159
+ // ── Resolution ──────────────────────────────────────────────────────
160
+
161
+ /**
162
+ * Resolve the model tier for a given phase/step in a given session.
163
+ *
164
+ * Returns `undefined` when the session policy is "inherit" (or when an
165
+ * override file explicitly maps to inherit via a missing entry — this does
166
+ * not happen today but stays consistent with "no opinion" semantics).
167
+ *
168
+ * Callers treat `undefined` as "do not pass a model parameter to the Agent
169
+ * tool" — identical to pre-routing behavior.
170
+ */
171
+ export function resolveModel(
172
+ state: Pick<WorkKitState, "modelPolicy" | "classification" | "mode"> & { metadata: { worktreeRoot: string } },
173
+ phase: PhaseName,
174
+ step: string
175
+ ): ModelTier | undefined {
176
+ const key = `${phase}/${step}`;
177
+ const policy = state.modelPolicy ?? "auto";
178
+
179
+ // Policy "inherit" short-circuits everything: no model override at all.
180
+ if (policy === "inherit") {
181
+ return undefined;
182
+ }
183
+
184
+ // Layers 1 & 2: JSON overrides win over everything else.
185
+ const overrides = loadOverrides(state.metadata.worktreeRoot);
186
+ if (overrides.workspace[key]) return overrides.workspace[key];
187
+ if (overrides.userGlobal[key]) return overrides.userGlobal[key];
188
+
189
+ // Layer 3: Forced policy (opus/sonnet/haiku) beats all routing.
190
+ if (policy !== "auto") {
191
+ return policy;
192
+ }
193
+
194
+ // Layer 4: Classification override (auto-kit only).
195
+ if (state.mode === "auto-kit" && state.classification) {
196
+ const classOverride = BY_CLASSIFICATION[state.classification][key];
197
+ if (classOverride) return classOverride;
198
+ }
199
+
200
+ // Layer 5: Step default.
201
+ if (BY_STEP[key]) return BY_STEP[key];
202
+
203
+ // Layer 6: Phase default.
204
+ if (BY_PHASE[phase]) return BY_PHASE[phase];
205
+
206
+ // Layer 7: Hard default.
207
+ return HARD_DEFAULT;
208
+ }
@@ -0,0 +1,127 @@
1
+ import { describe, it, afterEach } from "node:test";
2
+ import * as assert from "node:assert/strict";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import * as os from "node:os";
6
+ import { randomUUID } from "node:crypto";
7
+ import { loadProjectConfig } from "./project-config.js";
8
+ import { resolveParallelGroups, DEFAULT_PARALLEL_GROUPS } from "../workflow/parallel.js";
9
+ import { PROJECT_CONFIG_FILE } from "./constants.js";
10
+
11
+ function makeTmpDir(): string {
12
+ const dir = path.join(os.tmpdir(), `wk-config-${randomUUID()}`);
13
+ fs.mkdirSync(dir, { recursive: true });
14
+ return dir;
15
+ }
16
+
17
+ let tmpDirs: string[] = [];
18
+
19
+ afterEach(() => {
20
+ for (const dir of tmpDirs) fs.rmSync(dir, { recursive: true, force: true });
21
+ tmpDirs = [];
22
+ });
23
+
24
+ describe("loadProjectConfig", () => {
25
+ it("returns empty config when file is missing", () => {
26
+ const tmp = makeTmpDir();
27
+ tmpDirs.push(tmp);
28
+ const config = loadProjectConfig(tmp);
29
+ assert.deepStrictEqual(config, {});
30
+ });
31
+
32
+ it("loads valid defaults", () => {
33
+ const tmp = makeTmpDir();
34
+ tmpDirs.push(tmp);
35
+ fs.writeFileSync(
36
+ path.join(tmp, PROJECT_CONFIG_FILE),
37
+ JSON.stringify({ defaults: { mode: "auto", classification: "feature", gated: true } }),
38
+ );
39
+ const config = loadProjectConfig(tmp);
40
+ assert.equal(config.defaults?.mode, "auto");
41
+ assert.equal(config.defaults?.classification, "feature");
42
+ assert.equal(config.defaults?.gated, true);
43
+ });
44
+
45
+ it("ignores invalid mode and classification", () => {
46
+ const tmp = makeTmpDir();
47
+ tmpDirs.push(tmp);
48
+ fs.writeFileSync(
49
+ path.join(tmp, PROJECT_CONFIG_FILE),
50
+ JSON.stringify({ defaults: { mode: "wat", classification: "nope" } }),
51
+ );
52
+ const config = loadProjectConfig(tmp);
53
+ assert.equal(config.defaults?.mode, undefined);
54
+ assert.equal(config.defaults?.classification, undefined);
55
+ });
56
+
57
+ it("loads parallel group overrides", () => {
58
+ const tmp = makeTmpDir();
59
+ tmpDirs.push(tmp);
60
+ fs.writeFileSync(
61
+ path.join(tmp, PROJECT_CONFIG_FILE),
62
+ JSON.stringify({
63
+ parallel: {
64
+ test: { parallel: ["verify"], thenSequential: "validate" },
65
+ },
66
+ }),
67
+ );
68
+ const config = loadProjectConfig(tmp);
69
+ assert.deepStrictEqual(config.parallel?.test, { parallel: ["verify"], thenSequential: "validate" });
70
+ });
71
+
72
+ it("filters invalid step names from parallel overrides", () => {
73
+ const tmp = makeTmpDir();
74
+ tmpDirs.push(tmp);
75
+ fs.writeFileSync(
76
+ path.join(tmp, PROJECT_CONFIG_FILE),
77
+ JSON.stringify({
78
+ parallel: { test: { parallel: ["verify", "made-up"] } },
79
+ }),
80
+ );
81
+ const config = loadProjectConfig(tmp);
82
+ assert.deepStrictEqual(config.parallel?.test?.parallel, ["verify"]);
83
+ });
84
+
85
+ it("validates workflow include/exclude refs", () => {
86
+ const tmp = makeTmpDir();
87
+ tmpDirs.push(tmp);
88
+ fs.writeFileSync(
89
+ path.join(tmp, PROJECT_CONFIG_FILE),
90
+ JSON.stringify({
91
+ workflow: { include: ["review/security", "bogus/step"], exclude: ["test/e2e"] },
92
+ }),
93
+ );
94
+ const config = loadProjectConfig(tmp);
95
+ assert.deepStrictEqual(config.workflow?.include, ["review/security"]);
96
+ assert.deepStrictEqual(config.workflow?.exclude, ["test/e2e"]);
97
+ });
98
+
99
+ it("survives invalid JSON", () => {
100
+ const tmp = makeTmpDir();
101
+ tmpDirs.push(tmp);
102
+ fs.writeFileSync(path.join(tmp, PROJECT_CONFIG_FILE), "not json");
103
+ const config = loadProjectConfig(tmp);
104
+ assert.deepStrictEqual(config, {});
105
+ });
106
+ });
107
+
108
+ describe("resolveParallelGroups", () => {
109
+ it("returns defaults with no project root", () => {
110
+ assert.deepStrictEqual(resolveParallelGroups(), DEFAULT_PARALLEL_GROUPS);
111
+ });
112
+
113
+ it("merges project overrides over defaults", () => {
114
+ const tmp = makeTmpDir();
115
+ tmpDirs.push(tmp);
116
+ fs.writeFileSync(
117
+ path.join(tmp, PROJECT_CONFIG_FILE),
118
+ JSON.stringify({
119
+ parallel: { test: { parallel: ["verify"], thenSequential: "validate" } },
120
+ }),
121
+ );
122
+ const groups = resolveParallelGroups(tmp);
123
+ assert.deepStrictEqual(groups.test, { parallel: ["verify"], thenSequential: "validate" });
124
+ // review (not overridden) keeps default
125
+ assert.deepStrictEqual(groups.review, DEFAULT_PARALLEL_GROUPS.review);
126
+ });
127
+ });
@@ -0,0 +1,106 @@
1
+ import * as path from "node:path";
2
+ import {
3
+ PHASE_NAMES,
4
+ STEPS_BY_PHASE,
5
+ isClassification,
6
+ type PhaseName,
7
+ type Classification,
8
+ } from "../state/schema.js";
9
+ import { readJsonFile } from "../utils/json.js";
10
+ import { PROJECT_CONFIG_FILE } from "./constants.js";
11
+
12
+ /**
13
+ * Optional `.work-kit-config.json` lives at the main repo root.
14
+ *
15
+ * Example:
16
+ * {
17
+ * "defaults": { "mode": "auto", "classification": "feature", "gated": false },
18
+ * "parallel": {
19
+ * "review": { "parallel": ["self-review", "security"], "thenSequential": "handoff" }
20
+ * },
21
+ * "workflow": {
22
+ * "include": ["review/security"],
23
+ * "exclude": ["review/performance"]
24
+ * }
25
+ * }
26
+ */
27
+ export interface ProjectParallelGroup {
28
+ parallel: string[];
29
+ thenSequential?: string;
30
+ }
31
+
32
+ export interface ProjectConfig {
33
+ defaults?: {
34
+ mode?: "full" | "auto";
35
+ classification?: Classification;
36
+ gated?: boolean;
37
+ };
38
+ /** Override or extend the per-phase parallel groups. */
39
+ parallel?: Partial<Record<PhaseName, ProjectParallelGroup>>;
40
+ /** Force-include or force-exclude specific steps (phase/step). */
41
+ workflow?: {
42
+ include?: string[];
43
+ exclude?: string[];
44
+ };
45
+ }
46
+
47
+ const EMPTY_CONFIG: ProjectConfig = {};
48
+
49
+ /** Load and validate the project config. Returns empty config when missing/invalid. */
50
+ export function loadProjectConfig(mainRepoRoot: string): ProjectConfig {
51
+ const parsed = readJsonFile<unknown>(path.join(mainRepoRoot, PROJECT_CONFIG_FILE));
52
+ if (!parsed) return EMPTY_CONFIG;
53
+ return validateConfig(parsed);
54
+ }
55
+
56
+ function validateConfig(raw: any): ProjectConfig {
57
+ const out: ProjectConfig = {};
58
+
59
+ if (raw && typeof raw === "object") {
60
+ if (raw.defaults && typeof raw.defaults === "object") {
61
+ const d = raw.defaults;
62
+ out.defaults = {};
63
+ if (d.mode === "full" || d.mode === "auto") out.defaults.mode = d.mode;
64
+ if (typeof d.classification === "string" && isClassification(d.classification)) {
65
+ out.defaults.classification = d.classification;
66
+ }
67
+ if (typeof d.gated === "boolean") out.defaults.gated = d.gated;
68
+ }
69
+
70
+ if (raw.parallel && typeof raw.parallel === "object") {
71
+ out.parallel = {};
72
+ for (const [phase, group] of Object.entries(raw.parallel)) {
73
+ if (!(PHASE_NAMES as readonly string[]).includes(phase)) continue;
74
+ const g = group as any;
75
+ if (!g || !Array.isArray(g.parallel)) continue;
76
+ const validSteps = STEPS_BY_PHASE[phase as PhaseName];
77
+ const parallel = g.parallel.filter((s: any) => typeof s === "string" && validSteps.includes(s));
78
+ if (parallel.length === 0) continue;
79
+ const entry: ProjectParallelGroup = { parallel };
80
+ if (typeof g.thenSequential === "string" && validSteps.includes(g.thenSequential)) {
81
+ entry.thenSequential = g.thenSequential;
82
+ }
83
+ out.parallel[phase as PhaseName] = entry;
84
+ }
85
+ }
86
+
87
+ if (raw.workflow && typeof raw.workflow === "object") {
88
+ out.workflow = {};
89
+ for (const key of ["include", "exclude"] as const) {
90
+ if (Array.isArray(raw.workflow[key])) {
91
+ out.workflow[key] = raw.workflow[key].filter((s: any) => isValidStepRef(s));
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ return out;
98
+ }
99
+
100
+ function isValidStepRef(ref: any): boolean {
101
+ if (typeof ref !== "string") return false;
102
+ const [phase, step] = ref.split("/");
103
+ if (!phase || !step) return false;
104
+ if (!(PHASE_NAMES as readonly string[]).includes(phase)) return false;
105
+ return STEPS_BY_PHASE[phase as PhaseName].includes(step);
106
+ }