@chamba/core 0.1.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/dist/index.js ADDED
@@ -0,0 +1,1218 @@
1
+ // src/util/path.ts
2
+ function joinPath(...parts) {
3
+ return parts.filter((p) => p.length > 0).join("/").replace(/\/{2,}/g, "/");
4
+ }
5
+ function basename(path) {
6
+ const trimmed = path.replace(/\/+$/, "");
7
+ const idx = trimmed.lastIndexOf("/");
8
+ return idx === -1 ? trimmed : trimmed.slice(idx + 1);
9
+ }
10
+ function dirname(path) {
11
+ const trimmed = path.replace(/\/+$/, "");
12
+ const idx = trimmed.lastIndexOf("/");
13
+ return idx === -1 ? "" : trimmed.slice(0, idx);
14
+ }
15
+ function extname(path) {
16
+ const base = basename(path);
17
+ const idx = base.lastIndexOf(".");
18
+ return idx <= 0 ? "" : base.slice(idx);
19
+ }
20
+
21
+ // src/workspace/workspace.ts
22
+ var WORKSPACE_DIR = ".chamba";
23
+ var WORKSPACE_FILE = "workspace.md";
24
+ var WORKSPACE_RELATIVE_PATH = `${WORKSPACE_DIR}/${WORKSPACE_FILE}`;
25
+ function renderWorkspaceMarkdown(ws) {
26
+ const lines = [];
27
+ lines.push("# Workspace");
28
+ lines.push("");
29
+ lines.push("> Generated by chamba. Editable by hand \u2014 chamba will not overwrite");
30
+ lines.push("> your edits; `workspace_reload` only proposes a diff.");
31
+ lines.push("");
32
+ lines.push("## Description");
33
+ lines.push("");
34
+ lines.push(
35
+ ws.description.trim().length > 0 ? ws.description.trim() : "_No description detected._"
36
+ );
37
+ lines.push("");
38
+ lines.push("## Languages");
39
+ lines.push("");
40
+ if (ws.languages.length > 0) {
41
+ for (const lang of ws.languages) lines.push(`- ${lang}`);
42
+ } else {
43
+ lines.push("_None detected._");
44
+ }
45
+ lines.push("");
46
+ lines.push("## Framework");
47
+ lines.push("");
48
+ lines.push(ws.framework ?? "_None detected._");
49
+ lines.push("");
50
+ lines.push("## Conventions");
51
+ lines.push("");
52
+ if (ws.conventions.length > 0) {
53
+ for (const c of ws.conventions) lines.push(`- ${c}`);
54
+ } else {
55
+ lines.push("_None detected._");
56
+ }
57
+ lines.push("");
58
+ lines.push("## Active projects");
59
+ lines.push("");
60
+ if (ws.projects.length > 0) {
61
+ for (const p of ws.projects) {
62
+ const meta = [p.language, p.framework].filter(Boolean).join(", ");
63
+ lines.push(`- **${p.name}** (\`${p.path}\`)${meta ? ` \u2014 ${meta}` : ""}`);
64
+ }
65
+ } else {
66
+ lines.push("_None detected._");
67
+ }
68
+ lines.push("");
69
+ lines.push("## Folder map");
70
+ lines.push("");
71
+ if (ws.folderMap.length > 0) {
72
+ for (const dir of ws.folderMap) lines.push(`- ${dir}/`);
73
+ } else {
74
+ lines.push("_Empty._");
75
+ }
76
+ lines.push("");
77
+ return lines.join("\n");
78
+ }
79
+
80
+ // src/memory/filesystem-store.ts
81
+ var MEMORY_DIR = `${WORKSPACE_DIR}/memory`;
82
+ var FilesystemMemoryStore = class {
83
+ constructor(fs, clock, root) {
84
+ this.fs = fs;
85
+ this.clock = clock;
86
+ this.root = root;
87
+ }
88
+ fs;
89
+ clock;
90
+ root;
91
+ dir() {
92
+ return joinPath(this.root, MEMORY_DIR);
93
+ }
94
+ pathFor(key) {
95
+ return joinPath(this.dir(), `${slugifyKey(key)}.md`);
96
+ }
97
+ async remember(input) {
98
+ const path = this.pathFor(input.key);
99
+ const now = this.clock.now().toISOString();
100
+ const existing = await this.read(path);
101
+ let memory;
102
+ if (existing) {
103
+ memory = {
104
+ key: existing.key,
105
+ tags: [.../* @__PURE__ */ new Set([...existing.tags, ...input.tags ?? []])],
106
+ createdAt: existing.createdAt,
107
+ updatedAt: now,
108
+ content: `${existing.content.trimEnd()}
109
+
110
+ ## Update ${now}
111
+
112
+ ${input.content.trim()}`,
113
+ path
114
+ };
115
+ } else {
116
+ memory = {
117
+ key: input.key,
118
+ tags: input.tags ?? [],
119
+ createdAt: now,
120
+ updatedAt: now,
121
+ content: input.content.trim(),
122
+ path
123
+ };
124
+ }
125
+ await this.fs.mkdir(this.dir());
126
+ await this.fs.writeFile(path, render(memory));
127
+ return memory;
128
+ }
129
+ async recall(query) {
130
+ const keywords = query.toLowerCase().split(/\s+/).filter((w) => w.length > 0);
131
+ if (keywords.length === 0) return [];
132
+ let entries;
133
+ try {
134
+ entries = await this.fs.readDir(this.dir());
135
+ } catch {
136
+ return [];
137
+ }
138
+ const scored = [];
139
+ for (const entry of entries) {
140
+ if (!entry.isFile || !entry.name.toLowerCase().endsWith(".md")) continue;
141
+ const memory = await this.read(joinPath(this.dir(), entry.name));
142
+ if (!memory) continue;
143
+ const haystack = `${memory.key} ${memory.tags.join(" ")} ${memory.content}`.toLowerCase();
144
+ const score = keywords.filter((k) => haystack.includes(k)).length;
145
+ if (score > 0) scored.push({ memory, score });
146
+ }
147
+ scored.sort((a, b) => b.score - a.score);
148
+ return scored.map((s) => s.memory);
149
+ }
150
+ async read(path) {
151
+ let text;
152
+ try {
153
+ text = await this.fs.readFile(path);
154
+ } catch {
155
+ return null;
156
+ }
157
+ return parse(text, path);
158
+ }
159
+ };
160
+ function slugifyKey(key) {
161
+ const slug = key.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "");
162
+ return slug.length > 0 ? slug : "memory";
163
+ }
164
+ function render(memory) {
165
+ const frontmatter = [
166
+ "---",
167
+ `key: ${memory.key}`,
168
+ `tags: [${memory.tags.join(", ")}]`,
169
+ `createdAt: ${memory.createdAt}`,
170
+ `updatedAt: ${memory.updatedAt}`,
171
+ "---"
172
+ ].join("\n");
173
+ return `${frontmatter}
174
+
175
+ ${memory.content.trim()}
176
+ `;
177
+ }
178
+ function parse(text, path) {
179
+ const fm = text.match(/^---\n([\s\S]*?)\n---\n?/);
180
+ const meta = {};
181
+ if (fm?.[1]) {
182
+ for (const line of fm[1].split("\n")) {
183
+ const m = line.match(/^(\w+):\s*(.*)$/);
184
+ if (m?.[1]) meta[m[1]] = (m[2] ?? "").trim();
185
+ }
186
+ }
187
+ const body = fm ? text.slice(fm[0].length) : text;
188
+ const tagsRaw = (meta.tags ?? "").replace(/^\[|\]$/g, "");
189
+ const tags = tagsRaw.split(",").map((t) => t.trim()).filter((t) => t.length > 0);
190
+ return {
191
+ key: meta.key ?? slugFromPath(path),
192
+ tags,
193
+ createdAt: meta.createdAt ?? "",
194
+ updatedAt: meta.updatedAt ?? meta.createdAt ?? "",
195
+ content: body.trim(),
196
+ path
197
+ };
198
+ }
199
+ function slugFromPath(path) {
200
+ const file = path.slice(path.lastIndexOf("/") + 1);
201
+ return file.replace(/\.md$/, "");
202
+ }
203
+
204
+ // src/obsidian/note-template.ts
205
+ function slugify(title) {
206
+ const slug = title.toLowerCase().normalize("NFD").replace(/\p{Diacritic}/gu, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
207
+ return slug.length > 0 ? slug : "note";
208
+ }
209
+ function renderNote(fields) {
210
+ const tags = fields.tags.map((t) => slugify(t)).filter((t) => t.length > 0);
211
+ const frontmatter = [
212
+ "---",
213
+ `title: "${escapeYaml(fields.title)}"`,
214
+ `date: ${fields.date}`,
215
+ `tags: [${tags.join(", ")}]`,
216
+ "source: chamba",
217
+ "---"
218
+ ].join("\n");
219
+ return `${frontmatter}
220
+
221
+ # ${fields.title}
222
+
223
+ ${fields.body.trim()}
224
+ `;
225
+ }
226
+ function escapeYaml(value) {
227
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
228
+ }
229
+
230
+ // src/obsidian/vault-writer.ts
231
+ var VAULT_NOTES_DIR = "proyectos";
232
+ var VaultWriter = class {
233
+ constructor(fs, clock) {
234
+ this.fs = fs;
235
+ this.clock = clock;
236
+ }
237
+ fs;
238
+ clock;
239
+ async write(input) {
240
+ const date = this.clock.today();
241
+ const slug = slugify(input.projectSlug ?? input.title);
242
+ const dir = joinPath(input.vaultPath, VAULT_NOTES_DIR);
243
+ const notePath = joinPath(dir, `${date}-${slug}.md`);
244
+ const note = renderNote({
245
+ title: input.title,
246
+ date,
247
+ tags: input.tags && input.tags.length > 0 ? input.tags : ["chamba", "project"],
248
+ body: input.content
249
+ });
250
+ await this.fs.mkdir(dir);
251
+ await this.fs.writeFile(notePath, note);
252
+ return { notePath };
253
+ }
254
+ };
255
+
256
+ // src/plan/validator.ts
257
+ var SENSITIVE_RE = /\b(auth|authentication|authorization|payments?|billing|migrations?|secrets?|credentials?|passwords?)\b/i;
258
+ var SENSITIVE_PATH_RE = /(^|\/)(auth|payments|billing|migrations|secrets?|credentials)(\/|$)/i;
259
+ var WORKER_RE = /\b(implementer|tester|reviewer|worker)\b/i;
260
+ var TESTS_RE = /\b(tests?|vitest|jest|spec|unit test|integration test)\b/i;
261
+ var PLACEHOLDER_RE = /\b(todo|tbd|fixme|placeholder)\b/i;
262
+ var DEFAULT_MODULES = /* @__PURE__ */ new Set([
263
+ "packages",
264
+ "examples",
265
+ "src",
266
+ "test",
267
+ "tests",
268
+ "scripts",
269
+ "docs",
270
+ "app",
271
+ ".chamba",
272
+ ".github"
273
+ ]);
274
+ function validatePlan(input) {
275
+ const plan = input.plan;
276
+ const lower = plan.toLowerCase();
277
+ const sec = sections(plan);
278
+ const issues = [];
279
+ const suggestions = [];
280
+ const riskFlags = [];
281
+ const acItems = listItems(getSection(sec, "acceptance criteria")).filter(isConcrete);
282
+ if (!lower.includes("acceptance criteria") || acItems.length === 0) {
283
+ issues.push({
284
+ code: "no-acceptance-criteria",
285
+ severity: "error",
286
+ message: "No explicit acceptance criteria with at least one concrete, checkable item."
287
+ });
288
+ suggestions.push('Add an "## Acceptance criteria" section with checkable bullets.');
289
+ }
290
+ const negatedTests = /\b(no|sin|without)\s+(unit\s+|integration\s+)?tests?\b/i.test(lower);
291
+ if (!TESTS_RE.test(lower) || negatedTests) {
292
+ issues.push({
293
+ code: "no-tests",
294
+ severity: "error",
295
+ message: "No tests mentioned. State how the change will be verified."
296
+ });
297
+ suggestions.push("Add a tester subtask or a test-related acceptance criterion.");
298
+ }
299
+ const subtaskItems = listItems(getSection(sec, "subtasks"));
300
+ if (subtaskItems.length === 0) {
301
+ issues.push({
302
+ code: "no-subtasks",
303
+ severity: "error",
304
+ message: "No subtasks listed. Break the work into concrete steps."
305
+ });
306
+ } else {
307
+ if (!subtaskItems.some((it) => WORKER_RE.test(it))) {
308
+ issues.push({
309
+ code: "subtask-without-worker",
310
+ severity: "error",
311
+ message: "Subtasks have no worker assigned (implementer / tester / reviewer)."
312
+ });
313
+ suggestions.push("Prefix each subtask with the responsible worker.");
314
+ }
315
+ const vague = subtaskItems.filter((it) => !isConcrete(it)).length;
316
+ if (vague > 0) {
317
+ issues.push({
318
+ code: "vague-subtask",
319
+ severity: "warning",
320
+ message: `${vague} subtask(s) lack a concrete description (placeholder or too short).`
321
+ });
322
+ }
323
+ }
324
+ const files = extractPaths(plan);
325
+ if (input.workspace && files.length > 0) {
326
+ const known = knownModules(input.workspace);
327
+ const outside = [...new Set(files.filter((f) => !known.has(topSegment(f))))];
328
+ if (outside.length > 0) {
329
+ riskFlags.push(`Touches files outside known modules: ${outside.join(", ")}`);
330
+ suggestions.push("Confirm these paths are intended; they are not in the workspace map.");
331
+ }
332
+ }
333
+ const sensitive = SENSITIVE_RE.test(plan) || files.some((f) => SENSITIVE_PATH_RE.test(f));
334
+ const hasRisk = listItems(getSection(sec, "risk")).filter(isConcrete).length > 0;
335
+ if (sensitive) {
336
+ riskFlags.push("References a sensitive area (auth / payments / migrations / secrets).");
337
+ if (!hasRisk) {
338
+ issues.push({
339
+ code: "missing-risk-assessment",
340
+ severity: "error",
341
+ message: "Touches a sensitive area but has no risk assessment in the Risks section."
342
+ });
343
+ suggestions.push('Add a concrete entry under "## Risks" describing the risk and mitigation.');
344
+ }
345
+ }
346
+ return { issues, suggestions, riskFlags };
347
+ }
348
+ function sections(plan) {
349
+ const map = /* @__PURE__ */ new Map([["", []]]);
350
+ let current = "";
351
+ for (const line of plan.split("\n")) {
352
+ const heading = line.match(/^#{1,6}\s+(.*)$/);
353
+ if (heading) {
354
+ current = (heading[1] ?? "").trim().toLowerCase();
355
+ if (!map.has(current)) map.set(current, []);
356
+ } else {
357
+ map.get(current)?.push(line);
358
+ }
359
+ }
360
+ return map;
361
+ }
362
+ function getSection(map, name) {
363
+ for (const [heading, lines] of map) {
364
+ if (heading.includes(name)) return lines;
365
+ }
366
+ return [];
367
+ }
368
+ function listItems(lines) {
369
+ const items = [];
370
+ for (const line of lines) {
371
+ const m = line.match(/^\s*(?:\d+\.|[-*])\s+(.*)$/);
372
+ if (m) items.push((m[1] ?? "").trim());
373
+ }
374
+ return items;
375
+ }
376
+ function isConcrete(item) {
377
+ const stripped = item.replace(/<!--.*?-->/g, "").replace(/\[[ xX]?\]/g, "").replace(/\*\*/g, "").trim();
378
+ if (stripped.length < 6) return false;
379
+ if (/^\.+$/.test(stripped)) return false;
380
+ if (PLACEHOLDER_RE.test(stripped)) return false;
381
+ return true;
382
+ }
383
+ function extractPaths(plan) {
384
+ const matches = plan.match(/[\w.@-]+(?:\/[\w.@-]+)+/g) ?? [];
385
+ return matches.filter((p) => !p.startsWith("http") && !p.includes("://"));
386
+ }
387
+ function topSegment(path) {
388
+ const clean = path.replace(/^\.\//, "").replace(/^\//, "");
389
+ return clean.split("/")[0] ?? clean;
390
+ }
391
+ function knownModules(ws) {
392
+ const known = new Set(DEFAULT_MODULES);
393
+ for (const dir of ws.folderMap) known.add(dir);
394
+ for (const p of ws.projects) {
395
+ if (p.path !== ".") known.add(topSegment(p.path));
396
+ }
397
+ return known;
398
+ }
399
+
400
+ // src/plan/reviewer.ts
401
+ var Reviewer = class {
402
+ review(input) {
403
+ const { issues, suggestions, riskFlags } = validatePlan(input);
404
+ const approved = !issues.some((i) => i.severity === "error");
405
+ return { approved, issues, suggestions, riskFlags };
406
+ }
407
+ };
408
+
409
+ // src/plan/template.ts
410
+ function suggestSubtasks() {
411
+ return [
412
+ {
413
+ title: "Implement the change",
414
+ worker: "implementer",
415
+ description: "<!-- what to build; keep it concrete -->",
416
+ filesLikelyTouched: []
417
+ },
418
+ {
419
+ title: "Write or extend tests",
420
+ worker: "tester",
421
+ description: "<!-- which behaviours to cover with tests -->",
422
+ filesLikelyTouched: []
423
+ }
424
+ ];
425
+ }
426
+ function suggestFilesLikelyTouched(workspace) {
427
+ if (!workspace) return [];
428
+ const fromProjects = workspace.projects.filter((p) => p.path !== ".").map((p) => p.path);
429
+ return fromProjects.length > 0 ? fromProjects : workspace.folderMap.map((d) => `${d}/`);
430
+ }
431
+ function generatePlanTemplate(input) {
432
+ const files = suggestFilesLikelyTouched(input.workspace);
433
+ const subtasks = suggestSubtasks();
434
+ const lines = [];
435
+ lines.push(`# Plan: ${input.task}`);
436
+ lines.push("");
437
+ if (input.context && input.context.trim().length > 0) {
438
+ lines.push("## Context");
439
+ lines.push("");
440
+ lines.push("<!-- Loaded context (from chamba_load_context). Use it; do not delete. -->");
441
+ lines.push(input.context.trim());
442
+ lines.push("");
443
+ }
444
+ lines.push("## Goal");
445
+ lines.push("");
446
+ lines.push('<!-- One sentence: what does "done" look like? -->');
447
+ lines.push("");
448
+ lines.push("## Acceptance criteria");
449
+ lines.push("");
450
+ lines.push("- [ ] <!-- a concrete, checkable criterion -->");
451
+ lines.push("- [ ] Tests cover the new behaviour");
452
+ lines.push("");
453
+ lines.push("## Subtasks");
454
+ lines.push("");
455
+ subtasks.forEach((s, i) => {
456
+ lines.push(`${i + 1}. **${s.worker}** \u2014 ${s.title}`);
457
+ lines.push(` - ${s.description}`);
458
+ lines.push(" - files likely touched: <!-- list paths -->");
459
+ });
460
+ lines.push("");
461
+ lines.push("## Risks");
462
+ lines.push("");
463
+ lines.push("<!-- Flag anything touching auth, payments, or database migrations. -->");
464
+ lines.push('- <!-- risk or "none identified" -->');
465
+ lines.push("");
466
+ lines.push("## Files likely touched");
467
+ lines.push("");
468
+ if (files.length > 0) {
469
+ for (const f of files) lines.push(`- \`${f}\``);
470
+ } else {
471
+ lines.push("- <!-- list the files/modules this will change -->");
472
+ }
473
+ lines.push("");
474
+ return lines.join("\n");
475
+ }
476
+
477
+ // src/testing/fake-process.ts
478
+ var FakeProcess = class {
479
+ constructor(handler = () => ({})) {
480
+ this.handler = handler;
481
+ }
482
+ handler;
483
+ calls = [];
484
+ async exec(command, args, options) {
485
+ this.calls.push({ command, args, cwd: options?.cwd });
486
+ const out = this.handler(command, args);
487
+ return { stdout: out.stdout ?? "", stderr: out.stderr ?? "", exitCode: out.exitCode ?? 0 };
488
+ }
489
+ };
490
+
491
+ // src/testing/memory-filesystem.ts
492
+ var MemoryFilesystem = class {
493
+ files = /* @__PURE__ */ new Map();
494
+ dirs = /* @__PURE__ */ new Set();
495
+ constructor(initial = {}) {
496
+ for (const [path, content] of Object.entries(initial)) {
497
+ this.set(path, content);
498
+ }
499
+ }
500
+ norm(path) {
501
+ const n = path.replace(/\/+$/, "");
502
+ return n.length === 0 ? "/" : n;
503
+ }
504
+ set(path, content) {
505
+ const norm = this.norm(path);
506
+ this.files.set(norm, content);
507
+ let dir = norm;
508
+ while (dir.includes("/")) {
509
+ const idx = dir.lastIndexOf("/");
510
+ dir = idx === 0 ? "/" : dir.slice(0, idx);
511
+ this.dirs.add(dir);
512
+ if (dir === "/") break;
513
+ }
514
+ }
515
+ async readFile(path) {
516
+ const content = this.files.get(this.norm(path));
517
+ if (content === void 0) throw new Error(`ENOENT: no such file '${path}'`);
518
+ return content;
519
+ }
520
+ async writeFile(path, content) {
521
+ this.set(path, content);
522
+ }
523
+ async exists(path) {
524
+ const norm = this.norm(path);
525
+ return this.files.has(norm) || this.dirs.has(norm);
526
+ }
527
+ async mkdir(path) {
528
+ this.dirs.add(this.norm(path));
529
+ }
530
+ async remove(path) {
531
+ const norm = this.norm(path);
532
+ this.files.delete(norm);
533
+ this.dirs.delete(norm);
534
+ const prefix = `${norm}/`;
535
+ for (const file of [...this.files.keys()]) {
536
+ if (file.startsWith(prefix)) this.files.delete(file);
537
+ }
538
+ for (const dir of [...this.dirs]) {
539
+ if (dir.startsWith(prefix)) this.dirs.delete(dir);
540
+ }
541
+ }
542
+ async readDir(path) {
543
+ const norm = this.norm(path);
544
+ const prefix = norm === "/" ? "/" : `${norm}/`;
545
+ const seen = /* @__PURE__ */ new Set();
546
+ const entries = [];
547
+ const pushChild = (full, isDir) => {
548
+ if (!full.startsWith(prefix) || full === norm) return;
549
+ const rest = full.slice(prefix.length);
550
+ const slash = rest.indexOf("/");
551
+ const name = slash === -1 ? rest : rest.slice(0, slash);
552
+ const childIsDir = slash !== -1 || isDir;
553
+ if (name.length === 0 || seen.has(name)) return;
554
+ seen.add(name);
555
+ entries.push({ name, isDirectory: childIsDir, isFile: !childIsDir });
556
+ };
557
+ for (const file of this.files.keys()) pushChild(file, false);
558
+ for (const dir of this.dirs) pushChild(dir, true);
559
+ return entries;
560
+ }
561
+ };
562
+
563
+ // src/workspace/context-builder.ts
564
+ var DEFAULT_MAX_TOKENS = 2e3;
565
+ var NOTE_SCAN_MAX_DEPTH = 8;
566
+ var MAX_NOTES = 5;
567
+ var SKIP_DIRS = /* @__PURE__ */ new Set([".obsidian", ".trash", "node_modules", ".git"]);
568
+ var STOPWORDS = /* @__PURE__ */ new Set([
569
+ "the",
570
+ "and",
571
+ "for",
572
+ "with",
573
+ "add",
574
+ "use",
575
+ "that",
576
+ "this",
577
+ "into",
578
+ "from",
579
+ "all",
580
+ "can",
581
+ "should",
582
+ "una",
583
+ "unos",
584
+ "para",
585
+ "con",
586
+ "los",
587
+ "las",
588
+ "del"
589
+ ]);
590
+ var ContextBuilder = class {
591
+ constructor(fs) {
592
+ this.fs = fs;
593
+ }
594
+ fs;
595
+ async build(input) {
596
+ const sections2 = [this.workspaceSection(input.workspace)];
597
+ let relevantNotes = [];
598
+ if (input.vaultPath) {
599
+ const notes = await this.searchNotes(input.vaultPath, input.task);
600
+ relevantNotes = notes.map((n) => n.path);
601
+ sections2.push(this.notesSection(notes));
602
+ }
603
+ const maxChars = (input.maxTokens ?? DEFAULT_MAX_TOKENS) * 4;
604
+ const context = clamp(sections2.join("\n\n"), maxChars);
605
+ return { context, relevantNotes };
606
+ }
607
+ workspaceSection(ws) {
608
+ const lines = ["## Workspace context", "", ws.description.trim()];
609
+ if (ws.languages.length > 0) lines.push("", `Languages: ${ws.languages.join(", ")}`);
610
+ if (ws.framework) lines.push(`Framework: ${ws.framework}`);
611
+ if (ws.projects.length > 0) {
612
+ lines.push("", "Projects:");
613
+ for (const p of ws.projects) lines.push(`- ${p.name} (\`${p.path}\`)`);
614
+ }
615
+ return lines.join("\n");
616
+ }
617
+ notesSection(notes) {
618
+ if (notes.length === 0) {
619
+ return "## Relevant notes\n\nNo vault notes matched this task.";
620
+ }
621
+ const lines = ["## Relevant notes", ""];
622
+ for (const note of notes) {
623
+ lines.push(`- \`${note.path}\` \u2014 ${note.snippet}`);
624
+ }
625
+ return lines.join("\n");
626
+ }
627
+ async searchNotes(vaultPath, task) {
628
+ const keywords = extractKeywords(task);
629
+ if (keywords.length === 0) return [];
630
+ const files = await this.collectMarkdown(vaultPath);
631
+ const scored = [];
632
+ for (const file of files) {
633
+ const text = await this.tryRead(file);
634
+ if (text === null) continue;
635
+ const lower = text.toLowerCase();
636
+ let score = 0;
637
+ for (const kw of keywords) {
638
+ score += occurrences(lower, kw);
639
+ }
640
+ if (score > 0) {
641
+ scored.push({ path: file, score, snippet: firstMatchingLine(text, keywords) });
642
+ }
643
+ }
644
+ scored.sort((a, b) => b.score - a.score);
645
+ return scored.slice(0, MAX_NOTES);
646
+ }
647
+ async collectMarkdown(root) {
648
+ const out = [];
649
+ const visit = async (dir, depth) => {
650
+ if (depth > NOTE_SCAN_MAX_DEPTH) return;
651
+ let entries;
652
+ try {
653
+ entries = await this.fs.readDir(dir);
654
+ } catch {
655
+ return;
656
+ }
657
+ for (const entry of entries) {
658
+ const full = joinPath(dir, entry.name);
659
+ if (entry.isDirectory) {
660
+ if (SKIP_DIRS.has(entry.name)) continue;
661
+ await visit(full, depth + 1);
662
+ } else if (entry.name.toLowerCase().endsWith(".md")) {
663
+ out.push(full);
664
+ }
665
+ }
666
+ };
667
+ await visit(root, 0);
668
+ return out;
669
+ }
670
+ async tryRead(path) {
671
+ try {
672
+ return await this.fs.readFile(path);
673
+ } catch {
674
+ return null;
675
+ }
676
+ }
677
+ };
678
+ function extractKeywords(task) {
679
+ const seen = /* @__PURE__ */ new Set();
680
+ for (const raw of task.toLowerCase().split(/[^a-z0-9áéíóúñ]+/i)) {
681
+ const w = raw.trim();
682
+ if (w.length >= 3 && !STOPWORDS.has(w)) seen.add(w);
683
+ }
684
+ return [...seen];
685
+ }
686
+ function occurrences(haystack, needle) {
687
+ if (needle.length === 0) return 0;
688
+ let count = 0;
689
+ let idx = haystack.indexOf(needle);
690
+ while (idx !== -1) {
691
+ count++;
692
+ idx = haystack.indexOf(needle, idx + needle.length);
693
+ }
694
+ return count;
695
+ }
696
+ function firstMatchingLine(text, keywords) {
697
+ for (const raw of text.split("\n")) {
698
+ const line = raw.trim();
699
+ if (line.length === 0) continue;
700
+ const lower = line.toLowerCase();
701
+ if (keywords.some((kw) => lower.includes(kw))) {
702
+ return line.length > 120 ? `${line.slice(0, 117)}...` : line;
703
+ }
704
+ }
705
+ return "(matched)";
706
+ }
707
+ function clamp(text, maxChars) {
708
+ if (text.length <= maxChars) return text;
709
+ return `${text.slice(0, Math.max(0, maxChars - 1))}\u2026`;
710
+ }
711
+
712
+ // src/workspace/diff.ts
713
+ function diffLines(oldText, newText) {
714
+ const a = oldText.split("\n");
715
+ const b = newText.split("\n");
716
+ const n = a.length;
717
+ const m = b.length;
718
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
719
+ for (let i2 = n - 1; i2 >= 0; i2--) {
720
+ const row = dp[i2];
721
+ const next = dp[i2 + 1];
722
+ for (let j2 = m - 1; j2 >= 0; j2--) {
723
+ row[j2] = a[i2] === b[j2] ? next[j2 + 1] + 1 : Math.max(next[j2], row[j2 + 1]);
724
+ }
725
+ }
726
+ const out = [];
727
+ let i = 0;
728
+ let j = 0;
729
+ while (i < n && j < m) {
730
+ if (a[i] === b[j]) {
731
+ out.push(` ${a[i]}`);
732
+ i++;
733
+ j++;
734
+ } else if ((dp[i + 1]?.[j] ?? 0) >= (dp[i]?.[j + 1] ?? 0)) {
735
+ out.push(`- ${a[i]}`);
736
+ i++;
737
+ } else {
738
+ out.push(`+ ${b[j]}`);
739
+ j++;
740
+ }
741
+ }
742
+ while (i < n) out.push(`- ${a[i++]}`);
743
+ while (j < m) out.push(`+ ${b[j++]}`);
744
+ return out.join("\n");
745
+ }
746
+ function textsEqual(oldText, newText) {
747
+ return oldText === newText;
748
+ }
749
+
750
+ // src/workspace/obsidian-detector.ts
751
+ var COUNT_MAX_DEPTH = 8;
752
+ var SKIP_DIRS2 = /* @__PURE__ */ new Set([".obsidian", ".trash", "node_modules", ".git"]);
753
+ var ObsidianDetector = class {
754
+ constructor(fs) {
755
+ this.fs = fs;
756
+ }
757
+ fs;
758
+ async detect(opts) {
759
+ if (opts.explicitPath) {
760
+ if (await this.fs.exists(opts.explicitPath)) {
761
+ return {
762
+ found: true,
763
+ path: opts.explicitPath,
764
+ noteCount: await this.countNotes(opts.explicitPath)
765
+ };
766
+ }
767
+ return { found: false };
768
+ }
769
+ for (const root of opts.searchRoots ?? []) {
770
+ if (await this.fs.exists(joinPath(root, ".obsidian"))) {
771
+ return { found: true, path: root, noteCount: await this.countNotes(root) };
772
+ }
773
+ }
774
+ return { found: false };
775
+ }
776
+ async countNotes(vault) {
777
+ let count = 0;
778
+ const visit = async (dir, depth) => {
779
+ if (depth > COUNT_MAX_DEPTH) return;
780
+ let entries;
781
+ try {
782
+ entries = await this.fs.readDir(dir);
783
+ } catch {
784
+ return;
785
+ }
786
+ for (const entry of entries) {
787
+ if (entry.isDirectory) {
788
+ if (SKIP_DIRS2.has(entry.name)) continue;
789
+ await visit(joinPath(dir, entry.name), depth + 1);
790
+ } else if (entry.name.toLowerCase().endsWith(".md")) {
791
+ count++;
792
+ }
793
+ }
794
+ };
795
+ await visit(vault, 0);
796
+ return count;
797
+ }
798
+ };
799
+
800
+ // src/workspace/scanner.ts
801
+ var MAX_DEPTH = 6;
802
+ var DEFAULT_IGNORES = [
803
+ "node_modules",
804
+ ".git",
805
+ "dist",
806
+ "build",
807
+ "coverage",
808
+ ".next",
809
+ ".turbo",
810
+ ".cache",
811
+ ".DS_Store"
812
+ ];
813
+ var LANGUAGE_BY_EXT = {
814
+ ".ts": "TypeScript",
815
+ ".tsx": "TypeScript",
816
+ ".mts": "TypeScript",
817
+ ".cts": "TypeScript",
818
+ ".js": "JavaScript",
819
+ ".jsx": "JavaScript",
820
+ ".mjs": "JavaScript",
821
+ ".cjs": "JavaScript",
822
+ ".py": "Python",
823
+ ".rs": "Rust",
824
+ ".go": "Go",
825
+ ".rb": "Ruby",
826
+ ".java": "Java",
827
+ ".kt": "Kotlin",
828
+ ".php": "PHP",
829
+ ".cs": "C#",
830
+ ".swift": "Swift",
831
+ ".c": "C",
832
+ ".cpp": "C++",
833
+ ".sh": "Shell"
834
+ };
835
+ var MANIFEST_FILES = /* @__PURE__ */ new Set(["package.json", "pyproject.toml", "Cargo.toml", "go.mod"]);
836
+ var FRAMEWORKS = [
837
+ ["next", "Next.js"],
838
+ ["@nestjs/core", "NestJS"],
839
+ ["@angular/core", "Angular"],
840
+ ["remix", "Remix"],
841
+ ["astro", "Astro"],
842
+ ["svelte", "Svelte"],
843
+ ["vue", "Vue"],
844
+ ["react", "React"],
845
+ ["hono", "Hono"],
846
+ ["fastify", "Fastify"],
847
+ ["express", "Express"]
848
+ ];
849
+ var PY_FRAMEWORKS = [
850
+ ["django", "Django"],
851
+ ["fastapi", "FastAPI"],
852
+ ["flask", "Flask"]
853
+ ];
854
+ var WorkspaceScanner = class {
855
+ constructor(fs) {
856
+ this.fs = fs;
857
+ }
858
+ fs;
859
+ async scan(root) {
860
+ const rules = await this.loadIgnoreRules(root);
861
+ const acc = { topDirs: /* @__PURE__ */ new Set(), exts: /* @__PURE__ */ new Set(), manifests: [] };
862
+ await this.walk(root, "", 0, rules, acc);
863
+ const languages = this.detectLanguages(acc.exts);
864
+ const projects = await this.detectProjects(root, acc.manifests, languages);
865
+ const rootProject = projects.find((p) => p.path === ".");
866
+ const framework = rootProject?.framework ?? projects.find((p) => p.framework)?.framework;
867
+ const conventions = await this.detectConventions(root);
868
+ const description = await this.detectDescription(root, rootProject, framework, languages);
869
+ return {
870
+ root,
871
+ description,
872
+ languages,
873
+ framework,
874
+ conventions,
875
+ projects,
876
+ folderMap: [...acc.topDirs].sort()
877
+ };
878
+ }
879
+ async loadIgnoreRules(root) {
880
+ const patterns = [...DEFAULT_IGNORES];
881
+ for (const file of [".gitignore", ".dockerignore"]) {
882
+ const text = await this.tryRead(joinPath(root, file));
883
+ if (text !== null) patterns.push(...parseIgnoreFile(text));
884
+ }
885
+ return patterns.map(compileIgnore);
886
+ }
887
+ async walk(root, rel, depth, rules, acc) {
888
+ if (depth > MAX_DEPTH) return;
889
+ const dirPath = rel.length > 0 ? joinPath(root, rel) : root;
890
+ let entries;
891
+ try {
892
+ entries = await this.fs.readDir(dirPath);
893
+ } catch {
894
+ return;
895
+ }
896
+ for (const entry of entries) {
897
+ const childRel = rel.length > 0 ? `${rel}/${entry.name}` : entry.name;
898
+ if (isIgnored(rules, childRel, entry.isDirectory)) continue;
899
+ if (entry.isDirectory) {
900
+ if (depth === 0) acc.topDirs.add(entry.name);
901
+ await this.walk(root, childRel, depth + 1, rules, acc);
902
+ } else {
903
+ const ext = extname(entry.name);
904
+ if (ext.length > 0) acc.exts.add(ext);
905
+ if (MANIFEST_FILES.has(entry.name)) acc.manifests.push(childRel);
906
+ }
907
+ }
908
+ }
909
+ detectLanguages(exts) {
910
+ const langs = /* @__PURE__ */ new Set();
911
+ for (const ext of exts) {
912
+ const lang = LANGUAGE_BY_EXT[ext];
913
+ if (lang) langs.add(lang);
914
+ }
915
+ return [...langs].sort();
916
+ }
917
+ async detectProjects(root, manifests, languages) {
918
+ const projects = [];
919
+ for (const manifestRel of manifests) {
920
+ const dir = dirname(manifestRel);
921
+ const name = basename(manifestRel);
922
+ const project = await this.readProject(root, manifestRel, dir, name, languages);
923
+ if (project) projects.push(project);
924
+ }
925
+ projects.sort(
926
+ (a, b) => a.path === "." ? -1 : b.path === "." ? 1 : a.path.localeCompare(b.path)
927
+ );
928
+ return projects;
929
+ }
930
+ async readProject(root, manifestRel, dir, manifestName, languages) {
931
+ const path = dir.length === 0 ? "." : dir;
932
+ const fallbackName = path === "." ? basename(root) : basename(dir);
933
+ if (manifestName === "package.json") {
934
+ const text = await this.tryRead(joinPath(root, manifestRel));
935
+ const pkg = text !== null ? safeJsonParse(text) : null;
936
+ const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : fallbackName;
937
+ const language = languages.includes("TypeScript") ? "TypeScript" : "JavaScript";
938
+ return { name, path, language, framework: detectNodeFramework(pkg) };
939
+ }
940
+ if (manifestName === "pyproject.toml") {
941
+ const text = await this.tryRead(joinPath(root, manifestRel)) ?? "";
942
+ return {
943
+ name: tomlName(text) ?? fallbackName,
944
+ path,
945
+ language: "Python",
946
+ framework: detectPyFramework(text)
947
+ };
948
+ }
949
+ if (manifestName === "Cargo.toml") {
950
+ const text = await this.tryRead(joinPath(root, manifestRel)) ?? "";
951
+ return { name: tomlName(text) ?? fallbackName, path, language: "Rust" };
952
+ }
953
+ if (manifestName === "go.mod") {
954
+ return { name: fallbackName, path, language: "Go" };
955
+ }
956
+ return null;
957
+ }
958
+ async detectConventions(root) {
959
+ const conventions = [];
960
+ const has = (file) => this.fs.exists(joinPath(root, file));
961
+ if (await has("pnpm-workspace.yaml")) conventions.push("pnpm workspaces (monorepo)");
962
+ if (await has("biome.json")) conventions.push("Biome for lint + format");
963
+ if (await has("vitest.config.ts") || await has("vitest.config.js")) {
964
+ conventions.push("Vitest for tests");
965
+ }
966
+ if (await has("tsconfig.json")) conventions.push("TypeScript with shared tsconfig");
967
+ if (await has(".editorconfig")) conventions.push("EditorConfig");
968
+ const nvmrc = await this.tryRead(joinPath(root, ".nvmrc"));
969
+ if (nvmrc !== null) conventions.push(`Node ${nvmrc.trim()}`);
970
+ return conventions;
971
+ }
972
+ async detectDescription(root, rootProject, framework, languages) {
973
+ const readme = await this.findReadme(root);
974
+ if (readme !== null) {
975
+ const line = firstProseLine(readme);
976
+ if (line.length > 0) return line;
977
+ }
978
+ const name = rootProject?.name ?? basename(root);
979
+ const stack = framework ?? languages[0] ?? "project";
980
+ return `${name} \u2014 a ${stack} project.`;
981
+ }
982
+ async findReadme(root) {
983
+ let entries;
984
+ try {
985
+ entries = await this.fs.readDir(root);
986
+ } catch {
987
+ return null;
988
+ }
989
+ const readme = entries.find((e) => e.isFile && /^readme(\.|$)/i.test(e.name));
990
+ if (!readme) return null;
991
+ return this.tryRead(joinPath(root, readme.name));
992
+ }
993
+ async tryRead(path) {
994
+ try {
995
+ return await this.fs.readFile(path);
996
+ } catch {
997
+ return null;
998
+ }
999
+ }
1000
+ };
1001
+ function parseIgnoreFile(text) {
1002
+ return text.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#") && !l.startsWith("!"));
1003
+ }
1004
+ function compileIgnore(pattern) {
1005
+ let p = pattern;
1006
+ const dirOnly = p.endsWith("/");
1007
+ if (dirOnly) p = p.slice(0, -1);
1008
+ const leadingSlash = p.startsWith("/");
1009
+ if (leadingSlash) p = p.slice(1);
1010
+ const anchored = leadingSlash || p.includes("/");
1011
+ return { re: new RegExp(`^${globToRegExp(p)}$`), anchored, dirOnly };
1012
+ }
1013
+ function globToRegExp(glob) {
1014
+ let out = "";
1015
+ for (let i = 0; i < glob.length; i++) {
1016
+ const ch = glob[i];
1017
+ if (ch === "*") {
1018
+ if (glob[i + 1] === "*") {
1019
+ out += ".*";
1020
+ i++;
1021
+ } else {
1022
+ out += "[^/]*";
1023
+ }
1024
+ } else if (ch === "?") {
1025
+ out += "[^/]";
1026
+ } else if (/[.+^$(){}|[\]\\]/.test(ch)) {
1027
+ out += `\\${ch}`;
1028
+ } else {
1029
+ out += ch;
1030
+ }
1031
+ }
1032
+ return out;
1033
+ }
1034
+ function isIgnored(rules, relPath, isDir) {
1035
+ const base = basename(relPath);
1036
+ for (const rule of rules) {
1037
+ if (rule.dirOnly && !isDir) continue;
1038
+ const target = rule.anchored ? relPath : base;
1039
+ if (rule.re.test(target)) return true;
1040
+ }
1041
+ return false;
1042
+ }
1043
+ function safeJsonParse(text) {
1044
+ try {
1045
+ const parsed = JSON.parse(text);
1046
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
1047
+ } catch {
1048
+ return null;
1049
+ }
1050
+ }
1051
+ function detectNodeFramework(pkg) {
1052
+ if (!pkg) return void 0;
1053
+ const deps = {
1054
+ ...asRecord(pkg.dependencies) ?? {},
1055
+ ...asRecord(pkg.devDependencies) ?? {}
1056
+ };
1057
+ for (const [dep, label] of FRAMEWORKS) {
1058
+ if (dep in deps) return label;
1059
+ }
1060
+ return void 0;
1061
+ }
1062
+ function tomlName(toml) {
1063
+ const match = toml.match(/^\s*name\s*=\s*["']([^"']+)["']/m);
1064
+ return match?.[1];
1065
+ }
1066
+ function detectPyFramework(pyproject) {
1067
+ const lower = pyproject.toLowerCase();
1068
+ for (const [dep, label] of PY_FRAMEWORKS) {
1069
+ if (lower.includes(dep)) return label;
1070
+ }
1071
+ return void 0;
1072
+ }
1073
+ function asRecord(value) {
1074
+ return typeof value === "object" && value !== null ? value : null;
1075
+ }
1076
+ function firstProseLine(markdown) {
1077
+ for (const raw of markdown.split("\n")) {
1078
+ const line = raw.trim().replace(/^>\s?/, "").trim();
1079
+ if (line.length === 0) continue;
1080
+ if (line.startsWith("#")) continue;
1081
+ if (line.startsWith("![") || line.startsWith("<")) continue;
1082
+ return line.length > 200 ? `${line.slice(0, 197)}...` : line;
1083
+ }
1084
+ return "";
1085
+ }
1086
+
1087
+ // src/worktree/branch-naming.ts
1088
+ function slugifyForGit(value) {
1089
+ const slug = value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/\.\.+/g, "-").replace(/^[-.]+|[-.]+$/g, "");
1090
+ return slug.length > 0 ? slug : "x";
1091
+ }
1092
+ function buildBranchName(input) {
1093
+ return `chamba/${input.date}-${slugifyForGit(input.taskSlug)}/${slugifyForGit(input.workerId)}`;
1094
+ }
1095
+ function worktreeRelativePath(taskSlug, workerId) {
1096
+ return joinPath(WORKSPACE_DIR, "worktrees", slugifyForGit(taskSlug), slugifyForGit(workerId));
1097
+ }
1098
+
1099
+ // src/worktree/git-detector.ts
1100
+ var GitDetector = class {
1101
+ constructor(process) {
1102
+ this.process = process;
1103
+ }
1104
+ process;
1105
+ cache = /* @__PURE__ */ new Map();
1106
+ async isGitRepo(root) {
1107
+ const cached = this.cache.get(root);
1108
+ if (cached !== void 0) return cached;
1109
+ const result = await this.process.exec("git", ["rev-parse", "--is-inside-work-tree"], {
1110
+ cwd: root
1111
+ });
1112
+ const isRepo = result.exitCode === 0 && result.stdout.trim() === "true";
1113
+ this.cache.set(root, isRepo);
1114
+ return isRepo;
1115
+ }
1116
+ };
1117
+
1118
+ // src/worktree/manager.ts
1119
+ var WorktreeError = class extends Error {
1120
+ name = "WorktreeError";
1121
+ };
1122
+ var WorktreeManager = class {
1123
+ constructor(process) {
1124
+ this.process = process;
1125
+ }
1126
+ process;
1127
+ async create(input) {
1128
+ const branch = buildBranchName({
1129
+ date: input.date,
1130
+ taskSlug: input.taskSlug,
1131
+ workerId: input.workerId
1132
+ });
1133
+ const path = joinPath(input.root, worktreeRelativePath(input.taskSlug, input.workerId));
1134
+ const args = ["worktree", "add", "-b", branch, path];
1135
+ if (input.baseBranch) args.push(input.baseBranch);
1136
+ const result = await this.process.exec("git", args, { cwd: input.root });
1137
+ if (result.exitCode !== 0) {
1138
+ throw new WorktreeError(
1139
+ `git worktree add failed: ${result.stderr.trim() || "unknown error"}`
1140
+ );
1141
+ }
1142
+ return { branch, path, taskSlug: input.taskSlug, workerId: input.workerId };
1143
+ }
1144
+ async list(root) {
1145
+ const result = await this.process.exec("git", ["worktree", "list", "--porcelain"], {
1146
+ cwd: root
1147
+ });
1148
+ if (result.exitCode !== 0) return [];
1149
+ return parsePorcelain(result.stdout);
1150
+ }
1151
+ async cleanup(root, branch) {
1152
+ const worktrees = await this.list(root);
1153
+ const target = worktrees.find((w) => w.branch === branch);
1154
+ if (!target) {
1155
+ throw new WorktreeError(`No worktree found for branch '${branch}'.`);
1156
+ }
1157
+ const result = await this.process.exec("git", ["worktree", "remove", target.path], {
1158
+ cwd: root
1159
+ });
1160
+ if (result.exitCode !== 0) {
1161
+ throw new WorktreeError(
1162
+ `git worktree remove failed (uncommitted changes?): ${result.stderr.trim() || "unknown error"}`
1163
+ );
1164
+ }
1165
+ return { removed: true, branchKept: true, mergeSuggestion: `git merge --no-ff ${branch}` };
1166
+ }
1167
+ };
1168
+ function parsePorcelain(output) {
1169
+ const result = [];
1170
+ for (const block of output.split(/\n\s*\n/)) {
1171
+ let path;
1172
+ let head;
1173
+ let branch;
1174
+ for (const raw of block.split("\n")) {
1175
+ const line = raw.trim();
1176
+ if (line.startsWith("worktree ")) path = line.slice("worktree ".length);
1177
+ else if (line.startsWith("HEAD ")) head = line.slice("HEAD ".length);
1178
+ else if (line.startsWith("branch "))
1179
+ branch = line.slice("branch ".length).replace(/^refs\/heads\//, "");
1180
+ }
1181
+ if (path) result.push({ path, head, branch });
1182
+ }
1183
+ return result;
1184
+ }
1185
+ export {
1186
+ ContextBuilder,
1187
+ FakeProcess,
1188
+ FilesystemMemoryStore,
1189
+ GitDetector,
1190
+ MEMORY_DIR,
1191
+ MemoryFilesystem,
1192
+ ObsidianDetector,
1193
+ Reviewer,
1194
+ VAULT_NOTES_DIR,
1195
+ VaultWriter,
1196
+ WORKSPACE_DIR,
1197
+ WORKSPACE_FILE,
1198
+ WORKSPACE_RELATIVE_PATH,
1199
+ WorkspaceScanner,
1200
+ WorktreeError,
1201
+ WorktreeManager,
1202
+ basename,
1203
+ buildBranchName,
1204
+ diffLines,
1205
+ dirname,
1206
+ extname,
1207
+ generatePlanTemplate,
1208
+ joinPath,
1209
+ renderNote,
1210
+ renderWorkspaceMarkdown,
1211
+ slugify,
1212
+ slugifyForGit,
1213
+ suggestFilesLikelyTouched,
1214
+ suggestSubtasks,
1215
+ textsEqual,
1216
+ validatePlan,
1217
+ worktreeRelativePath
1218
+ };