@joshski/dust 0.1.33 → 0.1.35

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,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  // lib/cli/run.ts
3
3
  import { existsSync } from "node:fs";
4
- import { chmod, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
4
+ import { chmod as chmod2, mkdir as mkdir2, readdir as readdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
5
5
 
6
6
  // lib/config/settings.ts
7
7
  import { join } from "node:path";
8
8
  var DEFAULT_SETTINGS = {
9
- dustCommand: "npx dust"
9
+ dustCommand: "npx dust",
10
+ installCommand: "npm install"
10
11
  };
11
12
  function detectDustCommand(cwd, fileSystem) {
12
13
  if (fileSystem.exists(join(cwd, "bun.lockb"))) {
@@ -23,6 +24,21 @@ function detectDustCommand(cwd, fileSystem) {
23
24
  }
24
25
  return "npx dust";
25
26
  }
27
+ function detectInstallCommand(cwd, fileSystem) {
28
+ if (fileSystem.exists(join(cwd, "bun.lockb"))) {
29
+ return "bun install";
30
+ }
31
+ if (fileSystem.exists(join(cwd, "pnpm-lock.yaml"))) {
32
+ return "pnpm install";
33
+ }
34
+ if (fileSystem.exists(join(cwd, "package-lock.json"))) {
35
+ return "npm install";
36
+ }
37
+ if (process.env.BUN_INSTALL) {
38
+ return "bun install";
39
+ }
40
+ return "npm install";
41
+ }
26
42
  function detectTestCommand(cwd, fileSystem) {
27
43
  if (fileSystem.exists(join(cwd, "bun.lockb")) || fileSystem.exists(join(cwd, "bun.lock"))) {
28
44
  return "bun test";
@@ -54,7 +70,8 @@ async function loadSettings(cwd, fileSystem) {
54
70
  const settingsPath = join(cwd, ".dust", "config", "settings.json");
55
71
  if (!fileSystem.exists(settingsPath)) {
56
72
  const result = {
57
- dustCommand: detectDustCommand(cwd, fileSystem)
73
+ dustCommand: detectDustCommand(cwd, fileSystem),
74
+ installCommand: detectInstallCommand(cwd, fileSystem)
58
75
  };
59
76
  if (process.env.DUST_EVENTS_URL) {
60
77
  result.eventsUrl = process.env.DUST_EVENTS_URL;
@@ -74,13 +91,17 @@ async function loadSettings(cwd, fileSystem) {
74
91
  if (!parsed.dustCommand) {
75
92
  result.dustCommand = detectDustCommand(cwd, fileSystem);
76
93
  }
94
+ if (!parsed.installCommand) {
95
+ result.installCommand = detectInstallCommand(cwd, fileSystem);
96
+ }
77
97
  if (process.env.DUST_EVENTS_URL) {
78
98
  result.eventsUrl = process.env.DUST_EVENTS_URL;
79
99
  }
80
100
  return result;
81
101
  } catch {
82
102
  const result = {
83
- dustCommand: detectDustCommand(cwd, fileSystem)
103
+ dustCommand: detectDustCommand(cwd, fileSystem),
104
+ installCommand: detectInstallCommand(cwd, fileSystem)
84
105
  };
85
106
  if (process.env.DUST_EVENTS_URL) {
86
107
  result.eventsUrl = process.env.DUST_EVENTS_URL;
@@ -123,7 +144,7 @@ import { join as join4 } from "node:path";
123
144
  // lib/agents/detection.ts
124
145
  function detectAgent(env = process.env) {
125
146
  if (env.CLAUDECODE) {
126
- if (env.CLAUDE_CODE_ENTRYPOINT === "remote") {
147
+ if (env.CLAUDE_CODE_REMOTE) {
127
148
  return { type: "claude-code-web", name: "Claude Code Web" };
128
149
  }
129
150
  return { type: "claude-code", name: "Claude Code" };
@@ -323,62 +344,10 @@ async function agent(dependencies) {
323
344
  return { exitCode: 0 };
324
345
  }
325
346
 
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";
347
+ // lib/cli/commands/audit.ts
348
+ import { readdirSync, readFileSync as readFileSync2 } from "node:fs";
349
+ import { basename, dirname as dirname2, join as join5 } from "node:path";
350
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
382
351
 
383
352
  // lib/markdown/markdown-utilities.ts
384
353
  function extractTitle(content) {
@@ -432,1759 +401,3109 @@ function extractOpeningSentence(content) {
432
401
  return sentenceMatch[1];
433
402
  }
434
403
 
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`;
404
+ // lib/cli/colors.ts
405
+ var ANSI_COLORS = {
406
+ reset: "\x1B[0m",
407
+ bold: "\x1B[1m",
408
+ dim: "\x1B[2m",
409
+ cyan: "\x1B[36m",
410
+ green: "\x1B[32m",
411
+ yellow: "\x1B[33m"
412
+ };
413
+ var NO_COLORS = {
414
+ reset: "",
415
+ bold: "",
416
+ dim: "",
417
+ cyan: "",
418
+ green: "",
419
+ yellow: ""
420
+ };
421
+ function shouldDisableColors() {
422
+ if (process.env.NO_COLOR !== undefined) {
423
+ return true;
424
+ }
425
+ if (process.env.TERM === "dumb") {
426
+ return true;
427
+ }
428
+ if (!process.stdout.isTTY) {
429
+ return true;
430
+ }
431
+ return false;
432
+ }
433
+ function getColors() {
434
+ return shouldDisableColors() ? NO_COLORS : ANSI_COLORS;
443
435
  }
444
436
 
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
- };
437
+ // lib/cli/commands/audit.ts
438
+ var __dirname3 = dirname2(fileURLToPath2(import.meta.url));
439
+ var stockAuditsDir = join5(__dirname3, "../../templates/audits");
440
+ function loadStockAudits() {
441
+ const files = readdirSync(stockAuditsDir).filter((f) => f.endsWith(".md")).sort();
442
+ return files.map((file) => {
443
+ const template = readFileSync2(join5(stockAuditsDir, file), "utf-8");
444
+ const name = basename(file, ".md");
445
+ const description = extractOpeningSentence(template);
446
+ return { name, description, template };
447
+ });
448
+ }
449
+ function transformAuditContent(content) {
450
+ const titleMatch = content.match(/^#\s+(.+)$/m);
451
+ if (!titleMatch) {
452
+ return content;
458
453
  }
459
- return null;
454
+ const originalTitle = titleMatch[1];
455
+ return content.replace(/^#\s+.+$/m, `# Audit: ${originalTitle}`);
460
456
  }
461
- function validateTitleFilenameMatch(filePath, content) {
462
- const title = extractTitle(content);
463
- if (!title) {
464
- return null;
457
+ async function addAudit(auditName, dependencies) {
458
+ const { context, fileSystem, settings } = dependencies;
459
+ const dustPath = `${context.cwd}/.dust`;
460
+ const userAuditsPath = `${dustPath}/config/audits`;
461
+ const tasksPath = `${dustPath}/tasks`;
462
+ const taskFilePath = `${tasksPath}/audit-${auditName}.md`;
463
+ const relativeTaskPath = `.dust/tasks/audit-${auditName}.md`;
464
+ if (fileSystem.exists(taskFilePath)) {
465
+ context.stderr(`Error: Audit task already exists at ${relativeTaskPath}`);
466
+ return { exitCode: 1 };
465
467
  }
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
- };
468
+ const userAuditPath = `${userAuditsPath}/${auditName}.md`;
469
+ if (fileSystem.exists(userAuditPath)) {
470
+ const content = await fileSystem.readFile(userAuditPath);
471
+ const transformedContent = transformAuditContent(content);
472
+ await fileSystem.mkdir(tasksPath, { recursive: true });
473
+ await fileSystem.writeFile(taskFilePath, transformedContent);
474
+ context.stdout(`→ ${relativeTaskPath}`);
475
+ return { exitCode: 0 };
474
476
  }
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
- };
477
+ const stockAudit = loadStockAudits().find((a) => a.name === auditName);
478
+ if (stockAudit) {
479
+ const transformedContent = transformAuditContent(stockAudit.template);
480
+ await fileSystem.mkdir(tasksPath, { recursive: true });
481
+ await fileSystem.writeFile(taskFilePath, transformedContent);
482
+ context.stdout(`→ ${relativeTaskPath}`);
483
+ return { exitCode: 0 };
484
484
  }
485
- return null;
485
+ context.stderr(`Error: Audit '${auditName}' not found`);
486
+ context.stderr(`Run '${settings.dustCommand} audit' to see available audits`);
487
+ return { exitCode: 1 };
486
488
  }
487
- function validateOpeningSentenceLength(filePath, content) {
488
- const openingSentence = extractOpeningSentence(content);
489
- if (!openingSentence) {
490
- return null;
489
+ async function listAudits(dependencies) {
490
+ const { context, fileSystem } = dependencies;
491
+ const colors = getColors();
492
+ const dustPath = `${context.cwd}/.dust`;
493
+ const userAuditsPath = `${dustPath}/config/audits`;
494
+ const audits = new Map;
495
+ for (const stockAudit of loadStockAudits()) {
496
+ audits.set(stockAudit.name, {
497
+ name: stockAudit.name,
498
+ description: stockAudit.description,
499
+ source: "stock"
500
+ });
491
501
  }
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
- };
502
+ if (fileSystem.exists(userAuditsPath)) {
503
+ const files = await fileSystem.readdir(userAuditsPath);
504
+ const mdFiles = files.filter((f) => f.endsWith(".md")).sort();
505
+ for (const file of mdFiles) {
506
+ const name = basename(file, ".md");
507
+ const filePath = `${userAuditsPath}/${file}`;
508
+ const content = await fileSystem.readFile(filePath);
509
+ const title = extractTitle(content);
510
+ const openingSentence = extractOpeningSentence(content);
511
+ const relativePath = `.dust/config/audits/${file}`;
512
+ audits.set(name, {
513
+ name: title || name,
514
+ description: openingSentence || "",
515
+ source: relativePath
516
+ });
517
+ }
497
518
  }
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;
519
+ context.stdout("\uD83D\uDD0D Audits");
520
+ context.stdout("");
521
+ context.stdout("Audits are canned tasks that help maintain project health.");
522
+ context.stdout("");
523
+ for (const auditInfo of audits.values()) {
524
+ context.stdout(`${colors.bold}# ${auditInfo.name}${colors.reset}`);
525
+ if (auditInfo.description) {
526
+ context.stdout(`${colors.dim}${auditInfo.description}${colors.reset}`);
527
+ }
528
+ context.stdout(`${colors.cyan}→ ${auditInfo.source}${colors.reset}`);
529
+ context.stdout("");
518
530
  }
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
- };
531
+ return { exitCode: 0 };
532
+ }
533
+ async function audit(dependencies) {
534
+ const auditName = dependencies.arguments[0];
535
+ if (auditName) {
536
+ return addAudit(auditName, dependencies);
527
537
  }
528
- return null;
538
+ return listAudits(dependencies);
529
539
  }
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
- }
540
+
541
+ // lib/cli/commands/bucket.ts
542
+ import { spawn as nodeSpawn3 } from "node:child_process";
543
+ import { accessSync } from "node:fs";
544
+ import { chmod, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
545
+ import { createServer as httpCreateServer } from "node:http";
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);
1555
+ appendLogLine(repoState.logBuffer, createLogLine(`Stopped loop for ${repoName}`, "stdout"));
1072
1556
  }
1073
- async function runConfiguredChecksSerially(checks, cwd, runner) {
1074
- const results = [];
1075
- for (const check of checks) {
1076
- results.push(await runSingleCheck(check, cwd, runner));
1557
+ async function addRepository(repository, manager, repoDeps, context) {
1558
+ if (manager.repositories.has(repository.name)) {
1559
+ return;
1077
1560
  }
1078
- return results;
1079
- }
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
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
+ let resolvedPort = 0;
2075
+ const server = httpCreateServer(async (nodeRequest, nodeResponse) => {
2076
+ const url = new URL(nodeRequest.url ?? "/", `http://localhost:${resolvedPort}`);
2077
+ const request = new Request(url.toString(), {
2078
+ method: nodeRequest.method ?? "GET"
2079
+ });
2080
+ const response = handler(request);
2081
+ const body = await response.text();
2082
+ nodeResponse.writeHead(response.status, {
2083
+ "Content-Type": response.headers.get("content-type") ?? "text/plain"
2084
+ });
2085
+ nodeResponse.end(body);
2086
+ });
2087
+ server.listen(0, () => {
2088
+ const addr2 = server.address();
2089
+ if (addr2 && typeof addr2 === "object") {
2090
+ resolvedPort = addr2.port;
2091
+ }
2092
+ });
2093
+ const addr = server.address();
2094
+ if (addr && typeof addr === "object") {
2095
+ resolvedPort = addr.port;
2096
+ }
2097
+ return { port: resolvedPort, stop: () => server.close() };
2098
+ }
2099
+ function defaultOpenBrowser(url) {
2100
+ const cmd = process.platform === "darwin" ? "open" : "xdg-open";
2101
+ nodeSpawn3(cmd, [url], { stdio: "ignore", detached: true }).unref();
2102
+ }
2103
+ function createDefaultBucketDependencies() {
2104
+ const authFileSystem = {
2105
+ exists: (path) => {
2106
+ try {
2107
+ accessSync(path);
2108
+ return true;
2109
+ } catch {
2110
+ return false;
2111
+ }
2112
+ },
2113
+ readFile: (path) => readFile(path, "utf8"),
2114
+ writeFile: (path, content) => writeFile(path, content, "utf8"),
2115
+ mkdir: (path, options) => mkdir(path, options).then(() => {}),
2116
+ readdir: (path) => readdir(path),
2117
+ chmod: (path, mode) => chmod(path, mode)
2118
+ };
2119
+ return {
2120
+ spawn: nodeSpawn3,
2121
+ createWebSocket: defaultCreateWebSocket,
2122
+ setupKeypress: defaultSetupKeypress,
2123
+ setupSignals: defaultSetupSignals,
2124
+ setupResize: defaultSetupResize,
2125
+ getTerminalSize: defaultGetTerminalSize,
2126
+ writeStdout: defaultWriteStdout,
2127
+ isTTY: process.stdout.isTTY ?? false,
2128
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
2129
+ getTempDir: () => tmpdir(),
2130
+ auth: {
2131
+ createServer: defaultCreateServer,
2132
+ openBrowser: defaultOpenBrowser,
2133
+ getHomeDir: () => homedir(),
2134
+ fileSystem: authFileSystem
2135
+ }
2136
+ };
2137
+ }
2138
+ function createInitialState() {
2139
+ const sessionId = crypto.randomUUID();
2140
+ const systemBuffer = createLogBuffer();
2141
+ const state = {
2142
+ ws: null,
2143
+ repositories: new Map,
2144
+ reconnectDelay: INITIAL_RECONNECT_DELAY_MS,
2145
+ reconnectTimer: null,
2146
+ shuttingDown: false,
2147
+ sessionId,
2148
+ emit: () => {},
2149
+ sendEvent: () => {},
2150
+ ui: createTerminalUIState(),
2151
+ logBuffers: new Map
2152
+ };
2153
+ state.sendEvent = createEventMessageSender(() => state.ws);
2154
+ state.logBuffers.set("system", systemBuffer);
2155
+ addRepository2(state.ui, "system", systemBuffer);
2156
+ return state;
2157
+ }
2158
+ function getWebSocketUrl() {
2159
+ return process.env.DUST_BUCKET_AGENT_CONNECT_URL || DEFAULT_DUSTBUCKET_WS_URL;
2160
+ }
2161
+ function toRepositoryDependencies(bucketDeps, fileSystem) {
2162
+ return {
2163
+ spawn: bucketDeps.spawn,
2164
+ run,
2165
+ fileSystem,
2166
+ sleep: bucketDeps.sleep,
2167
+ getTempDir: bucketDeps.getTempDir
2168
+ };
2169
+ }
2170
+ function syncUIWithRepoList(state, repos) {
2171
+ const incomingNames = new Set;
2172
+ for (const data of repos) {
2173
+ const repo = parseRepository(data);
2174
+ if (repo) {
2175
+ incomingNames.add(repo.name);
2176
+ if (!state.ui.repositories.includes(repo.name)) {
2177
+ let buffer = state.logBuffers.get(repo.name);
2178
+ if (!buffer) {
2179
+ buffer = createLogBuffer();
2180
+ state.logBuffers.set(repo.name, buffer);
2181
+ }
2182
+ addRepository2(state.ui, repo.name, buffer);
2183
+ }
2184
+ }
2185
+ }
2186
+ for (const name of [...state.ui.repositories]) {
2187
+ if (name !== "system" && !incomingNames.has(name)) {
2188
+ state.logBuffers.delete(name);
2189
+ removeRepository2(state.ui, name);
2190
+ }
2191
+ }
2192
+ }
2193
+ function syncTUI(state) {
2194
+ const currentUIRepos = new Set(state.ui.repositories);
2195
+ const currentRepos = new Set(state.repositories.keys());
2196
+ for (const [name, repoState] of state.repositories) {
2197
+ state.logBuffers.set(name, repoState.logBuffer);
2198
+ addRepository2(state.ui, name, repoState.logBuffer);
2199
+ }
2200
+ for (const name of currentUIRepos) {
2201
+ if (name !== "system" && !currentRepos.has(name)) {
2202
+ state.logBuffers.delete(name);
2203
+ removeRepository2(state.ui, name);
2204
+ }
2205
+ }
2206
+ }
2207
+ function logMessage(state, context, useTUI, message, stream = "stdout") {
2208
+ if (useTUI) {
2209
+ const systemBuffer = state.logBuffers.get("system");
2210
+ if (!systemBuffer)
2211
+ return;
2212
+ appendLogLine(systemBuffer, createLogLine(message, stream));
2213
+ } else if (stream === "stderr") {
2214
+ context.stderr(message);
2215
+ } else {
2216
+ context.stdout(message);
1265
2217
  }
1266
- if (!process.stdout.isTTY) {
1267
- return true;
2218
+ }
2219
+ function createTUIContext(state, context, useTUI) {
2220
+ if (!useTUI)
2221
+ return context;
2222
+ return {
2223
+ ...context,
2224
+ stdout: (message) => logMessage(state, context, true, message, "stdout"),
2225
+ stderr: (message) => logMessage(state, context, true, message, "stderr")
2226
+ };
2227
+ }
2228
+ function waitForConnection(token, bucketDeps) {
2229
+ const wsUrl = getWebSocketUrl();
2230
+ const ws = bucketDeps.createWebSocket(wsUrl, token);
2231
+ return new Promise((resolve, reject) => {
2232
+ ws.onopen = () => resolve(ws);
2233
+ ws.onerror = (error) => reject(new Error(error.message));
2234
+ ws.onclose = (event) => reject(new Error(`Connection closed (code ${event.code})`));
2235
+ });
2236
+ }
2237
+ function connectWebSocket(token, state, bucketDependencies, context, fileSystem, useTUI, connectedWs) {
2238
+ if (state.shuttingDown)
2239
+ return;
2240
+ const wsUrl = getWebSocketUrl();
2241
+ let ws;
2242
+ if (connectedWs) {
2243
+ ws = connectedWs;
2244
+ state.ws = ws;
2245
+ state.emit({ type: "bucket.connected" });
2246
+ logMessage(state, context, useTUI, formatBucketEvent({ type: "bucket.connected" }));
2247
+ state.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
2248
+ } else {
2249
+ logMessage(state, context, useTUI, `Connecting to ${wsUrl}...`);
2250
+ ws = bucketDependencies.createWebSocket(wsUrl, token);
2251
+ state.ws = ws;
2252
+ ws.onopen = () => {
2253
+ state.emit({ type: "bucket.connected" });
2254
+ logMessage(state, context, useTUI, formatBucketEvent({ type: "bucket.connected" }));
2255
+ state.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
2256
+ };
1268
2257
  }
1269
- return false;
2258
+ ws.onclose = (event) => {
2259
+ const disconnectEvent = {
2260
+ type: "bucket.disconnected",
2261
+ code: event.code,
2262
+ reason: event.reason || "none"
2263
+ };
2264
+ state.emit(disconnectEvent);
2265
+ logMessage(state, context, useTUI, formatBucketEvent(disconnectEvent));
2266
+ state.ws = null;
2267
+ if (!state.shuttingDown) {
2268
+ logMessage(state, context, useTUI, `Reconnecting in ${state.reconnectDelay / 1000} seconds...`);
2269
+ state.reconnectTimer = setTimeout(() => {
2270
+ connectWebSocket(token, state, bucketDependencies, context, fileSystem, useTUI);
2271
+ }, state.reconnectDelay);
2272
+ state.reconnectDelay = Math.min(state.reconnectDelay * 2, MAX_RECONNECT_DELAY_MS);
2273
+ }
2274
+ };
2275
+ ws.onerror = (error) => {
2276
+ logMessage(state, context, useTUI, `WebSocket error: ${error.message}`, "stderr");
2277
+ };
2278
+ ws.onmessage = (event) => {
2279
+ try {
2280
+ const message = JSON.parse(event.data);
2281
+ if (message.type === "repository-list") {
2282
+ const repos = message.repositories ?? [];
2283
+ logMessage(state, context, useTUI, `Received repository list (${repos.length} repositories)`);
2284
+ syncUIWithRepoList(state, repos);
2285
+ const repoDeps = toRepositoryDependencies(bucketDependencies, fileSystem);
2286
+ const repoContext = createTUIContext(state, context, useTUI);
2287
+ handleRepositoryList(repos, state, repoDeps, repoContext).then(() => syncTUI(state)).catch((error) => {
2288
+ logMessage(state, context, useTUI, `Failed to handle repository list: ${error.message}`, "stderr");
2289
+ });
2290
+ }
2291
+ } catch {
2292
+ logMessage(state, context, useTUI, `Failed to parse WebSocket message: ${event.data}`, "stderr");
2293
+ }
2294
+ };
1270
2295
  }
1271
- function getColors() {
1272
- return shouldDisableColors() ? NO_COLORS : ANSI_COLORS;
2296
+ async function shutdown(state, bucketDeps, context) {
2297
+ if (state.shuttingDown)
2298
+ return;
2299
+ state.shuttingDown = true;
2300
+ context.stdout("Shutting down...");
2301
+ if (state.reconnectTimer) {
2302
+ clearTimeout(state.reconnectTimer);
2303
+ state.reconnectTimer = null;
2304
+ }
2305
+ if (state.ws && state.ws.readyState === WS_OPEN) {
2306
+ state.ws.close();
2307
+ state.ws = null;
2308
+ }
2309
+ for (const repoState of state.repositories.values()) {
2310
+ repoState.stopRequested = true;
2311
+ }
2312
+ const loopPromises = Array.from(state.repositories.values()).map((rs) => rs.loopPromise).filter((p) => p !== null);
2313
+ await Promise.all(loopPromises.map((p) => p.catch(() => {})));
2314
+ for (const repoState of state.repositories.values()) {
2315
+ await removeRepository(repoState.path, bucketDeps.spawn, context);
2316
+ }
2317
+ state.repositories.clear();
2318
+ }
2319
+ function setupTUI(state, bucketDeps) {
2320
+ const { width, height } = bucketDeps.getTerminalSize();
2321
+ updateDimensions(state.ui, width, height);
2322
+ bucketDeps.writeStdout(enterAlternateScreen());
2323
+ const cleanupResize = bucketDeps.setupResize((w, h) => {
2324
+ updateDimensions(state.ui, w, h);
2325
+ });
2326
+ const renderInterval = setInterval(() => {
2327
+ if (!state.shuttingDown) {
2328
+ bucketDeps.writeStdout(renderFrame(state.ui));
2329
+ }
2330
+ }, 100);
2331
+ return {
2332
+ cleanup: () => {
2333
+ clearInterval(renderInterval);
2334
+ bucketDeps.writeStdout(exitAlternateScreen());
2335
+ cleanupResize();
2336
+ }
2337
+ };
1273
2338
  }
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 });
2339
+ function createKeypressHandler(useTUI, state, onQuit) {
2340
+ if (useTUI) {
2341
+ return (key) => {
2342
+ const shouldQuit = handleKeyInput(state.ui, key);
2343
+ if (shouldQuit)
2344
+ onQuit();
2345
+ };
1283
2346
  }
1284
- return { dustCommand, checks };
2347
+ return (key) => {
2348
+ if (key === "q" || key === "\x03")
2349
+ onQuit();
2350
+ };
1285
2351
  }
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 });
2352
+ async function resolveToken(commandArgs, authDeps, context) {
2353
+ if (commandArgs[0]) {
2354
+ return commandArgs[0];
1299
2355
  }
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
- }
2356
+ const stored = await loadStoredToken(authDeps.fileSystem, authDeps.getHomeDir());
2357
+ if (stored) {
2358
+ return stored;
1308
2359
  }
2360
+ context.stdout("Opening browser to authenticate with dustbucket...");
1309
2361
  try {
1310
- const settings = generateSettings(context.cwd, fileSystem);
1311
- await fileSystem.writeFile(`${dustPath}/config/settings.json`, `${JSON.stringify(settings, null, 2)}
1312
- `, { flag: "wx" });
2362
+ const token = await authenticate(authDeps);
2363
+ await storeToken(authDeps.fileSystem, authDeps.getHomeDir(), token);
2364
+ context.stdout("Authenticated successfully");
2365
+ return token;
1313
2366
  } catch (error) {
1314
- if (error.code !== "EEXIST") {
1315
- throw error;
1316
- }
2367
+ context.stderr(`Authentication failed: ${error.message}`);
2368
+ return null;
1317
2369
  }
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`);
2370
+ }
2371
+ async function bucket(dependencies, bucketDeps = createDefaultBucketDependencies()) {
2372
+ const { arguments: commandArgs, context, fileSystem } = dependencies;
2373
+ const token = await resolveToken(commandArgs, bucketDeps.auth, context);
2374
+ if (!token) {
2375
+ return { exitCode: 1 };
1325
2376
  }
1326
- const claudeMdPath = `${context.cwd}/CLAUDE.md`;
2377
+ const wsUrl = getWebSocketUrl();
2378
+ context.stdout(`Connecting to ${wsUrl}...`);
2379
+ let initialWs;
1327
2380
  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`);
2381
+ initialWs = await waitForConnection(token, bucketDeps);
1331
2382
  } 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}`);
2383
+ if (error.message.includes("1008") || error.message.includes("401")) {
2384
+ context.stderr("Token rejected. Clearing stored credentials...");
2385
+ await clearToken(bucketDeps.auth.fileSystem, bucketDeps.auth.getHomeDir());
2386
+ context.stderr("Run `dust bucket` again to re-authenticate.");
1334
2387
  } else {
1335
- throw error;
2388
+ context.stderr(`Failed to connect: ${error.message}`);
1336
2389
  }
2390
+ return { exitCode: 1 };
1337
2391
  }
1338
- const agentsMdPath = `${context.cwd}/AGENTS.md`;
2392
+ context.stdout("Connected");
2393
+ const state = createInitialState();
2394
+ const useTUI = bucketDeps.isTTY;
1339
2395
  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
- }
2396
+ state.ui.connectedHost = new URL(wsUrl).hostname;
2397
+ } catch {
2398
+ state.ui.connectedHost = wsUrl;
1349
2399
  }
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"`);
2400
+ let tuiHandle;
2401
+ let cleanupKeypress;
2402
+ let cleanupSignals;
2403
+ try {
2404
+ if (useTUI) {
2405
+ tuiHandle = setupTUI(state, bucketDeps);
2406
+ }
2407
+ await new Promise((resolve) => {
2408
+ const doShutdown = async () => {
2409
+ await shutdown(state, bucketDeps, context);
2410
+ resolve();
2411
+ };
2412
+ const onKey = createKeypressHandler(useTUI, state, () => {
2413
+ doShutdown();
2414
+ });
2415
+ cleanupKeypress = bucketDeps.setupKeypress(onKey);
2416
+ cleanupSignals = bucketDeps.setupSignals(() => {
2417
+ doShutdown();
2418
+ });
2419
+ connectWebSocket(token, state, bucketDeps, context, fileSystem, useTUI, initialWs);
2420
+ if (!useTUI) {
2421
+ context.stdout(" Press q or Ctrl+C to exit");
2422
+ }
2423
+ });
2424
+ } finally {
2425
+ tuiHandle?.cleanup();
2426
+ cleanupKeypress?.();
2427
+ cleanupSignals?.();
2428
+ }
2429
+ context.stdout("Goodbye!");
1360
2430
  return { exitCode: 0 };
1361
2431
  }
1362
2432
 
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);
2433
+ // lib/cli/process-runner.ts
2434
+ import { spawn } from "node:child_process";
2435
+ function createShellRunner(spawnFn) {
2436
+ return {
2437
+ run: (command, cwd, timeoutMs) => runBufferedProcess(spawnFn, command, [], cwd, true, timeoutMs)
2438
+ };
2439
+ }
2440
+ var defaultShellRunner = createShellRunner(spawn);
2441
+ function createGitRunner(spawnFn) {
2442
+ return {
2443
+ run: (gitArguments, cwd) => runBufferedProcess(spawnFn, "git", gitArguments, cwd, false)
2444
+ };
2445
+ }
2446
+ var defaultGitRunner = createGitRunner(spawn);
2447
+ function runBufferedProcess(spawnFn, command, commandArguments, cwd, shell, timeoutMs) {
2448
+ return new Promise((resolve) => {
2449
+ const proc = spawnFn(command, commandArguments, { cwd, shell });
2450
+ const chunks = [];
2451
+ let resolved = false;
2452
+ let timer;
2453
+ if (timeoutMs !== undefined) {
2454
+ timer = setTimeout(() => {
2455
+ resolved = true;
2456
+ proc.kill();
2457
+ resolve({
2458
+ exitCode: 1,
2459
+ output: chunks.join(""),
2460
+ timedOut: true
2461
+ });
2462
+ }, timeoutMs);
2463
+ }
2464
+ proc.stdout?.on("data", (data) => {
2465
+ chunks.push(data.toString());
2466
+ });
2467
+ proc.stderr?.on("data", (data) => {
2468
+ chunks.push(data.toString());
2469
+ });
2470
+ proc.on("close", (code) => {
2471
+ if (resolved)
2472
+ return;
2473
+ if (timer !== undefined)
2474
+ clearTimeout(timer);
2475
+ resolve({ exitCode: code ?? 1, output: chunks.join("") });
2476
+ });
2477
+ proc.on("error", (error) => {
2478
+ if (resolved)
2479
+ return;
2480
+ if (timer !== undefined)
2481
+ clearTimeout(timer);
2482
+ resolve({ exitCode: 1, output: error.message });
2483
+ });
2484
+ });
2485
+ }
2486
+
2487
+ // lib/cli/commands/lint-markdown.ts
2488
+ import { dirname as dirname3, resolve } from "node:path";
2489
+
2490
+ // lib/workflow-tasks.ts
2491
+ var IDEA_TRANSITION_PREFIXES = [
2492
+ "Refine Idea: ",
2493
+ "Decompose Idea: ",
2494
+ "Shelve Idea: "
2495
+ ];
2496
+ function titleToFilename(title) {
2497
+ return `${title.toLowerCase().replace(/\./g, "-").replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "")}.md`;
2498
+ }
2499
+
2500
+ // lib/cli/commands/lint-markdown.ts
2501
+ var REQUIRED_HEADINGS = ["## Goals", "## Blocked By", "## Definition of Done"];
2502
+ var REQUIRED_GOAL_HEADINGS = ["## Parent Goal", "## Sub-Goals"];
2503
+ var SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*\.md$/;
2504
+ var MAX_OPENING_SENTENCE_LENGTH = 150;
2505
+ function validateFilename(filePath) {
2506
+ const parts = filePath.split("/");
2507
+ const filename = parts[parts.length - 1];
2508
+ if (!SLUG_PATTERN.test(filename)) {
2509
+ return {
2510
+ file: filePath,
2511
+ message: `Filename "${filename}" does not match slug-style naming`
2512
+ };
1389
2513
  }
1390
- const relMap = new Map;
1391
- for (const rel of relationships) {
1392
- relMap.set(rel.filePath, rel);
2514
+ return null;
2515
+ }
2516
+ function validateTitleFilenameMatch(filePath, content) {
2517
+ const title = extractTitle(content);
2518
+ if (!title) {
2519
+ return null;
2520
+ }
2521
+ const parts = filePath.split("/");
2522
+ const actualFilename = parts[parts.length - 1];
2523
+ const expectedFilename = titleToFilename(title);
2524
+ if (actualFilename !== expectedFilename) {
2525
+ return {
2526
+ file: filePath,
2527
+ message: `Filename "${actualFilename}" does not match title "${title}" (expected "${expectedFilename}")`
2528
+ };
2529
+ }
2530
+ return null;
2531
+ }
2532
+ function validateOpeningSentence(filePath, content) {
2533
+ const openingSentence = extractOpeningSentence(content);
2534
+ if (!openingSentence) {
2535
+ return {
2536
+ file: filePath,
2537
+ message: "Missing or malformed opening sentence after H1 heading"
2538
+ };
2539
+ }
2540
+ return null;
2541
+ }
2542
+ function validateOpeningSentenceLength(filePath, content) {
2543
+ const openingSentence = extractOpeningSentence(content);
2544
+ if (!openingSentence) {
2545
+ return null;
2546
+ }
2547
+ if (openingSentence.length > MAX_OPENING_SENTENCE_LENGTH) {
2548
+ return {
2549
+ file: filePath,
2550
+ message: `Opening sentence is ${openingSentence.length} characters (max ${MAX_OPENING_SENTENCE_LENGTH}). Split into multiple sentences; only the first sentence is checked.`
2551
+ };
2552
+ }
2553
+ return null;
2554
+ }
2555
+ var NON_IMPERATIVE_STARTERS = new Set([
2556
+ "the",
2557
+ "a",
2558
+ "an",
2559
+ "this",
2560
+ "that",
2561
+ "these",
2562
+ "those",
2563
+ "we",
2564
+ "it",
2565
+ "they",
2566
+ "you",
2567
+ "i"
2568
+ ]);
2569
+ function validateImperativeOpeningSentence(filePath, content) {
2570
+ const openingSentence = extractOpeningSentence(content);
2571
+ if (!openingSentence) {
2572
+ return null;
1393
2573
  }
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
- }
2574
+ const firstWord = openingSentence.split(/\s/)[0].replace(/[^a-zA-Z]/g, "");
2575
+ const lower = firstWord.toLowerCase();
2576
+ if (NON_IMPERATIVE_STARTERS.has(lower) || lower.endsWith("ing")) {
2577
+ const preview = openingSentence.length > 40 ? `${openingSentence.slice(0, 40)}...` : openingSentence;
1403
2578
  return {
1404
- filePath,
1405
- title: titleMap.get(filePath) || basename(filePath, ".md"),
1406
- children
2579
+ file: filePath,
2580
+ message: `Opening sentence should use imperative form (e.g., "Add X" not "This adds X"). Found: "${preview}"`
1407
2581
  };
1408
2582
  }
1409
- return rootGoals.map((rel) => buildNode(rel.filePath));
2583
+ return null;
1410
2584
  }
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);
2585
+ function validateTaskHeadings(filePath, content) {
2586
+ const violations = [];
2587
+ for (const heading of REQUIRED_HEADINGS) {
2588
+ if (!content.includes(heading)) {
2589
+ violations.push({
2590
+ file: filePath,
2591
+ message: `Missing required heading: "${heading}"`
2592
+ });
1420
2593
  }
1421
2594
  }
2595
+ return violations;
1422
2596
  }
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("");
2597
+ function validateLinks(filePath, content, fileSystem) {
2598
+ const violations = [];
2599
+ const lines = content.split(`
2600
+ `);
2601
+ const fileDir = dirname3(filePath);
2602
+ for (let i = 0;i < lines.length; i++) {
2603
+ const line = lines[i];
2604
+ const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
2605
+ let match = linkPattern.exec(line);
2606
+ while (match) {
2607
+ const linkTarget = match[2];
2608
+ if (!linkTarget.startsWith("http://") && !linkTarget.startsWith("https://") && !linkTarget.startsWith("#")) {
2609
+ const targetPath = linkTarget.split("#")[0];
2610
+ const resolvedPath = resolve(fileDir, targetPath);
2611
+ if (!fileSystem.exists(resolvedPath)) {
2612
+ violations.push({
2613
+ file: filePath,
2614
+ message: `Broken link: "${linkTarget}"`,
2615
+ line: i + 1
2616
+ });
2617
+ }
1452
2618
  }
1453
- continue;
2619
+ match = linkPattern.exec(line);
1454
2620
  }
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
- }
2621
+ }
2622
+ return violations;
2623
+ }
2624
+ function validateIdeaOpenQuestions(filePath, content) {
2625
+ const violations = [];
2626
+ const lines = content.split(`
2627
+ `);
2628
+ let inOpenQuestions = false;
2629
+ let currentQuestionLine = null;
2630
+ let inCodeBlock = false;
2631
+ for (let i = 0;i < lines.length; i++) {
2632
+ const line = lines[i];
2633
+ if (line.startsWith("```")) {
2634
+ inCodeBlock = !inCodeBlock;
2635
+ continue;
1466
2636
  }
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}`);
2637
+ if (inCodeBlock)
2638
+ continue;
2639
+ if (line.startsWith("## ")) {
2640
+ if (inOpenQuestions && currentQuestionLine !== null) {
2641
+ violations.push({
2642
+ file: filePath,
2643
+ message: "Question has no options listed beneath it",
2644
+ line: currentQuestionLine
2645
+ });
1477
2646
  }
1478
- if (openingSentence) {
1479
- context.stdout(`${colors.dim}${openingSentence}${colors.reset}`);
2647
+ const headingText = line.slice(3).trimEnd();
2648
+ if (headingText.toLowerCase() === "open questions" && headingText !== "Open Questions") {
2649
+ violations.push({
2650
+ file: filePath,
2651
+ message: `Heading "${line.trimEnd()}" should be "## Open Questions"`,
2652
+ line: i + 1
2653
+ });
1480
2654
  }
1481
- context.stdout(`${colors.cyan}→ ${relativePath}${colors.reset}`);
1482
- context.stdout("");
2655
+ inOpenQuestions = line === "## Open Questions";
2656
+ currentQuestionLine = null;
2657
+ continue;
1483
2658
  }
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())
2659
+ if (!inOpenQuestions)
2660
+ continue;
2661
+ if (/^[-*] /.test(line.trimStart())) {
2662
+ violations.push({
2663
+ file: filePath,
2664
+ message: "Open Questions must use ### headings for questions and #### headings for options, not bullet points. Run `dust new idea` to see the expected format.",
2665
+ line: i + 1
2666
+ });
2667
+ continue;
2668
+ }
2669
+ if (line.startsWith("### ")) {
2670
+ if (currentQuestionLine !== null) {
2671
+ violations.push({
2672
+ file: filePath,
2673
+ message: "Question has no options listed beneath it",
2674
+ line: currentQuestionLine
2675
+ });
2676
+ }
2677
+ if (!line.trimEnd().endsWith("?")) {
2678
+ violations.push({
2679
+ file: filePath,
2680
+ message: 'Questions must end with "?" (e.g., "### Should we take our own payments?")',
2681
+ line: i + 1
2682
+ });
2683
+ currentQuestionLine = null;
2684
+ } else {
2685
+ currentQuestionLine = i + 1;
2686
+ }
1546
2687
  continue;
1547
- try {
1548
- yield JSON.parse(line);
1549
- } catch {}
2688
+ }
2689
+ if (line.startsWith("#### ")) {
2690
+ currentQuestionLine = null;
2691
+ }
1550
2692
  }
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}`));
2693
+ if (inOpenQuestions && currentQuestionLine !== null) {
2694
+ violations.push({
2695
+ file: filePath,
2696
+ message: "Question has no options listed beneath it",
2697
+ line: currentQuestionLine
1557
2698
  });
1558
- proc.on("error", reject);
1559
- });
2699
+ }
2700
+ return violations;
1560
2701
  }
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 };
2702
+ var SEMANTIC_RULES = [
2703
+ {
2704
+ section: "## Goals",
2705
+ requiredPath: "/.dust/goals/",
2706
+ description: "goal"
2707
+ },
2708
+ {
2709
+ section: "## Blocked By",
2710
+ requiredPath: "/.dust/tasks/",
2711
+ description: "task"
2712
+ }
2713
+ ];
2714
+ function validateSemanticLinks(filePath, content) {
2715
+ const violations = [];
2716
+ const lines = content.split(`
2717
+ `);
2718
+ const fileDir = dirname3(filePath);
2719
+ let currentSection = null;
2720
+ for (let i = 0;i < lines.length; i++) {
2721
+ const line = lines[i];
2722
+ if (line.startsWith("## ")) {
2723
+ currentSection = line;
2724
+ continue;
1568
2725
  }
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
- };
2726
+ const rule = SEMANTIC_RULES.find((r) => r.section === currentSection);
2727
+ if (!rule)
2728
+ continue;
2729
+ const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
2730
+ let match = linkPattern.exec(line);
2731
+ while (match) {
2732
+ const linkTarget = match[2];
2733
+ if (linkTarget.startsWith("#")) {
2734
+ violations.push({
2735
+ file: filePath,
2736
+ message: `Link in "${rule.section}" must point to a ${rule.description} file, not an anchor: "${linkTarget}"`,
2737
+ line: i + 1
2738
+ });
2739
+ match = linkPattern.exec(line);
2740
+ continue;
2741
+ }
2742
+ if (linkTarget.startsWith("http://") || linkTarget.startsWith("https://")) {
2743
+ violations.push({
2744
+ file: filePath,
2745
+ message: `Link in "${rule.section}" must point to a ${rule.description} file, not an external URL: "${linkTarget}"`,
2746
+ line: i + 1
2747
+ });
2748
+ match = linkPattern.exec(line);
2749
+ continue;
2750
+ }
2751
+ const targetPath = linkTarget.split("#")[0];
2752
+ const resolvedPath = resolve(fileDir, targetPath);
2753
+ if (!resolvedPath.includes(rule.requiredPath)) {
2754
+ violations.push({
2755
+ file: filePath,
2756
+ message: `Link in "${rule.section}" must point to a ${rule.description} file: "${linkTarget}"`,
2757
+ line: i + 1
2758
+ });
1581
2759
  }
2760
+ match = linkPattern.exec(line);
1582
2761
  }
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)
2762
+ }
2763
+ return violations;
2764
+ }
2765
+ function validateIdeaTransitionTitle(filePath, content, ideasPath, fileSystem) {
2766
+ const title = extractTitle(content);
2767
+ if (!title) {
2768
+ return null;
2769
+ }
2770
+ for (const prefix of IDEA_TRANSITION_PREFIXES) {
2771
+ if (title.startsWith(prefix)) {
2772
+ const ideaTitle = title.slice(prefix.length);
2773
+ const ideaFilename = titleToFilename(ideaTitle);
2774
+ if (!fileSystem.exists(`${ideasPath}/${ideaFilename}`)) {
2775
+ return {
2776
+ file: filePath,
2777
+ message: `Idea transition task references non-existent idea: "${ideaTitle}" (expected file "${ideaFilename}" in ideas/)`
1591
2778
  };
1592
2779
  }
2780
+ return null;
1593
2781
  }
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
2782
  }
2783
+ return null;
1607
2784
  }
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);
2785
+ function validateGoalHierarchySections(filePath, content) {
2786
+ const violations = [];
2787
+ for (const heading of REQUIRED_GOAL_HEADINGS) {
2788
+ if (!content.includes(heading)) {
2789
+ violations.push({
2790
+ file: filePath,
2791
+ message: `Missing required heading: "${heading}"`
2792
+ });
1622
2793
  }
1623
2794
  }
1624
- lines.push(DIVIDER);
1625
- appendOtherArgs(lines, others);
1626
- return lines;
2795
+ return violations;
1627
2796
  }
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);
2797
+ function validateGoalHierarchyLinks(filePath, content) {
2798
+ const violations = [];
2799
+ const lines = content.split(`
2800
+ `);
2801
+ const fileDir = dirname3(filePath);
2802
+ let currentSection = null;
2803
+ for (let i = 0;i < lines.length; i++) {
2804
+ const line = lines[i];
2805
+ if (line.startsWith("## ")) {
2806
+ currentSection = line;
2807
+ continue;
2808
+ }
2809
+ if (currentSection !== "## Parent Goal" && currentSection !== "## Sub-Goals") {
2810
+ continue;
2811
+ }
2812
+ const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
2813
+ let match = linkPattern.exec(line);
2814
+ while (match) {
2815
+ const linkTarget = match[2];
2816
+ if (linkTarget.startsWith("#")) {
2817
+ violations.push({
2818
+ file: filePath,
2819
+ message: `Link in "${currentSection}" must point to a goal file, not an anchor: "${linkTarget}"`,
2820
+ line: i + 1
2821
+ });
2822
+ match = linkPattern.exec(line);
2823
+ continue;
2824
+ }
2825
+ if (linkTarget.startsWith("http://") || linkTarget.startsWith("https://")) {
2826
+ violations.push({
2827
+ file: filePath,
2828
+ message: `Link in "${currentSection}" must point to a goal file, not an external URL: "${linkTarget}"`,
2829
+ line: i + 1
2830
+ });
2831
+ match = linkPattern.exec(line);
2832
+ continue;
2833
+ }
2834
+ const targetPath = linkTarget.split("#")[0];
2835
+ const resolvedPath = resolve(fileDir, targetPath);
2836
+ if (!resolvedPath.includes("/.dust/goals/")) {
2837
+ violations.push({
2838
+ file: filePath,
2839
+ message: `Link in "${currentSection}" must point to a goal file: "${linkTarget}"`,
2840
+ line: i + 1
2841
+ });
2842
+ }
2843
+ match = linkPattern.exec(line);
1646
2844
  }
1647
2845
  }
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);
2846
+ return violations;
2847
+ }
2848
+ function extractGoalRelationships(filePath, content) {
2849
+ const lines = content.split(`
2850
+ `);
2851
+ const fileDir = dirname3(filePath);
2852
+ const parentGoals = [];
2853
+ const subGoals = [];
2854
+ let currentSection = null;
2855
+ for (const line of lines) {
2856
+ if (line.startsWith("## ")) {
2857
+ currentSection = line;
2858
+ continue;
2859
+ }
2860
+ if (currentSection !== "## Parent Goal" && currentSection !== "## Sub-Goals") {
2861
+ continue;
2862
+ }
2863
+ const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
2864
+ let match = linkPattern.exec(line);
2865
+ while (match) {
2866
+ const linkTarget = match[2];
2867
+ if (!linkTarget.startsWith("#") && !linkTarget.startsWith("http://") && !linkTarget.startsWith("https://")) {
2868
+ const targetPath = linkTarget.split("#")[0];
2869
+ const resolvedPath = resolve(fileDir, targetPath);
2870
+ if (resolvedPath.includes("/.dust/goals/")) {
2871
+ if (currentSection === "## Parent Goal") {
2872
+ parentGoals.push(resolvedPath);
2873
+ } else {
2874
+ subGoals.push(resolvedPath);
2875
+ }
2876
+ }
2877
+ }
2878
+ match = linkPattern.exec(line);
1655
2879
  }
1656
2880
  }
1657
- lines.push(DIVIDER);
1658
- appendOtherArgs(lines, others);
1659
- return lines;
2881
+ return { filePath, parentGoals, subGoals };
1660
2882
  }
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})`;
2883
+ function validateBidirectionalLinks(allGoalRelationships) {
2884
+ const violations = [];
2885
+ const relationshipMap = new Map;
2886
+ for (const rel of allGoalRelationships) {
2887
+ relationshipMap.set(rel.filePath, rel);
1672
2888
  }
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}`);
2889
+ for (const rel of allGoalRelationships) {
2890
+ for (const parentPath of rel.parentGoals) {
2891
+ const parentRel = relationshipMap.get(parentPath);
2892
+ if (parentRel && !parentRel.subGoals.includes(rel.filePath)) {
2893
+ violations.push({
2894
+ file: rel.filePath,
2895
+ message: `Parent goal "${parentPath}" does not list this goal as a sub-goal`
2896
+ });
2897
+ }
2898
+ }
2899
+ for (const subGoalPath of rel.subGoals) {
2900
+ const subGoalRel = relationshipMap.get(subGoalPath);
2901
+ if (subGoalRel && !subGoalRel.parentGoals.includes(rel.filePath)) {
2902
+ violations.push({
2903
+ file: rel.filePath,
2904
+ message: `Sub-goal "${subGoalPath}" does not list this goal as its parent`
2905
+ });
2906
+ }
2907
+ }
1693
2908
  }
1694
- appendOtherArgs(lines, others);
1695
- return lines;
2909
+ return violations;
1696
2910
  }
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}`);
2911
+ function validateNoCycles(allGoalRelationships) {
2912
+ const violations = [];
2913
+ const relationshipMap = new Map;
2914
+ for (const rel of allGoalRelationships) {
2915
+ relationshipMap.set(rel.filePath, rel);
2916
+ }
2917
+ for (const rel of allGoalRelationships) {
2918
+ const visited = new Set;
2919
+ const path = [];
2920
+ let current = rel.filePath;
2921
+ while (current) {
2922
+ if (visited.has(current)) {
2923
+ const cycleStart = path.indexOf(current);
2924
+ const cyclePath = path.slice(cycleStart).concat(current);
2925
+ violations.push({
2926
+ file: rel.filePath,
2927
+ message: `Cycle detected in goal hierarchy: ${cyclePath.join(" -> ")}`
2928
+ });
2929
+ break;
2930
+ }
2931
+ visited.add(current);
2932
+ path.push(current);
2933
+ const currentRel = relationshipMap.get(current);
2934
+ if (currentRel && currentRel.parentGoals.length > 0) {
2935
+ current = currentRel.parentGoals[0];
2936
+ } else {
2937
+ current = null;
2938
+ }
1707
2939
  }
1708
2940
  }
1709
- appendOtherArgs(lines, others);
1710
- return lines;
2941
+ return violations;
1711
2942
  }
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})`;
2943
+ async function safeScanDir(glob, dirPath) {
2944
+ const files = [];
2945
+ try {
2946
+ for await (const file of glob.scan(dirPath)) {
2947
+ files.push(file);
2948
+ }
2949
+ return { files, exists: true };
2950
+ } catch (error) {
2951
+ if (error.code === "ENOENT") {
2952
+ return { files: [], exists: false };
2953
+ }
2954
+ throw error;
1740
2955
  }
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
2956
  }
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}"`);
2957
+ async function lintMarkdown(dependencies) {
2958
+ const { context, fileSystem, globScanner: glob } = dependencies;
2959
+ const dustPath = `${context.cwd}/.dust`;
2960
+ const dustScan = await safeScanDir(glob, dustPath);
2961
+ if (!dustScan.exists) {
2962
+ context.stderr("Error: .dust directory not found");
2963
+ context.stderr("Run 'dust init' to initialize a Dust repository");
2964
+ return { exitCode: 1 };
1774
2965
  }
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);
2966
+ const dustFiles = dustScan.files;
2967
+ const violations = [];
2968
+ context.stdout("Validating links in .dust/...");
2969
+ for (const file of dustFiles) {
2970
+ if (!file.endsWith(".md"))
2971
+ continue;
2972
+ const filePath = `${dustPath}/${file}`;
2973
+ try {
2974
+ const content = await fileSystem.readFile(filePath);
2975
+ violations.push(...validateLinks(filePath, content, fileSystem));
2976
+ } catch (error) {
2977
+ if (error.code !== "ENOENT") {
2978
+ throw error;
2979
+ }
2980
+ }
1798
2981
  }
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];
2982
+ const contentDirs = ["goals", "facts", "ideas", "tasks"];
2983
+ context.stdout("Validating content files...");
2984
+ for (const dir of contentDirs) {
2985
+ const dirPath = `${dustPath}/${dir}`;
2986
+ const { files } = await safeScanDir(glob, dirPath);
2987
+ for (const file of files) {
2988
+ if (!file.endsWith(".md"))
2989
+ continue;
2990
+ const filePath = `${dirPath}/${file}`;
2991
+ let content;
2992
+ try {
2993
+ content = await fileSystem.readFile(filePath);
2994
+ } catch (error) {
2995
+ if (error.code === "ENOENT") {
2996
+ continue;
2997
+ }
2998
+ throw error;
2999
+ }
3000
+ const openingSentenceViolation = validateOpeningSentence(filePath, content);
3001
+ if (openingSentenceViolation) {
3002
+ violations.push(openingSentenceViolation);
3003
+ }
3004
+ const openingSentenceLengthViolation = validateOpeningSentenceLength(filePath, content);
3005
+ if (openingSentenceLengthViolation) {
3006
+ violations.push(openingSentenceLengthViolation);
3007
+ }
3008
+ const titleFilenameViolation = validateTitleFilenameMatch(filePath, content);
3009
+ if (titleFilenameViolation) {
3010
+ violations.push(titleFilenameViolation);
3011
+ }
1806
3012
  }
1807
3013
  }
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;
3014
+ const ideasPath = `${dustPath}/ideas`;
3015
+ const { files: ideaFiles } = await safeScanDir(glob, ideasPath);
3016
+ if (ideaFiles.length > 0) {
3017
+ context.stdout("Validating idea files in .dust/ideas/...");
3018
+ for (const file of ideaFiles) {
3019
+ if (!file.endsWith(".md"))
3020
+ continue;
3021
+ const filePath = `${ideasPath}/${file}`;
3022
+ let content;
3023
+ try {
3024
+ content = await fileSystem.readFile(filePath);
3025
+ } catch (error) {
3026
+ if (error.code === "ENOENT") {
3027
+ continue;
3028
+ }
3029
+ throw error;
1829
3030
  }
3031
+ violations.push(...validateIdeaOpenQuestions(filePath, content));
1830
3032
  }
1831
3033
  }
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("");
3034
+ const tasksPath = `${dustPath}/tasks`;
3035
+ const { files: taskFiles } = await safeScanDir(glob, tasksPath);
3036
+ if (taskFiles.length > 0) {
3037
+ context.stdout("Validating task files in .dust/tasks/...");
3038
+ for (const file of taskFiles) {
3039
+ if (!file.endsWith(".md"))
3040
+ continue;
3041
+ const filePath = `${tasksPath}/${file}`;
3042
+ let content;
3043
+ try {
3044
+ content = await fileSystem.readFile(filePath);
3045
+ } catch (error) {
3046
+ if (error.code === "ENOENT") {
3047
+ continue;
3048
+ }
3049
+ throw error;
1842
3050
  }
1843
- for (const line of formatToolUse(event.name, event.input)) {
1844
- sink.line(line);
3051
+ const filenameViolation = validateFilename(filePath);
3052
+ if (filenameViolation) {
3053
+ violations.push(filenameViolation);
1845
3054
  }
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;
3055
+ violations.push(...validateTaskHeadings(filePath, content));
3056
+ violations.push(...validateSemanticLinks(filePath, content));
3057
+ const imperativeViolation = validateImperativeOpeningSentence(filePath, content);
3058
+ if (imperativeViolation) {
3059
+ violations.push(imperativeViolation);
3060
+ }
3061
+ const ideaTransitionViolation = validateIdeaTransitionTitle(filePath, content, ideasPath, fileSystem);
3062
+ if (ideaTransitionViolation) {
3063
+ violations.push(ideaTransitionViolation);
3064
+ }
3065
+ }
3066
+ }
3067
+ const goalsPath = `${dustPath}/goals`;
3068
+ const { files: goalFiles } = await safeScanDir(glob, goalsPath);
3069
+ if (goalFiles.length > 0) {
3070
+ context.stdout("Validating goal hierarchy in .dust/goals/...");
3071
+ const allGoalRelationships = [];
3072
+ for (const file of goalFiles) {
3073
+ if (!file.endsWith(".md"))
3074
+ continue;
3075
+ const filePath = `${goalsPath}/${file}`;
3076
+ let content;
3077
+ try {
3078
+ content = await fileSystem.readFile(filePath);
3079
+ } catch (error) {
3080
+ if (error.code === "ENOENT") {
3081
+ continue;
3082
+ }
3083
+ throw error;
3084
+ }
3085
+ violations.push(...validateGoalHierarchySections(filePath, content));
3086
+ violations.push(...validateGoalHierarchyLinks(filePath, content));
3087
+ allGoalRelationships.push(extractGoalRelationships(filePath, content));
3088
+ }
3089
+ violations.push(...validateBidirectionalLinks(allGoalRelationships));
3090
+ violations.push(...validateNoCycles(allGoalRelationships));
3091
+ }
3092
+ if (violations.length === 0) {
3093
+ context.stdout("All validations passed!");
3094
+ return { exitCode: 0 };
3095
+ }
3096
+ context.stderr(`Found ${violations.length} violation(s):`);
3097
+ context.stderr("");
3098
+ for (const v of violations) {
3099
+ const location = v.line ? `:${v.line}` : "";
3100
+ context.stderr(` ${v.file}${location}`);
3101
+ context.stderr(` ${v.message}`);
1860
3102
  }
3103
+ return { exitCode: 1 };
1861
3104
  }
1862
- function createStdoutSink() {
3105
+
3106
+ // lib/cli/commands/check.ts
3107
+ var DEFAULT_CHECK_TIMEOUT_MS = 13000;
3108
+ async function runSingleCheck(check, cwd, runner) {
3109
+ const timeoutMs = check.timeoutMilliseconds ?? DEFAULT_CHECK_TIMEOUT_MS;
3110
+ const startTime = Date.now();
3111
+ const result = await runner.run(check.command, cwd, timeoutMs);
3112
+ const durationMs = Date.now() - startTime;
1863
3113
  return {
1864
- write: (text) => process.stdout.write(text),
1865
- line: (text) => console.log(text)
3114
+ name: check.name,
3115
+ command: check.command,
3116
+ exitCode: result.exitCode,
3117
+ output: result.output,
3118
+ hints: check.hints,
3119
+ durationMs,
3120
+ timedOut: result.timedOut,
3121
+ timeoutSeconds: timeoutMs / 1000
1866
3122
  };
1867
3123
  }
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);
3124
+ async function runConfiguredChecks(checks, cwd, runner) {
3125
+ const promises = checks.map((check) => runSingleCheck(check, cwd, runner));
3126
+ return Promise.all(promises);
1882
3127
  }
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);
3128
+ async function runConfiguredChecksSerially(checks, cwd, runner) {
3129
+ const results = [];
3130
+ for (const check of checks) {
3131
+ results.push(await runSingleCheck(check, cwd, runner));
1900
3132
  }
1901
- return blockers;
3133
+ return results;
1902
3134
  }
1903
- async function findUnblockedTasks(cwd, fileSystem) {
1904
- const dustPath = `${cwd}/.dust`;
1905
- if (!fileSystem.exists(dustPath)) {
1906
- return { error: ".dust directory not found", tasks: [] };
3135
+ async function runValidationCheck(dependencies) {
3136
+ const outputLines = [];
3137
+ const bufferedContext = {
3138
+ cwd: dependencies.context.cwd,
3139
+ stdout: (msg) => outputLines.push(msg),
3140
+ stderr: (msg) => outputLines.push(msg)
3141
+ };
3142
+ const startTime = Date.now();
3143
+ const result = await lintMarkdown({
3144
+ ...dependencies,
3145
+ context: bufferedContext,
3146
+ arguments: []
3147
+ });
3148
+ const durationMs = Date.now() - startTime;
3149
+ return {
3150
+ name: "lint markdown",
3151
+ command: "dust lint markdown",
3152
+ exitCode: result.exitCode,
3153
+ output: outputLines.join(`
3154
+ `),
3155
+ isBuiltIn: true,
3156
+ durationMs
3157
+ };
3158
+ }
3159
+ function displayResults(results, context) {
3160
+ const passed = results.filter((r) => r.exitCode === 0);
3161
+ const failed = results.filter((r) => r.exitCode !== 0);
3162
+ for (const result of results) {
3163
+ if (result.timedOut) {
3164
+ context.stdout(`✗ ${result.name} [timed out after ${result.timeoutSeconds}s]`);
3165
+ } else {
3166
+ const timing = result.durationMs !== undefined && result.durationMs >= 1000 ? ` [${(result.durationMs / 1000).toFixed(1)}s]` : "";
3167
+ if (result.exitCode === 0) {
3168
+ context.stdout(`✓ ${result.name}${timing}`);
3169
+ } else {
3170
+ context.stdout(`✗ ${result.name}${timing}`);
3171
+ }
3172
+ }
1907
3173
  }
1908
- const tasksPath = `${dustPath}/tasks`;
1909
- if (!fileSystem.exists(tasksPath)) {
1910
- return { tasks: [] };
3174
+ for (const result of failed) {
3175
+ context.stdout("");
3176
+ context.stdout(`> ${result.command}`);
3177
+ if (result.timedOut) {
3178
+ 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`);
3179
+ }
3180
+ if (result.output.trim()) {
3181
+ context.stdout(result.output.trimEnd());
3182
+ }
3183
+ if (result.hints && result.hints.length > 0) {
3184
+ context.stdout("");
3185
+ context.stdout(`Hints for fixing '${result.name}':`);
3186
+ for (const hint of result.hints) {
3187
+ context.stdout(` - ${hint}`);
3188
+ }
3189
+ }
1911
3190
  }
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: [] };
3191
+ context.stdout("");
3192
+ const indicator = failed.length > 0 ? "" : "✓";
3193
+ context.stdout(`${indicator} ${passed.length}/${results.length} checks passed`);
3194
+ return failed.length > 0 ? 1 : 0;
3195
+ }
3196
+ async function check(dependencies, shellRunner = defaultShellRunner) {
3197
+ const {
3198
+ arguments: commandArguments,
3199
+ context,
3200
+ fileSystem,
3201
+ settings
3202
+ } = dependencies;
3203
+ const serial = commandArguments.includes("--serial");
3204
+ if (!settings.checks || settings.checks.length === 0) {
3205
+ context.stderr("Error: No checks configured in .dust/config/settings.json");
3206
+ context.stderr("");
3207
+ context.stderr("Add checks to your settings.json:");
3208
+ context.stderr(" {");
3209
+ context.stderr(' "checks": [');
3210
+ context.stderr(' { "name": "lint", "command": "npm run lint" },');
3211
+ context.stderr(' { "name": "test", "command": "npm test" }');
3212
+ context.stderr(" ]");
3213
+ context.stderr(" }");
3214
+ return { exitCode: 1 };
1916
3215
  }
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 });
3216
+ const dustPath = `${context.cwd}/.dust`;
3217
+ const hasDustDir = fileSystem.exists(dustPath);
3218
+ if (serial) {
3219
+ const results2 = [];
3220
+ if (hasDustDir) {
3221
+ results2.push(await runValidationCheck(dependencies));
1929
3222
  }
3223
+ const configuredResults = await runConfiguredChecksSerially(settings.checks, context.cwd, shellRunner);
3224
+ results2.push(...configuredResults);
3225
+ const exitCode2 = displayResults(results2, context);
3226
+ return { exitCode: exitCode2 };
1930
3227
  }
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}`);
3228
+ const checkPromises = [];
3229
+ if (hasDustDir) {
3230
+ checkPromises.push(runValidationCheck(dependencies));
3231
+ }
3232
+ checkPromises.push(runConfiguredChecks(settings.checks, context.cwd, shellRunner));
3233
+ const promiseResults = await Promise.all(checkPromises);
3234
+ const results = [];
3235
+ for (const result of promiseResults) {
3236
+ if (Array.isArray(result)) {
3237
+ results.push(...result);
3238
+ } else {
3239
+ results.push(result);
1943
3240
  }
1944
- context.stdout(`${colors.cyan}→ ${task.path}${colors.reset}`);
1945
- context.stdout("");
1946
3241
  }
3242
+ const exitCode = displayResults(results, context);
3243
+ return { exitCode };
1947
3244
  }
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");
3245
+
3246
+ // lib/cli/commands/focus.ts
3247
+ async function focus(dependencies) {
3248
+ const { context, settings } = dependencies;
3249
+ const objective = dependencies.arguments.join(" ").trim();
3250
+ if (!objective) {
3251
+ context.stderr("Error: No objective provided");
3252
+ context.stderr('Usage: dust focus "your objective here"');
1954
3253
  return { exitCode: 1 };
1955
3254
  }
1956
- if (result.tasks.length === 0) {
1957
- return { exitCode: 0 };
3255
+ const hooksInstalled = await manageGitHooks(dependencies);
3256
+ const vars = templateVariables(settings, hooksInstalled);
3257
+ context.stdout(`\uD83C\uDFAF Focus: ${objective}`);
3258
+ context.stdout("");
3259
+ const steps = [];
3260
+ let step = 1;
3261
+ steps.push(`${step}. Run \`${vars.bin} check\` to verify the project is in a good state`);
3262
+ step++;
3263
+ steps.push(`${step}. Implement the task`);
3264
+ step++;
3265
+ if (!hooksInstalled) {
3266
+ steps.push(`${step}. Run \`${vars.bin} check\` before committing`);
3267
+ step++;
1958
3268
  }
1959
- printTaskList(context, result.tasks);
3269
+ 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", " ```", "");
3270
+ step++;
3271
+ steps.push(`${step}. Push your commit to the remote repository`);
3272
+ steps.push("");
3273
+ steps.push("Keep your change small and focused. One task, one commit.");
3274
+ context.stdout(steps.join(`
3275
+ `));
1960
3276
  return { exitCode: 0 };
1961
3277
  }
1962
3278
 
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
- });
3279
+ // lib/cli/commands/help.ts
3280
+ function generateHelpText(settings) {
3281
+ return loadTemplate("help", { bin: settings.dustCommand });
2054
3282
  }
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;
3283
+ async function help(dependencies) {
3284
+ dependencies.context.stdout(generateHelpText(dependencies.settings));
3285
+ return { exitCode: 0 };
2065
3286
  }
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
3287
 
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
3288
+ // lib/cli/commands/implement-task.ts
3289
+ async function implementTask(dependencies) {
3290
+ const { context, settings } = dependencies;
3291
+ const hooksInstalled = await manageGitHooks(dependencies);
3292
+ const vars = templateVariables(settings, hooksInstalled);
3293
+ context.stdout(`Run \`${vars.bin} focus "<task name>"\` to set your focus and see implementation instructions.`);
3294
+ return { exitCode: 0 };
3295
+ }
2086
3296
 
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
- }
3297
+ // lib/cli/commands/init.ts
3298
+ var DUST_DIRECTORIES = ["goals", "ideas", "tasks", "facts", "config"];
3299
+ function generateSettings(cwd, fileSystem) {
3300
+ const dustCommand = detectDustCommand(cwd, fileSystem);
3301
+ const testCommand = detectTestCommand(cwd, fileSystem);
3302
+ const checks = [];
3303
+ if (testCommand) {
3304
+ checks.push({ name: "test", command: testCommand });
2104
3305
  }
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";
3306
+ return { dustCommand, checks };
3307
+ }
3308
+ var USE_DUST_FACT = `# Use dust for planning
3309
+
3310
+ This project uses [dust](https://github.com/joshski/dust) for planning and documentation.
3311
+ `;
3312
+ async function init(dependencies) {
3313
+ const { context, fileSystem } = dependencies;
3314
+ const colors = getColors();
3315
+ const dustPath = `${context.cwd}/.dust`;
3316
+ const dustCommand = detectDustCommand(context.cwd, fileSystem);
3317
+ const agentInstruction = `Always immediately run \`${dustCommand} agent\` when you start working in this repository.`;
3318
+ await fileSystem.mkdir(dustPath, { recursive: true });
3319
+ for (const dir of DUST_DIRECTORIES) {
3320
+ await fileSystem.mkdir(`${dustPath}/${dir}`, { recursive: true });
2110
3321
  }
2111
- emit({ type: "loop.tasks_found" });
2112
- emit({ type: "claude.started" });
3322
+ let dustDirCreated = false;
2113
3323
  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";
3324
+ await fileSystem.writeFile(`${dustPath}/facts/use-dust-for-planning.md`, USE_DUST_FACT, { flag: "wx" });
3325
+ dustDirCreated = true;
2124
3326
  } 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";
3327
+ if (error.code !== "EEXIST") {
3328
+ throw error;
3329
+ }
2129
3330
  }
2130
- }
2131
- function parseMaxIterations(commandArguments) {
2132
- if (commandArguments.length === 0) {
2133
- return DEFAULT_MAX_ITERATIONS;
3331
+ try {
3332
+ const settings = generateSettings(context.cwd, fileSystem);
3333
+ await fileSystem.writeFile(`${dustPath}/config/settings.json`, `${JSON.stringify(settings, null, 2)}
3334
+ `, { flag: "wx" });
3335
+ } catch (error) {
3336
+ if (error.code !== "EEXIST") {
3337
+ throw error;
3338
+ }
2134
3339
  }
2135
- const parsed = Number.parseInt(commandArguments[0], 10);
2136
- if (Number.isNaN(parsed) || parsed <= 0) {
2137
- return DEFAULT_MAX_ITERATIONS;
3340
+ if (dustDirCreated) {
3341
+ context.stdout(`${colors.green}✨ Initialized${colors.reset} Dust repository in ${colors.cyan}.dust/${colors.reset}`);
3342
+ context.stdout(`${colors.green}\uD83D\uDCC1 Created directories:${colors.reset} ${colors.dim}${DUST_DIRECTORIES.join(", ")}${colors.reset}`);
3343
+ context.stdout(`${colors.green}\uD83D\uDCC4 Created initial fact:${colors.reset} ${colors.cyan}.dust/facts/use-dust-for-planning.md${colors.reset}`);
3344
+ context.stdout(`${colors.green}⚙️ Created settings:${colors.reset} ${colors.cyan}.dust/config/settings.json${colors.reset}`);
3345
+ } else {
3346
+ context.stdout(`${colors.yellow}\uD83D\uDCE6 Note:${colors.reset} ${colors.cyan}.dust${colors.reset} directory already exists, skipping creation`);
2138
3347
  }
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);
3348
+ const claudeMdPath = `${context.cwd}/CLAUDE.md`;
3349
+ try {
3350
+ const claudeContent = loadTemplate("claude-md", { dustCommand });
3351
+ await fileSystem.writeFile(claudeMdPath, claudeContent, { flag: "wx" });
3352
+ context.stdout(`${colors.green}\uD83D\uDCC4 Created${colors.reset} ${colors.cyan}CLAUDE.md${colors.reset} with agent instructions`);
3353
+ } catch (error) {
3354
+ if (error.code === "EEXIST") {
3355
+ context.stdout(`${colors.yellow}⚠️ Warning:${colors.reset} ${colors.cyan}CLAUDE.md${colors.reset} already exists. Consider adding: ${colors.dim}"${agentInstruction}"${colors.reset}`);
3356
+ } else {
3357
+ throw error;
2156
3358
  }
2157
- postEventFn(event);
2158
- };
2159
- emit({ type: "loop.warning" });
2160
- emit({ type: "loop.started", maxIterations });
2161
- context.stdout(" Press Ctrl+C to stop");
3359
+ }
3360
+ const agentsMdPath = `${context.cwd}/AGENTS.md`;
3361
+ try {
3362
+ const agentsContent = loadTemplate("agents-md", { dustCommand });
3363
+ await fileSystem.writeFile(agentsMdPath, agentsContent, { flag: "wx" });
3364
+ context.stdout(`${colors.green}\uD83D\uDCC4 Created${colors.reset} ${colors.cyan}AGENTS.md${colors.reset} with agent instructions`);
3365
+ } catch (error) {
3366
+ if (error.code === "EEXIST") {
3367
+ context.stdout(`${colors.yellow}⚠️ Warning:${colors.reset} ${colors.cyan}AGENTS.md${colors.reset} already exists. Consider adding: ${colors.dim}"${agentInstruction}"${colors.reset}`);
3368
+ } else {
3369
+ throw error;
3370
+ }
3371
+ }
3372
+ const runner = dustCommand.split(" ")[0];
2162
3373
  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;
3374
+ context.stdout(`${colors.bold}\uD83D\uDE80 Next steps:${colors.reset} Commit the changes if you are happy, then get planning!`);
3375
+ context.stdout("");
3376
+ context.stdout(`${colors.dim}If this is a new repository, you can start adding ideas or tasks right away:${colors.reset}`);
3377
+ context.stdout(` ${colors.cyan}>${colors.reset} ${runner} claude "Idea: friendly UI for non-technical users"`);
3378
+ context.stdout(` ${colors.cyan}>${colors.reset} ${runner} codex "Task: set up code coverage"`);
3379
+ context.stdout("");
3380
+ context.stdout(`${colors.dim}If this is an existing codebase, you might want to backfill goals and facts:${colors.reset}`);
3381
+ context.stdout(` ${colors.cyan}>${colors.reset} ${runner} claude "Add goals and facts based on the code in this repository"`);
3382
+ return { exitCode: 0 };
3383
+ }
3384
+
3385
+ // lib/cli/commands/list.ts
3386
+ import { basename as basename2 } from "node:path";
3387
+ var VALID_TYPES = ["tasks", "ideas", "goals", "facts"];
3388
+ var SECTION_HEADERS = {
3389
+ tasks: "\uD83D\uDCCB Tasks",
3390
+ ideas: "\uD83D\uDCA1 Ideas",
3391
+ goals: "\uD83C\uDFAF Goals",
3392
+ facts: "\uD83D\uDCC4 Facts"
3393
+ };
3394
+ var TYPE_EXPLANATIONS = {
3395
+ tasks: "Tasks are detailed work plans with dependencies and completion criteria. Each task describes a specific piece of work to be done.",
3396
+ ideas: "Ideas are future feature notes and proposals. Ideas capture possibilities that haven't yet been refined into actionable tasks.",
3397
+ goals: "Goals are mission statements and guiding principles. Goals describe desired outcomes and values that inform decision-making.",
3398
+ facts: "Facts are current state documentation. Facts capture how things work today, providing context for agents and contributors."
3399
+ };
3400
+ async function buildGoalHierarchy(goalsPath, fileSystem) {
3401
+ const files = await fileSystem.readdir(goalsPath);
3402
+ const mdFiles = files.filter((f) => f.endsWith(".md"));
3403
+ const relationships = [];
3404
+ const titleMap = new Map;
3405
+ for (const file of mdFiles) {
3406
+ const filePath = `${goalsPath}/${file}`;
3407
+ const content = await fileSystem.readFile(filePath);
3408
+ relationships.push(extractGoalRelationships(filePath, content));
3409
+ const title = extractTitle(content) || basename2(file, ".md");
3410
+ titleMap.set(filePath, title);
3411
+ }
3412
+ const relMap = new Map;
3413
+ for (const rel of relationships) {
3414
+ relMap.set(rel.filePath, rel);
3415
+ }
3416
+ const rootGoals = relationships.filter((rel) => rel.parentGoals.length === 0);
3417
+ function buildNode(filePath) {
3418
+ const rel = relMap.get(filePath);
3419
+ const children = [];
3420
+ if (rel) {
3421
+ for (const childPath of rel.subGoals) {
3422
+ children.push(buildNode(childPath));
2169
3423
  }
2170
- emit({ type: "claude.raw_event", rawEvent });
3424
+ }
3425
+ return {
3426
+ filePath,
3427
+ title: titleMap.get(filePath) || basename2(filePath, ".md"),
3428
+ children
2171
3429
  };
2172
3430
  }
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
- });
3431
+ return rootGoals.map((rel) => buildNode(rel.filePath));
3432
+ }
3433
+ function renderHierarchy(nodes, output, prefix = "") {
3434
+ for (let i = 0;i < nodes.length; i++) {
3435
+ const node = nodes[i];
3436
+ const isLastNode = i === nodes.length - 1;
3437
+ const connector = isLastNode ? "└── " : "├── ";
3438
+ const childPrefix = isLastNode ? " " : "│ ";
3439
+ output(`${prefix}${connector}${node.title}`);
3440
+ if (node.children.length > 0) {
3441
+ renderHierarchy(node.children, output, prefix + childPrefix);
3442
+ }
3443
+ }
3444
+ }
3445
+ async function list(dependencies) {
3446
+ const { arguments: commandArguments, context, fileSystem } = dependencies;
3447
+ const dustPath = `${context.cwd}/.dust`;
3448
+ const colors = getColors();
3449
+ if (!fileSystem.exists(dustPath)) {
3450
+ context.stderr("Error: .dust directory not found");
3451
+ context.stderr("Run 'dust init' to initialize a Dust repository");
3452
+ return { exitCode: 1 };
3453
+ }
3454
+ const typesToList = commandArguments.length === 0 ? [...VALID_TYPES] : commandArguments.filter((a) => VALID_TYPES.includes(a));
3455
+ if (commandArguments.length > 0 && typesToList.length === 0) {
3456
+ context.stderr(`Invalid type: ${commandArguments[0]}`);
3457
+ context.stderr(`Valid types: ${VALID_TYPES.join(", ")}`);
3458
+ return { exitCode: 1 };
3459
+ }
3460
+ const specificTypeRequested = commandArguments.length > 0;
3461
+ for (const type of typesToList) {
3462
+ const dirPath = `${dustPath}/${type}`;
3463
+ const dirExists = fileSystem.exists(dirPath);
3464
+ const files = dirExists ? await fileSystem.readdir(dirPath) : [];
3465
+ const mdFiles = files.filter((f) => f.endsWith(".md")).sort();
3466
+ if (mdFiles.length === 0) {
3467
+ if (specificTypeRequested) {
3468
+ context.stdout(SECTION_HEADERS[type]);
3469
+ context.stdout("");
3470
+ context.stdout(TYPE_EXPLANATIONS[type]);
3471
+ context.stdout("");
3472
+ context.stdout(`No ${type} found.`);
3473
+ context.stdout("");
3474
+ }
3475
+ continue;
3476
+ }
3477
+ context.stdout(SECTION_HEADERS[type]);
3478
+ context.stdout("");
3479
+ context.stdout(TYPE_EXPLANATIONS[type]);
3480
+ context.stdout("");
3481
+ if (type === "goals") {
3482
+ const hierarchy = await buildGoalHierarchy(dirPath, fileSystem);
3483
+ if (hierarchy.length > 0) {
3484
+ context.stdout(`${colors.dim}Hierarchy:${colors.reset}`);
3485
+ renderHierarchy(hierarchy, (line) => context.stdout(line));
3486
+ context.stdout("");
3487
+ }
3488
+ }
3489
+ for (const file of mdFiles) {
3490
+ const filePath = `${dirPath}/${file}`;
3491
+ const content = await fileSystem.readFile(filePath);
3492
+ const title = extractTitle(content);
3493
+ const openingSentence = extractOpeningSentence(content);
3494
+ const relativePath = `.dust/${type}/${file}`;
3495
+ if (title) {
3496
+ context.stdout(`${colors.bold}# ${title}${colors.reset}`);
3497
+ } else {
3498
+ context.stdout(`${colors.bold}# ${file.replace(".md", "")}${colors.reset}`);
3499
+ }
3500
+ if (openingSentence) {
3501
+ context.stdout(`${colors.dim}${openingSentence}${colors.reset}`);
3502
+ }
3503
+ context.stdout(`${colors.cyan}→ ${relativePath}${colors.reset}`);
3504
+ context.stdout("");
2185
3505
  }
2186
3506
  }
2187
- emit({ type: "loop.ended", maxIterations });
2188
3507
  return { exitCode: 0 };
2189
3508
  }
