@joshski/dust 0.1.73 → 0.1.75

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/audits.js CHANGED
@@ -670,6 +670,55 @@ function globalState() {
670
670
  - [ ] Proposed ideas for refactoring global state to explicit dependencies
671
671
  `;
672
672
  }
673
+ function repositoryContext() {
674
+ return dedent`
675
+ # Repository Context
676
+
677
+ Compile or update \`.dust/repository.md\` with a high-level overview of the repository's purpose, capabilities, and design philosophy.
678
+
679
+ ## Purpose
680
+
681
+ The repository context document helps downstream agents quickly understand the project without reading individual files. It describes features, scenarios, and design philosophy rather than implementation details. This enables high-level planning where agents reason about capabilities rather than code structure.
682
+
683
+ ## Scope
684
+
685
+ Review the current state of the codebase and produce a document covering:
686
+
687
+ 1. **What the project is** - A one-sentence summary of its purpose
688
+ 2. **What it does** - The key capabilities and features it provides
689
+ 3. **How it fits into workflows** - How users or other systems interact with it
690
+ 4. **Design philosophy** - The guiding principles behind its architecture
691
+ 5. **Key scenarios** - The main use cases or user journeys it supports
692
+
693
+ Avoid mentioning specific file paths, class names, or implementation details. Write for someone who needs to make high-level suggestions about the project's direction, not someone about to edit a specific file.
694
+
695
+ ## Analysis Steps
696
+
697
+ 1. Read the existing \`.dust/repository.md\` if it exists
698
+ 2. Review README, package.json, and top-level documentation for project purpose
699
+ 3. Scan the codebase to understand features and capabilities
700
+ 4. Review \`.dust/principles/\` for design philosophy
701
+ 5. Review \`.dust/facts/\` for context on current state
702
+ 6. Update \`.dust/repository.md\` with current findings, preserving any sections that are still accurate
703
+
704
+ ## Principles
705
+
706
+ (none)
707
+
708
+ ## Blocked By
709
+
710
+ (none)
711
+
712
+ ## Definition of Done
713
+
714
+ - [ ] \`.dust/repository.md\` exists and is up to date
715
+ - [ ] Document describes what the project does without referencing specific files
716
+ - [ ] Key capabilities and features are listed
717
+ - [ ] Design philosophy or guiding approach is captured
718
+ - [ ] Document is concise enough to fit comfortably in an agent context window
719
+ - [ ] A new agent reading only this document could make sensible high-level suggestions
720
+ `;
721
+ }
673
722
  function ubiquitousLanguage() {
674
723
  return dedent`
675
724
  # Ubiquitous Language
@@ -736,6 +785,7 @@ var stockAuditFunctions = {
736
785
  "ideas-from-principles": ideasFromPrinciples,
737
786
  "performance-review": performanceReview,
738
787
  "refactoring-opportunities": refactoringOpportunities,
788
+ "repository-context": repositoryContext,
739
789
  "security-review": securityReview,
740
790
  "stale-ideas": staleIdeas,
741
791
  "test-coverage": testCoverage,
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.73";
278
+ var DUST_VERSION = "0.1.75";
279
279
 
280
280
  // lib/session.ts
281
281
  var DUST_UNATTENDED = "DUST_UNATTENDED";
@@ -1217,6 +1217,55 @@ function globalState() {
1217
1217
  - [ ] Proposed ideas for refactoring global state to explicit dependencies
1218
1218
  `;
1219
1219
  }
1220
+ function repositoryContext() {
1221
+ return dedent`
1222
+ # Repository Context
1223
+
1224
+ Compile or update \`.dust/repository.md\` with a high-level overview of the repository's purpose, capabilities, and design philosophy.
1225
+
1226
+ ## Purpose
1227
+
1228
+ The repository context document helps downstream agents quickly understand the project without reading individual files. It describes features, scenarios, and design philosophy rather than implementation details. This enables high-level planning where agents reason about capabilities rather than code structure.
1229
+
1230
+ ## Scope
1231
+
1232
+ Review the current state of the codebase and produce a document covering:
1233
+
1234
+ 1. **What the project is** - A one-sentence summary of its purpose
1235
+ 2. **What it does** - The key capabilities and features it provides
1236
+ 3. **How it fits into workflows** - How users or other systems interact with it
1237
+ 4. **Design philosophy** - The guiding principles behind its architecture
1238
+ 5. **Key scenarios** - The main use cases or user journeys it supports
1239
+
1240
+ Avoid mentioning specific file paths, class names, or implementation details. Write for someone who needs to make high-level suggestions about the project's direction, not someone about to edit a specific file.
1241
+
1242
+ ## Analysis Steps
1243
+
1244
+ 1. Read the existing \`.dust/repository.md\` if it exists
1245
+ 2. Review README, package.json, and top-level documentation for project purpose
1246
+ 3. Scan the codebase to understand features and capabilities
1247
+ 4. Review \`.dust/principles/\` for design philosophy
1248
+ 5. Review \`.dust/facts/\` for context on current state
1249
+ 6. Update \`.dust/repository.md\` with current findings, preserving any sections that are still accurate
1250
+
1251
+ ## Principles
1252
+
1253
+ (none)
1254
+
1255
+ ## Blocked By
1256
+
1257
+ (none)
1258
+
1259
+ ## Definition of Done
1260
+
1261
+ - [ ] \`.dust/repository.md\` exists and is up to date
1262
+ - [ ] Document describes what the project does without referencing specific files
1263
+ - [ ] Key capabilities and features are listed
1264
+ - [ ] Design philosophy or guiding approach is captured
1265
+ - [ ] Document is concise enough to fit comfortably in an agent context window
1266
+ - [ ] A new agent reading only this document could make sensible high-level suggestions
1267
+ `;
1268
+ }
1220
1269
  function ubiquitousLanguage() {
1221
1270
  return dedent`
1222
1271
  # Ubiquitous Language
@@ -1283,6 +1332,7 @@ var stockAuditFunctions = {
1283
1332
  "ideas-from-principles": ideasFromPrinciples,
1284
1333
  "performance-review": performanceReview,
1285
1334
  "refactoring-opportunities": refactoringOpportunities,
1335
+ "repository-context": repositoryContext,
1286
1336
  "security-review": securityReview,
1287
1337
  "stale-ideas": staleIdeas,
1288
1338
  "test-coverage": testCoverage,
@@ -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.73",
3
+ "version": "0.1.75",
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": [