@joshski/dust 0.1.72 → 0.1.74

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.
@@ -3,9 +3,9 @@ import { type Fact } from './facts';
3
3
  import { type Idea, type IdeaOpenQuestion, type IdeaOption, parseOpenQuestions } from './ideas';
4
4
  import { type Principle } from './principles';
5
5
  import { type Task } from './tasks';
6
- import { type AllWorkflowTasks, CAPTURE_IDEA_PREFIX, type CreateIdeaTransitionTaskResult, type DecomposeIdeaOptions, findAllCaptureIdeaTasks, findAllWorkflowTasks, type IdeaInProgress, type OpenQuestionResponse, type ParsedCaptureIdeaTask, type WorkflowTaskMatch } from './workflow-tasks';
6
+ import { type AllWorkflowTasks, CAPTURE_IDEA_PREFIX, type CreateIdeaTransitionTaskResult, type DecomposeIdeaOptions, findAllWorkflowTasks, type IdeaInProgress, type OpenQuestionResponse, type ParsedCaptureIdeaTask, type WorkflowTaskMatch } from './workflow-tasks';
7
7
  export type { AllWorkflowTasks, CreateIdeaTransitionTaskResult, DecomposeIdeaOptions, Fact, Idea, IdeaOpenQuestion, IdeaOption, OpenQuestionResponse, ParsedCaptureIdeaTask, Principle, Task, WorkflowTaskMatch, };
8
- export { CAPTURE_IDEA_PREFIX, findAllCaptureIdeaTasks, findAllWorkflowTasks, parseOpenQuestions, };
8
+ export { CAPTURE_IDEA_PREFIX, findAllWorkflowTasks, parseOpenQuestions };
9
9
  export type { IdeaInProgress };
