@joshski/dust 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -0
- package/bin/dust +3 -0
- package/dist/dust.js +499 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Dust
|
|
2
|
+
|
|
3
|
+
A lightweight planning system and work tracker optimised for humans working with AI agents.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/joshski/dust/actions/workflows/ci.yml)
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
Document your system and any future plans in a [.dust](./.dust) directory in your repository:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
.dust/
|
|
13
|
+
├── goals/ # Mission statements explaining why the project exists
|
|
14
|
+
├── ideas/ # Brief notes about future tasks (intentionally vague)
|
|
15
|
+
├── tasks/ # Detailed work plans with dependencies and definition of done
|
|
16
|
+
├── facts/ # Current state: design, architecture, rules, invariants
|
|
17
|
+
└── hooks/ # Executable scripts for CLI integration (e.g., quality gates)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The `goals`, `ideas`, `tasks`, and `facts` directories should be flat (no subdirectories) and contain only markdown files with slug-style names (alphanumeric and hyphens only).
|
|
21
|
+
|
|
22
|
+
The `hooks` directory contains executable scripts that integrate with the `dust` CLI. For example, the `check` hook is run by `dust check` to execute project-defined quality gates.
|
|
23
|
+
|
|
24
|
+
## CLI Commands
|
|
25
|
+
|
|
26
|
+
The `dust` CLI provides commands for managing your planning repository:
|
|
27
|
+
|
|
28
|
+
| Command | Description |
|
|
29
|
+
|---------|-------------|
|
|
30
|
+
| `dust init` | Initialize a new Dust repository with the standard directory structure |
|
|
31
|
+
| `dust prompt <name>` | Output a prompt by name from the `prompts/` directory |
|
|
32
|
+
| `dust validate` | Run validation checks on `.dust/` files (links, task structure, naming) |
|
|
33
|
+
| `dust list [type]` | List items by type (tasks, ideas, goals, facts) or all if no type specified |
|
|
34
|
+
| `dust next` | Show tasks ready to work on (not blocked by other incomplete tasks) |
|
|
35
|
+
| `dust check` | Run `validate` then execute the project's quality gate hook at `.dust/hooks/check` |
|
|
36
|
+
| `dust help` | Show help message with all available commands |
|
|
37
|
+
|
|
38
|
+
### Examples
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# Initialize a new project
|
|
42
|
+
dust init
|
|
43
|
+
|
|
44
|
+
# List all tasks
|
|
45
|
+
dust list tasks
|
|
46
|
+
|
|
47
|
+
# List everything (tasks, ideas, goals, facts)
|
|
48
|
+
dust list
|
|
49
|
+
|
|
50
|
+
# Show tasks that are ready to start
|
|
51
|
+
dust next
|
|
52
|
+
|
|
53
|
+
# Validate all markdown files under ./.dust
|
|
54
|
+
dust validate
|
|
55
|
+
|
|
56
|
+
# Run quality checks (validation + custom hook)
|
|
57
|
+
dust check
|
|
58
|
+
|
|
59
|
+
# Output a prompt by name
|
|
60
|
+
dust prompt work
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Workflow
|
|
64
|
+
|
|
65
|
+
Dust is designed for successive cycles of human planning (AI-assisted, of course) followed by agent autonomy, followed by human planning, and so on.
|
|
66
|
+
|
|
67
|
+
In order for work to begin, there must be a task. A worker (an AI agent or human) chooses any task to work on. In a team environment, the worker must “claim” the task i.e. let the team know they are working on it. The team can use a version control system (like git) claim the task by making a branch with the same name as the task. If any attempt to claim fails (e.g. a branch with that name already exists) then the agent must choose an alternative task.
|
|
68
|
+
|
|
69
|
+
When the worker completes their task, they make a single commit that includes the work, but also deletes the task, and removes any references to the task. The commit should often update one or more facts as well.
|
|
70
|
+
|
|
71
|
+
Tasks should be small units of work that can be completed quickly and result in a single commit that leaves the system in a reasonable state (e.g. no broken or half-implemented features exposed to end users). When in doubt, workers are encouraged to split the task into smaller sub-tasks, and abandon the attempt to finish any ambitious work in one go.
|
|
72
|
+
|
|
73
|
+
Over time, new ideas emerge, and ideas become more detailed plans in the form of "tasks". This should be deferred until the last responsible moment. Since humans like control over plans, ideas become plans in the "human-in-the-loop" phase at the start of a sprint.
|
|
74
|
+
|
|
75
|
+
## Tasks
|
|
76
|
+
|
|
77
|
+
Tasks are the only markdown files that have a strict structure. Tasks must have each of the following subheadings:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
## Goals
|
|
81
|
+
## Blocked by
|
|
82
|
+
## Definition of done
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
* Goals - a list of relative links to other markdown files, always under ./.dust/goals
|
|
86
|
+
* Blocked by - a list relative links to other markdown files, each of which nominates a task that must be implemented before this task can be started.
|
|
87
|
+
* Definition of done - A short description of how the implementor of the task can decide when the task has been completed successfully
|
|
88
|
+
|
|
89
|
+
These special headings and sections are required, but the remainder of the document is free form.
|
|
90
|
+
|
|
91
|
+
## The single commit
|
|
92
|
+
|
|
93
|
+
Each task should be a small unit of work. If it was underestimated, the agent implementing the task should commit any progress that does not have a negative impact on end users, and create another "follow up" task to complete the remainder of the work.
|
|
94
|
+
|
|
95
|
+
## Links between documents
|
|
96
|
+
|
|
97
|
+
Documents should include links to all relevant related documents, regardless of the type. These should be relative links in markdown format. The link text should typically match the title of the target document.
|
|
98
|
+
|
|
99
|
+
## Change history
|
|
100
|
+
|
|
101
|
+
Commits delete tasks, but commit history can be traversed to retrieve the thinking behind any changes. Tools can be implemented to make this easier or build indexes. The current working copy is kept intentionally free of this detail, to keep commits clean and reduce noise in the current repository state.
|
|
102
|
+
|
|
103
|
+
## Hygiene
|
|
104
|
+
|
|
105
|
+
A linter can be used for static analysis of task files, and to ensure there are no broken relative links as the result of any changes.
|
|
106
|
+
|
|
107
|
+
Regular semantic and logic checks are expected to be carried out to ensure ideas have not drifted from reality. This would typically happen after one or more commits, e.g. at the end of a sprint.
|
package/bin/dust
ADDED
package/dist/dust.js
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
// lib/cli/entry.ts
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
// lib/cli/check.ts
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
// lib/cli/validate.ts
|
|
9
|
+
import { dirname, resolve } from "node:path";
|
|
10
|
+
var REQUIRED_HEADINGS = ["## Goals", "## Blocked by", "## Definition of done"];
|
|
11
|
+
var SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*\.md$/;
|
|
12
|
+
function validateFilename(filePath) {
|
|
13
|
+
const parts = filePath.split("/");
|
|
14
|
+
const filename = parts[parts.length - 1];
|
|
15
|
+
if (!SLUG_PATTERN.test(filename)) {
|
|
16
|
+
return {
|
|
17
|
+
file: filePath,
|
|
18
|
+
message: `Filename "${filename}" does not match slug-style naming`
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
function validateTaskHeadings(filePath, content) {
|
|
24
|
+
const violations = [];
|
|
25
|
+
for (const heading of REQUIRED_HEADINGS) {
|
|
26
|
+
if (!content.includes(heading)) {
|
|
27
|
+
violations.push({
|
|
28
|
+
file: filePath,
|
|
29
|
+
message: `Missing required heading: "${heading}"`
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return violations;
|
|
34
|
+
}
|
|
35
|
+
function validateLinks(filePath, content, fs) {
|
|
36
|
+
const violations = [];
|
|
37
|
+
const lines = content.split(`
|
|
38
|
+
`);
|
|
39
|
+
const fileDir = dirname(filePath);
|
|
40
|
+
for (let i = 0;i < lines.length; i++) {
|
|
41
|
+
const line = lines[i];
|
|
42
|
+
const linkPattern = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
43
|
+
let match = linkPattern.exec(line);
|
|
44
|
+
while (match) {
|
|
45
|
+
const linkTarget = match[2];
|
|
46
|
+
if (!linkTarget.startsWith("http://") && !linkTarget.startsWith("https://") && !linkTarget.startsWith("#")) {
|
|
47
|
+
const targetPath = linkTarget.split("#")[0];
|
|
48
|
+
const resolvedPath = resolve(fileDir, targetPath);
|
|
49
|
+
if (!fs.exists(resolvedPath)) {
|
|
50
|
+
violations.push({
|
|
51
|
+
file: filePath,
|
|
52
|
+
message: `Broken link: "${linkTarget}"`,
|
|
53
|
+
line: i + 1
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
match = linkPattern.exec(line);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return violations;
|
|
61
|
+
}
|
|
62
|
+
async function validate(ctx, fs, _args, glob) {
|
|
63
|
+
const dustPath = `${ctx.cwd}/.dust`;
|
|
64
|
+
if (!fs.exists(dustPath)) {
|
|
65
|
+
ctx.stderr("Error: .dust directory not found");
|
|
66
|
+
ctx.stderr("Run 'dust init' to initialize a Dust repository");
|
|
67
|
+
return { exitCode: 1 };
|
|
68
|
+
}
|
|
69
|
+
const violations = [];
|
|
70
|
+
ctx.stdout("Validating links in .dust/...");
|
|
71
|
+
for await (const file of glob.scan(dustPath)) {
|
|
72
|
+
if (!file.endsWith(".md"))
|
|
73
|
+
continue;
|
|
74
|
+
const filePath = `${dustPath}/${file}`;
|
|
75
|
+
const content = await fs.readFile(filePath);
|
|
76
|
+
violations.push(...validateLinks(filePath, content, fs));
|
|
77
|
+
}
|
|
78
|
+
const tasksPath = `${dustPath}/tasks`;
|
|
79
|
+
if (fs.exists(tasksPath)) {
|
|
80
|
+
ctx.stdout("Validating task files in .dust/tasks/...");
|
|
81
|
+
for await (const file of glob.scan(tasksPath)) {
|
|
82
|
+
if (!file.endsWith(".md"))
|
|
83
|
+
continue;
|
|
84
|
+
const filePath = `${tasksPath}/${file}`;
|
|
85
|
+
const content = await fs.readFile(filePath);
|
|
86
|
+
const filenameViolation = validateFilename(filePath);
|
|
87
|
+
if (filenameViolation) {
|
|
88
|
+
violations.push(filenameViolation);
|
|
89
|
+
}
|
|
90
|
+
violations.push(...validateTaskHeadings(filePath, content));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (violations.length === 0) {
|
|
94
|
+
ctx.stdout("All validations passed!");
|
|
95
|
+
return { exitCode: 0 };
|
|
96
|
+
}
|
|
97
|
+
ctx.stderr(`Found ${violations.length} violation(s):`);
|
|
98
|
+
ctx.stderr("");
|
|
99
|
+
for (const v of violations) {
|
|
100
|
+
const location = v.line ? `:${v.line}` : "";
|
|
101
|
+
ctx.stderr(` ${v.file}${location}`);
|
|
102
|
+
ctx.stderr(` ${v.message}`);
|
|
103
|
+
}
|
|
104
|
+
return { exitCode: 1 };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// lib/cli/check.ts
|
|
108
|
+
function createProcessRunner(spawnFn) {
|
|
109
|
+
return {
|
|
110
|
+
spawn: (command, args, options) => {
|
|
111
|
+
return new Promise((resolve2) => {
|
|
112
|
+
const proc = spawnFn(command, args, options);
|
|
113
|
+
proc.on("close", (code) => resolve2(code ?? 1));
|
|
114
|
+
proc.on("error", () => resolve2(1));
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
var defaultProcessRunner = createProcessRunner(spawn);
|
|
120
|
+
async function check(ctx, fs, _args, runner = defaultProcessRunner, glob) {
|
|
121
|
+
if (glob) {
|
|
122
|
+
const validationResult = await validate(ctx, fs, [], glob);
|
|
123
|
+
if (validationResult.exitCode !== 0) {
|
|
124
|
+
return validationResult;
|
|
125
|
+
}
|
|
126
|
+
ctx.stdout("");
|
|
127
|
+
}
|
|
128
|
+
const hookPath = `${ctx.cwd}/.dust/hooks/check`;
|
|
129
|
+
if (!fs.exists(hookPath)) {
|
|
130
|
+
ctx.stderr("Error: No check hook found at .dust/hooks/check");
|
|
131
|
+
ctx.stderr("");
|
|
132
|
+
ctx.stderr("To create a check hook:");
|
|
133
|
+
ctx.stderr(" 1. Create the hooks directory: mkdir -p .dust/hooks");
|
|
134
|
+
ctx.stderr(" 2. Create the check script: touch .dust/hooks/check");
|
|
135
|
+
ctx.stderr(" 3. Make it executable: chmod +x .dust/hooks/check");
|
|
136
|
+
ctx.stderr(" 4. Add your quality checks (tests, linting, etc.)");
|
|
137
|
+
return { exitCode: 1 };
|
|
138
|
+
}
|
|
139
|
+
const exitCode = await runner.spawn(hookPath, [], {
|
|
140
|
+
cwd: ctx.cwd,
|
|
141
|
+
stdio: "inherit"
|
|
142
|
+
});
|
|
143
|
+
return { exitCode };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// lib/cli/init.ts
|
|
147
|
+
var DUST_DIRECTORIES = ["goals", "ideas", "tasks", "facts"];
|
|
148
|
+
var DEFAULT_GOAL = `# Project Goal
|
|
149
|
+
|
|
150
|
+
Describe the high-level mission of this project.
|
|
151
|
+
`;
|
|
152
|
+
async function init(ctx, fs, _args) {
|
|
153
|
+
const dustPath = `${ctx.cwd}/.dust`;
|
|
154
|
+
if (fs.exists(dustPath)) {
|
|
155
|
+
ctx.stderr("Error: .dust directory already exists");
|
|
156
|
+
return { exitCode: 1 };
|
|
157
|
+
}
|
|
158
|
+
await fs.mkdir(dustPath, { recursive: true });
|
|
159
|
+
for (const dir of DUST_DIRECTORIES) {
|
|
160
|
+
await fs.mkdir(`${dustPath}/${dir}`, { recursive: true });
|
|
161
|
+
}
|
|
162
|
+
await fs.writeFile(`${dustPath}/goals/project-goal.md`, DEFAULT_GOAL);
|
|
163
|
+
ctx.stdout("Initialized Dust repository in .dust/");
|
|
164
|
+
ctx.stdout(`Created directories: ${DUST_DIRECTORIES.join(", ")}`);
|
|
165
|
+
ctx.stdout("Created initial goal: .dust/goals/project-goal.md");
|
|
166
|
+
return { exitCode: 0 };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// lib/cli/list.ts
|
|
170
|
+
var VALID_TYPES = ["tasks", "ideas", "goals", "facts"];
|
|
171
|
+
function extractTitle(content) {
|
|
172
|
+
const match = content.match(/^#\s+(.+)$/m);
|
|
173
|
+
return match ? match[1].trim() : null;
|
|
174
|
+
}
|
|
175
|
+
async function list(ctx, fs, args) {
|
|
176
|
+
const dustPath = `${ctx.cwd}/.dust`;
|
|
177
|
+
if (!fs.exists(dustPath)) {
|
|
178
|
+
ctx.stderr("Error: .dust directory not found");
|
|
179
|
+
ctx.stderr("Run 'dust init' to initialize a Dust repository");
|
|
180
|
+
return { exitCode: 1 };
|
|
181
|
+
}
|
|
182
|
+
const typesToList = args.length === 0 ? [...VALID_TYPES] : args.filter((a) => VALID_TYPES.includes(a));
|
|
183
|
+
if (args.length > 0 && typesToList.length === 0) {
|
|
184
|
+
ctx.stderr(`Invalid type: ${args[0]}`);
|
|
185
|
+
ctx.stderr(`Valid types: ${VALID_TYPES.join(", ")}`);
|
|
186
|
+
return { exitCode: 1 };
|
|
187
|
+
}
|
|
188
|
+
for (const type of typesToList) {
|
|
189
|
+
const dirPath = `${dustPath}/${type}`;
|
|
190
|
+
if (!fs.exists(dirPath)) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const files = await fs.readdir(dirPath);
|
|
194
|
+
const mdFiles = files.filter((f) => f.endsWith(".md")).sort();
|
|
195
|
+
if (mdFiles.length === 0) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
ctx.stdout(`${type}:`);
|
|
199
|
+
for (const file of mdFiles) {
|
|
200
|
+
const filePath = `${dirPath}/${file}`;
|
|
201
|
+
const content = await fs.readFile(filePath);
|
|
202
|
+
const title = extractTitle(content);
|
|
203
|
+
const name = file.replace(/\.md$/, "");
|
|
204
|
+
if (title) {
|
|
205
|
+
ctx.stdout(` ${name} - ${title}`);
|
|
206
|
+
} else {
|
|
207
|
+
ctx.stdout(` ${name}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
ctx.stdout("");
|
|
211
|
+
}
|
|
212
|
+
return { exitCode: 0 };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// lib/cli/next.ts
|
|
216
|
+
function extractTitle2(content) {
|
|
217
|
+
const match = content.match(/^#\s+(.+)$/m);
|
|
218
|
+
return match ? match[1].trim() : null;
|
|
219
|
+
}
|
|
220
|
+
function extractBlockedBy(content) {
|
|
221
|
+
const blockedByMatch = content.match(/^## Blocked by\s*\n([\s\S]*?)(?=\n## |\n*$)/m);
|
|
222
|
+
if (!blockedByMatch) {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
const section = blockedByMatch[1].trim();
|
|
226
|
+
if (section === "(none)") {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
const linkPattern = /\[.*?\]\(([^)]+\.md)\)/g;
|
|
230
|
+
const blockers = [];
|
|
231
|
+
let match = linkPattern.exec(section);
|
|
232
|
+
while (match !== null) {
|
|
233
|
+
blockers.push(match[1]);
|
|
234
|
+
match = linkPattern.exec(section);
|
|
235
|
+
}
|
|
236
|
+
return blockers;
|
|
237
|
+
}
|
|
238
|
+
async function next(ctx, fs, _args) {
|
|
239
|
+
const dustPath = `${ctx.cwd}/.dust`;
|
|
240
|
+
if (!fs.exists(dustPath)) {
|
|
241
|
+
ctx.stderr("Error: .dust directory not found");
|
|
242
|
+
ctx.stderr("Run 'dust init' to initialize a Dust repository");
|
|
243
|
+
return { exitCode: 1 };
|
|
244
|
+
}
|
|
245
|
+
const tasksPath = `${dustPath}/tasks`;
|
|
246
|
+
if (!fs.exists(tasksPath)) {
|
|
247
|
+
return { exitCode: 0 };
|
|
248
|
+
}
|
|
249
|
+
const files = await fs.readdir(tasksPath);
|
|
250
|
+
const mdFiles = files.filter((f) => f.endsWith(".md")).sort();
|
|
251
|
+
if (mdFiles.length === 0) {
|
|
252
|
+
return { exitCode: 0 };
|
|
253
|
+
}
|
|
254
|
+
const existingTasks = new Set(mdFiles);
|
|
255
|
+
const unblockedTasks = [];
|
|
256
|
+
for (const file of mdFiles) {
|
|
257
|
+
const filePath = `${tasksPath}/${file}`;
|
|
258
|
+
const content = await fs.readFile(filePath);
|
|
259
|
+
const blockers = extractBlockedBy(content);
|
|
260
|
+
const hasIncompleteBlocker = blockers.some((blocker) => existingTasks.has(blocker));
|
|
261
|
+
if (!hasIncompleteBlocker) {
|
|
262
|
+
const title = extractTitle2(content);
|
|
263
|
+
const name = file.replace(/\.md$/, "");
|
|
264
|
+
unblockedTasks.push({ name, title });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (unblockedTasks.length === 0) {
|
|
268
|
+
return { exitCode: 0 };
|
|
269
|
+
}
|
|
270
|
+
ctx.stdout("Next tasks:");
|
|
271
|
+
for (const task of unblockedTasks) {
|
|
272
|
+
if (task.title) {
|
|
273
|
+
ctx.stdout(` ${task.name} - ${task.title}`);
|
|
274
|
+
} else {
|
|
275
|
+
ctx.stdout(` ${task.name}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return { exitCode: 0 };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// lib/cli/prompt.ts
|
|
282
|
+
async function prompt(ctx, fs, args) {
|
|
283
|
+
const promptsDir = `${ctx.cwd}/prompts`;
|
|
284
|
+
if (args.length === 0) {
|
|
285
|
+
ctx.stderr("Usage: dust prompt <name>");
|
|
286
|
+
ctx.stderr("Example: dust prompt work");
|
|
287
|
+
return { exitCode: 1 };
|
|
288
|
+
}
|
|
289
|
+
const promptName = args[0];
|
|
290
|
+
const promptFile = `${promptsDir}/${promptName}.md`;
|
|
291
|
+
if (!fs.exists(promptFile)) {
|
|
292
|
+
ctx.stderr(`Error: Prompt '${promptName}' not found`);
|
|
293
|
+
ctx.stderr("Available prompts:");
|
|
294
|
+
try {
|
|
295
|
+
const files = await fs.readdir(promptsDir);
|
|
296
|
+
const prompts = files.filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""));
|
|
297
|
+
for (const p of prompts) {
|
|
298
|
+
ctx.stderr(` ${p}`);
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
ctx.stderr(" (no prompts directory found)");
|
|
302
|
+
}
|
|
303
|
+
return { exitCode: 1 };
|
|
304
|
+
}
|
|
305
|
+
const content = await fs.readFile(promptFile);
|
|
306
|
+
ctx.stdout(content);
|
|
307
|
+
return { exitCode: 0 };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// lib/cli/settings.ts
|
|
311
|
+
import { join } from "node:path";
|
|
312
|
+
var DEFAULT_SETTINGS = {
|
|
313
|
+
binaryPath: "dust"
|
|
314
|
+
};
|
|
315
|
+
async function loadSettings(cwd, fs) {
|
|
316
|
+
const settingsPath = join(cwd, ".dust", "config", "settings.json");
|
|
317
|
+
if (!fs.exists(settingsPath)) {
|
|
318
|
+
return DEFAULT_SETTINGS;
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
const content = await fs.readFile(settingsPath);
|
|
322
|
+
const parsed = JSON.parse(content);
|
|
323
|
+
return {
|
|
324
|
+
...DEFAULT_SETTINGS,
|
|
325
|
+
...parsed
|
|
326
|
+
};
|
|
327
|
+
} catch {
|
|
328
|
+
return DEFAULT_SETTINGS;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// lib/cli/main.ts
|
|
333
|
+
var COMMANDS = [
|
|
334
|
+
"init",
|
|
335
|
+
"prompt",
|
|
336
|
+
"validate",
|
|
337
|
+
"list",
|
|
338
|
+
"next",
|
|
339
|
+
"check",
|
|
340
|
+
"help"
|
|
341
|
+
];
|
|
342
|
+
function generateHelpText(settings) {
|
|
343
|
+
const bin = settings.binaryPath;
|
|
344
|
+
return `dust - A lightweight planning system for human-AI collaboration
|
|
345
|
+
|
|
346
|
+
Usage: ${bin} <command> [options]
|
|
347
|
+
|
|
348
|
+
Commands:
|
|
349
|
+
init Initialize a new Dust repository
|
|
350
|
+
prompt <name> Output a prompt by name (e.g., ${bin} prompt work)
|
|
351
|
+
validate Run validation checks on .dust/ files
|
|
352
|
+
list [type] List items (tasks, ideas, goals, facts)
|
|
353
|
+
next Show tasks ready to work on (not blocked)
|
|
354
|
+
check Run project-defined quality gate hook
|
|
355
|
+
help Show this help message
|
|
356
|
+
|
|
357
|
+
Examples:
|
|
358
|
+
${bin} init
|
|
359
|
+
${bin} prompt work
|
|
360
|
+
${bin} validate
|
|
361
|
+
${bin} list tasks
|
|
362
|
+
${bin} list
|
|
363
|
+
${bin} next
|
|
364
|
+
${bin} check
|
|
365
|
+
|
|
366
|
+
---
|
|
367
|
+
|
|
368
|
+
## Agent Guide
|
|
369
|
+
|
|
370
|
+
This section provides comprehensive guidance for AI agents working with dust.
|
|
371
|
+
|
|
372
|
+
### Directory Structure
|
|
373
|
+
|
|
374
|
+
The \`.dust/\` directory contains all planning artifacts:
|
|
375
|
+
|
|
376
|
+
- **\`.dust/goals/\`** - Mission statements and guiding principles
|
|
377
|
+
- **\`.dust/ideas/\`** - Future feature notes and proposals (intentionally vague)
|
|
378
|
+
- **\`.dust/tasks/\`** - Detailed work plans with dependencies and definitions of done
|
|
379
|
+
- **\`.dust/facts/\`** - Documentation of current system state and architecture
|
|
380
|
+
- **\`.dust/hooks/\`** - Executable scripts for quality gates (e.g., \`check\` hook)
|
|
381
|
+
|
|
382
|
+
All files are markdown with slug-style names (lowercase, hyphens, no spaces).
|
|
383
|
+
|
|
384
|
+
### Working on Tasks
|
|
385
|
+
|
|
386
|
+
**Run \`${bin} check\` before starting work** to verify the project is in a good state before making changes.
|
|
387
|
+
|
|
388
|
+
Run \`${bin} next\` to find tasks ready to work on. Each task file contains:
|
|
389
|
+
|
|
390
|
+
- \`## Goals\` - Links to goals this task supports
|
|
391
|
+
- \`## Blocked by\` - Tasks that must complete first (empty or "(none)" means ready)
|
|
392
|
+
- \`## Definition of done\` - Criteria for completion
|
|
393
|
+
|
|
394
|
+
A task is **unblocked** when its "Blocked by" section is empty, says "(none)", or all referenced task files have been deleted.
|
|
395
|
+
|
|
396
|
+
### Completing a Task
|
|
397
|
+
|
|
398
|
+
**Run \`${bin} check\` before committing** to ensure all quality gates pass.
|
|
399
|
+
|
|
400
|
+
When finishing a task, create a single atomic commit that includes:
|
|
401
|
+
|
|
402
|
+
1. All implementation changes
|
|
403
|
+
2. Deletion of the completed task file
|
|
404
|
+
3. Updates to any facts that changed
|
|
405
|
+
4. Deletion of any ideas that were fully realized
|
|
406
|
+
5. Updates to any tasks that referenced this one in their "Blocked by" sections
|
|
407
|
+
|
|
408
|
+
### Common Workflows
|
|
409
|
+
|
|
410
|
+
- **"Work on the next task"** - Run \`${bin} next\`, pick a task, implement it
|
|
411
|
+
- **"Work on task X"** - Implement \`.dust/tasks/X.md\` directly
|
|
412
|
+
- **"Convert idea Y to tasks"** - Break down \`.dust/ideas/Y.md\` into tasks
|
|
413
|
+
- **"Validate facts"** - Check \`.dust/facts/\` for accuracy against the codebase
|
|
414
|
+
|
|
415
|
+
### Configuring Agent Files
|
|
416
|
+
|
|
417
|
+
Projects using dust should add a minimal pointer to their agent configuration files (CLAUDE.md, AGENTS.md, etc.):
|
|
418
|
+
|
|
419
|
+
\`\`\`markdown
|
|
420
|
+
This project uses [dust](https://github.com/joshski/dust) for planning and documentation.
|
|
421
|
+
Always run \`dust help\` when you start working in this repository.
|
|
422
|
+
\`\`\`
|
|
423
|
+
|
|
424
|
+
This approach keeps agent instructions minimal, ensures agents get current documentation, and reduces maintenance burden.
|
|
425
|
+
`;
|
|
426
|
+
}
|
|
427
|
+
var HELP_TEXT = generateHelpText({ binaryPath: "dust" });
|
|
428
|
+
function isHelpRequest(command) {
|
|
429
|
+
return !command || command === "help" || command === "--help" || command === "-h";
|
|
430
|
+
}
|
|
431
|
+
function isValidCommand(command) {
|
|
432
|
+
return COMMANDS.includes(command);
|
|
433
|
+
}
|
|
434
|
+
async function runCommand(command, commandArgs, ctx, fs, glob, settings) {
|
|
435
|
+
switch (command) {
|
|
436
|
+
case "init":
|
|
437
|
+
return init(ctx, fs, commandArgs);
|
|
438
|
+
case "prompt":
|
|
439
|
+
return prompt(ctx, fs, commandArgs);
|
|
440
|
+
case "validate":
|
|
441
|
+
return validate(ctx, fs, commandArgs, glob);
|
|
442
|
+
case "list":
|
|
443
|
+
return list(ctx, fs, commandArgs);
|
|
444
|
+
case "next":
|
|
445
|
+
return next(ctx, fs, commandArgs);
|
|
446
|
+
case "check":
|
|
447
|
+
return check(ctx, fs, commandArgs, defaultProcessRunner, glob);
|
|
448
|
+
case "help":
|
|
449
|
+
ctx.stdout(generateHelpText(settings));
|
|
450
|
+
return { exitCode: 0 };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
async function main(options) {
|
|
454
|
+
const { args, ctx, fs, glob } = options;
|
|
455
|
+
const command = args[0];
|
|
456
|
+
const commandArgs = args.slice(1);
|
|
457
|
+
const settings = await loadSettings(ctx.cwd, fs);
|
|
458
|
+
const helpText = generateHelpText(settings);
|
|
459
|
+
if (isHelpRequest(command)) {
|
|
460
|
+
ctx.stdout(helpText);
|
|
461
|
+
return { exitCode: 0 };
|
|
462
|
+
}
|
|
463
|
+
if (!isValidCommand(command)) {
|
|
464
|
+
ctx.stderr(`Unknown command: ${command}`);
|
|
465
|
+
ctx.stderr(`Run '${settings.binaryPath} help' for available commands`);
|
|
466
|
+
return { exitCode: 1 };
|
|
467
|
+
}
|
|
468
|
+
return runCommand(command, commandArgs, ctx, fs, glob, settings);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// lib/cli/entry.ts
|
|
472
|
+
var fs = {
|
|
473
|
+
exists: existsSync,
|
|
474
|
+
readFile: (path) => readFile(path, "utf-8"),
|
|
475
|
+
writeFile: (path, content) => writeFile(path, content, "utf-8"),
|
|
476
|
+
mkdir: async (path, options) => {
|
|
477
|
+
await mkdir(path, options);
|
|
478
|
+
},
|
|
479
|
+
readdir: (path) => readdir(path)
|
|
480
|
+
};
|
|
481
|
+
var glob = {
|
|
482
|
+
scan: async function* (dir) {
|
|
483
|
+
for (const entry of await readdir(dir, { recursive: true })) {
|
|
484
|
+
if (entry.endsWith(".md"))
|
|
485
|
+
yield entry;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
var result = await main({
|
|
490
|
+
args: process.argv.slice(2),
|
|
491
|
+
ctx: {
|
|
492
|
+
cwd: process.cwd(),
|
|
493
|
+
stdout: console.log,
|
|
494
|
+
stderr: console.error
|
|
495
|
+
},
|
|
496
|
+
fs,
|
|
497
|
+
glob
|
|
498
|
+
});
|
|
499
|
+
process.exit(result.exitCode);
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@joshski/dust",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A lightweight planning system for human-AI collaboration",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"dust": "./dist/dust.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"bin"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/joshski/dust.git"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"ai",
|
|
19
|
+
"planning",
|
|
20
|
+
"collaboration",
|
|
21
|
+
"cli"
|
|
22
|
+
],
|
|
23
|
+
"author": "joshski",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "bun build lib/cli/entry.ts --target node --outfile dist/dust.js",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"test:coverage": "vitest run --coverage"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@biomejs/biome": "^2.3.13",
|
|
32
|
+
"@types/bun": "^1.3.6",
|
|
33
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
34
|
+
"vitest": "^4.0.18"
|
|
35
|
+
}
|
|
36
|
+
}
|