@joshski/dust 0.1.65 → 0.1.67

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/artifacts.js CHANGED
@@ -302,19 +302,53 @@ async function findAllCaptureIdeaTasks(fileSystem, dustPath) {
302
302
  function titleToFilename(title) {
303
303
  return `${title.toLowerCase().replace(/\./g, "-").replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "")}.md`;
304
304
  }
305
- var WORKFLOW_TASK_TYPES = [
306
- { type: "refine", prefix: "Refine Idea: " },
307
- { type: "decompose-idea", prefix: "Decompose Idea: " },
308
- { type: "shelve", prefix: "Shelve Idea: " }
305
+ var WORKFLOW_SECTION_HEADINGS = [
306
+ { type: "refine", heading: "Refines Idea" },
307
+ { type: "decompose-idea", heading: "Decomposes Idea" },
308
+ { type: "shelve", heading: "Shelves Idea" }
309
309
  ];
310
+ function extractIdeaSlugFromSection(content, sectionHeading) {
311
+ const lines = content.split(`
312
+ `);
313
+ let inSection = false;
314
+ for (const line of lines) {
315
+ if (line.startsWith("## ")) {
316
+ inSection = line.trimEnd() === `## ${sectionHeading}`;
317
+ continue;
318
+ }
319
+ if (!inSection)
320
+ continue;
321
+ if (line.startsWith("# "))
322
+ break;
323
+ const linkMatch = line.match(MARKDOWN_LINK_PATTERN);
324
+ if (linkMatch) {
325
+ const target = linkMatch[2];
326
+ const slugMatch = target.match(/([^/]+)\.md$/);
327
+ if (slugMatch) {
328
+ return slugMatch[1];
329
+ }
330
+ }
331
+ }
332
+ return null;
333
+ }
310
334
  async function findWorkflowTaskForIdea(fileSystem, dustPath, ideaSlug) {
311
- const ideaTitle = await readIdeaTitle(fileSystem, dustPath, ideaSlug);
312
- for (const { type, prefix } of WORKFLOW_TASK_TYPES) {
313
- const filename = titleToFilename(`${prefix}${ideaTitle}`);
314
- const filePath = `${dustPath}/tasks/${filename}`;
315
- if (fileSystem.exists(filePath)) {
316
- const taskSlug = filename.replace(/\.md$/, "");
317
- return { type, ideaSlug, taskSlug };
335
+ const ideaPath = `${dustPath}/ideas/${ideaSlug}.md`;
336
+ if (!fileSystem.exists(ideaPath)) {
337
+ throw new Error(`Idea not found: "${ideaSlug}" (expected file at ${ideaPath})`);
338
+ }
339
+ const tasksPath = `${dustPath}/tasks`;
340
+ if (!fileSystem.exists(tasksPath)) {
341
+ return null;
342
+ }
343
+ const files = await fileSystem.readdir(tasksPath);
344
+ for (const file of files.filter((f) => f.endsWith(".md")).sort()) {
345
+ const content = await fileSystem.readFile(`${tasksPath}/${file}`);
346
+ for (const { type, heading } of WORKFLOW_SECTION_HEADINGS) {
347
+ const linkedSlug = extractIdeaSlugFromSection(content, heading);
348
+ if (linkedSlug === ideaSlug) {
349
+ const taskSlug = file.replace(/\.md$/, "");
350
+ return { type, ideaSlug, taskSlug };
351
+ }
318
352
  }
319
353
  }
320
354
  return null;
@@ -342,18 +376,26 @@ ${sections.join(`
342
376
  `)}
343
377
  `;
344
378
  }
345
- function renderTask(title, openingSentence, definitionOfDone, options) {
379
+ function renderIdeaSection(ideaSection) {
380
+ return `## ${ideaSection.heading}
381
+
382
+ - [${ideaSection.ideaTitle}](../ideas/${ideaSection.ideaSlug}.md)
383
+ `;
384
+ }
385
+ function renderTask(title, openingSentence, definitionOfDone, ideaSection, options) {
346
386
  const descriptionParagraph = options?.description !== undefined ? `
347
387
  ${options.description}
348
388
  ` : "";
349
389
  const resolvedSection = options?.resolvedQuestions && options.resolvedQuestions.length > 0 ? `
350
390
  ${renderResolvedQuestions(options.resolvedQuestions)}
351
391
  ` : "";
392
+ const ideaSectionContent = `
393
+ ${renderIdeaSection(ideaSection)}
394
+ `;
352
395
  return `# ${title}
353
396
 
354
397
  ${openingSentence}
355
- ${descriptionParagraph}${resolvedSection}
356
- ## Blocked By
398
+ ${descriptionParagraph}${resolvedSection}${ideaSectionContent}## Blocked By
357
399
 
358
400
  (none)
359
401
 
@@ -363,13 +405,17 @@ ${definitionOfDone.map((item) => `- [ ] ${item}`).join(`
363
405
  `)}
364
406
  `;
365
407
  }
366
- async function createIdeaTask(fileSystem, dustPath, prefix, ideaSlug, openingSentenceTemplate, definitionOfDone, taskOptions) {
408
+ async function createIdeaTask(fileSystem, dustPath, prefix, ideaSlug, openingSentenceTemplate, definitionOfDone, ideaSectionHeading, taskOptions) {
367
409
  const ideaTitle = await readIdeaTitle(fileSystem, dustPath, ideaSlug);
368
410
  const taskTitle = `${prefix}${ideaTitle}`;
369
411
  const filename = titleToFilename(taskTitle);
370
412
  const filePath = `${dustPath}/tasks/${filename}`;
371
413
  const openingSentence = openingSentenceTemplate(ideaTitle);
372
- const content = renderTask(taskTitle, openingSentence, definitionOfDone, taskOptions);
414
+ const ideaSection = { heading: ideaSectionHeading, ideaTitle, ideaSlug };
415
+ const content = renderTask(taskTitle, openingSentence, definitionOfDone, ideaSection, {
416
+ description: taskOptions?.description,
417
+ resolvedQuestions: taskOptions?.resolvedQuestions
418
+ });
373
419
  await fileSystem.writeFile(filePath, content);
374
420
  return { filePath };
375
421
  }
@@ -379,20 +425,20 @@ async function createRefineIdeaTask(fileSystem, dustPath, ideaSlug, description)
379
425
  "Open questions are added for any ambiguous or underspecified aspects",
380
426
  "Open questions follow the required heading format and focus on high-value decisions",
381
427
  "Idea file is updated with findings"
382
- ], { description });
428
+ ], "Refines Idea", { description });
383
429
  }
384
430
  async function decomposeIdea(fileSystem, dustPath, options) {
385
431
  return createIdeaTask(fileSystem, dustPath, "Decompose Idea: ", options.ideaSlug, (ideaTitle) => `Create one or more well-defined tasks from this idea. Prefer smaller, narrowly scoped tasks that each deliver a thin but complete vertical slice of working software -- a path through the system that can be tested end-to-end -- rather than component-oriented tasks (like "add schema" or "build endpoint") that only work once all tasks are done. Split the idea into multiple tasks if it covers more than one logical change. Review \`.dust/principles/\` to link relevant principles and \`.dust/facts/\` for design decisions that should inform the task. See [${ideaTitle}](../ideas/${options.ideaSlug}.md).`, [
386
432
  "One or more new tasks are created in .dust/tasks/",
387
433
  "Task's Principles section links to relevant principles from .dust/principles/",
388
434
  "The original idea is deleted or updated to reflect remaining scope"
389
- ], {
435
+ ], "Decomposes Idea", {
390
436
  description: options.description,
391
437
  resolvedQuestions: options.openQuestionResponses
392
438
  });
393
439
  }
394
440
  async function createShelveIdeaTask(fileSystem, dustPath, ideaSlug, description) {
395
- return createIdeaTask(fileSystem, dustPath, "Shelve Idea: ", ideaSlug, (ideaTitle) => `Archive this idea and remove it from the active backlog. See [${ideaTitle}](../ideas/${ideaSlug}.md).`, ["Idea file is deleted", "Rationale is recorded in the commit message"], { description });
441
+ return createIdeaTask(fileSystem, dustPath, "Shelve Idea: ", ideaSlug, (ideaTitle) => `Archive this idea and remove it from the active backlog. See [${ideaTitle}](../ideas/${ideaSlug}.md).`, ["Idea file is deleted", "Rationale is recorded in the commit message"], "Shelves Idea", { description });
396
442
  }
397
443
  async function createCaptureIdeaTask(fileSystem, dustPath, options) {
398
444
  const { title, description, buildItNow } = options;
@@ -4,9 +4,10 @@
4
4
  * Users can override any of these by placing a file with the same name
5
5
  * in .dust/config/audits/.
6
6
  */
7
- export interface StockAudit {
7
+ interface StockAudit {
8
8
  name: string;
9
9
  description: string;
10
10
  template: string;
11
11
  }
12
12
  export declare function loadStockAudits(): StockAudit[];
13
+ export {};
@@ -2,7 +2,7 @@
2
2
  * Common types for CLI commands
3
3
  */
4
4
  import type { FileSystem, GlobScanner } from '../filesystem/types';
5
- export type { FileSystem, GlobScanner, ReadableFileSystem, WriteOptions, } from '../filesystem/types';
5
+ export type { FileSystem, GlobScanner, ReadableFileSystem, } from '../filesystem/types';
6
6
  export interface CommandContext {
7
7
  cwd: string;
8
8
  stdout: (message: string) => void;
package/dist/dust.js CHANGED
@@ -275,7 +275,25 @@ async function loadSettings(cwd, fileSystem) {
275
275
  }
276
276
 
277
277
  // lib/version.ts
278
- var DUST_VERSION = "0.1.65";
278
+ var DUST_VERSION = "0.1.67";
279
+
280
+ // lib/session.ts
281
+ var DUST_UNATTENDED = "DUST_UNATTENDED";
282
+ var DUST_SKIP_AGENT = "DUST_SKIP_AGENT";
283
+ var DUST_REPOSITORY_ID = "DUST_REPOSITORY_ID";
284
+ function isUnattended(env = process.env) {
285
+ return !!env[DUST_UNATTENDED];
286
+ }
287
+ function buildUnattendedEnv(options) {
288
+ const env = {
289
+ [DUST_UNATTENDED]: "1",
290
+ [DUST_SKIP_AGENT]: "1"
291
+ };
292
+ if (options?.repositoryId) {
293
+ env[DUST_REPOSITORY_ID] = options.repositoryId;
294
+ }
295
+ return env;
296
+ }
279
297
 
280
298
  // lib/cli/dedent.ts
281
299
  function dedent(strings, ...values) {
@@ -524,7 +542,7 @@ ${vars.agentInstructions}` : "";
524
542
  }
525
543
  async function agent(dependencies, env = process.env) {
526
544
  const { context, fileSystem, settings } = dependencies;
527
- if (env.DUST_SKIP_AGENT === "1") {
545
+ if (env[DUST_SKIP_AGENT] === "1") {
528
546
  context.stdout("You're running in an automated loop - proceeding to implement the assigned task.");
529
547
  return { exitCode: 0 };
530
548
  }
@@ -2189,13 +2207,7 @@ async function runOneIteration(dependencies, loopDependencies, onLoopEvent, onAg
2189
2207
  logger = log,
2190
2208
  repositoryId
2191
2209
  } = options;
2192
- const baseEnv = {
2193
- DUST_UNATTENDED: "1",
2194
- DUST_SKIP_AGENT: "1"
2195
- };
2196
- if (repositoryId) {
2197
- baseEnv.DUST_REPOSITORY_ID = repositoryId;
2198
- }
2210
+ const baseEnv = buildUnattendedEnv({ repositoryId });
2199
2211
  log("syncing with remote");
2200
2212
  onLoopEvent({ type: "loop.syncing" });
2201
2213
  const pullResult = await gitPull(context.cwd, spawn);
@@ -2319,6 +2331,10 @@ function parseMaxIterations(commandArguments) {
2319
2331
  async function loopClaude(dependencies, loopDependencies = createDefaultDependencies()) {
2320
2332
  enableFileLogs("loop");
2321
2333
  const { context, settings } = dependencies;
2334
+ if (isUnattended()) {
2335
+ context.stderr("dust loop cannot run inside an unattended session (DUST_UNATTENDED is set)");
2336
+ return { exitCode: 1 };
2337
+ }
2322
2338
  const { postEvent } = loopDependencies;
2323
2339
  const maxIterations = parseMaxIterations(dependencies.arguments);
2324
2340
  const eventsUrl = settings.eventsUrl;
@@ -3502,9 +3518,13 @@ async function resolveToken(authDeps, context) {
3502
3518
  return null;
3503
3519
  }
3504
3520
  }
3505
- async function bucket(dependencies, bucketDeps = createDefaultBucketDependencies()) {
3521
+ async function bucketWorker(dependencies, bucketDeps = createDefaultBucketDependencies()) {
3506
3522
  enableFileLogs("bucket");
3507
3523
  const { context, fileSystem } = dependencies;
3524
+ if (isUnattended()) {
3525
+ context.stderr("dust bucket cannot run inside an unattended session (DUST_UNATTENDED is set)");
3526
+ return { exitCode: 1 };
3527
+ }
3508
3528
  const token = await resolveToken(bucketDeps.auth, context);
3509
3529
  if (!token) {
3510
3530
  return { exitCode: 1 };
@@ -3964,6 +3984,11 @@ function validateTitleFilenameMatch(filePath, content) {
3964
3984
  }
3965
3985
 
3966
3986
  // lib/lint/validators/idea-validator.ts
3987
+ var WORKFLOW_PREFIX_TO_SECTION = {
3988
+ "Refine Idea: ": "Refines Idea",
3989
+ "Decompose Idea: ": "Decomposes Idea",
3990
+ "Shelve Idea: ": "Shelves Idea"
3991
+ };
3967
3992
  function validateIdeaOpenQuestions(filePath, content) {
3968
3993
  const violations = [];
3969
3994
  const lines = content.split(`
@@ -4077,6 +4102,114 @@ function validateIdeaTransitionTitle(filePath, content, ideasPath, fileSystem) {
4077
4102
  }
4078
4103
  return null;
4079
4104
  }
4105
+ function extractSectionContent(content, sectionHeading) {
4106
+ const lines = content.split(`
4107
+ `);
4108
+ let inSection = false;
4109
+ let sectionContent = "";
4110
+ let startLine = 0;
4111
+ for (let i = 0;i < lines.length; i++) {
4112
+ const line = lines[i];
4113
+ if (line.startsWith("## ")) {
4114
+ if (inSection)
4115
+ break;
4116
+ if (line.trimEnd() === `## ${sectionHeading}`) {
4117
+ inSection = true;
4118
+ startLine = i + 1;
4119
+ }
4120
+ continue;
4121
+ }
4122
+ if (line.startsWith("# ") && inSection)
4123
+ break;
4124
+ if (inSection) {
4125
+ sectionContent += `${line}
4126
+ `;
4127
+ }
4128
+ }
4129
+ if (!inSection)
4130
+ return null;
4131
+ return { content: sectionContent, startLine };
4132
+ }
4133
+ function validateWorkflowTaskBodySection(filePath, content, ideasPath, fileSystem) {
4134
+ const violations = [];
4135
+ const title = extractTitle(content);
4136
+ if (!title)
4137
+ return violations;
4138
+ let matchedPrefix = null;
4139
+ for (const prefix of IDEA_TRANSITION_PREFIXES) {
4140
+ if (title.startsWith(prefix)) {
4141
+ matchedPrefix = prefix;
4142
+ break;
4143
+ }
4144
+ }
4145
+ if (!matchedPrefix)
4146
+ return violations;
4147
+ const expectedHeading = WORKFLOW_PREFIX_TO_SECTION[matchedPrefix];
4148
+ const section = extractSectionContent(content, expectedHeading);
4149
+ if (!section) {
4150
+ violations.push({
4151
+ file: filePath,
4152
+ message: `Workflow task with "${matchedPrefix.trim()}" prefix is missing required "## ${expectedHeading}" section. Add a section with a link to the idea file, e.g.:
4153
+
4154
+ ## ${expectedHeading}
4155
+
4156
+ - [Idea Title](../ideas/idea-slug.md)`
4157
+ });
4158
+ return violations;
4159
+ }
4160
+ const linkRegex = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
4161
+ const links = [];
4162
+ const sectionLines = section.content.split(`
4163
+ `);
4164
+ for (let i = 0;i < sectionLines.length; i++) {
4165
+ const line = sectionLines[i];
4166
+ let match = linkRegex.exec(line);
4167
+ while (match !== null) {
4168
+ links.push({
4169
+ text: match[1],
4170
+ target: match[2],
4171
+ line: section.startLine + i + 1
4172
+ });
4173
+ match = linkRegex.exec(line);
4174
+ }
4175
+ }
4176
+ if (links.length === 0) {
4177
+ violations.push({
4178
+ file: filePath,
4179
+ message: `"## ${expectedHeading}" section contains no link. Add a markdown link to the idea file, e.g.:
4180
+
4181
+ - [Idea Title](../ideas/idea-slug.md)`,
4182
+ line: section.startLine
4183
+ });
4184
+ return violations;
4185
+ }
4186
+ const ideaLinks = links.filter((l) => l.target.includes("/ideas/") || l.target.startsWith("../ideas/"));
4187
+ if (ideaLinks.length === 0) {
4188
+ violations.push({
4189
+ file: filePath,
4190
+ message: `"## ${expectedHeading}" section contains no link to an idea file. Links must point to a file in ../ideas/, e.g.:
4191
+
4192
+ - [Idea Title](../ideas/idea-slug.md)`,
4193
+ line: section.startLine
4194
+ });
4195
+ return violations;
4196
+ }
4197
+ for (const link of ideaLinks) {
4198
+ const slugMatch = link.target.match(/([^/]+)\.md$/);
4199
+ if (!slugMatch)
4200
+ continue;
4201
+ const ideaSlug = slugMatch[1];
4202
+ const ideaFilePath = `${ideasPath}/${ideaSlug}.md`;
4203
+ if (!fileSystem.exists(ideaFilePath)) {
4204
+ violations.push({
4205
+ file: filePath,
4206
+ message: `Link to idea "${link.text}" points to non-existent file: ${ideaSlug}.md. Either create the idea file at ideas/${ideaSlug}.md or update the link to point to an existing idea.`,
4207
+ line: link.line
4208
+ });
4209
+ }
4210
+ }
4211
+ return violations;
4212
+ }
4080
4213
 
4081
4214
  // lib/lint/validators/link-validator.ts
4082
4215
  import { dirname as dirname3, resolve } from "node:path";
@@ -4480,6 +4613,7 @@ async function lintMarkdown(dependencies) {
4480
4613
  if (ideaTransitionViolation) {
4481
4614
  violations.push(ideaTransitionViolation);
4482
4615
  }
4616
+ violations.push(...validateWorkflowTaskBodySection(filePath, content, ideasPath, fileSystem));
4483
4617
  }
4484
4618
  }
4485
4619
  const principlesPath = `${dustPath}/principles`;
@@ -5428,7 +5562,7 @@ async function prePush(dependencies, gitRunner = defaultGitRunner, env = process
5428
5562
  if (agent2.type === "unknown") {
5429
5563
  return { exitCode: 0 };
5430
5564
  }
5431
- if (env.DUST_UNATTENDED) {
5565
+ if (isUnattended(env)) {
5432
5566
  const uncommittedFiles = await getUncommittedFiles(context.cwd, gitRunner);
5433
5567
  if (uncommittedFiles.length > 0) {
5434
5568
  context.stderr("");
@@ -5498,7 +5632,7 @@ var commandRegistry = {
5498
5632
  check,
5499
5633
  agent,
5500
5634
  audit,
5501
- bucket,
5635
+ "bucket worker": bucketWorker,
5502
5636
  "bucket asset upload": bucketAssetUpload,
5503
5637
  focus,
5504
5638
  "new task": newTask,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joshski/dust",
3
- "version": "0.1.65",
3
+ "version": "0.1.67",
4
4
  "description": "Flow state for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -63,9 +63,8 @@
63
63
  "@biomejs/biome": "^2.3.13",
64
64
  "@types/bun": "^1.3.6",
65
65
  "@vitest/coverage-v8": "^4.0.18",
66
+ "istanbul-lib-coverage": "^3.2.2",
67
+ "istanbul-lib-report": "^3.0.1",
66
68
  "vitest": "^4.0.18"
67
- },
68
- "dependencies": {
69
- "@joshski/dust": "^0.1.49"
70
69
  }
71
70
  }