10
10
  export interface ArtifactsRepository {
11
11
  parseIdea(options: {
@@ -27,16 +27,21 @@ export interface ArtifactsRepository {
27
27
  createRefineIdeaTask(options: {
28
28
  ideaSlug: string;
29
29
  description?: string;
30
+ dustCommand?: string;
31
+ }): Promise<CreateIdeaTransitionTaskResult>;
32
+ createDecomposeIdeaTask(options: DecomposeIdeaOptions & {
33
+ dustCommand?: string;
30
34
  }): Promise<CreateIdeaTransitionTaskResult>;
31
- createDecomposeIdeaTask(options: DecomposeIdeaOptions): Promise<CreateIdeaTransitionTaskResult>;
32
35
  createShelveIdeaTask(options: {
33
36
  ideaSlug: string;
34
37
  description?: string;
38
+ dustCommand?: string;
35
39
  }): Promise<CreateIdeaTransitionTaskResult>;
36
40
  createIdeaTask(options: {
37
41
  title: string;
38
42
  description: string;
39
43
  expedite?: boolean;
44
+ dustCommand?: string;
40
45
  }): Promise<CreateIdeaTransitionTaskResult>;
41
46
  findWorkflowTaskForIdea(options: {
42
47
  ideaSlug: string;
@@ -11,7 +11,6 @@ export interface ParsedCaptureIdeaTask {
11
11
  ideaDescription: string;
12
12
  expedite: boolean;
13
13
  }
14
- export declare function findAllCaptureIdeaTasks(fileSystem: ReadableFileSystem, dustPath: string): Promise<IdeaInProgress[]>;
15
14
  /**
16
15
  * Converts a markdown title to the expected filename using deterministic rules:
17
16
  * 1. Convert to lowercase
@@ -46,12 +45,13 @@ export interface DecomposeIdeaOptions {
46
45
  description?: string;
47
46
  openQuestionResponses?: OpenQuestionResponse[];
48
47
  }
49
- export declare function createRefineIdeaTask(fileSystem: FileSystem, dustPath: string, ideaSlug: string, description?: string): Promise<CreateIdeaTransitionTaskResult>;
50
- export declare function decomposeIdea(fileSystem: FileSystem, dustPath: string, options: DecomposeIdeaOptions): Promise<CreateIdeaTransitionTaskResult>;
51
- export declare function createShelveIdeaTask(fileSystem: FileSystem, dustPath: string, ideaSlug: string, description?: string): Promise<CreateIdeaTransitionTaskResult>;
48
+ export declare function createRefineIdeaTask(fileSystem: FileSystem, dustPath: string, ideaSlug: string, description?: string, dustCommand?: string): Promise<CreateIdeaTransitionTaskResult>;
49
+ export declare function decomposeIdea(fileSystem: FileSystem, dustPath: string, options: DecomposeIdeaOptions, dustCommand?: string): Promise<CreateIdeaTransitionTaskResult>;
50
+ export declare function createShelveIdeaTask(fileSystem: FileSystem, dustPath: string, ideaSlug: string, description?: string, _dustCommand?: string): Promise<CreateIdeaTransitionTaskResult>;
52
51
  export declare function createIdeaTask(fileSystem: FileSystem, dustPath: string, options: {
53
52
  title: string;
54
53
  description: string;
55
54
  expedite?: boolean;
55
+ dustCommand?: string;
56
56
  }): Promise<CreateIdeaTransitionTaskResult>;
57
57
  export declare function parseCaptureIdeaTask(fileSystem: ReadableFileSystem, dustPath: string, taskSlug: string): Promise<ParsedCaptureIdeaTask | null>;
package/dist/artifacts.js CHANGED
@@ -273,32 +273,6 @@ async function parseTask(fileSystem, dustPath, slug) {
273
273
  // lib/artifacts/workflow-tasks.ts
274
274
  var CAPTURE_IDEA_PREFIX = "Add Idea: ";
275
275
  var EXPEDITE_IDEA_PREFIX = "Expedite Idea: ";
276
- async function findAllCaptureIdeaTasks(fileSystem, dustPath) {
277
- const tasksPath = `${dustPath}/tasks`;
278
- if (!fileSystem.exists(tasksPath))
279
- return [];
280
- const files = await fileSystem.readdir(tasksPath);
281
- const results = [];
282
- for (const file of files.filter((f) => f.endsWith(".md")).sort()) {
283
- const content = await fileSystem.readFile(`${tasksPath}/${file}`);
284
- const titleMatch = content.match(/^#\s+(.+)$/m);
285
- if (!titleMatch)
286
- continue;
287
- const title = titleMatch[1].trim();
288
- if (title.startsWith(CAPTURE_IDEA_PREFIX)) {
289
- results.push({
290
- taskSlug: file.replace(/\.md$/, ""),
291
- ideaTitle: title.slice(CAPTURE_IDEA_PREFIX.length)
292
- });
293
- } else if (title.startsWith(EXPEDITE_IDEA_PREFIX)) {
294
- results.push({
295
- taskSlug: file.replace(/\.md$/, ""),
296
- ideaTitle: title.slice(EXPEDITE_IDEA_PREFIX.length)
297
- });
298
- }
299
- }
300
- return results;
301
- }
302
276
  function titleToFilename(title) {
303
277
  return `${title.toLowerCase().replace(/\./g, "-").replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "")}.md`;
304
278
  }
@@ -458,16 +432,18 @@ async function createIdeaTransitionTask(fileSystem, dustPath, prefix, ideaSlug,
458
432
  await fileSystem.writeFile(filePath, content);
459
433
  return { filePath };
460
434
  }
461
- async function createRefineIdeaTask(fileSystem, dustPath, ideaSlug, description) {
462
- return createIdeaTransitionTask(fileSystem, dustPath, "Refine Idea: ", ideaSlug, (ideaTitle) => `Thoroughly research this idea and refine it into a well-defined proposal. Read the idea file, explore the codebase for relevant context, and identify any ambiguity. Where aspects are unclear or could go multiple ways, add open questions to the idea file. Review \`.dust/principles/\` for alignment and \`.dust/facts/\` for relevant design decisions. See [${ideaTitle}](../ideas/${ideaSlug}.md). If you add open questions, use \`## Open Questions\` with \`### Question?\` headings and one or more \`#### Option\` headings beneath each question, and only add questions that are meaningful decisions worth asking.`, [
435
+ async function createRefineIdeaTask(fileSystem, dustPath, ideaSlug, description, dustCommand) {
436
+ const cmd = dustCommand ?? "dust";
437
+ return createIdeaTransitionTask(fileSystem, dustPath, "Refine Idea: ", ideaSlug, (ideaTitle) => `Thoroughly research this idea and refine it into a well-defined proposal. Read the idea file, explore the codebase for relevant context, and identify any ambiguity. Where aspects are unclear or could go multiple ways, add open questions to the idea file. Run \`${cmd} principles\` for alignment and \`${cmd} facts\` for relevant design decisions. See [${ideaTitle}](../ideas/${ideaSlug}.md). If you add open questions, use \`## Open Questions\` with \`### Question?\` headings and one or more \`#### Option\` headings beneath each question, and only add questions that are meaningful decisions worth asking.`, [
463
438
  "Idea is thoroughly researched with relevant codebase context",
464
439
  "Open questions are added for any ambiguous or underspecified aspects",
465
440
  "Open questions follow the required heading format and focus on high-value decisions",
466
441
  "Idea file is updated with findings"
467
442
  ], "Refines Idea", { description });
468
443
  }
469
- async function decomposeIdea(fileSystem, dustPath, options) {
470
- return createIdeaTransitionTask(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).`, [
444
+ async function decomposeIdea(fileSystem, dustPath, options, dustCommand) {
445
+ const cmd = dustCommand ?? "dust";
446
+ return createIdeaTransitionTask(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. Run \`${cmd} principles\` to link relevant principles and \`${cmd} facts\` for design decisions that should inform the task. See [${ideaTitle}](../ideas/${options.ideaSlug}.md).`, [
471
447
  "One or more new tasks are created in .dust/tasks/",
472
448
  "Task's Principles section links to relevant principles from .dust/principles/",
473
449
  "The original idea is deleted or updated to reflect remaining scope"
@@ -476,11 +452,12 @@ async function decomposeIdea(fileSystem, dustPath, options) {
476
452
  resolvedQuestions: options.openQuestionResponses
477
453
  });
478
454
  }
479
- async function createShelveIdeaTask(fileSystem, dustPath, ideaSlug, description) {
455
+ async function createShelveIdeaTask(fileSystem, dustPath, ideaSlug, description, _dustCommand) {
480
456
  return createIdeaTransitionTask(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 });
481
457
  }
482
458
  async function createIdeaTask(fileSystem, dustPath, options) {
483
- const { title, description, expedite } = options;
459
+ const { title, description, expedite, dustCommand } = options;
460
+ const cmd = dustCommand ?? "dust";
484
461
  if (!title || !title.trim()) {
485
462
  throw new Error("title is required and must not be whitespace-only");
486
463
  }
@@ -493,7 +470,7 @@ async function createIdeaTask(fileSystem, dustPath, options) {
493
470
  const filePath2 = `${dustPath}/tasks/${filename2}`;
494
471
  const content2 = `# ${taskTitle2}
495
472
 
496
- Research this idea briefly. If confident the implementation is straightforward (clear scope, minimal risk, no open questions), implement directly and commit. Otherwise, create one or more narrowly-scoped task files in \`.dust/tasks/\`. Review \`.dust/principles/\` and \`.dust/facts/\` for relevant context.
473
+ Research this idea briefly. If confident the implementation is straightforward (clear scope, minimal risk, no open questions), implement directly and commit. Otherwise, create one or more narrowly-scoped task files in \`.dust/tasks/\`. Run \`${cmd} principles\` and \`${cmd} facts\` for relevant context.
497
474
 
498
475
  ## Idea Description
499
476
 
@@ -517,7 +494,7 @@ ${description}
517
494
  const filePath = `${dustPath}/tasks/${filename}`;
518
495
  const content = `# ${taskTitle}
519
496
 
520
- Research this idea thoroughly, then create one or more idea files in \`.dust/ideas/\`. Read the codebase for relevant context, flesh out the description, and identify any ambiguity. Where aspects are unclear or could go multiple ways, add open questions to the idea file. If you add open questions, use \`## Open Questions\` with \`### Question?\` headings and one or more \`#### Option\` headings beneath each question, and only add questions that are meaningful decisions worth asking. Review \`.dust/principles/\` and \`.dust/facts/\` for relevant context.
497
+ Research this idea thoroughly, then create one or more idea files in \`.dust/ideas/\`. Read the codebase for relevant context, flesh out the description, and identify any ambiguity. Where aspects are unclear or could go multiple ways, add open questions to the idea file. If you add open questions, use \`## Open Questions\` with \`### Question?\` headings and one or more \`#### Option\` headings beneath each question, and only add questions that are meaningful decisions worth asking. Run \`${cmd} principles\` and \`${cmd} facts\` for relevant context.
521
498
 
522
499
  ## Idea Description
523
500
 
@@ -616,13 +593,13 @@ function buildArtifactsRepository(fileSystem, dustPath) {
616
593
  return files.filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, "")).sort();
617
594
  },
618
595
  async createRefineIdeaTask(options) {
619
- return createRefineIdeaTask(fileSystem, dustPath, options.ideaSlug, options.description);
596
+ return createRefineIdeaTask(fileSystem, dustPath, options.ideaSlug, options.description, options.dustCommand);
620
597
  },
621
598
  async createDecomposeIdeaTask(options) {
622
- return decomposeIdea(fileSystem, dustPath, options);
599
+ return decomposeIdea(fileSystem, dustPath, options, options.dustCommand);
623
600
  },
624
601
  async createShelveIdeaTask(options) {
625
- return createShelveIdeaTask(fileSystem, dustPath, options.ideaSlug, options.description);
602
+ return createShelveIdeaTask(fileSystem, dustPath, options.ideaSlug, options.description, options.dustCommand);
626
603
  },
627
604
  async createIdeaTask(options) {
628
605
  return createIdeaTask(fileSystem, dustPath, options);
@@ -692,7 +669,6 @@ function buildReadOnlyArtifactsRepository(fileSystem, dustPath) {
692
669
  export {
693
670
  parseOpenQuestions,
694
671
  findAllWorkflowTasks,
695
- findAllCaptureIdeaTasks,
696
672
  buildReadOnlyArtifactsRepository,
697
673
  buildArtifactsRepository,
698
674
  CAPTURE_IDEA_PREFIX
package/dist/dust.js CHANGED
@@ -275,7 +275,7 @@ async function loadSettings(cwd, fileSystem) {
275
275
  }
276
276
 
277
277
  // lib/version.ts
278
- var DUST_VERSION = "0.1.72";
278
+ var DUST_VERSION = "0.1.74";
279
279
 
280
280
  // lib/session.ts
281
281
  var DUST_UNATTENDED = "DUST_UNATTENDED";
@@ -25,10 +25,12 @@ export interface FileSystemEmulator extends FileSystem, GlobScanner {
25
25
  * Implements both FileSystem and GlobScanner interfaces - the scan() method
26
26
  * iterates over the files the emulator knows about.
27
27
  *
28
- * @param tree - Nested object representing file system hierarchy
28
+ * @param tree - Nested object representing file system hierarchy (paths get '/' prefix)
29
+ * @param flatFiles - Optional record of path→content entries added as-is (no prefix)
29
30
  * @returns FileSystemEmulator with tracking for created directories and written files
30
31
  *
31
32
  * @example
33
+ * // Nested tree (paths become /project/.dust/...)
32
34
  * createFileSystemEmulator({
33
35
  * project: {
34
36
  * '.dust': {
@@ -37,5 +39,11 @@ export interface FileSystemEmulator extends FileSystem, GlobScanner {
37
39
  * }
38
40
  * }
39
41
  * })
42
+ *
43
+ * // Flat files (paths used as-is)
44
+ * createFileSystemEmulator({}, {
45
+ * '.dust/config/audits/security.md': '# Security Audit\n...',
46
+ * '.dust/tasks/audit-security.md': '# Run security audit\n...',
47
+ * })
40
48
  */
41
- export declare function createFileSystemEmulator(tree?: FileSystemTree): FileSystemEmulator;
49
+ export declare function createFileSystemEmulator(tree?: FileSystemTree, flatFiles?: Record<string, string>): FileSystemEmulator;
@@ -28,8 +28,17 @@ function flattenFileSystemTree(tree, basePath = "") {
28
28
  }
29
29
  return { files, paths };
30
30
  }
31
- function createFileSystemEmulator(tree = {}) {
31
+ function createFileSystemEmulator(tree = {}, flatFiles) {
32
32
  const { files, paths } = flattenFileSystemTree(tree);
33
+ if (flatFiles) {
34
+ for (const [filePath, content] of Object.entries(flatFiles)) {
35
+ files.set(filePath, content);
36
+ paths.add(filePath);
37
+ for (let dir = filePath.substring(0, filePath.lastIndexOf("/"));dir; dir = dir.substring(0, dir.lastIndexOf("/"))) {
38
+ paths.add(dir);
39
+ }
40
+ }
41
+ }
33
42
  const createdDirs = [];
34
43
  const writtenFiles = new Map;
35
44
  const permissions = new Map;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Content validation for .dust markdown files
3
+ */
4
+ import type { Violation } from './types';
5
+ export declare function validateOpeningSentence(filePath: string, content: string): Violation | null;
6
+ export declare function validateOpeningSentenceLength(filePath: string, content: string): Violation | null;
7
+ export declare function validateImperativeOpeningSentence(filePath: string, content: string): Violation | null;
8
+ export declare function validateTaskHeadings(filePath: string, content: string): Violation[];
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Directory structure validation for .dust
3
+ */
4
+ import type { ReadableFileSystem } from '../../filesystem/types';
5
+ import type { Violation } from './types';
6
+ export declare function validateContentDirectoryFiles(dirPath: string, fileSystem: ReadableFileSystem): Promise<Violation[]>;
7
+ export declare function validateDirectoryStructure(dustPath: string, fileSystem: ReadableFileSystem, extraDirectories?: string[]): Promise<Violation[]>;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Filename validation for .dust markdown files
3
+ */
4
+ import type { Violation } from './types';
5
+ export declare function validateFilename(filePath: string): Violation | null;
6
+ export declare function validateTitleFilenameMatch(filePath: string, content: string): Violation | null;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Idea file validation for .dust markdown files
3
+ */
4
+ import type { ReadableFileSystem } from '../../filesystem/types';
5
+ import type { Violation } from './types';
6
+ export declare function validateIdeaOpenQuestions(filePath: string, content: string): Violation[];
7
+ export declare function validateIdeaTransitionTitle(filePath: string, content: string, ideasPath: string, fileSystem: ReadableFileSystem): Violation | null;
8
+ export declare function validateWorkflowTaskBodySection(filePath: string, content: string, ideasPath: string, fileSystem: ReadableFileSystem): Violation[];
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Link validation for .dust markdown files
3
+ */
4
+ import type { ReadableFileSystem } from '../../filesystem/types';
5
+ import type { Violation } from './types';
6
+ export declare function validateLinks(filePath: string, content: string, fileSystem: ReadableFileSystem): Violation[];
7
+ export declare function validateSemanticLinks(filePath: string, content: string): Violation[];
8
+ export declare function validatePrincipleHierarchyLinks(filePath: string, content: string): Violation[];
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Principle hierarchy validation for .dust markdown files
3
+ */
4
+ import type { PrincipleRelationships, Violation } from './types';
5
+ export type { PrincipleRelationships };
6
+ export declare function validatePrincipleHierarchySections(filePath: string, content: string): Violation[];
7
+ export declare function extractPrincipleRelationships(filePath: string, content: string): PrincipleRelationships;
8
+ export declare function validateBidirectionalLinks(allPrincipleRelationships: PrincipleRelationships[]): Violation[];
9
+ export declare function validateNoCycles(allPrincipleRelationships: PrincipleRelationships[]): Violation[];
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Shared types for lint validators
3
+ */
4
+ export interface Violation {
5
+ file: string;
6
+ message: string;
7
+ line?: number;
8
+ }
9
+ export interface PrincipleRelationships {
10
+ filePath: string;
11
+ parentPrinciples: string[];
12
+ subPrinciples: string[];
13
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Artifact patch validation API.
3
+ *
4
+ * Validates proposed artifact changes against existing .dust/ content
5
+ * using the same validators as `dust lint`.
6
+ */
7
+ import type { ReadableFileSystem } from '../filesystem/types';
8
+ import type { Violation } from '../lint/validators/types';
9
+ export type { Violation } from '../lint/validators/types';
10
+ export interface ArtifactPatch {
11
+ files: Record<string, string | null>;
12
+ }
13
+ export interface ValidationResult {
14
+ valid: boolean;
15
+ violations: Violation[];
16
+ }
17
+ /**
18
+ * Validates a patch of artifact changes against existing .dust/ content.
19
+ *
20
+ * @param fileSystem - The existing filesystem (e.g. from createFileSystemEmulator)
21
+ * @param dustPath - Absolute path to the .dust directory
22
+ * @param patch - Proposed new/changed files, with paths relative to dustPath
23
+ */
24
+ export declare function validatePatch(fileSystem: ReadableFileSystem, dustPath: string, patch: ArtifactPatch): Promise<ValidationResult>;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Overlay filesystem that layers patch files over an existing ReadableFileSystem.
3
+ * Supports deletions via a set of paths to hide from the base filesystem.
4
+ */
5
+ import type { ReadableFileSystem } from '../filesystem/types';
6
+ export declare function createOverlayFileSystem(base: ReadableFileSystem, patchFiles: Map<string, string>, deletedPaths?: Set<string>): ReadableFileSystem;
@@ -0,0 +1,835 @@
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
+ var MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/;
7
+ function extractOpeningSentence(content) {
8
+ const lines = content.split(`
9
+ `);
10
+ let h1Index = -1;
11
+ for (let i = 0;i < lines.length; i++) {
12
+ if (lines[i].match(/^#\s+.+$/)) {
13
+ h1Index = i;
14
+ break;
15
+ }
16
+ }
17
+ if (h1Index === -1) {
18
+ return null;
19
+ }
20
+ let paragraphStart = -1;
21
+ for (let i = h1Index + 1;i < lines.length; i++) {
22
+ const line = lines[i].trim();
23
+ if (line !== "") {
24
+ paragraphStart = i;
25
+ break;
26
+ }
27
+ }
28
+ if (paragraphStart === -1) {
29
+ return null;
30
+ }
31
+ const firstLine = lines[paragraphStart];
32
+ const trimmedFirstLine = firstLine.trim();
33
+ if (trimmedFirstLine.startsWith("#") || trimmedFirstLine.startsWith("-") || trimmedFirstLine.startsWith("*") || trimmedFirstLine.startsWith("+") || trimmedFirstLine.match(/^\d+\./) || trimmedFirstLine.startsWith("```") || trimmedFirstLine.startsWith(">")) {
34
+ return null;
35
+ }
36
+ let paragraph = "";
37
+ for (let i = paragraphStart;i < lines.length; i++) {
38
+ const line = lines[i].trim();
39
+ if (line === "")
40
+ break;
41
+ if (line.startsWith("#") || line.startsWith("```") || line.startsWith(">")) {
42
+ break;
43
+ }
44
+ paragraph += (paragraph ? " " : "") + line;
45
+ }
46
+ const sentenceMatch = paragraph.match(/^(.+?[.?!])(?:\s|$)/);
47
+ if (!sentenceMatch) {
48
+ return null;
49
+ }
50
+ return sentenceMatch[1];
51
+ }
52
+
53
+ // lib/lint/validators/content-validator.ts
54
+ var REQUIRED_HEADINGS = ["## Blocked By", "## Definition of Done"];
55
+ var MAX_OPENING_SENTENCE_LENGTH = 150;
56
+ var NON_IMPERATIVE_STARTERS = new Set([
57
+ "the",
58
+ "a",
59
+ "an",
60
+ "this",
61
+ "that",
62
+ "these",
63
+ "those",
64
+ "we",
65
+ "it",
66
+ "they",
67
+ "you",
68
+ "i"
69
+ ]);
70
+ function validateOpeningSentence(filePath, content) {
71
+ const openingSentence = extractOpeningSentence(content);
72
+ if (!openingSentence) {
73
+ return {
74
+ file: filePath,
75
+ message: "Missing or malformed opening sentence after H1 heading"
76
+ };
77
+ }
78
+ return null;
79
+ }
80
+ function validateOpeningSentenceLength(filePath, content) {
81
+ const openingSentence = extractOpeningSentence(content);
82
+ if (!openingSentence) {
83
+ return null;
84
+ }
85
+ if (openingSentence.length > MAX_OPENING_SENTENCE_LENGTH) {
86
+ return {
87
+ file: filePath,
88
+ message: `Opening sentence is ${openingSentence.length} characters (max ${MAX_OPENING_SENTENCE_LENGTH}). Split into multiple sentences; only the first sentence is checked.`
89
+ };
90
+ }
91
+ return null;
92
+ }
93
+ function validateImperativeOpeningSentence(filePath, content) {
94
+ const openingSentence = extractOpeningSentence(content);
95
+ if (!openingSentence) {
96
+ return null;
97
+ }
98
+ const firstWord = openingSentence.split(/\s/)[0].replace(/[^a-zA-Z]/g, "");
99
+ const lower = firstWord.toLowerCase();
100
+ if (NON_IMPERATIVE_STARTERS.has(lower) || lower.endsWith("ing")) {
101
+ const preview = openingSentence.length > 40 ? `${openingSentence.slice(0, 40)}...` : openingSentence;
102
+ return {
103
+ file: filePath,
104
+ message: `Opening sentence should use imperative form (e.g., "Add X" not "This adds X"). Found: "${preview}"`
105
+ };
106
+ }
107
+ return null;
108
+ }
109
+ function validateTaskHeadings(filePath, content) {
110
+ const violations = [];
111
+ for (const heading of REQUIRED_HEADINGS) {
112
+ if (!content.includes(heading)) {
113
+ violations.push({
114
+ file: filePath,
115
+ message: `Missing required heading: "${heading}"`
116
+ });
117
+ }
118
+ }
119
+ return violations;
120
+ }
121
+
122
+ // lib/lint/validators/directory-validator.ts
123
+ async function validateContentDirectoryFiles(dirPath, fileSystem) {
124
+ const violations = [];
125
+ let entries;
126
+ try {
127
+ entries = await fileSystem.readdir(dirPath);
128
+ } catch (error) {
129
+ if (error.code === "ENOENT") {
130
+ return [];
131
+ }
132
+ throw error;
133
+ }
134
+ for (const entry of entries) {
135
+ const entryPath = `${dirPath}/${entry}`;
136
+ if (entry.startsWith(".")) {
137
+ violations.push({
138
+ file: entryPath,
139
+ message: `Hidden file "${entry}" found in content directory`
140
+ });
141
+ continue;
142
+ }
143
+ if (fileSystem.isDirectory(entryPath)) {
144
+ violations.push({
145
+ file: entryPath,
146
+ message: `Subdirectory "${entry}" found in content directory (content directories should be flat)`
147
+ });
148
+ continue;
149
+ }
150
+ if (!entry.endsWith(".md")) {
151
+ violations.push({
152
+ file: entryPath,
153
+ message: `Non-markdown file "${entry}" found in content directory`
154
+ });
155
+ }
156
+ }
157
+ return violations;
158
+ }
159
+
160
+ // lib/artifacts/workflow-tasks.ts
161
+ var IDEA_TRANSITION_PREFIXES = [
162
+ "Refine Idea: ",
163
+ "Decompose Idea: ",
164
+ "Shelve Idea: "
165
+ ];
166
+ function titleToFilename(title) {
167
+ return `${title.toLowerCase().replace(/\./g, "-").replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "")}.md`;
168
+ }
169
+
170
+ // lib/lint/validators/filename-validator.ts
171
+ var SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*\.md$/;
172
+ function validateFilename(filePath) {
173
+ const parts = filePath.split("/");
174
+ const filename = parts[parts.length - 1];
175
+ if (!SLUG_PATTERN.test(filename)) {
176
+ return {
177
+ file: filePath,
178
+ message: `Filename "${filename}" does not match slug-style naming`
179
+ };
180
+ }
181
+ return null;
182
+ }
183
+ function validateTitleFilenameMatch(filePath, content) {
184
+ const title = extractTitle(content);
185
+ if (!title) {
186
+ return null;
187
+ }
188
+ const parts = filePath.split("/");
189
+ const actualFilename = parts[parts.length - 1];
190
+ const expectedFilename = titleToFilename(title);
191
+ if (actualFilename !== expectedFilename) {
192
+ return {
193
+ file: filePath,
194
+ message: `Filename "${actualFilename}" does not match title "${title}" (expected "${expectedFilename}")`
195
+ };
196
+ }
197
+ return null;
198
+ }
199
+
200
+ // lib/lint/validators/idea-validator.ts
201
+ var WORKFLOW_PREFIX_TO_SECTION = {
202
+ "Refine Idea: ": "Refines Idea",
203
+ "Decompose Idea: ": "Decomposes Idea",
204
+ "Shelve Idea: ": "Shelves Idea"
205
+ };
206
+ function validateIdeaOpenQuestions(filePath, content) {
207
+ const violations = [];
208
+ const lines = content.split(`
209
+ `);
210
+ const topLevelStructureMessage = "Open Questions must use `### Question?` headings and `#### Option` headings at the top level. Put supporting markdown (including lists and code blocks) under an option heading. Run `dust new idea` to see the expected format.";
211
+ let inOpenQuestions = false;
212
+ let currentQuestionLine = null;
213
+ let inOption = false;
214
+ let inCodeBlock = false;
215
+ for (let i = 0;i < lines.length; i++) {
216
+ const line = lines[i];
217
+ const trimmedLine = line.trimEnd();
218
+ const nonWhitespaceLine = line.trim();
219
+ if (line.startsWith("## ")) {
220
+ if (inOpenQuestions && currentQuestionLine !== null) {
221
+ violations.push({
222
+ file: filePath,
223
+ message: "Question has no options listed beneath it",
224
+ line: currentQuestionLine
225
+ });
226
+ }
227
+ const headingText = line.slice(3).trimEnd();
228
+ if (headingText.toLowerCase() === "open questions" && headingText !== "Open Questions") {
229
+ violations.push({
230
+ file: filePath,
231
+ message: `Heading "${line.trimEnd()}" should be "## Open Questions"`,
232
+ line: i + 1
233
+ });
234
+ }
235
+ inOpenQuestions = line === "## Open Questions";
236
+ currentQuestionLine = null;
237
+ inOption = false;
238
+ inCodeBlock = false;
239
+ continue;
240
+ }
241
+ if (!inOpenQuestions)
242
+ continue;
243
+ if (line.startsWith("```")) {
244
+ if (!inOption && !inCodeBlock) {
245
+ violations.push({
246
+ file: filePath,
247
+ message: topLevelStructureMessage,
248
+ line: i + 1
249
+ });
250
+ }
251
+ inCodeBlock = !inCodeBlock;
252
+ continue;
253
+ }
254
+ if (inCodeBlock)
255
+ continue;
256
+ if (line.startsWith("### ")) {
257
+ inOption = false;
258
+ if (currentQuestionLine !== null) {
259
+ violations.push({
260
+ file: filePath,
261
+ message: "Question has no options listed beneath it",
262
+ line: currentQuestionLine
263
+ });
264
+ }
265
+ if (!trimmedLine.endsWith("?")) {
266
+ violations.push({
267
+ file: filePath,
268
+ message: 'Questions must end with "?" (e.g., "### Should we take our own payments?")',
269
+ line: i + 1
270
+ });
271
+ currentQuestionLine = null;
272
+ } else {
273
+ currentQuestionLine = i + 1;
274
+ }
275
+ continue;
276
+ }
277
+ if (line.startsWith("#### ")) {
278
+ currentQuestionLine = null;
279
+ inOption = true;
280
+ continue;
281
+ }
282
+ if (nonWhitespaceLine && !inOption) {
283
+ violations.push({
284
+ file: filePath,
285
+ message: topLevelStructureMessage,
286
+ line: i + 1
287
+ });
288
+ }
289
+ }
290
+ if (inOpenQuestions && currentQuestionLine !== null) {
291
+ violations.push({
292
+ file: filePath,
293
+ message: "Question has no options listed beneath it",
294
+ line: currentQuestionLine
295
+ });
296
+ }
297
+ return violations;
298
+ }
299
+ function validateIdeaTransitionTitle(filePath, content, ideasPath, fileSystem) {
300
+ const title = extractTitle(content);
301
+ if (!title) {
302
+ return null;
303
+ }
304
+ for (const prefix of IDEA_TRANSITION_PREFIXES) {
305
+ if (title.startsWith(prefix)) {
306
+ const ideaTitle = title.slice(prefix.length);
307
+ const ideaFilename = titleToFilename(ideaTitle);
308
+ if (!fileSystem.exists(`${ideasPath}/${ideaFilename}`)) {
309
+ return {
310
+ file: filePath,
311
+ message: `Idea transition task references non-existent idea: "${ideaTitle}" (expected file "${ideaFilename}" in ideas/)`
312
+ };
313
+ }
314
+ return null;
315
+ }
316
+ }
317
+ return null;
318
+ }
319
+ function extractSectionContent(content, sectionHeading) {
320
+ const lines = content.split(`
321
+ `);
322
+ let inSection = false;
323
+ let sectionContent = "";
324
+ let startLine = 0;
325
+ for (let i = 0;i < lines.length; i++) {
326
+ const line = lines[i];
327
+ if (line.startsWith("## ")) {
328
+ if (inSection)
329
+ break;
330
+ if (line.trimEnd() === `## ${sectionHeading}`) {
331
+ inSection = true;
332
+ startLine = i + 1;
333
+ }
334
+ continue;
335
+ }
336
+ if (line.startsWith("# ") && inSection)
337
+ break;
338
+ if (inSection) {
339
+ sectionContent += `${line}
340
+ `;
341
+ }
342
+ }
343
+ if (!inSection)
344
+ return null;
345
+ return { content: sectionContent, startLine };
346
+ }
347
+ function validateWorkflowTaskBodySection(filePath, content, ideasPath, fileSystem) {
348
+ const violations = [];
349
+ const title = extractTitle(content);
350
+ if (!title)
351
+ return violations;
352
+ let matchedPrefix = null;
353
+ for (const prefix of IDEA_TRANSITION_PREFIXES) {
354
+ if (title.startsWith(prefix)) {
355
+ matchedPrefix = prefix;
356
+ break;
357
+ }
358
+ }
359
+ if (!matchedPrefix)
360
+ return violations;
361
+ const expectedHeading = WORKFLOW_PREFIX_TO_SECTION[matchedPrefix];
362
+ const section = extractSectionContent(content, expectedHeading);
363
+ if (!section) {
364
+ violations.push({
365
+ file: filePath,
366
+ message: `Workflow task with "${matchedPrefix.trim()}" prefix is missing required "## ${expectedHeading}" section. Add a section with a link to the idea file, e.g.:
367
+
368
+ ## ${expectedHeading}
369
+
370
+ - [Idea Title](../ideas/idea-slug.md)`
371
+ });
372
+ return violations;
373
+ }
374
+ const linkRegex = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
375
+ const links = [];
376
+ const sectionLines = section.content.split(`
377
+ `);
378
+ for (let i = 0;i < sectionLines.length; i++) {
379
+ const line = sectionLines[i];
380
+ let match = linkRegex.exec(line);
381
+ while (match !== null) {
382
+ links.push({
383
+ text: match[1],
384
+ target: match[2],
385
+ line: section.startLine + i + 1
386
+ });
387
+ match = linkRegex.exec(line);
388
+ }
389
+ }
390
+ if (links.length === 0) {
391
+ violations.push({
392
+ file: filePath,
393
+ message: `"## ${expectedHeading}" section contains no link. Add a markdown link to the idea file, e.g.:
394
+
395
+ - [Idea Title](../ideas/idea-slug.md)`,
396
+ line: section.startLine
397
+ });
398
+ return violations;
399
+ }
400
+ const ideaLinks = links.filter((l) => l.target.includes("/ideas/") || l.target.startsWith("../ideas/"));
401
+ if (ideaLinks.length === 0) {
402
+ violations.push({
403
+ file: filePath,
404
+ message: `"## ${expectedHeading}" section contains no link to an idea file. Links must point to a file in ../ideas/, e.g.:
405
+
406
+ - [Idea Title](../ideas/idea-slug.md)`,
407
+ line: section.startLine
408
+ });
409
+ return violations;
410
+ }
411
+ for (const link of ideaLinks) {
412
+ const slugMatch = link.target.match(/([^/]+)\.md$/);
413
+ if (!slugMatch)
414
+ continue;
415
+ const ideaSlug = slugMatch[1];
416
+ const ideaFilePath = `${ideasPath}/${ideaSlug}.md`;
417
+ if (!fileSystem.exists(ideaFilePath)) {
418
+ violations.push({
419
+ file: filePath,
420
+ 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.`,
421
+ line: link.line
422
+ });
423
+ }
424
+ }
425
+ return violations;
426
+ }
427
+
428
+ // lib/lint/validators/link-validator.ts
429
+ import { dirname, resolve } from "node:path";
430
+ var SEMANTIC_RULES = [
431
+ {
432
+ section: "## Principles",
433
+ requiredPath: "/.dust/principles/",
434
+ description: "principle"
435
+ },
436
+ {
437
+ section: "## Blocked By",
438
+ requiredPath: "/.dust/tasks/",
439
+ description: "task"
440
+ }
441
+ ];
442
+ function validateLinks(filePath, content, fileSystem) {
443
+ const violations = [];
444
+ const lines = content.split(`
445
+ `);
446
+ const fileDir = dirname(filePath);
447
+ for (let i = 0;i < lines.length; i++) {
448
+ const line = lines[i];
449
+ const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
450
+ let match = linkPattern.exec(line);
451
+ while (match) {
452
+ const linkTarget = match[2];
453
+ if (!linkTarget.startsWith("http://") && !linkTarget.startsWith("https://") && !linkTarget.startsWith("#")) {
454
+ const targetPath = linkTarget.split("#")[0];
455
+ const resolvedPath = resolve(fileDir, targetPath);
456
+ if (!fileSystem.exists(resolvedPath)) {
457
+ violations.push({
458
+ file: filePath,
459
+ message: `Broken link: "${linkTarget}"`,
460
+ line: i + 1
461
+ });
462
+ }
463
+ }
464
+ match = linkPattern.exec(line);
465
+ }
466
+ }
467
+ return violations;
468
+ }
469
+ function validateSemanticLinks(filePath, content) {
470
+ const violations = [];
471
+ const lines = content.split(`
472
+ `);
473
+ const fileDir = dirname(filePath);
474
+ let currentSection = null;
475
+ for (let i = 0;i < lines.length; i++) {
476
+ const line = lines[i];
477
+ if (line.startsWith("## ")) {
478
+ currentSection = line;
479
+ continue;
480
+ }
481
+ const rule = SEMANTIC_RULES.find((r) => r.section === currentSection);
482
+ if (!rule)
483
+ continue;
484
+ const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
485
+ let match = linkPattern.exec(line);
486
+ while (match) {
487
+ const linkTarget = match[2];
488
+ if (linkTarget.startsWith("#")) {
489
+ violations.push({
490
+ file: filePath,
491
+ message: `Link in "${rule.section}" must point to a ${rule.description} file, not an anchor: "${linkTarget}"`,
492
+ line: i + 1
493
+ });
494
+ match = linkPattern.exec(line);
495
+ continue;
496
+ }
497
+ if (linkTarget.startsWith("http://") || linkTarget.startsWith("https://")) {
498
+ violations.push({
499
+ file: filePath,
500
+ message: `Link in "${rule.section}" must point to a ${rule.description} file, not an external URL: "${linkTarget}"`,
501
+ line: i + 1
502
+ });
503
+ match = linkPattern.exec(line);
504
+ continue;
505
+ }
506
+ const targetPath = linkTarget.split("#")[0];
507
+ const resolvedPath = resolve(fileDir, targetPath);
508
+ if (!resolvedPath.includes(rule.requiredPath)) {
509
+ violations.push({
510
+ file: filePath,
511
+ message: `Link in "${rule.section}" must point to a ${rule.description} file: "${linkTarget}"`,
512
+ line: i + 1
513
+ });
514
+ }
515
+ match = linkPattern.exec(line);
516
+ }
517
+ }
518
+ return violations;
519
+ }
520
+ function validatePrincipleHierarchyLinks(filePath, content) {
521
+ const violations = [];
522
+ const lines = content.split(`
523
+ `);
524
+ const fileDir = dirname(filePath);
525
+ let currentSection = null;
526
+ for (let i = 0;i < lines.length; i++) {
527
+ const line = lines[i];
528
+ if (line.startsWith("## ")) {
529
+ currentSection = line;
530
+ continue;
531
+ }
532
+ if (currentSection !== "## Parent Principle" && currentSection !== "## Sub-Principles") {
533
+ continue;
534
+ }
535
+ const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
536
+ let match = linkPattern.exec(line);
537
+ while (match) {
538
+ const linkTarget = match[2];
539
+ if (linkTarget.startsWith("#")) {
540
+ violations.push({
541
+ file: filePath,
542
+ message: `Link in "${currentSection}" must point to a principle file, not an anchor: "${linkTarget}"`,
543
+ line: i + 1
544
+ });
545
+ match = linkPattern.exec(line);
546
+ continue;
547
+ }
548
+ if (linkTarget.startsWith("http://") || linkTarget.startsWith("https://")) {
549
+ violations.push({
550
+ file: filePath,
551
+ message: `Link in "${currentSection}" must point to a principle file, not an external URL: "${linkTarget}"`,
552
+ line: i + 1
553
+ });
554
+ match = linkPattern.exec(line);
555
+ continue;
556
+ }
557
+ const targetPath = linkTarget.split("#")[0];
558
+ const resolvedPath = resolve(fileDir, targetPath);
559
+ if (!resolvedPath.includes("/.dust/principles/")) {
560
+ violations.push({
561
+ file: filePath,
562
+ message: `Link in "${currentSection}" must point to a principle file: "${linkTarget}"`,
563
+ line: i + 1
564
+ });
565
+ }
566
+ match = linkPattern.exec(line);
567
+ }
568
+ }
569
+ return violations;
570
+ }
571
+
572
+ // lib/lint/validators/principle-hierarchy.ts
573
+ import { dirname as dirname2, resolve as resolve2 } from "node:path";
574
+ var REQUIRED_PRINCIPLE_HEADINGS = ["## Parent Principle", "## Sub-Principles"];
575
+ function validatePrincipleHierarchySections(filePath, content) {
576
+ const violations = [];
577
+ for (const heading of REQUIRED_PRINCIPLE_HEADINGS) {
578
+ if (!content.includes(heading)) {
579
+ violations.push({
580
+ file: filePath,
581
+ message: `Missing required heading: "${heading}"`
582
+ });
583
+ }
584
+ }
585
+ return violations;
586
+ }
587
+ function extractPrincipleRelationships(filePath, content) {
588
+ const lines = content.split(`
589
+ `);
590
+ const fileDir = dirname2(filePath);
591
+ const parentPrinciples = [];
592
+ const subPrinciples = [];
593
+ let currentSection = null;
594
+ for (const line of lines) {
595
+ if (line.startsWith("## ")) {
596
+ currentSection = line;
597
+ continue;
598
+ }
599
+ if (currentSection !== "## Parent Principle" && currentSection !== "## Sub-Principles") {
600
+ continue;
601
+ }
602
+ const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
603
+ let match = linkPattern.exec(line);
604
+ while (match) {
605
+ const linkTarget = match[2];
606
+ if (!linkTarget.startsWith("#") && !linkTarget.startsWith("http://") && !linkTarget.startsWith("https://")) {
607
+ const targetPath = linkTarget.split("#")[0];
608
+ const resolvedPath = resolve2(fileDir, targetPath);
609
+ if (resolvedPath.includes("/.dust/principles/")) {
610
+ if (currentSection === "## Parent Principle") {
611
+ parentPrinciples.push(resolvedPath);
612
+ } else {
613
+ subPrinciples.push(resolvedPath);
614
+ }
615
+ }
616
+ }
617
+ match = linkPattern.exec(line);
618
+ }
619
+ }
620
+ return { filePath, parentPrinciples, subPrinciples };
621
+ }
622
+ function validateBidirectionalLinks(allPrincipleRelationships) {
623
+ const violations = [];
624
+ const relationshipMap = new Map;
625
+ for (const rel of allPrincipleRelationships) {
626
+ relationshipMap.set(rel.filePath, rel);
627
+ }
628
+ for (const rel of allPrincipleRelationships) {
629
+ for (const parentPath of rel.parentPrinciples) {
630
+ const parentRel = relationshipMap.get(parentPath);
631
+ if (parentRel && !parentRel.subPrinciples.includes(rel.filePath)) {
632
+ violations.push({
633
+ file: rel.filePath,
634
+ message: `Parent principle "${parentPath}" does not list this principle as a sub-principle`
635
+ });
636
+ }
637
+ }
638
+ for (const subPrinciplePath of rel.subPrinciples) {
639
+ const subPrincipleRel = relationshipMap.get(subPrinciplePath);
640
+ if (subPrincipleRel && !subPrincipleRel.parentPrinciples.includes(rel.filePath)) {
641
+ violations.push({
642
+ file: rel.filePath,
643
+ message: `Sub-principle "${subPrinciplePath}" does not list this principle as its parent`
644
+ });
645
+ }
646
+ }
647
+ }
648
+ return violations;
649
+ }
650
+ function validateNoCycles(allPrincipleRelationships) {
651
+ const violations = [];
652
+ const relationshipMap = new Map;
653
+ for (const rel of allPrincipleRelationships) {
654
+ relationshipMap.set(rel.filePath, rel);
655
+ }
656
+ for (const rel of allPrincipleRelationships) {
657
+ const visited = new Set;
658
+ const path = [];
659
+ let current = rel.filePath;
660
+ while (current) {
661
+ if (visited.has(current)) {
662
+ const cycleStart = path.indexOf(current);
663
+ const cyclePath = path.slice(cycleStart).concat(current);
664
+ violations.push({
665
+ file: rel.filePath,
666
+ message: `Cycle detected in principle hierarchy: ${cyclePath.join(" -> ")}`
667
+ });
668
+ break;
669
+ }
670
+ visited.add(current);
671
+ path.push(current);
672
+ const currentRel = relationshipMap.get(current);
673
+ if (currentRel && currentRel.parentPrinciples.length > 0) {
674
+ current = currentRel.parentPrinciples[0];
675
+ } else {
676
+ current = null;
677
+ }
678
+ }
679
+ }
680
+ return violations;
681
+ }
682
+
683
+ // lib/validation/overlay-filesystem.ts
684
+ function createOverlayFileSystem(base, patchFiles, deletedPaths = new Set) {
685
+ const patchDirs = new Set;
686
+ for (const path of patchFiles.keys()) {
687
+ let dir = path;
688
+ while (dir.includes("/")) {
689
+ dir = dir.substring(0, dir.lastIndexOf("/"));
690
+ if (dir)
691
+ patchDirs.add(dir);
692
+ }
693
+ }
694
+ function isDeleted(path) {
695
+ return deletedPaths.has(path);
696
+ }
697
+ return {
698
+ exists(path) {
699
+ if (isDeleted(path))
700
+ return false;
701
+ return patchFiles.has(path) || patchDirs.has(path) || base.exists(path);
702
+ },
703
+ async readFile(path) {
704
+ if (isDeleted(path)) {
705
+ const error = new Error(`ENOENT: no such file or directory, open '${path}'`);
706
+ error.code = "ENOENT";
707
+ throw error;
708
+ }
709
+ const patchContent = patchFiles.get(path);
710
+ if (patchContent !== undefined) {
711
+ return patchContent;
712
+ }
713
+ return base.readFile(path);
714
+ },
715
+ async readdir(path) {
716
+ const prefix = `${path}/`;
717
+ const entries = new Set;
718
+ for (const patchPath of patchFiles.keys()) {
719
+ if (patchPath.startsWith(prefix)) {
720
+ const relative = patchPath.slice(prefix.length);
721
+ const firstSegment = relative.split("/")[0];
722
+ entries.add(firstSegment);
723
+ }
724
+ }
725
+ try {
726
+ const baseEntries = await base.readdir(path);
727
+ for (const entry of baseEntries) {
728
+ const entryPath = `${path}/${entry}`;
729
+ if (!isDeleted(entryPath)) {
730
+ entries.add(entry);
731
+ }
732
+ }
733
+ } catch {}
734
+ return Array.from(entries);
735
+ },
736
+ isDirectory(path) {
737
+ if (isDeleted(path))
738
+ return false;
739
+ return patchDirs.has(path) || base.isDirectory(path);
740
+ }
741
+ };
742
+ }
743
+
744
+ // lib/validation/index.ts
745
+ async function validatePatch(fileSystem, dustPath, patch) {
746
+ const absolutePatchFiles = new Map;
747
+ const deletedPaths = new Set;
748
+ for (const [relativePath, content] of Object.entries(patch.files)) {
749
+ const absolutePath = `${dustPath}/${relativePath}`;
750
+ if (content === null) {
751
+ deletedPaths.add(absolutePath);
752
+ } else {
753
+ absolutePatchFiles.set(absolutePath, content);
754
+ }
755
+ }
756
+ const overlayFs = createOverlayFileSystem(fileSystem, absolutePatchFiles, deletedPaths);
757
+ const violations = [];
758
+ const contentDirs = ["principles", "facts", "ideas", "tasks"];
759
+ const patchDirs = new Set;
760
+ for (const relativePath of Object.keys(patch.files)) {
761
+ const dir = relativePath.split("/")[0];
762
+ if (contentDirs.includes(dir)) {
763
+ patchDirs.add(dir);
764
+ }
765
+ }
766
+ for (const dir of patchDirs) {
767
+ violations.push(...await validateContentDirectoryFiles(`${dustPath}/${dir}`, overlayFs));
768
+ }
769
+ for (const [relativePath, content] of Object.entries(patch.files)) {
770
+ if (content === null)
771
+ continue;
772
+ if (!relativePath.endsWith(".md"))
773
+ continue;
774
+ const filePath = `${dustPath}/${relativePath}`;
775
+ const dir = relativePath.split("/")[0];
776
+ violations.push(...validateLinks(filePath, content, overlayFs));
777
+ if (contentDirs.includes(dir)) {
778
+ const openingSentence = validateOpeningSentence(filePath, content);
779
+ if (openingSentence)
780
+ violations.push(openingSentence);
781
+ const openingSentenceLength = validateOpeningSentenceLength(filePath, content);
782
+ if (openingSentenceLength)
783
+ violations.push(openingSentenceLength);
784
+ const titleFilename = validateTitleFilenameMatch(filePath, content);
785
+ if (titleFilename)
786
+ violations.push(titleFilename);
787
+ }
788
+ if (dir === "ideas") {
789
+ violations.push(...validateIdeaOpenQuestions(filePath, content));
790
+ }
791
+ if (dir === "tasks") {
792
+ const filenameViolation = validateFilename(filePath);
793
+ if (filenameViolation)
794
+ violations.push(filenameViolation);
795
+ violations.push(...validateTaskHeadings(filePath, content));
796
+ violations.push(...validateSemanticLinks(filePath, content));
797
+ const imperativeViolation = validateImperativeOpeningSentence(filePath, content);
798
+ if (imperativeViolation)
799
+ violations.push(imperativeViolation);
800
+ const ideasPath = `${dustPath}/ideas`;
801
+ const ideaTransition = validateIdeaTransitionTitle(filePath, content, ideasPath, overlayFs);
802
+ if (ideaTransition)
803
+ violations.push(ideaTransition);
804
+ violations.push(...validateWorkflowTaskBodySection(filePath, content, ideasPath, overlayFs));
805
+ }
806
+ if (dir === "principles") {
807
+ violations.push(...validatePrincipleHierarchySections(filePath, content));
808
+ violations.push(...validatePrincipleHierarchyLinks(filePath, content));
809
+ }
810
+ }
811
+ const hasPrinciplePatches = Object.keys(patch.files).some((p) => p.startsWith("principles/"));
812
+ if (hasPrinciplePatches) {
813
+ const allRelationships = [];
814
+ const principlesPath = `${dustPath}/principles`;
815
+ try {
816
+ const existingFiles = await overlayFs.readdir(principlesPath);
817
+ for (const file of existingFiles) {
818
+ if (!file.endsWith(".md"))
819
+ continue;
820
+ const filePath = `${principlesPath}/${file}`;
821
+ const content = await overlayFs.readFile(filePath);
822
+ allRelationships.push(extractPrincipleRelationships(filePath, content));
823
+ }
824
+ } catch {}
825
+ violations.push(...validateBidirectionalLinks(allRelationships));
826
+ violations.push(...validateNoCycles(allRelationships));
827
+ }
828
+ return {
829
+ valid: violations.length === 0,
830
+ violations
831
+ };
832
+ }
833
+ export {
834
+ validatePatch
835
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joshski/dust",
3
- "version": "0.1.72",
3
+ "version": "0.1.74",
4
4
  "description": "Flow state for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,6 +37,10 @@
37
37
  "./biome": {
38
38
  "import": "./dist/biome.js",
39
39
  "types": "./dist/biome/index.d.ts"
40
+ },
41
+ "./validation": {
42
+ "import": "./dist/validation.js",
43
+ "types": "./dist/validation/index.d.ts"
40
44
  }
41
45
  },
42
46
  "files": [