2190
3509
 
@@ -2385,6 +3704,8 @@ var commandRegistry = {
2385
3704
  next,
2386
3705
  check,
2387
3706
  agent,
3707
+ audit,
3708
+ bucket,
2388
3709
  focus,
2389
3710
  "new task": newTask,
2390
3711
  "new goal": newGoal,
@@ -2455,10 +3776,10 @@ function createFileSystem(primitives) {
2455
3776
  chmod: (path, mode) => primitives.chmod(path, mode)
2456
3777
  };
2457
3778
  }
2458
- function createGlobScanner(readdir) {
3779
+ function createGlobScanner(readdir2) {
2459
3780
  return {
2460
3781
  scan: async function* (dir) {
2461
- for (const entry of await readdir(dir, { recursive: true })) {
3782
+ for (const entry of await readdir2(dir, { recursive: true })) {
2462
3783
  if (entry.endsWith(".md"))
2463
3784
  yield entry;
2464
3785
  }
@@ -2482,4 +3803,4 @@ async function wireEntry(fsPrimitives, processPrimitives, consolePrimitives) {
2482
3803
  }
2483
3804
 
2484
3805
  // lib/cli/run.ts
2485
- await wireEntry({ existsSync, readFile, writeFile, mkdir, readdir, chmod }, { argv: process.argv, cwd: () => process.cwd(), exit: process.exit }, { log: console.log, error: console.error });
3806
+ await wireEntry({ existsSync, readFile: readFile2, writeFile: writeFile2, mkdir: mkdir2, readdir: readdir2, chmod: chmod2 }, { argv: process.argv, cwd: () => process.cwd(), exit: process.exit }, { log: console.log, error: console.error });