@joshski/dust 0.1.112 → 0.1.113

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 CHANGED
@@ -40,6 +40,23 @@ npx dust loop claude
40
40
 
41
41
  This runs Claude Code in a [ralph loop](https://ghuntley.com/loop/), picking up tasks until they are all done.
42
42
 
43
+ ## Codex Hook (Optional)
44
+
45
+ For [Codex](https://github.com/openai/codex) 0.125.0 or newer, you can replace the `AGENTS.md` instruction with a `SessionStart` hook that loads dust's instructions directly into the model's context — once per session, with no extra agent commands. Add this to `~/.codex/config.toml` (or your project's Codex config):
46
+
47
+ ```toml
48
+ [features]
49
+ codex_hooks = true
50
+
51
+ [[hooks.SessionStart]]
52
+ matcher = "^startup$"
53
+
54
+ [[hooks.SessionStart.hooks]]
55
+ type = "command"
56
+ command = "bunx dust codex hook"
57
+ statusMessage = "Loading dust agent instructions"
58
+ ```
59
+
43
60
  ## Learn More
44
61
 
45
62
  Details live in the [.dust/facts](./.dust/facts) directory:
@@ -16,7 +16,7 @@ export interface TemplateVars {
16
16
  isClaudeCodeWeb: boolean;
17
17
  hasIdeaFile: boolean;
18
18
  }
19
- export interface TemplateVarsWithInstructions extends TemplateVars {
19
+ interface TemplateVarsWithInstructions extends TemplateVars {
20
20
  agentInstructions: string;
21
21
  }
22
22
  /**
@@ -33,6 +33,13 @@ export declare function templateVariables(settings: DustSettings, hooksInstalled
33
33
  export declare function templateVariablesWithInstructions(cwd: string, fileSystem: FileReader, settings: DustSettings, hooksInstalled: boolean, env: NodeJS.ProcessEnv, options?: {
34
34
  hasIdeaFile?: boolean;
35
35
  }): Promise<TemplateVarsWithInstructions>;
36
+ /**
37
+ * Renders the dust agent greeting text.
38
+ *
39
+ * Used by both `dust agent` (printed to the user) and `dust codex hook`
40
+ * (injected as `additionalContext` into the model's session context).
41
+ */
42
+ export declare function agentGreeting(vars: TemplateVarsWithInstructions): string;
36
43
  /**
37
44
  * Manages git hook installation for agent commands.
38
45
  * Automatically installs pre-push hooks if:
@@ -42,3 +49,4 @@ export declare function templateVariablesWithInstructions(cwd: string, fileSyste
42
49
  * Returns whether hooks are installed.
43
50
  */
44
51
  export declare function manageGitHooks(dependencies: CommandDependencies): Promise<boolean>;
52
+ export {};
package/dist/dust.js CHANGED
@@ -7,7 +7,7 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
7
7
  var require_package = __commonJS((exports, module) => {
8
8
  module.exports = {
9
9
  name: "@joshski/dust",
10
- version: "0.1.112",
10
+ version: "0.1.113",
11
11
  description: "Flow state for AI coding agents",
12
12
  type: "module",
13
13
  bin: {
@@ -731,7 +731,7 @@ async function loadSettings(cwd, fileSystem, runtime) {
731
731
  }
732
732
 
733
733
  // lib/version.ts
734
- var DUST_VERSION = "0.1.112";
734
+ var DUST_VERSION = "0.1.113";
735
735
 
736
736
  // lib/cli/middleware.ts
737
737
  function applyMiddleware(middlewares, execute) {
@@ -783,16 +783,6 @@ function createDefaultTracingOptions() {
783
783
  };
784
784
  }
785
785
 
786
- // lib/cli/dedent.ts
787
- function dedent(strings, ...values) {
788
- const result = strings.reduce((acc, part, index) => acc + part + (values[index] ?? ""), "");
789
- const lines = result.split(`
790
- `);
791
- const indent = lines.filter((line) => line.trim()).reduce((min, line) => Math.min(min, line.match(/^\s*/)[0].length), Number.POSITIVE_INFINITY);
792
- return lines.map((line) => line.slice(indent)).join(`
793
- `).trim();
794
- }
795
-
796
786
  // lib/cli/shared/agent-shared.ts
797
787
  import { join as join4 } from "node:path";
798
788
 
@@ -939,6 +929,16 @@ ${newHookContent}
939
929
  };
940
930
  }
941
931
 
932
+ // lib/cli/dedent.ts
933
+ function dedent(strings, ...values) {
934
+ const result = strings.reduce((acc, part, index) => acc + part + (values[index] ?? ""), "");
935
+ const lines = result.split(`
936
+ `);
937
+ const indent = lines.filter((line) => line.trim()).reduce((min, line) => Math.min(min, line.match(/^\s*/)[0].length), Number.POSITIVE_INFINITY);
938
+ return lines.map((line) => line.slice(indent)).join(`
939
+ `).trim();
940
+ }
941
+
942
942
  // lib/cli/shared/agent-shared.ts
943
943
  async function loadAgentInstructions(cwd, fileSystem, agentType) {
944
944
  const instructionsPath = join4(cwd, ".dust", "config", "agents", `${agentType}.md`);
@@ -979,25 +979,6 @@ async function templateVariablesWithInstructions(cwd, fileSystem, settings, hook
979
979
  agentInstructions
980
980
  };
981
981
  }
982
- async function manageGitHooks(dependencies) {
983
- const { context, fileSystem, settings } = dependencies;
984
- const hooks = createHooksManager(context.cwd, fileSystem, settings);
985
- if (!hooks.isGitRepo()) {
986
- return false;
987
- }
988
- const isInstalled = await hooks.isHookInstalled();
989
- if (!isInstalled) {
990
- await hooks.installHook();
991
- return true;
992
- }
993
- const hookBinaryPath = await hooks.getHookBinaryPath();
994
- if (hookBinaryPath && hookBinaryPath !== settings.dustCommand) {
995
- await hooks.updateHookBinaryPath(settings.dustCommand);
996
- }
997
- return true;
998
- }
999
-
1000
- // lib/cli/commands/agent.ts
1001
982
  function agentGreeting(vars) {
1002
983
  const instructions = vars.agentInstructions ? `
1003
984
  ---
@@ -1035,6 +1016,25 @@ ${vars.agentInstructions}` : "";
1035
1016
  Do NOT proceed without running one of these commands.${instructions}
1036
1017
  `;
1037
1018
  }
1019
+ async function manageGitHooks(dependencies) {
1020
+ const { context, fileSystem, settings } = dependencies;
1021
+ const hooks = createHooksManager(context.cwd, fileSystem, settings);
1022
+ if (!hooks.isGitRepo()) {
1023
+ return false;
1024
+ }
1025
+ const isInstalled = await hooks.isHookInstalled();
1026
+ if (!isInstalled) {
1027
+ await hooks.installHook();
1028
+ return true;
1029
+ }
1030
+ const hookBinaryPath = await hooks.getHookBinaryPath();
1031
+ if (hookBinaryPath && hookBinaryPath !== settings.dustCommand) {
1032
+ await hooks.updateHookBinaryPath(settings.dustCommand);
1033
+ }
1034
+ return true;
1035
+ }
1036
+
1037
+ // lib/cli/commands/agent.ts
1038
1038
  async function agent(dependencies, env = process.env) {
1039
1039
  const { context, fileSystem, settings } = dependencies;
1040
1040
  if (env[DUST_SKIP_AGENT] === "1") {
@@ -6420,6 +6420,7 @@ async function parseCaptureIdeaTask(fileSystem, dustPath, taskSlug) {
6420
6420
  }
6421
6421
 
6422
6422
  // lib/lint/validators/content-validator.ts
6423
+ var FRONT_MATTER_DELIMITER = "---";
6423
6424
  var REQUIRED_TASK_HEADINGS = ["Task Type", "Blocked By", "Definition of Done"];
6424
6425
  var ALLOWED_TASK_TYPES = new Set(VALID_TASK_TYPES);
6425
6426
  var MAX_OPENING_SENTENCE_LENGTH = 150;
@@ -6437,6 +6438,18 @@ var NON_IMPERATIVE_STARTERS = new Set([
6437
6438
  "you",
6438
6439
  "i"
6439
6440
  ]);
6441
+ function validateNoFrontMatter(artifact) {
6442
+ const firstLine = artifact.rawContent.split(`
6443
+ `)[0];
6444
+ if (firstLine.trim() === FRONT_MATTER_DELIMITER) {
6445
+ return {
6446
+ file: artifact.filePath,
6447
+ line: 1,
6448
+ message: "Artifact must not contain front matter. The title must be the first line."
6449
+ };
6450
+ }
6451
+ return null;
6452
+ }
6440
6453
  function validateOpeningSentence(artifact) {
6441
6454
  if (!artifact.openingSentence) {
6442
6455
  return {
@@ -6897,6 +6910,40 @@ async function executeTask(task, runParameters, onAgentEvent, context, agentName
6897
6910
  return "claude_error";
6898
6911
  }
6899
6912
  }
6913
+ function selectShellRunner(spawnFn, options, loopDeps) {
6914
+ if (options.docker && options.containerRuntime) {
6915
+ return buildContainerShellRunner(spawnFn, options.containerRuntime, options.docker);
6916
+ }
6917
+ return loopDeps.shellRunner ?? defaultShellRunner;
6918
+ }
6919
+ function buildContainerShellRunner(spawnFn, containerRuntime, docker) {
6920
+ const runConfig = {
6921
+ imageTag: docker.imageTag,
6922
+ repoPath: docker.repoPath,
6923
+ homeDir: docker.homeDir,
6924
+ gitProxyUrl: docker.gitProxyUrl
6925
+ };
6926
+ const baseArgs = containerRuntime.buildRunArgs(runConfig);
6927
+ return {
6928
+ run: (command, _cwd) => new Promise((resolve) => {
6929
+ const proc = spawnFn(containerRuntime.runCommand, [
6930
+ ...baseArgs,
6931
+ "sh",
6932
+ "-c",
6933
+ command
6934
+ ]);
6935
+ const chunks = [];
6936
+ proc.stdout?.on("data", (data) => chunks.push(data.toString()));
6937
+ proc.stderr?.on("data", (data) => chunks.push(data.toString()));
6938
+ proc.on("close", (code) => {
6939
+ resolve({ exitCode: code ?? 1, output: chunks.join("") });
6940
+ });
6941
+ proc.on("error", (error) => {
6942
+ resolve({ exitCode: 1, output: error.message });
6943
+ });
6944
+ })
6945
+ };
6946
+ }
6900
6947
  async function runOneIteration(dependencies, loopDependencies, onLoopEvent, onAgentEvent, options = {}) {
6901
6948
  const { context, fileSystem, settings } = dependencies;
6902
6949
  const { spawn: spawn2, run: run2 } = loopDependencies;
@@ -6946,7 +6993,7 @@ async function runOneIteration(dependencies, loopDependencies, onLoopEvent, onAg
6946
6993
  const taskTitle = task.title ?? task.path;
6947
6994
  log2(`found ${tasks.length} task(s), picking: ${taskTitle}`);
6948
6995
  onLoopEvent({ type: "loop.tasks_found" });
6949
- const shellRunner = loopDependencies.shellRunner ?? defaultShellRunner;
6996
+ const shellRunner = selectShellRunner(spawn2, options, loopDependencies);
6950
6997
  const preflightResult = await runPreflightChecks(context.cwd, settings.dustCommand, settings.installCommand, shellRunner, onLoopEvent, onAgentEvent, taskTitle);
6951
6998
  if (preflightResult.failed) {
6952
6999
  return handleCheckFailure(preflightResult.output, settings.dustCommand, { run: run2, prompt: "", spawnOptions, onRawEvent }, onAgentEvent, context, agentName, agentType, logger);
@@ -11440,6 +11487,8 @@ function validateIdeaOpenQuestions(artifact) {
11440
11487
  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.";
11441
11488
  let inOpenQuestions = false;
11442
11489
  let currentQuestionLine = null;
11490
+ let currentQuestionText = null;
11491
+ let currentQuestionOptionNames = new Set;
11443
11492
  let inOption = false;
11444
11493
  let inCodeBlock = false;
11445
11494
  for (let i = 0;i < lines.length; i++) {
@@ -11463,6 +11512,8 @@ function validateIdeaOpenQuestions(artifact) {
11463
11512
  violations.push(...validateH2Heading(filePath, line, i + 1, inOpenQuestions, currentQuestionLine));
11464
11513
  inOpenQuestions = line === "## Open Questions";
11465
11514
  currentQuestionLine = null;
11515
+ currentQuestionText = null;
11516
+ currentQuestionOptionNames = new Set;
11466
11517
  inOption = false;
11467
11518
  inCodeBlock = false;
11468
11519
  continue;
@@ -11478,6 +11529,7 @@ function validateIdeaOpenQuestions(artifact) {
11478
11529
  line: currentQuestionLine
11479
11530
  });
11480
11531
  }
11532
+ currentQuestionOptionNames = new Set;
11481
11533
  if (!trimmedLine.endsWith("?")) {
11482
11534
  violations.push({
11483
11535
  file: filePath,
@@ -11485,12 +11537,24 @@ function validateIdeaOpenQuestions(artifact) {
11485
11537
  line: i + 1
11486
11538
  });
11487
11539
  currentQuestionLine = null;
11540
+ currentQuestionText = null;
11488
11541
  } else {
11489
11542
  currentQuestionLine = i + 1;
11543
+ currentQuestionText = trimmedLine.slice(4);
11490
11544
  }
11491
11545
  continue;
11492
11546
  }
11493
11547
  if (line.startsWith("#### ")) {
11548
+ const optionName = trimmedLine.slice(5);
11549
+ if (currentQuestionOptionNames.has(optionName)) {
11550
+ violations.push({
11551
+ file: filePath,
11552
+ message: `Duplicate option "${optionName}" under question "${currentQuestionText}" — each option must have a unique name`,
11553
+ line: i + 1
11554
+ });
11555
+ } else {
11556
+ currentQuestionOptionNames.add(optionName);
11557
+ }
11494
11558
  currentQuestionLine = null;
11495
11559
  inOption = true;
11496
11560
  continue;
@@ -11866,6 +11930,9 @@ function validateArtifacts(context) {
11866
11930
  }
11867
11931
  for (const artifacts of Object.values(byType)) {
11868
11932
  for (const artifact of artifacts) {
11933
+ const frontMatterViolation = validateNoFrontMatter(artifact);
11934
+ if (frontMatterViolation)
11935
+ violations.push(frontMatterViolation);
11869
11936
  const openingSentenceViolation = validateOpeningSentence(artifact);
11870
11937
  if (openingSentenceViolation)
11871
11938
  violations.push(openingSentenceViolation);
@@ -12214,6 +12281,75 @@ async function check(dependencies, shellRunner, clock, _setInterval, _clearInter
12214
12281
  return { exitCode };
12215
12282
  }
12216
12283
 
12284
+ // lib/cli/commands/codex-hook.ts
12285
+ var KNOWN_HOOK_EVENTS = [
12286
+ "PreToolUse",
12287
+ "PermissionRequest",
12288
+ "PostToolUse",
12289
+ "SessionStart",
12290
+ "UserPromptSubmit",
12291
+ "Stop"
12292
+ ];
12293
+ async function readStdinUtf8() {
12294
+ const chunks = [];
12295
+ for await (const chunk of process.stdin) {
12296
+ chunks.push(chunk);
12297
+ }
12298
+ return Buffer.concat(chunks).toString("utf8");
12299
+ }
12300
+ var defaultCodexHookDependencies = {
12301
+ readStdin: readStdinUtf8
12302
+ };
12303
+ function isKnownEvent(value) {
12304
+ return typeof value === "string" && KNOWN_HOOK_EVENTS.includes(value);
12305
+ }
12306
+ async function handleSessionStart(dependencies) {
12307
+ const { context, fileSystem, settings } = dependencies;
12308
+ const agentInstructions = await loadAgentInstructions(context.cwd, fileSystem, "codex");
12309
+ const additionalContext = agentGreeting({
12310
+ bin: settings.dustCommand,
12311
+ agentName: "Codex",
12312
+ hooksInstalled: false,
12313
+ isClaudeCodeWeb: false,
12314
+ hasIdeaFile: true,
12315
+ agentInstructions
12316
+ });
12317
+ return JSON.stringify({
12318
+ continue: true,
12319
+ hookSpecificOutput: {
12320
+ hookEventName: "SessionStart",
12321
+ additionalContext
12322
+ },
12323
+ systemMessage: "dust agent loaded"
12324
+ });
12325
+ }
12326
+ function handleNoOp() {
12327
+ return JSON.stringify({ continue: true });
12328
+ }
12329
+ async function codexHook(dependencies, hookDependencies = defaultCodexHookDependencies) {
12330
+ const { context } = dependencies;
12331
+ const raw = await hookDependencies.readStdin();
12332
+ let payload;
12333
+ try {
12334
+ payload = JSON.parse(raw);
12335
+ } catch {
12336
+ context.stderr("dust codex hook: failed to parse stdin as JSON");
12337
+ return { exitCode: 1 };
12338
+ }
12339
+ if (!payload || typeof payload !== "object") {
12340
+ context.stderr("dust codex hook: stdin payload must be a JSON object");
12341
+ return { exitCode: 1 };
12342
+ }
12343
+ const eventName = payload.hook_event_name;
12344
+ if (!isKnownEvent(eventName)) {
12345
+ context.stderr(`dust codex hook: unknown hook_event_name: ${JSON.stringify(eventName)}`);
12346
+ return { exitCode: 1 };
12347
+ }
12348
+ const response = eventName === "SessionStart" ? await handleSessionStart(dependencies) : handleNoOp();
12349
+ context.stdout(response);
12350
+ return { exitCode: 0 };
12351
+ }
12352
+
12217
12353
  // lib/bundled-core-principles.ts
12218
12354
  var BUNDLED_PRINCIPLES = [
12219
12355
  {
@@ -13857,16 +13993,15 @@ async function init(dependencies) {
13857
13993
  throw error;
13858
13994
  }
13859
13995
  }
13860
- const runner = dustCommand.split(" ")[0];
13861
13996
  context.stdout("");
13862
13997
  context.stdout(`${colors.bold}\uD83D\uDE80 Next steps:${colors.reset} Commit the changes if you are happy, then get planning!`);
13863
13998
  context.stdout("");
13864
13999
  context.stdout(`${colors.dim}If this is a new repository, you can start adding ideas or tasks right away:${colors.reset}`);
13865
- context.stdout(` ${colors.cyan}>${colors.reset} ${runner} claude "Idea: friendly UI for non-technical users"`);
13866
- context.stdout(` ${colors.cyan}>${colors.reset} ${runner} codex "Task: set up code coverage"`);
14000
+ context.stdout(` ${colors.cyan}>${colors.reset} claude "Idea: friendly UI for non-technical users"`);
14001
+ context.stdout(` ${colors.cyan}>${colors.reset} codex "Task: set up code coverage"`);
13867
14002
  context.stdout("");
13868
14003
  context.stdout(`${colors.dim}If this is an existing codebase, you might want to backfill principles and facts:${colors.reset}`);
13869
- context.stdout(` ${colors.cyan}>${colors.reset} ${runner} claude "Add principles and facts based on the code in this repository"`);
14004
+ context.stdout(` ${colors.cyan}>${colors.reset} claude "Add principles and facts based on the code in this repository"`);
13870
14005
  return { exitCode: 0 };
13871
14006
  }
13872
14007
 
@@ -14377,7 +14512,8 @@ async function runLoop(dependencies, loopDependencies) {
14377
14512
  let completedIterations = 0;
14378
14513
  const iterationOptions = {
14379
14514
  hooksInstalled,
14380
- docker: dockerConfig
14515
+ docker: dockerConfig,
14516
+ containerRuntime
14381
14517
  };
14382
14518
  if (eventsUrl) {
14383
14519
  iterationOptions.onRawEvent = createHeartbeatThrottler(onAgentEvent, loopDependencies.agentType ?? "claude");
@@ -14861,6 +14997,9 @@ function runLoopClaude(commandDependencies) {
14861
14997
  function runLoopCodex(commandDependencies) {
14862
14998
  return loopCodex(commandDependencies, createCodexDependencies());
14863
14999
  }
15000
+ function runCodexHook(commandDependencies) {
15001
+ return codexHook(commandDependencies, defaultCodexHookDependencies);
15002
+ }
14864
15003
  var commandRegistry = {
14865
15004
  init,
14866
15005
  lint: lintMarkdown,
@@ -14875,6 +15014,7 @@ var commandRegistry = {
14875
15014
  audit,
14876
15015
  "bucket worker": bucketWorker,
14877
15016
  "bucket tool": bucketTool,
15017
+ "codex hook": runCodexHook,
14878
15018
  "core principle": corePrinciple,
14879
15019
  focus,
14880
15020
  "new task": newTask,
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import type { ParsedArtifact } from '../../artifacts/parsed-artifact';
5
5
  import type { Violation } from './types';
6
+ export declare function validateNoFrontMatter(artifact: ParsedArtifact): Violation | null;
6
7
  export declare function validateOpeningSentence(artifact: ParsedArtifact): Violation | null;
7
8
  export declare function validateOpeningSentenceLength(artifact: ParsedArtifact): Violation | null;
8
9
  export declare function validateImperativeOpeningSentence(artifact: ParsedArtifact): Violation | null;
@@ -1,6 +1,7 @@
1
1
  import { spawn as nodeSpawn } from 'node:child_process';
2
2
  import type { BoundRunFn, DockerSpawnConfig } from '../claude/types';
3
3
  import type { DockerDependencies } from '../docker/docker-agent';
4
+ import type { ContainerRuntime } from '../container/runtime';
4
5
  import { type SessionConfig } from '../env-config';
5
6
  import { type ShellRunner } from '../cli/process-runner';
6
7
  import type { CommandDependencies } from '../cli/types';
@@ -40,11 +41,14 @@ export interface IterationOptions {
40
41
  branch?: string;
41
42
  /** Trace ID for correlating events across processes */
42
43
  traceId?: string;
44
+ /** Container runtime to use for container-aware pre-flight checks */
45
+ containerRuntime?: ContainerRuntime | null;
43
46
  }
44
47
  export declare function findAvailableTasks(dependencies: CommandDependencies): Promise<{
45
48
  tasks: UnblockedTask[];
46
49
  invalidTasks: InvalidTask[];
47
50
  }>;
51
+ export declare function buildContainerShellRunner(spawnFn: typeof nodeSpawn, containerRuntime: ContainerRuntime, docker: DockerSpawnConfig): ShellRunner;
48
52
  export declare function runOneIteration(dependencies: CommandDependencies, loopDependencies: LoopDependencies, onLoopEvent: LoopEmitFn, onAgentEvent?: SendAgentEventFn, options?: IterationOptions): Promise<IterationResult>;
49
53
  export declare function buildTaskPrompt(taskPath: string, taskContent: string, instructions: string, toolsSection: string, dustCommand: string, branch?: string): string;
50
54
  export {};
package/dist/patch.js CHANGED
@@ -251,6 +251,7 @@ function validateAuditHeadings(artifact) {
251
251
  }
252
252
 
253
253
  // lib/lint/validators/content-validator.ts
254
+ var FRONT_MATTER_DELIMITER = "---";
254
255
  var REQUIRED_TASK_HEADINGS = ["Task Type", "Blocked By", "Definition of Done"];
255
256
  var ALLOWED_TASK_TYPES = new Set(VALID_TASK_TYPES);
256
257
  var MAX_OPENING_SENTENCE_LENGTH = 150;
@@ -268,6 +269,18 @@ var NON_IMPERATIVE_STARTERS = new Set([
268
269
  "you",
269
270
  "i"
270
271
  ]);
272
+ function validateNoFrontMatter(artifact) {
273
+ const firstLine = artifact.rawContent.split(`
274
+ `)[0];
275
+ if (firstLine.trim() === FRONT_MATTER_DELIMITER) {
276
+ return {
277
+ file: artifact.filePath,
278
+ line: 1,
279
+ message: "Artifact must not contain front matter. The title must be the first line."
280
+ };
281
+ }
282
+ return null;
283
+ }
271
284
  function validateOpeningSentence(artifact) {
272
285
  if (!artifact.openingSentence) {
273
286
  return {
@@ -453,6 +466,8 @@ function validateIdeaOpenQuestions(artifact) {
453
466
  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.";
454
467
  let inOpenQuestions = false;
455
468
  let currentQuestionLine = null;
469
+ let currentQuestionText = null;
470
+ let currentQuestionOptionNames = new Set;
456
471
  let inOption = false;
457
472
  let inCodeBlock = false;
458
473
  for (let i = 0;i < lines.length; i++) {
@@ -476,6 +491,8 @@ function validateIdeaOpenQuestions(artifact) {
476
491
  violations.push(...validateH2Heading(filePath, line, i + 1, inOpenQuestions, currentQuestionLine));
477
492
  inOpenQuestions = line === "## Open Questions";
478
493
  currentQuestionLine = null;
494
+ currentQuestionText = null;
495
+ currentQuestionOptionNames = new Set;
479
496
  inOption = false;
480
497
  inCodeBlock = false;
481
498
  continue;
@@ -491,6 +508,7 @@ function validateIdeaOpenQuestions(artifact) {
491
508
  line: currentQuestionLine
492
509
  });
493
510
  }
511
+ currentQuestionOptionNames = new Set;
494
512
  if (!trimmedLine.endsWith("?")) {
495
513
  violations.push({
496
514
  file: filePath,
@@ -498,12 +516,24 @@ function validateIdeaOpenQuestions(artifact) {
498
516
  line: i + 1
499
517
  });
500
518
  currentQuestionLine = null;
519
+ currentQuestionText = null;
501
520
  } else {
502
521
  currentQuestionLine = i + 1;
522
+ currentQuestionText = trimmedLine.slice(4);
503
523
  }
504
524
  continue;
505
525
  }
506
526
  if (line.startsWith("#### ")) {
527
+ const optionName = trimmedLine.slice(5);
528
+ if (currentQuestionOptionNames.has(optionName)) {
529
+ violations.push({
530
+ file: filePath,
531
+ message: `Duplicate option "${optionName}" under question "${currentQuestionText}" — each option must have a unique name`,
532
+ line: i + 1
533
+ });
534
+ } else {
535
+ currentQuestionOptionNames.add(optionName);
536
+ }
507
537
  currentQuestionLine = null;
508
538
  inOption = true;
509
539
  continue;
@@ -879,6 +909,9 @@ function validateArtifacts(context) {
879
909
  }
880
910
  for (const artifacts of Object.values(byType)) {
881
911
  for (const artifact of artifacts) {
912
+ const frontMatterViolation = validateNoFrontMatter(artifact);
913
+ if (frontMatterViolation)
914
+ violations.push(frontMatterViolation);
882
915
  const openingSentenceViolation = validateOpeningSentence(artifact);
883
916
  if (openingSentenceViolation)
884
917
  violations.push(openingSentenceViolation);
@@ -248,6 +248,7 @@ function validateAuditHeadings(artifact) {
248
248
  }
249
249
 
250
250
  // lib/lint/validators/content-validator.ts
251
+ var FRONT_MATTER_DELIMITER = "---";
251
252
  var REQUIRED_TASK_HEADINGS = ["Task Type", "Blocked By", "Definition of Done"];
252
253
  var ALLOWED_TASK_TYPES = new Set(VALID_TASK_TYPES);
253
254
  var MAX_OPENING_SENTENCE_LENGTH = 150;
@@ -265,6 +266,18 @@ var NON_IMPERATIVE_STARTERS = new Set([
265
266
  "you",
266
267
  "i"
267
268
  ]);
269
+ function validateNoFrontMatter(artifact) {
270
+ const firstLine = artifact.rawContent.split(`
271
+ `)[0];
272
+ if (firstLine.trim() === FRONT_MATTER_DELIMITER) {
273
+ return {
274
+ file: artifact.filePath,
275
+ line: 1,
276
+ message: "Artifact must not contain front matter. The title must be the first line."
277
+ };
278
+ }
279
+ return null;
280
+ }
268
281
  function validateOpeningSentence(artifact) {
269
282
  if (!artifact.openingSentence) {
270
283
  return {
@@ -450,6 +463,8 @@ function validateIdeaOpenQuestions(artifact) {
450
463
  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.";
451
464
  let inOpenQuestions = false;
452
465
  let currentQuestionLine = null;
466
+ let currentQuestionText = null;
467
+ let currentQuestionOptionNames = new Set;
453
468
  let inOption = false;
454
469
  let inCodeBlock = false;
455
470
  for (let i = 0;i < lines.length; i++) {
@@ -473,6 +488,8 @@ function validateIdeaOpenQuestions(artifact) {
473
488
  violations.push(...validateH2Heading(filePath, line, i + 1, inOpenQuestions, currentQuestionLine));
474
489
  inOpenQuestions = line === "## Open Questions";
475
490
  currentQuestionLine = null;
491
+ currentQuestionText = null;
492
+ currentQuestionOptionNames = new Set;
476
493
  inOption = false;
477
494
  inCodeBlock = false;
478
495
  continue;
@@ -488,6 +505,7 @@ function validateIdeaOpenQuestions(artifact) {
488
505
  line: currentQuestionLine
489
506
  });
490
507
  }
508
+ currentQuestionOptionNames = new Set;
491
509
  if (!trimmedLine.endsWith("?")) {
492
510
  violations.push({
493
511
  file: filePath,
@@ -495,12 +513,24 @@ function validateIdeaOpenQuestions(artifact) {
495
513
  line: i + 1
496
514
  });
497
515
  currentQuestionLine = null;
516
+ currentQuestionText = null;
498
517
  } else {
499
518
  currentQuestionLine = i + 1;
519
+ currentQuestionText = trimmedLine.slice(4);
500
520
  }
501
521
  continue;
502
522
  }
503
523
  if (line.startsWith("#### ")) {
524
+ const optionName = trimmedLine.slice(5);
525
+ if (currentQuestionOptionNames.has(optionName)) {
526
+ violations.push({
527
+ file: filePath,
528
+ message: `Duplicate option "${optionName}" under question "${currentQuestionText}" — each option must have a unique name`,
529
+ line: i + 1
530
+ });
531
+ } else {
532
+ currentQuestionOptionNames.add(optionName);
533
+ }
504
534
  currentQuestionLine = null;
505
535
  inOption = true;
506
536
  continue;
@@ -876,6 +906,9 @@ function validateArtifacts(context) {
876
906
  }
877
907
  for (const artifacts of Object.values(byType)) {
878
908
  for (const artifact of artifacts) {
909
+ const frontMatterViolation = validateNoFrontMatter(artifact);
910
+ if (frontMatterViolation)
911
+ violations.push(frontMatterViolation);
879
912
  const openingSentenceViolation = validateOpeningSentence(artifact);
880
913
  if (openingSentenceViolation)
881
914
  violations.push(openingSentenceViolation);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joshski/dust",
3
- "version": "0.1.112",
3
+ "version": "0.1.113",
4
4
  "description": "Flow state for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {