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.
Files changed (40) hide show
  1. package/README.md +11 -0
  2. package/cli/src/commands/bootstrap.test.ts +40 -0
  3. package/cli/src/commands/bootstrap.ts +38 -0
  4. package/cli/src/commands/extract.ts +217 -0
  5. package/cli/src/commands/init.test.ts +50 -0
  6. package/cli/src/commands/init.ts +32 -5
  7. package/cli/src/commands/learn.test.ts +217 -0
  8. package/cli/src/commands/learn.ts +104 -0
  9. package/cli/src/commands/next.ts +30 -10
  10. package/cli/src/commands/observe.ts +16 -21
  11. package/cli/src/commands/pause-resume.test.ts +2 -2
  12. package/cli/src/commands/resume.ts +95 -7
  13. package/cli/src/commands/setup.ts +144 -0
  14. package/cli/src/commands/status.ts +2 -0
  15. package/cli/src/commands/workflow.ts +19 -9
  16. package/cli/src/config/constants.ts +10 -0
  17. package/cli/src/config/model-routing.test.ts +190 -0
  18. package/cli/src/config/model-routing.ts +208 -0
  19. package/cli/src/config/workflow.ts +5 -5
  20. package/cli/src/index.ts +70 -5
  21. package/cli/src/observer/data.ts +132 -9
  22. package/cli/src/observer/renderer.ts +34 -36
  23. package/cli/src/observer/watcher.ts +28 -16
  24. package/cli/src/state/schema.ts +50 -3
  25. package/cli/src/state/store.ts +39 -4
  26. package/cli/src/utils/fs.ts +13 -0
  27. package/cli/src/utils/knowledge.ts +471 -0
  28. package/package.json +1 -1
  29. package/skills/auto-kit/SKILL.md +27 -10
  30. package/skills/full-kit/SKILL.md +25 -8
  31. package/skills/resume-kit/SKILL.md +44 -8
  32. package/skills/wk-bootstrap/SKILL.md +6 -0
  33. package/skills/wk-build/SKILL.md +3 -2
  34. package/skills/wk-deploy/SKILL.md +1 -0
  35. package/skills/wk-plan/SKILL.md +3 -2
  36. package/skills/wk-review/SKILL.md +1 -0
  37. package/skills/wk-test/SKILL.md +1 -0
  38. package/skills/wk-test/steps/e2e.md +15 -12
  39. package/skills/wk-wrap-up/SKILL.md +15 -2
  40. package/skills/wk-wrap-up/steps/knowledge.md +76 -0
