@joshski/dust 0.1.13 → 0.1.15

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/dust.js +420 -49
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Flow state for AI coding agents.**
4
4
 
5
- Dust provides a CLI that agents use to systematically blitz through your work queue.
5
+ Dust provides a CLI that agents use to systematically blaze through your backlog.
6
6
 
7
7
  [![CI](https://github.com/joshski/dust/actions/workflows/ci.yml/badge.svg)](https://github.com/joshski/dust/actions/workflows/ci.yml)
8
8
 
package/dist/dust.js CHANGED
@@ -97,6 +97,20 @@ function loadTemplate(name, variables = {}) {
97
97
  return content;
98
98
  }
99
99
 
100
+ // lib/agents/detection.ts
101
+ function detectAgent(env = process.env) {
102
+ if (env.CLAUDECODE) {
103
+ if (env.CLAUDE_CODE_ENTRYPOINT === "remote") {
104
+ return { type: "claude-code-web", name: "Claude Code Web" };
105
+ }
106
+ return { type: "claude-code", name: "Claude Code" };
107
+ }
108
+ if (env.CODEX_HOME) {
109
+ return { type: "codex", name: "Codex" };
110
+ }
111
+ return { type: "unknown", name: "Agent" };
112
+ }
113
+
100
114
  // lib/git/hooks.ts
101
115
  import { join as join3 } from "node:path";
102
116
  var DUST_HOOK_START = "# BEGIN DUST HOOK";
@@ -220,25 +234,13 @@ ${newHookContent}
220
234
  }
221
235
 
222
236
  // lib/cli/commands/agent-shared.ts
223
- function detectAgent(env = process.env) {
224
- if (env.CLAUDECODE) {
225
- if (env.CLAUDE_CODE_ENTRYPOINT === "remote") {
226
- return "Claude Code Web";
227
- }
228
- return "Claude Code";
229
- }
230
- if (env.CODEX_HOME) {
231
- return "Codex";
232
- }
233
- return "Agent";
234
- }
235
237
  function templateVariables(settings, hooksInstalled, env = process.env) {
236
- const agentName = detectAgent(env);
238
+ const agent = detectAgent(env);
237
239
  return {
238
240
  bin: settings.dustCommand,
239
- agentName,
241
+ agentName: agent.name,
240
242
  hooksInstalled: hooksInstalled ? "true" : "false",
241
- isClaudeCodeWeb: agentName === "Claude Code Web" ? "true" : ""
243
+ isClaudeCodeWeb: agent.type === "claude-code-web" ? "true" : ""
242
244
  };
243
245
  }
244
246
  async function manageGitHooks(dependencies) {
@@ -330,6 +332,7 @@ function extractOpeningSentence(content) {
330
332
  var REQUIRED_HEADINGS = ["## Goals", "## Blocked by", "## Definition of done"];
331
333
  var REQUIRED_GOAL_HEADINGS = ["## Parent Goal", "## Sub-Goals"];
332
334
  var SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*\.md$/;
335
+ var MAX_OPENING_SENTENCE_LENGTH = 150;
333
336
  function validateFilename(filePath) {
334
337
  const parts = filePath.split("/");
335
338
  const filename = parts[parts.length - 1];
@@ -370,6 +373,19 @@ function validateOpeningSentence(filePath, content) {
370
373
  }
371
374
  return null;
372
375
  }
376
+ function validateOpeningSentenceLength(filePath, content) {
377
+ const openingSentence = extractOpeningSentence(content);
378
+ if (!openingSentence) {
379
+ return null;
380
+ }
381
+ if (openingSentence.length > MAX_OPENING_SENTENCE_LENGTH) {
382
+ return {
383
+ file: filePath,
384
+ message: `Opening sentence is ${openingSentence.length} characters (max ${MAX_OPENING_SENTENCE_LENGTH}). Split into multiple sentences; only the first sentence is checked.`
385
+ };
386
+ }
387
+ return null;
388
+ }
373
389
  function validateTaskHeadings(filePath, content) {
374
390
  const violations = [];
375
391
  for (const heading of REQUIRED_HEADINGS) {
@@ -662,6 +678,10 @@ async function lintMarkdown(dependencies) {
662
678
  if (openingSentenceViolation) {
663
679
  violations.push(openingSentenceViolation);
664
680
  }
681
+ const openingSentenceLengthViolation = validateOpeningSentenceLength(filePath, content);
682
+ if (openingSentenceLengthViolation) {
683
+ violations.push(openingSentenceLengthViolation);
684
+ }
665
685
  const titleFilenameViolation = validateTitleFilenameMatch(filePath, content);
666
686
  if (titleFilenameViolation) {
667
687
  violations.push(titleFilenameViolation);
@@ -1101,7 +1121,8 @@ async function* spawnClaudeCode(prompt, options = {}, dependencies = defaultDepe
1101
1121
  model,
1102
1122
  systemPrompt,
1103
1123
  sessionId,
1104
- dangerouslySkipPermissions
1124
+ dangerouslySkipPermissions,
1125
+ env
1105
1126
  } = options;
1106
1127
  const claudeArguments = [
1107
1128
  "-p",
@@ -1131,7 +1152,8 @@ async function* spawnClaudeCode(prompt, options = {}, dependencies = defaultDepe
1131
1152
  }
1132
1153
  const proc = dependencies.spawn("claude", claudeArguments, {
1133
1154
  cwd,
1134
- stdio: ["ignore", "pipe", "pipe"]
1155
+ stdio: ["ignore", "pipe", "pipe"],
1156
+ env: { ...process.env, ...env }
1135
1157
  });
1136
1158
  if (!proc.stdout) {
1137
1159
  throw new Error("Failed to get stdout from claude process");
@@ -1202,7 +1224,216 @@ function* parseRawEvent(raw) {
1202
1224
  }
1203
1225
  }
1204
1226
 
1227
+ // lib/claude/tool-formatters.ts
1228
+ var DIVIDER = "────────────────────────────────";
1229
+ function formatWrite(input) {
1230
+ const filePath = input.file_path;
1231
+ const content = input.content;
1232
+ const others = getUnrecognizedArgs(input, ["file_path", "content"]);
1233
+ const lines = [];
1234
+ lines.push(`\uD83D\uDD27 Write: ${filePath ?? "(unknown)"}`);
1235
+ lines.push(DIVIDER);
1236
+ if (content !== undefined) {
1237
+ for (const line of content.split(`
1238
+ `)) {
1239
+ lines.push(line);
1240
+ }
1241
+ }
1242
+ lines.push(DIVIDER);
1243
+ appendOtherArgs(lines, others);
1244
+ return lines;
1245
+ }
1246
+ function formatEdit(input) {
1247
+ const filePath = input.file_path;
1248
+ const oldString = input.old_string;
1249
+ const newString = input.new_string;
1250
+ const others = getUnrecognizedArgs(input, [
1251
+ "file_path",
1252
+ "old_string",
1253
+ "new_string",
1254
+ "replace_all"
1255
+ ]);
1256
+ const lines = [];
1257
+ lines.push(`\uD83D\uDD27 Edit: ${filePath ?? "(unknown)"}`);
1258
+ lines.push("Replace:");
1259
+ lines.push(DIVIDER);
1260
+ if (oldString !== undefined) {
1261
+ for (const line of oldString.split(`
1262
+ `)) {
1263
+ lines.push(line);
1264
+ }
1265
+ }
1266
+ lines.push(DIVIDER);
1267
+ lines.push("With:");
1268
+ lines.push(DIVIDER);
1269
+ if (newString !== undefined) {
1270
+ for (const line of newString.split(`
1271
+ `)) {
1272
+ lines.push(line);
1273
+ }
1274
+ }
1275
+ lines.push(DIVIDER);
1276
+ appendOtherArgs(lines, others);
1277
+ return lines;
1278
+ }
1279
+ function formatRead(input) {
1280
+ const filePath = input.file_path;
1281
+ const offset = input.offset;
1282
+ const limit = input.limit;
1283
+ const others = getUnrecognizedArgs(input, ["file_path", "offset", "limit"]);
1284
+ const lines = [];
1285
+ let lineRange = "";
1286
+ if (offset !== undefined || limit !== undefined) {
1287
+ const start = offset ?? 1;
1288
+ const end = limit !== undefined ? start + limit - 1 : undefined;
1289
+ lineRange = end !== undefined ? ` (lines ${start}-${end})` : ` (from line ${start})`;
1290
+ }
1291
+ lines.push(`\uD83D\uDD27 Read: ${filePath ?? "(unknown)"}${lineRange}`);
1292
+ appendOtherArgs(lines, others);
1293
+ return lines;
1294
+ }
1295
+ function formatBash(input) {
1296
+ const command = input.command;
1297
+ const description = input.description;
1298
+ const others = getUnrecognizedArgs(input, [
1299
+ "command",
1300
+ "description",
1301
+ "timeout",
1302
+ "run_in_background",
1303
+ "dangerouslyDisableSandbox",
1304
+ "_simulatedSedEdit"
1305
+ ]);
1306
+ const lines = [];
1307
+ const header = description ?? "Run command";
1308
+ lines.push(`\uD83D\uDD27 Bash: ${header}`);
1309
+ if (command !== undefined) {
1310
+ lines.push(`$ ${command}`);
1311
+ }
1312
+ appendOtherArgs(lines, others);
1313
+ return lines;
1314
+ }
1315
+ function formatTodoWrite(input) {
1316
+ const todos = input.todos;
1317
+ const others = getUnrecognizedArgs(input, ["todos"]);
1318
+ const lines = [];
1319
+ const count = todos?.length ?? 0;
1320
+ lines.push(`\uD83D\uDD27 TodoWrite: ${count} item${count === 1 ? "" : "s"}`);
1321
+ if (todos) {
1322
+ for (const todo of todos) {
1323
+ const icon = todo.status === "completed" ? "☑" : "☐";
1324
+ lines.push(`${icon} ${todo.content}`);
1325
+ }
1326
+ }
1327
+ appendOtherArgs(lines, others);
1328
+ return lines;
1329
+ }
1330
+ function formatGrep(input) {
1331
+ const pattern = input.pattern;
1332
+ const path = input.path;
1333
+ const glob = input.glob;
1334
+ const type = input.type;
1335
+ const others = getUnrecognizedArgs(input, [
1336
+ "pattern",
1337
+ "path",
1338
+ "glob",
1339
+ "type",
1340
+ "output_mode",
1341
+ "context",
1342
+ "-A",
1343
+ "-B",
1344
+ "-C",
1345
+ "-i",
1346
+ "-n",
1347
+ "head_limit",
1348
+ "offset",
1349
+ "multiline"
1350
+ ]);
1351
+ const lines = [];
1352
+ const location = path ?? ".";
1353
+ let filter = "";
1354
+ if (glob) {
1355
+ filter = ` (${glob})`;
1356
+ } else if (type) {
1357
+ filter = ` (type: ${type})`;
1358
+ }
1359
+ lines.push(`\uD83D\uDD27 Grep: "${pattern ?? ""}" in ${location}${filter}`);
1360
+ appendOtherArgs(lines, others);
1361
+ return lines;
1362
+ }
1363
+ function formatGlob(input) {
1364
+ const pattern = input.pattern;
1365
+ const path = input.path;
1366
+ const others = getUnrecognizedArgs(input, ["pattern", "path"]);
1367
+ const lines = [];
1368
+ const location = path ?? ".";
1369
+ lines.push(`\uD83D\uDD27 Glob: ${pattern ?? ""} in ${location}`);
1370
+ appendOtherArgs(lines, others);
1371
+ return lines;
1372
+ }
1373
+ function formatTask(input) {
1374
+ const description = input.description;
1375
+ const subagentType = input.subagent_type;
1376
+ const prompt = input.prompt;
1377
+ const others = getUnrecognizedArgs(input, [
1378
+ "description",
1379
+ "subagent_type",
1380
+ "prompt",
1381
+ "model",
1382
+ "max_turns",
1383
+ "resume",
1384
+ "run_in_background"
1385
+ ]);
1386
+ const lines = [];
1387
+ const header = description ?? subagentType ?? "task";
1388
+ lines.push(`\uD83D\uDD27 Task: ${header}`);
1389
+ if (prompt !== undefined) {
1390
+ const truncated = prompt.length > 100 ? `${prompt.slice(0, 100)}...` : prompt;
1391
+ lines.push(`"${truncated}"`);
1392
+ }
1393
+ appendOtherArgs(lines, others);
1394
+ return lines;
1395
+ }
1396
+ function formatFallback(name, input) {
1397
+ const lines = [];
1398
+ lines.push(`\uD83D\uDD27 Tool: ${name}`);
1399
+ lines.push(`Input: ${JSON.stringify(input, null, 2)}`);
1400
+ return lines;
1401
+ }
1402
+ var formatters = {
1403
+ Write: formatWrite,
1404
+ Edit: formatEdit,
1405
+ Read: formatRead,
1406
+ Bash: formatBash,
1407
+ TodoWrite: formatTodoWrite,
1408
+ Grep: formatGrep,
1409
+ Glob: formatGlob,
1410
+ Task: formatTask
1411
+ };
1412
+ function formatToolUse(name, input) {
1413
+ const formatter = formatters[name];
1414
+ if (formatter) {
1415
+ return formatter(input);
1416
+ }
1417
+ return formatFallback(name, input);
1418
+ }
1419
+ function getUnrecognizedArgs(input, knownKeys) {
1420
+ const others = {};
1421
+ for (const key of Object.keys(input)) {
1422
+ if (!knownKeys.includes(key)) {
1423
+ others[key] = input[key];
1424
+ }
1425
+ }
1426
+ return others;
1427
+ }
1428
+ function appendOtherArgs(lines, others) {
1429
+ if (Object.keys(others).length > 0) {
1430
+ lines.push("");
1431
+ lines.push(`(Other arguments: ${JSON.stringify(others)})`);
1432
+ }
1433
+ }
1434
+
1205
1435
  // lib/claude/streamer.ts
1436
+ var DIVIDER2 = "────────────────────────────────";
1206
1437
  async function streamEvents(events, sink) {
1207
1438
  let hadTextOutput = false;
1208
1439
  for await (const raw of events) {
@@ -1226,12 +1457,15 @@ function processEvent(event, sink, state) {
1226
1457
  sink.line("");
1227
1458
  sink.line("");
1228
1459
  }
1229
- sink.line(`\uD83D\uDD27 Tool: ${event.name}`);
1230
- sink.line(` Input: ${JSON.stringify(event.input, null, 2).replace(/\n/g, `
1231
- `)}`);
1460
+ for (const line of formatToolUse(event.name, event.input)) {
1461
+ sink.line(line);
1462
+ }
1232
1463
  break;
1233
1464
  case "tool_result":
1234
- sink.line(`✅ Result (${event.content.length} chars)`);
1465
+ sink.line("Result:");
1466
+ sink.line(DIVIDER2);
1467
+ sink.line(event.content);
1468
+ sink.line(DIVIDER2);
1235
1469
  sink.line("");
1236
1470
  break;
1237
1471
  case "result":
@@ -1331,11 +1565,71 @@ async function next(dependencies) {
1331
1565
  }
1332
1566
 
1333
1567
  // lib/cli/commands/loop.ts
1568
+ function formatEvent(event) {
1569
+ switch (event.type) {
1570
+ case "loop.warning":
1571
+ return "⚠️ WARNING: This command skips all permission checks. Only use in a sandbox environment!";
1572
+ case "loop.started":
1573
+ return `\uD83D\uDD04 Starting dust loop claude (max ${event.maxIterations} iterations)...`;
1574
+ case "loop.syncing":
1575
+ return "\uD83D\uDD04 Syncing with remote...";
1576
+ case "loop.sync_skipped":
1577
+ return `Note: git pull skipped (${event.reason})`;
1578
+ case "loop.checking_tasks":
1579
+ return "\uD83D\uDD0D Checking for available tasks...";
1580
+ case "loop.no_tasks":
1581
+ return "\uD83D\uDCA4 No tasks available. Sleeping...";
1582
+ case "loop.tasks_found":
1583
+ return "✨ Found task(s). \uD83E\uDD16 Starting Claude...";
1584
+ case "claude.started":
1585
+ return "\uD83E\uDD16 Claude session started";
1586
+ case "claude.ended":
1587
+ return event.success ? "\uD83E\uDD16 Claude session ended (success)" : `\uD83E\uDD16 Claude session ended (error: ${event.error})`;
1588
+ case "loop.iteration_complete":
1589
+ return `\uD83D\uDCCB Completed iteration ${event.iteration}/${event.maxIterations}`;
1590
+ case "loop.ended":
1591
+ return `\uD83C\uDFC1 Reached max iterations (${event.maxIterations}). Exiting.`;
1592
+ }
1593
+ }
1594
+ async function defaultPostEvent(url, payload) {
1595
+ await fetch(url, {
1596
+ method: "POST",
1597
+ headers: { "Content-Type": "application/json" },
1598
+ body: JSON.stringify(payload)
1599
+ });
1600
+ }
1334
1601
  function createDefaultDependencies() {
1335
1602
  return {
1336
1603
  spawn: nodeSpawn2,
1337
1604
  run,
1338
- sleep: (ms) => new Promise((resolve2) => setTimeout(resolve2, ms))
1605
+ sleep: (ms) => new Promise((resolve2) => setTimeout(resolve2, ms)),
1606
+ postEvent: defaultPostEvent
1607
+ };
1608
+ }
1609
+ function createEventPoster(eventsUrl, sessionId, postEvent, onError) {
1610
+ let sequence = 0;
1611
+ let currentAgentSessionId;
1612
+ return (event) => {
1613
+ if (!eventsUrl)
1614
+ return;
1615
+ sequence++;
1616
+ if (event.type === "claude.started") {
1617
+ currentAgentSessionId = crypto.randomUUID();
1618
+ }
1619
+ const payload = {
1620
+ sequence,
1621
+ timestamp: new Date().toISOString(),
1622
+ sessionId,
1623
+ event
1624
+ };
1625
+ if (event.type.startsWith("claude.") && currentAgentSessionId) {
1626
+ payload.agentSessionId = currentAgentSessionId;
1627
+ payload.agentType = "claude";
1628
+ }
1629
+ postEvent(eventsUrl, payload).catch(onError);
1630
+ if (event.type === "claude.ended") {
1631
+ currentAgentSessionId = undefined;
1632
+ }
1339
1633
  };
1340
1634
  }
1341
1635
  var SLEEP_INTERVAL_MS = 30000;
@@ -1373,35 +1667,61 @@ async function hasAvailableTasks(dependencies) {
1373
1667
  await next({ ...dependencies, context: captureContext });
1374
1668
  return hasOutput;
1375
1669
  }
1376
- async function runOneIteration(dependencies, loopDependencies) {
1670
+ async function runOneIteration(dependencies, loopDependencies, emit) {
1377
1671
  const { context } = dependencies;
1378
1672
  const { spawn: spawn2, run: run2 } = loopDependencies;
1379
- context.stdout("\uD83D\uDD04 Syncing with remote...");
1673
+ emit({ type: "loop.syncing" });
1380
1674
  const pullResult = await gitPull(context.cwd, spawn2);
1381
1675
  if (!pullResult.success) {
1382
- context.stdout(`Note: git pull skipped (${pullResult.message})`);
1676
+ emit({
1677
+ type: "loop.sync_skipped",
1678
+ reason: pullResult.message ?? "unknown error"
1679
+ });
1680
+ emit({ type: "claude.started" });
1681
+ const prompt = `git pull failed with the following error:
1682
+
1683
+ ${pullResult.message}
1684
+
1685
+ Please resolve this issue. Common approaches:
1686
+ 1. If there are merge conflicts, resolve them
1687
+ 2. If local commits need to be rebased, use git rebase
1688
+ 3. After resolving, commit any changes and push to remote
1689
+
1690
+ Make sure the repository is in a clean state and synced with remote before finishing.`;
1691
+ try {
1692
+ await run2(prompt, {
1693
+ cwd: context.cwd,
1694
+ dangerouslySkipPermissions: true,
1695
+ env: { DUST_UNATTENDED: "1" }
1696
+ });
1697
+ emit({ type: "claude.ended", success: true });
1698
+ return "resolved_pull_conflict";
1699
+ } catch (error) {
1700
+ const errorMessage = error instanceof Error ? error.message : String(error);
1701
+ context.stderr(`Claude failed to resolve git pull conflict: ${errorMessage}`);
1702
+ emit({ type: "claude.ended", success: false, error: errorMessage });
1703
+ }
1383
1704
  }
1384
- context.stdout("\uD83D\uDD0D Checking for available tasks...");
1705
+ emit({ type: "loop.checking_tasks" });
1385
1706
  const hasTasks = await hasAvailableTasks(dependencies);
1386
1707
  if (!hasTasks) {
1387
- context.stdout("\uD83D\uDCA4 No tasks available. Sleeping...");
1388
- context.stdout("");
1708
+ emit({ type: "loop.no_tasks" });
1389
1709
  return "no_tasks";
1390
1710
  }
1391
- context.stdout("✨ Found task(s). \uD83E\uDD16 Starting Claude...");
1392
- context.stdout("");
1711
+ emit({ type: "loop.tasks_found" });
1712
+ emit({ type: "claude.started" });
1393
1713
  try {
1394
- await run2("go", { cwd: context.cwd, dangerouslySkipPermissions: true });
1395
- context.stdout("");
1396
- context.stdout("✅ Claude session complete. Continuing loop...");
1397
- context.stdout("");
1714
+ await run2("go", {
1715
+ cwd: context.cwd,
1716
+ dangerouslySkipPermissions: true,
1717
+ env: { DUST_UNATTENDED: "1" }
1718
+ });
1719
+ emit({ type: "claude.ended", success: true });
1398
1720
  return "ran_claude";
1399
1721
  } catch (error) {
1400
- const message = error instanceof Error ? error.message : String(error);
1401
- context.stderr(`Claude exited with error: ${message}`);
1402
- context.stdout("");
1403
- context.stdout("✅ Claude session complete. Continuing loop...");
1404
- context.stdout("");
1722
+ const errorMessage = error instanceof Error ? error.message : String(error);
1723
+ context.stderr(`Claude exited with error: ${errorMessage}`);
1724
+ emit({ type: "claude.ended", success: false, error: errorMessage });
1405
1725
  return "claude_error";
1406
1726
  }
1407
1727
  }
@@ -1416,25 +1736,38 @@ function parseMaxIterations(commandArguments) {
1416
1736
  return parsed;
1417
1737
  }
1418
1738
  async function loopClaude(dependencies, loopDependencies = createDefaultDependencies()) {
1419
- const { context } = dependencies;
1739
+ const { context, settings } = dependencies;
1740
+ const { postEvent } = loopDependencies;
1420
1741
  const maxIterations = parseMaxIterations(dependencies.arguments);
1421
- context.stdout("⚠️ WARNING: This command skips all permission checks. Only use in a sandbox environment!");
1422
- context.stdout("");
1423
- context.stdout(`\uD83D\uDD04 Starting dust loop claude (max ${maxIterations} iterations)...`);
1742
+ const eventsUrl = settings.eventsUrl;
1743
+ const sessionId = crypto.randomUUID();
1744
+ const postTypedEvent = createEventPoster(eventsUrl, sessionId, postEvent, (error) => {
1745
+ const message = error instanceof Error ? error.message : String(error);
1746
+ context.stderr(`Event POST failed: ${message}`);
1747
+ });
1748
+ const emit = (event) => {
1749
+ context.stdout(formatEvent(event));
1750
+ postTypedEvent(event);
1751
+ };
1752
+ emit({ type: "loop.warning" });
1753
+ emit({ type: "loop.started", maxIterations });
1424
1754
  context.stdout(" Press Ctrl+C to stop");
1425
1755
  context.stdout("");
1426
1756
  let completedIterations = 0;
1427
1757
  while (completedIterations < maxIterations) {
1428
- const result = await runOneIteration(dependencies, loopDependencies);
1758
+ const result = await runOneIteration(dependencies, loopDependencies, emit);
1429
1759
  if (result === "no_tasks") {
1430
1760
  await loopDependencies.sleep(SLEEP_INTERVAL_MS);
1431
1761
  } else {
1432
1762
  completedIterations++;
1433
- context.stdout(`\uD83D\uDCCB Completed iteration ${completedIterations}/${maxIterations}`);
1434
- context.stdout("");
1763
+ emit({
1764
+ type: "loop.iteration_complete",
1765
+ iteration: completedIterations,
1766
+ maxIterations
1767
+ });
1435
1768
  }
1436
1769
  }
1437
- context.stdout(`\uD83C\uDFC1 Reached max iterations (${maxIterations}). Exiting.`);
1770
+ emit({ type: "loop.ended", maxIterations });
1438
1771
  return { exitCode: 0 };
1439
1772
  }
1440
1773
 
@@ -1535,12 +1868,50 @@ async function getChangesFromRemote(cwd, gitRunner) {
1535
1868
  }
1536
1869
  return parseGitDiffNameStatus(diffResult.output);
1537
1870
  }
1538
- async function prePush(dependencies, gitRunner = defaultGitRunner) {
1871
+ async function getUncommittedFiles(cwd, gitRunner) {
1872
+ const result = await gitRunner.run(["status", "--porcelain"], cwd);
1873
+ if (result.exitCode !== 0 || !result.output.trim()) {
1874
+ return [];
1875
+ }
1876
+ const files = [];
1877
+ const lines = result.output.split(`
1878
+ `).filter((line) => line.length > 0);
1879
+ for (const line of lines) {
1880
+ if (line.length > 3) {
1881
+ const path = line.substring(3);
1882
+ const arrowIndex = path.indexOf(" -> ");
1883
+ if (arrowIndex !== -1) {
1884
+ files.push(path.substring(arrowIndex + 4));
1885
+ } else {
1886
+ files.push(path);
1887
+ }
1888
+ }
1889
+ }
1890
+ return files;
1891
+ }
1892
+ async function prePush(dependencies, gitRunner = defaultGitRunner, env = process.env) {
1539
1893
  const { context } = dependencies;
1894
+ if (env.DUST_UNATTENDED) {
1895
+ const uncommittedFiles = await getUncommittedFiles(context.cwd, gitRunner);
1896
+ if (uncommittedFiles.length > 0) {
1897
+ context.stderr("");
1898
+ context.stderr("⚠️ Push blocked: uncommitted changes detected in unattended mode.");
1899
+ context.stderr("");
1900
+ context.stderr("You are running in unattended mode (DUST_UNATTENDED=1) and have uncommitted files:");
1901
+ for (const file of uncommittedFiles) {
1902
+ context.stderr(` → ${file}`);
1903
+ }
1904
+ context.stderr("");
1905
+ context.stderr("Commit or discard these changes before pushing to avoid broken builds.");
1906
+ context.stderr("");
1907
+ return { exitCode: 1 };
1908
+ }
1909
+ }
1540
1910
  const changes = await getChangesFromRemote(context.cwd, gitRunner);
1541
1911
  if (changes.length > 0) {
1542
1912
  const analysis = analyzeChangesForTaskOnlyPattern(changes);
1543
- if (analysis.isTaskOnly) {
1913
+ const agent2 = detectAgent(env);
1914
+ if (analysis.isTaskOnly && agent2.type === "claude-code-web") {
1544
1915
  context.stderr("");
1545
1916
  context.stderr("⚠️ Task-only commit detected! You added a task but did not implement it.");
1546
1917
  context.stderr("");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@joshski/dust",
3
- "version": "0.1.13",
4
- "description": "A lightweight planning system for human-AI collaboration",
3
+ "version": "0.1.15",
4
+ "description": "Flow state for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "dust": "./dist/dust.js"