@joshski/dust 0.1.32 → 0.1.34

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/dust.js CHANGED
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
4
+
2
5
  // lib/cli/run.ts
3
6
  import { existsSync } from "node:fs";
4
7
  import { chmod, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
@@ -6,7 +9,8 @@ import { chmod, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
6
9
  // lib/config/settings.ts
7
10
  import { join } from "node:path";
8
11
  var DEFAULT_SETTINGS = {
9
- dustCommand: "npx dust"
12
+ dustCommand: "npx dust",
13
+ installCommand: "npm install"
10
14
  };
11
15
  function detectDustCommand(cwd, fileSystem) {
12
16
  if (fileSystem.exists(join(cwd, "bun.lockb"))) {
@@ -23,6 +27,21 @@ function detectDustCommand(cwd, fileSystem) {
23
27
  }
24
28
  return "npx dust";
25
29
  }
30
+ function detectInstallCommand(cwd, fileSystem) {
31
+ if (fileSystem.exists(join(cwd, "bun.lockb"))) {
32
+ return "bun install";
33
+ }
34
+ if (fileSystem.exists(join(cwd, "pnpm-lock.yaml"))) {
35
+ return "pnpm install";
36
+ }
37
+ if (fileSystem.exists(join(cwd, "package-lock.json"))) {
38
+ return "npm install";
39
+ }
40
+ if (process.env.BUN_INSTALL) {
41
+ return "bun install";
42
+ }
43
+ return "npm install";
44
+ }
26
45
  function detectTestCommand(cwd, fileSystem) {
27
46
  if (fileSystem.exists(join(cwd, "bun.lockb")) || fileSystem.exists(join(cwd, "bun.lock"))) {
28
47
  return "bun test";
@@ -54,7 +73,8 @@ async function loadSettings(cwd, fileSystem) {
54
73
  const settingsPath = join(cwd, ".dust", "config", "settings.json");
55
74
  if (!fileSystem.exists(settingsPath)) {
56
75
  const result = {
57
- dustCommand: detectDustCommand(cwd, fileSystem)
76
+ dustCommand: detectDustCommand(cwd, fileSystem),
77
+ installCommand: detectInstallCommand(cwd, fileSystem)
58
78
  };
59
79
  if (process.env.DUST_EVENTS_URL) {
60
80
  result.eventsUrl = process.env.DUST_EVENTS_URL;
@@ -74,13 +94,17 @@ async function loadSettings(cwd, fileSystem) {
74
94
  if (!parsed.dustCommand) {
75
95
  result.dustCommand = detectDustCommand(cwd, fileSystem);
76
96
  }
97
+ if (!parsed.installCommand) {
98
+ result.installCommand = detectInstallCommand(cwd, fileSystem);
99
+ }
77
100
  if (process.env.DUST_EVENTS_URL) {
78
101
  result.eventsUrl = process.env.DUST_EVENTS_URL;
79
102
  }
80
103
  return result;
81
104
  } catch {
82
105
  const result = {
83
- dustCommand: detectDustCommand(cwd, fileSystem)
106
+ dustCommand: detectDustCommand(cwd, fileSystem),
107
+ installCommand: detectInstallCommand(cwd, fileSystem)
84
108
  };
85
109
  if (process.env.DUST_EVENTS_URL) {
86
110
  result.eventsUrl = process.env.DUST_EVENTS_URL;
@@ -123,7 +147,7 @@ import { join as join4 } from "node:path";
123
147
  // lib/agents/detection.ts
124
148
  function detectAgent(env = process.env) {
125
149
  if (env.CLAUDECODE) {
126
- if (env.CLAUDE_CODE_ENTRYPOINT === "remote") {
150
+ if (env.CLAUDE_CODE_REMOTE) {
127
151
  return { type: "claude-code-web", name: "Claude Code Web" };
128
152
  }
129
153
  return { type: "claude-code", name: "Claude Code" };
@@ -323,62 +347,10 @@ async function agent(dependencies) {
323
347
  return { exitCode: 0 };
324
348
  }
325
349
 
326
- // lib/cli/process-runner.ts
327
- import { spawn } from "node:child_process";
328
- function createShellRunner(spawnFn) {
329
- return {
330
- run: (command, cwd, timeoutMs) => runBufferedProcess(spawnFn, command, [], cwd, true, timeoutMs)
331
- };
332
- }
333
- var defaultShellRunner = createShellRunner(spawn);
334
- function createGitRunner(spawnFn) {
335
- return {
336
- run: (gitArguments, cwd) => runBufferedProcess(spawnFn, "git", gitArguments, cwd, false)
337
- };
338
- }
339
- var defaultGitRunner = createGitRunner(spawn);
340
- function runBufferedProcess(spawnFn, command, commandArguments, cwd, shell, timeoutMs) {
341
- return new Promise((resolve) => {
342
- const proc = spawnFn(command, commandArguments, { cwd, shell });
343
- const chunks = [];
344
- let resolved = false;
345
- let timer;
346
- if (timeoutMs !== undefined) {
347
- timer = setTimeout(() => {
348
- resolved = true;
349
- proc.kill();
350
- resolve({
351
- exitCode: 1,
352
- output: chunks.join(""),
353
- timedOut: true
354
- });
355
- }, timeoutMs);
356
- }
357
- proc.stdout?.on("data", (data) => {
358
- chunks.push(data.toString());
359
- });
360
- proc.stderr?.on("data", (data) => {
361
- chunks.push(data.toString());
362
- });
363
- proc.on("close", (code) => {
364
- if (resolved)
365
- return;
366
- if (timer !== undefined)
367
- clearTimeout(timer);
368
- resolve({ exitCode: code ?? 1, output: chunks.join("") });
369
- });
370
- proc.on("error", (error) => {
371
- if (resolved)
372
- return;
373
- if (timer !== undefined)
374
- clearTimeout(timer);
375
- resolve({ exitCode: 1, output: error.message });
376
- });
377
- });
378
- }
379
-
380
- // lib/cli/commands/lint-markdown.ts
381
- import { dirname as dirname2, resolve } from "node:path";
350
+ // lib/cli/commands/audit.ts
351
+ import { readdirSync, readFileSync as readFileSync2 } from "node:fs";
352
+ import { basename, dirname as dirname2, join as join5 } from "node:path";
353
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
382
354
 
383
355
  // lib/markdown/markdown-utilities.ts
384
356
  function extractTitle(content) {
@@ -432,1759 +404,3096 @@ function extractOpeningSentence(content) {
432
404
  return sentenceMatch[1];
433
405
  }
434
406
 
435
- // lib/workflow-tasks.ts
436
- var IDEA_TRANSITION_PREFIXES = [
437
- "Refine Idea: ",
438
- "Decompose Idea: ",
439
- "Shelve Idea: "
440
- ];
441
- function titleToFilename(title) {
442
- return `${title.toLowerCase().replace(/\./g, "-").replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "")}.md`;
407
+ // lib/cli/colors.ts
408
+ var ANSI_COLORS = {
409
+ reset: "\x1B[0m",
410
+ bold: "\x1B[1m",
411
+ dim: "\x1B[2m",
412
+ cyan: "\x1B[36m",
413
+ green: "\x1B[32m",
414
+ yellow: "\x1B[33m"
415
+ };
416
+ var NO_COLORS = {
417
+ reset: "",
418
+ bold: "",
419
+ dim: "",
420
+ cyan: "",
421
+ green: "",
422
+ yellow: ""
423
+ };
424
+ function shouldDisableColors() {
425
+ if (process.env.NO_COLOR !== undefined) {
426
+ return true;
427
+ }
428
+ if (process.env.TERM === "dumb") {
429
+ return true;
430
+ }
431
+ if (!process.stdout.isTTY) {
432
+ return true;
433
+ }
434
+ return false;
435
+ }
436
+ function getColors() {
437
+ return shouldDisableColors() ? NO_COLORS : ANSI_COLORS;
443
438
  }
444
439
 
445
- // lib/cli/commands/lint-markdown.ts
446
- var REQUIRED_HEADINGS = ["## Goals", "## Blocked By", "## Definition of Done"];
447
- var REQUIRED_GOAL_HEADINGS = ["## Parent Goal", "## Sub-Goals"];
448
- var SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*\.md$/;
449
- var MAX_OPENING_SENTENCE_LENGTH = 150;
450
- function validateFilename(filePath) {
451
- const parts = filePath.split("/");
452
- const filename = parts[parts.length - 1];
453
- if (!SLUG_PATTERN.test(filename)) {
454
- return {
455
- file: filePath,
456
- message: `Filename "${filename}" does not match slug-style naming`
457
- };
440
+ // lib/cli/commands/audit.ts
441
+ var __dirname3 = dirname2(fileURLToPath2(import.meta.url));
442
+ var stockAuditsDir = join5(__dirname3, "../../templates/audits");
443
+ function loadStockAudits() {
444
+ const files = readdirSync(stockAuditsDir).filter((f) => f.endsWith(".md")).sort();
445
+ return files.map((file) => {
446
+ const template = readFileSync2(join5(stockAuditsDir, file), "utf-8");
447
+ const name = basename(file, ".md");
448
+ const description = extractOpeningSentence(template);
449
+ return { name, description, template };
450
+ });
451
+ }
452
+ function transformAuditContent(content) {
453
+ const titleMatch = content.match(/^#\s+(.+)$/m);
454
+ if (!titleMatch) {
455
+ return content;
458
456
  }
459
- return null;
457
+ const originalTitle = titleMatch[1];
458
+ return content.replace(/^#\s+.+$/m, `# Audit: ${originalTitle}`);
460
459
  }
461
- function validateTitleFilenameMatch(filePath, content) {
462
- const title = extractTitle(content);
463
- if (!title) {
464
- return null;
460
+ async function addAudit(auditName, dependencies) {
461
+ const { context, fileSystem, settings } = dependencies;
462
+ const dustPath = `${context.cwd}/.dust`;
463
+ const userAuditsPath = `${dustPath}/config/audits`;
464
+ const tasksPath = `${dustPath}/tasks`;
465
+ const taskFilePath = `${tasksPath}/audit-${auditName}.md`;
466
+ const relativeTaskPath = `.dust/tasks/audit-${auditName}.md`;
467
+ if (fileSystem.exists(taskFilePath)) {
468
+ context.stderr(`Error: Audit task already exists at ${relativeTaskPath}`);
469
+ return { exitCode: 1 };
465
470
  }
466
- const parts = filePath.split("/");
467
- const actualFilename = parts[parts.length - 1];
468
- const expectedFilename = titleToFilename(title);
469
- if (actualFilename !== expectedFilename) {
470
- return {
471
- file: filePath,
472
- message: `Filename "${actualFilename}" does not match title "${title}" (expected "${expectedFilename}")`
473
- };
471
+ const userAuditPath = `${userAuditsPath}/${auditName}.md`;
472
+ if (fileSystem.exists(userAuditPath)) {
473
+ const content = await fileSystem.readFile(userAuditPath);
474
+ const transformedContent = transformAuditContent(content);
475
+ await fileSystem.mkdir(tasksPath, { recursive: true });
476
+ await fileSystem.writeFile(taskFilePath, transformedContent);
477
+ context.stdout(`→ ${relativeTaskPath}`);
478
+ return { exitCode: 0 };
474
479
  }
475
- return null;
476
- }
477
- function validateOpeningSentence(filePath, content) {
478
- const openingSentence = extractOpeningSentence(content);
479
- if (!openingSentence) {
480
- return {
481
- file: filePath,
482
- message: "Missing or malformed opening sentence after H1 heading"
483
- };
480
+ const stockAudit = loadStockAudits().find((a) => a.name === auditName);
481
+ if (stockAudit) {
482
+ const transformedContent = transformAuditContent(stockAudit.template);
483
+ await fileSystem.mkdir(tasksPath, { recursive: true });
484
+ await fileSystem.writeFile(taskFilePath, transformedContent);
485
+ context.stdout(`→ ${relativeTaskPath}`);
486
+ return { exitCode: 0 };
484
487
  }
485
- return null;
488
+ context.stderr(`Error: Audit '${auditName}' not found`);
489
+ context.stderr(`Run '${settings.dustCommand} audit' to see available audits`);
490
+ return { exitCode: 1 };
486
491
  }
487
- function validateOpeningSentenceLength(filePath, content) {
488
- const openingSentence = extractOpeningSentence(content);
489
- if (!openingSentence) {
490
- return null;
492
+ async function listAudits(dependencies) {
493
+ const { context, fileSystem } = dependencies;
494
+ const colors = getColors();
495
+ const dustPath = `${context.cwd}/.dust`;
496
+ const userAuditsPath = `${dustPath}/config/audits`;
497
+ const audits = new Map;
498
+ for (const stockAudit of loadStockAudits()) {
499
+ audits.set(stockAudit.name, {
500
+ name: stockAudit.name,
501
+ description: stockAudit.description,
502
+ source: "stock"
503
+ });
491
504
  }
492
- if (openingSentence.length > MAX_OPENING_SENTENCE_LENGTH) {
493
- return {
494
- file: filePath,
495
- message: `Opening sentence is ${openingSentence.length} characters (max ${MAX_OPENING_SENTENCE_LENGTH}). Split into multiple sentences; only the first sentence is checked.`
496
- };
505
+ if (fileSystem.exists(userAuditsPath)) {
506
+ const files = await fileSystem.readdir(userAuditsPath);
507
+ const mdFiles = files.filter((f) => f.endsWith(".md")).sort();
508
+ for (const file of mdFiles) {
509
+ const name = basename(file, ".md");
510
+ const filePath = `${userAuditsPath}/${file}`;
511
+ const content = await fileSystem.readFile(filePath);
512
+ const title = extractTitle(content);
513
+ const openingSentence = extractOpeningSentence(content);
514
+ const relativePath = `.dust/config/audits/${file}`;
515
+ audits.set(name, {
516
+ name: title || name,
517
+ description: openingSentence || "",
518
+ source: relativePath
519
+ });
520
+ }
497
521
  }
498
- return null;
499
- }
500
- var NON_IMPERATIVE_STARTERS = new Set([
501
- "the",
502
- "a",
503
- "an",
504
- "this",
505
- "that",
506
- "these",
507
- "those",
508
- "we",
509
- "it",
510
- "they",
511
- "you",
512
- "i"
513
- ]);
514
- function validateImperativeOpeningSentence(filePath, content) {
515
- const openingSentence = extractOpeningSentence(content);
516
- if (!openingSentence) {
517
- return null;
522
+ context.stdout("\uD83D\uDD0D Audits");
523
+ context.stdout("");
524
+ context.stdout("Audits are canned tasks that help maintain project health.");
525
+ context.stdout("");
526
+ for (const auditInfo of audits.values()) {
527
+ context.stdout(`${colors.bold}# ${auditInfo.name}${colors.reset}`);
528
+ if (auditInfo.description) {
529
+ context.stdout(`${colors.dim}${auditInfo.description}${colors.reset}`);
530
+ }
531
+ context.stdout(`${colors.cyan}→ ${auditInfo.source}${colors.reset}`);
532
+ context.stdout("");
518
533
  }
519
- const firstWord = openingSentence.split(/\s/)[0].replace(/[^a-zA-Z]/g, "");
520
- const lower = firstWord.toLowerCase();
521
- if (NON_IMPERATIVE_STARTERS.has(lower) || lower.endsWith("ing")) {
522
- const preview = openingSentence.length > 40 ? `${openingSentence.slice(0, 40)}...` : openingSentence;
523
- return {
524
- file: filePath,
525
- message: `Opening sentence should use imperative form (e.g., "Add X" not "This adds X"). Found: "${preview}"`
526
- };
534
+ return { exitCode: 0 };
535
+ }
536
+ async function audit(dependencies) {
537
+ const auditName = dependencies.arguments[0];
538
+ if (auditName) {
539
+ return addAudit(auditName, dependencies);
527
540
  }
528
- return null;
541
+ return listAudits(dependencies);
529
542
  }
530
- function validateTaskHeadings(filePath, content) {
531
- const violations = [];
532
- for (const heading of REQUIRED_HEADINGS) {
533
- if (!content.includes(heading)) {
534
- violations.push({
535
- file: filePath,
536
- message: `Missing required heading: "${heading}"`
537
- });
538
- }
543
+
544
+ // lib/cli/commands/bucket.ts
545
+ import { spawn as nodeSpawn3 } from "node:child_process";
546
+ import { homedir, tmpdir } from "node:os";
547
+
548
+ // lib/bucket/auth.ts
549
+ import { join as join6 } from "node:path";
550
+ var CREDENTIALS_DIR = ".dust";
551
+ var CREDENTIALS_FILE = "credentials.json";
552
+ var AUTH_TIMEOUT_MS = 120000;
553
+ var DEFAULT_DUSTBUCKET_HOST = "https://dustbucket.com";
554
+ function getDustbucketHost() {
555
+ return process.env.DUST_BUCKET_HOST || DEFAULT_DUSTBUCKET_HOST;
556
+ }
557
+ function credentialsPath(homeDir) {
558
+ return join6(homeDir, CREDENTIALS_DIR, CREDENTIALS_FILE);
559
+ }
560
+ async function loadStoredToken(fileSystem, homeDir) {
561
+ const path = credentialsPath(homeDir);
562
+ try {
563
+ const content = await fileSystem.readFile(path);
564
+ const data = JSON.parse(content);
565
+ return typeof data.token === "string" ? data.token : null;
566
+ } catch {
567
+ return null;
539
568
  }
540
- return violations;
541
569
  }
542
- function validateLinks(filePath, content, fileSystem) {
543
- const violations = [];
544
- const lines = content.split(`
545
- `);
546
- const fileDir = dirname2(filePath);
547
- for (let i = 0;i < lines.length; i++) {
548
- const line = lines[i];
549
- const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
550
- let match = linkPattern.exec(line);
551
- while (match) {
552
- const linkTarget = match[2];
553
- if (!linkTarget.startsWith("http://") && !linkTarget.startsWith("https://") && !linkTarget.startsWith("#")) {
554
- const targetPath = linkTarget.split("#")[0];
555
- const resolvedPath = resolve(fileDir, targetPath);
556
- if (!fileSystem.exists(resolvedPath)) {
557
- violations.push({
558
- file: filePath,
559
- message: `Broken link: "${linkTarget}"`,
560
- line: i + 1
561
- });
570
+ async function storeToken(fileSystem, homeDir, token) {
571
+ const dirPath = join6(homeDir, CREDENTIALS_DIR);
572
+ await fileSystem.mkdir(dirPath, { recursive: true });
573
+ await fileSystem.writeFile(credentialsPath(homeDir), JSON.stringify({ token }));
574
+ }
575
+ async function clearToken(fileSystem, homeDir) {
576
+ const path = credentialsPath(homeDir);
577
+ try {
578
+ await fileSystem.writeFile(path, "{}");
579
+ } catch {}
580
+ }
581
+ async function authenticate(authDeps) {
582
+ return new Promise((resolve, reject) => {
583
+ let timer = null;
584
+ let serverHandle = null;
585
+ const cleanup = () => {
586
+ if (timer) {
587
+ clearTimeout(timer);
588
+ timer = null;
589
+ }
590
+ if (serverHandle) {
591
+ serverHandle.stop();
592
+ serverHandle = null;
593
+ }
594
+ };
595
+ const handler = (request) => {
596
+ const url = new URL(request.url);
597
+ if (url.pathname === "/callback") {
598
+ const token = url.searchParams.get("token");
599
+ if (token) {
600
+ cleanup();
601
+ resolve(token);
602
+ return new Response("<html><body><p>Authentication successful! You can close this tab.</p></body></html>", { headers: { "Content-Type": "text/html" } });
562
603
  }
604
+ return new Response("Missing token", { status: 400 });
563
605
  }
564
- match = linkPattern.exec(line);
606
+ return new Response("Not found", { status: 404 });
607
+ };
608
+ try {
609
+ const server = authDeps.createServer(handler);
610
+ serverHandle = server;
611
+ const dustbucketUrl = getDustbucketHost();
612
+ const authUrl = `${dustbucketUrl}/auth/cli?port=${server.port}`;
613
+ authDeps.openBrowser(authUrl);
614
+ timer = setTimeout(() => {
615
+ cleanup();
616
+ reject(new Error("Authentication timed out"));
617
+ }, authDeps.authTimeoutMs ?? AUTH_TIMEOUT_MS);
618
+ } catch (error) {
619
+ cleanup();
620
+ reject(error);
565
621
  }
566
- }
567
- return violations;
622
+ });
568
623
  }
569
- function validateIdeaOpenQuestions(filePath, content) {
570
- const violations = [];
571
- const lines = content.split(`
572
- `);
573
- let inOpenQuestions = false;
574
- let currentQuestionLine = null;
575
- let inCodeBlock = false;
576
- for (let i = 0;i < lines.length; i++) {
577
- const line = lines[i];
578
- if (line.startsWith("```")) {
579
- inCodeBlock = !inCodeBlock;
580
- continue;
581
- }
582
- if (inCodeBlock)
583
- continue;
584
- if (line.startsWith("## ")) {
585
- if (inOpenQuestions && currentQuestionLine !== null) {
586
- violations.push({
587
- file: filePath,
588
- message: "Question has no options listed beneath it",
589
- line: currentQuestionLine
590
- });
591
- }
592
- const headingText = line.slice(3).trimEnd();
593
- if (headingText.toLowerCase() === "open questions" && headingText !== "Open Questions") {
594
- violations.push({
595
- file: filePath,
596
- message: `Heading "${line.trimEnd()}" should be "## Open Questions"`,
597
- line: i + 1
598
- });
599
- }
600
- inOpenQuestions = line === "## Open Questions";
601
- currentQuestionLine = null;
602
- continue;
603
- }
604
- if (!inOpenQuestions)
605
- continue;
606
- if (/^[-*] /.test(line.trimStart())) {
607
- violations.push({
608
- file: filePath,
609
- message: "Open Questions must use ### headings for questions and #### headings for options, not bullet points. Run `dust new idea` to see the expected format.",
610
- line: i + 1
611
- });
612
- continue;
613
- }
614
- if (line.startsWith("### ")) {
615
- if (currentQuestionLine !== null) {
616
- violations.push({
617
- file: filePath,
618
- message: "Question has no options listed beneath it",
619
- line: currentQuestionLine
620
- });
621
- }
622
- if (!line.trimEnd().endsWith("?")) {
623
- violations.push({
624
- file: filePath,
625
- message: 'Questions must end with "?" (e.g., "### Should we take our own payments?")',
626
- line: i + 1
627
- });
628
- currentQuestionLine = null;
629
- } else {
630
- currentQuestionLine = i + 1;
631
- }
632
- continue;
633
- }
634
- if (line.startsWith("#### ")) {
635
- currentQuestionLine = null;
624
+
625
+ // lib/bucket/events.ts
626
+ var WS_OPEN = 1;
627
+ function formatBucketEvent(event) {
628
+ switch (event.type) {
629
+ case "bucket.connected":
630
+ return "Connected to dustbucket";
631
+ case "bucket.disconnected":
632
+ return `Disconnected (code: ${event.code}, reason: ${event.reason || "none"})`;
633
+ case "bucket.repository_added":
634
+ return `Added repository: ${event.repository}`;
635
+ case "bucket.repository_removed":
636
+ return `Removed repository: ${event.repository}`;
637
+ case "bucket.error":
638
+ return event.repository ? `Error for ${event.repository}: ${event.error}` : `Error: ${event.error}`;
639
+ }
640
+ }
641
+ function createEventMessageSender(getWebSocket) {
642
+ return (msg) => {
643
+ const ws = getWebSocket();
644
+ if (ws && ws.readyState === WS_OPEN) {
645
+ try {
646
+ ws.send(JSON.stringify(msg));
647
+ } catch {}
636
648
  }
649
+ };
650
+ }
651
+
652
+ // lib/bucket/log-buffer.ts
653
+ var MAX_LINES = 5000;
654
+ var TRIM_TO_LINES = 3000;
655
+ function createLogBuffer(maxLines = MAX_LINES, trimToLines = TRIM_TO_LINES) {
656
+ return {
657
+ lines: [],
658
+ maxLines,
659
+ trimToLines
660
+ };
661
+ }
662
+ function appendLogLine(buffer, line) {
663
+ buffer.lines.push(line);
664
+ if (buffer.lines.length > buffer.maxLines) {
665
+ buffer.lines = buffer.lines.slice(-buffer.trimToLines);
637
666
  }
638
- if (inOpenQuestions && currentQuestionLine !== null) {
639
- violations.push({
640
- file: filePath,
641
- message: "Question has no options listed beneath it",
642
- line: currentQuestionLine
643
- });
667
+ }
668
+ function createLogLine(text, stream, timestamp = Date.now()) {
669
+ return { text, stream, timestamp };
670
+ }
671
+ function getLogLines(buffer) {
672
+ return buffer.lines;
673
+ }
674
+
675
+ // lib/bucket/repository.ts
676
+ import { join as join7 } from "node:path";
677
+
678
+ // lib/agent-events.ts
679
+ function mapToAgentEvent(event) {
680
+ switch (event.type) {
681
+ case "claude.started":
682
+ return { type: "agent-session-started" };
683
+ case "claude.ended":
684
+ return {
685
+ type: "agent-session-ended",
686
+ success: event.success,
687
+ error: event.error
688
+ };
689
+ case "claude.raw_event":
690
+ if (typeof event.rawEvent.type === "string" && event.rawEvent.type === "stream_event") {
691
+ return { type: "agent-session-activity" };
692
+ }
693
+ return { type: "claude-event", rawEvent: event.rawEvent };
694
+ default:
695
+ return null;
644
696
  }
645
- return violations;
646
697
  }
647
- var SEMANTIC_RULES = [
648
- {
649
- section: "## Goals",
650
- requiredPath: "/.dust/goals/",
651
- description: "goal"
652
- },
653
- {
654
- section: "## Blocked By",
655
- requiredPath: "/.dust/tasks/",
656
- description: "task"
698
+
699
+ // lib/claude/spawn-claude-code.ts
700
+ import { spawn as nodeSpawn } from "node:child_process";
701
+ import { createInterface as nodeCreateInterface } from "node:readline";
702
+ var defaultDependencies = {
703
+ spawn: nodeSpawn,
704
+ createInterface: nodeCreateInterface
705
+ };
706
+ async function* spawnClaudeCode(prompt, options = {}, dependencies = defaultDependencies) {
707
+ const {
708
+ cwd,
709
+ allowedTools,
710
+ maxTurns,
711
+ model,
712
+ systemPrompt,
713
+ sessionId,
714
+ dangerouslySkipPermissions,
715
+ env
716
+ } = options;
717
+ const claudeArguments = [
718
+ "-p",
719
+ prompt,
720
+ "--output-format",
721
+ "stream-json",
722
+ "--verbose",
723
+ "--include-partial-messages"
724
+ ];
725
+ if (allowedTools?.length) {
726
+ claudeArguments.push("--allowedTools", ...allowedTools);
657
727
  }
658
- ];
659
- function validateSemanticLinks(filePath, content) {
660
- const violations = [];
661
- const lines = content.split(`
662
- `);
663
- const fileDir = dirname2(filePath);
664
- let currentSection = null;
665
- for (let i = 0;i < lines.length; i++) {
666
- const line = lines[i];
667
- if (line.startsWith("## ")) {
668
- currentSection = line;
669
- continue;
670
- }
671
- const rule = SEMANTIC_RULES.find((r) => r.section === currentSection);
672
- if (!rule)
728
+ if (maxTurns) {
729
+ claudeArguments.push("--max-turns", String(maxTurns));
730
+ }
731
+ if (model) {
732
+ claudeArguments.push("--model", model);
733
+ }
734
+ if (systemPrompt) {
735
+ claudeArguments.push("--system-prompt", systemPrompt);
736
+ }
737
+ if (sessionId) {
738
+ claudeArguments.push("--session-id", sessionId);
739
+ }
740
+ if (dangerouslySkipPermissions) {
741
+ claudeArguments.push("--dangerously-skip-permissions");
742
+ }
743
+ const proc = dependencies.spawn("claude", claudeArguments, {
744
+ cwd,
745
+ stdio: ["ignore", "pipe", "pipe"],
746
+ env: { ...process.env, ...env }
747
+ });
748
+ if (!proc.stdout) {
749
+ throw new Error("Failed to get stdout from claude process");
750
+ }
751
+ const rl = dependencies.createInterface({ input: proc.stdout });
752
+ for await (const line of rl) {
753
+ if (!line.trim())
673
754
  continue;
674
- const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
675
- let match = linkPattern.exec(line);
676
- while (match) {
677
- const linkTarget = match[2];
678
- if (linkTarget.startsWith("#")) {
679
- violations.push({
680
- file: filePath,
681
- message: `Link in "${rule.section}" must point to a ${rule.description} file, not an anchor: "${linkTarget}"`,
682
- line: i + 1
683
- });
684
- match = linkPattern.exec(line);
685
- continue;
755
+ try {
756
+ yield JSON.parse(line);
757
+ } catch {}
758
+ }
759
+ let stderrOutput = "";
760
+ proc.stderr?.on("data", (data) => {
761
+ stderrOutput += data.toString();
762
+ });
763
+ await new Promise((resolve, reject) => {
764
+ proc.on("close", (code) => {
765
+ if (code === 0 || code === null)
766
+ resolve();
767
+ else {
768
+ const errMsg = stderrOutput.trim() ? `claude exited with code ${code}: ${stderrOutput.trim()}` : `claude exited with code ${code}`;
769
+ reject(new Error(errMsg));
686
770
  }
687
- if (linkTarget.startsWith("http://") || linkTarget.startsWith("https://")) {
688
- violations.push({
689
- file: filePath,
690
- message: `Link in "${rule.section}" must point to a ${rule.description} file, not an external URL: "${linkTarget}"`,
691
- line: i + 1
692
- });
693
- match = linkPattern.exec(line);
694
- continue;
771
+ });
772
+ proc.on("error", reject);
773
+ });
774
+ }
775
+
776
+ // lib/claude/event-parser.ts
777
+ function* parseRawEvent(raw) {
778
+ if (raw.type === "stream_event") {
779
+ const event = raw.event;
780
+ if (event?.delta?.type === "text_delta" && event.delta.text) {
781
+ yield { type: "text_delta", text: event.delta.text };
782
+ }
783
+ } else if (raw.type === "assistant") {
784
+ const msg = raw;
785
+ const content = msg.message?.content ?? [];
786
+ yield { type: "assistant_message", content };
787
+ for (const block of content) {
788
+ if (block.type === "tool_use" && block.id && block.name && block.input) {
789
+ yield {
790
+ type: "tool_use",
791
+ id: block.id,
792
+ name: block.name,
793
+ input: block.input
794
+ };
695
795
  }
696
- const targetPath = linkTarget.split("#")[0];
697
- const resolvedPath = resolve(fileDir, targetPath);
698
- if (!resolvedPath.includes(rule.requiredPath)) {
699
- violations.push({
700
- file: filePath,
701
- message: `Link in "${rule.section}" must point to a ${rule.description} file: "${linkTarget}"`,
702
- line: i + 1
703
- });
796
+ }
797
+ } else if (raw.type === "user") {
798
+ const msg = raw;
799
+ for (const block of msg.message?.content ?? []) {
800
+ if (block.type === "tool_result" && block.tool_use_id) {
801
+ yield {
802
+ type: "tool_result",
803
+ toolUseId: block.tool_use_id,
804
+ content: typeof block.content === "string" ? block.content : JSON.stringify(block.content)
805
+ };
704
806
  }
705
- match = linkPattern.exec(line);
706
807
  }
808
+ } else if (raw.type === "result") {
809
+ const r = raw;
810
+ yield {
811
+ type: "result",
812
+ subtype: r.subtype ?? "success",
813
+ result: r.result,
814
+ error: r.error,
815
+ cost_usd: r.total_cost_usd ?? r.cost_usd ?? 0,
816
+ duration_ms: r.duration_ms ?? 0,
817
+ num_turns: r.num_turns ?? 0,
818
+ session_id: r.session_id ?? ""
819
+ };
707
820
  }
708
- return violations;
709
821
  }
710
- function validateIdeaTransitionTitle(filePath, content, ideasPath, fileSystem) {
711
- const title = extractTitle(content);
712
- if (!title) {
713
- return null;
822
+
823
+ // lib/claude/tool-formatters.ts
824
+ var DIVIDER = "────────────────────────────────";
825
+ function formatWrite(input) {
826
+ const filePath = input.file_path;
827
+ const content = input.content;
828
+ const others = getUnrecognizedArgs(input, ["file_path", "content"]);
829
+ const lines = [];
830
+ lines.push(`\uD83D\uDD27 Write: ${filePath ?? "(unknown)"}`);
831
+ lines.push(DIVIDER);
832
+ if (content !== undefined) {
833
+ for (const line of content.split(`
834
+ `)) {
835
+ lines.push(line);
836
+ }
714
837
  }
715
- for (const prefix of IDEA_TRANSITION_PREFIXES) {
716
- if (title.startsWith(prefix)) {
717
- const ideaTitle = title.slice(prefix.length);
718
- const ideaFilename = titleToFilename(ideaTitle);
719
- if (!fileSystem.exists(`${ideasPath}/${ideaFilename}`)) {
720
- return {
721
- file: filePath,
722
- message: `Idea transition task references non-existent idea: "${ideaTitle}" (expected file "${ideaFilename}" in ideas/)`
723
- };
838
+ lines.push(DIVIDER);
839
+ appendOtherArgs(lines, others);
840
+ return lines;
841
+ }
842
+ function formatEdit(input) {
843
+ const filePath = input.file_path;
844
+ const oldString = input.old_string;
845
+ const newString = input.new_string;
846
+ const others = getUnrecognizedArgs(input, [
847
+ "file_path",
848
+ "old_string",
849
+ "new_string",
850
+ "replace_all"
851
+ ]);
852
+ const lines = [];
853
+ lines.push(`\uD83D\uDD27 Edit: ${filePath ?? "(unknown)"}`);
854
+ lines.push("Replace:");
855
+ lines.push(DIVIDER);
856
+ if (oldString !== undefined) {
857
+ for (const line of oldString.split(`
858
+ `)) {
859
+ lines.push(line);
860
+ }
861
+ }
862
+ lines.push(DIVIDER);
863
+ lines.push("With:");
864
+ lines.push(DIVIDER);
865
+ if (newString !== undefined) {
866
+ for (const line of newString.split(`
867
+ `)) {
868
+ lines.push(line);
869
+ }
870
+ }
871
+ lines.push(DIVIDER);
872
+ appendOtherArgs(lines, others);
873
+ return lines;
874
+ }
875
+ function formatRead(input) {
876
+ const filePath = input.file_path;
877
+ const offset = input.offset;
878
+ const limit = input.limit;
879
+ const others = getUnrecognizedArgs(input, ["file_path", "offset", "limit"]);
880
+ const lines = [];
881
+ let lineRange = "";
882
+ if (offset !== undefined || limit !== undefined) {
883
+ const start = offset ?? 1;
884
+ const end = limit !== undefined ? start + limit - 1 : undefined;
885
+ lineRange = end !== undefined ? ` (lines ${start}-${end})` : ` (from line ${start})`;
886
+ }
887
+ lines.push(`\uD83D\uDD27 Read: ${filePath ?? "(unknown)"}${lineRange}`);
888
+ appendOtherArgs(lines, others);
889
+ return lines;
890
+ }
891
+ function formatBash(input) {
892
+ const command = input.command;
893
+ const description = input.description;
894
+ const others = getUnrecognizedArgs(input, [
895
+ "command",
896
+ "description",
897
+ "timeout",
898
+ "run_in_background",
899
+ "dangerouslyDisableSandbox",
900
+ "_simulatedSedEdit"
901
+ ]);
902
+ const lines = [];
903
+ const header = description ?? "Run command";
904
+ lines.push(`\uD83D\uDD27 Bash: ${header}`);
905
+ if (command !== undefined) {
906
+ lines.push(`$ ${command}`);
907
+ }
908
+ appendOtherArgs(lines, others);
909
+ return lines;
910
+ }
911
+ function formatTodoWrite(input) {
912
+ const todos = input.todos;
913
+ const others = getUnrecognizedArgs(input, ["todos"]);
914
+ const lines = [];
915
+ const count = todos?.length ?? 0;
916
+ lines.push(`\uD83D\uDD27 TodoWrite: ${count} item${count === 1 ? "" : "s"}`);
917
+ if (todos) {
918
+ for (const todo of todos) {
919
+ const icon = todo.status === "completed" ? "☑" : "☐";
920
+ lines.push(`${icon} ${todo.content}`);
921
+ }
922
+ }
923
+ appendOtherArgs(lines, others);
924
+ return lines;
925
+ }
926
+ function formatGrep(input) {
927
+ const pattern = input.pattern;
928
+ const path = input.path;
929
+ const glob = input.glob;
930
+ const type = input.type;
931
+ const others = getUnrecognizedArgs(input, [
932
+ "pattern",
933
+ "path",
934
+ "glob",
935
+ "type",
936
+ "output_mode",
937
+ "context",
938
+ "-A",
939
+ "-B",
940
+ "-C",
941
+ "-i",
942
+ "-n",
943
+ "head_limit",
944
+ "offset",
945
+ "multiline"
946
+ ]);
947
+ const lines = [];
948
+ const location = path ?? ".";
949
+ let filter = "";
950
+ if (glob) {
951
+ filter = ` (${glob})`;
952
+ } else if (type) {
953
+ filter = ` (type: ${type})`;
954
+ }
955
+ lines.push(`\uD83D\uDD27 Grep: "${pattern ?? ""}" in ${location}${filter}`);
956
+ appendOtherArgs(lines, others);
957
+ return lines;
958
+ }
959
+ function formatGlob(input) {
960
+ const pattern = input.pattern;
961
+ const path = input.path;
962
+ const others = getUnrecognizedArgs(input, ["pattern", "path"]);
963
+ const lines = [];
964
+ const location = path ?? ".";
965
+ lines.push(`\uD83D\uDD27 Glob: ${pattern ?? ""} in ${location}`);
966
+ appendOtherArgs(lines, others);
967
+ return lines;
968
+ }
969
+ function formatTask(input) {
970
+ const description = input.description;
971
+ const subagentType = input.subagent_type;
972
+ const prompt = input.prompt;
973
+ const others = getUnrecognizedArgs(input, [
974
+ "description",
975
+ "subagent_type",
976
+ "prompt",
977
+ "model",
978
+ "max_turns",
979
+ "resume",
980
+ "run_in_background"
981
+ ]);
982
+ const lines = [];
983
+ const header = description ?? subagentType ?? "task";
984
+ lines.push(`\uD83D\uDD27 Task: ${header}`);
985
+ if (prompt !== undefined) {
986
+ const truncated = prompt.length > 100 ? `${prompt.slice(0, 100)}...` : prompt;
987
+ lines.push(`"${truncated}"`);
988
+ }
989
+ appendOtherArgs(lines, others);
990
+ return lines;
991
+ }
992
+ function formatFallback(name, input) {
993
+ const lines = [];
994
+ lines.push(`\uD83D\uDD27 Tool: ${name}`);
995
+ lines.push(`Input: ${JSON.stringify(input, null, 2)}`);
996
+ return lines;
997
+ }
998
+ var formatters = {
999
+ Write: formatWrite,
1000
+ Edit: formatEdit,
1001
+ Read: formatRead,
1002
+ Bash: formatBash,
1003
+ TodoWrite: formatTodoWrite,
1004
+ Grep: formatGrep,
1005
+ Glob: formatGlob,
1006
+ Task: formatTask
1007
+ };
1008
+ function formatToolUse(name, input) {
1009
+ const formatter = formatters[name];
1010
+ if (formatter) {
1011
+ return formatter(input);
1012
+ }
1013
+ return formatFallback(name, input);
1014
+ }
1015
+ function getUnrecognizedArgs(input, knownKeys) {
1016
+ const others = {};
1017
+ for (const key of Object.keys(input)) {
1018
+ if (!knownKeys.includes(key)) {
1019
+ others[key] = input[key];
1020
+ }
1021
+ }
1022
+ return others;
1023
+ }
1024
+ function appendOtherArgs(lines, others) {
1025
+ if (Object.keys(others).length > 0) {
1026
+ lines.push("");
1027
+ lines.push(`(Other arguments: ${JSON.stringify(others)})`);
1028
+ }
1029
+ }
1030
+
1031
+ // lib/claude/streamer.ts
1032
+ var DIVIDER2 = "────────────────────────────────";
1033
+ async function streamEvents(events, sink, onRawEvent) {
1034
+ let hadTextOutput = false;
1035
+ for await (const raw of events) {
1036
+ onRawEvent?.(raw);
1037
+ for (const event of parseRawEvent(raw)) {
1038
+ processEvent(event, sink, { hadTextOutput });
1039
+ if (event.type === "text_delta") {
1040
+ hadTextOutput = true;
1041
+ } else if (event.type === "tool_use") {
1042
+ hadTextOutput = false;
1043
+ }
1044
+ }
1045
+ }
1046
+ }
1047
+ function processEvent(event, sink, state) {
1048
+ switch (event.type) {
1049
+ case "text_delta":
1050
+ sink.write(event.text);
1051
+ break;
1052
+ case "tool_use":
1053
+ if (state.hadTextOutput) {
1054
+ sink.line("");
1055
+ sink.line("");
724
1056
  }
1057
+ for (const line of formatToolUse(event.name, event.input)) {
1058
+ sink.line(line);
1059
+ }
1060
+ break;
1061
+ case "tool_result":
1062
+ sink.line("Result:");
1063
+ sink.line(DIVIDER2);
1064
+ sink.line(event.content);
1065
+ sink.line(DIVIDER2);
1066
+ sink.line("");
1067
+ break;
1068
+ case "result":
1069
+ sink.line("");
1070
+ sink.line(`\uD83C\uDFC1 Done: ${event.subtype}, ${event.num_turns} turns, $${event.cost_usd.toFixed(4)}`);
1071
+ break;
1072
+ case "assistant_message":
1073
+ break;
1074
+ }
1075
+ }
1076
+ function createStdoutSink() {
1077
+ return {
1078
+ write: (text) => process.stdout.write(text),
1079
+ line: (text) => console.log(text)
1080
+ };
1081
+ }
1082
+
1083
+ // lib/claude/run.ts
1084
+ var defaultRunnerDependencies = {
1085
+ spawnClaudeCode,
1086
+ createStdoutSink,
1087
+ streamEvents
1088
+ };
1089
+ async function run(prompt, options = {}, dependencies = defaultRunnerDependencies) {
1090
+ const isRunOptions = (opt) => ("spawnOptions" in opt) || ("onRawEvent" in opt);
1091
+ const spawnOptions = isRunOptions(options) ? options.spawnOptions ?? {} : options;
1092
+ const onRawEvent = isRunOptions(options) ? options.onRawEvent : undefined;
1093
+ const events = dependencies.spawnClaudeCode(prompt, spawnOptions);
1094
+ const sink = dependencies.createStdoutSink();
1095
+ await dependencies.streamEvents(events, sink, onRawEvent);
1096
+ }
1097
+
1098
+ // lib/cli/commands/loop.ts
1099
+ import { spawn as nodeSpawn2 } from "node:child_process";
1100
+
1101
+ // lib/cli/commands/next.ts
1102
+ function extractBlockedBy(content) {
1103
+ const blockedByMatch = content.match(/^## Blocked By\s*\n([\s\S]*?)(?=\n## |\n*$)/m);
1104
+ if (!blockedByMatch) {
1105
+ return [];
1106
+ }
1107
+ const section = blockedByMatch[1].trim();
1108
+ if (section === "(none)") {
1109
+ return [];
1110
+ }
1111
+ const linkPattern = /\[.*?\]\(([^)]+\.md)\)/g;
1112
+ const blockers = [];
1113
+ let match = linkPattern.exec(section);
1114
+ while (match !== null) {
1115
+ blockers.push(match[1]);
1116
+ match = linkPattern.exec(section);
1117
+ }
1118
+ return blockers;
1119
+ }
1120
+ async function findUnblockedTasks(cwd, fileSystem) {
1121
+ const dustPath = `${cwd}/.dust`;
1122
+ if (!fileSystem.exists(dustPath)) {
1123
+ return { error: ".dust directory not found", tasks: [] };
1124
+ }
1125
+ const tasksPath = `${dustPath}/tasks`;
1126
+ if (!fileSystem.exists(tasksPath)) {
1127
+ return { tasks: [] };
1128
+ }
1129
+ const files = await fileSystem.readdir(tasksPath);
1130
+ const mdFiles = files.filter((f) => f.endsWith(".md")).sort();
1131
+ if (mdFiles.length === 0) {
1132
+ return { tasks: [] };
1133
+ }
1134
+ const existingTasks = new Set(mdFiles);
1135
+ const tasks = [];
1136
+ for (const file of mdFiles) {
1137
+ const filePath = `${tasksPath}/${file}`;
1138
+ const content = await fileSystem.readFile(filePath);
1139
+ const blockers = extractBlockedBy(content);
1140
+ const hasIncompleteBlocker = blockers.some((blocker) => existingTasks.has(blocker));
1141
+ if (!hasIncompleteBlocker) {
1142
+ const title = extractTitle(content);
1143
+ const openingSentence = extractOpeningSentence(content);
1144
+ const relativePath = `.dust/tasks/${file}`;
1145
+ tasks.push({ path: relativePath, title, openingSentence });
1146
+ }
1147
+ }
1148
+ return { tasks };
1149
+ }
1150
+ function printTaskList(context, tasks) {
1151
+ const colors = getColors();
1152
+ context.stdout("\uD83D\uDCCB Next tasks");
1153
+ context.stdout("");
1154
+ for (const task of tasks) {
1155
+ const parts = task.path.split("/");
1156
+ const displayTitle = task.title || parts[parts.length - 1].replace(".md", "");
1157
+ context.stdout(`${colors.bold}# ${displayTitle}${colors.reset}`);
1158
+ if (task.openingSentence) {
1159
+ context.stdout(`${colors.dim}${task.openingSentence}${colors.reset}`);
1160
+ }
1161
+ context.stdout(`${colors.cyan}→ ${task.path}${colors.reset}`);
1162
+ context.stdout("");
1163
+ }
1164
+ }
1165
+ async function next(dependencies) {
1166
+ const { context, fileSystem } = dependencies;
1167
+ const result = await findUnblockedTasks(context.cwd, fileSystem);
1168
+ if (result.error) {
1169
+ context.stderr(`Error: ${result.error}`);
1170
+ context.stderr("Run 'dust init' to initialize a Dust repository");
1171
+ return { exitCode: 1 };
1172
+ }
1173
+ if (result.tasks.length === 0) {
1174
+ return { exitCode: 0 };
1175
+ }
1176
+ printTaskList(context, result.tasks);
1177
+ return { exitCode: 0 };
1178
+ }
1179
+
1180
+ // lib/cli/commands/loop.ts
1181
+ function formatEvent(event) {
1182
+ switch (event.type) {
1183
+ case "loop.warning":
1184
+ return "⚠️ WARNING: This command skips all permission checks. Only use in a sandbox environment!";
1185
+ case "loop.started":
1186
+ return `\uD83D\uDD04 Starting dust loop claude (max ${event.maxIterations} iterations)...`;
1187
+ case "loop.syncing":
1188
+ return "\uD83C\uDF0D Syncing with remote";
1189
+ case "loop.sync_skipped":
1190
+ return `Note: git pull skipped (${event.reason})`;
1191
+ case "loop.checking_tasks":
1192
+ return null;
1193
+ case "loop.no_tasks":
1194
+ return `\uD83D\uDE34 No tasks available. Sleeping...
1195
+ `;
1196
+ case "loop.tasks_found":
1197
+ return `✨ Found a task. Going to work!
1198
+ `;
1199
+ case "claude.started":
1200
+ return "\uD83E\uDD16 Starting Claude...";
1201
+ case "claude.ended":
1202
+ return event.success ? "\uD83E\uDD16 Claude session ended (success)" : `\uD83E\uDD16 Claude session ended (error: ${event.error})`;
1203
+ case "claude.raw_event":
725
1204
  return null;
1205
+ case "loop.iteration_complete":
1206
+ return `\uD83D\uDCCB Completed iteration ${event.iteration}/${event.maxIterations}`;
1207
+ case "loop.ended":
1208
+ return `\uD83C\uDFC1 Reached max iterations (${event.maxIterations}). Exiting.`;
1209
+ }
1210
+ }
1211
+ async function defaultPostEvent(url, payload) {
1212
+ await fetch(url, {
1213
+ method: "POST",
1214
+ headers: { "Content-Type": "application/json" },
1215
+ body: JSON.stringify(payload)
1216
+ });
1217
+ }
1218
+ function createDefaultDependencies() {
1219
+ return {
1220
+ spawn: nodeSpawn2,
1221
+ run,
1222
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
1223
+ postEvent: defaultPostEvent
1224
+ };
1225
+ }
1226
+ function createEventPoster(eventsUrl, sessionId, postEvent, onError, getAgentSessionId, repository = "") {
1227
+ let sequence = 0;
1228
+ return (event) => {
1229
+ if (!eventsUrl)
1230
+ return;
1231
+ const agentEvent = mapToAgentEvent(event);
1232
+ if (!agentEvent)
1233
+ return;
1234
+ sequence++;
1235
+ const payload = {
1236
+ sequence,
1237
+ timestamp: new Date().toISOString(),
1238
+ sessionId,
1239
+ repository,
1240
+ event: agentEvent
1241
+ };
1242
+ const agentSessionId = getAgentSessionId?.();
1243
+ if (agentSessionId) {
1244
+ payload.agentSessionId = agentSessionId;
1245
+ }
1246
+ postEvent(eventsUrl, payload).catch(onError);
1247
+ };
1248
+ }
1249
+ var SLEEP_INTERVAL_MS = 30000;
1250
+ var DEFAULT_MAX_ITERATIONS = 10;
1251
+ async function gitPull(cwd, spawn) {
1252
+ return new Promise((resolve) => {
1253
+ const proc = spawn("git", ["pull"], {
1254
+ cwd,
1255
+ stdio: ["ignore", "pipe", "pipe"]
1256
+ });
1257
+ let stderr = "";
1258
+ proc.stderr?.on("data", (data) => {
1259
+ stderr += data.toString();
1260
+ });
1261
+ proc.on("close", (code) => {
1262
+ if (code === 0) {
1263
+ resolve({ success: true });
1264
+ } else {
1265
+ resolve({ success: false, message: stderr.trim() || "git pull failed" });
1266
+ }
1267
+ });
1268
+ proc.on("error", (error) => {
1269
+ resolve({ success: false, message: error.message });
1270
+ });
1271
+ });
1272
+ }
1273
+ async function hasAvailableTasks(dependencies) {
1274
+ let hasOutput = false;
1275
+ const captureContext = {
1276
+ ...dependencies.context,
1277
+ stdout: () => {
1278
+ hasOutput = true;
726
1279
  }
727
- }
728
- return null;
1280
+ };
1281
+ await next({ ...dependencies, context: captureContext });
1282
+ return hasOutput;
729
1283
  }
730
- function validateGoalHierarchySections(filePath, content) {
731
- const violations = [];
732
- for (const heading of REQUIRED_GOAL_HEADINGS) {
733
- if (!content.includes(heading)) {
734
- violations.push({
735
- file: filePath,
736
- message: `Missing required heading: "${heading}"`
1284
+ async function runOneIteration(dependencies, loopDependencies, emit, options = {}) {
1285
+ const { context } = dependencies;
1286
+ const { spawn, run: run2 } = loopDependencies;
1287
+ const { onRawEvent } = options;
1288
+ emit({ type: "loop.syncing" });
1289
+ const pullResult = await gitPull(context.cwd, spawn);
1290
+ if (!pullResult.success) {
1291
+ emit({
1292
+ type: "loop.sync_skipped",
1293
+ reason: pullResult.message ?? "unknown error"
1294
+ });
1295
+ emit({ type: "claude.started" });
1296
+ const prompt2 = `git pull failed with the following error:
1297
+
1298
+ ${pullResult.message}
1299
+
1300
+ Please resolve this issue. Common approaches:
1301
+ 1. If there are merge conflicts, resolve them
1302
+ 2. If local commits need to be rebased, use git rebase
1303
+ 3. After resolving, commit any changes and push to remote
1304
+
1305
+ Make sure the repository is in a clean state and synced with remote before finishing.`;
1306
+ try {
1307
+ await run2(prompt2, {
1308
+ spawnOptions: {
1309
+ cwd: context.cwd,
1310
+ dangerouslySkipPermissions: true,
1311
+ env: { DUST_UNATTENDED: "1" }
1312
+ },
1313
+ onRawEvent
737
1314
  });
1315
+ emit({ type: "claude.ended", success: true });
1316
+ return "resolved_pull_conflict";
1317
+ } catch (error) {
1318
+ const errorMessage = error instanceof Error ? error.message : String(error);
1319
+ context.stderr(`Claude failed to resolve git pull conflict: ${errorMessage}`);
1320
+ emit({ type: "claude.ended", success: false, error: errorMessage });
738
1321
  }
739
1322
  }
740
- return violations;
741
- }
742
- function validateGoalHierarchyLinks(filePath, content) {
743
- const violations = [];
744
- const lines = content.split(`
745
- `);
746
- const fileDir = dirname2(filePath);
747
- let currentSection = null;
748
- for (let i = 0;i < lines.length; i++) {
749
- const line = lines[i];
750
- if (line.startsWith("## ")) {
751
- currentSection = line;
752
- continue;
753
- }
754
- if (currentSection !== "## Parent Goal" && currentSection !== "## Sub-Goals") {
755
- continue;
756
- }
757
- const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
758
- let match = linkPattern.exec(line);
759
- while (match) {
760
- const linkTarget = match[2];
761
- if (linkTarget.startsWith("#")) {
762
- violations.push({
763
- file: filePath,
764
- message: `Link in "${currentSection}" must point to a goal file, not an anchor: "${linkTarget}"`,
765
- line: i + 1
766
- });
767
- match = linkPattern.exec(line);
768
- continue;
769
- }
770
- if (linkTarget.startsWith("http://") || linkTarget.startsWith("https://")) {
771
- violations.push({
772
- file: filePath,
773
- message: `Link in "${currentSection}" must point to a goal file, not an external URL: "${linkTarget}"`,
774
- line: i + 1
775
- });
776
- match = linkPattern.exec(line);
777
- continue;
778
- }
779
- const targetPath = linkTarget.split("#")[0];
780
- const resolvedPath = resolve(fileDir, targetPath);
781
- if (!resolvedPath.includes("/.dust/goals/")) {
782
- violations.push({
783
- file: filePath,
784
- message: `Link in "${currentSection}" must point to a goal file: "${linkTarget}"`,
785
- line: i + 1
786
- });
787
- }
788
- match = linkPattern.exec(line);
789
- }
1323
+ emit({ type: "loop.checking_tasks" });
1324
+ const hasTasks = await hasAvailableTasks(dependencies);
1325
+ if (!hasTasks) {
1326
+ emit({ type: "loop.no_tasks" });
1327
+ return "no_tasks";
790
1328
  }
791
- return violations;
792
- }
793
- function extractGoalRelationships(filePath, content) {
794
- const lines = content.split(`
795
- `);
796
- const fileDir = dirname2(filePath);
797
- const parentGoals = [];
798
- const subGoals = [];
799
- let currentSection = null;
800
- for (const line of lines) {
801
- if (line.startsWith("## ")) {
802
- currentSection = line;
803
- continue;
804
- }
805
- if (currentSection !== "## Parent Goal" && currentSection !== "## Sub-Goals") {
806
- continue;
807
- }
808
- const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
809
- let match = linkPattern.exec(line);
810
- while (match) {
811
- const linkTarget = match[2];
812
- if (!linkTarget.startsWith("#") && !linkTarget.startsWith("http://") && !linkTarget.startsWith("https://")) {
813
- const targetPath = linkTarget.split("#")[0];
814
- const resolvedPath = resolve(fileDir, targetPath);
815
- if (resolvedPath.includes("/.dust/goals/")) {
816
- if (currentSection === "## Parent Goal") {
817
- parentGoals.push(resolvedPath);
818
- } else {
819
- subGoals.push(resolvedPath);
820
- }
821
- }
822
- }
823
- match = linkPattern.exec(line);
824
- }
1329
+ emit({ type: "loop.tasks_found" });
1330
+ emit({ type: "claude.started" });
1331
+ const { dustCommand, installCommand = "npm install" } = dependencies.settings;
1332
+ const prompt = `Run \`${installCommand} && ${dustCommand} agent && ${dustCommand} pick task\` and follow the instructions.`;
1333
+ try {
1334
+ await run2(prompt, {
1335
+ spawnOptions: {
1336
+ cwd: context.cwd,
1337
+ dangerouslySkipPermissions: true,
1338
+ env: { DUST_UNATTENDED: "1" }
1339
+ },
1340
+ onRawEvent
1341
+ });
1342
+ emit({ type: "claude.ended", success: true });
1343
+ return "ran_claude";
1344
+ } catch (error) {
1345
+ const errorMessage = error instanceof Error ? error.message : String(error);
1346
+ context.stderr(`Claude exited with error: ${errorMessage}`);
1347
+ emit({ type: "claude.ended", success: false, error: errorMessage });
1348
+ return "claude_error";
825
1349
  }
826
- return { filePath, parentGoals, subGoals };
827
1350
  }
828
- function validateBidirectionalLinks(allGoalRelationships) {
829
- const violations = [];
830
- const relationshipMap = new Map;
831
- for (const rel of allGoalRelationships) {
832
- relationshipMap.set(rel.filePath, rel);
1351
+ function parseMaxIterations(commandArguments) {
1352
+ if (commandArguments.length === 0) {
1353
+ return DEFAULT_MAX_ITERATIONS;
833
1354
  }
834
- for (const rel of allGoalRelationships) {
835
- for (const parentPath of rel.parentGoals) {
836
- const parentRel = relationshipMap.get(parentPath);
837
- if (parentRel && !parentRel.subGoals.includes(rel.filePath)) {
838
- violations.push({
839
- file: rel.filePath,
840
- message: `Parent goal "${parentPath}" does not list this goal as a sub-goal`
841
- });
842
- }
843
- }
844
- for (const subGoalPath of rel.subGoals) {
845
- const subGoalRel = relationshipMap.get(subGoalPath);
846
- if (subGoalRel && !subGoalRel.parentGoals.includes(rel.filePath)) {
847
- violations.push({
848
- file: rel.filePath,
849
- message: `Sub-goal "${subGoalPath}" does not list this goal as its parent`
850
- });
851
- }
852
- }
1355
+ const parsed = Number.parseInt(commandArguments[0], 10);
1356
+ if (Number.isNaN(parsed) || parsed <= 0) {
1357
+ return DEFAULT_MAX_ITERATIONS;
853
1358
  }
854
- return violations;
1359
+ return parsed;
855
1360
  }
856
- function validateNoCycles(allGoalRelationships) {
857
- const violations = [];
858
- const relationshipMap = new Map;
859
- for (const rel of allGoalRelationships) {
860
- relationshipMap.set(rel.filePath, rel);
861
- }
862
- for (const rel of allGoalRelationships) {
863
- const visited = new Set;
864
- const path = [];
865
- let current = rel.filePath;
866
- while (current) {
867
- if (visited.has(current)) {
868
- const cycleStart = path.indexOf(current);
869
- const cyclePath = path.slice(cycleStart).concat(current);
870
- violations.push({
871
- file: rel.filePath,
872
- message: `Cycle detected in goal hierarchy: ${cyclePath.join(" -> ")}`
873
- });
874
- break;
875
- }
876
- visited.add(current);
877
- path.push(current);
878
- const currentRel = relationshipMap.get(current);
879
- if (currentRel && currentRel.parentGoals.length > 0) {
880
- current = currentRel.parentGoals[0];
881
- } else {
882
- current = null;
1361
+ async function loopClaude(dependencies, loopDependencies = createDefaultDependencies()) {
1362
+ const { context, settings } = dependencies;
1363
+ const { postEvent } = loopDependencies;
1364
+ const maxIterations = parseMaxIterations(dependencies.arguments);
1365
+ const eventsUrl = settings.eventsUrl;
1366
+ const sessionId = crypto.randomUUID();
1367
+ let agentSessionId;
1368
+ const postEventFn = createEventPoster(eventsUrl, sessionId, postEvent, (error) => {
1369
+ const message = error instanceof Error ? error.message : String(error);
1370
+ context.stderr(`Event POST failed: ${message}`);
1371
+ }, () => agentSessionId);
1372
+ const emit = (event) => {
1373
+ const formatted = formatEvent(event);
1374
+ if (formatted !== null) {
1375
+ context.stdout(formatted);
1376
+ }
1377
+ postEventFn(event);
1378
+ };
1379
+ emit({ type: "loop.warning" });
1380
+ emit({ type: "loop.started", maxIterations });
1381
+ context.stdout(" Press Ctrl+C to stop");
1382
+ context.stdout("");
1383
+ let completedIterations = 0;
1384
+ const iterationOptions = {};
1385
+ if (eventsUrl) {
1386
+ iterationOptions.onRawEvent = (rawEvent) => {
1387
+ if (typeof rawEvent.session_id === "string" && rawEvent.session_id) {
1388
+ agentSessionId = rawEvent.session_id;
883
1389
  }
1390
+ emit({ type: "claude.raw_event", rawEvent });
1391
+ };
1392
+ }
1393
+ while (completedIterations < maxIterations) {
1394
+ agentSessionId = undefined;
1395
+ const result = await runOneIteration(dependencies, loopDependencies, emit, iterationOptions);
1396
+ if (result === "no_tasks") {
1397
+ await loopDependencies.sleep(SLEEP_INTERVAL_MS);
1398
+ } else {
1399
+ completedIterations++;
1400
+ emit({
1401
+ type: "loop.iteration_complete",
1402
+ iteration: completedIterations,
1403
+ maxIterations
1404
+ });
884
1405
  }
885
1406
  }
886
- return violations;
1407
+ emit({ type: "loop.ended", maxIterations });
1408
+ return { exitCode: 0 };
887
1409
  }
888
- async function safeScanDir(glob, dirPath) {
889
- const files = [];
890
- try {
891
- for await (const file of glob.scan(dirPath)) {
892
- files.push(file);
893
- }
894
- return { files, exists: true };
895
- } catch (error) {
896
- if (error.code === "ENOENT") {
897
- return { files: [], exists: false };
1410
+
1411
+ // lib/bucket/repository.ts
1412
+ var SLEEP_INTERVAL_MS2 = 30000;
1413
+ function parseRepository(data) {
1414
+ if (typeof data === "string") {
1415
+ return { name: data, gitUrl: data };
1416
+ }
1417
+ if (typeof data === "object" && data !== null && "name" in data && "gitUrl" in data) {
1418
+ const repositoryData = data;
1419
+ if (typeof repositoryData.name === "string" && typeof repositoryData.gitUrl === "string") {
1420
+ return { name: repositoryData.name, gitUrl: repositoryData.gitUrl };
898
1421
  }
899
- throw error;
900
1422
  }
1423
+ return null;
901
1424
  }
902
- async function lintMarkdown(dependencies) {
903
- const { context, fileSystem, globScanner: glob } = dependencies;
904
- const dustPath = `${context.cwd}/.dust`;
905
- const dustScan = await safeScanDir(glob, dustPath);
906
- if (!dustScan.exists) {
907
- context.stderr("Error: .dust directory not found");
908
- context.stderr("Run 'dust init' to initialize a Dust repository");
909
- return { exitCode: 1 };
910
- }
911
- const dustFiles = dustScan.files;
912
- const violations = [];
913
- context.stdout("Validating links in .dust/...");
914
- for (const file of dustFiles) {
915
- if (!file.endsWith(".md"))
916
- continue;
917
- const filePath = `${dustPath}/${file}`;
918
- try {
919
- const content = await fileSystem.readFile(filePath);
920
- violations.push(...validateLinks(filePath, content, fileSystem));
921
- } catch (error) {
922
- if (error.code !== "ENOENT") {
923
- throw error;
1425
+ function getRepoTempPath(repoName, tempDir) {
1426
+ const safeName = repoName.replace(/[^a-zA-Z0-9-_]/g, "-");
1427
+ return join7(tempDir, `dust-bucket-${safeName}`);
1428
+ }
1429
+ async function cloneRepository(repository, targetPath, spawn, context) {
1430
+ return new Promise((resolve) => {
1431
+ const proc = spawn("git", ["clone", repository.gitUrl, targetPath], {
1432
+ stdio: ["ignore", "pipe", "pipe"]
1433
+ });
1434
+ let stderr = "";
1435
+ proc.stderr?.on("data", (data) => {
1436
+ stderr += data.toString();
1437
+ });
1438
+ proc.on("close", (code) => {
1439
+ if (code === 0) {
1440
+ resolve(true);
1441
+ } else {
1442
+ context.stderr(`Failed to clone ${repository.name}: ${stderr.trim()}`);
1443
+ resolve(false);
924
1444
  }
925
- }
926
- }
927
- const contentDirs = ["goals", "facts", "ideas", "tasks"];
928
- context.stdout("Validating content files...");
929
- for (const dir of contentDirs) {
930
- const dirPath = `${dustPath}/${dir}`;
931
- const { files } = await safeScanDir(glob, dirPath);
932
- for (const file of files) {
933
- if (!file.endsWith(".md"))
934
- continue;
935
- const filePath = `${dirPath}/${file}`;
936
- let content;
937
- try {
938
- content = await fileSystem.readFile(filePath);
939
- } catch (error) {
940
- if (error.code === "ENOENT") {
941
- continue;
1445
+ });
1446
+ proc.on("error", (error) => {
1447
+ context.stderr(`Failed to clone ${repository.name}: ${error.message}`);
1448
+ resolve(false);
1449
+ });
1450
+ });
1451
+ }
1452
+ async function removeRepository(path, spawn, context) {
1453
+ return new Promise((resolve) => {
1454
+ const proc = spawn("rm", ["-rf", path], {
1455
+ stdio: ["ignore", "pipe", "pipe"]
1456
+ });
1457
+ proc.on("close", (code) => {
1458
+ resolve(code === 0);
1459
+ });
1460
+ proc.on("error", (error) => {
1461
+ context.stderr(`Failed to remove ${path}: ${error.message}`);
1462
+ resolve(false);
1463
+ });
1464
+ });
1465
+ }
1466
+ function createNoOpGlobScanner() {
1467
+ return {
1468
+ scan: async function* () {}
1469
+ };
1470
+ }
1471
+ async function runRepositoryLoop(repoState, repoDeps, sendEvent, sessionId) {
1472
+ const { spawn, run: run2, fileSystem, sleep } = repoDeps;
1473
+ const repoName = repoState.repository.name;
1474
+ const settings = await loadSettings(repoState.path, fileSystem);
1475
+ const commandDeps = {
1476
+ arguments: [],
1477
+ context: {
1478
+ cwd: repoState.path,
1479
+ stdout: (msg) => appendLogLine(repoState.logBuffer, createLogLine(msg, "stdout")),
1480
+ stderr: (msg) => appendLogLine(repoState.logBuffer, createLogLine(msg, "stderr"))
1481
+ },
1482
+ fileSystem,
1483
+ globScanner: createNoOpGlobScanner(),
1484
+ settings
1485
+ };
1486
+ let partialLine = "";
1487
+ const bufferSinkDeps = {
1488
+ ...defaultRunnerDependencies,
1489
+ createStdoutSink: () => ({
1490
+ write: (text) => {
1491
+ partialLine += text;
1492
+ const lines = partialLine.split(`
1493
+ `);
1494
+ for (let i = 0;i < lines.length - 1; i++) {
1495
+ appendLogLine(repoState.logBuffer, createLogLine(lines[i], "stdout"));
942
1496
  }
943
- throw error;
944
- }
945
- const openingSentenceViolation = validateOpeningSentence(filePath, content);
946
- if (openingSentenceViolation) {
947
- violations.push(openingSentenceViolation);
948
- }
949
- const openingSentenceLengthViolation = validateOpeningSentenceLength(filePath, content);
950
- if (openingSentenceLengthViolation) {
951
- violations.push(openingSentenceLengthViolation);
952
- }
953
- const titleFilenameViolation = validateTitleFilenameMatch(filePath, content);
954
- if (titleFilenameViolation) {
955
- violations.push(titleFilenameViolation);
956
- }
957
- }
958
- }
959
- const ideasPath = `${dustPath}/ideas`;
960
- const { files: ideaFiles } = await safeScanDir(glob, ideasPath);
961
- if (ideaFiles.length > 0) {
962
- context.stdout("Validating idea files in .dust/ideas/...");
963
- for (const file of ideaFiles) {
964
- if (!file.endsWith(".md"))
965
- continue;
966
- const filePath = `${ideasPath}/${file}`;
967
- let content;
968
- try {
969
- content = await fileSystem.readFile(filePath);
970
- } catch (error) {
971
- if (error.code === "ENOENT") {
972
- continue;
1497
+ partialLine = lines[lines.length - 1];
1498
+ },
1499
+ line: (text) => {
1500
+ if (partialLine) {
1501
+ appendLogLine(repoState.logBuffer, createLogLine(partialLine, "stdout"));
1502
+ partialLine = "";
973
1503
  }
974
- throw error;
975
- }
976
- violations.push(...validateIdeaOpenQuestions(filePath, content));
977
- }
978
- }
979
- const tasksPath = `${dustPath}/tasks`;
980
- const { files: taskFiles } = await safeScanDir(glob, tasksPath);
981
- if (taskFiles.length > 0) {
982
- context.stdout("Validating task files in .dust/tasks/...");
983
- for (const file of taskFiles) {
984
- if (!file.endsWith(".md"))
985
- continue;
986
- const filePath = `${tasksPath}/${file}`;
987
- let content;
988
- try {
989
- content = await fileSystem.readFile(filePath);
990
- } catch (error) {
991
- if (error.code === "ENOENT") {
992
- continue;
1504
+ for (const segment of text.split(`
1505
+ `)) {
1506
+ appendLogLine(repoState.logBuffer, createLogLine(segment, "stdout"));
993
1507
  }
994
- throw error;
995
- }
996
- const filenameViolation = validateFilename(filePath);
997
- if (filenameViolation) {
998
- violations.push(filenameViolation);
999
- }
1000
- violations.push(...validateTaskHeadings(filePath, content));
1001
- violations.push(...validateSemanticLinks(filePath, content));
1002
- const imperativeViolation = validateImperativeOpeningSentence(filePath, content);
1003
- if (imperativeViolation) {
1004
- violations.push(imperativeViolation);
1005
1508
  }
1006
- const ideaTransitionViolation = validateIdeaTransitionTitle(filePath, content, ideasPath, fileSystem);
1007
- if (ideaTransitionViolation) {
1008
- violations.push(ideaTransitionViolation);
1509
+ })
1510
+ };
1511
+ const bufferRun = (prompt, options) => run2(prompt, options, bufferSinkDeps);
1512
+ const loopDeps = {
1513
+ spawn,
1514
+ run: bufferRun,
1515
+ sleep,
1516
+ postEvent: async () => {}
1517
+ };
1518
+ let agentSessionId;
1519
+ let sequence = 0;
1520
+ const loopEmit = (event) => {
1521
+ const formatted = formatEvent(event);
1522
+ if (formatted !== null) {
1523
+ appendLogLine(repoState.logBuffer, createLogLine(formatted, "stdout"));
1524
+ }
1525
+ const agentEvent = mapToAgentEvent(event);
1526
+ if (agentEvent && sendEvent && sessionId) {
1527
+ sequence++;
1528
+ const msg = {
1529
+ sequence,
1530
+ timestamp: new Date().toISOString(),
1531
+ sessionId,
1532
+ repository: repoName,
1533
+ event: agentEvent
1534
+ };
1535
+ if (agentSessionId) {
1536
+ msg.agentSessionId = agentSessionId;
1009
1537
  }
1538
+ sendEvent(msg);
1010
1539
  }
1011
- }
1012
- const goalsPath = `${dustPath}/goals`;
1013
- const { files: goalFiles } = await safeScanDir(glob, goalsPath);
1014
- if (goalFiles.length > 0) {
1015
- context.stdout("Validating goal hierarchy in .dust/goals/...");
1016
- const allGoalRelationships = [];
1017
- for (const file of goalFiles) {
1018
- if (!file.endsWith(".md"))
1019
- continue;
1020
- const filePath = `${goalsPath}/${file}`;
1021
- let content;
1022
- try {
1023
- content = await fileSystem.readFile(filePath);
1024
- } catch (error) {
1025
- if (error.code === "ENOENT") {
1026
- continue;
1540
+ };
1541
+ while (!repoState.stopRequested) {
1542
+ agentSessionId = undefined;
1543
+ const result = await runOneIteration(commandDeps, loopDeps, loopEmit, {
1544
+ onRawEvent: (rawEvent) => {
1545
+ if (typeof rawEvent.session_id === "string" && rawEvent.session_id) {
1546
+ agentSessionId = rawEvent.session_id;
1027
1547
  }
1028
- throw error;
1548
+ loopEmit({ type: "claude.raw_event", rawEvent });
1029
1549
  }
1030
- violations.push(...validateGoalHierarchySections(filePath, content));
1031
- violations.push(...validateGoalHierarchyLinks(filePath, content));
1032
- allGoalRelationships.push(extractGoalRelationships(filePath, content));
1550
+ });
1551
+ if (result === "no_tasks") {
1552
+ await sleep(SLEEP_INTERVAL_MS2);
1033
1553
  }
1034
- violations.push(...validateBidirectionalLinks(allGoalRelationships));
1035
- violations.push(...validateNoCycles(allGoalRelationships));
1036
1554
  }
1037
- if (violations.length === 0) {
1038
- context.stdout("All validations passed!");
1039
- return { exitCode: 0 };
1040
- }
1041
- context.stderr(`Found ${violations.length} violation(s):`);
1042
- context.stderr("");
1043
- for (const v of violations) {
1044
- const location = v.line ? `:${v.line}` : "";
1045
- context.stderr(` ${v.file}${location}`);
1046
- context.stderr(` ${v.message}`);
1047
- }
1048
- return { exitCode: 1 };
1049
- }
1050
-
1051
- // lib/cli/commands/check.ts
1052
- var DEFAULT_CHECK_TIMEOUT_MS = 13000;
1053
- async function runSingleCheck(check, cwd, runner) {
1054
- const timeoutMs = check.timeoutMilliseconds ?? DEFAULT_CHECK_TIMEOUT_MS;
1055
- const startTime = Date.now();
1056
- const result = await runner.run(check.command, cwd, timeoutMs);
1057
- const durationMs = Date.now() - startTime;
1058
- return {
1059
- name: check.name,
1060
- command: check.command,
1061
- exitCode: result.exitCode,
1062
- output: result.output,
1063
- hints: check.hints,
1064
- durationMs,
1065
- timedOut: result.timedOut,
1066
- timeoutSeconds: timeoutMs / 1000
1067
- };
1068
- }
1069
- async function runConfiguredChecks(checks, cwd, runner) {
1070
- const promises = checks.map((check) => runSingleCheck(check, cwd, runner));
1071
- return Promise.all(promises);
1072
- }
1073
- async function runConfiguredChecksSerially(checks, cwd, runner) {
1074
- const results = [];
1075
- for (const check of checks) {
1076
- results.push(await runSingleCheck(check, cwd, runner));
1077
- }
1078
- return results;
1555
+ appendLogLine(repoState.logBuffer, createLogLine(`Stopped loop for ${repoName}`, "stdout"));
1079
1556
  }
1080
- async function runValidationCheck(dependencies) {
1081
- const outputLines = [];
1082
- const bufferedContext = {
1083
- cwd: dependencies.context.cwd,
1084
- stdout: (msg) => outputLines.push(msg),
1085
- stderr: (msg) => outputLines.push(msg)
1086
- };
1087
- const startTime = Date.now();
1088
- const result = await lintMarkdown({
1089
- ...dependencies,
1090
- context: bufferedContext,
1091
- arguments: []
1092
- });
1093
- const durationMs = Date.now() - startTime;
1094
- return {
1095
- name: "lint markdown",
1096
- command: "dust lint markdown",
1097
- exitCode: result.exitCode,
1098
- output: outputLines.join(`
1099
- `),
1100
- isBuiltIn: true,
1101
- durationMs
1557
+ async function addRepository(repository, manager, repoDeps, context) {
1558
+ if (manager.repositories.has(repository.name)) {
1559
+ return;
1560
+ }
1561
+ const repoPath = getRepoTempPath(repository.name, repoDeps.getTempDir());
1562
+ if (repoDeps.fileSystem.exists(repoPath)) {
1563
+ await removeRepository(repoPath, repoDeps.spawn, context);
1564
+ }
1565
+ context.stdout(`Adding repository: ${repository.name}`);
1566
+ const success = await cloneRepository(repository, repoPath, repoDeps.spawn, context);
1567
+ if (!success) {
1568
+ const errorEvent = {
1569
+ type: "bucket.error",
1570
+ repository: repository.name,
1571
+ error: "Clone failed"
1572
+ };
1573
+ manager.emit(errorEvent);
1574
+ context.stderr(formatBucketEvent(errorEvent));
1575
+ return;
1576
+ }
1577
+ const repoState = {
1578
+ repository,
1579
+ path: repoPath,
1580
+ loopPromise: null,
1581
+ stopRequested: false,
1582
+ logBuffer: manager.logBuffers.get(repository.name) ?? createLogBuffer()
1583
+ };
1584
+ manager.repositories.set(repository.name, repoState);
1585
+ const addedEvent = {
1586
+ type: "bucket.repository_added",
1587
+ repository: repository.name
1102
1588
  };
1589
+ manager.emit(addedEvent);
1590
+ context.stdout(formatBucketEvent(addedEvent));
1591
+ repoState.loopPromise = runRepositoryLoop(repoState, repoDeps, manager.sendEvent, manager.sessionId);
1592
+ }
1593
+ async function removeRepositoryFromManager(repoName, manager, repoDeps, context) {
1594
+ const repoState = manager.repositories.get(repoName);
1595
+ if (!repoState) {
1596
+ return;
1597
+ }
1598
+ repoState.stopRequested = true;
1599
+ if (repoState.loopPromise) {
1600
+ await Promise.race([repoState.loopPromise, repoDeps.sleep(5000)]);
1601
+ }
1602
+ await removeRepository(repoState.path, repoDeps.spawn, context);
1603
+ manager.repositories.delete(repoName);
1604
+ const removedEvent = {
1605
+ type: "bucket.repository_removed",
1606
+ repository: repoName
1607
+ };
1608
+ manager.emit(removedEvent);
1609
+ context.stdout(formatBucketEvent(removedEvent));
1103
1610
  }
1104
- function displayResults(results, context) {
1105
- const passed = results.filter((r) => r.exitCode === 0);
1106
- const failed = results.filter((r) => r.exitCode !== 0);
1107
- for (const result of results) {
1108
- if (result.timedOut) {
1109
- context.stdout(`✗ ${result.name} [timed out after ${result.timeoutSeconds}s]`);
1110
- } else {
1111
- const timing = result.durationMs !== undefined && result.durationMs >= 1000 ? ` [${(result.durationMs / 1000).toFixed(1)}s]` : "";
1112
- if (result.exitCode === 0) {
1113
- context.stdout(`✓ ${result.name}${timing}`);
1114
- } else {
1115
- context.stdout(`✗ ${result.name}${timing}`);
1116
- }
1611
+ async function handleRepositoryList(repositories, manager, repoDeps, context) {
1612
+ const incomingRepos = new Map;
1613
+ for (const data of repositories) {
1614
+ const repo = parseRepository(data);
1615
+ if (repo) {
1616
+ incomingRepos.set(repo.name, repo);
1117
1617
  }
1118
1618
  }
1119
- for (const result of failed) {
1120
- context.stdout("");
1121
- context.stdout(`> ${result.command}`);
1122
- if (result.timedOut) {
1123
- context.stdout(`Note: This check was killed after ${result.timeoutSeconds}s. To configure a different timeout, set "timeoutMilliseconds" in the check configuration in .dust/config/settings.json`);
1619
+ for (const [name, repo] of incomingRepos) {
1620
+ if (!manager.repositories.has(name)) {
1621
+ await addRepository(repo, manager, repoDeps, context);
1124
1622
  }
1125
- if (result.output.trim()) {
1126
- context.stdout(result.output.trimEnd());
1623
+ }
1624
+ for (const name of manager.repositories.keys()) {
1625
+ if (!incomingRepos.has(name)) {
1626
+ await removeRepositoryFromManager(name, manager, repoDeps, context);
1127
1627
  }
1128
- if (result.hints && result.hints.length > 0) {
1129
- context.stdout("");
1130
- context.stdout(`Hints for fixing '${result.name}':`);
1131
- for (const hint of result.hints) {
1132
- context.stdout(` - ${hint}`);
1628
+ }
1629
+ }
1630
+
1631
+ // lib/bucket/terminal-ui.ts
1632
+ var ANSI = {
1633
+ HIDE_CURSOR: "\x1B[?25l",
1634
+ SHOW_CURSOR: "\x1B[?25h",
1635
+ MOVE_TO: (row, col) => `\x1B[${row};${col}H`,
1636
+ CLEAR_LINE: "\x1B[2K",
1637
+ CLEAR_SCREEN: "\x1B[2J",
1638
+ ENTER_ALT_SCREEN: "\x1B[?1049h",
1639
+ EXIT_ALT_SCREEN: "\x1B[?1049l",
1640
+ RESET: "\x1B[0m",
1641
+ BOLD: "\x1B[1m",
1642
+ DIM: "\x1B[2m",
1643
+ UNDERLINE: "\x1B[4m",
1644
+ INVERSE: "\x1B[7m",
1645
+ FG_BLACK: "\x1B[30m",
1646
+ FG_RED: "\x1B[31m",
1647
+ FG_GREEN: "\x1B[32m",
1648
+ FG_YELLOW: "\x1B[33m",
1649
+ FG_BLUE: "\x1B[34m",
1650
+ FG_MAGENTA: "\x1B[35m",
1651
+ FG_CYAN: "\x1B[36m",
1652
+ FG_WHITE: "\x1B[37m",
1653
+ BG_BLACK: "\x1B[40m",
1654
+ BG_RED: "\x1B[41m",
1655
+ BG_GREEN: "\x1B[42m",
1656
+ BG_YELLOW: "\x1B[43m",
1657
+ BG_BLUE: "\x1B[44m",
1658
+ BG_MAGENTA: "\x1B[45m",
1659
+ BG_CYAN: "\x1B[46m",
1660
+ BG_WHITE: "\x1B[47m"
1661
+ };
1662
+ var REPO_COLORS = [
1663
+ ANSI.FG_CYAN,
1664
+ ANSI.FG_MAGENTA,
1665
+ ANSI.FG_YELLOW,
1666
+ ANSI.FG_GREEN,
1667
+ ANSI.FG_BLUE,
1668
+ ANSI.FG_RED
1669
+ ];
1670
+ var SYSTEM_COLOR = ANSI.DIM;
1671
+ function visibleLength(text) {
1672
+ return text.replace(/\x1b\[[0-9;]*m/g, "").length;
1673
+ }
1674
+ function truncateLine(text, maxWidth) {
1675
+ if (maxWidth <= 0)
1676
+ return "";
1677
+ if (visibleLength(text) <= maxWidth)
1678
+ return text;
1679
+ const ansiRegex = /\x1b\[[0-9;]*m/g;
1680
+ let visibleCount = 0;
1681
+ let result = "";
1682
+ let lastIndex = 0;
1683
+ for (let match = ansiRegex.exec(text);match !== null; match = ansiRegex.exec(text)) {
1684
+ const textBefore = text.slice(lastIndex, match.index);
1685
+ for (const char of textBefore) {
1686
+ if (visibleCount >= maxWidth - 1) {
1687
+ result += "…";
1688
+ return result + ANSI.RESET;
1133
1689
  }
1690
+ result += char;
1691
+ visibleCount++;
1134
1692
  }
1693
+ result += match[0];
1694
+ lastIndex = match.index + match[0].length;
1135
1695
  }
1136
- context.stdout("");
1137
- const indicator = failed.length > 0 ? "✗" : "✓";
1138
- context.stdout(`${indicator} ${passed.length}/${results.length} checks passed`);
1139
- return failed.length > 0 ? 1 : 0;
1696
+ const remaining = text.slice(lastIndex);
1697
+ for (const char of remaining) {
1698
+ if (visibleCount >= maxWidth - 1) {
1699
+ result += "…";
1700
+ return result + ANSI.RESET;
1701
+ }
1702
+ result += char;
1703
+ visibleCount++;
1704
+ }
1705
+ return result;
1140
1706
  }
1141
- async function check(dependencies, shellRunner = defaultShellRunner) {
1142
- const {
1143
- arguments: commandArguments,
1144
- context,
1145
- fileSystem,
1146
- settings
1147
- } = dependencies;
1148
- const serial = commandArguments.includes("--serial");
1149
- if (!settings.checks || settings.checks.length === 0) {
1150
- context.stderr("Error: No checks configured in .dust/config/settings.json");
1151
- context.stderr("");
1152
- context.stderr("Add checks to your settings.json:");
1153
- context.stderr(" {");
1154
- context.stderr(' "checks": [');
1155
- context.stderr(' { "name": "lint", "command": "npm run lint" },');
1156
- context.stderr(' { "name": "test", "command": "npm test" }');
1157
- context.stderr(" ]");
1158
- context.stderr(" }");
1159
- return { exitCode: 1 };
1707
+ function createTerminalUIState() {
1708
+ return {
1709
+ repositories: [],
1710
+ selectedIndex: -1,
1711
+ logBuffers: new Map,
1712
+ scrollOffset: 0,
1713
+ autoScroll: true,
1714
+ width: 80,
1715
+ height: 24,
1716
+ connectedHost: ""
1717
+ };
1718
+ }
1719
+ function updateDimensions(state, width, height) {
1720
+ state.width = width;
1721
+ state.height = height;
1722
+ }
1723
+ function addRepository2(state, name, logBuffer) {
1724
+ if (!state.repositories.includes(name)) {
1725
+ state.repositories.push(name);
1726
+ state.repositories.sort((a, b) => {
1727
+ if (a === "system")
1728
+ return 1;
1729
+ if (b === "system")
1730
+ return -1;
1731
+ return a.localeCompare(b);
1732
+ });
1160
1733
  }
1161
- const dustPath = `${context.cwd}/.dust`;
1162
- const hasDustDir = fileSystem.exists(dustPath);
1163
- if (serial) {
1164
- const results2 = [];
1165
- if (hasDustDir) {
1166
- results2.push(await runValidationCheck(dependencies));
1734
+ state.logBuffers.set(name, logBuffer);
1735
+ }
1736
+ function removeRepository2(state, name) {
1737
+ const index = state.repositories.indexOf(name);
1738
+ if (index >= 0) {
1739
+ state.repositories.splice(index, 1);
1740
+ state.logBuffers.delete(name);
1741
+ if (state.selectedIndex >= state.repositories.length) {
1742
+ state.selectedIndex = state.repositories.length - 1;
1167
1743
  }
1168
- const configuredResults = await runConfiguredChecksSerially(settings.checks, context.cwd, shellRunner);
1169
- results2.push(...configuredResults);
1170
- const exitCode2 = displayResults(results2, context);
1171
- return { exitCode: exitCode2 };
1172
1744
  }
1173
- const checkPromises = [];
1174
- if (hasDustDir) {
1175
- checkPromises.push(runValidationCheck(dependencies));
1745
+ }
1746
+ function selectNext(state) {
1747
+ if (state.repositories.length === 0)
1748
+ return;
1749
+ state.selectedIndex++;
1750
+ if (state.selectedIndex >= state.repositories.length) {
1751
+ state.selectedIndex = -1;
1176
1752
  }
1177
- checkPromises.push(runConfiguredChecks(settings.checks, context.cwd, shellRunner));
1178
- const promiseResults = await Promise.all(checkPromises);
1179
- const results = [];
1180
- for (const result of promiseResults) {
1181
- if (Array.isArray(result)) {
1182
- results.push(...result);
1753
+ }
1754
+ function selectPrevious(state) {
1755
+ if (state.repositories.length === 0)
1756
+ return;
1757
+ state.selectedIndex--;
1758
+ if (state.selectedIndex < -1) {
1759
+ state.selectedIndex = state.repositories.length - 1;
1760
+ }
1761
+ }
1762
+ function scrollUp(state, lines = 1) {
1763
+ const logs = getVisibleLogs(state);
1764
+ const maxScroll = Math.max(0, logs.length - getLogAreaHeight(state));
1765
+ state.scrollOffset = Math.min(state.scrollOffset + lines, maxScroll);
1766
+ if (state.scrollOffset > 0) {
1767
+ state.autoScroll = false;
1768
+ }
1769
+ }
1770
+ function scrollDown(state, lines = 1) {
1771
+ state.scrollOffset = Math.max(0, state.scrollOffset - lines);
1772
+ if (state.scrollOffset === 0) {
1773
+ state.autoScroll = true;
1774
+ }
1775
+ }
1776
+ function scrollToTop(state) {
1777
+ const logs = getVisibleLogs(state);
1778
+ const maxScroll = Math.max(0, logs.length - getLogAreaHeight(state));
1779
+ state.scrollOffset = maxScroll;
1780
+ state.autoScroll = false;
1781
+ }
1782
+ function scrollToBottom(state) {
1783
+ state.scrollOffset = 0;
1784
+ state.autoScroll = true;
1785
+ }
1786
+ function getTabRowCount(state) {
1787
+ if (state.width <= 0)
1788
+ return 1;
1789
+ const tabWidths = [5];
1790
+ for (const name of state.repositories) {
1791
+ tabWidths.push(name.length + 2);
1792
+ }
1793
+ let rows = 1;
1794
+ let currentRowWidth = 0;
1795
+ for (const tabWidth of tabWidths) {
1796
+ const separatorWidth = currentRowWidth > 0 ? 1 : 0;
1797
+ if (currentRowWidth + separatorWidth + tabWidth > state.width && currentRowWidth > 0) {
1798
+ rows++;
1799
+ currentRowWidth = tabWidth;
1183
1800
  } else {
1184
- results.push(result);
1801
+ currentRowWidth += separatorWidth + tabWidth;
1185
1802
  }
1186
1803
  }
1187
- const exitCode = displayResults(results, context);
1188
- return { exitCode };
1804
+ return rows;
1189
1805
  }
1190
-
1191
- // lib/cli/commands/focus.ts
1192
- async function focus(dependencies) {
1193
- const { context, settings } = dependencies;
1194
- const objective = dependencies.arguments.join(" ").trim();
1195
- if (!objective) {
1196
- context.stderr("Error: No objective provided");
1197
- context.stderr('Usage: dust focus "your objective here"');
1198
- return { exitCode: 1 };
1806
+ function getLogAreaHeight(state) {
1807
+ const tabRows = getTabRowCount(state);
1808
+ return Math.max(1, state.height - (tabRows + 3));
1809
+ }
1810
+ function getRepoColor(name, index) {
1811
+ if (name === "system")
1812
+ return SYSTEM_COLOR;
1813
+ return REPO_COLORS[index % REPO_COLORS.length];
1814
+ }
1815
+ function getVisibleLogs(state) {
1816
+ if (state.selectedIndex === -1) {
1817
+ const allLogs = [];
1818
+ const repoColors = new Map;
1819
+ for (let i = 0;i < state.repositories.length; i++) {
1820
+ const repoName2 = state.repositories[i];
1821
+ repoColors.set(repoName2, getRepoColor(repoName2, i));
1822
+ }
1823
+ for (const repoName2 of state.repositories) {
1824
+ const buffer2 = state.logBuffers.get(repoName2);
1825
+ if (!buffer2)
1826
+ continue;
1827
+ const color2 = repoColors.get(repoName2) ?? ANSI.FG_WHITE;
1828
+ const lines = getLogLines(buffer2);
1829
+ for (const line of lines) {
1830
+ allLogs.push({ ...line, repository: repoName2, color: color2 });
1831
+ }
1832
+ }
1833
+ allLogs.sort((a, b) => a.timestamp - b.timestamp);
1834
+ return allLogs;
1199
1835
  }
1200
- const hooksInstalled = await manageGitHooks(dependencies);
1201
- const vars = templateVariables(settings, hooksInstalled);
1202
- context.stdout(`\uD83C\uDFAF Focus: ${objective}`);
1203
- context.stdout("");
1204
- const steps = [];
1205
- let step = 1;
1206
- steps.push(`${step}. Run \`${vars.bin} check\` to verify the project is in a good state`);
1207
- step++;
1208
- steps.push(`${step}. Implement the task`);
1209
- step++;
1210
- if (!hooksInstalled) {
1211
- steps.push(`${step}. Run \`${vars.bin} check\` before committing`);
1212
- step++;
1836
+ const repoName = state.repositories[state.selectedIndex];
1837
+ if (!repoName)
1838
+ return [];
1839
+ const buffer = state.logBuffers.get(repoName);
1840
+ if (!buffer)
1841
+ return [];
1842
+ const color = getRepoColor(repoName, state.selectedIndex);
1843
+ return getLogLines(buffer).map((line) => ({
1844
+ ...line,
1845
+ repository: repoName,
1846
+ color
1847
+ }));
1848
+ }
1849
+ function renderTabs(state) {
1850
+ const tabs = [];
1851
+ if (state.selectedIndex === -1) {
1852
+ tabs.push({ text: `${ANSI.INVERSE} All ${ANSI.RESET}`, width: 5 });
1853
+ } else {
1854
+ tabs.push({ text: " All ", width: 5 });
1855
+ }
1856
+ for (let i = 0;i < state.repositories.length; i++) {
1857
+ const name = state.repositories[i];
1858
+ const color = getRepoColor(name, i);
1859
+ const width = name.length + 2;
1860
+ if (i === state.selectedIndex) {
1861
+ tabs.push({
1862
+ text: `${ANSI.INVERSE}${color} ${name} ${ANSI.RESET}`,
1863
+ width
1864
+ });
1865
+ } else {
1866
+ tabs.push({ text: `${color} ${name} ${ANSI.RESET}`, width });
1867
+ }
1213
1868
  }
1214
- steps.push(`${step}. Create a single atomic commit that includes:`, " - All implementation changes", " - Deletion of the completed task file", " - Updates to any facts that changed", " - Deletion of any ideas that were fully realized", "", ' Use the task title as the commit message. Task titles are written in imperative form, which is the recommended style for git commit messages. Do not add prefixes like "Complete task:" - use the title directly.', "", ' Example: If the task title is "Add validation for user input", the commit message should be:', " ```", " Add validation for user input", " ```", "");
1215
- step++;
1216
- steps.push(`${step}. Push your commit to the remote repository`);
1217
- steps.push("");
1218
- steps.push("Keep your change small and focused. One task, one commit.");
1219
- context.stdout(steps.join(`
1220
- `));
1221
- return { exitCode: 0 };
1869
+ const rows = [[]];
1870
+ let currentRowWidth = 0;
1871
+ for (const tab of tabs) {
1872
+ const separatorWidth = currentRowWidth > 0 ? 1 : 0;
1873
+ if (currentRowWidth + separatorWidth + tab.width > state.width && currentRowWidth > 0) {
1874
+ rows.push([tab]);
1875
+ currentRowWidth = tab.width;
1876
+ } else {
1877
+ rows[rows.length - 1].push(tab);
1878
+ currentRowWidth += separatorWidth + tab.width;
1879
+ }
1880
+ }
1881
+ return rows.map((row) => row.map((t) => t.text).join("|"));
1222
1882
  }
1223
-
1224
- // lib/cli/commands/help.ts
1225
- function generateHelpText(settings) {
1226
- return loadTemplate("help", { bin: settings.dustCommand });
1883
+ function renderHelpLine() {
1884
+ return `${ANSI.DIM}[←→] select [↑↓] scroll [PgUp/PgDn] page [g/G] top/bottom [q] quit${ANSI.RESET}`;
1227
1885
  }
1228
- async function help(dependencies) {
1229
- dependencies.context.stdout(generateHelpText(dependencies.settings));
1230
- return { exitCode: 0 };
1886
+ function renderSeparator(width) {
1887
+ return "─".repeat(width);
1231
1888
  }
1232
-
1233
- // lib/cli/commands/implement-task.ts
1234
- async function implementTask(dependencies) {
1235
- const { context, settings } = dependencies;
1236
- const hooksInstalled = await manageGitHooks(dependencies);
1237
- const vars = templateVariables(settings, hooksInstalled);
1238
- context.stdout(`Run \`${vars.bin} focus "<task name>"\` to set your focus and see implementation instructions.`);
1239
- return { exitCode: 0 };
1889
+ function formatLogLine(line, prefixAlign, maxWidth) {
1890
+ let prefix = "";
1891
+ let prefixWidth = 0;
1892
+ if (prefixAlign > 0) {
1893
+ const paddedName = line.repository.padEnd(prefixAlign);
1894
+ prefix = `${line.color}${paddedName}${ANSI.RESET} ${ANSI.DIM}|${ANSI.RESET} `;
1895
+ prefixWidth = prefixAlign + 3;
1896
+ }
1897
+ const textColor = line.stream === "stderr" ? ANSI.FG_RED : "";
1898
+ const textReset = line.stream === "stderr" ? ANSI.RESET : "";
1899
+ const sanitizedText = line.text.replace(/[\r\n]+/g, "");
1900
+ const text = `${textColor}${sanitizedText}${textReset}`;
1901
+ const availableWidth = maxWidth - prefixWidth;
1902
+ if (availableWidth <= 0)
1903
+ return prefix;
1904
+ return prefix + truncateLine(text, availableWidth);
1240
1905
  }
1241
-
1242
- // lib/cli/colors.ts
1243
- var ANSI_COLORS = {
1244
- reset: "\x1B[0m",
1245
- bold: "\x1B[1m",
1246
- dim: "\x1B[2m",
1247
- cyan: "\x1B[36m",
1248
- green: "\x1B[32m",
1249
- yellow: "\x1B[33m"
1250
- };
1251
- var NO_COLORS = {
1252
- reset: "",
1253
- bold: "",
1254
- dim: "",
1255
- cyan: "",
1256
- green: "",
1257
- yellow: ""
1906
+ function renderFrame(state) {
1907
+ const lines = [];
1908
+ const hostLabel = state.connectedHost ? ` ${ANSI.DIM}[connected to ${state.connectedHost}]${ANSI.RESET}` : "";
1909
+ lines.push(`${ANSI.BOLD}✨ dust bucket${ANSI.RESET}${hostLabel}`);
1910
+ const tabRows = renderTabs(state);
1911
+ for (const tabRow of tabRows) {
1912
+ lines.push(tabRow);
1913
+ }
1914
+ const contentWidth = state.width - 1;
1915
+ lines.push(truncateLine(renderHelpLine(), contentWidth));
1916
+ lines.push(renderSeparator(contentWidth));
1917
+ const logs = getVisibleLogs(state);
1918
+ const logAreaHeight = getLogAreaHeight(state);
1919
+ const prefixAlign = state.selectedIndex === -1 ? Math.max(0, ...state.repositories.map((r) => r.length)) : 0;
1920
+ const totalLogs = logs.length;
1921
+ const endIndex = Math.max(0, totalLogs - state.scrollOffset);
1922
+ const startIndex = Math.max(0, endIndex - logAreaHeight);
1923
+ for (let i = startIndex;i < endIndex; i++) {
1924
+ const logLine = logs[i];
1925
+ lines.push(formatLogLine(logLine, prefixAlign, contentWidth));
1926
+ }
1927
+ const renderedLogLines = endIndex - startIndex;
1928
+ for (let i = renderedLogLines;i < logAreaHeight; i++) {
1929
+ lines.push("");
1930
+ }
1931
+ if (state.scrollOffset > 0) {
1932
+ const indicator = `${ANSI.DIM}↓ ${state.scrollOffset} more${ANSI.RESET}`;
1933
+ lines[lines.length - 1] = indicator;
1934
+ }
1935
+ const output = [];
1936
+ for (let i = 0;i < lines.length; i++) {
1937
+ output.push(ANSI.MOVE_TO(i + 1, 1));
1938
+ output.push(ANSI.CLEAR_LINE);
1939
+ output.push(lines[i]);
1940
+ }
1941
+ return output.join("");
1942
+ }
1943
+ function enterAlternateScreen() {
1944
+ return ANSI.ENTER_ALT_SCREEN + ANSI.HIDE_CURSOR + ANSI.CLEAR_SCREEN + "\x1B[?1000h" + "\x1B[?1006h";
1945
+ }
1946
+ function exitAlternateScreen() {
1947
+ return `\x1B[?1006l\x1B[?1000l${ANSI.EXIT_ALT_SCREEN}${ANSI.SHOW_CURSOR}`;
1948
+ }
1949
+ var KEYS = {
1950
+ UP: "\x1B[A",
1951
+ DOWN: "\x1B[B",
1952
+ RIGHT: "\x1B[C",
1953
+ LEFT: "\x1B[D",
1954
+ PAGE_UP: "\x1B[5~",
1955
+ PAGE_DOWN: "\x1B[6~",
1956
+ HOME: "\x1B[H",
1957
+ END: "\x1B[F",
1958
+ CTRL_C: "\x03"
1258
1959
  };
1259
- function shouldDisableColors() {
1260
- if (process.env.NO_COLOR !== undefined) {
1261
- return true;
1960
+ var SGR_MOUSE_RE = new RegExp(String.raw`^\x1b\[<(\d+);\d+;\d+[Mm]$`);
1961
+ function parseSGRMouse(key) {
1962
+ const match = key.match(SGR_MOUSE_RE);
1963
+ if (!match)
1964
+ return null;
1965
+ return Number.parseInt(match[1], 10);
1966
+ }
1967
+ function handleKeyInput(state, key) {
1968
+ const mouseButton = parseSGRMouse(key);
1969
+ if (mouseButton !== null) {
1970
+ if (mouseButton === 64) {
1971
+ scrollUp(state, 3);
1972
+ } else if (mouseButton === 65) {
1973
+ scrollDown(state, 3);
1974
+ }
1975
+ return false;
1262
1976
  }
1263
- if (process.env.TERM === "dumb") {
1264
- return true;
1977
+ switch (key) {
1978
+ case "q":
1979
+ case KEYS.CTRL_C:
1980
+ return true;
1981
+ case KEYS.LEFT:
1982
+ selectPrevious(state);
1983
+ state.scrollOffset = 0;
1984
+ state.autoScroll = true;
1985
+ break;
1986
+ case KEYS.RIGHT:
1987
+ selectNext(state);
1988
+ state.scrollOffset = 0;
1989
+ state.autoScroll = true;
1990
+ break;
1991
+ case KEYS.UP:
1992
+ scrollUp(state, 1);
1993
+ break;
1994
+ case KEYS.DOWN:
1995
+ scrollDown(state, 1);
1996
+ break;
1997
+ case KEYS.PAGE_UP:
1998
+ scrollUp(state, getLogAreaHeight(state));
1999
+ break;
2000
+ case KEYS.PAGE_DOWN:
2001
+ scrollDown(state, getLogAreaHeight(state));
2002
+ break;
2003
+ case "g":
2004
+ case KEYS.HOME:
2005
+ scrollToTop(state);
2006
+ break;
2007
+ case "G":
2008
+ case KEYS.END:
2009
+ scrollToBottom(state);
2010
+ break;
2011
+ }
2012
+ return false;
2013
+ }
2014
+
2015
+ // lib/cli/commands/bucket.ts
2016
+ var DEFAULT_DUSTBUCKET_WS_URL = "wss://dustbucket.com/agent/connect";
2017
+ var INITIAL_RECONNECT_DELAY_MS = 1000;
2018
+ var MAX_RECONNECT_DELAY_MS = 30000;
2019
+ function defaultCreateWebSocket(url, token) {
2020
+ const ws = new WebSocket(url, {
2021
+ headers: {
2022
+ Authorization: `Bearer ${token}`
2023
+ }
2024
+ });
2025
+ return ws;
2026
+ }
2027
+ function defaultSetupKeypress(onKey) {
2028
+ const stdin = process.stdin;
2029
+ if (!stdin.isTTY) {
2030
+ return () => {};
2031
+ }
2032
+ stdin.setRawMode(true);
2033
+ stdin.resume();
2034
+ stdin.setEncoding("utf8");
2035
+ const handler = (key) => {
2036
+ onKey(key);
2037
+ };
2038
+ stdin.on("data", handler);
2039
+ return () => {
2040
+ stdin.removeListener("data", handler);
2041
+ stdin.setRawMode(false);
2042
+ stdin.pause();
2043
+ };
2044
+ }
2045
+ function defaultSetupSignals(onSignal) {
2046
+ const handler = () => onSignal();
2047
+ process.on("SIGINT", handler);
2048
+ process.on("SIGTERM", handler);
2049
+ return () => {
2050
+ process.removeListener("SIGINT", handler);
2051
+ process.removeListener("SIGTERM", handler);
2052
+ };
2053
+ }
2054
+ function defaultSetupResize(onResize) {
2055
+ const handler = () => {
2056
+ const { columns, rows } = process.stdout;
2057
+ onResize(columns ?? 80, rows ?? 24);
2058
+ };
2059
+ process.stdout.on("resize", handler);
2060
+ return () => {
2061
+ process.stdout.removeListener("resize", handler);
2062
+ };
2063
+ }
2064
+ function defaultGetTerminalSize() {
2065
+ return {
2066
+ width: process.stdout.columns ?? 80,
2067
+ height: process.stdout.rows ?? 24
2068
+ };
2069
+ }
2070
+ function defaultWriteStdout(data) {
2071
+ process.stdout.write(data);
2072
+ }
2073
+ function defaultCreateServer(handler) {
2074
+ const server = Bun.serve({
2075
+ port: 0,
2076
+ fetch: handler
2077
+ });
2078
+ return { port: server.port ?? 0, stop: () => server.stop() };
2079
+ }
2080
+ function defaultOpenBrowser(url) {
2081
+ const cmd = process.platform === "darwin" ? "open" : "xdg-open";
2082
+ nodeSpawn3(cmd, [url], { stdio: "ignore", detached: true }).unref();
2083
+ }
2084
+ function createDefaultBucketDependencies() {
2085
+ const authFileSystem = {
2086
+ exists: (path) => {
2087
+ try {
2088
+ const file = Bun.file(path);
2089
+ return file.size > 0;
2090
+ } catch {
2091
+ return false;
2092
+ }
2093
+ },
2094
+ readFile: (path) => Bun.file(path).text(),
2095
+ writeFile: (path, content) => Bun.write(path, content).then(() => {}),
2096
+ mkdir: (path, options) => {
2097
+ const { mkdir } = __require("node:fs/promises");
2098
+ return mkdir(path, options);
2099
+ },
2100
+ readdir: (path) => {
2101
+ const { readdir } = __require("node:fs/promises");
2102
+ return readdir(path);
2103
+ },
2104
+ chmod: (path, mode) => {
2105
+ const { chmod } = __require("node:fs/promises");
2106
+ return chmod(path, mode);
2107
+ }
2108
+ };
2109
+ return {
2110
+ spawn: nodeSpawn3,
2111
+ createWebSocket: defaultCreateWebSocket,
2112
+ setupKeypress: defaultSetupKeypress,
2113
+ setupSignals: defaultSetupSignals,
2114
+ setupResize: defaultSetupResize,
2115
+ getTerminalSize: defaultGetTerminalSize,
2116
+ writeStdout: defaultWriteStdout,
2117
+ isTTY: process.stdout.isTTY ?? false,
2118
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
2119
+ getTempDir: () => tmpdir(),
2120
+ auth: {
2121
+ createServer: defaultCreateServer,
2122
+ openBrowser: defaultOpenBrowser,
2123
+ getHomeDir: () => homedir(),
2124
+ fileSystem: authFileSystem
2125
+ }
2126
+ };
2127
+ }
2128
+ function createInitialState() {
2129
+ const sessionId = crypto.randomUUID();
2130
+ const systemBuffer = createLogBuffer();
2131
+ const state = {
2132
+ ws: null,
2133
+ repositories: new Map,
2134
+ reconnectDelay: INITIAL_RECONNECT_DELAY_MS,
2135
+ reconnectTimer: null,
2136
+ shuttingDown: false,
2137
+ sessionId,
2138
+ emit: () => {},
2139
+ sendEvent: () => {},
2140
+ ui: createTerminalUIState(),
2141
+ logBuffers: new Map
2142
+ };
2143
+ state.sendEvent = createEventMessageSender(() => state.ws);
2144
+ state.logBuffers.set("system", systemBuffer);
2145
+ addRepository2(state.ui, "system", systemBuffer);
2146
+ return state;
2147
+ }
2148
+ function getWebSocketUrl() {
2149
+ return process.env.DUST_BUCKET_AGENT_CONNECT_URL || DEFAULT_DUSTBUCKET_WS_URL;
2150
+ }
2151
+ function toRepositoryDependencies(bucketDeps, fileSystem) {
2152
+ return {
2153
+ spawn: bucketDeps.spawn,
2154
+ run,
2155
+ fileSystem,
2156
+ sleep: bucketDeps.sleep,
2157
+ getTempDir: bucketDeps.getTempDir
2158
+ };
2159
+ }
2160
+ function syncUIWithRepoList(state, repos) {
2161
+ const incomingNames = new Set;
2162
+ for (const data of repos) {
2163
+ const repo = parseRepository(data);
2164
+ if (repo) {
2165
+ incomingNames.add(repo.name);
2166
+ if (!state.ui.repositories.includes(repo.name)) {
2167
+ let buffer = state.logBuffers.get(repo.name);
2168
+ if (!buffer) {
2169
+ buffer = createLogBuffer();
2170
+ state.logBuffers.set(repo.name, buffer);
2171
+ }
2172
+ addRepository2(state.ui, repo.name, buffer);
2173
+ }
2174
+ }
2175
+ }
2176
+ for (const name of [...state.ui.repositories]) {
2177
+ if (name !== "system" && !incomingNames.has(name)) {
2178
+ state.logBuffers.delete(name);
2179
+ removeRepository2(state.ui, name);
2180
+ }
2181
+ }
2182
+ }
2183
+ function syncTUI(state) {
2184
+ const currentUIRepos = new Set(state.ui.repositories);
2185
+ const currentRepos = new Set(state.repositories.keys());
2186
+ for (const [name, repoState] of state.repositories) {
2187
+ state.logBuffers.set(name, repoState.logBuffer);
2188
+ addRepository2(state.ui, name, repoState.logBuffer);
2189
+ }
2190
+ for (const name of currentUIRepos) {
2191
+ if (name !== "system" && !currentRepos.has(name)) {
2192
+ state.logBuffers.delete(name);
2193
+ removeRepository2(state.ui, name);
2194
+ }
2195
+ }
2196
+ }
2197
+ function logMessage(state, context, useTUI, message, stream = "stdout") {
2198
+ if (useTUI) {
2199
+ const systemBuffer = state.logBuffers.get("system");
2200
+ if (!systemBuffer)
2201
+ return;
2202
+ appendLogLine(systemBuffer, createLogLine(message, stream));
2203
+ } else if (stream === "stderr") {
2204
+ context.stderr(message);
2205
+ } else {
2206
+ context.stdout(message);
1265
2207
  }
1266
- if (!process.stdout.isTTY) {
1267
- return true;
2208
+ }
2209
+ function createTUIContext(state, context, useTUI) {
2210
+ if (!useTUI)
2211
+ return context;
2212
+ return {
2213
+ ...context,
2214
+ stdout: (message) => logMessage(state, context, true, message, "stdout"),
2215
+ stderr: (message) => logMessage(state, context, true, message, "stderr")
2216
+ };
2217
+ }
2218
+ function waitForConnection(token, bucketDeps) {
2219
+ const wsUrl = getWebSocketUrl();
2220
+ const ws = bucketDeps.createWebSocket(wsUrl, token);
2221
+ return new Promise((resolve, reject) => {
2222
+ ws.onopen = () => resolve(ws);
2223
+ ws.onerror = (error) => reject(new Error(error.message));
2224
+ ws.onclose = (event) => reject(new Error(`Connection closed (code ${event.code})`));
2225
+ });
2226
+ }
2227
+ function connectWebSocket(token, state, bucketDependencies, context, fileSystem, useTUI, connectedWs) {
2228
+ if (state.shuttingDown)
2229
+ return;
2230
+ const wsUrl = getWebSocketUrl();
2231
+ let ws;
2232
+ if (connectedWs) {
2233
+ ws = connectedWs;
2234
+ state.ws = ws;
2235
+ state.emit({ type: "bucket.connected" });
2236
+ logMessage(state, context, useTUI, formatBucketEvent({ type: "bucket.connected" }));
2237
+ state.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
2238
+ } else {
2239
+ logMessage(state, context, useTUI, `Connecting to ${wsUrl}...`);
2240
+ ws = bucketDependencies.createWebSocket(wsUrl, token);
2241
+ state.ws = ws;
2242
+ ws.onopen = () => {
2243
+ state.emit({ type: "bucket.connected" });
2244
+ logMessage(state, context, useTUI, formatBucketEvent({ type: "bucket.connected" }));
2245
+ state.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
2246
+ };
1268
2247
  }
1269
- return false;
2248
+ ws.onclose = (event) => {
2249
+ const disconnectEvent = {
2250
+ type: "bucket.disconnected",
2251
+ code: event.code,
2252
+ reason: event.reason || "none"
2253
+ };
2254
+ state.emit(disconnectEvent);
2255
+ logMessage(state, context, useTUI, formatBucketEvent(disconnectEvent));
2256
+ state.ws = null;
2257
+ if (!state.shuttingDown) {
2258
+ logMessage(state, context, useTUI, `Reconnecting in ${state.reconnectDelay / 1000} seconds...`);
2259
+ state.reconnectTimer = setTimeout(() => {
2260
+ connectWebSocket(token, state, bucketDependencies, context, fileSystem, useTUI);
2261
+ }, state.reconnectDelay);
2262
+ state.reconnectDelay = Math.min(state.reconnectDelay * 2, MAX_RECONNECT_DELAY_MS);
2263
+ }
2264
+ };
2265
+ ws.onerror = (error) => {
2266
+ logMessage(state, context, useTUI, `WebSocket error: ${error.message}`, "stderr");
2267
+ };
2268
+ ws.onmessage = (event) => {
2269
+ try {
2270
+ const message = JSON.parse(event.data);
2271
+ if (message.type === "repository-list") {
2272
+ const repos = message.repositories ?? [];
2273
+ logMessage(state, context, useTUI, `Received repository list (${repos.length} repositories)`);
2274
+ syncUIWithRepoList(state, repos);
2275
+ const repoDeps = toRepositoryDependencies(bucketDependencies, fileSystem);
2276
+ const repoContext = createTUIContext(state, context, useTUI);
2277
+ handleRepositoryList(repos, state, repoDeps, repoContext).then(() => syncTUI(state)).catch((error) => {
2278
+ logMessage(state, context, useTUI, `Failed to handle repository list: ${error.message}`, "stderr");
2279
+ });
2280
+ }
2281
+ } catch {
2282
+ logMessage(state, context, useTUI, `Failed to parse WebSocket message: ${event.data}`, "stderr");
2283
+ }
2284
+ };
1270
2285
  }
1271
- function getColors() {
1272
- return shouldDisableColors() ? NO_COLORS : ANSI_COLORS;
2286
+ async function shutdown(state, bucketDeps, context) {
2287
+ if (state.shuttingDown)
2288
+ return;
2289
+ state.shuttingDown = true;
2290
+ context.stdout("Shutting down...");
2291
+ if (state.reconnectTimer) {
2292
+ clearTimeout(state.reconnectTimer);
2293
+ state.reconnectTimer = null;
2294
+ }
2295
+ if (state.ws && state.ws.readyState === WS_OPEN) {
2296
+ state.ws.close();
2297
+ state.ws = null;
2298
+ }
2299
+ for (const repoState of state.repositories.values()) {
2300
+ repoState.stopRequested = true;
2301
+ }
2302
+ const loopPromises = Array.from(state.repositories.values()).map((rs) => rs.loopPromise).filter((p) => p !== null);
2303
+ await Promise.all(loopPromises.map((p) => p.catch(() => {})));
2304
+ for (const repoState of state.repositories.values()) {
2305
+ await removeRepository(repoState.path, bucketDeps.spawn, context);
2306
+ }
2307
+ state.repositories.clear();
2308
+ }
2309
+ function setupTUI(state, bucketDeps) {
2310
+ const { width, height } = bucketDeps.getTerminalSize();
2311
+ updateDimensions(state.ui, width, height);
2312
+ bucketDeps.writeStdout(enterAlternateScreen());
2313
+ const cleanupResize = bucketDeps.setupResize((w, h) => {
2314
+ updateDimensions(state.ui, w, h);
2315
+ });
2316
+ const renderInterval = setInterval(() => {
2317
+ if (!state.shuttingDown) {
2318
+ bucketDeps.writeStdout(renderFrame(state.ui));
2319
+ }
2320
+ }, 100);
2321
+ return {
2322
+ cleanup: () => {
2323
+ clearInterval(renderInterval);
2324
+ bucketDeps.writeStdout(exitAlternateScreen());
2325
+ cleanupResize();
2326
+ }
2327
+ };
1273
2328
  }
1274
-
1275
- // lib/cli/commands/init.ts
1276
- var DUST_DIRECTORIES = ["goals", "ideas", "tasks", "facts", "config"];
1277
- function generateSettings(cwd, fileSystem) {
1278
- const dustCommand = detectDustCommand(cwd, fileSystem);
1279
- const testCommand = detectTestCommand(cwd, fileSystem);
1280
- const checks = [];
1281
- if (testCommand) {
1282
- checks.push({ name: "test", command: testCommand });
2329
+ function createKeypressHandler(useTUI, state, onQuit) {
2330
+ if (useTUI) {
2331
+ return (key) => {
2332
+ const shouldQuit = handleKeyInput(state.ui, key);
2333
+ if (shouldQuit)
2334
+ onQuit();
2335
+ };
1283
2336
  }
1284
- return { dustCommand, checks };
2337
+ return (key) => {
2338
+ if (key === "q" || key === "\x03")
2339
+ onQuit();
2340
+ };
1285
2341
  }
1286
- var USE_DUST_FACT = `# Use dust for planning
1287
-
1288
- This project uses [dust](https://github.com/joshski/dust) for planning and documentation.
1289
- `;
1290
- async function init(dependencies) {
1291
- const { context, fileSystem } = dependencies;
1292
- const colors = getColors();
1293
- const dustPath = `${context.cwd}/.dust`;
1294
- const dustCommand = detectDustCommand(context.cwd, fileSystem);
1295
- const agentInstruction = `Always immediately run \`${dustCommand} agent\` when you start working in this repository.`;
1296
- await fileSystem.mkdir(dustPath, { recursive: true });
1297
- for (const dir of DUST_DIRECTORIES) {
1298
- await fileSystem.mkdir(`${dustPath}/${dir}`, { recursive: true });
2342
+ async function resolveToken(commandArgs, authDeps, context) {
2343
+ if (commandArgs[0]) {
2344
+ return commandArgs[0];
1299
2345
  }
1300
- let dustDirCreated = false;
1301
- try {
1302
- await fileSystem.writeFile(`${dustPath}/facts/use-dust-for-planning.md`, USE_DUST_FACT, { flag: "wx" });
1303
- dustDirCreated = true;
1304
- } catch (error) {
1305
- if (error.code !== "EEXIST") {
1306
- throw error;
1307
- }
2346
+ const stored = await loadStoredToken(authDeps.fileSystem, authDeps.getHomeDir());
2347
+ if (stored) {
2348
+ return stored;
1308
2349
  }
2350
+ context.stdout("Opening browser to authenticate with dustbucket...");
1309
2351
  try {
1310
- const settings = generateSettings(context.cwd, fileSystem);
1311
- await fileSystem.writeFile(`${dustPath}/config/settings.json`, `${JSON.stringify(settings, null, 2)}
1312
- `, { flag: "wx" });
2352
+ const token = await authenticate(authDeps);
2353
+ await storeToken(authDeps.fileSystem, authDeps.getHomeDir(), token);
2354
+ context.stdout("Authenticated successfully");
2355
+ return token;
1313
2356
  } catch (error) {
1314
- if (error.code !== "EEXIST") {
1315
- throw error;
1316
- }
2357
+ context.stderr(`Authentication failed: ${error.message}`);
2358
+ return null;
1317
2359
  }
1318
- if (dustDirCreated) {
1319
- context.stdout(`${colors.green}✨ Initialized${colors.reset} Dust repository in ${colors.cyan}.dust/${colors.reset}`);
1320
- context.stdout(`${colors.green}\uD83D\uDCC1 Created directories:${colors.reset} ${colors.dim}${DUST_DIRECTORIES.join(", ")}${colors.reset}`);
1321
- context.stdout(`${colors.green}\uD83D\uDCC4 Created initial fact:${colors.reset} ${colors.cyan}.dust/facts/use-dust-for-planning.md${colors.reset}`);
1322
- context.stdout(`${colors.green}⚙️ Created settings:${colors.reset} ${colors.cyan}.dust/config/settings.json${colors.reset}`);
1323
- } else {
1324
- context.stdout(`${colors.yellow}\uD83D\uDCE6 Note:${colors.reset} ${colors.cyan}.dust${colors.reset} directory already exists, skipping creation`);
2360
+ }
2361
+ async function bucket(dependencies, bucketDeps = createDefaultBucketDependencies()) {
2362
+ const { arguments: commandArgs, context, fileSystem } = dependencies;
2363
+ const token = await resolveToken(commandArgs, bucketDeps.auth, context);
2364
+ if (!token) {
2365
+ return { exitCode: 1 };
1325
2366
  }
1326
- const claudeMdPath = `${context.cwd}/CLAUDE.md`;
2367
+ const wsUrl = getWebSocketUrl();
2368
+ context.stdout(`Connecting to ${wsUrl}...`);
2369
+ let initialWs;
1327
2370
  try {
1328
- const claudeContent = loadTemplate("claude-md", { dustCommand });
1329
- await fileSystem.writeFile(claudeMdPath, claudeContent, { flag: "wx" });
1330
- context.stdout(`${colors.green}\uD83D\uDCC4 Created${colors.reset} ${colors.cyan}CLAUDE.md${colors.reset} with agent instructions`);
2371
+ initialWs = await waitForConnection(token, bucketDeps);
1331
2372
  } catch (error) {
1332
- if (error.code === "EEXIST") {
1333
- context.stdout(`${colors.yellow}⚠️ Warning:${colors.reset} ${colors.cyan}CLAUDE.md${colors.reset} already exists. Consider adding: ${colors.dim}"${agentInstruction}"${colors.reset}`);
2373
+ if (error.message.includes("1008") || error.message.includes("401")) {
2374
+ context.stderr("Token rejected. Clearing stored credentials...");
2375
+ await clearToken(bucketDeps.auth.fileSystem, bucketDeps.auth.getHomeDir());
2376
+ context.stderr("Run `dust bucket` again to re-authenticate.");
1334
2377
  } else {
1335
- throw error;
2378
+ context.stderr(`Failed to connect: ${error.message}`);
1336
2379
  }
2380
+ return { exitCode: 1 };
1337
2381
  }
1338
- const agentsMdPath = `${context.cwd}/AGENTS.md`;
2382
+ context.stdout("Connected");
2383
+ const state = createInitialState();
2384
+ const useTUI = bucketDeps.isTTY;
1339
2385
  try {
1340
- const agentsContent = loadTemplate("agents-md", { dustCommand });
1341
- await fileSystem.writeFile(agentsMdPath, agentsContent, { flag: "wx" });
1342
- context.stdout(`${colors.green}\uD83D\uDCC4 Created${colors.reset} ${colors.cyan}AGENTS.md${colors.reset} with agent instructions`);
1343
- } catch (error) {
1344
- if (error.code === "EEXIST") {
1345
- context.stdout(`${colors.yellow}⚠️ Warning:${colors.reset} ${colors.cyan}AGENTS.md${colors.reset} already exists. Consider adding: ${colors.dim}"${agentInstruction}"${colors.reset}`);
1346
- } else {
1347
- throw error;
1348
- }
2386
+ state.ui.connectedHost = new URL(wsUrl).hostname;
2387
+ } catch {
2388
+ state.ui.connectedHost = wsUrl;
1349
2389
  }
1350
- const runner = dustCommand.split(" ")[0];
1351
- context.stdout("");
1352
- context.stdout(`${colors.bold}\uD83D\uDE80 Next steps:${colors.reset} Commit the changes if you are happy, then get planning!`);
1353
- context.stdout("");
1354
- context.stdout(`${colors.dim}If this is a new repository, you can start adding ideas or tasks right away:${colors.reset}`);
1355
- context.stdout(` ${colors.cyan}>${colors.reset} ${runner} claude "Idea: friendly UI for non-technical users"`);
1356
- context.stdout(` ${colors.cyan}>${colors.reset} ${runner} codex "Task: set up code coverage"`);
1357
- context.stdout("");
1358
- context.stdout(`${colors.dim}If this is an existing codebase, you might want to backfill goals and facts:${colors.reset}`);
1359
- context.stdout(` ${colors.cyan}>${colors.reset} ${runner} claude "Add goals and facts based on the code in this repository"`);
2390
+ let tuiHandle;
2391
+ let cleanupKeypress;
2392
+ let cleanupSignals;
2393
+ try {
2394
+ if (useTUI) {
2395
+ tuiHandle = setupTUI(state, bucketDeps);
2396
+ }
2397
+ await new Promise((resolve) => {
2398
+ const doShutdown = async () => {
2399
+ await shutdown(state, bucketDeps, context);
2400
+ resolve();
2401
+ };
2402
+ const onKey = createKeypressHandler(useTUI, state, () => {
2403
+ doShutdown();
2404
+ });
2405
+ cleanupKeypress = bucketDeps.setupKeypress(onKey);
2406
+ cleanupSignals = bucketDeps.setupSignals(() => {
2407
+ doShutdown();
2408
+ });
2409
+ connectWebSocket(token, state, bucketDeps, context, fileSystem, useTUI, initialWs);
2410
+ if (!useTUI) {
2411
+ context.stdout(" Press q or Ctrl+C to exit");
2412
+ }
2413
+ });
2414
+ } finally {
2415
+ tuiHandle?.cleanup();
2416
+ cleanupKeypress?.();
2417
+ cleanupSignals?.();
2418
+ }
2419
+ context.stdout("Goodbye!");
1360
2420
  return { exitCode: 0 };
1361
2421
  }
1362
2422
 
1363
- // lib/cli/commands/list.ts
1364
- import { basename } from "node:path";
1365
- var VALID_TYPES = ["tasks", "ideas", "goals", "facts"];
1366
- var SECTION_HEADERS = {
1367
- tasks: "\uD83D\uDCCB Tasks",
1368
- ideas: "\uD83D\uDCA1 Ideas",
1369
- goals: "\uD83C\uDFAF Goals",
1370
- facts: "\uD83D\uDCC4 Facts"
1371
- };
1372
- var TYPE_EXPLANATIONS = {
1373
- tasks: "Tasks are detailed work plans with dependencies and completion criteria. Each task describes a specific piece of work to be done.",
1374
- ideas: "Ideas are future feature notes and proposals. Ideas capture possibilities that haven't yet been refined into actionable tasks.",
1375
- goals: "Goals are mission statements and guiding principles. Goals describe desired outcomes and values that inform decision-making.",
1376
- facts: "Facts are current state documentation. Facts capture how things work today, providing context for agents and contributors."
1377
- };
1378
- async function buildGoalHierarchy(goalsPath, fileSystem) {
1379
- const files = await fileSystem.readdir(goalsPath);
1380
- const mdFiles = files.filter((f) => f.endsWith(".md"));
1381
- const relationships = [];
1382
- const titleMap = new Map;
1383
- for (const file of mdFiles) {
1384
- const filePath = `${goalsPath}/${file}`;
1385
- const content = await fileSystem.readFile(filePath);
1386
- relationships.push(extractGoalRelationships(filePath, content));
1387
- const title = extractTitle(content) || basename(file, ".md");
1388
- titleMap.set(filePath, title);
2423
+ // lib/cli/process-runner.ts
2424
+ import { spawn } from "node:child_process";
2425
+ function createShellRunner(spawnFn) {
2426
+ return {
2427
+ run: (command, cwd, timeoutMs) => runBufferedProcess(spawnFn, command, [], cwd, true, timeoutMs)
2428
+ };
2429
+ }
2430
+ var defaultShellRunner = createShellRunner(spawn);
2431
+ function createGitRunner(spawnFn) {
2432
+ return {
2433
+ run: (gitArguments, cwd) => runBufferedProcess(spawnFn, "git", gitArguments, cwd, false)
2434
+ };
2435
+ }
2436
+ var defaultGitRunner = createGitRunner(spawn);
2437
+ function runBufferedProcess(spawnFn, command, commandArguments, cwd, shell, timeoutMs) {
2438
+ return new Promise((resolve) => {
2439
+ const proc = spawnFn(command, commandArguments, { cwd, shell });
2440
+ const chunks = [];
2441
+ let resolved = false;
2442
+ let timer;
2443
+ if (timeoutMs !== undefined) {
2444
+ timer = setTimeout(() => {
2445
+ resolved = true;
2446
+ proc.kill();
2447
+ resolve({
2448
+ exitCode: 1,
2449
+ output: chunks.join(""),
2450
+ timedOut: true
2451
+ });
2452
+ }, timeoutMs);
2453
+ }
2454
+ proc.stdout?.on("data", (data) => {
2455
+ chunks.push(data.toString());
2456
+ });
2457
+ proc.stderr?.on("data", (data) => {
2458
+ chunks.push(data.toString());
2459
+ });
2460
+ proc.on("close", (code) => {
2461
+ if (resolved)
2462
+ return;
2463
+ if (timer !== undefined)
2464
+ clearTimeout(timer);
2465
+ resolve({ exitCode: code ?? 1, output: chunks.join("") });
2466
+ });
2467
+ proc.on("error", (error) => {
2468
+ if (resolved)
2469
+ return;
2470
+ if (timer !== undefined)
2471
+ clearTimeout(timer);
2472
+ resolve({ exitCode: 1, output: error.message });
2473
+ });
2474
+ });
2475
+ }
2476
+
2477
+ // lib/cli/commands/lint-markdown.ts
2478
+ import { dirname as dirname3, resolve } from "node:path";
2479
+
2480
+ // lib/workflow-tasks.ts
2481
+ var IDEA_TRANSITION_PREFIXES = [
2482
+ "Refine Idea: ",
2483
+ "Decompose Idea: ",
2484
+ "Shelve Idea: "
2485
+ ];
2486
+ function titleToFilename(title) {
2487
+ return `${title.toLowerCase().replace(/\./g, "-").replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "")}.md`;
2488
+ }
2489
+
2490
+ // lib/cli/commands/lint-markdown.ts
2491
+ var REQUIRED_HEADINGS = ["## Goals", "## Blocked By", "## Definition of Done"];
2492
+ var REQUIRED_GOAL_HEADINGS = ["## Parent Goal", "## Sub-Goals"];
2493
+ var SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*\.md$/;
2494
+ var MAX_OPENING_SENTENCE_LENGTH = 150;
2495
+ function validateFilename(filePath) {
2496
+ const parts = filePath.split("/");
2497
+ const filename = parts[parts.length - 1];
2498
+ if (!SLUG_PATTERN.test(filename)) {
2499
+ return {
2500
+ file: filePath,
2501
+ message: `Filename "${filename}" does not match slug-style naming`
2502
+ };
1389
2503
  }
1390
- const relMap = new Map;
1391
- for (const rel of relationships) {
1392
- relMap.set(rel.filePath, rel);
2504
+ return null;
2505
+ }
2506
+ function validateTitleFilenameMatch(filePath, content) {
2507
+ const title = extractTitle(content);
2508
+ if (!title) {
2509
+ return null;
2510
+ }
2511
+ const parts = filePath.split("/");
2512
+ const actualFilename = parts[parts.length - 1];
2513
+ const expectedFilename = titleToFilename(title);
2514
+ if (actualFilename !== expectedFilename) {
2515
+ return {
2516
+ file: filePath,
2517
+ message: `Filename "${actualFilename}" does not match title "${title}" (expected "${expectedFilename}")`
2518
+ };
2519
+ }
2520
+ return null;
2521
+ }
2522
+ function validateOpeningSentence(filePath, content) {
2523
+ const openingSentence = extractOpeningSentence(content);
2524
+ if (!openingSentence) {
2525
+ return {
2526
+ file: filePath,
2527
+ message: "Missing or malformed opening sentence after H1 heading"
2528
+ };
2529
+ }
2530
+ return null;
2531
+ }
2532
+ function validateOpeningSentenceLength(filePath, content) {
2533
+ const openingSentence = extractOpeningSentence(content);
2534
+ if (!openingSentence) {
2535
+ return null;
2536
+ }
2537
+ if (openingSentence.length > MAX_OPENING_SENTENCE_LENGTH) {
2538
+ return {
2539
+ file: filePath,
2540
+ message: `Opening sentence is ${openingSentence.length} characters (max ${MAX_OPENING_SENTENCE_LENGTH}). Split into multiple sentences; only the first sentence is checked.`
2541
+ };
2542
+ }
2543
+ return null;
2544
+ }
2545
+ var NON_IMPERATIVE_STARTERS = new Set([
2546
+ "the",
2547
+ "a",
2548
+ "an",
2549
+ "this",
2550
+ "that",
2551
+ "these",
2552
+ "those",
2553
+ "we",
2554
+ "it",
2555
+ "they",
2556
+ "you",
2557
+ "i"
2558
+ ]);
2559
+ function validateImperativeOpeningSentence(filePath, content) {
2560
+ const openingSentence = extractOpeningSentence(content);
2561
+ if (!openingSentence) {
2562
+ return null;
1393
2563
  }
1394
- const rootGoals = relationships.filter((rel) => rel.parentGoals.length === 0);
1395
- function buildNode(filePath) {
1396
- const rel = relMap.get(filePath);
1397
- const children = [];
1398
- if (rel) {
1399
- for (const childPath of rel.subGoals) {
1400
- children.push(buildNode(childPath));
1401
- }
1402
- }
2564
+ const firstWord = openingSentence.split(/\s/)[0].replace(/[^a-zA-Z]/g, "");
2565
+ const lower = firstWord.toLowerCase();
2566
+ if (NON_IMPERATIVE_STARTERS.has(lower) || lower.endsWith("ing")) {
2567
+ const preview = openingSentence.length > 40 ? `${openingSentence.slice(0, 40)}...` : openingSentence;
1403
2568
  return {
1404
- filePath,
1405
- title: titleMap.get(filePath) || basename(filePath, ".md"),
1406
- children
2569
+ file: filePath,
2570
+ message: `Opening sentence should use imperative form (e.g., "Add X" not "This adds X"). Found: "${preview}"`
1407
2571
  };
1408
2572
  }
1409
- return rootGoals.map((rel) => buildNode(rel.filePath));
2573
+ return null;
1410
2574
  }
1411
- function renderHierarchy(nodes, output, prefix = "") {
1412
- for (let i = 0;i < nodes.length; i++) {
1413
- const node = nodes[i];
1414
- const isLastNode = i === nodes.length - 1;
1415
- const connector = isLastNode ? "└── " : "├── ";
1416
- const childPrefix = isLastNode ? " " : "│ ";
1417
- output(`${prefix}${connector}${node.title}`);
1418
- if (node.children.length > 0) {
1419
- renderHierarchy(node.children, output, prefix + childPrefix);
2575
+ function validateTaskHeadings(filePath, content) {
2576
+ const violations = [];
2577
+ for (const heading of REQUIRED_HEADINGS) {
2578
+ if (!content.includes(heading)) {
2579
+ violations.push({
2580
+ file: filePath,
2581
+ message: `Missing required heading: "${heading}"`
2582
+ });
1420
2583
  }
1421
2584
  }
2585
+ return violations;
1422
2586
  }
1423
- async function list(dependencies) {
1424
- const { arguments: commandArguments, context, fileSystem } = dependencies;
1425
- const dustPath = `${context.cwd}/.dust`;
1426
- const colors = getColors();
1427
- if (!fileSystem.exists(dustPath)) {
1428
- context.stderr("Error: .dust directory not found");
1429
- context.stderr("Run 'dust init' to initialize a Dust repository");
1430
- return { exitCode: 1 };
1431
- }
1432
- const typesToList = commandArguments.length === 0 ? [...VALID_TYPES] : commandArguments.filter((a) => VALID_TYPES.includes(a));
1433
- if (commandArguments.length > 0 && typesToList.length === 0) {
1434
- context.stderr(`Invalid type: ${commandArguments[0]}`);
1435
- context.stderr(`Valid types: ${VALID_TYPES.join(", ")}`);
1436
- return { exitCode: 1 };
1437
- }
1438
- const specificTypeRequested = commandArguments.length > 0;
1439
- for (const type of typesToList) {
1440
- const dirPath = `${dustPath}/${type}`;
1441
- const dirExists = fileSystem.exists(dirPath);
1442
- const files = dirExists ? await fileSystem.readdir(dirPath) : [];
1443
- const mdFiles = files.filter((f) => f.endsWith(".md")).sort();
1444
- if (mdFiles.length === 0) {
1445
- if (specificTypeRequested) {
1446
- context.stdout(SECTION_HEADERS[type]);
1447
- context.stdout("");
1448
- context.stdout(TYPE_EXPLANATIONS[type]);
1449
- context.stdout("");
1450
- context.stdout(`No ${type} found.`);
1451
- context.stdout("");
2587
+ function validateLinks(filePath, content, fileSystem) {
2588
+ const violations = [];
2589
+ const lines = content.split(`
2590
+ `);
2591
+ const fileDir = dirname3(filePath);
2592
+ for (let i = 0;i < lines.length; i++) {
2593
+ const line = lines[i];
2594
+ const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
2595
+ let match = linkPattern.exec(line);
2596
+ while (match) {
2597
+ const linkTarget = match[2];
2598
+ if (!linkTarget.startsWith("http://") && !linkTarget.startsWith("https://") && !linkTarget.startsWith("#")) {
2599
+ const targetPath = linkTarget.split("#")[0];
2600
+ const resolvedPath = resolve(fileDir, targetPath);
2601
+ if (!fileSystem.exists(resolvedPath)) {
2602
+ violations.push({
2603
+ file: filePath,
2604
+ message: `Broken link: "${linkTarget}"`,
2605
+ line: i + 1
2606
+ });
2607
+ }
1452
2608
  }
1453
- continue;
2609
+ match = linkPattern.exec(line);
1454
2610
  }
1455
- context.stdout(SECTION_HEADERS[type]);
1456
- context.stdout("");
1457
- context.stdout(TYPE_EXPLANATIONS[type]);
1458
- context.stdout("");
1459
- if (type === "goals") {
1460
- const hierarchy = await buildGoalHierarchy(dirPath, fileSystem);
1461
- if (hierarchy.length > 0) {
1462
- context.stdout(`${colors.dim}Hierarchy:${colors.reset}`);
1463
- renderHierarchy(hierarchy, (line) => context.stdout(line));
1464
- context.stdout("");
1465
- }
2611
+ }
2612
+ return violations;
2613
+ }
2614
+ function validateIdeaOpenQuestions(filePath, content) {
2615
+ const violations = [];
2616
+ const lines = content.split(`
2617
+ `);
2618
+ let inOpenQuestions = false;
2619
+ let currentQuestionLine = null;
2620
+ let inCodeBlock = false;
2621
+ for (let i = 0;i < lines.length; i++) {
2622
+ const line = lines[i];
2623
+ if (line.startsWith("```")) {
2624
+ inCodeBlock = !inCodeBlock;
2625
+ continue;
1466
2626
  }
1467
- for (const file of mdFiles) {
1468
- const filePath = `${dirPath}/${file}`;
1469
- const content = await fileSystem.readFile(filePath);
1470
- const title = extractTitle(content);
1471
- const openingSentence = extractOpeningSentence(content);
1472
- const relativePath = `.dust/${type}/${file}`;
1473
- if (title) {
1474
- context.stdout(`${colors.bold}# ${title}${colors.reset}`);
1475
- } else {
1476
- context.stdout(`${colors.bold}# ${file.replace(".md", "")}${colors.reset}`);
2627
+ if (inCodeBlock)
2628
+ continue;
2629
+ if (line.startsWith("## ")) {
2630
+ if (inOpenQuestions && currentQuestionLine !== null) {
2631
+ violations.push({
2632
+ file: filePath,
2633
+ message: "Question has no options listed beneath it",
2634
+ line: currentQuestionLine
2635
+ });
1477
2636
  }
1478
- if (openingSentence) {
1479
- context.stdout(`${colors.dim}${openingSentence}${colors.reset}`);
2637
+ const headingText = line.slice(3).trimEnd();
2638
+ if (headingText.toLowerCase() === "open questions" && headingText !== "Open Questions") {
2639
+ violations.push({
2640
+ file: filePath,
2641
+ message: `Heading "${line.trimEnd()}" should be "## Open Questions"`,
2642
+ line: i + 1
2643
+ });
1480
2644
  }
1481
- context.stdout(`${colors.cyan}→ ${relativePath}${colors.reset}`);
1482
- context.stdout("");
2645
+ inOpenQuestions = line === "## Open Questions";
2646
+ currentQuestionLine = null;
2647
+ continue;
1483
2648
  }
1484
- }
1485
- return { exitCode: 0 };
1486
- }
1487
-
1488
- // lib/cli/commands/loop.ts
1489
- import { spawn as nodeSpawn2 } from "node:child_process";
1490
-
1491
- // lib/claude/spawn-claude-code.ts
1492
- import { spawn as nodeSpawn } from "node:child_process";
1493
- import { createInterface as nodeCreateInterface } from "node:readline";
1494
- var defaultDependencies = {
1495
- spawn: nodeSpawn,
1496
- createInterface: nodeCreateInterface
1497
- };
1498
- async function* spawnClaudeCode(prompt, options = {}, dependencies = defaultDependencies) {
1499
- const {
1500
- cwd,
1501
- allowedTools,
1502
- maxTurns,
1503
- model,
1504
- systemPrompt,
1505
- sessionId,
1506
- dangerouslySkipPermissions,
1507
- env
1508
- } = options;
1509
- const claudeArguments = [
1510
- "-p",
1511
- prompt,
1512
- "--output-format",
1513
- "stream-json",
1514
- "--verbose",
1515
- "--include-partial-messages"
1516
- ];
1517
- if (allowedTools?.length) {
1518
- claudeArguments.push("--allowedTools", ...allowedTools);
1519
- }
1520
- if (maxTurns) {
1521
- claudeArguments.push("--max-turns", String(maxTurns));
1522
- }
1523
- if (model) {
1524
- claudeArguments.push("--model", model);
1525
- }
1526
- if (systemPrompt) {
1527
- claudeArguments.push("--system-prompt", systemPrompt);
1528
- }
1529
- if (sessionId) {
1530
- claudeArguments.push("--session-id", sessionId);
1531
- }
1532
- if (dangerouslySkipPermissions) {
1533
- claudeArguments.push("--dangerously-skip-permissions");
1534
- }
1535
- const proc = dependencies.spawn("claude", claudeArguments, {
1536
- cwd,
1537
- stdio: ["ignore", "pipe", "pipe"],
1538
- env: { ...process.env, ...env }
1539
- });
1540
- if (!proc.stdout) {
1541
- throw new Error("Failed to get stdout from claude process");
1542
- }
1543
- const rl = dependencies.createInterface({ input: proc.stdout });
1544
- for await (const line of rl) {
1545
- if (!line.trim())
2649
+ if (!inOpenQuestions)
2650
+ continue;
2651
+ if (/^[-*] /.test(line.trimStart())) {
2652
+ violations.push({
2653
+ file: filePath,
2654
+ message: "Open Questions must use ### headings for questions and #### headings for options, not bullet points. Run `dust new idea` to see the expected format.",
2655
+ line: i + 1
2656
+ });
2657
+ continue;
2658
+ }
2659
+ if (line.startsWith("### ")) {
2660
+ if (currentQuestionLine !== null) {
2661
+ violations.push({
2662
+ file: filePath,
2663
+ message: "Question has no options listed beneath it",
2664
+ line: currentQuestionLine
2665
+ });
2666
+ }
2667
+ if (!line.trimEnd().endsWith("?")) {
2668
+ violations.push({
2669
+ file: filePath,
2670
+ message: 'Questions must end with "?" (e.g., "### Should we take our own payments?")',
2671
+ line: i + 1
2672
+ });
2673
+ currentQuestionLine = null;
2674
+ } else {
2675
+ currentQuestionLine = i + 1;
2676
+ }
1546
2677
  continue;
1547
- try {
1548
- yield JSON.parse(line);
1549
- } catch {}
2678
+ }
2679
+ if (line.startsWith("#### ")) {
2680
+ currentQuestionLine = null;
2681
+ }
1550
2682
  }
1551
- await new Promise((resolve2, reject) => {
1552
- proc.on("close", (code) => {
1553
- if (code === 0 || code === null)
1554
- resolve2();
1555
- else
1556
- reject(new Error(`claude exited with code ${code}`));
2683
+ if (inOpenQuestions && currentQuestionLine !== null) {
2684
+ violations.push({
2685
+ file: filePath,
2686
+ message: "Question has no options listed beneath it",
2687
+ line: currentQuestionLine
1557
2688
  });
1558
- proc.on("error", reject);
1559
- });
2689
+ }
2690
+ return violations;
1560
2691
  }
1561
-
1562
- // lib/claude/event-parser.ts
1563
- function* parseRawEvent(raw) {
1564
- if (raw.type === "stream_event") {
1565
- const event = raw.event;
1566
- if (event?.delta?.type === "text_delta" && event.delta.text) {
1567
- yield { type: "text_delta", text: event.delta.text };
2692
+ var SEMANTIC_RULES = [
2693
+ {
2694
+ section: "## Goals",
2695
+ requiredPath: "/.dust/goals/",
2696
+ description: "goal"
2697
+ },
2698
+ {
2699
+ section: "## Blocked By",
2700
+ requiredPath: "/.dust/tasks/",
2701
+ description: "task"
2702
+ }
2703
+ ];
2704
+ function validateSemanticLinks(filePath, content) {
2705
+ const violations = [];
2706
+ const lines = content.split(`
2707
+ `);
2708
+ const fileDir = dirname3(filePath);
2709
+ let currentSection = null;
2710
+ for (let i = 0;i < lines.length; i++) {
2711
+ const line = lines[i];
2712
+ if (line.startsWith("## ")) {
2713
+ currentSection = line;
2714
+ continue;
1568
2715
  }
1569
- } else if (raw.type === "assistant") {
1570
- const msg = raw;
1571
- const content = msg.message?.content ?? [];
1572
- yield { type: "assistant_message", content };
1573
- for (const block of content) {
1574
- if (block.type === "tool_use" && block.id && block.name && block.input) {
1575
- yield {
1576
- type: "tool_use",
1577
- id: block.id,
1578
- name: block.name,
1579
- input: block.input
1580
- };
2716
+ const rule = SEMANTIC_RULES.find((r) => r.section === currentSection);
2717
+ if (!rule)
2718
+ continue;
2719
+ const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
2720
+ let match = linkPattern.exec(line);
2721
+ while (match) {
2722
+ const linkTarget = match[2];
2723
+ if (linkTarget.startsWith("#")) {
2724
+ violations.push({
2725
+ file: filePath,
2726
+ message: `Link in "${rule.section}" must point to a ${rule.description} file, not an anchor: "${linkTarget}"`,
2727
+ line: i + 1
2728
+ });
2729
+ match = linkPattern.exec(line);
2730
+ continue;
2731
+ }
2732
+ if (linkTarget.startsWith("http://") || linkTarget.startsWith("https://")) {
2733
+ violations.push({
2734
+ file: filePath,
2735
+ message: `Link in "${rule.section}" must point to a ${rule.description} file, not an external URL: "${linkTarget}"`,
2736
+ line: i + 1
2737
+ });
2738
+ match = linkPattern.exec(line);
2739
+ continue;
2740
+ }
2741
+ const targetPath = linkTarget.split("#")[0];
2742
+ const resolvedPath = resolve(fileDir, targetPath);
2743
+ if (!resolvedPath.includes(rule.requiredPath)) {
2744
+ violations.push({
2745
+ file: filePath,
2746
+ message: `Link in "${rule.section}" must point to a ${rule.description} file: "${linkTarget}"`,
2747
+ line: i + 1
2748
+ });
1581
2749
  }
2750
+ match = linkPattern.exec(line);
1582
2751
  }
1583
- } else if (raw.type === "user") {
1584
- const msg = raw;
1585
- for (const block of msg.message?.content ?? []) {
1586
- if (block.type === "tool_result" && block.tool_use_id) {
1587
- yield {
1588
- type: "tool_result",
1589
- toolUseId: block.tool_use_id,
1590
- content: typeof block.content === "string" ? block.content : JSON.stringify(block.content)
2752
+ }
2753
+ return violations;
2754
+ }
2755
+ function validateIdeaTransitionTitle(filePath, content, ideasPath, fileSystem) {
2756
+ const title = extractTitle(content);
2757
+ if (!title) {
2758
+ return null;
2759
+ }
2760
+ for (const prefix of IDEA_TRANSITION_PREFIXES) {
2761
+ if (title.startsWith(prefix)) {
2762
+ const ideaTitle = title.slice(prefix.length);
2763
+ const ideaFilename = titleToFilename(ideaTitle);
2764
+ if (!fileSystem.exists(`${ideasPath}/${ideaFilename}`)) {
2765
+ return {
2766
+ file: filePath,
2767
+ message: `Idea transition task references non-existent idea: "${ideaTitle}" (expected file "${ideaFilename}" in ideas/)`
1591
2768
  };
1592
2769
  }
2770
+ return null;
1593
2771
  }
1594
- } else if (raw.type === "result") {
1595
- const r = raw;
1596
- yield {
1597
- type: "result",
1598
- subtype: r.subtype ?? "success",
1599
- result: r.result,
1600
- error: r.error,
1601
- cost_usd: r.total_cost_usd ?? r.cost_usd ?? 0,
1602
- duration_ms: r.duration_ms ?? 0,
1603
- num_turns: r.num_turns ?? 0,
1604
- session_id: r.session_id ?? ""
1605
- };
1606
2772
  }
2773
+ return null;
1607
2774
  }
1608
-
1609
- // lib/claude/tool-formatters.ts
1610
- var DIVIDER = "────────────────────────────────";
1611
- function formatWrite(input) {
1612
- const filePath = input.file_path;
1613
- const content = input.content;
1614
- const others = getUnrecognizedArgs(input, ["file_path", "content"]);
1615
- const lines = [];
1616
- lines.push(`\uD83D\uDD27 Write: ${filePath ?? "(unknown)"}`);
1617
- lines.push(DIVIDER);
1618
- if (content !== undefined) {
1619
- for (const line of content.split(`
1620
- `)) {
1621
- lines.push(line);
2775
+ function validateGoalHierarchySections(filePath, content) {
2776
+ const violations = [];
2777
+ for (const heading of REQUIRED_GOAL_HEADINGS) {
2778
+ if (!content.includes(heading)) {
2779
+ violations.push({
2780
+ file: filePath,
2781
+ message: `Missing required heading: "${heading}"`
2782
+ });
1622
2783
  }
1623
2784
  }
1624
- lines.push(DIVIDER);
1625
- appendOtherArgs(lines, others);
1626
- return lines;
2785
+ return violations;
1627
2786
  }
1628
- function formatEdit(input) {
1629
- const filePath = input.file_path;
1630
- const oldString = input.old_string;
1631
- const newString = input.new_string;
1632
- const others = getUnrecognizedArgs(input, [
1633
- "file_path",
1634
- "old_string",
1635
- "new_string",
1636
- "replace_all"
1637
- ]);
1638
- const lines = [];
1639
- lines.push(`\uD83D\uDD27 Edit: ${filePath ?? "(unknown)"}`);
1640
- lines.push("Replace:");
1641
- lines.push(DIVIDER);
1642
- if (oldString !== undefined) {
1643
- for (const line of oldString.split(`
1644
- `)) {
1645
- lines.push(line);
2787
+ function validateGoalHierarchyLinks(filePath, content) {
2788
+ const violations = [];
2789
+ const lines = content.split(`
2790
+ `);
2791
+ const fileDir = dirname3(filePath);
2792
+ let currentSection = null;
2793
+ for (let i = 0;i < lines.length; i++) {
2794
+ const line = lines[i];
2795
+ if (line.startsWith("## ")) {
2796
+ currentSection = line;
2797
+ continue;
2798
+ }
2799
+ if (currentSection !== "## Parent Goal" && currentSection !== "## Sub-Goals") {
2800
+ continue;
2801
+ }
2802
+ const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
2803
+ let match = linkPattern.exec(line);
2804
+ while (match) {
2805
+ const linkTarget = match[2];
2806
+ if (linkTarget.startsWith("#")) {
2807
+ violations.push({
2808
+ file: filePath,
2809
+ message: `Link in "${currentSection}" must point to a goal file, not an anchor: "${linkTarget}"`,
2810
+ line: i + 1
2811
+ });
2812
+ match = linkPattern.exec(line);
2813
+ continue;
2814
+ }
2815
+ if (linkTarget.startsWith("http://") || linkTarget.startsWith("https://")) {
2816
+ violations.push({
2817
+ file: filePath,
2818
+ message: `Link in "${currentSection}" must point to a goal file, not an external URL: "${linkTarget}"`,
2819
+ line: i + 1
2820
+ });
2821
+ match = linkPattern.exec(line);
2822
+ continue;
2823
+ }
2824
+ const targetPath = linkTarget.split("#")[0];
2825
+ const resolvedPath = resolve(fileDir, targetPath);
2826
+ if (!resolvedPath.includes("/.dust/goals/")) {
2827
+ violations.push({
2828
+ file: filePath,
2829
+ message: `Link in "${currentSection}" must point to a goal file: "${linkTarget}"`,
2830
+ line: i + 1
2831
+ });
2832
+ }
2833
+ match = linkPattern.exec(line);
1646
2834
  }
1647
2835
  }
1648
- lines.push(DIVIDER);
1649
- lines.push("With:");
1650
- lines.push(DIVIDER);
1651
- if (newString !== undefined) {
1652
- for (const line of newString.split(`
1653
- `)) {
1654
- lines.push(line);
2836
+ return violations;
2837
+ }
2838
+ function extractGoalRelationships(filePath, content) {
2839
+ const lines = content.split(`
2840
+ `);
2841
+ const fileDir = dirname3(filePath);
2842
+ const parentGoals = [];
2843
+ const subGoals = [];
2844
+ let currentSection = null;
2845
+ for (const line of lines) {
2846
+ if (line.startsWith("## ")) {
2847
+ currentSection = line;
2848
+ continue;
2849
+ }
2850
+ if (currentSection !== "## Parent Goal" && currentSection !== "## Sub-Goals") {
2851
+ continue;
2852
+ }
2853
+ const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
2854
+ let match = linkPattern.exec(line);
2855
+ while (match) {
2856
+ const linkTarget = match[2];
2857
+ if (!linkTarget.startsWith("#") && !linkTarget.startsWith("http://") && !linkTarget.startsWith("https://")) {
2858
+ const targetPath = linkTarget.split("#")[0];
2859
+ const resolvedPath = resolve(fileDir, targetPath);
2860
+ if (resolvedPath.includes("/.dust/goals/")) {
2861
+ if (currentSection === "## Parent Goal") {
2862
+ parentGoals.push(resolvedPath);
2863
+ } else {
2864
+ subGoals.push(resolvedPath);
2865
+ }
2866
+ }
2867
+ }
2868
+ match = linkPattern.exec(line);
1655
2869
  }
1656
2870
  }
1657
- lines.push(DIVIDER);
1658
- appendOtherArgs(lines, others);
1659
- return lines;
2871
+ return { filePath, parentGoals, subGoals };
1660
2872
  }
1661
- function formatRead(input) {
1662
- const filePath = input.file_path;
1663
- const offset = input.offset;
1664
- const limit = input.limit;
1665
- const others = getUnrecognizedArgs(input, ["file_path", "offset", "limit"]);
1666
- const lines = [];
1667
- let lineRange = "";
1668
- if (offset !== undefined || limit !== undefined) {
1669
- const start = offset ?? 1;
1670
- const end = limit !== undefined ? start + limit - 1 : undefined;
1671
- lineRange = end !== undefined ? ` (lines ${start}-${end})` : ` (from line ${start})`;
2873
+ function validateBidirectionalLinks(allGoalRelationships) {
2874
+ const violations = [];
2875
+ const relationshipMap = new Map;
2876
+ for (const rel of allGoalRelationships) {
2877
+ relationshipMap.set(rel.filePath, rel);
1672
2878
  }
1673
- lines.push(`\uD83D\uDD27 Read: ${filePath ?? "(unknown)"}${lineRange}`);
1674
- appendOtherArgs(lines, others);
1675
- return lines;
1676
- }
1677
- function formatBash(input) {
1678
- const command = input.command;
1679
- const description = input.description;
1680
- const others = getUnrecognizedArgs(input, [
1681
- "command",
1682
- "description",
1683
- "timeout",
1684
- "run_in_background",
1685
- "dangerouslyDisableSandbox",
1686
- "_simulatedSedEdit"
1687
- ]);
1688
- const lines = [];
1689
- const header = description ?? "Run command";
1690
- lines.push(`\uD83D\uDD27 Bash: ${header}`);
1691
- if (command !== undefined) {
1692
- lines.push(`$ ${command}`);
2879
+ for (const rel of allGoalRelationships) {
2880
+ for (const parentPath of rel.parentGoals) {
2881
+ const parentRel = relationshipMap.get(parentPath);
2882
+ if (parentRel && !parentRel.subGoals.includes(rel.filePath)) {
2883
+ violations.push({
2884
+ file: rel.filePath,
2885
+ message: `Parent goal "${parentPath}" does not list this goal as a sub-goal`
2886
+ });
2887
+ }
2888
+ }
2889
+ for (const subGoalPath of rel.subGoals) {
2890
+ const subGoalRel = relationshipMap.get(subGoalPath);
2891
+ if (subGoalRel && !subGoalRel.parentGoals.includes(rel.filePath)) {
2892
+ violations.push({
2893
+ file: rel.filePath,
2894
+ message: `Sub-goal "${subGoalPath}" does not list this goal as its parent`
2895
+ });
2896
+ }
2897
+ }
1693
2898
  }
1694
- appendOtherArgs(lines, others);
1695
- return lines;
2899
+ return violations;
1696
2900
  }
1697
- function formatTodoWrite(input) {
1698
- const todos = input.todos;
1699
- const others = getUnrecognizedArgs(input, ["todos"]);
1700
- const lines = [];
1701
- const count = todos?.length ?? 0;
1702
- lines.push(`\uD83D\uDD27 TodoWrite: ${count} item${count === 1 ? "" : "s"}`);
1703
- if (todos) {
1704
- for (const todo of todos) {
1705
- const icon = todo.status === "completed" ? "☑" : "☐";
1706
- lines.push(`${icon} ${todo.content}`);
2901
+ function validateNoCycles(allGoalRelationships) {
2902
+ const violations = [];
2903
+ const relationshipMap = new Map;
2904
+ for (const rel of allGoalRelationships) {
2905
+ relationshipMap.set(rel.filePath, rel);
2906
+ }
2907
+ for (const rel of allGoalRelationships) {
2908
+ const visited = new Set;
2909
+ const path = [];
2910
+ let current = rel.filePath;
2911
+ while (current) {
2912
+ if (visited.has(current)) {
2913
+ const cycleStart = path.indexOf(current);
2914
+ const cyclePath = path.slice(cycleStart).concat(current);
2915
+ violations.push({
2916
+ file: rel.filePath,
2917
+ message: `Cycle detected in goal hierarchy: ${cyclePath.join(" -> ")}`
2918
+ });
2919
+ break;
2920
+ }
2921
+ visited.add(current);
2922
+ path.push(current);
2923
+ const currentRel = relationshipMap.get(current);
2924
+ if (currentRel && currentRel.parentGoals.length > 0) {
2925
+ current = currentRel.parentGoals[0];
2926
+ } else {
2927
+ current = null;
2928
+ }
1707
2929
  }
1708
2930
  }
1709
- appendOtherArgs(lines, others);
1710
- return lines;
2931
+ return violations;
1711
2932
  }
1712
- function formatGrep(input) {
1713
- const pattern = input.pattern;
1714
- const path = input.path;
1715
- const glob = input.glob;
1716
- const type = input.type;
1717
- const others = getUnrecognizedArgs(input, [
1718
- "pattern",
1719
- "path",
1720
- "glob",
1721
- "type",
1722
- "output_mode",
1723
- "context",
1724
- "-A",
1725
- "-B",
1726
- "-C",
1727
- "-i",
1728
- "-n",
1729
- "head_limit",
1730
- "offset",
1731
- "multiline"
1732
- ]);
1733
- const lines = [];
1734
- const location = path ?? ".";
1735
- let filter = "";
1736
- if (glob) {
1737
- filter = ` (${glob})`;
1738
- } else if (type) {
1739
- filter = ` (type: ${type})`;
2933
+ async function safeScanDir(glob, dirPath) {
2934
+ const files = [];
2935
+ try {
2936
+ for await (const file of glob.scan(dirPath)) {
2937
+ files.push(file);
2938
+ }
2939
+ return { files, exists: true };
2940
+ } catch (error) {
2941
+ if (error.code === "ENOENT") {
2942
+ return { files: [], exists: false };
2943
+ }
2944
+ throw error;
1740
2945
  }
1741
- lines.push(`\uD83D\uDD27 Grep: "${pattern ?? ""}" in ${location}${filter}`);
1742
- appendOtherArgs(lines, others);
1743
- return lines;
1744
- }
1745
- function formatGlob(input) {
1746
- const pattern = input.pattern;
1747
- const path = input.path;
1748
- const others = getUnrecognizedArgs(input, ["pattern", "path"]);
1749
- const lines = [];
1750
- const location = path ?? ".";
1751
- lines.push(`\uD83D\uDD27 Glob: ${pattern ?? ""} in ${location}`);
1752
- appendOtherArgs(lines, others);
1753
- return lines;
1754
2946
  }
1755
- function formatTask(input) {
1756
- const description = input.description;
1757
- const subagentType = input.subagent_type;
1758
- const prompt = input.prompt;
1759
- const others = getUnrecognizedArgs(input, [
1760
- "description",
1761
- "subagent_type",
1762
- "prompt",
1763
- "model",
1764
- "max_turns",
1765
- "resume",
1766
- "run_in_background"
1767
- ]);
1768
- const lines = [];
1769
- const header = description ?? subagentType ?? "task";
1770
- lines.push(`\uD83D\uDD27 Task: ${header}`);
1771
- if (prompt !== undefined) {
1772
- const truncated = prompt.length > 100 ? `${prompt.slice(0, 100)}...` : prompt;
1773
- lines.push(`"${truncated}"`);
2947
+ async function lintMarkdown(dependencies) {
2948
+ const { context, fileSystem, globScanner: glob } = dependencies;
2949
+ const dustPath = `${context.cwd}/.dust`;
2950
+ const dustScan = await safeScanDir(glob, dustPath);
2951
+ if (!dustScan.exists) {
2952
+ context.stderr("Error: .dust directory not found");
2953
+ context.stderr("Run 'dust init' to initialize a Dust repository");
2954
+ return { exitCode: 1 };
1774
2955
  }
1775
- appendOtherArgs(lines, others);
1776
- return lines;
1777
- }
1778
- function formatFallback(name, input) {
1779
- const lines = [];
1780
- lines.push(`\uD83D\uDD27 Tool: ${name}`);
1781
- lines.push(`Input: ${JSON.stringify(input, null, 2)}`);
1782
- return lines;
1783
- }
1784
- var formatters = {
1785
- Write: formatWrite,
1786
- Edit: formatEdit,
1787
- Read: formatRead,
1788
- Bash: formatBash,
1789
- TodoWrite: formatTodoWrite,
1790
- Grep: formatGrep,
1791
- Glob: formatGlob,
1792
- Task: formatTask
1793
- };
1794
- function formatToolUse(name, input) {
1795
- const formatter = formatters[name];
1796
- if (formatter) {
1797
- return formatter(input);
2956
+ const dustFiles = dustScan.files;
2957
+ const violations = [];
2958
+ context.stdout("Validating links in .dust/...");
2959
+ for (const file of dustFiles) {
2960
+ if (!file.endsWith(".md"))
2961
+ continue;
2962
+ const filePath = `${dustPath}/${file}`;
2963
+ try {
2964
+ const content = await fileSystem.readFile(filePath);
2965
+ violations.push(...validateLinks(filePath, content, fileSystem));
2966
+ } catch (error) {
2967
+ if (error.code !== "ENOENT") {
2968
+ throw error;
2969
+ }
2970
+ }
1798
2971
  }
1799
- return formatFallback(name, input);
1800
- }
1801
- function getUnrecognizedArgs(input, knownKeys) {
1802
- const others = {};
1803
- for (const key of Object.keys(input)) {
1804
- if (!knownKeys.includes(key)) {
1805
- others[key] = input[key];
2972
+ const contentDirs = ["goals", "facts", "ideas", "tasks"];
2973
+ context.stdout("Validating content files...");
2974
+ for (const dir of contentDirs) {
2975
+ const dirPath = `${dustPath}/${dir}`;
2976
+ const { files } = await safeScanDir(glob, dirPath);
2977
+ for (const file of files) {
2978
+ if (!file.endsWith(".md"))
2979
+ continue;
2980
+ const filePath = `${dirPath}/${file}`;
2981
+ let content;
2982
+ try {
2983
+ content = await fileSystem.readFile(filePath);
2984
+ } catch (error) {
2985
+ if (error.code === "ENOENT") {
2986
+ continue;
2987
+ }
2988
+ throw error;
2989
+ }
2990
+ const openingSentenceViolation = validateOpeningSentence(filePath, content);
2991
+ if (openingSentenceViolation) {
2992
+ violations.push(openingSentenceViolation);
2993
+ }
2994
+ const openingSentenceLengthViolation = validateOpeningSentenceLength(filePath, content);
2995
+ if (openingSentenceLengthViolation) {
2996
+ violations.push(openingSentenceLengthViolation);
2997
+ }
2998
+ const titleFilenameViolation = validateTitleFilenameMatch(filePath, content);
2999
+ if (titleFilenameViolation) {
3000
+ violations.push(titleFilenameViolation);
3001
+ }
1806
3002
  }
1807
3003
  }
1808
- return others;
1809
- }
1810
- function appendOtherArgs(lines, others) {
1811
- if (Object.keys(others).length > 0) {
1812
- lines.push("");
1813
- lines.push(`(Other arguments: ${JSON.stringify(others)})`);
1814
- }
1815
- }
1816
-
1817
- // lib/claude/streamer.ts
1818
- var DIVIDER2 = "────────────────────────────────";
1819
- async function streamEvents(events, sink, onRawEvent) {
1820
- let hadTextOutput = false;
1821
- for await (const raw of events) {
1822
- onRawEvent?.(raw);
1823
- for (const event of parseRawEvent(raw)) {
1824
- processEvent(event, sink, { hadTextOutput });
1825
- if (event.type === "text_delta") {
1826
- hadTextOutput = true;
1827
- } else if (event.type === "tool_use") {
1828
- hadTextOutput = false;
3004
+ const ideasPath = `${dustPath}/ideas`;
3005
+ const { files: ideaFiles } = await safeScanDir(glob, ideasPath);
3006
+ if (ideaFiles.length > 0) {
3007
+ context.stdout("Validating idea files in .dust/ideas/...");
3008
+ for (const file of ideaFiles) {
3009
+ if (!file.endsWith(".md"))
3010
+ continue;
3011
+ const filePath = `${ideasPath}/${file}`;
3012
+ let content;
3013
+ try {
3014
+ content = await fileSystem.readFile(filePath);
3015
+ } catch (error) {
3016
+ if (error.code === "ENOENT") {
3017
+ continue;
3018
+ }
3019
+ throw error;
1829
3020
  }
3021
+ violations.push(...validateIdeaOpenQuestions(filePath, content));
1830
3022
  }
1831
3023
  }
1832
- }
1833
- function processEvent(event, sink, state) {
1834
- switch (event.type) {
1835
- case "text_delta":
1836
- sink.write(event.text);
1837
- break;
1838
- case "tool_use":
1839
- if (state.hadTextOutput) {
1840
- sink.line("");
1841
- sink.line("");
3024
+ const tasksPath = `${dustPath}/tasks`;
3025
+ const { files: taskFiles } = await safeScanDir(glob, tasksPath);
3026
+ if (taskFiles.length > 0) {
3027
+ context.stdout("Validating task files in .dust/tasks/...");
3028
+ for (const file of taskFiles) {
3029
+ if (!file.endsWith(".md"))
3030
+ continue;
3031
+ const filePath = `${tasksPath}/${file}`;
3032
+ let content;
3033
+ try {
3034
+ content = await fileSystem.readFile(filePath);
3035
+ } catch (error) {
3036
+ if (error.code === "ENOENT") {
3037
+ continue;
3038
+ }
3039
+ throw error;
1842
3040
  }
1843
- for (const line of formatToolUse(event.name, event.input)) {
1844
- sink.line(line);
3041
+ const filenameViolation = validateFilename(filePath);
3042
+ if (filenameViolation) {
3043
+ violations.push(filenameViolation);
1845
3044
  }
1846
- break;
1847
- case "tool_result":
1848
- sink.line("Result:");
1849
- sink.line(DIVIDER2);
1850
- sink.line(event.content);
1851
- sink.line(DIVIDER2);
1852
- sink.line("");
1853
- break;
1854
- case "result":
1855
- sink.line("");
1856
- sink.line(`\uD83C\uDFC1 Done: ${event.subtype}, ${event.num_turns} turns, $${event.cost_usd.toFixed(4)}`);
1857
- break;
1858
- case "assistant_message":
1859
- break;
3045
+ violations.push(...validateTaskHeadings(filePath, content));
3046
+ violations.push(...validateSemanticLinks(filePath, content));
3047
+ const imperativeViolation = validateImperativeOpeningSentence(filePath, content);
3048
+ if (imperativeViolation) {
3049
+ violations.push(imperativeViolation);
3050
+ }
3051
+ const ideaTransitionViolation = validateIdeaTransitionTitle(filePath, content, ideasPath, fileSystem);
3052
+ if (ideaTransitionViolation) {
3053
+ violations.push(ideaTransitionViolation);
3054
+ }
3055
+ }
3056
+ }
3057
+ const goalsPath = `${dustPath}/goals`;
3058
+ const { files: goalFiles } = await safeScanDir(glob, goalsPath);
3059
+ if (goalFiles.length > 0) {
3060
+ context.stdout("Validating goal hierarchy in .dust/goals/...");
3061
+ const allGoalRelationships = [];
3062
+ for (const file of goalFiles) {
3063
+ if (!file.endsWith(".md"))
3064
+ continue;
3065
+ const filePath = `${goalsPath}/${file}`;
3066
+ let content;
3067
+ try {
3068
+ content = await fileSystem.readFile(filePath);
3069
+ } catch (error) {
3070
+ if (error.code === "ENOENT") {
3071
+ continue;
3072
+ }
3073
+ throw error;
3074
+ }
3075
+ violations.push(...validateGoalHierarchySections(filePath, content));
3076
+ violations.push(...validateGoalHierarchyLinks(filePath, content));
3077
+ allGoalRelationships.push(extractGoalRelationships(filePath, content));
3078
+ }
3079
+ violations.push(...validateBidirectionalLinks(allGoalRelationships));
3080
+ violations.push(...validateNoCycles(allGoalRelationships));
3081
+ }
3082
+ if (violations.length === 0) {
3083
+ context.stdout("All validations passed!");
3084
+ return { exitCode: 0 };
3085
+ }
3086
+ context.stderr(`Found ${violations.length} violation(s):`);
3087
+ context.stderr("");
3088
+ for (const v of violations) {
3089
+ const location = v.line ? `:${v.line}` : "";
3090
+ context.stderr(` ${v.file}${location}`);
3091
+ context.stderr(` ${v.message}`);
1860
3092
  }
3093
+ return { exitCode: 1 };
1861
3094
  }
1862
- function createStdoutSink() {
3095
+
3096
+ // lib/cli/commands/check.ts
3097
+ var DEFAULT_CHECK_TIMEOUT_MS = 13000;
3098
+ async function runSingleCheck(check, cwd, runner) {
3099
+ const timeoutMs = check.timeoutMilliseconds ?? DEFAULT_CHECK_TIMEOUT_MS;
3100
+ const startTime = Date.now();
3101
+ const result = await runner.run(check.command, cwd, timeoutMs);
3102
+ const durationMs = Date.now() - startTime;
1863
3103
  return {
1864
- write: (text) => process.stdout.write(text),
1865
- line: (text) => console.log(text)
3104
+ name: check.name,
3105
+ command: check.command,
3106
+ exitCode: result.exitCode,
3107
+ output: result.output,
3108
+ hints: check.hints,
3109
+ durationMs,
3110
+ timedOut: result.timedOut,
3111
+ timeoutSeconds: timeoutMs / 1000
1866
3112
  };
1867
3113
  }
1868
-
1869
- // lib/claude/run.ts
1870
- var defaultRunnerDependencies = {
1871
- spawnClaudeCode,
1872
- createStdoutSink,
1873
- streamEvents
1874
- };
1875
- async function run(prompt, options = {}, dependencies = defaultRunnerDependencies) {
1876
- const isRunOptions = (opt) => ("spawnOptions" in opt) || ("onRawEvent" in opt);
1877
- const spawnOptions = isRunOptions(options) ? options.spawnOptions ?? {} : options;
1878
- const onRawEvent = isRunOptions(options) ? options.onRawEvent : undefined;
1879
- const events = dependencies.spawnClaudeCode(prompt, spawnOptions);
1880
- const sink = dependencies.createStdoutSink();
1881
- await dependencies.streamEvents(events, sink, onRawEvent);
3114
+ async function runConfiguredChecks(checks, cwd, runner) {
3115
+ const promises = checks.map((check) => runSingleCheck(check, cwd, runner));
3116
+ return Promise.all(promises);
1882
3117
  }
1883
-
1884
- // lib/cli/commands/next.ts
1885
- function extractBlockedBy(content) {
1886
- const blockedByMatch = content.match(/^## Blocked By\s*\n([\s\S]*?)(?=\n## |\n*$)/m);
1887
- if (!blockedByMatch) {
1888
- return [];
1889
- }
1890
- const section = blockedByMatch[1].trim();
1891
- if (section === "(none)") {
1892
- return [];
1893
- }
1894
- const linkPattern = /\[.*?\]\(([^)]+\.md)\)/g;
1895
- const blockers = [];
1896
- let match = linkPattern.exec(section);
1897
- while (match !== null) {
1898
- blockers.push(match[1]);
1899
- match = linkPattern.exec(section);
3118
+ async function runConfiguredChecksSerially(checks, cwd, runner) {
3119
+ const results = [];
3120
+ for (const check of checks) {
3121
+ results.push(await runSingleCheck(check, cwd, runner));
1900
3122
  }
1901
- return blockers;
3123
+ return results;
1902
3124
  }
1903
- async function findUnblockedTasks(cwd, fileSystem) {
1904
- const dustPath = `${cwd}/.dust`;
1905
- if (!fileSystem.exists(dustPath)) {
1906
- return { error: ".dust directory not found", tasks: [] };
3125
+ async function runValidationCheck(dependencies) {
3126
+ const outputLines = [];
3127
+ const bufferedContext = {
3128
+ cwd: dependencies.context.cwd,
3129
+ stdout: (msg) => outputLines.push(msg),
3130
+ stderr: (msg) => outputLines.push(msg)
3131
+ };
3132
+ const startTime = Date.now();
3133
+ const result = await lintMarkdown({
3134
+ ...dependencies,
3135
+ context: bufferedContext,
3136
+ arguments: []
3137
+ });
3138
+ const durationMs = Date.now() - startTime;
3139
+ return {
3140
+ name: "lint markdown",
3141
+ command: "dust lint markdown",
3142
+ exitCode: result.exitCode,
3143
+ output: outputLines.join(`
3144
+ `),
3145
+ isBuiltIn: true,
3146
+ durationMs
3147
+ };
3148
+ }
3149
+ function displayResults(results, context) {
3150
+ const passed = results.filter((r) => r.exitCode === 0);
3151
+ const failed = results.filter((r) => r.exitCode !== 0);
3152
+ for (const result of results) {
3153
+ if (result.timedOut) {
3154
+ context.stdout(`✗ ${result.name} [timed out after ${result.timeoutSeconds}s]`);
3155
+ } else {
3156
+ const timing = result.durationMs !== undefined && result.durationMs >= 1000 ? ` [${(result.durationMs / 1000).toFixed(1)}s]` : "";
3157
+ if (result.exitCode === 0) {
3158
+ context.stdout(`✓ ${result.name}${timing}`);
3159
+ } else {
3160
+ context.stdout(`✗ ${result.name}${timing}`);
3161
+ }
3162
+ }
1907
3163
  }
1908
- const tasksPath = `${dustPath}/tasks`;
1909
- if (!fileSystem.exists(tasksPath)) {
1910
- return { tasks: [] };
3164
+ for (const result of failed) {
3165
+ context.stdout("");
3166
+ context.stdout(`> ${result.command}`);
3167
+ if (result.timedOut) {
3168
+ context.stdout(`Note: This check was killed after ${result.timeoutSeconds}s. To configure a different timeout, set "timeoutMilliseconds" in the check configuration in .dust/config/settings.json`);
3169
+ }
3170
+ if (result.output.trim()) {
3171
+ context.stdout(result.output.trimEnd());
3172
+ }
3173
+ if (result.hints && result.hints.length > 0) {
3174
+ context.stdout("");
3175
+ context.stdout(`Hints for fixing '${result.name}':`);
3176
+ for (const hint of result.hints) {
3177
+ context.stdout(` - ${hint}`);
3178
+ }
3179
+ }
1911
3180
  }
1912
- const files = await fileSystem.readdir(tasksPath);
1913
- const mdFiles = files.filter((f) => f.endsWith(".md")).sort();
1914
- if (mdFiles.length === 0) {
1915
- return { tasks: [] };
3181
+ context.stdout("");
3182
+ const indicator = failed.length > 0 ? "" : "✓";
3183
+ context.stdout(`${indicator} ${passed.length}/${results.length} checks passed`);
3184
+ return failed.length > 0 ? 1 : 0;
3185
+ }
3186
+ async function check(dependencies, shellRunner = defaultShellRunner) {
3187
+ const {
3188
+ arguments: commandArguments,
3189
+ context,
3190
+ fileSystem,
3191
+ settings
3192
+ } = dependencies;
3193
+ const serial = commandArguments.includes("--serial");
3194
+ if (!settings.checks || settings.checks.length === 0) {
3195
+ context.stderr("Error: No checks configured in .dust/config/settings.json");
3196
+ context.stderr("");
3197
+ context.stderr("Add checks to your settings.json:");
3198
+ context.stderr(" {");
3199
+ context.stderr(' "checks": [');
3200
+ context.stderr(' { "name": "lint", "command": "npm run lint" },');
3201
+ context.stderr(' { "name": "test", "command": "npm test" }');
3202
+ context.stderr(" ]");
3203
+ context.stderr(" }");
3204
+ return { exitCode: 1 };
1916
3205
  }
1917
- const existingTasks = new Set(mdFiles);
1918
- const tasks = [];
1919
- for (const file of mdFiles) {
1920
- const filePath = `${tasksPath}/${file}`;
1921
- const content = await fileSystem.readFile(filePath);
1922
- const blockers = extractBlockedBy(content);
1923
- const hasIncompleteBlocker = blockers.some((blocker) => existingTasks.has(blocker));
1924
- if (!hasIncompleteBlocker) {
1925
- const title = extractTitle(content);
1926
- const openingSentence = extractOpeningSentence(content);
1927
- const relativePath = `.dust/tasks/${file}`;
1928
- tasks.push({ path: relativePath, title, openingSentence });
3206
+ const dustPath = `${context.cwd}/.dust`;
3207
+ const hasDustDir = fileSystem.exists(dustPath);
3208
+ if (serial) {
3209
+ const results2 = [];
3210
+ if (hasDustDir) {
3211
+ results2.push(await runValidationCheck(dependencies));
1929
3212
  }
3213
+ const configuredResults = await runConfiguredChecksSerially(settings.checks, context.cwd, shellRunner);
3214
+ results2.push(...configuredResults);
3215
+ const exitCode2 = displayResults(results2, context);
3216
+ return { exitCode: exitCode2 };
1930
3217
  }
1931
- return { tasks };
1932
- }
1933
- function printTaskList(context, tasks) {
1934
- const colors = getColors();
1935
- context.stdout("\uD83D\uDCCB Next tasks");
1936
- context.stdout("");
1937
- for (const task of tasks) {
1938
- const parts = task.path.split("/");
1939
- const displayTitle = task.title || parts[parts.length - 1].replace(".md", "");
1940
- context.stdout(`${colors.bold}# ${displayTitle}${colors.reset}`);
1941
- if (task.openingSentence) {
1942
- context.stdout(`${colors.dim}${task.openingSentence}${colors.reset}`);
3218
+ const checkPromises = [];
3219
+ if (hasDustDir) {
3220
+ checkPromises.push(runValidationCheck(dependencies));
3221
+ }
3222
+ checkPromises.push(runConfiguredChecks(settings.checks, context.cwd, shellRunner));
3223
+ const promiseResults = await Promise.all(checkPromises);
3224
+ const results = [];
3225
+ for (const result of promiseResults) {
3226
+ if (Array.isArray(result)) {
3227
+ results.push(...result);
3228
+ } else {
3229
+ results.push(result);
1943
3230
  }
1944
- context.stdout(`${colors.cyan}→ ${task.path}${colors.reset}`);
1945
- context.stdout("");
1946
3231
  }
3232
+ const exitCode = displayResults(results, context);
3233
+ return { exitCode };
1947
3234
  }
1948
- async function next(dependencies) {
1949
- const { context, fileSystem } = dependencies;
1950
- const result = await findUnblockedTasks(context.cwd, fileSystem);
1951
- if (result.error) {
1952
- context.stderr(`Error: ${result.error}`);
1953
- context.stderr("Run 'dust init' to initialize a Dust repository");
3235
+
3236
+ // lib/cli/commands/focus.ts
3237
+ async function focus(dependencies) {
3238
+ const { context, settings } = dependencies;
3239
+ const objective = dependencies.arguments.join(" ").trim();
3240
+ if (!objective) {
3241
+ context.stderr("Error: No objective provided");
3242
+ context.stderr('Usage: dust focus "your objective here"');
1954
3243
  return { exitCode: 1 };
1955
3244
  }
1956
- if (result.tasks.length === 0) {
1957
- return { exitCode: 0 };
3245
+ const hooksInstalled = await manageGitHooks(dependencies);
3246
+ const vars = templateVariables(settings, hooksInstalled);
3247
+ context.stdout(`\uD83C\uDFAF Focus: ${objective}`);
3248
+ context.stdout("");
3249
+ const steps = [];
3250
+ let step = 1;
3251
+ steps.push(`${step}. Run \`${vars.bin} check\` to verify the project is in a good state`);
3252
+ step++;
3253
+ steps.push(`${step}. Implement the task`);
3254
+ step++;
3255
+ if (!hooksInstalled) {
3256
+ steps.push(`${step}. Run \`${vars.bin} check\` before committing`);
3257
+ step++;
1958
3258
  }
1959
- printTaskList(context, result.tasks);
3259
+ steps.push(`${step}. Create a single atomic commit that includes:`, " - All implementation changes", " - Deletion of the completed task file", " - Updates to any facts that changed", " - Deletion of the idea file that spawned this task (if remaining scope exists, create new ideas for it)", "", ' Use the task title as the commit message. Task titles are written in imperative form, which is the recommended style for git commit messages. Do not add prefixes like "Complete task:" - use the title directly.', "", ' Example: If the task title is "Add validation for user input", the commit message should be:', " ```", " Add validation for user input", " ```", "");
3260
+ step++;
3261
+ steps.push(`${step}. Push your commit to the remote repository`);
3262
+ steps.push("");
3263
+ steps.push("Keep your change small and focused. One task, one commit.");
3264
+ context.stdout(steps.join(`
3265
+ `));
1960
3266
  return { exitCode: 0 };
1961
3267
  }
1962
3268
 
1963
- // lib/cli/commands/loop.ts
1964
- function formatEvent(event) {
1965
- switch (event.type) {
1966
- case "loop.warning":
1967
- return "⚠️ WARNING: This command skips all permission checks. Only use in a sandbox environment!";
1968
- case "loop.started":
1969
- return `\uD83D\uDD04 Starting dust loop claude (max ${event.maxIterations} iterations)...`;
1970
- case "loop.syncing":
1971
- return "\uD83C\uDF0D Syncing with remote";
1972
- case "loop.sync_skipped":
1973
- return `Note: git pull skipped (${event.reason})`;
1974
- case "loop.checking_tasks":
1975
- return null;
1976
- case "loop.no_tasks":
1977
- return `\uD83D\uDE34 No tasks available. Sleeping...
1978
- `;
1979
- case "loop.tasks_found":
1980
- return `✨ Found a task. Going to work!
1981
- `;
1982
- case "claude.started":
1983
- return "\uD83E\uDD16 Starting Claude...";
1984
- case "claude.ended":
1985
- return event.success ? "\uD83E\uDD16 Claude session ended (success)" : `\uD83E\uDD16 Claude session ended (error: ${event.error})`;
1986
- case "claude.raw_event":
1987
- return null;
1988
- case "loop.iteration_complete":
1989
- return `\uD83D\uDCCB Completed iteration ${event.iteration}/${event.maxIterations}`;
1990
- case "loop.ended":
1991
- return `\uD83C\uDFC1 Reached max iterations (${event.maxIterations}). Exiting.`;
1992
- }
1993
- }
1994
- async function defaultPostEvent(url, payload) {
1995
- await fetch(url, {
1996
- method: "POST",
1997
- headers: { "Content-Type": "application/json" },
1998
- body: JSON.stringify(payload)
1999
- });
2000
- }
2001
- function createDefaultDependencies() {
2002
- return {
2003
- spawn: nodeSpawn2,
2004
- run,
2005
- sleep: (ms) => new Promise((resolve2) => setTimeout(resolve2, ms)),
2006
- postEvent: defaultPostEvent
2007
- };
2008
- }
2009
- function createEventPoster(eventsUrl, sessionId, postEvent, onError, getAgentSessionId) {
2010
- let sequence = 0;
2011
- return (event) => {
2012
- if (!eventsUrl)
2013
- return;
2014
- sequence++;
2015
- const payload = {
2016
- sequence,
2017
- timestamp: new Date().toISOString(),
2018
- sessionId,
2019
- event
2020
- };
2021
- if (event.type.startsWith("claude.")) {
2022
- payload.agentType = "claude";
2023
- const agentSessionId = getAgentSessionId?.();
2024
- if (agentSessionId) {
2025
- payload.agentSessionId = agentSessionId;
2026
- }
2027
- }
2028
- postEvent(eventsUrl, payload).catch(onError);
2029
- };
2030
- }
2031
- var SLEEP_INTERVAL_MS = 30000;
2032
- var DEFAULT_MAX_ITERATIONS = 10;
2033
- async function gitPull(cwd, spawn2) {
2034
- return new Promise((resolve2) => {
2035
- const proc = spawn2("git", ["pull"], {
2036
- cwd,
2037
- stdio: ["ignore", "pipe", "pipe"]
2038
- });
2039
- let stderr = "";
2040
- proc.stderr?.on("data", (data) => {
2041
- stderr += data.toString();
2042
- });
2043
- proc.on("close", (code) => {
2044
- if (code === 0) {
2045
- resolve2({ success: true });
2046
- } else {
2047
- resolve2({ success: false, message: stderr.trim() || "git pull failed" });
2048
- }
2049
- });
2050
- proc.on("error", (error) => {
2051
- resolve2({ success: false, message: error.message });
2052
- });
2053
- });
3269
+ // lib/cli/commands/help.ts
3270
+ function generateHelpText(settings) {
3271
+ return loadTemplate("help", { bin: settings.dustCommand });
2054
3272
  }
2055
- async function hasAvailableTasks(dependencies) {
2056
- let hasOutput = false;
2057
- const captureContext = {
2058
- ...dependencies.context,
2059
- stdout: () => {
2060
- hasOutput = true;
2061
- }
2062
- };
2063
- await next({ ...dependencies, context: captureContext });
2064
- return hasOutput;
3273
+ async function help(dependencies) {
3274
+ dependencies.context.stdout(generateHelpText(dependencies.settings));
3275
+ return { exitCode: 0 };
2065
3276
  }
2066
- async function runOneIteration(dependencies, loopDependencies, emit, options = {}) {
2067
- const { context } = dependencies;
2068
- const { spawn: spawn2, run: run2 } = loopDependencies;
2069
- const { onRawEvent } = options;
2070
- emit({ type: "loop.syncing" });
2071
- const pullResult = await gitPull(context.cwd, spawn2);
2072
- if (!pullResult.success) {
2073
- emit({
2074
- type: "loop.sync_skipped",
2075
- reason: pullResult.message ?? "unknown error"
2076
- });
2077
- emit({ type: "claude.started" });
2078
- const prompt = `git pull failed with the following error:
2079
3277
 
2080
- ${pullResult.message}
2081
-
2082
- Please resolve this issue. Common approaches:
2083
- 1. If there are merge conflicts, resolve them
2084
- 2. If local commits need to be rebased, use git rebase
2085
- 3. After resolving, commit any changes and push to remote
3278
+ // lib/cli/commands/implement-task.ts
3279
+ async function implementTask(dependencies) {
3280
+ const { context, settings } = dependencies;
3281
+ const hooksInstalled = await manageGitHooks(dependencies);
3282
+ const vars = templateVariables(settings, hooksInstalled);
3283
+ context.stdout(`Run \`${vars.bin} focus "<task name>"\` to set your focus and see implementation instructions.`);
3284
+ return { exitCode: 0 };
3285
+ }
2086
3286
 
2087
- Make sure the repository is in a clean state and synced with remote before finishing.`;
2088
- try {
2089
- await run2(prompt, {
2090
- spawnOptions: {
2091
- cwd: context.cwd,
2092
- dangerouslySkipPermissions: true,
2093
- env: { DUST_UNATTENDED: "1" }
2094
- },
2095
- onRawEvent
2096
- });
2097
- emit({ type: "claude.ended", success: true });
2098
- return "resolved_pull_conflict";
2099
- } catch (error) {
2100
- const errorMessage = error instanceof Error ? error.message : String(error);
2101
- context.stderr(`Claude failed to resolve git pull conflict: ${errorMessage}`);
2102
- emit({ type: "claude.ended", success: false, error: errorMessage });
2103
- }
3287
+ // lib/cli/commands/init.ts
3288
+ var DUST_DIRECTORIES = ["goals", "ideas", "tasks", "facts", "config"];
3289
+ function generateSettings(cwd, fileSystem) {
3290
+ const dustCommand = detectDustCommand(cwd, fileSystem);
3291
+ const testCommand = detectTestCommand(cwd, fileSystem);
3292
+ const checks = [];
3293
+ if (testCommand) {
3294
+ checks.push({ name: "test", command: testCommand });
2104
3295
  }
2105
- emit({ type: "loop.checking_tasks" });
2106
- const hasTasks = await hasAvailableTasks(dependencies);
2107
- if (!hasTasks) {
2108
- emit({ type: "loop.no_tasks" });
2109
- return "no_tasks";
3296
+ return { dustCommand, checks };
3297
+ }
3298
+ var USE_DUST_FACT = `# Use dust for planning
3299
+
3300
+ This project uses [dust](https://github.com/joshski/dust) for planning and documentation.
3301
+ `;
3302
+ async function init(dependencies) {
3303
+ const { context, fileSystem } = dependencies;
3304
+ const colors = getColors();
3305
+ const dustPath = `${context.cwd}/.dust`;
3306
+ const dustCommand = detectDustCommand(context.cwd, fileSystem);
3307
+ const agentInstruction = `Always immediately run \`${dustCommand} agent\` when you start working in this repository.`;
3308
+ await fileSystem.mkdir(dustPath, { recursive: true });
3309
+ for (const dir of DUST_DIRECTORIES) {
3310
+ await fileSystem.mkdir(`${dustPath}/${dir}`, { recursive: true });
2110
3311
  }
2111
- emit({ type: "loop.tasks_found" });
2112
- emit({ type: "claude.started" });
3312
+ let dustDirCreated = false;
2113
3313
  try {
2114
- await run2("go", {
2115
- spawnOptions: {
2116
- cwd: context.cwd,
2117
- dangerouslySkipPermissions: true,
2118
- env: { DUST_UNATTENDED: "1" }
2119
- },
2120
- onRawEvent
2121
- });
2122
- emit({ type: "claude.ended", success: true });
2123
- return "ran_claude";
3314
+ await fileSystem.writeFile(`${dustPath}/facts/use-dust-for-planning.md`, USE_DUST_FACT, { flag: "wx" });
3315
+ dustDirCreated = true;
2124
3316
  } catch (error) {
2125
- const errorMessage = error instanceof Error ? error.message : String(error);
2126
- context.stderr(`Claude exited with error: ${errorMessage}`);
2127
- emit({ type: "claude.ended", success: false, error: errorMessage });
2128
- return "claude_error";
3317
+ if (error.code !== "EEXIST") {
3318
+ throw error;
3319
+ }
2129
3320
  }
2130
- }
2131
- function parseMaxIterations(commandArguments) {
2132
- if (commandArguments.length === 0) {
2133
- return DEFAULT_MAX_ITERATIONS;
3321
+ try {
3322
+ const settings = generateSettings(context.cwd, fileSystem);
3323
+ await fileSystem.writeFile(`${dustPath}/config/settings.json`, `${JSON.stringify(settings, null, 2)}
3324
+ `, { flag: "wx" });
3325
+ } catch (error) {
3326
+ if (error.code !== "EEXIST") {
3327
+ throw error;
3328
+ }
2134
3329
  }
2135
- const parsed = Number.parseInt(commandArguments[0], 10);
2136
- if (Number.isNaN(parsed) || parsed <= 0) {
2137
- return DEFAULT_MAX_ITERATIONS;
3330
+ if (dustDirCreated) {
3331
+ context.stdout(`${colors.green}✨ Initialized${colors.reset} Dust repository in ${colors.cyan}.dust/${colors.reset}`);
3332
+ context.stdout(`${colors.green}\uD83D\uDCC1 Created directories:${colors.reset} ${colors.dim}${DUST_DIRECTORIES.join(", ")}${colors.reset}`);
3333
+ context.stdout(`${colors.green}\uD83D\uDCC4 Created initial fact:${colors.reset} ${colors.cyan}.dust/facts/use-dust-for-planning.md${colors.reset}`);
3334
+ context.stdout(`${colors.green}⚙️ Created settings:${colors.reset} ${colors.cyan}.dust/config/settings.json${colors.reset}`);
3335
+ } else {
3336
+ context.stdout(`${colors.yellow}\uD83D\uDCE6 Note:${colors.reset} ${colors.cyan}.dust${colors.reset} directory already exists, skipping creation`);
2138
3337
  }
2139
- return parsed;
2140
- }
2141
- async function loopClaude(dependencies, loopDependencies = createDefaultDependencies()) {
2142
- const { context, settings } = dependencies;
2143
- const { postEvent } = loopDependencies;
2144
- const maxIterations = parseMaxIterations(dependencies.arguments);
2145
- const eventsUrl = settings.eventsUrl;
2146
- const sessionId = crypto.randomUUID();
2147
- let agentSessionId;
2148
- const postEventFn = createEventPoster(eventsUrl, sessionId, postEvent, (error) => {
2149
- const message = error instanceof Error ? error.message : String(error);
2150
- context.stderr(`Event POST failed: ${message}`);
2151
- }, () => agentSessionId);
2152
- const emit = (event) => {
2153
- const formatted = formatEvent(event);
2154
- if (formatted !== null) {
2155
- context.stdout(formatted);
3338
+ const claudeMdPath = `${context.cwd}/CLAUDE.md`;
3339
+ try {
3340
+ const claudeContent = loadTemplate("claude-md", { dustCommand });
3341
+ await fileSystem.writeFile(claudeMdPath, claudeContent, { flag: "wx" });
3342
+ context.stdout(`${colors.green}\uD83D\uDCC4 Created${colors.reset} ${colors.cyan}CLAUDE.md${colors.reset} with agent instructions`);
3343
+ } catch (error) {
3344
+ if (error.code === "EEXIST") {
3345
+ context.stdout(`${colors.yellow}⚠️ Warning:${colors.reset} ${colors.cyan}CLAUDE.md${colors.reset} already exists. Consider adding: ${colors.dim}"${agentInstruction}"${colors.reset}`);
3346
+ } else {
3347
+ throw error;
2156
3348
  }
2157
- postEventFn(event);
2158
- };
2159
- emit({ type: "loop.warning" });
2160
- emit({ type: "loop.started", maxIterations });
2161
- context.stdout(" Press Ctrl+C to stop");
3349
+ }
3350
+ const agentsMdPath = `${context.cwd}/AGENTS.md`;
3351
+ try {
3352
+ const agentsContent = loadTemplate("agents-md", { dustCommand });
3353
+ await fileSystem.writeFile(agentsMdPath, agentsContent, { flag: "wx" });
3354
+ context.stdout(`${colors.green}\uD83D\uDCC4 Created${colors.reset} ${colors.cyan}AGENTS.md${colors.reset} with agent instructions`);
3355
+ } catch (error) {
3356
+ if (error.code === "EEXIST") {
3357
+ context.stdout(`${colors.yellow}⚠️ Warning:${colors.reset} ${colors.cyan}AGENTS.md${colors.reset} already exists. Consider adding: ${colors.dim}"${agentInstruction}"${colors.reset}`);
3358
+ } else {
3359
+ throw error;
3360
+ }
3361
+ }
3362
+ const runner = dustCommand.split(" ")[0];
2162
3363
  context.stdout("");
2163
- let completedIterations = 0;
2164
- const iterationOptions = {};
2165
- if (eventsUrl) {
2166
- iterationOptions.onRawEvent = (rawEvent) => {
2167
- if (typeof rawEvent.session_id === "string" && rawEvent.session_id) {
2168
- agentSessionId = rawEvent.session_id;
3364
+ context.stdout(`${colors.bold}\uD83D\uDE80 Next steps:${colors.reset} Commit the changes if you are happy, then get planning!`);
3365
+ context.stdout("");
3366
+ context.stdout(`${colors.dim}If this is a new repository, you can start adding ideas or tasks right away:${colors.reset}`);
3367
+ context.stdout(` ${colors.cyan}>${colors.reset} ${runner} claude "Idea: friendly UI for non-technical users"`);
3368
+ context.stdout(` ${colors.cyan}>${colors.reset} ${runner} codex "Task: set up code coverage"`);
3369
+ context.stdout("");
3370
+ context.stdout(`${colors.dim}If this is an existing codebase, you might want to backfill goals and facts:${colors.reset}`);
3371
+ context.stdout(` ${colors.cyan}>${colors.reset} ${runner} claude "Add goals and facts based on the code in this repository"`);
3372
+ return { exitCode: 0 };
3373
+ }
3374
+
3375
+ // lib/cli/commands/list.ts
3376
+ import { basename as basename2 } from "node:path";
3377
+ var VALID_TYPES = ["tasks", "ideas", "goals", "facts"];
3378
+ var SECTION_HEADERS = {
3379
+ tasks: "\uD83D\uDCCB Tasks",
3380
+ ideas: "\uD83D\uDCA1 Ideas",
3381
+ goals: "\uD83C\uDFAF Goals",
3382
+ facts: "\uD83D\uDCC4 Facts"
3383
+ };
3384
+ var TYPE_EXPLANATIONS = {
3385
+ tasks: "Tasks are detailed work plans with dependencies and completion criteria. Each task describes a specific piece of work to be done.",
3386
+ ideas: "Ideas are future feature notes and proposals. Ideas capture possibilities that haven't yet been refined into actionable tasks.",
3387
+ goals: "Goals are mission statements and guiding principles. Goals describe desired outcomes and values that inform decision-making.",
3388
+ facts: "Facts are current state documentation. Facts capture how things work today, providing context for agents and contributors."
3389
+ };
3390
+ async function buildGoalHierarchy(goalsPath, fileSystem) {
3391
+ const files = await fileSystem.readdir(goalsPath);
3392
+ const mdFiles = files.filter((f) => f.endsWith(".md"));
3393
+ const relationships = [];
3394
+ const titleMap = new Map;
3395
+ for (const file of mdFiles) {
3396
+ const filePath = `${goalsPath}/${file}`;
3397
+ const content = await fileSystem.readFile(filePath);
3398
+ relationships.push(extractGoalRelationships(filePath, content));
3399
+ const title = extractTitle(content) || basename2(file, ".md");
3400
+ titleMap.set(filePath, title);
3401
+ }
3402
+ const relMap = new Map;
3403
+ for (const rel of relationships) {
3404
+ relMap.set(rel.filePath, rel);
3405
+ }
3406
+ const rootGoals = relationships.filter((rel) => rel.parentGoals.length === 0);
3407
+ function buildNode(filePath) {
3408
+ const rel = relMap.get(filePath);
3409
+ const children = [];
3410
+ if (rel) {
3411
+ for (const childPath of rel.subGoals) {
3412
+ children.push(buildNode(childPath));
2169
3413
  }
2170
- emit({ type: "claude.raw_event", rawEvent });
3414
+ }
3415
+ return {
3416
+ filePath,
3417
+ title: titleMap.get(filePath) || basename2(filePath, ".md"),
3418
+ children
2171
3419
  };
2172
3420
  }
2173
- while (completedIterations < maxIterations) {
2174
- agentSessionId = undefined;
2175
- const result = await runOneIteration(dependencies, loopDependencies, emit, iterationOptions);
2176
- if (result === "no_tasks") {
2177
- await loopDependencies.sleep(SLEEP_INTERVAL_MS);
2178
- } else {
2179
- completedIterations++;
2180
- emit({
2181
- type: "loop.iteration_complete",
2182
- iteration: completedIterations,
2183
- maxIterations
2184
- });
3421
+ return rootGoals.map((rel) => buildNode(rel.filePath));
3422
+ }
3423
+ function renderHierarchy(nodes, output, prefix = "") {
3424
+ for (let i = 0;i < nodes.length; i++) {
3425
+ const node = nodes[i];
3426
+ const isLastNode = i === nodes.length - 1;
3427
+ const connector = isLastNode ? "└── " : "├── ";
3428
+ const childPrefix = isLastNode ? " " : "│ ";
3429
+ output(`${prefix}${connector}${node.title}`);
3430
+ if (node.children.length > 0) {
3431
+ renderHierarchy(node.children, output, prefix + childPrefix);
3432
+ }
3433
+ }
3434
+ }
3435
+ async function list(dependencies) {
3436
+ const { arguments: commandArguments, context, fileSystem } = dependencies;
3437
+ const dustPath = `${context.cwd}/.dust`;
3438
+ const colors = getColors();
3439
+ if (!fileSystem.exists(dustPath)) {
3440
+ context.stderr("Error: .dust directory not found");
3441
+ context.stderr("Run 'dust init' to initialize a Dust repository");
3442
+ return { exitCode: 1 };
3443
+ }
3444
+ const typesToList = commandArguments.length === 0 ? [...VALID_TYPES] : commandArguments.filter((a) => VALID_TYPES.includes(a));
3445
+ if (commandArguments.length > 0 && typesToList.length === 0) {
3446
+ context.stderr(`Invalid type: ${commandArguments[0]}`);
3447
+ context.stderr(`Valid types: ${VALID_TYPES.join(", ")}`);
3448
+ return { exitCode: 1 };
3449
+ }
3450
+ const specificTypeRequested = commandArguments.length > 0;
3451
+ for (const type of typesToList) {
3452
+ const dirPath = `${dustPath}/${type}`;
3453
+ const dirExists = fileSystem.exists(dirPath);
3454
+ const files = dirExists ? await fileSystem.readdir(dirPath) : [];
3455
+ const mdFiles = files.filter((f) => f.endsWith(".md")).sort();
3456
+ if (mdFiles.length === 0) {
3457
+ if (specificTypeRequested) {
3458
+ context.stdout(SECTION_HEADERS[type]);
3459
+ context.stdout("");
3460
+ context.stdout(TYPE_EXPLANATIONS[type]);
3461
+ context.stdout("");
3462
+ context.stdout(`No ${type} found.`);
3463
+ context.stdout("");
3464
+ }
3465
+ continue;
3466
+ }
3467
+ context.stdout(SECTION_HEADERS[type]);
3468
+ context.stdout("");
3469
+ context.stdout(TYPE_EXPLANATIONS[type]);
3470
+ context.stdout("");
3471
+ if (type === "goals") {
3472
+ const hierarchy = await buildGoalHierarchy(dirPath, fileSystem);
3473
+ if (hierarchy.length > 0) {
3474
+ context.stdout(`${colors.dim}Hierarchy:${colors.reset}`);
3475
+ renderHierarchy(hierarchy, (line) => context.stdout(line));
3476
+ context.stdout("");
3477
+ }
3478
+ }
3479
+ for (const file of mdFiles) {
3480
+ const filePath = `${dirPath}/${file}`;
3481
+ const content = await fileSystem.readFile(filePath);
3482
+ const title = extractTitle(content);
3483
+ const openingSentence = extractOpeningSentence(content);
3484
+ const relativePath = `.dust/${type}/${file}`;
3485
+ if (title) {
3486
+ context.stdout(`${colors.bold}# ${title}${colors.reset}`);
3487
+ } else {
3488
+ context.stdout(`${colors.bold}# ${file.replace(".md", "")}${colors.reset}`);
3489
+ }
3490
+ if (openingSentence) {
3491
+ context.stdout(`${colors.dim}${openingSentence}${colors.reset}`);
3492
+ }
3493
+ context.stdout(`${colors.cyan}→ ${relativePath}${colors.reset}`);
3494
+ context.stdout("");
2185
3495
  }
2186
3496
  }
2187
- emit({ type: "loop.ended", maxIterations });
2188
3497
  return { exitCode: 0 };
2189
3498
  }
2190
3499
 
@@ -2385,6 +3694,8 @@ var commandRegistry = {
2385
3694
  next,
2386
3695
  check,
2387
3696
  agent,
3697
+ audit,
3698
+ bucket,
2388
3699
  focus,
2389
3700
  "new task": newTask,
2390
3701
  "new goal": newGoal,