work-kit-cli 0.3.0 → 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 +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 +217 -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 +217 -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 +144 -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
|
@@ -0,0 +1,217 @@
|
|
|
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 { initCommand } from "./init.js";
|
|
8
|
+
import { learnCommand } from "./learn.js";
|
|
9
|
+
import { extractCommand } from "./extract.js";
|
|
10
|
+
import { KNOWLEDGE_DIR, AUTO_BLOCK_START, AUTO_BLOCK_END } from "../utils/knowledge.js";
|
|
11
|
+
|
|
12
|
+
function makeTmpDir(): string {
|
|
13
|
+
const dir = path.join(os.tmpdir(), `work-kit-test-${randomUUID()}`);
|
|
14
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
15
|
+
return dir;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let tmpDirs: string[] = [];
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
for (const dir of tmpDirs) {
|
|
22
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
tmpDirs = [];
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function setupSession(tmp: string) {
|
|
28
|
+
initCommand({
|
|
29
|
+
mode: "full",
|
|
30
|
+
description: "Test feature",
|
|
31
|
+
worktreeRoot: tmp,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("learnCommand", () => {
|
|
36
|
+
it("rejects invalid type", () => {
|
|
37
|
+
const tmp = makeTmpDir();
|
|
38
|
+
tmpDirs.push(tmp);
|
|
39
|
+
setupSession(tmp);
|
|
40
|
+
const r = learnCommand({ type: "garbage", text: "x", worktreeRoot: tmp });
|
|
41
|
+
assert.equal(r.action, "error");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("rejects empty text", () => {
|
|
45
|
+
const tmp = makeTmpDir();
|
|
46
|
+
tmpDirs.push(tmp);
|
|
47
|
+
setupSession(tmp);
|
|
48
|
+
const r = learnCommand({ type: "lesson", text: " ", worktreeRoot: tmp });
|
|
49
|
+
assert.equal(r.action, "error");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("appends a lesson and creates auto-block markers", () => {
|
|
53
|
+
const tmp = makeTmpDir();
|
|
54
|
+
tmpDirs.push(tmp);
|
|
55
|
+
setupSession(tmp);
|
|
56
|
+
|
|
57
|
+
const r = learnCommand({
|
|
58
|
+
type: "lesson",
|
|
59
|
+
text: "Test fixtures must be reset between Playwright suites.",
|
|
60
|
+
worktreeRoot: tmp,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
assert.equal(r.action, "learned");
|
|
64
|
+
assert.equal(r.file, "lessons.md");
|
|
65
|
+
|
|
66
|
+
const lessonsPath = path.join(tmp, KNOWLEDGE_DIR, "lessons.md");
|
|
67
|
+
assert.ok(fs.existsSync(lessonsPath));
|
|
68
|
+
const content = fs.readFileSync(lessonsPath, "utf-8");
|
|
69
|
+
assert.ok(content.includes(AUTO_BLOCK_START));
|
|
70
|
+
assert.ok(content.includes(AUTO_BLOCK_END));
|
|
71
|
+
assert.ok(content.includes("Test fixtures must be reset"));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("routes each type to its own file", () => {
|
|
75
|
+
const tmp = makeTmpDir();
|
|
76
|
+
tmpDirs.push(tmp);
|
|
77
|
+
setupSession(tmp);
|
|
78
|
+
|
|
79
|
+
learnCommand({ type: "lesson", text: "L", worktreeRoot: tmp });
|
|
80
|
+
learnCommand({ type: "convention", text: "C", worktreeRoot: tmp });
|
|
81
|
+
learnCommand({ type: "risk", text: "R", worktreeRoot: tmp });
|
|
82
|
+
learnCommand({ type: "workflow", text: "W", worktreeRoot: tmp });
|
|
83
|
+
|
|
84
|
+
const dir = path.join(tmp, KNOWLEDGE_DIR);
|
|
85
|
+
assert.ok(fs.readFileSync(path.join(dir, "lessons.md"), "utf-8").includes("L"));
|
|
86
|
+
assert.ok(fs.readFileSync(path.join(dir, "conventions.md"), "utf-8").includes("C"));
|
|
87
|
+
assert.ok(fs.readFileSync(path.join(dir, "risks.md"), "utf-8").includes("R"));
|
|
88
|
+
assert.ok(fs.readFileSync(path.join(dir, "workflow.md"), "utf-8").includes("W"));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("redacts secrets in text before writing", () => {
|
|
92
|
+
const tmp = makeTmpDir();
|
|
93
|
+
tmpDirs.push(tmp);
|
|
94
|
+
setupSession(tmp);
|
|
95
|
+
|
|
96
|
+
const r = learnCommand({
|
|
97
|
+
type: "lesson",
|
|
98
|
+
text: "API key is sk-abc123def456ghi789jkl012mno345 leaked here",
|
|
99
|
+
worktreeRoot: tmp,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
assert.equal(r.action, "learned");
|
|
103
|
+
assert.equal(r.redacted, true);
|
|
104
|
+
assert.ok((r.redactedKinds ?? []).includes("openai-style"));
|
|
105
|
+
|
|
106
|
+
const content = fs.readFileSync(
|
|
107
|
+
path.join(tmp, KNOWLEDGE_DIR, "lessons.md"),
|
|
108
|
+
"utf-8"
|
|
109
|
+
);
|
|
110
|
+
assert.ok(content.includes("[REDACTED]"));
|
|
111
|
+
assert.ok(!content.includes("sk-abc123def456ghi789"));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("is idempotent on identical entries (returns duplicate)", () => {
|
|
115
|
+
const tmp = makeTmpDir();
|
|
116
|
+
tmpDirs.push(tmp);
|
|
117
|
+
setupSession(tmp);
|
|
118
|
+
|
|
119
|
+
const first = learnCommand({ type: "risk", text: "Same text", worktreeRoot: tmp });
|
|
120
|
+
const second = learnCommand({ type: "risk", text: "Same text", worktreeRoot: tmp });
|
|
121
|
+
|
|
122
|
+
assert.equal(first.action, "learned");
|
|
123
|
+
assert.equal(second.action, "duplicate");
|
|
124
|
+
|
|
125
|
+
const content = fs.readFileSync(
|
|
126
|
+
path.join(tmp, KNOWLEDGE_DIR, "risks.md"),
|
|
127
|
+
"utf-8"
|
|
128
|
+
);
|
|
129
|
+
// Only one entry should exist
|
|
130
|
+
const matches = content.match(/Same text/g) ?? [];
|
|
131
|
+
assert.equal(matches.length, 1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("auto-fills phase and step from tracker.json", () => {
|
|
135
|
+
const tmp = makeTmpDir();
|
|
136
|
+
tmpDirs.push(tmp);
|
|
137
|
+
setupSession(tmp);
|
|
138
|
+
|
|
139
|
+
learnCommand({
|
|
140
|
+
type: "lesson",
|
|
141
|
+
text: "Phase auto-fill check",
|
|
142
|
+
worktreeRoot: tmp,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const content = fs.readFileSync(
|
|
146
|
+
path.join(tmp, KNOWLEDGE_DIR, "lessons.md"),
|
|
147
|
+
"utf-8"
|
|
148
|
+
);
|
|
149
|
+
// Init starts at plan/clarify
|
|
150
|
+
assert.ok(content.includes("plan/clarify"));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("extracts typed bullets from state.md ## Observations", () => {
|
|
154
|
+
const tmp = makeTmpDir();
|
|
155
|
+
tmpDirs.push(tmp);
|
|
156
|
+
setupSession(tmp);
|
|
157
|
+
|
|
158
|
+
// Inject typed bullets into the existing ## Observations section
|
|
159
|
+
const stateMdPath = path.join(tmp, ".work-kit", "state.md");
|
|
160
|
+
const original = fs.readFileSync(stateMdPath, "utf-8");
|
|
161
|
+
const injected = original.replace(
|
|
162
|
+
"\n## Deviations",
|
|
163
|
+
"\n- [risk] foo.ts is fragile\n- [convention] All inputs validated via Zod\n- [workflow:test/e2e] e2e step needs server\n- [lesson] We discovered something useful\n\n## Deviations"
|
|
164
|
+
);
|
|
165
|
+
fs.writeFileSync(stateMdPath, injected);
|
|
166
|
+
|
|
167
|
+
const r = extractCommand({ worktreeRoot: tmp });
|
|
168
|
+
assert.equal(r.action, "extracted");
|
|
169
|
+
// 4 typed observation bullets, all should route
|
|
170
|
+
assert.equal(r.byType.risk, 1);
|
|
171
|
+
assert.equal(r.byType.convention, 1);
|
|
172
|
+
assert.equal(r.byType.workflow, 1, "workflow:test/e2e bullet should be routed (regression: digit in step name)");
|
|
173
|
+
assert.equal(r.byType.lesson, 1);
|
|
174
|
+
|
|
175
|
+
const workflowMd = fs.readFileSync(path.join(tmp, KNOWLEDGE_DIR, "workflow.md"), "utf-8");
|
|
176
|
+
assert.ok(workflowMd.includes("e2e step needs server"));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("extract is idempotent (re-run produces only duplicates)", () => {
|
|
180
|
+
const tmp = makeTmpDir();
|
|
181
|
+
tmpDirs.push(tmp);
|
|
182
|
+
setupSession(tmp);
|
|
183
|
+
|
|
184
|
+
const stateMdPath = path.join(tmp, ".work-kit", "state.md");
|
|
185
|
+
const original = fs.readFileSync(stateMdPath, "utf-8");
|
|
186
|
+
fs.writeFileSync(
|
|
187
|
+
stateMdPath,
|
|
188
|
+
original.replace("\n## Deviations", "\n- [risk] one\n- [risk] two\n\n## Deviations")
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const first = extractCommand({ worktreeRoot: tmp });
|
|
192
|
+
const second = extractCommand({ worktreeRoot: tmp });
|
|
193
|
+
|
|
194
|
+
assert.equal(first.written, 2);
|
|
195
|
+
assert.equal(first.duplicates, 0);
|
|
196
|
+
assert.equal(second.written, 0);
|
|
197
|
+
assert.equal(second.duplicates, 2);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("does not contaminate the Manual section", () => {
|
|
201
|
+
const tmp = makeTmpDir();
|
|
202
|
+
tmpDirs.push(tmp);
|
|
203
|
+
setupSession(tmp);
|
|
204
|
+
|
|
205
|
+
learnCommand({ type: "lesson", text: "Auto only", worktreeRoot: tmp });
|
|
206
|
+
|
|
207
|
+
const content = fs.readFileSync(
|
|
208
|
+
path.join(tmp, KNOWLEDGE_DIR, "lessons.md"),
|
|
209
|
+
"utf-8"
|
|
210
|
+
);
|
|
211
|
+
// Find the Manual section and ensure the entry isn't in it
|
|
212
|
+
const manualIdx = content.indexOf("## Manual");
|
|
213
|
+
assert.ok(manualIdx > -1);
|
|
214
|
+
const manualSection = content.slice(manualIdx);
|
|
215
|
+
assert.ok(!manualSection.includes("Auto only"));
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { findWorktreeRoot, readState, resolveMainRepoRoot, stateExists, gitHeadSha } from "../state/store.js";
|
|
2
|
+
import {
|
|
3
|
+
appendAutoEntry,
|
|
4
|
+
ensureKnowledgeDir,
|
|
5
|
+
fileForType,
|
|
6
|
+
isKnowledgeType,
|
|
7
|
+
redact,
|
|
8
|
+
type KnowledgeEntry,
|
|
9
|
+
type KnowledgeType,
|
|
10
|
+
} from "../utils/knowledge.js";
|
|
11
|
+
import { skillFilePath } from "../config/workflow.js";
|
|
12
|
+
import type { PhaseName } from "../state/schema.js";
|
|
13
|
+
|
|
14
|
+
export interface LearnOptions {
|
|
15
|
+
type: string;
|
|
16
|
+
text: string;
|
|
17
|
+
scope?: string;
|
|
18
|
+
phase?: string;
|
|
19
|
+
step?: string;
|
|
20
|
+
source?: string;
|
|
21
|
+
worktreeRoot?: string;
|
|
22
|
+
/** When true, suppress the one-time commit warning. Used by extract. */
|
|
23
|
+
quiet?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LearnResult {
|
|
27
|
+
action: "learned" | "duplicate" | "error";
|
|
28
|
+
type?: KnowledgeType;
|
|
29
|
+
file?: string;
|
|
30
|
+
redacted?: boolean;
|
|
31
|
+
redactedKinds?: string[];
|
|
32
|
+
message?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function learnCommand(opts: LearnOptions): LearnResult {
|
|
36
|
+
if (!opts.type || !isKnowledgeType(opts.type)) {
|
|
37
|
+
return {
|
|
38
|
+
action: "error",
|
|
39
|
+
message: `Invalid --type "${opts.type}". Must be one of: lesson, convention, risk, workflow.`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (!opts.text || opts.text.trim().length === 0) {
|
|
43
|
+
return { action: "error", message: "--text is required and cannot be empty." };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const type = opts.type as KnowledgeType;
|
|
47
|
+
|
|
48
|
+
// Try to locate a session for auto-fill. Falls back to manual --phase/--step.
|
|
49
|
+
const root = opts.worktreeRoot || findWorktreeRoot();
|
|
50
|
+
let sessionSlug: string | undefined;
|
|
51
|
+
let phase: string | undefined = opts.phase;
|
|
52
|
+
let step: string | undefined = opts.step;
|
|
53
|
+
let mainRepoRoot: string | undefined;
|
|
54
|
+
let skillPath: string | undefined;
|
|
55
|
+
|
|
56
|
+
if (root && stateExists(root)) {
|
|
57
|
+
const state = readState(root);
|
|
58
|
+
sessionSlug = state.slug;
|
|
59
|
+
mainRepoRoot = state.metadata?.mainRepoRoot ?? resolveMainRepoRoot(root);
|
|
60
|
+
if (!phase) phase = state.currentPhase ?? undefined;
|
|
61
|
+
if (!step) step = state.currentStep ?? undefined;
|
|
62
|
+
if (phase && step) {
|
|
63
|
+
skillPath = skillFilePath(phase as PhaseName, step);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!mainRepoRoot) {
|
|
68
|
+
// No session — caller must be in a git repo we can resolve
|
|
69
|
+
mainRepoRoot = resolveMainRepoRoot(process.cwd());
|
|
70
|
+
if (!mainRepoRoot) {
|
|
71
|
+
return {
|
|
72
|
+
action: "error",
|
|
73
|
+
message: "No work-kit session found and not inside a git repo. Run from a project directory or provide --worktree-root.",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { text, redacted, matches } = redact(opts.text);
|
|
79
|
+
|
|
80
|
+
ensureKnowledgeDir(mainRepoRoot);
|
|
81
|
+
|
|
82
|
+
const entry: KnowledgeEntry = {
|
|
83
|
+
ts: new Date().toISOString(),
|
|
84
|
+
sessionSlug,
|
|
85
|
+
phase,
|
|
86
|
+
step,
|
|
87
|
+
skillPath,
|
|
88
|
+
gitSha: gitHeadSha(mainRepoRoot),
|
|
89
|
+
source: opts.source ?? "explicit-cli",
|
|
90
|
+
text,
|
|
91
|
+
scope: opts.scope,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const file = fileForType(type);
|
|
95
|
+
const wrote = appendAutoEntry(mainRepoRoot, file, entry);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
action: wrote ? "learned" : "duplicate",
|
|
99
|
+
type,
|
|
100
|
+
file,
|
|
101
|
+
redacted,
|
|
102
|
+
redactedKinds: matches,
|
|
103
|
+
};
|
|
104
|
+
}
|
package/cli/src/commands/next.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { validatePhasePrerequisites } from "../state/validators.js";
|
|
|
4
4
|
import { buildAgentPrompt } from "../context/prompt-builder.js";
|
|
5
5
|
import { getParallelGroup } from "../workflow/parallel.js";
|
|
6
6
|
import { skillFilePath } from "../config/workflow.js";
|
|
7
|
+
import { resolveModel } from "../config/model-routing.js";
|
|
7
8
|
import { CLI_BINARY } from "../config/constants.js";
|
|
8
9
|
|
|
9
10
|
import type { Action, PhaseName, WorkKitState } from "../state/schema.js";
|
|
@@ -109,27 +110,27 @@ function buildSpawnAction(root: string, state: WorkKitState, phase: PhaseName, s
|
|
|
109
110
|
const sState = state.phases[phase].steps[s];
|
|
110
111
|
return sState && sState.status !== "skipped" && sState.status !== "completed";
|
|
111
112
|
})
|
|
112
|
-
.map((s) => ({
|
|
113
|
+
.map((s) => withModel({
|
|
113
114
|
phase,
|
|
114
115
|
step: s,
|
|
115
116
|
skillFile: skillFilePath(phase, s),
|
|
116
117
|
agentPrompt: buildAgentPrompt(root, state, phase, s, stateMd),
|
|
117
118
|
outputFile: `.work-kit/${phase}-${s}.md`,
|
|
118
|
-
}));
|
|
119
|
+
}, state));
|
|
119
120
|
|
|
120
121
|
// If all parallel members were filtered out, fall through to single agent
|
|
121
122
|
if (agents.length === 0) {
|
|
122
123
|
// Skip to thenSequential if it exists, otherwise nothing to do
|
|
123
124
|
if (parallelGroup.thenSequential) {
|
|
124
125
|
const seqStep = parallelGroup.thenSequential;
|
|
125
|
-
return {
|
|
126
|
+
return withModelAction({
|
|
126
127
|
action: "spawn_agent",
|
|
127
128
|
phase,
|
|
128
129
|
step: seqStep,
|
|
129
130
|
skillFile: skillFilePath(phase, seqStep),
|
|
130
131
|
agentPrompt: buildAgentPrompt(root, state, phase, seqStep, stateMd),
|
|
131
132
|
onComplete: `${CLI_BINARY} complete ${phase}/${seqStep}`,
|
|
132
|
-
};
|
|
133
|
+
}, state);
|
|
133
134
|
}
|
|
134
135
|
return { action: "error", message: `No active steps in parallel group for ${phase}` };
|
|
135
136
|
}
|
|
@@ -137,14 +138,14 @@ function buildSpawnAction(root: string, state: WorkKitState, phase: PhaseName, s
|
|
|
137
138
|
// If only 1 agent remains, run as single agent (no need for parallel)
|
|
138
139
|
if (agents.length === 1 && !parallelGroup.thenSequential) {
|
|
139
140
|
const agent = agents[0];
|
|
140
|
-
return {
|
|
141
|
+
return withModelAction({
|
|
141
142
|
action: "spawn_agent",
|
|
142
143
|
phase: agent.phase,
|
|
143
144
|
step: agent.step,
|
|
144
145
|
skillFile: agent.skillFile,
|
|
145
146
|
agentPrompt: agent.agentPrompt,
|
|
146
147
|
onComplete: `${CLI_BINARY} complete ${agent.phase}/${agent.step}`,
|
|
147
|
-
};
|
|
148
|
+
}, state);
|
|
148
149
|
}
|
|
149
150
|
|
|
150
151
|
for (const agent of agents) {
|
|
@@ -153,12 +154,12 @@ function buildSpawnAction(root: string, state: WorkKitState, phase: PhaseName, s
|
|
|
153
154
|
}
|
|
154
155
|
|
|
155
156
|
const thenSequential = parallelGroup.thenSequential
|
|
156
|
-
? {
|
|
157
|
+
? withModel({
|
|
157
158
|
phase,
|
|
158
159
|
step: parallelGroup.thenSequential,
|
|
159
160
|
skillFile: skillFilePath(phase, parallelGroup.thenSequential),
|
|
160
161
|
agentPrompt: buildAgentPrompt(root, state, phase, parallelGroup.thenSequential, stateMd),
|
|
161
|
-
}
|
|
162
|
+
}, state)
|
|
162
163
|
: undefined;
|
|
163
164
|
|
|
164
165
|
writeState(root, state);
|
|
@@ -174,12 +175,31 @@ function buildSpawnAction(root: string, state: WorkKitState, phase: PhaseName, s
|
|
|
174
175
|
const skill = skillFilePath(phase, step);
|
|
175
176
|
const prompt = buildAgentPrompt(root, state, phase, step, stateMd);
|
|
176
177
|
|
|
177
|
-
return {
|
|
178
|
+
return withModelAction({
|
|
178
179
|
action: "spawn_agent",
|
|
179
180
|
phase,
|
|
180
181
|
step,
|
|
181
182
|
skillFile: skill,
|
|
182
183
|
agentPrompt: prompt,
|
|
183
184
|
onComplete: `${CLI_BINARY} complete ${phase}/${step}`,
|
|
184
|
-
};
|
|
185
|
+
}, state);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Attach the resolved model tier to an AgentSpec. Omits the field entirely
|
|
190
|
+
* when resolveModel returns undefined (policy "inherit" or hard-default miss),
|
|
191
|
+
* keeping the action JSON compatible with skills that haven't yet been updated
|
|
192
|
+
* to forward a model parameter.
|
|
193
|
+
*/
|
|
194
|
+
function withModel<T extends { phase: PhaseName; step: string }>(spec: T, state: WorkKitState): T & { model?: ReturnType<typeof resolveModel> } {
|
|
195
|
+
const model = resolveModel(state, spec.phase, spec.step);
|
|
196
|
+
return model ? { ...spec, model } : spec;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function withModelAction(
|
|
200
|
+
action: Extract<Action, { action: "spawn_agent" }>,
|
|
201
|
+
state: WorkKitState
|
|
202
|
+
): Extract<Action, { action: "spawn_agent" }> {
|
|
203
|
+
const model = resolveModel(state, action.phase, action.step);
|
|
204
|
+
return model ? { ...action, model } : action;
|
|
185
205
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
-
import { execFileSync } from "node:child_process";
|
|
3
2
|
import {
|
|
4
3
|
renderDashboard,
|
|
5
4
|
enterAlternateScreen,
|
|
@@ -7,27 +6,23 @@ import {
|
|
|
7
6
|
moveCursorHome,
|
|
8
7
|
renderTooSmall,
|
|
9
8
|
} from "../observer/renderer.js";
|
|
10
|
-
import { collectDashboardData } from "../observer/data.js";
|
|
9
|
+
import { collectDashboardData, discoverWorkKitProjects } from "../observer/data.js";
|
|
11
10
|
import { startWatching } from "../observer/watcher.js";
|
|
11
|
+
import { gitMainRepoRoot } from "../state/store.js";
|
|
12
12
|
|
|
13
|
-
function
|
|
14
|
-
|
|
15
|
-
try {
|
|
16
|
-
const result = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
17
|
-
cwd: startDir,
|
|
18
|
-
encoding: "utf-8",
|
|
19
|
-
timeout: 5000,
|
|
20
|
-
});
|
|
21
|
-
return result.trim();
|
|
22
|
-
} catch {
|
|
23
|
-
return startDir;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
13
|
+
export async function observeCommand(opts: { mainRepo?: string; all?: boolean }): Promise<void> {
|
|
14
|
+
const cwdRoot = () => gitMainRepoRoot(process.cwd()) ?? process.cwd();
|
|
26
15
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
16
|
+
let mainRepoRoots: string[];
|
|
17
|
+
if (opts.all) {
|
|
18
|
+
mainRepoRoots = discoverWorkKitProjects();
|
|
19
|
+
if (mainRepoRoots.length === 0) {
|
|
20
|
+
// Fallback to current repo so the dashboard still has something to show
|
|
21
|
+
mainRepoRoots = [cwdRoot()];
|
|
22
|
+
}
|
|
23
|
+
} else {
|
|
24
|
+
mainRepoRoots = [opts.mainRepo ? path.resolve(opts.mainRepo) : cwdRoot()];
|
|
25
|
+
}
|
|
31
26
|
|
|
32
27
|
let scrollOffset = 0;
|
|
33
28
|
let tick = 0;
|
|
@@ -58,7 +53,7 @@ export async function observeCommand(opts: { mainRepo?: string }): Promise<void>
|
|
|
58
53
|
return;
|
|
59
54
|
}
|
|
60
55
|
|
|
61
|
-
const data = collectDashboardData(
|
|
56
|
+
const data = collectDashboardData(mainRepoRoots, watcher.getWorktrees());
|
|
62
57
|
const frame = moveCursorHome() + renderDashboard(data, width, height, scrollOffset, tick);
|
|
63
58
|
process.stdout.write(frame);
|
|
64
59
|
}
|
|
@@ -78,7 +73,7 @@ export async function observeCommand(opts: { mainRepo?: string }): Promise<void>
|
|
|
78
73
|
|
|
79
74
|
try {
|
|
80
75
|
// Set up file watching (before initial render so worktrees are cached)
|
|
81
|
-
watcher = startWatching(
|
|
76
|
+
watcher = startWatching(mainRepoRoots, () => {
|
|
82
77
|
render();
|
|
83
78
|
});
|
|
84
79
|
|
|
@@ -56,7 +56,7 @@ describe("pause / resume", () => {
|
|
|
56
56
|
initCommand({ mode: "full", description: "Resume test", worktreeRoot: tmp });
|
|
57
57
|
pauseCommand(undefined, tmp);
|
|
58
58
|
|
|
59
|
-
const result = resumeCommand(tmp);
|
|
59
|
+
const result = resumeCommand({ worktreeRoot: tmp });
|
|
60
60
|
assert.equal(result.action, "resumed");
|
|
61
61
|
|
|
62
62
|
const tracker = JSON.parse(fs.readFileSync(path.join(tmp, ".work-kit", "tracker.json"), "utf-8"));
|
|
@@ -69,7 +69,7 @@ describe("pause / resume", () => {
|
|
|
69
69
|
tmpDirs.push(tmp);
|
|
70
70
|
initCommand({ mode: "full", description: "Already running", worktreeRoot: tmp });
|
|
71
71
|
|
|
72
|
-
const result = resumeCommand(tmp);
|
|
72
|
+
const result = resumeCommand({ worktreeRoot: tmp });
|
|
73
73
|
assert.equal(result.action, "resumed");
|
|
74
74
|
});
|
|
75
75
|
|
|
@@ -1,14 +1,52 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { readState, writeState, findWorktreeRoot, statePath, gitMainRepoRoot } from "../state/store.js";
|
|
2
3
|
import { unpause } from "../state/helpers.js";
|
|
3
4
|
import { CLI_BINARY } from "../config/constants.js";
|
|
4
|
-
import
|
|
5
|
+
import { discoverWorktrees } from "../observer/data.js";
|
|
6
|
+
import type { Action, ResumableSessionSummary } from "../state/schema.js";
|
|
5
7
|
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
export interface ResumeOptions {
|
|
9
|
+
worktreeRoot?: string;
|
|
10
|
+
slug?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function collectResumableSessions(mainRepoRoot: string): ResumableSessionSummary[] {
|
|
14
|
+
const sessions: ResumableSessionSummary[] = [];
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
for (const wt of discoverWorktrees(mainRepoRoot)) {
|
|
17
|
+
let state;
|
|
18
|
+
try {
|
|
19
|
+
state = readState(wt);
|
|
20
|
+
} catch {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (state.status !== "paused" && state.status !== "in-progress") continue;
|
|
24
|
+
|
|
25
|
+
let mtimeMs = now;
|
|
26
|
+
try {
|
|
27
|
+
mtimeMs = fs.statSync(statePath(wt)).mtimeMs;
|
|
28
|
+
} catch {
|
|
29
|
+
// ignore — keep current time as fallback
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
sessions.push({
|
|
33
|
+
slug: state.slug,
|
|
34
|
+
branch: state.branch,
|
|
35
|
+
worktreeRoot: wt,
|
|
36
|
+
status: state.status,
|
|
37
|
+
pausedAt: state.pausedAt,
|
|
38
|
+
currentPhase: state.currentPhase,
|
|
39
|
+
currentStep: state.currentStep,
|
|
40
|
+
lastUpdatedAgoMs: now - mtimeMs,
|
|
41
|
+
});
|
|
10
42
|
}
|
|
43
|
+
// Sort: most recently updated first — fresh crashes are easy to spot at the top,
|
|
44
|
+
// and a still-running session in another terminal will have the smallest age.
|
|
45
|
+
sessions.sort((a, b) => a.lastUpdatedAgoMs - b.lastUpdatedAgoMs);
|
|
46
|
+
return sessions;
|
|
47
|
+
}
|
|
11
48
|
|
|
49
|
+
function resumeAt(root: string): Action {
|
|
12
50
|
const state = readState(root);
|
|
13
51
|
|
|
14
52
|
if (state.status === "completed") {
|
|
@@ -20,6 +58,7 @@ export function resumeCommand(worktreeRoot?: string): Action {
|
|
|
20
58
|
message: `${state.slug} is already in progress. Run \`${CLI_BINARY} next\` to continue.`,
|
|
21
59
|
phase: state.currentPhase,
|
|
22
60
|
step: state.currentStep,
|
|
61
|
+
worktreeRoot: root,
|
|
23
62
|
};
|
|
24
63
|
}
|
|
25
64
|
if (state.status === "failed") {
|
|
@@ -31,8 +70,57 @@ export function resumeCommand(worktreeRoot?: string): Action {
|
|
|
31
70
|
|
|
32
71
|
return {
|
|
33
72
|
action: "resumed",
|
|
34
|
-
message: `Resumed ${state.slug}.
|
|
73
|
+
message: `Resumed ${state.slug}. cd into ${root} and run \`${CLI_BINARY} next\` to continue.`,
|
|
35
74
|
phase: state.currentPhase,
|
|
36
75
|
step: state.currentStep,
|
|
76
|
+
worktreeRoot: root,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function resumeCommand(options: ResumeOptions = {}): Action {
|
|
81
|
+
// 1. Explicit worktree path → resume there directly (legacy behavior)
|
|
82
|
+
if (options.worktreeRoot) {
|
|
83
|
+
return resumeAt(options.worktreeRoot);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 2. Determine the main repo we're operating against. Works whether
|
|
87
|
+
// we're called from the main repo or from inside one of its worktrees.
|
|
88
|
+
const mainRepoRoot = gitMainRepoRoot(process.cwd());
|
|
89
|
+
if (!mainRepoRoot) {
|
|
90
|
+
// Non-git context: try the legacy cwd-walking lookup
|
|
91
|
+
const root = findWorktreeRoot();
|
|
92
|
+
if (!root) {
|
|
93
|
+
return { action: "error", message: "No work-kit state found and not inside a git repo." };
|
|
94
|
+
}
|
|
95
|
+
return resumeAt(root);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const sessions = collectResumableSessions(mainRepoRoot);
|
|
99
|
+
|
|
100
|
+
// 3. Slug selector → find matching session in this repo (paused OR in-progress)
|
|
101
|
+
if (options.slug) {
|
|
102
|
+
const match = sessions.find(s => s.slug === options.slug);
|
|
103
|
+
if (!match) {
|
|
104
|
+
return {
|
|
105
|
+
action: "error",
|
|
106
|
+
message: `No work-kit session with slug "${options.slug}" found in ${mainRepoRoot}.`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return resumeAt(match.worktreeRoot);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 4. No slug → list resumable sessions for the user to pick from
|
|
113
|
+
if (sessions.length === 0) {
|
|
114
|
+
return {
|
|
115
|
+
action: "error",
|
|
116
|
+
message: `No resumable work-kit sessions in ${mainRepoRoot}.`,
|
|
117
|
+
suggestion: `Start a new session with /full-kit or /auto-kit, or run \`${CLI_BINARY} observe\` to see active work.`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
action: "select_session",
|
|
123
|
+
message: `Found ${sessions.length} resumable session${sessions.length === 1 ? "" : "s"}. Re-run with --slug <slug> to continue one.`,
|
|
124
|
+
sessions,
|
|
37
125
|
};
|
|
38
126
|
}
|