work-kit-cli 0.3.0 → 0.4.1
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 +11 -0
- package/cli/src/commands/bootstrap.test.ts +40 -0
- package/cli/src/commands/bootstrap.ts +38 -0
- package/cli/src/commands/extract.ts +207 -0
- package/cli/src/commands/init.test.ts +50 -0
- package/cli/src/commands/init.ts +32 -5
- package/cli/src/commands/learn.test.ts +244 -0
- package/cli/src/commands/learn.ts +104 -0
- package/cli/src/commands/next.ts +30 -10
- package/cli/src/commands/observe.ts +16 -21
- package/cli/src/commands/pause-resume.test.ts +2 -2
- package/cli/src/commands/resume.ts +95 -7
- package/cli/src/commands/setup.ts +160 -0
- package/cli/src/commands/status.ts +2 -0
- package/cli/src/commands/workflow.ts +19 -9
- package/cli/src/config/constants.ts +10 -0
- package/cli/src/config/model-routing.test.ts +190 -0
- package/cli/src/config/model-routing.ts +208 -0
- package/cli/src/config/workflow.ts +5 -5
- package/cli/src/index.ts +70 -5
- package/cli/src/observer/data.ts +132 -9
- package/cli/src/observer/renderer.ts +34 -36
- package/cli/src/observer/watcher.ts +28 -16
- package/cli/src/state/schema.ts +50 -3
- package/cli/src/state/store.ts +39 -4
- package/cli/src/utils/fs.ts +13 -0
- package/cli/src/utils/knowledge.ts +471 -0
- package/package.json +1 -1
- package/skills/auto-kit/SKILL.md +27 -10
- package/skills/full-kit/SKILL.md +25 -8
- package/skills/resume-kit/SKILL.md +44 -8
- package/skills/wk-bootstrap/SKILL.md +6 -0
- package/skills/wk-build/SKILL.md +3 -2
- package/skills/wk-deploy/SKILL.md +1 -0
- package/skills/wk-plan/SKILL.md +3 -2
- package/skills/wk-review/SKILL.md +1 -0
- package/skills/wk-test/SKILL.md +1 -0
- package/skills/wk-test/steps/e2e.md +15 -12
- package/skills/wk-wrap-up/SKILL.md +15 -2
- package/skills/wk-wrap-up/steps/knowledge.md +76 -0
package/README.md
CHANGED
|
@@ -55,9 +55,20 @@ Best for: bug fixes, small changes, refactors, well-understood tasks.
|
|
|
55
55
|
| `validate` | Validate state integrity and phase prerequisites |
|
|
56
56
|
| `loopback` | Route back to a previous stage (max 2 per route) |
|
|
57
57
|
| `workflow` | Display the full workflow plan |
|
|
58
|
+
| `pause` | Pause the active session (state preserved on disk) |
|
|
59
|
+
| `resume [--slug <slug>]` | Without `--slug`: list resumable sessions in this repo. With `--slug`: resume the named session |
|
|
60
|
+
| `observe [--all]` | Live TUI dashboard of active/paused/completed sessions. `--all` watches every work-kit project on the system |
|
|
58
61
|
| `doctor` | Run environment health checks (supports `--json`) |
|
|
59
62
|
| `setup` | Install work-kit skills into a Claude Code project |
|
|
60
63
|
|
|
64
|
+
### Picking up where you left off
|
|
65
|
+
|
|
66
|
+
`work-kit resume` (or `/resume-kit` in Claude Code) scans every worktree of the current repo for `.work-kit/tracker.json` files in `paused` or `in-progress` state and lets you pick one. It works from the main repo root — no need to `cd` into a worktree first. In-progress sessions are listed too, so a terminal you closed without pausing can be recovered: just look for the row with a stale `lastUpdatedAgoMs`.
|
|
67
|
+
|
|
68
|
+
### Watching multiple projects
|
|
69
|
+
|
|
70
|
+
`work-kit observe --all` discovers every work-kit-enabled repo from your `~/.claude/projects/` history and watches them all in one dashboard. Each row shows the project name, work item slug, mode, type, current state, and worktree.
|
|
71
|
+
|
|
61
72
|
## Phases
|
|
62
73
|
|
|
63
74
|
| Phase | Steps | Agent |
|
|
@@ -6,6 +6,7 @@ import * as os from "node:os";
|
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import { bootstrapCommand } from "./bootstrap.js";
|
|
8
8
|
import { initCommand } from "./init.js";
|
|
9
|
+
import { learnCommand } from "./learn.js";
|
|
9
10
|
|
|
10
11
|
function makeTmpDir(): string {
|
|
11
12
|
const dir = path.join(os.tmpdir(), `work-kit-test-${randomUUID()}`);
|
|
@@ -94,6 +95,45 @@ describe("bootstrapCommand", () => {
|
|
|
94
95
|
assert.ok(result.nextAction?.includes("complete"));
|
|
95
96
|
});
|
|
96
97
|
|
|
98
|
+
it("injects knowledge field when knowledge files exist", () => {
|
|
99
|
+
const tmp = makeTmpDir();
|
|
100
|
+
tmpDirs.push(tmp);
|
|
101
|
+
|
|
102
|
+
initCommand({
|
|
103
|
+
mode: "full",
|
|
104
|
+
description: "Knowledge test",
|
|
105
|
+
worktreeRoot: tmp,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Write entries to lessons, conventions, risks, and workflow
|
|
109
|
+
learnCommand({ type: "lesson", text: "A useful lesson", worktreeRoot: tmp });
|
|
110
|
+
learnCommand({ type: "convention", text: "A coding convention", worktreeRoot: tmp });
|
|
111
|
+
learnCommand({ type: "risk", text: "A fragile area", worktreeRoot: tmp });
|
|
112
|
+
learnCommand({ type: "workflow", text: "Workflow feedback", worktreeRoot: tmp });
|
|
113
|
+
|
|
114
|
+
const result = bootstrapCommand(tmp);
|
|
115
|
+
assert.ok(result.knowledge, "knowledge field should be present");
|
|
116
|
+
assert.ok(result.knowledge?.lessons?.includes("A useful lesson"));
|
|
117
|
+
assert.ok(result.knowledge?.conventions?.includes("A coding convention"));
|
|
118
|
+
assert.ok(result.knowledge?.risks?.includes("A fragile area"));
|
|
119
|
+
// workflow.md is intentionally NOT injected
|
|
120
|
+
assert.equal((result.knowledge as any).workflow, undefined);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("does not inject knowledge field when no knowledge files exist", () => {
|
|
124
|
+
const tmp = makeTmpDir();
|
|
125
|
+
tmpDirs.push(tmp);
|
|
126
|
+
|
|
127
|
+
initCommand({
|
|
128
|
+
mode: "full",
|
|
129
|
+
description: "No knowledge test",
|
|
130
|
+
worktreeRoot: tmp,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const result = bootstrapCommand(tmp);
|
|
134
|
+
assert.equal(result.knowledge, undefined);
|
|
135
|
+
});
|
|
136
|
+
|
|
97
137
|
it("reports failed state", () => {
|
|
98
138
|
const tmp = makeTmpDir();
|
|
99
139
|
tmpDirs.push(tmp);
|
|
@@ -2,6 +2,13 @@ import fs from "node:fs";
|
|
|
2
2
|
import { findWorktreeRoot, readState, writeState, statePath } from "../state/store.js";
|
|
3
3
|
import { unpause } from "../state/helpers.js";
|
|
4
4
|
import { CLI_BINARY, STALE_THRESHOLD_MS } from "../config/constants.js";
|
|
5
|
+
import { fileForType, readKnowledgeFile } from "../utils/knowledge.js";
|
|
6
|
+
|
|
7
|
+
export interface BootstrapKnowledge {
|
|
8
|
+
lessons?: string;
|
|
9
|
+
conventions?: string;
|
|
10
|
+
risks?: string;
|
|
11
|
+
}
|
|
5
12
|
|
|
6
13
|
export interface BootstrapResult {
|
|
7
14
|
active: boolean;
|
|
@@ -16,6 +23,13 @@ export interface BootstrapResult {
|
|
|
16
23
|
resumeReason?: string;
|
|
17
24
|
nextAction?: string;
|
|
18
25
|
recovery?: string | null;
|
|
26
|
+
/**
|
|
27
|
+
* Project-level knowledge files (lessons/conventions/risks) read from
|
|
28
|
+
* <mainRepoRoot>/.work-kit-knowledge/. Capped at 200 lines per file.
|
|
29
|
+
* workflow.md is intentionally excluded — it's a write-only artifact for
|
|
30
|
+
* human curators, not session context.
|
|
31
|
+
*/
|
|
32
|
+
knowledge?: BootstrapKnowledge;
|
|
19
33
|
}
|
|
20
34
|
|
|
21
35
|
export interface BootstrapOptions {
|
|
@@ -75,6 +89,29 @@ export function bootstrapCommand(startDir?: string, options: BootstrapOptions =
|
|
|
75
89
|
nextAction = `Continue ${state.currentPhase ?? "next phase"}${state.currentStep ? "/" + state.currentStep : ""}. Run \`${CLI_BINARY} next\` to get the agent prompt.`;
|
|
76
90
|
}
|
|
77
91
|
|
|
92
|
+
// Load project-level knowledge files (best effort, never breaks bootstrap).
|
|
93
|
+
// workflow.md is intentionally excluded — it's a write-only artifact for
|
|
94
|
+
// human curators, not session context.
|
|
95
|
+
let knowledge: BootstrapKnowledge | undefined;
|
|
96
|
+
try {
|
|
97
|
+
const mainRepoRoot = state.metadata?.mainRepoRoot;
|
|
98
|
+
if (mainRepoRoot) {
|
|
99
|
+
const lessons = readKnowledgeFile(mainRepoRoot, fileForType("lesson"));
|
|
100
|
+
const conventions = readKnowledgeFile(mainRepoRoot, fileForType("convention"));
|
|
101
|
+
const risks = readKnowledgeFile(mainRepoRoot, fileForType("risk"));
|
|
102
|
+
if (lessons || conventions || risks) {
|
|
103
|
+
knowledge = {
|
|
104
|
+
...(lessons && { lessons }),
|
|
105
|
+
...(conventions && { conventions }),
|
|
106
|
+
...(risks && { risks }),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch (err: any) {
|
|
111
|
+
// Non-fatal: log to stderr but don't break bootstrap
|
|
112
|
+
process.stderr.write(`work-kit: failed to load knowledge files: ${err.message}\n`);
|
|
113
|
+
}
|
|
114
|
+
|
|
78
115
|
return {
|
|
79
116
|
active: true,
|
|
80
117
|
slug: state.slug,
|
|
@@ -87,5 +124,6 @@ export function bootstrapCommand(startDir?: string, options: BootstrapOptions =
|
|
|
87
124
|
...(resumed && { resumed: true, resumeReason }),
|
|
88
125
|
nextAction,
|
|
89
126
|
recovery,
|
|
127
|
+
...(knowledge && { knowledge }),
|
|
90
128
|
};
|
|
91
129
|
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { findWorktreeRoot, readState, readStateMd, resolveMainRepoRoot, gitHeadSha } from "../state/store.js";
|
|
2
|
+
import {
|
|
3
|
+
appendAutoEntries,
|
|
4
|
+
ensureKnowledgeDir,
|
|
5
|
+
fileForType,
|
|
6
|
+
isKnowledgeType,
|
|
7
|
+
KNOWLEDGE_TYPES,
|
|
8
|
+
redact,
|
|
9
|
+
type KnowledgeEntry,
|
|
10
|
+
type KnowledgeType,
|
|
11
|
+
} from "../utils/knowledge.js";
|
|
12
|
+
import { skillFilePath } from "../config/workflow.js";
|
|
13
|
+
import type { PhaseName, WorkKitState } from "../state/schema.js";
|
|
14
|
+
|
|
15
|
+
export interface ExtractOptions {
|
|
16
|
+
worktreeRoot?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ExtractResult {
|
|
20
|
+
action: "extracted" | "error";
|
|
21
|
+
written: number;
|
|
22
|
+
duplicates: number;
|
|
23
|
+
byType: Record<KnowledgeType, number>;
|
|
24
|
+
message?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface RawEntry {
|
|
28
|
+
type: KnowledgeType;
|
|
29
|
+
text: string;
|
|
30
|
+
phase?: string;
|
|
31
|
+
step?: string;
|
|
32
|
+
source: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function emptyByType(): Record<KnowledgeType, number> {
|
|
36
|
+
const out = {} as Record<KnowledgeType, number>;
|
|
37
|
+
for (const t of KNOWLEDGE_TYPES) out[t] = 0;
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Single-pass state.md parser ─────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const OBSERVATION_RE = /^-\s*\[([a-z]+)(?::([a-z0-9-]+\/[a-z0-9-]+))?\]\s*(.+)$/i;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Walk state.md once and emit raw entries from the three sections we know:
|
|
47
|
+
* Observations (typed bullets), Decisions (any bullet → convention),
|
|
48
|
+
* Deviations (any bullet → workflow with [deviation] prefix).
|
|
49
|
+
*/
|
|
50
|
+
function parseStateMd(stateMd: string): RawEntry[] {
|
|
51
|
+
const out: RawEntry[] = [];
|
|
52
|
+
if (!stateMd) return out;
|
|
53
|
+
|
|
54
|
+
// Only `## Observations` is auto-harvested. `## Decisions` and `## Deviations`
|
|
55
|
+
// are agent scratch space during normal phase work — they routinely contain
|
|
56
|
+
// test plans, acceptance-criteria checklists, and self-review dumps. Auto-
|
|
57
|
+
// routing them floods workflow.md with noise. Agents opt into harvesting by
|
|
58
|
+
// writing typed bullets (`- [lesson|convention|risk|workflow] text`) under
|
|
59
|
+
// `## Observations`.
|
|
60
|
+
let inObservations = false;
|
|
61
|
+
|
|
62
|
+
for (const rawLine of stateMd.split("\n")) {
|
|
63
|
+
const trimmed = rawLine.trim();
|
|
64
|
+
|
|
65
|
+
if (trimmed.startsWith("## ")) {
|
|
66
|
+
inObservations = trimmed.slice(3).trim().toLowerCase() === "observations";
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!inObservations) continue;
|
|
71
|
+
if (!trimmed.startsWith("-") || trimmed.startsWith("<!--")) continue;
|
|
72
|
+
|
|
73
|
+
const m = trimmed.match(OBSERVATION_RE);
|
|
74
|
+
if (!m) continue;
|
|
75
|
+
const tag = m[1].toLowerCase();
|
|
76
|
+
if (!isKnowledgeType(tag)) continue;
|
|
77
|
+
const phaseStep = m[2];
|
|
78
|
+
const text = m[3].trim();
|
|
79
|
+
if (text.length === 0) continue;
|
|
80
|
+
const entry: RawEntry = { type: tag, text, source: "auto-state-md" };
|
|
81
|
+
if (phaseStep) {
|
|
82
|
+
const [p, s] = phaseStep.split("/");
|
|
83
|
+
entry.phase = p;
|
|
84
|
+
entry.step = s;
|
|
85
|
+
}
|
|
86
|
+
out.push(entry);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Tracker.json extraction ─────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
function fromLoopbacks(state: WorkKitState): RawEntry[] {
|
|
95
|
+
return (state.loopbacks ?? []).map((lb) => ({
|
|
96
|
+
type: "workflow" as const,
|
|
97
|
+
text: `[loopback] ${lb.from.phase}/${lb.from.step} → ${lb.to.phase}/${lb.to.step}: ${lb.reason}`,
|
|
98
|
+
phase: lb.from.phase,
|
|
99
|
+
step: lb.from.step,
|
|
100
|
+
source: "auto-tracker",
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function fromSkippedAndFailed(state: WorkKitState): RawEntry[] {
|
|
105
|
+
const out: RawEntry[] = [];
|
|
106
|
+
for (const [phaseName, phaseState] of Object.entries(state.phases)) {
|
|
107
|
+
for (const [stepName, stepState] of Object.entries(phaseState.steps)) {
|
|
108
|
+
if (stepState.status === "skipped") {
|
|
109
|
+
out.push({
|
|
110
|
+
type: "workflow",
|
|
111
|
+
text: `[skipped] ${phaseName}/${stepName} was skipped during this session.`,
|
|
112
|
+
phase: phaseName,
|
|
113
|
+
step: stepName,
|
|
114
|
+
source: "auto-tracker",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
if (stepState.outcome === "broken" || stepState.outcome === "fix_needed") {
|
|
118
|
+
out.push({
|
|
119
|
+
type: "workflow",
|
|
120
|
+
text: `[failure] ${phaseName}/${stepName} reported outcome=${stepState.outcome}.`,
|
|
121
|
+
phase: phaseName,
|
|
122
|
+
step: stepName,
|
|
123
|
+
source: "auto-tracker",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Main ────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
export function extractCommand(opts: ExtractOptions = {}): ExtractResult {
|
|
134
|
+
const root = opts.worktreeRoot || findWorktreeRoot();
|
|
135
|
+
if (!root) {
|
|
136
|
+
return {
|
|
137
|
+
action: "error",
|
|
138
|
+
written: 0,
|
|
139
|
+
duplicates: 0,
|
|
140
|
+
byType: emptyByType(),
|
|
141
|
+
message: "No work-kit session found. Run from inside a worktree.",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let state: WorkKitState;
|
|
146
|
+
try {
|
|
147
|
+
state = readState(root);
|
|
148
|
+
} catch (e: any) {
|
|
149
|
+
return {
|
|
150
|
+
action: "error",
|
|
151
|
+
written: 0,
|
|
152
|
+
duplicates: 0,
|
|
153
|
+
byType: emptyByType(),
|
|
154
|
+
message: `Could not read state: ${e.message}`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const mainRepoRoot = state.metadata?.mainRepoRoot ?? resolveMainRepoRoot(root);
|
|
159
|
+
ensureKnowledgeDir(mainRepoRoot);
|
|
160
|
+
|
|
161
|
+
const stateMd = readStateMd(root) ?? "";
|
|
162
|
+
|
|
163
|
+
const raw: RawEntry[] = [
|
|
164
|
+
...parseStateMd(stateMd),
|
|
165
|
+
...fromLoopbacks(state),
|
|
166
|
+
...fromSkippedAndFailed(state),
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
const ts = new Date().toISOString();
|
|
170
|
+
const sha = gitHeadSha(mainRepoRoot);
|
|
171
|
+
|
|
172
|
+
// Group entries by destination file for a single read-modify-write per file.
|
|
173
|
+
const grouped = new Map<string, KnowledgeEntry[]>();
|
|
174
|
+
for (const r of raw) {
|
|
175
|
+
const phase = r.phase ?? state.currentPhase ?? undefined;
|
|
176
|
+
const step = r.step ?? state.currentStep ?? undefined;
|
|
177
|
+
const skillPath = phase && step ? skillFilePath(phase as PhaseName, step) : undefined;
|
|
178
|
+
const { text } = redact(r.text);
|
|
179
|
+
|
|
180
|
+
const entry: KnowledgeEntry = {
|
|
181
|
+
ts,
|
|
182
|
+
sessionSlug: state.slug,
|
|
183
|
+
phase,
|
|
184
|
+
step,
|
|
185
|
+
skillPath,
|
|
186
|
+
gitSha: sha,
|
|
187
|
+
source: r.source,
|
|
188
|
+
text,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const file = fileForType(r.type);
|
|
192
|
+
const bucket = grouped.get(file);
|
|
193
|
+
if (bucket) bucket.push(entry);
|
|
194
|
+
else grouped.set(file, [entry]);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const result = appendAutoEntries(mainRepoRoot, grouped);
|
|
198
|
+
|
|
199
|
+
// Map per-file write counts back to per-type counts. Each KnowledgeType
|
|
200
|
+
// routes to exactly one file, so the lookup is unambiguous.
|
|
201
|
+
const byType = emptyByType();
|
|
202
|
+
for (const t of KNOWLEDGE_TYPES) {
|
|
203
|
+
byType[t] = result.perFile.get(fileForType(t))?.written ?? 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { action: "extracted", written: result.written, duplicates: result.duplicates, byType };
|
|
207
|
+
}
|
|
@@ -94,6 +94,56 @@ describe("initCommand", () => {
|
|
|
94
94
|
}
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
+
it("persists model policy when provided", () => {
|
|
98
|
+
const tmp = makeTmpDir();
|
|
99
|
+
tmpDirs.push(tmp);
|
|
100
|
+
|
|
101
|
+
initCommand({
|
|
102
|
+
mode: "full",
|
|
103
|
+
description: "Ship the avatar feature",
|
|
104
|
+
modelPolicy: "opus",
|
|
105
|
+
worktreeRoot: tmp,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const state = JSON.parse(
|
|
109
|
+
fs.readFileSync(path.join(tmp, ".work-kit", "tracker.json"), "utf-8")
|
|
110
|
+
);
|
|
111
|
+
assert.equal(state.modelPolicy, "opus");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("omits modelPolicy from state when defaulting to auto", () => {
|
|
115
|
+
const tmp = makeTmpDir();
|
|
116
|
+
tmpDirs.push(tmp);
|
|
117
|
+
|
|
118
|
+
initCommand({
|
|
119
|
+
mode: "full",
|
|
120
|
+
description: "Some default task",
|
|
121
|
+
worktreeRoot: tmp,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const state = JSON.parse(
|
|
125
|
+
fs.readFileSync(path.join(tmp, ".work-kit", "tracker.json"), "utf-8")
|
|
126
|
+
);
|
|
127
|
+
assert.equal(state.modelPolicy, undefined);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("rejects invalid model policy", () => {
|
|
131
|
+
const tmp = makeTmpDir();
|
|
132
|
+
tmpDirs.push(tmp);
|
|
133
|
+
|
|
134
|
+
const result = initCommand({
|
|
135
|
+
mode: "full",
|
|
136
|
+
description: "Task with bad policy",
|
|
137
|
+
modelPolicy: "turbo" as any,
|
|
138
|
+
worktreeRoot: tmp,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
assert.equal(result.action, "error");
|
|
142
|
+
if (result.action === "error") {
|
|
143
|
+
assert.ok(result.message.includes("model-policy"));
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
97
147
|
it("auto mode with classification succeeds", () => {
|
|
98
148
|
const tmp = makeTmpDir();
|
|
99
149
|
tmpDirs.push(tmp);
|
package/cli/src/commands/init.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { WorkKitState, PhaseState, PhaseName, PHASE_NAMES, STEPS_BY_PHASE, WorkflowStep, Classification, MODE_FULL, MODE_AUTO } from "../state/schema.js";
|
|
3
|
+
import { WorkKitState, PhaseState, PhaseName, PHASE_NAMES, STEPS_BY_PHASE, WorkflowStep, Classification, MODE_FULL, MODE_AUTO, ModelPolicy, isModelPolicy } from "../state/schema.js";
|
|
4
4
|
import { writeState, writeStateMd, stateExists, STATE_DIR, resolveMainRepoRoot } from "../state/store.js";
|
|
5
5
|
import { buildFullWorkflow, buildDefaultWorkflow, skillFilePath } from "../config/workflow.js";
|
|
6
6
|
import { BRANCH_PREFIX, CLI_BINARY } from "../config/constants.js";
|
|
7
7
|
import { loadProjectConfig } from "../config/project-config.js";
|
|
8
|
+
import { resolveModel } from "../config/model-routing.js";
|
|
8
9
|
import type { Action } from "../state/schema.js";
|
|
9
10
|
|
|
10
11
|
function toSlug(description: string): string {
|
|
@@ -85,6 +86,15 @@ ${description}
|
|
|
85
86
|
<!-- Append here whenever you choose between real alternatives -->
|
|
86
87
|
<!-- Format: **<context>**: chose <X> over <Y> — <why> -->
|
|
87
88
|
|
|
89
|
+
## Observations
|
|
90
|
+
<!-- Append typed bullets as you notice things worth preserving across sessions. -->
|
|
91
|
+
<!-- wrap-up/knowledge routes these to .work-kit-knowledge/. -->
|
|
92
|
+
<!-- Grammar: - [lesson|convention|risk|workflow] text (workflow tag may include :phase/step) -->
|
|
93
|
+
<!-- Examples: -->
|
|
94
|
+
<!-- - [risk] auth.middleware.ts breaks if SESSION_SECRET is unset. -->
|
|
95
|
+
<!-- - [convention] All API errors must use createApiError() helper. -->
|
|
96
|
+
<!-- - [workflow:test/e2e] The e2e step doesn't tell agents to start the dev server first. -->
|
|
97
|
+
|
|
88
98
|
## Deviations
|
|
89
99
|
<!-- Append here whenever implementation diverges from the Blueprint -->
|
|
90
100
|
<!-- Format: **<Blueprint step>**: <what changed> — <why> -->
|
|
@@ -93,9 +103,12 @@ ${description}
|
|
|
93
103
|
return md;
|
|
94
104
|
}
|
|
95
105
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Append `entry` to `<root>/.gitignore` if it isn't already present.
|
|
108
|
+
* Idempotent. Creates the file if missing. Reused by setup.ts.
|
|
109
|
+
*/
|
|
110
|
+
export function ensureGitignored(root: string, entry: string): void {
|
|
111
|
+
const gitignorePath = path.join(root, ".gitignore");
|
|
99
112
|
|
|
100
113
|
if (fs.existsSync(gitignorePath)) {
|
|
101
114
|
const content = fs.readFileSync(gitignorePath, "utf-8");
|
|
@@ -111,6 +124,7 @@ export function initCommand(options: {
|
|
|
111
124
|
description: string;
|
|
112
125
|
classification?: Classification;
|
|
113
126
|
gated?: boolean;
|
|
127
|
+
modelPolicy?: ModelPolicy;
|
|
114
128
|
worktreeRoot?: string;
|
|
115
129
|
}): Action {
|
|
116
130
|
const worktreeRoot = options.worktreeRoot || process.cwd();
|
|
@@ -120,8 +134,17 @@ export function initCommand(options: {
|
|
|
120
134
|
const mode = options.mode ?? projectConfig.defaults?.mode ?? "full";
|
|
121
135
|
const classification = options.classification ?? projectConfig.defaults?.classification;
|
|
122
136
|
const gated = options.gated ?? projectConfig.defaults?.gated ?? false;
|
|
137
|
+
const modelPolicy: ModelPolicy = options.modelPolicy ?? "auto";
|
|
123
138
|
const { description } = options;
|
|
124
139
|
|
|
140
|
+
// Validate model policy (guards against CLI callers that bypass the commander layer)
|
|
141
|
+
if (!isModelPolicy(modelPolicy)) {
|
|
142
|
+
return {
|
|
143
|
+
action: "error",
|
|
144
|
+
message: `Invalid --model-policy "${modelPolicy}". Use one of: auto, opus, sonnet, haiku, inherit.`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
125
148
|
// Guard: don't overwrite existing state
|
|
126
149
|
if (stateExists(worktreeRoot)) {
|
|
127
150
|
return {
|
|
@@ -180,6 +203,7 @@ export function initCommand(options: {
|
|
|
180
203
|
mode: modeLabel,
|
|
181
204
|
...(gated && { gated: true }),
|
|
182
205
|
...(classification && { classification }),
|
|
206
|
+
...(modelPolicy !== "auto" && { modelPolicy }),
|
|
183
207
|
status: "in-progress",
|
|
184
208
|
currentPhase: firstPhase,
|
|
185
209
|
currentStep: firstStep,
|
|
@@ -193,12 +217,14 @@ export function initCommand(options: {
|
|
|
193
217
|
};
|
|
194
218
|
|
|
195
219
|
// Ensure .work-kit/ is gitignored (temp working state, not for commits)
|
|
196
|
-
ensureGitignored(worktreeRoot);
|
|
220
|
+
ensureGitignored(worktreeRoot, `${STATE_DIR}/`);
|
|
197
221
|
|
|
198
222
|
// Write state files
|
|
199
223
|
writeState(worktreeRoot, state);
|
|
200
224
|
writeStateMd(worktreeRoot, generateStateMd(slug, branch, modeLabel, description, classification, workflow));
|
|
201
225
|
|
|
226
|
+
const model = resolveModel(state, firstPhase, firstStep);
|
|
227
|
+
|
|
202
228
|
return {
|
|
203
229
|
action: "spawn_agent",
|
|
204
230
|
phase: firstPhase,
|
|
@@ -206,5 +232,6 @@ export function initCommand(options: {
|
|
|
206
232
|
skillFile: skillFilePath(firstPhase, firstStep),
|
|
207
233
|
agentPrompt: `You are starting the ${firstPhase} phase. Begin with the ${firstStep} step. Read the skill file and follow its instructions. Write outputs to .work-kit/state.md.`,
|
|
208
234
|
onComplete: `${CLI_BINARY} complete ${firstPhase}/${firstStep}`,
|
|
235
|
+
...(model && { model }),
|
|
209
236
|
};
|
|
210
237
|
}
|