@joshski/dust 0.1.56 → 0.1.58

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
@@ -1177,7 +1177,7 @@ function getLogLines(buffer) {
1177
1177
  }
1178
1178
 
1179
1179
  // lib/bucket/repository.ts
1180
- import { dirname as dirname2, join as join8 } from "node:path";
1180
+ import { dirname as dirname3, 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";
@@ -1595,6 +1595,9 @@ async function run(prompt, options = {}, dependencies = defaultRunnerDependencie
1595
1595
  await dependencies.streamEvents(events, sink, onRawEvent);
1596
1596
  }
1597
1597
 
1598
+ // lib/logging/index.ts
1599
+ import { join as join5 } from "node:path";
1600
+
1598
1601
  // lib/logging/match.ts
1599
1602
  function parsePatterns(debug) {
1600
1603
  if (!debug)
@@ -1617,56 +1620,105 @@ function formatLine(name, messages) {
1617
1620
 
1618
1621
  // lib/logging/sink.ts
1619
1622
  import { appendFileSync, mkdirSync } from "node:fs";
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;
1623
+ import { dirname } from "node:path";
1624
+
1625
+ class FileSink {
1626
+ logPath;
1627
+ _appendFileSync;
1628
+ _mkdirSync;
1629
+ resolvedPath;
1641
1630
  ready = false;
1631
+ constructor(logPath, _appendFileSync = appendFileSync, _mkdirSync = mkdirSync) {
1632
+ this.logPath = logPath;
1633
+ this._appendFileSync = _appendFileSync;
1634
+ this._mkdirSync = _mkdirSync;
1635
+ }
1636
+ ensureLogFile() {
1637
+ if (this.ready)
1638
+ return this.resolvedPath;
1639
+ this.ready = true;
1640
+ this.resolvedPath = this.logPath;
1641
+ try {
1642
+ this._mkdirSync(dirname(this.logPath), { recursive: true });
1643
+ } catch {
1644
+ this.resolvedPath = undefined;
1645
+ }
1646
+ return this.resolvedPath;
1647
+ }
1648
+ write(line) {
1649
+ const path = this.ensureLogFile();
1650
+ if (!path)
1651
+ return;
1652
+ try {
1653
+ this._appendFileSync(path, line);
1654
+ } catch {}
1655
+ }
1642
1656
  }
1643
- var writeToFile = (line) => {
1644
- const path = ensureLogFile();
1645
- if (!path)
1646
- return;
1647
- try {
1648
- appendFileSync(path, line);
1649
- } catch {}
1650
- };
1651
1657
 
1652
1658
  // 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))
1659
+ var DUST_LOG_FILE = "DUST_LOG_FILE";
1660
+ function createLoggingService() {
1661
+ let patterns = null;
1662
+ let initialized = false;
1663
+ let activeFileSink = null;
1664
+ const fileSinkCache = new Map;
1665
+ function init() {
1666
+ if (initialized)
1666
1667
  return;
1667
- write(formatLine(name, messages));
1668
+ initialized = true;
1669
+ const parsed = parsePatterns(process.env.DEBUG);
1670
+ patterns = parsed.length > 0 ? parsed : null;
1671
+ }
1672
+ function getOrCreateFileSink(path) {
1673
+ let sink = fileSinkCache.get(path);
1674
+ if (!sink) {
1675
+ sink = new FileSink(path);
1676
+ fileSinkCache.set(path, sink);
1677
+ }
1678
+ return sink;
1679
+ }
1680
+ return {
1681
+ enableFileLogs(scope, sinkForTesting) {
1682
+ const existing = process.env[DUST_LOG_FILE];
1683
+ const logDir = process.env.DUST_LOG_DIR ?? join5(process.cwd(), "log");
1684
+ const path = existing ?? join5(logDir, `${scope}.log`);
1685
+ if (!existing) {
1686
+ process.env[DUST_LOG_FILE] = path;
1687
+ }
1688
+ activeFileSink = sinkForTesting ?? new FileSink(path);
1689
+ },
1690
+ createLogger(name, options) {
1691
+ let perLoggerSink;
1692
+ if (options?.file === false) {
1693
+ perLoggerSink = null;
1694
+ } else if (typeof options?.file === "string") {
1695
+ perLoggerSink = getOrCreateFileSink(options.file);
1696
+ }
1697
+ return (...messages) => {
1698
+ init();
1699
+ const line = formatLine(name, messages);
1700
+ if (perLoggerSink !== undefined) {
1701
+ if (perLoggerSink !== null) {
1702
+ perLoggerSink.write(line);
1703
+ }
1704
+ } else if (activeFileSink) {
1705
+ activeFileSink.write(line);
1706
+ }
1707
+ if (patterns && matchesAny(name, patterns)) {
1708
+ process.stdout.write(line);
1709
+ }
1710
+ };
1711
+ },
1712
+ isEnabled(name) {
1713
+ init();
1714
+ return patterns !== null && matchesAny(name, patterns);
1715
+ }
1668
1716
  };
1669
1717
  }
1718
+ var defaultService = createLoggingService();
1719
+ var enableFileLogs = defaultService.enableFileLogs.bind(defaultService);
1720
+ var createLogger = defaultService.createLogger.bind(defaultService);
1721
+ var isEnabled = defaultService.isEnabled.bind(defaultService);
1670
1722
 
1671
1723
  // lib/bucket/repository-git.ts
1672
1724
  import { join as join6 } from "node:path";
@@ -1742,7 +1794,7 @@ function formatAgentEvent(event) {
1742
1794
  import { spawn as nodeSpawn2 } from "node:child_process";
1743
1795
  import { readFileSync } from "node:fs";
1744
1796
  import os from "node:os";
1745
- import { dirname, join as join7 } from "node:path";
1797
+ import { dirname as dirname2, join as join7 } from "node:path";
1746
1798
  import { fileURLToPath } from "node:url";
1747
1799
 
1748
1800
  // lib/workflow-tasks.ts
@@ -1887,7 +1939,7 @@ async function next(dependencies) {
1887
1939
  }
1888
1940
 
1889
1941
  // lib/cli/commands/loop.ts
1890
- var __dirname2 = dirname(fileURLToPath(import.meta.url));
1942
+ var __dirname2 = dirname2(fileURLToPath(import.meta.url));
1891
1943
  function getDustVersion() {
1892
1944
  const candidates = [
1893
1945
  join7(__dirname2, "../../../package.json"),
@@ -1970,7 +2022,7 @@ function createWireEventSender(eventsUrl, sessionId, postEvent, onError, getAgen
1970
2022
  postEvent(eventsUrl, payload).catch(onError);
1971
2023
  };
1972
2024
  }
1973
- var log = createLogger("dust.cli.commands.loop");
2025
+ var log = createLogger("dust:cli:commands:loop");
1974
2026
  var SLEEP_INTERVAL_MS = 30000;
1975
2027
  var SLEEP_STEP_MS = 1000;
1976
2028
  var DEFAULT_MAX_ITERATIONS = 10;
@@ -2137,6 +2189,7 @@ function parseMaxIterations(commandArguments) {
2137
2189
  return parsed;
2138
2190
  }
2139
2191
  async function loopClaude(dependencies, loopDependencies = createDefaultDependencies()) {
2192
+ enableFileLogs("loop");
2140
2193
  const { context, settings } = dependencies;
2141
2194
  const { postEvent } = loopDependencies;
2142
2195
  const maxIterations = parseMaxIterations(dependencies.arguments);
@@ -2200,7 +2253,7 @@ async function loopClaude(dependencies, loopDependencies = createDefaultDependen
2200
2253
  }
2201
2254
 
2202
2255
  // lib/bucket/repository-loop.ts
2203
- var log2 = createLogger("dust.bucket.repository-loop");
2256
+ var log2 = createLogger("dust:bucket:repository-loop");
2204
2257
  var FALLBACK_TIMEOUT_MS = 300000;
2205
2258
  function createNoOpGlobScanner() {
2206
2259
  return {
@@ -2349,7 +2402,7 @@ async function runRepositoryLoop(repoState, repoDeps, sendEvent, sessionId) {
2349
2402
  }
2350
2403
 
2351
2404
  // lib/bucket/repository.ts
2352
- var log3 = createLogger("dust.bucket.repository");
2405
+ var log3 = createLogger("dust:bucket:repository");
2353
2406
  function startRepositoryLoop(repoState, repoDeps, sendEvent, sessionId) {
2354
2407
  log3(`starting loop for ${repoState.repository.name}`);
2355
2408
  repoState.stopRequested = false;
@@ -2391,7 +2444,7 @@ async function addRepository(repository, manager, repoDeps, context) {
2391
2444
  }
2392
2445
  log3(`adding repository ${repository.name}`);
2393
2446
  const repoPath = getRepoPath(repository.name, repoDeps.getReposDir());
2394
- await repoDeps.fileSystem.mkdir(dirname2(repoPath), { recursive: true });
2447
+ await repoDeps.fileSystem.mkdir(dirname3(repoPath), { recursive: true });
2395
2448
  if (repoDeps.fileSystem.exists(repoPath)) {
2396
2449
  await removeRepository(repoPath, repoDeps.spawn, context);
2397
2450
  }
@@ -2884,7 +2937,7 @@ function handleKeyInput(state, key, options) {
2884
2937
  }
2885
2938
 
2886
2939
  // lib/cli/commands/bucket.ts
2887
- var log4 = createLogger("dust.cli.commands.bucket");
2940
+ var log4 = createLogger("dust:cli:commands:bucket");
2888
2941
  var DEFAULT_DUSTBUCKET_WS_URL = "wss://dustbucket.com/agent/connect";
2889
2942
  var INITIAL_RECONNECT_DELAY_MS = 1000;
2890
2943
  var MAX_RECONNECT_DELAY_MS = 30000;
@@ -3312,6 +3365,7 @@ async function resolveToken(authDeps, context) {
3312
3365
  }
3313
3366
  }
3314
3367
  async function bucket(dependencies, bucketDeps = createDefaultBucketDependencies()) {
3368
+ enableFileLogs("bucket");
3315
3369
  const { context, fileSystem } = dependencies;
3316
3370
  const token = await resolveToken(bucketDeps.auth, context);
3317
3371
  if (!token) {
@@ -3602,7 +3656,7 @@ function validateTitleFilenameMatch(filePath, content) {
3602
3656
  }
3603
3657
 
3604
3658
  // lib/lint/validators/goal-hierarchy.ts
3605
- import { dirname as dirname3, resolve } from "node:path";
3659
+ import { dirname as dirname4, resolve } from "node:path";
3606
3660
  var REQUIRED_GOAL_HEADINGS = ["## Parent Goal", "## Sub-Goals"];
3607
3661
  function validateGoalHierarchySections(filePath, content) {
3608
3662
  const violations = [];
@@ -3619,7 +3673,7 @@ function validateGoalHierarchySections(filePath, content) {
3619
3673
  function extractGoalRelationships(filePath, content) {
3620
3674
  const lines = content.split(`
3621
3675
  `);
3622
- const fileDir = dirname3(filePath);
3676
+ const fileDir = dirname4(filePath);
3623
3677
  const parentGoals = [];
3624
3678
  const subGoals = [];
3625
3679
  let currentSection = null;
@@ -3828,7 +3882,7 @@ function validateIdeaTransitionTitle(filePath, content, ideasPath, fileSystem) {
3828
3882
  }
3829
3883
 
3830
3884
  // lib/lint/validators/link-validator.ts
3831
- import { dirname as dirname4, resolve as resolve2 } from "node:path";
3885
+ import { dirname as dirname5, resolve as resolve2 } from "node:path";
3832
3886
  var SEMANTIC_RULES = [
3833
3887
  {
3834
3888
  section: "## Goals",
@@ -3845,7 +3899,7 @@ function validateLinks(filePath, content, fileSystem) {
3845
3899
  const violations = [];
3846
3900
  const lines = content.split(`
3847
3901
  `);
3848
- const fileDir = dirname4(filePath);
3902
+ const fileDir = dirname5(filePath);
3849
3903
  for (let i = 0;i < lines.length; i++) {
3850
3904
  const line = lines[i];
3851
3905
  const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
@@ -3872,7 +3926,7 @@ function validateSemanticLinks(filePath, content) {
3872
3926
  const violations = [];
3873
3927
  const lines = content.split(`
3874
3928
  `);
3875
- const fileDir = dirname4(filePath);
3929
+ const fileDir = dirname5(filePath);
3876
3930
  let currentSection = null;
3877
3931
  for (let i = 0;i < lines.length; i++) {
3878
3932
  const line = lines[i];
@@ -3923,7 +3977,7 @@ function validateGoalHierarchyLinks(filePath, content) {
3923
3977
  const violations = [];
3924
3978
  const lines = content.split(`
3925
3979
  `);
3926
- const fileDir = dirname4(filePath);
3980
+ const fileDir = dirname5(filePath);
3927
3981
  let currentSection = null;
3928
3982
  for (let i = 0;i < lines.length; i++) {
3929
3983
  const line = lines[i];
@@ -4160,8 +4214,26 @@ async function lintMarkdown(dependencies) {
4160
4214
  }
4161
4215
 
4162
4216
  // lib/cli/commands/check.ts
4163
- var log5 = createLogger("dust.cli.commands.check");
4217
+ var log5 = createLogger("dust:cli:commands:check");
4164
4218
  var DEFAULT_CHECK_TIMEOUT_MS = 13000;
4219
+ var MAX_OUTPUT_LINES = 500;
4220
+ var KEEP_LINES = 250;
4221
+ function truncateOutput(output) {
4222
+ const lines = output.split(`
4223
+ `);
4224
+ if (lines.length <= MAX_OUTPUT_LINES) {
4225
+ return output;
4226
+ }
4227
+ const snippedCount = lines.length - KEEP_LINES * 2;
4228
+ const firstLines = lines.slice(0, KEEP_LINES);
4229
+ const lastLines = lines.slice(-KEEP_LINES);
4230
+ return [
4231
+ ...firstLines,
4232
+ `[...snip ${snippedCount} lines...]`,
4233
+ ...lastLines
4234
+ ].join(`
4235
+ `);
4236
+ }
4165
4237
  async function runSingleCheck(check, cwd, runner) {
4166
4238
  const timeoutMs = check.timeoutMilliseconds ?? DEFAULT_CHECK_TIMEOUT_MS;
4167
4239
  log5(`running check ${check.name}: ${check.command}`);
@@ -4241,7 +4313,7 @@ function displayResults(results, context) {
4241
4313
  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`);
4242
4314
  }
4243
4315
  if (result.output.trim()) {
4244
- context.stdout(result.output.trimEnd());
4316
+ context.stdout(truncateOutput(result.output).trimEnd());
4245
4317
  }
4246
4318
  if (result.hints && result.hints.length > 0) {
4247
4319
  context.stdout("");
@@ -4264,7 +4336,7 @@ async function check(dependencies, shellRunner = defaultShellRunner) {
4264
4336
  settings
4265
4337
  } = dependencies;
4266
4338
  const serial = commandArguments.includes("--serial");
4267
- setLogScope("check");
4339
+ enableFileLogs("check");
4268
4340
  if (!settings.checks || settings.checks.length === 0) {
4269
4341
  context.stderr("Error: No checks configured in .dust/config/settings.json");
4270
4342
  context.stderr("");
@@ -4396,7 +4468,7 @@ function agentsMdContent(dustCommand) {
4396
4468
  Always immediately run \`${dustCommand} agent\` when you start working in this repository.
4397
4469
  `;
4398
4470
  }
4399
- async function init2(dependencies) {
4471
+ async function init(dependencies) {
4400
4472
  const { context, fileSystem } = dependencies;
4401
4473
  const colors = getColors();
4402
4474
  const dustPath = `${context.cwd}/.dust`;
@@ -5042,7 +5114,7 @@ async function facts(dependencies) {
5042
5114
 
5043
5115
  // lib/cli/main.ts
5044
5116
  var commandRegistry = {
5045
- init: init2,
5117
+ init,
5046
5118
  lint: lintMarkdown,
5047
5119
  list,
5048
5120
  tasks,
@@ -5066,7 +5138,6 @@ var commandRegistry = {
5066
5138
  help
5067
5139
  };
5068
5140
  var COMMANDS = Object.keys(commandRegistry).filter((cmd) => !cmd.includes(" "));
5069
- var HELP_TEXT = generateHelpText({ dustCommand: "dust" });
5070
5141
  function isHelpRequest(command) {
5071
5142
  return !command || command === "help" || command === "--help" || command === "-h";
5072
5143
  }
package/dist/ideas.js ADDED
@@ -0,0 +1,135 @@
1
+ // lib/markdown/markdown-utilities.ts
2
+ function extractTitle(content) {
3
+ const match = content.match(/^#\s+(.+)$/m);
4
+ return match ? match[1].trim() : null;
5
+ }
6
+ function extractOpeningSentence(content) {
7
+ const lines = content.split(`
8
+ `);
9
+ let h1Index = -1;
10
+ for (let i = 0;i < lines.length; i++) {
11
+ if (lines[i].match(/^#\s+.+$/)) {
12
+ h1Index = i;
13
+ break;
14
+ }
15
+ }
16
+ if (h1Index === -1) {
17
+ return null;
18
+ }
19
+ let paragraphStart = -1;
20
+ for (let i = h1Index + 1;i < lines.length; i++) {
21
+ const line = lines[i].trim();
22
+ if (line !== "") {
23
+ paragraphStart = i;
24
+ break;
25
+ }
26
+ }
27
+ if (paragraphStart === -1) {
28
+ return null;
29
+ }
30
+ const firstLine = lines[paragraphStart];
31
+ const trimmedFirstLine = firstLine.trim();
32
+ if (trimmedFirstLine.startsWith("#") || trimmedFirstLine.startsWith("-") || trimmedFirstLine.startsWith("*") || trimmedFirstLine.startsWith("+") || trimmedFirstLine.match(/^\d+\./) || trimmedFirstLine.startsWith("```") || trimmedFirstLine.startsWith(">")) {
33
+ return null;
34
+ }
35
+ let paragraph = "";
36
+ for (let i = paragraphStart;i < lines.length; i++) {
37
+ const line = lines[i].trim();
38
+ if (line === "")
39
+ break;
40
+ if (line.startsWith("#") || line.startsWith("```") || line.startsWith(">")) {
41
+ break;
42
+ }
43
+ paragraph += (paragraph ? " " : "") + line;
44
+ }
45
+ const sentenceMatch = paragraph.match(/^(.+?[.?!])(?:\s|$)/);
46
+ if (!sentenceMatch) {
47
+ return null;
48
+ }
49
+ return sentenceMatch[1];
50
+ }
51
+
52
+ // lib/ideas.ts
53
+ function parseOpenQuestions(content) {
54
+ const lines = content.split(`
55
+ `);
56
+ const questions = [];
57
+ let inOpenQuestions = false;
58
+ let currentQuestion = null;
59
+ let currentOption = null;
60
+ let descriptionLines = [];
61
+ function flushOption() {
62
+ if (currentOption) {
63
+ currentOption.description = descriptionLines.join(`
64
+ `).trim();
65
+ descriptionLines = [];
66
+ currentOption = null;
67
+ }
68
+ }
69
+ function flushQuestion() {
70
+ flushOption();
71
+ if (currentQuestion) {
72
+ questions.push(currentQuestion);
73
+ currentQuestion = null;
74
+ }
75
+ }
76
+ for (const line of lines) {
77
+ if (line.startsWith("## ")) {
78
+ if (inOpenQuestions) {
79
+ flushQuestion();
80
+ }
81
+ inOpenQuestions = line.trimEnd() === "## Open Questions";
82
+ continue;
83
+ }
84
+ if (!inOpenQuestions)
85
+ continue;
86
+ if (line.startsWith("### ")) {
87
+ flushQuestion();
88
+ currentQuestion = {
89
+ question: line.slice(4).trim(),
90
+ options: []
91
+ };
92
+ continue;
93
+ }
94
+ if (line.startsWith("#### ")) {
95
+ flushOption();
96
+ currentOption = {
97
+ name: line.slice(5).trim(),
98
+ description: ""
99
+ };
100
+ if (currentQuestion) {
101
+ currentQuestion.options.push(currentOption);
102
+ }
103
+ continue;
104
+ }
105
+ if (currentOption) {
106
+ descriptionLines.push(line);
107
+ }
108
+ }
109
+ flushQuestion();
110
+ return questions;
111
+ }
112
+ async function parseIdea(fileSystem, dustPath, slug) {
113
+ const ideaPath = `${dustPath}/ideas/${slug}.md`;
114
+ if (!fileSystem.exists(ideaPath)) {
115
+ throw new Error(`Idea not found: "${slug}" (expected file at ${ideaPath})`);
116
+ }
117
+ const content = await fileSystem.readFile(ideaPath);
118
+ const title = extractTitle(content);
119
+ if (!title) {
120
+ throw new Error(`Idea file has no title: ${ideaPath}`);
121
+ }
122
+ const openingSentence = extractOpeningSentence(content);
123
+ const openQuestions = parseOpenQuestions(content);
124
+ return {
125
+ slug,
126
+ title,
127
+ openingSentence,
128
+ content,
129
+ openQuestions
130
+ };
131
+ }
132
+ export {
133
+ parseOpenQuestions,
134
+ parseIdea
135
+ };
@@ -1,12 +1,19 @@
1
1
  /**
2
2
  * Minimal debug logging framework.
3
3
  *
4
- * When the DEBUG environment variable is set, matching loggers write
5
- * timestamped lines to `<cwd>/log/dust/<scope>.log`.
4
+ * Two independent output channels:
6
5
  *
7
- * The scope defaults to "debug" but can be changed via setLogScope()
8
- * so that different commands (e.g. `loop`, `check`, `bucket`) write
9
- * to separate log files.
6
+ * - **File logging** activated by `enableFileLogs(scope)` at command startup.
7
+ * Writes all logs to `./log/<scope>.log` by default. Two env vars control routing:
8
+ *
9
+ * Routing rules for enableFileLogs(scope):
10
+ * 1. If DUST_LOG_FILE is already set (inherited from a parent process such as
11
+ * `dust check`), use that path — all scopes land in the same file.
12
+ * 2. Otherwise compute the path from DUST_LOG_DIR (if set) or `<cwd>/log`, set
13
+ * DUST_LOG_FILE so child processes inherit the same destination, then write there.
14
+ *
15
+ * - **Stdout logging** — activated by `DEBUG=pattern`. Writes matching logs to
16
+ * stdout. Works in any command, regardless of whether file logging is enabled.
10
17
  *
11
18
  * DEBUG is a comma-separated list of match expressions. Each expression
12
19
  * can contain `*` as a wildcard (matches any sequence of characters).
@@ -21,24 +28,53 @@
21
28
  * structure under `lib/`. For example, `lib/bucket/repository-loop.ts` uses
22
29
  * the logger name `dust.bucket.repository-loop`.
23
30
  *
31
+ * ## Per-logger file routing
32
+ *
33
+ * `createLogger(name, { file })` accepts an optional `file` override:
34
+ * - `file: "path/to/file.log"` — route this logger to a dedicated file instead
35
+ * of the global sink set by `enableFileLogs`. The path is resolved relative to
36
+ * the log directory.
37
+ * - `file: false` — suppress file output for this logger entirely, even if
38
+ * `enableFileLogs` has been called. Stdout behavior (DEBUG matching) is
39
+ * unchanged.
40
+ * - When `file` is omitted, the logger uses the global file sink (if any).
41
+ *
42
+ * Sink selection precedence: per-logger sink → global sink → no file sink.
43
+ * File sinks are cached by path so multiple loggers targeting the same file
44
+ * share one sink instance.
45
+ *
24
46
  * No external dependencies.
25
47
  */
26
- import { type WriteFn } from './sink';
27
- export { setLogScope } from './sink';
48
+ import { type LogSink } from './sink';
28
49
  export type LogFn = (...messages: unknown[]) => void;
50
+ export interface LoggerOptions {
51
+ /**
52
+ * Per-logger file routing override.
53
+ * - `string` — path to a dedicated log file (e.g. `"./log/custom.log"`)
54
+ * - `false` — suppress file output for this logger
55
+ * - `undefined` — use the global file sink from `enableFileLogs` (default)
56
+ */
57
+ file?: string | false;
58
+ }
59
+ export interface LoggingService {
60
+ enableFileLogs(scope: string, sinkForTesting?: LogSink): void;
61
+ createLogger(name: string, options?: LoggerOptions): LogFn;
62
+ isEnabled(name: string): boolean;
63
+ }
29
64
  /**
30
- * Create a named logger function. The returned function writes to
31
- * `log/dust/<scope>.log` when the logger name matches the DEBUG patterns.
32
- *
33
- * @param name - Logger name, e.g. `dust.bucket.loop`
34
- * @param write - Override the default file writer (for testing)
65
+ * Create an isolated logging service instance. All mutable state is
66
+ * encapsulated inside the returned object.
67
+ */
68
+ export declare function createLoggingService(): LoggingService;
69
+ /**
70
+ * Activate file logging for this command. See {@link LoggingService.enableFileLogs}.
35
71
  */
36
- export declare function createLogger(name: string, write?: WriteFn): LogFn;
72
+ export declare const enableFileLogs: LoggingService['enableFileLogs'];
37
73
  /**
38
- * Check whether a logger name would be enabled under the current DEBUG value.
74
+ * Create a named logger function. See {@link LoggingService.createLogger}.
39
75
  */
40
- export declare function isEnabled(name: string): boolean;
76
+ export declare const createLogger: LoggingService['createLogger'];
41
77
  /**
42
- * Reset internal state (for testing only).
78
+ * Check whether a logger name would produce stdout output under the current DEBUG value.
43
79
  */
44
- export declare function _reset(): void;
80
+ export declare const isEnabled: LoggingService['isEnabled'];
@@ -1,24 +1,29 @@
1
1
  /**
2
2
  * File-based log sink — the imperative shell for debug logging.
3
3
  *
4
- * Lazily creates `<cwd>/log/dust/<scope>.log` and appends lines to it.
5
- * The scope defaults to "debug" but can be changed via setLogScope()
6
- * so that different commands write to separate log files.
4
+ * Writes log lines to an arbitrary file path, creating the directory lazily
5
+ * on first write. The path is determined by the caller (enableFileLogs in
6
+ * index.ts) rather than this class.
7
7
  */
8
- export type WriteFn = (line: string) => void;
9
- /**
10
- * Set the log scope, which determines the output filename.
11
- * Must be called before any logger writes (i.e. at command startup).
12
- *
13
- * For example, `setLogScope('loop')` writes to `log/dust/loop.log`.
14
- */
15
- export declare function setLogScope(name: string): void;
16
- /**
17
- * Write a line to the debug log file.
18
- * Silently no-ops if the file cannot be opened.
19
- */
20
- export declare const writeToFile: WriteFn;
21
- /**
22
- * Reset sink state (for testing only).
23
- */
24
- export declare function _resetSink(): void;
8
+ export interface LogSink {
9
+ write(line: string): void;
10
+ }
11
+ type AppendFileSyncFn = (path: string, data: string) => void;
12
+ type MkdirSyncFn = (path: string, options: {
13
+ recursive: boolean;
14
+ }) => void;
15
+ export declare class FileSink implements LogSink {
16
+ private readonly logPath;
17
+ private readonly _appendFileSync;
18
+ private readonly _mkdirSync;
19
+ private resolvedPath;
20
+ private ready;
21
+ constructor(logPath: string, _appendFileSync?: AppendFileSyncFn, _mkdirSync?: MkdirSyncFn);
22
+ private ensureLogFile;
23
+ /**
24
+ * Write a line to the log file.
25
+ * Silently no-ops if the file cannot be opened.
26
+ */
27
+ write(line: string): void;
28
+ }
29
+ export {};
package/dist/logging.js CHANGED
@@ -1,3 +1,6 @@
1
+ // lib/logging/index.ts
2
+ import { join } from "node:path";
3
+
1
4
  // lib/logging/match.ts
2
5
  function parsePatterns(debug) {
3
6
  if (!debug)
@@ -20,67 +23,108 @@ function formatLine(name, messages) {
20
23
 
21
24
  // lib/logging/sink.ts
22
25
  import { appendFileSync, mkdirSync } from "node:fs";
23
- import { join } from "node:path";
24
- var logPath;
25
- var ready = false;
26
- var scope = process.env.DEBUG_LOG_SCOPE || "debug";
27
- function ensureLogFile() {
28
- if (ready)
29
- return logPath;
30
- ready = true;
31
- const dir = join(process.cwd(), "log", "dust");
32
- logPath = join(dir, `${scope}.log`);
33
- try {
34
- mkdirSync(dir, { recursive: true });
35
- } catch {
36
- logPath = undefined;
37
- }
38
- return logPath;
39
- }
40
- function setLogScope(name) {
41
- scope = name;
42
- process.env.DEBUG_LOG_SCOPE = name;
43
- logPath = undefined;
26
+ import { dirname } from "node:path";
27
+
28
+ class FileSink {
29
+ logPath;
30
+ _appendFileSync;
31
+ _mkdirSync;
32
+ resolvedPath;
44
33
  ready = false;
34
+ constructor(logPath, _appendFileSync = appendFileSync, _mkdirSync = mkdirSync) {
35
+ this.logPath = logPath;
36
+ this._appendFileSync = _appendFileSync;
37
+ this._mkdirSync = _mkdirSync;
38
+ }
39
+ ensureLogFile() {
40
+ if (this.ready)
41
+ return this.resolvedPath;
42
+ this.ready = true;
43
+ this.resolvedPath = this.logPath;
44
+ try {
45
+ this._mkdirSync(dirname(this.logPath), { recursive: true });
46
+ } catch {
47
+ this.resolvedPath = undefined;
48
+ }
49
+ return this.resolvedPath;
50
+ }
51
+ write(line) {
52
+ const path = this.ensureLogFile();
53
+ if (!path)
54
+ return;
55
+ try {
56
+ this._appendFileSync(path, line);
57
+ } catch {}
58
+ }
45
59
  }
46
- var writeToFile = (line) => {
47
- const path = ensureLogFile();
48
- if (!path)
49
- return;
50
- try {
51
- appendFileSync(path, line);
52
- } catch {}
53
- };
54
60
 
55
61
  // lib/logging/index.ts
56
- var patterns = null;
57
- var initialized = false;
58
- function init() {
59
- if (initialized)
60
- return;
61
- initialized = true;
62
- const parsed = parsePatterns(process.env.DEBUG);
63
- patterns = parsed.length > 0 ? parsed : null;
64
- }
65
- function createLogger(name, write = writeToFile) {
66
- return (...messages) => {
67
- init();
68
- if (!patterns || !matchesAny(name, patterns))
62
+ var DUST_LOG_FILE = "DUST_LOG_FILE";
63
+ function createLoggingService() {
64
+ let patterns = null;
65
+ let initialized = false;
66
+ let activeFileSink = null;
67
+ const fileSinkCache = new Map;
68
+ function init() {
69
+ if (initialized)
69
70
  return;
70
- write(formatLine(name, messages));
71
+ initialized = true;
72
+ const parsed = parsePatterns(process.env.DEBUG);
73
+ patterns = parsed.length > 0 ? parsed : null;
74
+ }
75
+ function getOrCreateFileSink(path) {
76
+ let sink = fileSinkCache.get(path);
77
+ if (!sink) {
78
+ sink = new FileSink(path);
79
+ fileSinkCache.set(path, sink);
80
+ }
81
+ return sink;
82
+ }
83
+ return {
84
+ enableFileLogs(scope, sinkForTesting) {
85
+ const existing = process.env[DUST_LOG_FILE];
86
+ const logDir = process.env.DUST_LOG_DIR ?? join(process.cwd(), "log");
87
+ const path = existing ?? join(logDir, `${scope}.log`);
88
+ if (!existing) {
89
+ process.env[DUST_LOG_FILE] = path;
90
+ }
91
+ activeFileSink = sinkForTesting ?? new FileSink(path);
92
+ },
93
+ createLogger(name, options) {
94
+ let perLoggerSink;
95
+ if (options?.file === false) {
96
+ perLoggerSink = null;
97
+ } else if (typeof options?.file === "string") {
98
+ perLoggerSink = getOrCreateFileSink(options.file);
99
+ }
100
+ return (...messages) => {
101
+ init();
102
+ const line = formatLine(name, messages);
103
+ if (perLoggerSink !== undefined) {
104
+ if (perLoggerSink !== null) {
105
+ perLoggerSink.write(line);
106
+ }
107
+ } else if (activeFileSink) {
108
+ activeFileSink.write(line);
109
+ }
110
+ if (patterns && matchesAny(name, patterns)) {
111
+ process.stdout.write(line);
112
+ }
113
+ };
114
+ },
115
+ isEnabled(name) {
116
+ init();
117
+ return patterns !== null && matchesAny(name, patterns);
118
+ }
71
119
  };
72
120
  }
73
- function isEnabled(name) {
74
- init();
75
- return patterns !== null && matchesAny(name, patterns);
76
- }
77
- function _reset() {
78
- initialized = false;
79
- patterns = null;
80
- }
121
+ var defaultService = createLoggingService();
122
+ var enableFileLogs = defaultService.enableFileLogs.bind(defaultService);
123
+ var createLogger = defaultService.createLogger.bind(defaultService);
124
+ var isEnabled = defaultService.isEnabled.bind(defaultService);
81
125
  export {
82
- setLogScope,
83
126
  isEnabled,
84
- createLogger,
85
- _reset
127
+ enableFileLogs,
128
+ createLoggingService,
129
+ createLogger
86
130
  };
@@ -21,25 +21,36 @@ function formatMetrics(metrics) {
21
21
  return parts.join(', ')
22
22
  }
23
23
 
24
- function getUncoveredLines(fileCoverage) {
24
+ function getGapLines(fileCoverage) {
25
+ const gapLines = new Set()
26
+
25
27
  const lineCoverage = fileCoverage.getLineCoverage()
28
+ for (const [lineStr, hits] of Object.entries(lineCoverage)) {
29
+ if (hits === 0) gapLines.add(Number.parseInt(lineStr, 10))
30
+ }
31
+
32
+ const { branchMap, b } = fileCoverage
33
+ for (const [id, branch] of Object.entries(branchMap)) {
34
+ if (b[id].some(hits => hits === 0)) {
35
+ gapLines.add(branch.loc.start.line)
36
+ }
37
+ }
38
+
39
+ const sorted = [...gapLines].sort((a, b) => a - b)
26
40
  const ranges = []
27
41
  let rangeStart = null
28
42
  let rangeEnd = null
29
43
 
30
- for (const [lineStr, hits] of Object.entries(lineCoverage)) {
31
- const line = Number.parseInt(lineStr, 10)
32
- if (hits === 0) {
33
- if (rangeStart === null) {
34
- rangeStart = line
35
- rangeEnd = line
36
- } else if (line === rangeEnd + 1) {
37
- rangeEnd = line
38
- } else {
39
- ranges.push([rangeStart, rangeEnd])
40
- rangeStart = line
41
- rangeEnd = line
42
- }
44
+ for (const line of sorted) {
45
+ if (rangeStart === null) {
46
+ rangeStart = line
47
+ rangeEnd = line
48
+ } else if (line === rangeEnd + 1) {
49
+ rangeEnd = line
50
+ } else {
51
+ ranges.push([rangeStart, rangeEnd])
52
+ rangeStart = line
53
+ rangeEnd = line
43
54
  }
44
55
  }
45
56
 
@@ -68,7 +79,12 @@ class IncompleteCoverageReporter extends ReportBase {
68
79
  },
69
80
  })
70
81
 
71
- if (incompleteFiles.length === 0) return
82
+ if (incompleteFiles.length === 0) {
83
+ const cw = context.writer.writeFile(null)
84
+ cw.println('✔ 100% coverage!')
85
+ cw.close()
86
+ return
87
+ }
72
88
 
73
89
  const cw = context.writer.writeFile(null)
74
90
  const count = incompleteFiles.length
@@ -78,7 +94,7 @@ class IncompleteCoverageReporter extends ReportBase {
78
94
  for (const file of incompleteFiles) {
79
95
  cw.println('')
80
96
  cw.println(`${file.name} (${formatMetrics(file.metrics)})`)
81
- for (const line of getUncoveredLines(file.fileCoverage)) {
97
+ for (const line of getGapLines(file.fileCoverage)) {
82
98
  cw.println(`- ${line}`)
83
99
  }
84
100
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joshski/dust",
3
- "version": "0.1.56",
3
+ "version": "0.1.58",
4
4
  "description": "Flow state for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,10 @@
22
22
  "import": "./dist/agents.js",
23
23
  "types": "./dist/agents/detection.d.ts"
24
24
  },
25
+ "./ideas": {
26
+ "import": "./dist/ideas.js",
27
+ "types": "./dist/ideas.d.ts"
28
+ },
25
29
  "./istanbul/minimal-reporter": "./lib/istanbul/minimal-reporter.cjs"
26
30
  },
27
31
  "files": [
@@ -42,7 +46,7 @@
42
46
  "author": "joshski",
43
47
  "license": "MIT",
44
48
  "scripts": {
45
- "build": "bun build lib/cli/run.ts --target node --outfile dist/dust.js && printf '%s\\n%s' '#!/usr/bin/env node' \"$(cat dist/dust.js)\" > dist/dust.js && bun build lib/workflow-tasks.ts --target node --outfile dist/workflow-tasks.js && bun build lib/logging/index.ts --target node --outfile dist/logging.js && bun build lib/agents/detection.ts --target node --outfile dist/agents.js && bunx tsc --project tsconfig.build.json",
49
+ "build": "bun build lib/cli/run.ts --target node --outfile dist/dust.js && printf '%s\\n%s' '#!/usr/bin/env node' \"$(cat dist/dust.js)\" > dist/dust.js && bun build lib/workflow-tasks.ts --target node --outfile dist/workflow-tasks.js && bun build lib/logging/index.ts --target node --outfile dist/logging.js && bun build lib/agents/detection.ts --target node --outfile dist/agents.js && bun build lib/ideas.ts --target node --outfile dist/ideas.js && bunx tsc --project tsconfig.build.json",
46
50
  "test": "vitest run",
47
51
  "test:coverage": "vitest run --coverage",
48
52
  "eval": "bun run ./evals/run.ts"