@joshski/dust 0.1.49 → 0.1.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/dust.js +600 -372
  2. package/package.json +4 -1
package/dist/dust.js CHANGED
@@ -1008,7 +1008,7 @@ import { accessSync, statSync } from "node:fs";
1008
1008
  import { chmod, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
1009
1009
  import { createServer as httpCreateServer } from "node:http";
1010
1010
  import { homedir } from "node:os";
1011
- import { join as join8 } from "node:path";
1011
+ import { join as join9 } from "node:path";
1012
1012
 
1013
1013
  // lib/bucket/auth.ts
1014
1014
  import { join as join4 } from "node:path";
@@ -1177,7 +1177,7 @@ function getLogLines(buffer) {
1177
1177
  }
1178
1178
 
1179
1179
  // lib/bucket/repository.ts
1180
- import { dirname as dirname2, join as join7 } from "node:path";
1180
+ import { dirname as dirname2, join as join8 } from "node:path";
1181
1181
 
1182
1182
  // lib/claude/spawn-claude-code.ts
1183
1183
  import { spawn as nodeSpawn } from "node:child_process";
@@ -1195,7 +1195,8 @@ async function* spawnClaudeCode(prompt, options = {}, dependencies = defaultDepe
1195
1195
  systemPrompt,
1196
1196
  sessionId,
1197
1197
  dangerouslySkipPermissions,
1198
- env
1198
+ env,
1199
+ signal
1199
1200
  } = options;
1200
1201
  const claudeArguments = [
1201
1202
  "-p",
@@ -1231,19 +1232,11 @@ async function* spawnClaudeCode(prompt, options = {}, dependencies = defaultDepe
1231
1232
  if (!proc.stdout) {
1232
1233
  throw new Error("Failed to get stdout from claude process");
1233
1234
  }
1234
- const rl = dependencies.createInterface({ input: proc.stdout });
1235
- for await (const line of rl) {
1236
- if (!line.trim())
1237
- continue;
1238
- try {
1239
- yield JSON.parse(line);
1240
- } catch {}
1241
- }
1242
1235
  let stderrOutput = "";
1243
1236
  proc.stderr?.on("data", (data) => {
1244
1237
  stderrOutput += data.toString();
1245
1238
  });
1246
- await new Promise((resolve, reject) => {
1239
+ const closePromise = new Promise((resolve, reject) => {
1247
1240
  proc.on("close", (code) => {
1248
1241
  if (code === 0 || code === null)
1249
1242
  resolve();
@@ -1254,6 +1247,30 @@ async function* spawnClaudeCode(prompt, options = {}, dependencies = defaultDepe
1254
1247
  });
1255
1248
  proc.on("error", reject);
1256
1249
  });
1250
+ const abortHandler = () => {
1251
+ if (!proc.killed) {
1252
+ proc.kill();
1253
+ }
1254
+ };
1255
+ if (signal?.aborted) {
1256
+ abortHandler();
1257
+ } else if (signal) {
1258
+ signal.addEventListener("abort", abortHandler, { once: true });
1259
+ }
1260
+ const rl = dependencies.createInterface({ input: proc.stdout });
1261
+ try {
1262
+ for await (const line of rl) {
1263
+ if (!line.trim())
1264
+ continue;
1265
+ try {
1266
+ yield JSON.parse(line);
1267
+ } catch {}
1268
+ }
1269
+ await closePromise;
1270
+ } finally {
1271
+ signal?.removeEventListener("abort", abortHandler);
1272
+ rl.close?.();
1273
+ }
1257
1274
  }
1258
1275
 
1259
1276
  // lib/claude/event-parser.ts
@@ -1578,11 +1595,84 @@ async function run(prompt, options = {}, dependencies = defaultRunnerDependencie
1578
1595
  await dependencies.streamEvents(events, sink, onRawEvent);
1579
1596
  }
1580
1597
 
1581
- // lib/bucket/repository-git.ts
1598
+ // lib/logging/match.ts
1599
+ function parsePatterns(debug) {
1600
+ if (!debug)
1601
+ return [];
1602
+ const expressions = debug.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
1603
+ return expressions.map((expr) => {
1604
+ const escaped = expr.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
1605
+ const pattern = escaped.replace(/\*/g, ".*");
1606
+ return new RegExp(`^${pattern}$`);
1607
+ });
1608
+ }
1609
+ function matchesAny(name, patterns) {
1610
+ return patterns.some((re) => re.test(name));
1611
+ }
1612
+ function formatLine(name, messages) {
1613
+ const text = messages.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
1614
+ return `${new Date().toISOString()} [${name}] ${text}
1615
+ `;
1616
+ }
1617
+
1618
+ // lib/logging/sink.ts
1619
+ import { appendFileSync, mkdirSync } from "node:fs";
1582
1620
  import { join as join5 } from "node:path";
1621
+ var logPath;
1622
+ var ready = false;
1623
+ var scope = process.env.DEBUG_LOG_SCOPE || "debug";
1624
+ function ensureLogFile() {
1625
+ if (ready)
1626
+ return logPath;
1627
+ ready = true;
1628
+ const dir = join5(process.cwd(), "log", "dust");
1629
+ logPath = join5(dir, `${scope}.log`);
1630
+ try {
1631
+ mkdirSync(dir, { recursive: true });
1632
+ } catch {
1633
+ logPath = undefined;
1634
+ }
1635
+ return logPath;
1636
+ }
1637
+ function setLogScope(name) {
1638
+ scope = name;
1639
+ process.env.DEBUG_LOG_SCOPE = name;
1640
+ logPath = undefined;
1641
+ ready = false;
1642
+ }
1643
+ var writeToFile = (line) => {
1644
+ const path = ensureLogFile();
1645
+ if (!path)
1646
+ return;
1647
+ try {
1648
+ appendFileSync(path, line);
1649
+ } catch {}
1650
+ };
1651
+
1652
+ // lib/logging/index.ts
1653
+ var patterns = null;
1654
+ var initialized = false;
1655
+ function init() {
1656
+ if (initialized)
1657
+ return;
1658
+ initialized = true;
1659
+ const parsed = parsePatterns(process.env.DEBUG);
1660
+ patterns = parsed.length > 0 ? parsed : null;
1661
+ }
1662
+ function createLogger(name, write = writeToFile) {
1663
+ return (...messages) => {
1664
+ init();
1665
+ if (!patterns || !matchesAny(name, patterns))
1666
+ return;
1667
+ write(formatLine(name, messages));
1668
+ };
1669
+ }
1670
+
1671
+ // lib/bucket/repository-git.ts
1672
+ import { join as join6 } from "node:path";
1583
1673
  function getRepoPath(repoName, reposDir) {
1584
1674
  const safeName = repoName.replace(/[^a-zA-Z0-9-_/]/g, "-");
1585
- return join5(reposDir, safeName);
1675
+ return join6(reposDir, safeName);
1586
1676
  }
1587
1677
  async function cloneRepository(repository, targetPath, spawn, context) {
1588
1678
  return new Promise((resolve) => {
@@ -1652,7 +1742,7 @@ function formatAgentEvent(event) {
1652
1742
  import { spawn as nodeSpawn2 } from "node:child_process";
1653
1743
  import { readFileSync } from "node:fs";
1654
1744
  import os from "node:os";
1655
- import { dirname, join as join6 } from "node:path";
1745
+ import { dirname, join as join7 } from "node:path";
1656
1746
  import { fileURLToPath } from "node:url";
1657
1747
 
1658
1748
  // lib/workflow-tasks.ts
@@ -1800,8 +1890,8 @@ async function next(dependencies) {
1800
1890
  var __dirname2 = dirname(fileURLToPath(import.meta.url));
1801
1891
  function getDustVersion() {
1802
1892
  const candidates = [
1803
- join6(__dirname2, "../../../package.json"),
1804
- join6(__dirname2, "../package.json")
1893
+ join7(__dirname2, "../../../package.json"),
1894
+ join7(__dirname2, "../package.json")
1805
1895
  ];
1806
1896
  for (const candidate of candidates) {
1807
1897
  try {
@@ -1881,6 +1971,7 @@ function createWireEventSender(eventsUrl, sessionId, postEvent, onError, getAgen
1881
1971
  postEvent(eventsUrl, payload).catch(onError);
1882
1972
  };
1883
1973
  }
1974
+ var log = createLogger("dust.cli.commands.loop");
1884
1975
  var SLEEP_INTERVAL_MS = 30000;
1885
1976
  var DEFAULT_MAX_ITERATIONS = 10;
1886
1977
  async function gitPull(cwd, spawn) {
@@ -1914,10 +2005,12 @@ async function runOneIteration(dependencies, loopDependencies, onLoopEvent, onAg
1914
2005
  const { context } = dependencies;
1915
2006
  const { spawn, run: run2 } = loopDependencies;
1916
2007
  const agentName = loopDependencies.agentType === "codex" ? "Codex" : "Claude";
1917
- const { onRawEvent, hooksInstalled = false } = options;
2008
+ const { onRawEvent, hooksInstalled = false, signal } = options;
2009
+ log("syncing with remote");
1918
2010
  onLoopEvent({ type: "loop.syncing" });
1919
2011
  const pullResult = await gitPull(context.cwd, spawn);
1920
2012
  if (!pullResult.success) {
2013
+ log(`git pull failed: ${pullResult.message}`);
1921
2014
  onLoopEvent({
1922
2015
  type: "loop.sync_skipped",
1923
2016
  reason: pullResult.message
@@ -1947,7 +2040,8 @@ Make sure the repository is in a clean state and synced with remote before finis
1947
2040
  spawnOptions: {
1948
2041
  cwd: context.cwd,
1949
2042
  dangerouslySkipPermissions: true,
1950
- env: { DUST_UNATTENDED: "1", DUST_SKIP_AGENT: "1" }
2043
+ env: { DUST_UNATTENDED: "1", DUST_SKIP_AGENT: "1" },
2044
+ signal
1951
2045
  },
1952
2046
  onRawEvent
1953
2047
  });
@@ -1966,10 +2060,12 @@ Make sure the repository is in a clean state and synced with remote before finis
1966
2060
  onLoopEvent({ type: "loop.checking_tasks" });
1967
2061
  const tasks = await findAvailableTasks(dependencies);
1968
2062
  if (tasks.length === 0) {
2063
+ log("no tasks available");
1969
2064
  onLoopEvent({ type: "loop.no_tasks" });
1970
2065
  return "no_tasks";
1971
2066
  }
1972
2067
  const task = tasks[0];
2068
+ log(`found ${tasks.length} task(s), picking: ${task.title ?? task.path}`);
1973
2069
  onLoopEvent({ type: "loop.tasks_found" });
1974
2070
  const taskContent = await dependencies.fileSystem.readFile(`${dependencies.context.cwd}/${task.path}`);
1975
2071
  const { dustCommand, installCommand = "npm install" } = dependencies.settings;
@@ -2000,14 +2096,17 @@ ${instructions}`;
2000
2096
  spawnOptions: {
2001
2097
  cwd: context.cwd,
2002
2098
  dangerouslySkipPermissions: true,
2003
- env: { DUST_UNATTENDED: "1", DUST_SKIP_AGENT: "1" }
2099
+ env: { DUST_UNATTENDED: "1", DUST_SKIP_AGENT: "1" },
2100
+ signal
2004
2101
  },
2005
2102
  onRawEvent
2006
2103
  });
2104
+ log(`${agentName} completed task: ${task.title ?? task.path}`);
2007
2105
  onAgentEvent?.({ type: "agent-session-ended", success: true });
2008
2106
  return "ran_claude";
2009
2107
  } catch (error) {
2010
2108
  const errorMessage = error instanceof Error ? error.message : String(error);
2109
+ log(`${agentName} error on task ${task.title ?? task.path}: ${errorMessage}`);
2011
2110
  context.stderr(`${agentName} exited with error: ${errorMessage}`);
2012
2111
  onAgentEvent?.({
2013
2112
  type: "agent-session-ended",
@@ -2052,6 +2151,7 @@ async function loopClaude(dependencies, loopDependencies = createDefaultDependen
2052
2151
  sendWireEvent(event);
2053
2152
  };
2054
2153
  const hooksInstalled = await manageGitHooks(dependencies);
2154
+ log(`starting loop, maxIterations=${maxIterations}, sessionId=${sessionId}`);
2055
2155
  onLoopEvent({ type: "loop.warning" });
2056
2156
  onLoopEvent({
2057
2157
  type: "loop.started",
@@ -2071,9 +2171,11 @@ async function loopClaude(dependencies, loopDependencies = createDefaultDependen
2071
2171
  agentSessionId = crypto.randomUUID();
2072
2172
  const result = await runOneIteration(dependencies, loopDependencies, onLoopEvent, onAgentEvent, iterationOptions);
2073
2173
  if (result === "no_tasks") {
2174
+ log("sleeping, no tasks");
2074
2175
  await loopDependencies.sleep(SLEEP_INTERVAL_MS);
2075
2176
  } else {
2076
2177
  completedIterations++;
2178
+ log(`iteration ${completedIterations}/${maxIterations} complete, result=${result}`);
2077
2179
  onLoopEvent({
2078
2180
  type: "loop.iteration_complete",
2079
2181
  iteration: completedIterations,
@@ -2081,11 +2183,13 @@ async function loopClaude(dependencies, loopDependencies = createDefaultDependen
2081
2183
  });
2082
2184
  }
2083
2185
  }
2186
+ log(`loop ended after ${completedIterations} iterations`);
2084
2187
  onLoopEvent({ type: "loop.ended", maxIterations });
2085
2188
  return { exitCode: 0 };
2086
2189
  }
2087
2190
 
2088
2191
  // lib/bucket/repository-loop.ts
2192
+ var log2 = createLogger("dust.bucket.repository-loop");
2089
2193
  var FALLBACK_TIMEOUT_MS = 300000;
2090
2194
  function createNoOpGlobScanner() {
2091
2195
  return {
@@ -2173,26 +2277,55 @@ async function runRepositoryLoop(repoState, repoDeps, sendEvent, sessionId) {
2173
2277
  }
2174
2278
  };
2175
2279
  const hooksInstalled = await manageGitHooks(commandDeps);
2280
+ const logLine = (msg) => appendLogLine(repoState.logBuffer, createLogLine(msg, "stdout"));
2281
+ log2(`loop started for ${repoName} at ${repoState.path}`);
2176
2282
  while (!repoState.stopRequested) {
2177
2283
  agentSessionId = crypto.randomUUID();
2178
- const result = await runOneIteration(commandDeps, loopDeps, onLoopEvent, onAgentEvent, {
2179
- hooksInstalled,
2180
- onRawEvent: (rawEvent) => {
2181
- onAgentEvent(rawEventToAgentEvent(rawEvent));
2284
+ const abortController = new AbortController;
2285
+ const cancelCurrentIteration = () => {
2286
+ abortController.abort();
2287
+ };
2288
+ repoState.cancelCurrentIteration = cancelCurrentIteration;
2289
+ let result;
2290
+ try {
2291
+ result = await runOneIteration(commandDeps, loopDeps, onLoopEvent, onAgentEvent, {
2292
+ hooksInstalled,
2293
+ signal: abortController.signal,
2294
+ onRawEvent: (rawEvent) => {
2295
+ onAgentEvent(rawEventToAgentEvent(rawEvent));
2296
+ }
2297
+ });
2298
+ } catch (error) {
2299
+ const msg = error instanceof Error ? error.message : String(error);
2300
+ log2(`iteration error for ${repoName}: ${msg}`);
2301
+ appendLogLine(repoState.logBuffer, createLogLine(`Loop error: ${msg}`, "stderr"));
2302
+ await sleep(1e4);
2303
+ continue;
2304
+ } finally {
2305
+ if (repoState.cancelCurrentIteration === cancelCurrentIteration) {
2306
+ repoState.cancelCurrentIteration = undefined;
2182
2307
  }
2183
- });
2308
+ }
2184
2309
  if (result === "no_tasks") {
2185
2310
  if (repoState.taskAvailablePending) {
2186
2311
  repoState.taskAvailablePending = false;
2312
+ log2(`${repoName}: task signal received during iteration, rechecking`);
2313
+ logLine("Task signal received during iteration, rechecking...");
2187
2314
  continue;
2188
2315
  }
2316
+ log2(`${repoName}: no tasks available, waiting`);
2317
+ logLine("Waiting for tasks...");
2189
2318
  await new Promise((resolve) => {
2190
- repoState.wakeUp = () => {
2319
+ const wakeUpForThisWait = () => {
2320
+ if (repoState.wakeUp !== wakeUpForThisWait) {
2321
+ return;
2322
+ }
2191
2323
  repoState.wakeUp = undefined;
2192
2324
  resolve();
2193
2325
  };
2326
+ repoState.wakeUp = wakeUpForThisWait;
2194
2327
  sleep(FALLBACK_TIMEOUT_MS).then(() => {
2195
- if (repoState.wakeUp) {
2328
+ if (repoState.wakeUp === wakeUpForThisWait) {
2196
2329
  repoState.wakeUp = undefined;
2197
2330
  resolve();
2198
2331
  }
@@ -2200,9 +2333,27 @@ async function runRepositoryLoop(repoState, repoDeps, sendEvent, sessionId) {
2200
2333
  });
2201
2334
  }
2202
2335
  }
2336
+ log2(`loop stopped for ${repoName}`);
2203
2337
  appendLogLine(repoState.logBuffer, createLogLine(`Stopped loop for ${repoName}`, "stdout"));
2204
2338
  }
2339
+
2205
2340
  // lib/bucket/repository.ts
2341
+ var log3 = createLogger("dust.bucket.repository");
2342
+ function startRepositoryLoop(repoState, repoDeps, sendEvent, sessionId) {
2343
+ log3(`starting loop for ${repoState.repository.name}`);
2344
+ repoState.stopRequested = false;
2345
+ repoState.loopPromise = runRepositoryLoop(repoState, repoDeps, sendEvent, sessionId).catch((error) => {
2346
+ const message = error instanceof Error ? error.message : String(error);
2347
+ log3(`loop crashed for ${repoState.repository.name}: ${message}`);
2348
+ appendLogLine(repoState.logBuffer, createLogLine(`Repository loop crashed: ${message}`, "stderr"));
2349
+ }).finally(() => {
2350
+ log3(`loop finished for ${repoState.repository.name}`);
2351
+ repoState.loopPromise = null;
2352
+ repoState.agentStatus = "idle";
2353
+ repoState.wakeUp = undefined;
2354
+ repoState.cancelCurrentIteration = undefined;
2355
+ });
2356
+ }
2206
2357
  function parseRepository(data) {
2207
2358
  if (typeof data === "string") {
2208
2359
  return { name: data, gitUrl: data };
@@ -2224,8 +2375,10 @@ function parseRepository(data) {
2224
2375
  }
2225
2376
  async function addRepository(repository, manager, repoDeps, context) {
2226
2377
  if (manager.repositories.has(repository.name)) {
2378
+ log3(`repository ${repository.name} already exists, skipping add`);
2227
2379
  return;
2228
2380
  }
2381
+ log3(`adding repository ${repository.name}`);
2229
2382
  const repoPath = getRepoPath(repository.name, repoDeps.getReposDir());
2230
2383
  await repoDeps.fileSystem.mkdir(dirname2(repoPath), { recursive: true });
2231
2384
  if (repoDeps.fileSystem.exists(repoPath)) {
@@ -2258,14 +2411,16 @@ async function addRepository(repository, manager, repoDeps, context) {
2258
2411
  };
2259
2412
  manager.emit(addedEvent);
2260
2413
  context.stdout(formatBucketEvent(addedEvent));
2261
- repoState.loopPromise = runRepositoryLoop(repoState, repoDeps, manager.sendEvent, manager.sessionId);
2414
+ startRepositoryLoop(repoState, repoDeps, manager.sendEvent, manager.sessionId);
2262
2415
  }
2263
2416
  async function removeRepositoryFromManager(repoName, manager, repoDeps, context) {
2264
2417
  const repoState = manager.repositories.get(repoName);
2265
2418
  if (!repoState) {
2266
2419
  return;
2267
2420
  }
2421
+ log3(`removing repository ${repoName}`);
2268
2422
  repoState.stopRequested = true;
2423
+ repoState.cancelCurrentIteration?.();
2269
2424
  repoState.wakeUp?.();
2270
2425
  if (repoState.loopPromise) {
2271
2426
  await Promise.race([repoState.loopPromise, repoDeps.sleep(5000)]);
@@ -2576,14 +2731,24 @@ function formatLogLine(line, prefixAlign, maxWidth) {
2576
2731
  prefix = `${line.color}${paddedName}${ANSI.RESET} ${ANSI.DIM}|${ANSI.RESET} `;
2577
2732
  prefixWidth = prefixAlign + 3;
2578
2733
  }
2734
+ let timePrefix = "";
2735
+ let timePrefixWidth = 0;
2736
+ if (line.repository === "system") {
2737
+ const d = new Date(line.timestamp);
2738
+ const hh = String(d.getHours()).padStart(2, "0");
2739
+ const mm = String(d.getMinutes()).padStart(2, "0");
2740
+ const ss = String(d.getSeconds()).padStart(2, "0");
2741
+ timePrefix = `${ANSI.DIM}${hh}:${mm}:${ss}${ANSI.RESET} `;
2742
+ timePrefixWidth = 9;
2743
+ }
2579
2744
  const textColor = line.stream === "stderr" ? ANSI.FG_RED : "";
2580
2745
  const textReset = line.stream === "stderr" ? ANSI.RESET : "";
2581
2746
  const sanitizedText = line.text.replace(/[\r\n]+/g, "");
2582
2747
  const text = `${textColor}${sanitizedText}${textReset}`;
2583
- const availableWidth = maxWidth - prefixWidth;
2748
+ const availableWidth = maxWidth - prefixWidth - timePrefixWidth;
2584
2749
  if (availableWidth <= 0)
2585
- return prefix;
2586
- return prefix + truncateLine(text, availableWidth);
2750
+ return prefix + timePrefix;
2751
+ return prefix + timePrefix + truncateLine(text, availableWidth);
2587
2752
  }
2588
2753
  function renderFrame(state) {
2589
2754
  const lines = [];
@@ -2708,6 +2873,7 @@ function handleKeyInput(state, key, options) {
2708
2873
  }
2709
2874
 
2710
2875
  // lib/cli/commands/bucket.ts
2876
+ var log4 = createLogger("dust.cli.commands.bucket");
2711
2877
  var DEFAULT_DUSTBUCKET_WS_URL = "wss://dustbucket.com/agent/connect";
2712
2878
  var INITIAL_RECONNECT_DELAY_MS = 1000;
2713
2879
  var MAX_RECONNECT_DELAY_MS = 30000;
@@ -2829,7 +2995,7 @@ function createDefaultBucketDependencies() {
2829
2995
  writeStdout: defaultWriteStdout,
2830
2996
  isTTY: process.stdout.isTTY ?? false,
2831
2997
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
2832
- getReposDir: () => process.env.DUST_REPOS_DIR || join8(homedir(), ".dust", "repos"),
2998
+ getReposDir: () => process.env.DUST_REPOS_DIR || join9(homedir(), ".dust", "repos"),
2833
2999
  auth: {
2834
3000
  createServer: defaultCreateServer,
2835
3001
  openBrowser: defaultOpenBrowser,
@@ -2870,6 +3036,25 @@ function toRepositoryDependencies(bucketDeps, fileSystem) {
2870
3036
  getReposDir: bucketDeps.getReposDir
2871
3037
  };
2872
3038
  }
3039
+ function ensureRepositoryLoopRunning(repoState, state, repoDeps, context, useTUI) {
3040
+ if (repoState.loopPromise || repoState.wakeUp || repoState.stopRequested) {
3041
+ log4(`loop already running/waiting for ${repoState.repository.name}`);
3042
+ return;
3043
+ }
3044
+ logMessage(state, context, useTUI, `Repository loop not running for ${repoState.repository.name}; restarting`);
3045
+ startRepositoryLoop(repoState, repoDeps, state.sendEvent, state.sessionId);
3046
+ }
3047
+ function signalTaskAvailable(repoState, state, repoDeps, context, useTUI) {
3048
+ log4(`task-available signal for ${repoState.repository.name}`);
3049
+ ensureRepositoryLoopRunning(repoState, state, repoDeps, context, useTUI);
3050
+ if (repoState.wakeUp) {
3051
+ log4(`waking loop for ${repoState.repository.name}`);
3052
+ repoState.wakeUp();
3053
+ } else {
3054
+ log4(`marking task pending for ${repoState.repository.name} (loop busy)`);
3055
+ repoState.taskAvailablePending = true;
3056
+ }
3057
+ }
2873
3058
  function syncUIWithRepoList(state, repos) {
2874
3059
  const incomingNames = new Set;
2875
3060
  for (const data of repos) {
@@ -2993,9 +3178,14 @@ function connectWebSocket(token, state, bucketDependencies, context, fileSystem,
2993
3178
  ws.onmessage = (event) => {
2994
3179
  try {
2995
3180
  const message = JSON.parse(event.data);
3181
+ log4(`ws message: ${message.type}`);
2996
3182
  if (message.type === "repository-list") {
2997
3183
  const repos = message.repositories ?? [];
2998
- logMessage(state, context, useTUI, `Received repository list (${repos.length} repositories)`);
3184
+ const repoNames = repos.map((r) => {
3185
+ const name = r?.name ?? "?";
3186
+ return r?.hasTask ? `${name} (has task)` : name;
3187
+ }).join(", ");
3188
+ logMessage(state, context, useTUI, `Received repository list: ${repoNames || "(empty)"}`);
2999
3189
  syncUIWithRepoList(state, repos);
3000
3190
  const repoDeps = toRepositoryDependencies(bucketDependencies, fileSystem);
3001
3191
  const repoContext = createTUIContext(state, context, useTUI);
@@ -3005,11 +3195,7 @@ function connectWebSocket(token, state, bucketDependencies, context, fileSystem,
3005
3195
  if (typeof repoData === "object" && repoData !== null && "name" in repoData && "hasTask" in repoData && repoData.hasTask) {
3006
3196
  const repoState = state.repositories.get(repoData.name);
3007
3197
  if (repoState) {
3008
- if (repoState.wakeUp) {
3009
- repoState.wakeUp();
3010
- } else {
3011
- repoState.taskAvailablePending = true;
3012
- }
3198
+ signalTaskAvailable(repoState, state, repoDeps, context, useTUI);
3013
3199
  }
3014
3200
  }
3015
3201
  }
@@ -3019,13 +3205,13 @@ function connectWebSocket(token, state, bucketDependencies, context, fileSystem,
3019
3205
  } else if (message.type === "task-available") {
3020
3206
  const repoName = message.repository;
3021
3207
  if (typeof repoName === "string") {
3208
+ const repoDeps = toRepositoryDependencies(bucketDependencies, fileSystem);
3209
+ logMessage(state, context, useTUI, `Received task-available for ${repoName}`);
3022
3210
  const repoState = state.repositories.get(repoName);
3023
3211
  if (repoState) {
3024
- if (repoState.wakeUp) {
3025
- repoState.wakeUp();
3026
- } else {
3027
- repoState.taskAvailablePending = true;
3028
- }
3212
+ signalTaskAvailable(repoState, state, repoDeps, context, useTUI);
3213
+ } else {
3214
+ logMessage(state, context, useTUI, `No repository state found for ${repoName}`, "stderr");
3029
3215
  }
3030
3216
  }
3031
3217
  }
@@ -3038,6 +3224,7 @@ async function shutdown(state, bucketDeps, context) {
3038
3224
  if (state.shuttingDown)
3039
3225
  return;
3040
3226
  state.shuttingDown = true;
3227
+ log4("shutdown initiated");
3041
3228
  context.stdout("Shutting down...");
3042
3229
  if (state.reconnectTimer) {
3043
3230
  clearTimeout(state.reconnectTimer);
@@ -3049,6 +3236,7 @@ async function shutdown(state, bucketDeps, context) {
3049
3236
  }
3050
3237
  for (const repoState of state.repositories.values()) {
3051
3238
  repoState.stopRequested = true;
3239
+ repoState.cancelCurrentIteration?.();
3052
3240
  repoState.wakeUp?.();
3053
3241
  }
3054
3242
  const loopPromises = Array.from(state.repositories.values()).map((rs) => rs.loopPromise).filter((p) => p !== null);
@@ -3229,39 +3417,25 @@ function runBufferedProcess(spawnFn, command, commandArguments, cwd, shell, time
3229
3417
  }
3230
3418
 
3231
3419
  // lib/cli/commands/lint-markdown.ts
3232
- import { dirname as dirname3, join as join9, resolve } from "node:path";
3420
+ import { join as join10 } from "node:path";
3421
+
3422
+ // lib/lint/validators/content-validator.ts
3233
3423
  var REQUIRED_HEADINGS = ["## Goals", "## Blocked By", "## Definition of Done"];
3234
- var REQUIRED_GOAL_HEADINGS = ["## Parent Goal", "## Sub-Goals"];
3235
- var SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*\.md$/;
3236
- var EXPECTED_DIRECTORIES = ["goals", "ideas", "tasks", "facts", "config"];
3237
3424
  var MAX_OPENING_SENTENCE_LENGTH = 150;
3238
- function validateFilename(filePath) {
3239
- const parts = filePath.split("/");
3240
- const filename = parts[parts.length - 1];
3241
- if (!SLUG_PATTERN.test(filename)) {
3242
- return {
3243
- file: filePath,
3244
- message: `Filename "${filename}" does not match slug-style naming`
3245
- };
3246
- }
3247
- return null;
3248
- }
3249
- function validateTitleFilenameMatch(filePath, content) {
3250
- const title = extractTitle(content);
3251
- if (!title) {
3252
- return null;
3253
- }
3254
- const parts = filePath.split("/");
3255
- const actualFilename = parts[parts.length - 1];
3256
- const expectedFilename = titleToFilename(title);
3257
- if (actualFilename !== expectedFilename) {
3258
- return {
3259
- file: filePath,
3260
- message: `Filename "${actualFilename}" does not match title "${title}" (expected "${expectedFilename}")`
3261
- };
3262
- }
3263
- return null;
3264
- }
3425
+ var NON_IMPERATIVE_STARTERS = new Set([
3426
+ "the",
3427
+ "a",
3428
+ "an",
3429
+ "this",
3430
+ "that",
3431
+ "these",
3432
+ "those",
3433
+ "we",
3434
+ "it",
3435
+ "they",
3436
+ "you",
3437
+ "i"
3438
+ ]);
3265
3439
  function validateOpeningSentence(filePath, content) {
3266
3440
  const openingSentence = extractOpeningSentence(content);
3267
3441
  if (!openingSentence) {
@@ -3285,20 +3459,6 @@ function validateOpeningSentenceLength(filePath, content) {
3285
3459
  }
3286
3460
  return null;
3287
3461
  }
3288
- var NON_IMPERATIVE_STARTERS = new Set([
3289
- "the",
3290
- "a",
3291
- "an",
3292
- "this",
3293
- "that",
3294
- "these",
3295
- "those",
3296
- "we",
3297
- "it",
3298
- "they",
3299
- "you",
3300
- "i"
3301
- ]);
3302
3462
  function validateImperativeOpeningSentence(filePath, content) {
3303
3463
  const openingSentence = extractOpeningSentence(content);
3304
3464
  if (!openingSentence) {
@@ -3327,194 +3487,109 @@ function validateTaskHeadings(filePath, content) {
3327
3487
  }
3328
3488
  return violations;
3329
3489
  }
3330
- function validateLinks(filePath, content, fileSystem) {
3490
+
3491
+ // lib/lint/validators/directory-validator.ts
3492
+ var EXPECTED_DIRECTORIES = ["goals", "ideas", "tasks", "facts", "config"];
3493
+ async function validateContentDirectoryFiles(dirPath, fileSystem) {
3331
3494
  const violations = [];
3332
- const lines = content.split(`
3333
- `);
3334
- const fileDir = dirname3(filePath);
3335
- for (let i = 0;i < lines.length; i++) {
3336
- const line = lines[i];
3337
- const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
3338
- let match = linkPattern.exec(line);
3339
- while (match) {
3340
- const linkTarget = match[2];
3341
- if (!linkTarget.startsWith("http://") && !linkTarget.startsWith("https://") && !linkTarget.startsWith("#")) {
3342
- const targetPath = linkTarget.split("#")[0];
3343
- const resolvedPath = resolve(fileDir, targetPath);
3344
- if (!fileSystem.exists(resolvedPath)) {
3345
- violations.push({
3346
- file: filePath,
3347
- message: `Broken link: "${linkTarget}"`,
3348
- line: i + 1
3349
- });
3350
- }
3351
- }
3352
- match = linkPattern.exec(line);
3495
+ let entries;
3496
+ try {
3497
+ entries = await fileSystem.readdir(dirPath);
3498
+ } catch (error) {
3499
+ if (error.code === "ENOENT") {
3500
+ return [];
3353
3501
  }
3502
+ throw error;
3354
3503
  }
3355
- return violations;
3356
- }
3357
- function validateIdeaOpenQuestions(filePath, content) {
3358
- const violations = [];
3359
- const lines = content.split(`
3360
- `);
3361
- let inOpenQuestions = false;
3362
- let currentQuestionLine = null;
3363
- let inCodeBlock = false;
3364
- for (let i = 0;i < lines.length; i++) {
3365
- const line = lines[i];
3366
- if (line.startsWith("```")) {
3367
- inCodeBlock = !inCodeBlock;
3368
- continue;
3369
- }
3370
- if (inCodeBlock)
3371
- continue;
3372
- if (line.startsWith("## ")) {
3373
- if (inOpenQuestions && currentQuestionLine !== null) {
3374
- violations.push({
3375
- file: filePath,
3376
- message: "Question has no options listed beneath it",
3377
- line: currentQuestionLine
3378
- });
3379
- }
3380
- const headingText = line.slice(3).trimEnd();
3381
- if (headingText.toLowerCase() === "open questions" && headingText !== "Open Questions") {
3382
- violations.push({
3383
- file: filePath,
3384
- message: `Heading "${line.trimEnd()}" should be "## Open Questions"`,
3385
- line: i + 1
3386
- });
3387
- }
3388
- inOpenQuestions = line === "## Open Questions";
3389
- currentQuestionLine = null;
3390
- continue;
3391
- }
3392
- if (!inOpenQuestions)
3393
- continue;
3394
- if (/^[-*] /.test(line.trimStart())) {
3504
+ for (const entry of entries) {
3505
+ const entryPath = `${dirPath}/${entry}`;
3506
+ if (entry.startsWith(".")) {
3395
3507
  violations.push({
3396
- file: filePath,
3397
- message: "Open Questions must use ### headings for questions and #### headings for options, not bullet points. Run `dust new idea` to see the expected format.",
3398
- line: i + 1
3508
+ file: entryPath,
3509
+ message: `Hidden file "${entry}" found in content directory`
3399
3510
  });
3400
3511
  continue;
3401
3512
  }
3402
- if (line.startsWith("### ")) {
3403
- if (currentQuestionLine !== null) {
3404
- violations.push({
3405
- file: filePath,
3406
- message: "Question has no options listed beneath it",
3407
- line: currentQuestionLine
3408
- });
3409
- }
3410
- if (!line.trimEnd().endsWith("?")) {
3411
- violations.push({
3412
- file: filePath,
3413
- message: 'Questions must end with "?" (e.g., "### Should we take our own payments?")',
3414
- line: i + 1
3415
- });
3416
- currentQuestionLine = null;
3417
- } else {
3418
- currentQuestionLine = i + 1;
3419
- }
3513
+ if (fileSystem.isDirectory(entryPath)) {
3514
+ violations.push({
3515
+ file: entryPath,
3516
+ message: `Subdirectory "${entry}" found in content directory (content directories should be flat)`
3517
+ });
3420
3518
  continue;
3421
3519
  }
3422
- if (line.startsWith("#### ")) {
3423
- currentQuestionLine = null;
3520
+ if (!entry.endsWith(".md")) {
3521
+ violations.push({
3522
+ file: entryPath,
3523
+ message: `Non-markdown file "${entry}" found in content directory`
3524
+ });
3424
3525
  }
3425
3526
  }
3426
- if (inOpenQuestions && currentQuestionLine !== null) {
3427
- violations.push({
3428
- file: filePath,
3429
- message: "Question has no options listed beneath it",
3430
- line: currentQuestionLine
3431
- });
3432
- }
3433
3527
  return violations;
3434
3528
  }
3435
- var SEMANTIC_RULES = [
3436
- {
3437
- section: "## Goals",
3438
- requiredPath: "/.dust/goals/",
3439
- description: "goal"
3440
- },
3441
- {
3442
- section: "## Blocked By",
3443
- requiredPath: "/.dust/tasks/",
3444
- description: "task"
3445
- }
3446
- ];
3447
- function validateSemanticLinks(filePath, content) {
3529
+ async function validateDirectoryStructure(dustPath, fileSystem, extraDirectories = []) {
3448
3530
  const violations = [];
3449
- const lines = content.split(`
3450
- `);
3451
- const fileDir = dirname3(filePath);
3452
- let currentSection = null;
3453
- for (let i = 0;i < lines.length; i++) {
3454
- const line = lines[i];
3455
- if (line.startsWith("## ")) {
3456
- currentSection = line;
3457
- continue;
3531
+ let entries;
3532
+ try {
3533
+ entries = await fileSystem.readdir(dustPath);
3534
+ } catch (error) {
3535
+ if (error.code === "ENOENT") {
3536
+ return [];
3458
3537
  }
3459
- const rule = SEMANTIC_RULES.find((r) => r.section === currentSection);
3460
- if (!rule)
3538
+ throw error;
3539
+ }
3540
+ const allowedDirectories = new Set([
3541
+ ...EXPECTED_DIRECTORIES,
3542
+ ...extraDirectories
3543
+ ]);
3544
+ for (const entry of entries) {
3545
+ const entryPath = `${dustPath}/${entry}`;
3546
+ if (!fileSystem.isDirectory(entryPath)) {
3461
3547
  continue;
3462
- const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
3463
- let match = linkPattern.exec(line);
3464
- while (match) {
3465
- const linkTarget = match[2];
3466
- if (linkTarget.startsWith("#")) {
3467
- violations.push({
3468
- file: filePath,
3469
- message: `Link in "${rule.section}" must point to a ${rule.description} file, not an anchor: "${linkTarget}"`,
3470
- line: i + 1
3471
- });
3472
- match = linkPattern.exec(line);
3473
- continue;
3474
- }
3475
- if (linkTarget.startsWith("http://") || linkTarget.startsWith("https://")) {
3476
- violations.push({
3477
- file: filePath,
3478
- message: `Link in "${rule.section}" must point to a ${rule.description} file, not an external URL: "${linkTarget}"`,
3479
- line: i + 1
3480
- });
3481
- match = linkPattern.exec(line);
3482
- continue;
3483
- }
3484
- const targetPath = linkTarget.split("#")[0];
3485
- const resolvedPath = resolve(fileDir, targetPath);
3486
- if (!resolvedPath.includes(rule.requiredPath)) {
3487
- violations.push({
3488
- file: filePath,
3489
- message: `Link in "${rule.section}" must point to a ${rule.description} file: "${linkTarget}"`,
3490
- line: i + 1
3491
- });
3492
- }
3493
- match = linkPattern.exec(line);
3548
+ }
3549
+ if (!allowedDirectories.has(entry)) {
3550
+ const allowedList = [...allowedDirectories].sort().join(", ");
3551
+ violations.push({
3552
+ file: entryPath,
3553
+ message: `Unexpected directory "${entry}" in .dust/. Allowed directories: ${allowedList}. To allow this directory, add it to "extraDirectories" in .dust/config/settings.json`
3554
+ });
3494
3555
  }
3495
3556
  }
3496
3557
  return violations;
3497
3558
  }
3498
- function validateIdeaTransitionTitle(filePath, content, ideasPath, fileSystem) {
3559
+
3560
+ // lib/lint/validators/filename-validator.ts
3561
+ var SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*\.md$/;
3562
+ function validateFilename(filePath) {
3563
+ const parts = filePath.split("/");
3564
+ const filename = parts[parts.length - 1];
3565
+ if (!SLUG_PATTERN.test(filename)) {
3566
+ return {
3567
+ file: filePath,
3568
+ message: `Filename "${filename}" does not match slug-style naming`
3569
+ };
3570
+ }
3571
+ return null;
3572
+ }
3573
+ function validateTitleFilenameMatch(filePath, content) {
3499
3574
  const title = extractTitle(content);
3500
3575
  if (!title) {
3501
3576
  return null;
3502
3577
  }
3503
- for (const prefix of IDEA_TRANSITION_PREFIXES) {
3504
- if (title.startsWith(prefix)) {
3505
- const ideaTitle = title.slice(prefix.length);
3506
- const ideaFilename = titleToFilename(ideaTitle);
3507
- if (!fileSystem.exists(`${ideasPath}/${ideaFilename}`)) {
3508
- return {
3509
- file: filePath,
3510
- message: `Idea transition task references non-existent idea: "${ideaTitle}" (expected file "${ideaFilename}" in ideas/)`
3511
- };
3512
- }
3513
- return null;
3514
- }
3578
+ const parts = filePath.split("/");
3579
+ const actualFilename = parts[parts.length - 1];
3580
+ const expectedFilename = titleToFilename(title);
3581
+ if (actualFilename !== expectedFilename) {
3582
+ return {
3583
+ file: filePath,
3584
+ message: `Filename "${actualFilename}" does not match title "${title}" (expected "${expectedFilename}")`
3585
+ };
3515
3586
  }
3516
3587
  return null;
3517
3588
  }
3589
+
3590
+ // lib/lint/validators/goal-hierarchy.ts
3591
+ import { dirname as dirname3, resolve } from "node:path";
3592
+ var REQUIRED_GOAL_HEADINGS = ["## Parent Goal", "## Sub-Goals"];
3518
3593
  function validateGoalHierarchySections(filePath, content) {
3519
3594
  const violations = [];
3520
3595
  for (const heading of REQUIRED_GOAL_HEADINGS) {
@@ -3527,57 +3602,6 @@ function validateGoalHierarchySections(filePath, content) {
3527
3602
  }
3528
3603
  return violations;
3529
3604
  }
3530
- function validateGoalHierarchyLinks(filePath, content) {
3531
- const violations = [];
3532
- const lines = content.split(`
3533
- `);
3534
- const fileDir = dirname3(filePath);
3535
- let currentSection = null;
3536
- for (let i = 0;i < lines.length; i++) {
3537
- const line = lines[i];
3538
- if (line.startsWith("## ")) {
3539
- currentSection = line;
3540
- continue;
3541
- }
3542
- if (currentSection !== "## Parent Goal" && currentSection !== "## Sub-Goals") {
3543
- continue;
3544
- }
3545
- const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
3546
- let match = linkPattern.exec(line);
3547
- while (match) {
3548
- const linkTarget = match[2];
3549
- if (linkTarget.startsWith("#")) {
3550
- violations.push({
3551
- file: filePath,
3552
- message: `Link in "${currentSection}" must point to a goal file, not an anchor: "${linkTarget}"`,
3553
- line: i + 1
3554
- });
3555
- match = linkPattern.exec(line);
3556
- continue;
3557
- }
3558
- if (linkTarget.startsWith("http://") || linkTarget.startsWith("https://")) {
3559
- violations.push({
3560
- file: filePath,
3561
- message: `Link in "${currentSection}" must point to a goal file, not an external URL: "${linkTarget}"`,
3562
- line: i + 1
3563
- });
3564
- match = linkPattern.exec(line);
3565
- continue;
3566
- }
3567
- const targetPath = linkTarget.split("#")[0];
3568
- const resolvedPath = resolve(fileDir, targetPath);
3569
- if (!resolvedPath.includes("/.dust/goals/")) {
3570
- violations.push({
3571
- file: filePath,
3572
- message: `Link in "${currentSection}" must point to a goal file: "${linkTarget}"`,
3573
- line: i + 1
3574
- });
3575
- }
3576
- match = linkPattern.exec(line);
3577
- }
3578
- }
3579
- return violations;
3580
- }
3581
3605
  function extractGoalRelationships(filePath, content) {
3582
3606
  const lines = content.split(`
3583
3607
  `);
@@ -3673,86 +3697,266 @@ function validateNoCycles(allGoalRelationships) {
3673
3697
  }
3674
3698
  return violations;
3675
3699
  }
3676
- async function safeScanDir(glob, dirPath) {
3677
- const files = [];
3678
- try {
3679
- for await (const file of glob.scan(dirPath)) {
3680
- files.push(file);
3700
+
3701
+ // lib/lint/validators/idea-validator.ts
3702
+ function validateIdeaOpenQuestions(filePath, content) {
3703
+ const violations = [];
3704
+ const lines = content.split(`
3705
+ `);
3706
+ let inOpenQuestions = false;
3707
+ let currentQuestionLine = null;
3708
+ let inCodeBlock = false;
3709
+ for (let i = 0;i < lines.length; i++) {
3710
+ const line = lines[i];
3711
+ if (line.startsWith("```")) {
3712
+ inCodeBlock = !inCodeBlock;
3713
+ continue;
3681
3714
  }
3682
- return { files, exists: true };
3683
- } catch (error) {
3684
- if (error.code === "ENOENT") {
3685
- return { files: [], exists: false };
3715
+ if (inCodeBlock)
3716
+ continue;
3717
+ if (line.startsWith("## ")) {
3718
+ if (inOpenQuestions && currentQuestionLine !== null) {
3719
+ violations.push({
3720
+ file: filePath,
3721
+ message: "Question has no options listed beneath it",
3722
+ line: currentQuestionLine
3723
+ });
3724
+ }
3725
+ const headingText = line.slice(3).trimEnd();
3726
+ if (headingText.toLowerCase() === "open questions" && headingText !== "Open Questions") {
3727
+ violations.push({
3728
+ file: filePath,
3729
+ message: `Heading "${line.trimEnd()}" should be "## Open Questions"`,
3730
+ line: i + 1
3731
+ });
3732
+ }
3733
+ inOpenQuestions = line === "## Open Questions";
3734
+ currentQuestionLine = null;
3735
+ continue;
3686
3736
  }
3687
- throw error;
3737
+ if (!inOpenQuestions)
3738
+ continue;
3739
+ if (/^[-*] /.test(line.trimStart())) {
3740
+ violations.push({
3741
+ file: filePath,
3742
+ message: "Open Questions must use ### headings for questions and #### headings for options, not bullet points. Run `dust new idea` to see the expected format.",
3743
+ line: i + 1
3744
+ });
3745
+ continue;
3746
+ }
3747
+ if (line.startsWith("### ")) {
3748
+ if (currentQuestionLine !== null) {
3749
+ violations.push({
3750
+ file: filePath,
3751
+ message: "Question has no options listed beneath it",
3752
+ line: currentQuestionLine
3753
+ });
3754
+ }
3755
+ if (!line.trimEnd().endsWith("?")) {
3756
+ violations.push({
3757
+ file: filePath,
3758
+ message: 'Questions must end with "?" (e.g., "### Should we take our own payments?")',
3759
+ line: i + 1
3760
+ });
3761
+ currentQuestionLine = null;
3762
+ } else {
3763
+ currentQuestionLine = i + 1;
3764
+ }
3765
+ continue;
3766
+ }
3767
+ if (line.startsWith("#### ")) {
3768
+ currentQuestionLine = null;
3769
+ }
3770
+ }
3771
+ if (inOpenQuestions && currentQuestionLine !== null) {
3772
+ violations.push({
3773
+ file: filePath,
3774
+ message: "Question has no options listed beneath it",
3775
+ line: currentQuestionLine
3776
+ });
3688
3777
  }
3778
+ return violations;
3689
3779
  }
3690
- async function validateContentDirectoryFiles(dirPath, fileSystem) {
3780
+ function validateIdeaTransitionTitle(filePath, content, ideasPath, fileSystem) {
3781
+ const title = extractTitle(content);
3782
+ if (!title) {
3783
+ return null;
3784
+ }
3785
+ for (const prefix of IDEA_TRANSITION_PREFIXES) {
3786
+ if (title.startsWith(prefix)) {
3787
+ const ideaTitle = title.slice(prefix.length);
3788
+ const ideaFilename = titleToFilename(ideaTitle);
3789
+ if (!fileSystem.exists(`${ideasPath}/${ideaFilename}`)) {
3790
+ return {
3791
+ file: filePath,
3792
+ message: `Idea transition task references non-existent idea: "${ideaTitle}" (expected file "${ideaFilename}" in ideas/)`
3793
+ };
3794
+ }
3795
+ return null;
3796
+ }
3797
+ }
3798
+ return null;
3799
+ }
3800
+
3801
+ // lib/lint/validators/link-validator.ts
3802
+ import { dirname as dirname4, resolve as resolve2 } from "node:path";
3803
+ var SEMANTIC_RULES = [
3804
+ {
3805
+ section: "## Goals",
3806
+ requiredPath: "/.dust/goals/",
3807
+ description: "goal"
3808
+ },
3809
+ {
3810
+ section: "## Blocked By",
3811
+ requiredPath: "/.dust/tasks/",
3812
+ description: "task"
3813
+ }
3814
+ ];
3815
+ function validateLinks(filePath, content, fileSystem) {
3691
3816
  const violations = [];
3692
- let entries;
3693
- try {
3694
- entries = await fileSystem.readdir(dirPath);
3695
- } catch (error) {
3696
- if (error.code === "ENOENT") {
3697
- return [];
3817
+ const lines = content.split(`
3818
+ `);
3819
+ const fileDir = dirname4(filePath);
3820
+ for (let i = 0;i < lines.length; i++) {
3821
+ const line = lines[i];
3822
+ const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
3823
+ let match = linkPattern.exec(line);
3824
+ while (match) {
3825
+ const linkTarget = match[2];
3826
+ if (!linkTarget.startsWith("http://") && !linkTarget.startsWith("https://") && !linkTarget.startsWith("#")) {
3827
+ const targetPath = linkTarget.split("#")[0];
3828
+ const resolvedPath = resolve2(fileDir, targetPath);
3829
+ if (!fileSystem.exists(resolvedPath)) {
3830
+ violations.push({
3831
+ file: filePath,
3832
+ message: `Broken link: "${linkTarget}"`,
3833
+ line: i + 1
3834
+ });
3835
+ }
3836
+ }
3837
+ match = linkPattern.exec(line);
3698
3838
  }
3699
- throw error;
3700
3839
  }
3701
- for (const entry of entries) {
3702
- const entryPath = `${dirPath}/${entry}`;
3703
- if (entry.startsWith(".")) {
3704
- violations.push({
3705
- file: entryPath,
3706
- message: `Hidden file "${entry}" found in content directory`
3707
- });
3840
+ return violations;
3841
+ }
3842
+ function validateSemanticLinks(filePath, content) {
3843
+ const violations = [];
3844
+ const lines = content.split(`
3845
+ `);
3846
+ const fileDir = dirname4(filePath);
3847
+ let currentSection = null;
3848
+ for (let i = 0;i < lines.length; i++) {
3849
+ const line = lines[i];
3850
+ if (line.startsWith("## ")) {
3851
+ currentSection = line;
3708
3852
  continue;
3709
3853
  }
3710
- if (fileSystem.isDirectory(entryPath)) {
3711
- violations.push({
3712
- file: entryPath,
3713
- message: `Subdirectory "${entry}" found in content directory (content directories should be flat)`
3714
- });
3854
+ const rule = SEMANTIC_RULES.find((r) => r.section === currentSection);
3855
+ if (!rule)
3715
3856
  continue;
3716
- }
3717
- if (!entry.endsWith(".md")) {
3718
- violations.push({
3719
- file: entryPath,
3720
- message: `Non-markdown file "${entry}" found in content directory`
3721
- });
3857
+ const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
3858
+ let match = linkPattern.exec(line);
3859
+ while (match) {
3860
+ const linkTarget = match[2];
3861
+ if (linkTarget.startsWith("#")) {
3862
+ violations.push({
3863
+ file: filePath,
3864
+ message: `Link in "${rule.section}" must point to a ${rule.description} file, not an anchor: "${linkTarget}"`,
3865
+ line: i + 1
3866
+ });
3867
+ match = linkPattern.exec(line);
3868
+ continue;
3869
+ }
3870
+ if (linkTarget.startsWith("http://") || linkTarget.startsWith("https://")) {
3871
+ violations.push({
3872
+ file: filePath,
3873
+ message: `Link in "${rule.section}" must point to a ${rule.description} file, not an external URL: "${linkTarget}"`,
3874
+ line: i + 1
3875
+ });
3876
+ match = linkPattern.exec(line);
3877
+ continue;
3878
+ }
3879
+ const targetPath = linkTarget.split("#")[0];
3880
+ const resolvedPath = resolve2(fileDir, targetPath);
3881
+ if (!resolvedPath.includes(rule.requiredPath)) {
3882
+ violations.push({
3883
+ file: filePath,
3884
+ message: `Link in "${rule.section}" must point to a ${rule.description} file: "${linkTarget}"`,
3885
+ line: i + 1
3886
+ });
3887
+ }
3888
+ match = linkPattern.exec(line);
3722
3889
  }
3723
3890
  }
3724
3891
  return violations;
3725
3892
  }
3726
- async function validateDirectoryStructure(dustPath, fileSystem, extraDirectories = []) {
3893
+ function validateGoalHierarchyLinks(filePath, content) {
3727
3894
  const violations = [];
3728
- let entries;
3729
- try {
3730
- entries = await fileSystem.readdir(dustPath);
3731
- } catch (error) {
3732
- if (error.code === "ENOENT") {
3733
- return [];
3895
+ const lines = content.split(`
3896
+ `);
3897
+ const fileDir = dirname4(filePath);
3898
+ let currentSection = null;
3899
+ for (let i = 0;i < lines.length; i++) {
3900
+ const line = lines[i];
3901
+ if (line.startsWith("## ")) {
3902
+ currentSection = line;
3903
+ continue;
3734
3904
  }
3735
- throw error;
3736
- }
3737
- const allowedDirectories = new Set([
3738
- ...EXPECTED_DIRECTORIES,
3739
- ...extraDirectories
3740
- ]);
3741
- for (const entry of entries) {
3742
- const entryPath = `${dustPath}/${entry}`;
3743
- if (!fileSystem.isDirectory(entryPath)) {
3905
+ if (currentSection !== "## Parent Goal" && currentSection !== "## Sub-Goals") {
3744
3906
  continue;
3745
3907
  }
3746
- if (!allowedDirectories.has(entry)) {
3747
- const allowedList = [...allowedDirectories].sort().join(", ");
3748
- violations.push({
3749
- file: entryPath,
3750
- message: `Unexpected directory "${entry}" in .dust/. Allowed directories: ${allowedList}. To allow this directory, add it to "extraDirectories" in .dust/config/settings.json`
3751
- });
3908
+ const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
3909
+ let match = linkPattern.exec(line);
3910
+ while (match) {
3911
+ const linkTarget = match[2];
3912
+ if (linkTarget.startsWith("#")) {
3913
+ violations.push({
3914
+ file: filePath,
3915
+ message: `Link in "${currentSection}" must point to a goal file, not an anchor: "${linkTarget}"`,
3916
+ line: i + 1
3917
+ });
3918
+ match = linkPattern.exec(line);
3919
+ continue;
3920
+ }
3921
+ if (linkTarget.startsWith("http://") || linkTarget.startsWith("https://")) {
3922
+ violations.push({
3923
+ file: filePath,
3924
+ message: `Link in "${currentSection}" must point to a goal file, not an external URL: "${linkTarget}"`,
3925
+ line: i + 1
3926
+ });
3927
+ match = linkPattern.exec(line);
3928
+ continue;
3929
+ }
3930
+ const targetPath = linkTarget.split("#")[0];
3931
+ const resolvedPath = resolve2(fileDir, targetPath);
3932
+ if (!resolvedPath.includes("/.dust/goals/")) {
3933
+ violations.push({
3934
+ file: filePath,
3935
+ message: `Link in "${currentSection}" must point to a goal file: "${linkTarget}"`,
3936
+ line: i + 1
3937
+ });
3938
+ }
3939
+ match = linkPattern.exec(line);
3752
3940
  }
3753
3941
  }
3754
3942
  return violations;
3755
3943
  }
3944
+
3945
+ // lib/cli/commands/lint-markdown.ts
3946
+ async function safeScanDir(glob, dirPath) {
3947
+ const files = [];
3948
+ try {
3949
+ for await (const file of glob.scan(dirPath)) {
3950
+ files.push(file);
3951
+ }
3952
+ return { files, exists: true };
3953
+ } catch (error) {
3954
+ if (error.code === "ENOENT") {
3955
+ return { files: [], exists: false };
3956
+ }
3957
+ throw error;
3958
+ }
3959
+ }
3756
3960
  async function lintMarkdown(dependencies) {
3757
3961
  const { context, fileSystem, globScanner: glob } = dependencies;
3758
3962
  const dustPath = `${context.cwd}/.dust`;
@@ -3766,7 +3970,7 @@ async function lintMarkdown(dependencies) {
3766
3970
  const violations = [];
3767
3971
  context.stdout("Validating directory structure...");
3768
3972
  violations.push(...await validateDirectoryStructure(dustPath, fileSystem, dependencies.settings.extraDirectories));
3769
- const settingsPath = join9(dustPath, "config", "settings.json");
3973
+ const settingsPath = join10(dustPath, "config", "settings.json");
3770
3974
  if (fileSystem.exists(settingsPath)) {
3771
3975
  context.stdout("Validating settings.json...");
3772
3976
  try {
@@ -3927,12 +4131,16 @@ async function lintMarkdown(dependencies) {
3927
4131
  }
3928
4132
 
3929
4133
  // lib/cli/commands/check.ts
4134
+ var log5 = createLogger("dust.cli.commands.check");
3930
4135
  var DEFAULT_CHECK_TIMEOUT_MS = 13000;
3931
4136
  async function runSingleCheck(check, cwd, runner) {
3932
4137
  const timeoutMs = check.timeoutMilliseconds ?? DEFAULT_CHECK_TIMEOUT_MS;
4138
+ log5(`running check ${check.name}: ${check.command}`);
3933
4139
  const startTime = Date.now();
3934
4140
  const result = await runner.run(check.command, cwd, timeoutMs);
3935
4141
  const durationMs = Date.now() - startTime;
4142
+ const status = result.timedOut ? "timed out" : result.exitCode === 0 ? "passed" : "failed";
4143
+ log5(`check ${check.name} ${status} (${durationMs}ms)`);
3936
4144
  return {
3937
4145
  name: check.name,
3938
4146
  command: check.command,
@@ -3962,6 +4170,7 @@ async function runValidationCheck(dependencies) {
3962
4170
  stdout: (msg) => outputLines.push(msg),
3963
4171
  stderr: (msg) => outputLines.push(msg)
3964
4172
  };
4173
+ log5("running built-in check: dust lint");
3965
4174
  const startTime = Date.now();
3966
4175
  const result = await lintMarkdown({
3967
4176
  ...dependencies,
@@ -3969,6 +4178,8 @@ async function runValidationCheck(dependencies) {
3969
4178
  arguments: []
3970
4179
  });
3971
4180
  const durationMs = Date.now() - startTime;
4181
+ const lintStatus = result.exitCode === 0 ? "passed" : "failed";
4182
+ log5(`built-in check dust lint ${lintStatus} (${durationMs}ms)`);
3972
4183
  return {
3973
4184
  name: "lint",
3974
4185
  command: "dust lint",
@@ -4024,6 +4235,7 @@ async function check(dependencies, shellRunner = defaultShellRunner) {
4024
4235
  settings
4025
4236
  } = dependencies;
4026
4237
  const serial = commandArguments.includes("--serial");
4238
+ setLogScope("check");
4027
4239
  if (!settings.checks || settings.checks.length === 0) {
4028
4240
  context.stderr("Error: No checks configured in .dust/config/settings.json");
4029
4241
  context.stderr("");
@@ -4155,7 +4367,7 @@ function agentsMdContent(dustCommand) {
4155
4367
  Always immediately run \`${dustCommand} agent\` when you start working in this repository.
4156
4368
  `;
4157
4369
  }
4158
- async function init(dependencies) {
4370
+ async function init2(dependencies) {
4159
4371
  const { context, fileSystem } = dependencies;
4160
4372
  const colors = getColors();
4161
4373
  const dustPath = `${context.cwd}/.dust`;
@@ -4363,7 +4575,7 @@ var defaultDependencies2 = {
4363
4575
  createInterface: nodeCreateInterface2
4364
4576
  };
4365
4577
  async function* spawnCodex(prompt, options = {}, dependencies = defaultDependencies2) {
4366
- const { cwd, env } = options;
4578
+ const { cwd, env, signal } = options;
4367
4579
  const codexArguments = ["exec", prompt, "--json", "--yolo"];
4368
4580
  if (cwd) {
4369
4581
  codexArguments.push("--cd", cwd);
@@ -4375,22 +4587,14 @@ async function* spawnCodex(prompt, options = {}, dependencies = defaultDependenc
4375
4587
  if (!proc.stdout) {
4376
4588
  throw new Error("Failed to get stdout from codex process");
4377
4589
  }
4378
- const rl = dependencies.createInterface({ input: proc.stdout });
4379
- for await (const line of rl) {
4380
- if (!line.trim())
4381
- continue;
4382
- try {
4383
- yield JSON.parse(line);
4384
- } catch {}
4385
- }
4386
4590
  let stderrOutput = "";
4387
4591
  proc.stderr?.on("data", (data) => {
4388
4592
  stderrOutput += data.toString();
4389
4593
  });
4390
- await new Promise((resolve2, reject) => {
4594
+ const closePromise = new Promise((resolve3, reject) => {
4391
4595
  proc.on("close", (code) => {
4392
4596
  if (code === 0 || code === null)
4393
- resolve2();
4597
+ resolve3();
4394
4598
  else {
4395
4599
  const errMsg = stderrOutput.trim() ? `codex exited with code ${code}: ${stderrOutput.trim()}` : `codex exited with code ${code}`;
4396
4600
  reject(new Error(errMsg));
@@ -4398,6 +4602,30 @@ async function* spawnCodex(prompt, options = {}, dependencies = defaultDependenc
4398
4602
  });
4399
4603
  proc.on("error", reject);
4400
4604
  });
4605
+ const abortHandler = () => {
4606
+ if (!proc.killed) {
4607
+ proc.kill();
4608
+ }
4609
+ };
4610
+ if (signal?.aborted) {
4611
+ abortHandler();
4612
+ } else if (signal) {
4613
+ signal.addEventListener("abort", abortHandler, { once: true });
4614
+ }
4615
+ const rl = dependencies.createInterface({ input: proc.stdout });
4616
+ try {
4617
+ for await (const line of rl) {
4618
+ if (!line.trim())
4619
+ continue;
4620
+ try {
4621
+ yield JSON.parse(line);
4622
+ } catch {}
4623
+ }
4624
+ await closePromise;
4625
+ } finally {
4626
+ signal?.removeEventListener("abort", abortHandler);
4627
+ rl.close?.();
4628
+ }
4401
4629
  }
4402
4630
 
4403
4631
  // lib/codex/event-parser.ts
@@ -4785,7 +5013,7 @@ async function facts(dependencies) {
4785
5013
 
4786
5014
  // lib/cli/main.ts
4787
5015
  var commandRegistry = {
4788
- init,
5016
+ init: init2,
4789
5017
  lint: lintMarkdown,
4790
5018
  list,
4791
5019
  tasks,