@@ -8,7 +8,7 @@ export const BUILD_STEPS = ["setup", "migration", "red", "core", "ui", "refactor
8
8
  export const TEST_STEPS = ["verify", "e2e", "validate"] as const;
9
9
  export const REVIEW_STEPS = ["self-review", "security", "performance", "compliance", "handoff"] as const;
10
10
  export const DEPLOY_STEPS = ["merge", "monitor", "remediate"] as const;
11
- export const WRAPUP_STEPS = ["summary"] as const;
11
+ export const WRAPUP_STEPS = ["summary", "knowledge"] as const;
12
12
 
13
13
  export type PlanStep = (typeof PLAN_STEPS)[number];
14
14
  export type BuildStep = (typeof BUILD_STEPS)[number];
@@ -42,6 +42,35 @@ export function isClassification(value: string): value is Classification {
42
42
  return (CLASSIFICATIONS as readonly string[]).includes(value);
43
43
  }
44
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
+
45
74
  // ── Step Outcomes ───────────────────────────────────────────────────
46
75
 
47
76
  /**
@@ -123,6 +152,8 @@ export interface WorkKitState {
123
152
  gated?: boolean;
124
153
  classification?: Classification;
125
154
  status: WorkStatus;
155
+ /** Session-wide model policy, set once at init. Defaults to "auto". */
156
+ modelPolicy?: ModelPolicy;
126
157
  /** ISO timestamp the work was paused; cleared on resume. */
127
158
  pausedAt?: string;
128
159
  currentPhase: PhaseName | null;
@@ -144,14 +175,30 @@ export interface AgentSpec {
144
175
  skillFile: string;
145
176
  agentPrompt: string;
146
177
  outputFile?: string;
178
+ /** Resolved model tier for this agent. Omitted when policy is "inherit". */
179
+ model?: ModelTier;
147
180
  }
148
181
 
149
182
  export type Action =
150
- | { action: "spawn_agent"; phase: PhaseName; step: string; skillFile: string; agentPrompt: string; onComplete: string }
183
+ | { action: "spawn_agent"; phase: PhaseName; step: string; skillFile: string; agentPrompt: string; onComplete: string; model?: ModelTier }
151
184
  | { action: "spawn_parallel_agents"; agents: AgentSpec[]; thenSequential?: AgentSpec; onComplete: string }
152
185
  | { action: "wait_for_user"; message: string }
153
186
  | { action: "loopback"; from: Location; to: Location; reason: string }
154
187
  | { action: "complete"; message: string }
155
188
  | { action: "paused"; message: string }
156
- | { action: "resumed"; message: string; phase: PhaseName | null; step: string | null }
189
+ | { action: "resumed"; message: string; phase: PhaseName | null; step: string | null; worktreeRoot?: string }
190
+ | { action: "select_session"; message: string; sessions: ResumableSessionSummary[] }
157
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
+ }
@@ -103,19 +103,54 @@ export function readStateMd(worktreeRoot: string): string | null {
103
103
 
104
104
  // ── Git Helpers ─────────────────────────────────────────────────
105
105
 
106
- export function resolveMainRepoRoot(worktreeRoot: string): string {
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 {
107
111
  try {
108
112
  const output = execFileSync("git", ["worktree", "list", "--porcelain"], {
109
- cwd: worktreeRoot,
113
+ cwd,
110
114
  encoding: "utf-8",
111
115
  timeout: 5000,
116
+ stdio: ["ignore", "pipe", "ignore"],
112
117
  });
113
118
  const firstLine = output.split("\n").find(l => l.startsWith("worktree "));
114
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
+ }
115
150
  } catch {
116
- // fallback
151
+ // ignore
117
152
  }
118
- return worktreeRoot;
153
+ return undefined;
119
154
  }
120
155
 
121
156
  // ── Migration ───────────────────────────────────────────────────────
@@ -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,471 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as crypto from "node:crypto";
4
+ import { KNOWLEDGE_DIR, KNOWLEDGE_LOCK } from "../config/constants.js";
5
+ import { atomicWriteFile } from "./fs.js";
6
+
7
+ // Re-exported so existing call sites that import from here keep working.
8
+ export { KNOWLEDGE_DIR, KNOWLEDGE_LOCK };
9
+
10
+ // ── Constants ───────────────────────────────────────────────────────
11
+
12
+ export const AUTO_BLOCK_START = "<!-- work-kit:auto:start -->";
13
+ export const AUTO_BLOCK_END = "<!-- work-kit:auto:end -->";
14
+ export const MANUAL_HEADER = "## Manual";
15
+
16
+ export const KNOWLEDGE_TYPES = ["lesson", "convention", "risk", "workflow"] as const;
17
+ export type KnowledgeType = (typeof KNOWLEDGE_TYPES)[number];
18
+
19
+ export function isKnowledgeType(value: string): value is KnowledgeType {
20
+ return (KNOWLEDGE_TYPES as readonly string[]).includes(value);
21
+ }
22
+
23
+ const TYPE_TO_FILE: Record<KnowledgeType, string> = {
24
+ lesson: "lessons.md",
25
+ convention: "conventions.md",
26
+ risk: "risks.md",
27
+ workflow: "workflow.md",
28
+ };
29
+
30
+ const FILE_TO_TITLE: Record<string, string> = {
31
+ "lessons.md": "Lessons",
32
+ "conventions.md": "Conventions",
33
+ "risks.md": "Risks",
34
+ "workflow.md": "Workflow Feedback",
35
+ };
36
+
37
+ const FILE_TO_BLURB: Record<string, string> = {
38
+ "lessons.md":
39
+ "Project-specific learnings discovered while doing work in this codebase.",
40
+ "conventions.md":
41
+ "Codified rules this project follows. Once a convention is here, future sessions should respect it.",
42
+ "risks.md":
43
+ "Known fragile or dangerous areas. Touch these with care.",
44
+ "workflow.md":
45
+ "Feedback about the work-kit workflow itself as observed in this project — skill quality, step skips, loopbacks, failure modes. Mined manually to improve work-kit upstream.",
46
+ };
47
+
48
+ // ── Path Resolvers ──────────────────────────────────────────────────
49
+
50
+ export function knowledgeDir(mainRepoRoot: string): string {
51
+ return path.join(mainRepoRoot, KNOWLEDGE_DIR);
52
+ }
53
+
54
+ export function knowledgePath(mainRepoRoot: string, file: string): string {
55
+ return path.join(knowledgeDir(mainRepoRoot), file);
56
+ }
57
+
58
+ export function fileForType(type: KnowledgeType): string {
59
+ return TYPE_TO_FILE[type];
60
+ }
61
+
62
+ // ── Lock ────────────────────────────────────────────────────────────
63
+
64
+ const LOCK_TIMEOUT_MS = 5000;
65
+ const LOCK_POLL_MS = 50;
66
+
67
+ // Reused across sleep calls — Atomics.wait needs an Int32Array view but the
68
+ // contents don't matter, so we allocate it once per process.
69
+ const SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));
70
+ function sleepSync(ms: number): void {
71
+ Atomics.wait(SLEEP_BUF, 0, 0, ms);
72
+ }
73
+
74
+ /**
75
+ * Polling file-lock around `<knowledge>/.lock`. Uses fs.openSync(... 'wx')
76
+ * for atomic create-or-fail. Held only during the read-modify-write of a
77
+ * single .md file. Two parallel worktrees calling `learn` simultaneously
78
+ * are serialized — both succeed.
79
+ */
80
+ export function withKnowledgeLock<T>(mainRepoRoot: string, fn: () => T): T {
81
+ const dir = knowledgeDir(mainRepoRoot);
82
+ if (!fs.existsSync(dir)) {
83
+ fs.mkdirSync(dir, { recursive: true });
84
+ }
85
+ const lockPath = path.join(dir, KNOWLEDGE_LOCK);
86
+ const start = Date.now();
87
+
88
+ while (true) {
89
+ try {
90
+ const fd = fs.openSync(lockPath, "wx");
91
+ try {
92
+ return fn();
93
+ } finally {
94
+ try {
95
+ fs.closeSync(fd);
96
+ } catch {
97
+ // ignore
98
+ }
99
+ try {
100
+ fs.unlinkSync(lockPath);
101
+ } catch {
102
+ // ignore
103
+ }
104
+ }
105
+ } catch (err: any) {
106
+ if (err?.code !== "EEXIST") throw err;
107
+ if (Date.now() - start > LOCK_TIMEOUT_MS) {
108
+ throw new Error(
109
+ `Could not acquire knowledge lock at ${lockPath} within ${LOCK_TIMEOUT_MS}ms. Another work-kit process may be stuck — remove the .lock file if no work-kit process is running.`
110
+ );
111
+ }
112
+ sleepSync(LOCK_POLL_MS);
113
+ }
114
+ }
115
+ }
116
+
117
+ // ── Redaction ───────────────────────────────────────────────────────
118
+
119
+ const SECRET_PATTERNS: { name: string; re: RegExp }[] = [
120
+ { name: "openai-style", re: /sk-[A-Za-z0-9]{20,}/g },
121
+ { name: "anthropic", re: /sk-ant-[A-Za-z0-9_-]{20,}/g },
122
+ { name: "github-pat", re: /github_pat_[A-Za-z0-9_]{82}/g },
123
+ { name: "github-token", re: /ghp_[A-Za-z0-9]{36}/g },
124
+ { name: "github-oauth", re: /gho_[A-Za-z0-9]{36}/g },
125
+ { name: "aws-access-key", re: /AKIA[0-9A-Z]{16}/g },
126
+ // Generic 40-char hex token (matches API keys, hashes, etc.)
127
+ { name: "hex-40", re: /\b[a-fA-F0-9]{40}\b/g },
128
+ ];
129
+
130
+ export interface RedactionResult {
131
+ text: string;
132
+ redacted: boolean;
133
+ matches: string[];
134
+ }
135
+
136
+ export function redact(input: string): RedactionResult {
137
+ let text = input;
138
+ const matches: string[] = [];
139
+ for (const { name, re } of SECRET_PATTERNS) {
140
+ text = text.replace(re, () => {
141
+ matches.push(name);
142
+ return "[REDACTED]";
143
+ });
144
+ }
145
+ return { text, redacted: matches.length > 0, matches };
146
+ }
147
+
148
+ // ── Stub File Scaffolding ───────────────────────────────────────────
149
+
150
+ function stubContent(file: string): string {
151
+ const title = FILE_TO_TITLE[file] ?? file;
152
+ const blurb = FILE_TO_BLURB[file] ?? "";
153
+ return [
154
+ `# ${title}`,
155
+ "",
156
+ blurb,
157
+ "",
158
+ AUTO_BLOCK_START,
159
+ "## Auto-captured",
160
+ "",
161
+ "<!-- Tooling appends new entries inside this block. Do not edit by hand. -->",
162
+ "",
163
+ AUTO_BLOCK_END,
164
+ "",
165
+ MANUAL_HEADER,
166
+ "",
167
+ "<!-- Curated by humans. Tooling never edits below this line. -->",
168
+ "",
169
+ ].join("\n");
170
+ }
171
+
172
+ const README_CONTENT = `# .work-kit-knowledge
173
+
174
+ This directory holds project knowledge that work-kit captures and reads
175
+ across sessions. It is **committed to your repo** so the whole team
176
+ benefits.
177
+
178
+ ## Files
179
+
180
+ - **lessons.md** — things you learned about this codebase (project-specific).
181
+ - **conventions.md** — codified rules this project follows.
182
+ - **risks.md** — fragile or dangerous areas to handle with care.
183
+ - **workflow.md** — feedback about the work-kit workflow itself as observed
184
+ in this project. Mined manually across projects to improve work-kit.
185
+
186
+ Each file has two sections:
187
+
188
+ - **Auto-captured** — appended by work-kit during \`wrap-up/knowledge\` and
189
+ by \`work-kit learn\`. Inside \`<!-- work-kit:auto:start -->\` markers.
190
+ **Do not edit by hand.**
191
+ - **Manual** — for humans only. Tooling never touches it. Add curated rules
192
+ here.
193
+
194
+ ## Privacy warning
195
+
196
+ Files in this directory are committed to your repo. **Don't write secrets
197
+ here.** Work-kit redacts known secret shapes (API keys, tokens) at write
198
+ time, but the regex sweep is best-effort. Treat these files like any other
199
+ source you commit.
200
+
201
+ ## How is this populated?
202
+
203
+ - During a session, agents append typed bullets to \`## Observations\` in
204
+ \`.work-kit/state.md\`.
205
+ - At \`wrap-up/knowledge\`, the kit parses Observations + Decisions +
206
+ Deviations + tracker.json loopbacks and routes them to the four files.
207
+ - Agents may also call \`work-kit learn --type X --text "..."\` mid-session.
208
+
209
+ ## Reading
210
+
211
+ \`work-kit bootstrap\` injects \`lessons.md\`, \`conventions.md\`, and
212
+ \`risks.md\` into every new session's opening context. \`workflow.md\` is
213
+ **not** injected — it's a write-only artifact for human review.
214
+ `;
215
+
216
+ // Roots whose knowledge dir we've already verified this process. Lets
217
+ // repeated calls (one per `learn`/`extract` invocation) skip the 6 stat
218
+ // calls after the first hit per process.
219
+ const ensuredRoots = new Set<string>();
220
+
221
+ export function ensureKnowledgeDir(mainRepoRoot: string): { created: string[] } {
222
+ if (ensuredRoots.has(mainRepoRoot)) {
223
+ return { created: [] };
224
+ }
225
+
226
+ const dir = knowledgeDir(mainRepoRoot);
227
+ const created: string[] = [];
228
+
229
+ if (!fs.existsSync(dir)) {
230
+ fs.mkdirSync(dir, { recursive: true });
231
+ }
232
+
233
+ const readmePath = path.join(dir, "README.md");
234
+ if (!fs.existsSync(readmePath)) {
235
+ fs.writeFileSync(readmePath, README_CONTENT, "utf-8");
236
+ created.push("README.md");
237
+ }
238
+
239
+ for (const file of Object.values(TYPE_TO_FILE)) {
240
+ const p = path.join(dir, file);
241
+ if (!fs.existsSync(p)) {
242
+ fs.writeFileSync(p, stubContent(file), "utf-8");
243
+ created.push(file);
244
+ }
245
+ }
246
+
247
+ ensuredRoots.add(mainRepoRoot);
248
+ return { created };
249
+ }
250
+
251
+ // ── Append / Read ───────────────────────────────────────────────────
252
+
253
+ export interface KnowledgeEntry {
254
+ /** ISO timestamp */
255
+ ts: string;
256
+ sessionSlug?: string;
257
+ phase?: string;
258
+ step?: string;
259
+ skillPath?: string;
260
+ gitSha?: string;
261
+ /** "auto-state-md" | "auto-tracker" | "explicit-cli" */
262
+ source: string;
263
+ /** Free-form text. Will be redacted at write time. */
264
+ text: string;
265
+ /** Optional path glob for future filtering. Stored, not yet used. */
266
+ scope?: string;
267
+ }
268
+
269
+ /** Format an entry as a single markdown bullet inside the auto block. */
270
+ function formatEntry(entry: KnowledgeEntry): string {
271
+ const date = entry.ts.slice(0, 10);
272
+ const ctx: string[] = [];
273
+ if (entry.sessionSlug) ctx.push(`\`${entry.sessionSlug}\``);
274
+ if (entry.phase && entry.step) ctx.push(`(${entry.phase}/${entry.step})`);
275
+ else if (entry.phase) ctx.push(`(${entry.phase})`);
276
+ const ctxStr = ctx.length > 0 ? ` ${ctx.join(" ")}` : "";
277
+ const scopeStr = entry.scope ? ` _scope: \`${entry.scope}\`_` : "";
278
+ return `- **${date}**${ctxStr}: ${entry.text}${scopeStr}`;
279
+ }
280
+
281
+ /** Stable hash of an entry's identifying content (for idempotent dedup). */
282
+ function entryHash(entry: KnowledgeEntry): string {
283
+ const key = JSON.stringify({
284
+ text: entry.text,
285
+ phase: entry.phase ?? null,
286
+ step: entry.step ?? null,
287
+ source: entry.source,
288
+ });
289
+ return crypto.createHash("sha1").update(key).digest("hex").slice(0, 12);
290
+ }
291
+
292
+ /**
293
+ * Read a knowledge file. Returns null if it doesn't exist. If markers are
294
+ * missing or corrupted, the raw content is returned and append rebuilds them.
295
+ */
296
+ function readKnowledgeFileRaw(mainRepoRoot: string, file: string): string | null {
297
+ const p = knowledgePath(mainRepoRoot, file);
298
+ try {
299
+ return fs.readFileSync(p, "utf-8");
300
+ } catch (err: any) {
301
+ if (err?.code === "ENOENT") return null;
302
+ throw err;
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Read a knowledge file capped at `capLines` lines for bootstrap injection.
308
+ * Strategy: include the entire `## Manual` section + the most recent N
309
+ * entries from the auto-captured block until the cap is hit. If still over,
310
+ * append a "(N more entries)" tail.
311
+ */
312
+ export function readKnowledgeFile(
313
+ mainRepoRoot: string,
314
+ file: string,
315
+ capLines: number = 200
316
+ ): string | null {
317
+ const raw = readKnowledgeFileRaw(mainRepoRoot, file);
318
+ if (raw === null) return null;
319
+
320
+ const lines = raw.split("\n");
321
+ if (lines.length <= capLines) return raw;
322
+
323
+ // Find the auto block and manual section markers.
324
+ const autoStart = lines.findIndex((l) => l.includes(AUTO_BLOCK_START));
325
+ const autoEnd = lines.findIndex((l) => l.includes(AUTO_BLOCK_END));
326
+ const manualIdx = lines.findIndex((l) => l.trim() === MANUAL_HEADER);
327
+
328
+ if (autoStart === -1 || autoEnd === -1) {
329
+ // Markers missing — just truncate from the top.
330
+ return lines.slice(0, capLines).concat([`... (${lines.length - capLines} more lines)`]).join("\n");
331
+ }
332
+
333
+ const headerLines = lines.slice(0, autoStart + 1);
334
+ const autoBodyLines = lines.slice(autoStart + 1, autoEnd);
335
+ const autoCloseLine = lines[autoEnd];
336
+ const manualLines = manualIdx !== -1 ? lines.slice(manualIdx) : [];
337
+
338
+ // Reserve budget for header + manual + auto markers + safety
339
+ const reserved = headerLines.length + manualLines.length + 2;
340
+ let autoBudget = Math.max(5, capLines - reserved);
341
+
342
+ let truncatedAuto = autoBodyLines;
343
+ let omitted = 0;
344
+ if (autoBodyLines.length > autoBudget) {
345
+ truncatedAuto = autoBodyLines.slice(autoBodyLines.length - autoBudget);
346
+ omitted = autoBodyLines.length - autoBudget;
347
+ }
348
+
349
+ const out = [
350
+ ...headerLines,
351
+ ...(omitted > 0 ? [`<!-- ... ${omitted} older auto entries truncated for context budget ... -->`] : []),
352
+ ...truncatedAuto,
353
+ autoCloseLine,
354
+ ...manualLines,
355
+ ];
356
+ return out.join("\n");
357
+ }
358
+
359
+ /**
360
+ * Read a knowledge file's current content (or a fresh stub) and ensure the
361
+ * auto-block markers are present. If markers were missing, existing content
362
+ * is rebased under the new stub.
363
+ */
364
+ function loadOrStub(mainRepoRoot: string, file: string): string {
365
+ let content = readKnowledgeFileRaw(mainRepoRoot, file) ?? stubContent(file);
366
+
367
+ if (!content.includes(AUTO_BLOCK_START) || !content.includes(AUTO_BLOCK_END)) {
368
+ const existing = content.trim();
369
+ content = stubContent(file);
370
+ if (existing.length > 0 && !existing.startsWith(`# ${FILE_TO_TITLE[file]}`)) {
371
+ content = content.trimEnd() + "\n" + existing + "\n";
372
+ }
373
+ }
374
+ return content;
375
+ }
376
+
377
+ /**
378
+ * Insert a new entry into `content` just before the auto-block close marker.
379
+ * Returns the new content, or null if the entry's hash is already present
380
+ * (idempotent skip).
381
+ */
382
+ function insertEntry(content: string, entry: KnowledgeEntry): string | null {
383
+ const hashMarker = `<!-- hash:${entryHash(entry)} -->`;
384
+ const startIdx = content.indexOf(AUTO_BLOCK_START);
385
+ const endIdx = content.indexOf(AUTO_BLOCK_END);
386
+
387
+ if (startIdx !== -1 && endIdx !== -1) {
388
+ if (content.indexOf(hashMarker, startIdx) > -1 && content.indexOf(hashMarker, startIdx) < endIdx) {
389
+ return null;
390
+ }
391
+ }
392
+
393
+ const formatted = formatEntry(entry) + ` ${hashMarker}`;
394
+ const before = content.slice(0, endIdx);
395
+ const after = content.slice(endIdx);
396
+ // Ensure newline before the close marker so bullets don't fuse onto its line
397
+ const sep = before.endsWith("\n") ? "" : "\n";
398
+ return before + sep + formatted + "\n" + after;
399
+ }
400
+
401
+ /**
402
+ * Append an entry to a knowledge file's auto-captured block. Read-modify-write
403
+ * inside the lock. Idempotent: if an identical entry (by hash) already exists
404
+ * in the auto block, the write is skipped.
405
+ *
406
+ * Returns true if a new entry was appended, false if it was a duplicate.
407
+ *
408
+ * For multiple entries to the same file, prefer `appendAutoEntries` to do
409
+ * a single read-modify-write per file.
410
+ */
411
+ export function appendAutoEntry(
412
+ mainRepoRoot: string,
413
+ file: string,
414
+ entry: KnowledgeEntry
415
+ ): boolean {
416
+ return withKnowledgeLock(mainRepoRoot, () => {
417
+ const content = loadOrStub(mainRepoRoot, file);
418
+ const next = insertEntry(content, entry);
419
+ if (next === null) return false;
420
+ atomicWriteFile(knowledgePath(mainRepoRoot, file), next);
421
+ return true;
422
+ });
423
+ }
424
+
425
+ export interface AppendBatchResult {
426
+ written: number;
427
+ duplicates: number;
428
+ /** Per-file counts so callers can map back to whatever taxonomy they care about. */
429
+ perFile: Map<string, { written: number; duplicates: number }>;
430
+ }
431
+
432
+ /**
433
+ * Batched version of `appendAutoEntry`. Groups entries by file and does one
434
+ * read-modify-write per file under a single lock acquisition.
435
+ */
436
+ export function appendAutoEntries(
437
+ mainRepoRoot: string,
438
+ entriesByFile: Map<string, KnowledgeEntry[]>
439
+ ): AppendBatchResult {
440
+ return withKnowledgeLock(mainRepoRoot, () => {
441
+ const perFile = new Map<string, { written: number; duplicates: number }>();
442
+ let written = 0;
443
+ let duplicates = 0;
444
+
445
+ for (const [file, entries] of entriesByFile) {
446
+ let content = loadOrStub(mainRepoRoot, file);
447
+ let fileWritten = 0;
448
+ let fileDuplicates = 0;
449
+
450
+ for (const entry of entries) {
451
+ const next = insertEntry(content, entry);
452
+ if (next === null) {
453
+ fileDuplicates++;
454
+ } else {
455
+ content = next;
456
+ fileWritten++;
457
+ }
458
+ }
459
+
460
+ if (fileWritten > 0) {
461
+ atomicWriteFile(knowledgePath(mainRepoRoot, file), content);
462
+ }
463
+
464
+ perFile.set(file, { written: fileWritten, duplicates: fileDuplicates });
465
+ written += fileWritten;
466
+ duplicates += fileDuplicates;
467
+ }
468
+
469
+ return { written, duplicates, perFile };
470
+ });
471
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "work-kit-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Structured development workflow for Claude Code. Two modes, 6 phases, 27 steps.",
5
5
  "type": "module",
6
6
  "bin": {