@dungle-scrubs/tallow 0.8.27 → 0.9.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.
Files changed (99) hide show
  1. package/README.md +42 -1
  2. package/dist/cli.js +7 -1
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.d.ts +1 -1
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +1 -1
  7. package/dist/config.js.map +1 -1
  8. package/dist/install.d.ts.map +1 -1
  9. package/dist/install.js +2 -9
  10. package/dist/install.js.map +1 -1
  11. package/dist/interactive-mode-patch.d.ts.map +1 -1
  12. package/dist/interactive-mode-patch.js +20 -9
  13. package/dist/interactive-mode-patch.js.map +1 -1
  14. package/dist/model-metadata-overrides.d.ts +2 -5
  15. package/dist/model-metadata-overrides.d.ts.map +1 -1
  16. package/dist/model-metadata-overrides.js +23 -12
  17. package/dist/model-metadata-overrides.js.map +1 -1
  18. package/dist/sdk.d.ts.map +1 -1
  19. package/dist/sdk.js +20 -9
  20. package/dist/sdk.js.map +1 -1
  21. package/dist/workspace-transition-interactive.d.ts.map +1 -1
  22. package/dist/workspace-transition-interactive.js +53 -3
  23. package/dist/workspace-transition-interactive.js.map +1 -1
  24. package/dist/workspace-transition.d.ts +2 -1
  25. package/dist/workspace-transition.d.ts.map +1 -1
  26. package/dist/workspace-transition.js +16 -4
  27. package/dist/workspace-transition.js.map +1 -1
  28. package/extensions/__integration__/cd-tool-guidelines.test.ts +46 -0
  29. package/extensions/__integration__/welcome-screen.test.ts +240 -0
  30. package/extensions/_icons/__tests__/icons.test.ts +0 -1
  31. package/extensions/_icons/index.ts +0 -2
  32. package/extensions/_shared/pid-registry.ts +5 -5
  33. package/extensions/background-task-tool/index.ts +1 -1
  34. package/extensions/cd-tool/index.ts +4 -1
  35. package/extensions/context-fork/__tests__/context-fork.test.ts +9 -0
  36. package/extensions/edit-tool-enhanced/index.ts +3 -1
  37. package/extensions/health/__tests__/diagnostics.test.ts +25 -0
  38. package/extensions/health/index.ts +62 -1
  39. package/extensions/loop/__tests__/loop.test.ts +365 -1
  40. package/extensions/loop/index.ts +213 -3
  41. package/extensions/prompt-suggestions/__tests__/autocomplete.test.ts +111 -3
  42. package/extensions/prompt-suggestions/autocomplete.ts +23 -5
  43. package/extensions/prompt-suggestions/index.ts +62 -3
  44. package/extensions/read-tool-enhanced/index.ts +5 -1
  45. package/extensions/render-stabilizer/__tests__/render-stabilizer.test.ts +42 -0
  46. package/extensions/render-stabilizer/extension.json +5 -0
  47. package/extensions/render-stabilizer/index.ts +66 -0
  48. package/extensions/session-memory/index.ts +1 -1
  49. package/extensions/session-namer/index.ts +1 -1
  50. package/extensions/subagent-tool/__tests__/auto-cheap-model.test.ts +66 -6
  51. package/extensions/subagent-tool/__tests__/model-router-explicit-resolution.test.ts +79 -5
  52. package/extensions/subagent-tool/__tests__/presentation-rendering.test.ts +4 -4
  53. package/extensions/subagent-tool/index.ts +4 -2
  54. package/extensions/subagent-tool/process.ts +26 -8
  55. package/extensions/teams-tool/sessions/spawn.ts +2 -2
  56. package/extensions/welcome-screen/__tests__/welcome-screen.test.ts +35 -0
  57. package/extensions/welcome-screen/extension.json +20 -0
  58. package/extensions/welcome-screen/index.ts +189 -0
  59. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts +2 -2
  60. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts.map +1 -1
  61. package/node_modules/@mariozechner/pi-tui/dist/index.js +2 -2
  62. package/node_modules/@mariozechner/pi-tui/dist/index.js.map +1 -1
  63. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts +309 -25
  64. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts.map +1 -1
  65. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js +392 -72
  66. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js.map +1 -1
  67. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts +30 -0
  68. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
  69. package/node_modules/@mariozechner/pi-tui/dist/keys.js +50 -6
  70. package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
  71. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts +27 -0
  72. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts.map +1 -1
  73. package/node_modules/@mariozechner/pi-tui/dist/terminal.js +59 -4
  74. package/node_modules/@mariozechner/pi-tui/dist/terminal.js.map +1 -1
  75. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts +56 -0
  76. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts.map +1 -1
  77. package/node_modules/@mariozechner/pi-tui/dist/tui.js +188 -5
  78. package/node_modules/@mariozechner/pi-tui/dist/tui.js.map +1 -1
  79. package/node_modules/@mariozechner/pi-tui/package.json +1 -1
  80. package/node_modules/@mariozechner/pi-tui/src/__tests__/mouse-events.test.ts +134 -0
  81. package/node_modules/@mariozechner/pi-tui/src/__tests__/tmux-compat.test.ts +204 -0
  82. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-diff-regression.test.ts +49 -0
  83. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-render-scheduling.test.ts +2 -0
  84. package/node_modules/@mariozechner/pi-tui/src/index.ts +11 -0
  85. package/node_modules/@mariozechner/pi-tui/src/keybindings.ts +478 -140
  86. package/node_modules/@mariozechner/pi-tui/src/keys.ts +84 -6
  87. package/node_modules/@mariozechner/pi-tui/src/terminal.ts +69 -4
  88. package/node_modules/@mariozechner/pi-tui/src/tui.ts +205 -5
  89. package/package.json +9 -9
  90. package/runtime/config.ts +7 -0
  91. package/runtime/model-metadata-overrides.ts +7 -0
  92. package/schemas/settings.schema.json +0 -5
  93. package/skills/tallow-expert/SKILL.md +6 -4
  94. package/extensions/plan-mode-tool/__tests__/e2e.mjs +0 -350
  95. package/extensions/plan-mode-tool/__tests__/index.test.ts +0 -213
  96. package/extensions/plan-mode-tool/__tests__/utils.test.ts +0 -381
  97. package/extensions/plan-mode-tool/extension.json +0 -22
  98. package/extensions/plan-mode-tool/index.ts +0 -583
  99. package/extensions/plan-mode-tool/utils.ts +0 -257
@@ -2,16 +2,19 @@
2
2
  * Unit tests for the loop extension's pure helpers.
3
3
  *
4
4
  * Tests interval parsing, countdown formatting, argument parsing,
5
- * max iterations, and until-condition extraction.
5
+ * max iterations, until-condition extraction, natural-language parsing,
6
+ * and command building.
6
7
  */
7
8
 
8
9
  import { describe, expect, test } from "bun:test";
9
10
  import {
11
+ buildLoopCommand,
10
12
  extractUntilCondition,
11
13
  formatCountdown,
12
14
  parseInterval,
13
15
  parseLoopArgs,
14
16
  parseMaxIterations,
17
+ parseNaturalLanguageLoop,
15
18
  } from "../index.js";
16
19
 
17
20
  describe("parseInterval", () => {
@@ -322,3 +325,364 @@ describe("parseLoopArgs", () => {
322
325
  });
323
326
  });
324
327
  });
328
+
329
+ // ── Natural language parsing ─────────────────────────────────────────────
330
+
331
+ describe("parseNaturalLanguageLoop", () => {
332
+ // ── Interval extraction ──────────────────────────────────────────
333
+
334
+ test("extracts 'every N minutes'", () => {
335
+ const result = parseNaturalLanguageLoop("check ci every 2 minutes");
336
+ expect(result).toEqual({
337
+ action: "start",
338
+ intervalMs: 120_000,
339
+ intervalLabel: "2m",
340
+ prompt: "check ci",
341
+ maxIterations: null,
342
+ untilCondition: null,
343
+ });
344
+ });
345
+
346
+ test("extracts 'every N seconds'", () => {
347
+ const result = parseNaturalLanguageLoop("run tests every 30 seconds");
348
+ expect(result).toEqual({
349
+ action: "start",
350
+ intervalMs: 30_000,
351
+ intervalLabel: "30s",
352
+ prompt: "run tests",
353
+ maxIterations: null,
354
+ untilCondition: null,
355
+ });
356
+ });
357
+
358
+ test("extracts 'every N hrs'", () => {
359
+ const result = parseNaturalLanguageLoop("check logs every 2 hrs");
360
+ expect(result).toEqual({
361
+ action: "start",
362
+ intervalMs: 7_200_000,
363
+ intervalLabel: "2h",
364
+ prompt: "check logs",
365
+ maxIterations: null,
366
+ untilCondition: null,
367
+ });
368
+ });
369
+
370
+ test("extracts 'every minute' (no number → 1)", () => {
371
+ const result = parseNaturalLanguageLoop("check deploy every minute");
372
+ expect(result).toEqual({
373
+ action: "start",
374
+ intervalMs: 60_000,
375
+ intervalLabel: "1m",
376
+ prompt: "check deploy",
377
+ maxIterations: null,
378
+ untilCondition: null,
379
+ });
380
+ });
381
+
382
+ test("extracts 'every hour'", () => {
383
+ const result = parseNaturalLanguageLoop("summarize logs every hour");
384
+ expect(result).toEqual({
385
+ action: "start",
386
+ intervalMs: 3_600_000,
387
+ intervalLabel: "1h",
388
+ prompt: "summarize logs",
389
+ maxIterations: null,
390
+ untilCondition: null,
391
+ });
392
+ });
393
+
394
+ test("extracts 'every Nm' shorthand", () => {
395
+ const result = parseNaturalLanguageLoop("check ci every 5m");
396
+ expect(result).toEqual({
397
+ action: "start",
398
+ intervalMs: 300_000,
399
+ intervalLabel: "5m",
400
+ prompt: "check ci",
401
+ maxIterations: null,
402
+ untilCondition: null,
403
+ });
404
+ });
405
+
406
+ test("extracts bare interval without 'every'", () => {
407
+ const result = parseNaturalLanguageLoop("check ci 2m");
408
+ expect(result).toEqual({
409
+ action: "start",
410
+ intervalMs: 120_000,
411
+ intervalLabel: "2m",
412
+ prompt: "check ci",
413
+ maxIterations: null,
414
+ untilCondition: null,
415
+ });
416
+ });
417
+
418
+ test("returns null when no interval found", () => {
419
+ expect(parseNaturalLanguageLoop("check ci please")).toBeNull();
420
+ });
421
+
422
+ test("returns null when no prompt remains", () => {
423
+ expect(parseNaturalLanguageLoop("every 2m")).toBeNull();
424
+ });
425
+
426
+ // ── Condition extraction ─────────────────────────────────────────
427
+
428
+ test("extracts 'until' condition at end", () => {
429
+ const result = parseNaturalLanguageLoop("check ci every 2 minutes until it passes");
430
+ expect(result).toEqual({
431
+ action: "start",
432
+ intervalMs: 120_000,
433
+ intervalLabel: "2m",
434
+ prompt: "check ci",
435
+ maxIterations: null,
436
+ untilCondition: "it passes",
437
+ });
438
+ });
439
+
440
+ test("extracts 'stop when' condition at end", () => {
441
+ const result = parseNaturalLanguageLoop("run tests every 30s, stop when they pass");
442
+ expect(result).toEqual({
443
+ action: "start",
444
+ intervalMs: 30_000,
445
+ intervalLabel: "30s",
446
+ prompt: "run tests",
447
+ maxIterations: null,
448
+ untilCondition: "they pass",
449
+ });
450
+ });
451
+
452
+ test("extracts condition at start with comma separator", () => {
453
+ const result = parseNaturalLanguageLoop("until the build passes, check ci every 2m");
454
+ expect(result).toEqual({
455
+ action: "start",
456
+ intervalMs: 120_000,
457
+ intervalLabel: "2m",
458
+ prompt: "check ci",
459
+ maxIterations: null,
460
+ untilCondition: "the build passes",
461
+ });
462
+ });
463
+
464
+ test("strips quotes from NL condition", () => {
465
+ const result = parseNaturalLanguageLoop('check ci every 2m until "the build is green"');
466
+ expect(result).toEqual({
467
+ action: "start",
468
+ intervalMs: 120_000,
469
+ intervalLabel: "2m",
470
+ prompt: "check ci",
471
+ maxIterations: null,
472
+ untilCondition: "the build is green",
473
+ });
474
+ });
475
+
476
+ test("strips trailing punctuation from condition", () => {
477
+ const result = parseNaturalLanguageLoop("check ci every 2m until it passes.");
478
+ expect(result).toEqual({
479
+ action: "start",
480
+ intervalMs: 120_000,
481
+ intervalLabel: "2m",
482
+ prompt: "check ci",
483
+ maxIterations: null,
484
+ untilCondition: "it passes",
485
+ });
486
+ });
487
+
488
+ // ── Max iterations extraction ────────────────────────────────────
489
+
490
+ test("extracts 'N times'", () => {
491
+ const result = parseNaturalLanguageLoop("check ci every 2m 10 times");
492
+ expect(result).toEqual({
493
+ action: "start",
494
+ intervalMs: 120_000,
495
+ intervalLabel: "2m",
496
+ prompt: "check ci",
497
+ maxIterations: 10,
498
+ untilCondition: null,
499
+ });
500
+ });
501
+
502
+ test("extracts 'max N'", () => {
503
+ const result = parseNaturalLanguageLoop("check ci every 2m max 20");
504
+ expect(result).toEqual({
505
+ action: "start",
506
+ intervalMs: 120_000,
507
+ intervalLabel: "2m",
508
+ prompt: "check ci",
509
+ maxIterations: 20,
510
+ untilCondition: null,
511
+ });
512
+ });
513
+
514
+ test("extracts 'max N tries'", () => {
515
+ const result = parseNaturalLanguageLoop("monitor deploy health every minute, max 20 tries");
516
+ expect(result).toEqual({
517
+ action: "start",
518
+ intervalMs: 60_000,
519
+ intervalLabel: "1m",
520
+ prompt: "monitor deploy health",
521
+ maxIterations: 20,
522
+ untilCondition: null,
523
+ });
524
+ });
525
+
526
+ test("extracts 'at most N'", () => {
527
+ const result = parseNaturalLanguageLoop("check ci every 5m at most 15");
528
+ expect(result).toEqual({
529
+ action: "start",
530
+ intervalMs: 300_000,
531
+ intervalLabel: "5m",
532
+ prompt: "check ci",
533
+ maxIterations: 15,
534
+ untilCondition: null,
535
+ });
536
+ });
537
+
538
+ test("extracts x<N> in NL context", () => {
539
+ const result = parseNaturalLanguageLoop("check ci every 2m x10");
540
+ expect(result).toEqual({
541
+ action: "start",
542
+ intervalMs: 120_000,
543
+ intervalLabel: "2m",
544
+ prompt: "check ci",
545
+ maxIterations: 10,
546
+ untilCondition: null,
547
+ });
548
+ });
549
+
550
+ // ── Combined: all features ───────────────────────────────────────
551
+
552
+ test("extracts interval + condition + max iterations", () => {
553
+ const result = parseNaturalLanguageLoop(
554
+ "run tests every 30 seconds, max 20, until they all pass"
555
+ );
556
+ expect(result).toEqual({
557
+ action: "start",
558
+ intervalMs: 30_000,
559
+ intervalLabel: "30s",
560
+ prompt: "run tests",
561
+ maxIterations: 20,
562
+ untilCondition: "they all pass",
563
+ });
564
+ });
565
+
566
+ test("handles realistic CI monitoring request", () => {
567
+ const result = parseNaturalLanguageLoop(
568
+ "check the latest GitHub Actions run for this branch every 2 minutes until the latest CI run is green"
569
+ );
570
+ expect(result).toEqual({
571
+ action: "start",
572
+ intervalMs: 120_000,
573
+ intervalLabel: "2m",
574
+ prompt: "check the latest GitHub Actions run for this branch",
575
+ maxIterations: null,
576
+ untilCondition: "the latest CI run is green",
577
+ });
578
+ });
579
+
580
+ test("handles 'stop when' with comma", () => {
581
+ const result = parseNaturalLanguageLoop("run the test suite every 30s, stop when tests pass");
582
+ expect(result).toEqual({
583
+ action: "start",
584
+ intervalMs: 30_000,
585
+ intervalLabel: "30s",
586
+ prompt: "run the test suite",
587
+ maxIterations: null,
588
+ untilCondition: "tests pass",
589
+ });
590
+ });
591
+
592
+ // ── Interval position doesn't matter ─────────────────────────────
593
+
594
+ test("interval at start of text", () => {
595
+ const result = parseNaturalLanguageLoop("every 5 minutes check if the build finished");
596
+ expect(result).toEqual({
597
+ action: "start",
598
+ intervalMs: 300_000,
599
+ intervalLabel: "5m",
600
+ prompt: "check if the build finished",
601
+ maxIterations: null,
602
+ untilCondition: null,
603
+ });
604
+ });
605
+
606
+ test("interval in middle of text", () => {
607
+ const result = parseNaturalLanguageLoop("check ci every 2m until it's green");
608
+ expect(result).toEqual({
609
+ action: "start",
610
+ intervalMs: 120_000,
611
+ intervalLabel: "2m",
612
+ prompt: "check ci",
613
+ maxIterations: null,
614
+ untilCondition: "it's green",
615
+ });
616
+ });
617
+ });
618
+
619
+ // ── buildLoopCommand ─────────────────────────────────────────────────────
620
+
621
+ describe("buildLoopCommand", () => {
622
+ test("builds simple command", () => {
623
+ expect(
624
+ buildLoopCommand({
625
+ action: "start",
626
+ intervalMs: 300_000,
627
+ intervalLabel: "5m",
628
+ prompt: "check deploy",
629
+ maxIterations: null,
630
+ untilCondition: null,
631
+ })
632
+ ).toBe("/loop 5m check deploy");
633
+ });
634
+
635
+ test("includes max iterations", () => {
636
+ expect(
637
+ buildLoopCommand({
638
+ action: "start",
639
+ intervalMs: 60_000,
640
+ intervalLabel: "1m",
641
+ prompt: "run tests",
642
+ maxIterations: 10,
643
+ untilCondition: null,
644
+ })
645
+ ).toBe("/loop 1m x10 run tests");
646
+ });
647
+
648
+ test("includes until condition", () => {
649
+ expect(
650
+ buildLoopCommand({
651
+ action: "start",
652
+ intervalMs: 120_000,
653
+ intervalLabel: "2m",
654
+ prompt: "check ci",
655
+ maxIterations: null,
656
+ untilCondition: "build is green",
657
+ })
658
+ ).toBe('/loop 2m until "build is green" check ci');
659
+ });
660
+
661
+ test("includes both max iterations and condition", () => {
662
+ expect(
663
+ buildLoopCommand({
664
+ action: "start",
665
+ intervalMs: 30_000,
666
+ intervalLabel: "30s",
667
+ prompt: "run the test suite",
668
+ maxIterations: 50,
669
+ untilCondition: "tests pass",
670
+ })
671
+ ).toBe('/loop 30s x50 until "tests pass" run the test suite');
672
+ });
673
+
674
+ test("round-trips through parseLoopArgs", () => {
675
+ const nl = parseNaturalLanguageLoop("check ci every 2 minutes until the build passes");
676
+ if (!nl) throw new Error("Expected NL parse to succeed");
677
+ const command = buildLoopCommand(nl);
678
+ const strict = parseLoopArgs(command.replace(/^\/loop\s+/, ""));
679
+ expect(strict).toEqual({
680
+ action: "start",
681
+ intervalMs: 120_000,
682
+ intervalLabel: "2m",
683
+ prompt: "check ci",
684
+ maxIterations: null,
685
+ untilCondition: "the build passes",
686
+ });
687
+ });
688
+ });
@@ -10,7 +10,7 @@
10
10
  * - `until "<condition>"` — the model evaluates the condition each
11
11
  * iteration and calls the `loop_stop` tool when it's met
12
12
  *
13
- * Usage:
13
+ * Usage (strict syntax):
14
14
  * /loop 5m check the deploy status
15
15
  * /loop 1m x10 run the test suite
16
16
  * /loop 2m until "build is done" check fuse index progress
@@ -18,6 +18,11 @@
18
18
  * /loop 30s /stats
19
19
  * /loop stop
20
20
  * /loop status
21
+ *
22
+ * Natural language (auto-parsed → prefilled for confirmation):
23
+ * /loop check ci every 2 minutes until it passes
24
+ * /loop run tests every 30 seconds, stop when they pass
25
+ * /loop monitor deploy health every minute, max 20 tries
21
26
  */
22
27
 
23
28
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
@@ -277,6 +282,201 @@ export function parseLoopArgs(args: string): LoopArgs | { action: "error"; messa
277
282
  };
278
283
  }
279
284
 
285
+ // ── Natural Language Parsing ──────────────────────────────────────────────────
286
+
287
+ /** Map from human-readable unit names to short suffixes. */
288
+ const UNIT_ALIASES: Readonly<Record<string, string>> = {
289
+ s: "s",
290
+ sec: "s",
291
+ secs: "s",
292
+ second: "s",
293
+ seconds: "s",
294
+ m: "m",
295
+ min: "m",
296
+ mins: "m",
297
+ minute: "m",
298
+ minutes: "m",
299
+ h: "h",
300
+ hr: "h",
301
+ hrs: "h",
302
+ hour: "h",
303
+ hours: "h",
304
+ };
305
+
306
+ /**
307
+ * Attempt to parse a natural-language loop description into structured params.
308
+ *
309
+ * Extracts interval, condition, max iterations, and prompt from free-form
310
+ * text using regex heuristics. Returns null if no interval can be identified.
311
+ *
312
+ * This is a best-effort parser — the result is prefilled in the editor for
313
+ * the user to review, not executed directly.
314
+ *
315
+ * @param text - Free-form loop description
316
+ * @returns Parsed start params, or null if no interval was found
317
+ */
318
+ export function parseNaturalLanguageLoop(
319
+ text: string
320
+ ): Extract<LoopArgs, { action: "start" }> | null {
321
+ let work = text;
322
+
323
+ // ── Interval extraction ──────────────────────────────────────────
324
+
325
+ let intervalMs: number | null = null;
326
+ let intervalLabel: string | null = null;
327
+
328
+ // "every <N> <unit-word>" — e.g. "every 2 minutes", "every 30 seconds"
329
+ const everyLong = work.match(
330
+ /\bevery\s+(\d+)\s+(seconds?|secs?|sec|minutes?|mins?|min|hours?|hrs?|hr)\b/i
331
+ );
332
+ if (everyLong) {
333
+ const val = parseInt(everyLong[1], 10);
334
+ const unit = UNIT_ALIASES[everyLong[2].toLowerCase()];
335
+ if (unit && val > 0) {
336
+ intervalMs = val * UNIT_MS[unit];
337
+ intervalLabel = `${val}${unit}`;
338
+ work = work.replace(everyLong[0], " ");
339
+ }
340
+ }
341
+
342
+ // "every <unit-word>" without number → 1 unit. e.g. "every minute"
343
+ if (!intervalMs) {
344
+ const everyBare = work.match(/\bevery\s+(second|minute|hour)\b/i);
345
+ if (everyBare) {
346
+ const unit = UNIT_ALIASES[everyBare[1].toLowerCase()];
347
+ if (unit) {
348
+ intervalMs = UNIT_MS[unit];
349
+ intervalLabel = `1${unit}`;
350
+ work = work.replace(everyBare[0], " ");
351
+ }
352
+ }
353
+ }
354
+
355
+ // "every <N><short-unit>" — e.g. "every 2m"
356
+ if (!intervalMs) {
357
+ const everyShort = work.match(/\bevery\s+(\d+)(s|m|h)\b/i);
358
+ if (everyShort) {
359
+ const val = parseInt(everyShort[1], 10);
360
+ const unit = everyShort[2].toLowerCase();
361
+ if (val > 0) {
362
+ intervalMs = val * UNIT_MS[unit];
363
+ intervalLabel = `${val}${unit}`;
364
+ work = work.replace(everyShort[0], " ");
365
+ }
366
+ }
367
+ }
368
+
369
+ // Bare "<N><short-unit>" without "every" — e.g. "2m"
370
+ if (!intervalMs) {
371
+ const bare = work.match(/\b(\d+)(s|m|h)\b/);
372
+ if (bare) {
373
+ const val = parseInt(bare[1], 10);
374
+ const unit = bare[2].toLowerCase();
375
+ if (val > 0) {
376
+ intervalMs = val * UNIT_MS[unit];
377
+ intervalLabel = `${val}${unit}`;
378
+ work = work.replace(bare[0], " ");
379
+ }
380
+ }
381
+ }
382
+
383
+ if (!intervalMs || !intervalLabel) return null;
384
+
385
+ // ── Max iterations extraction ────────────────────────────────────
386
+
387
+ let maxIterations: number | null = null;
388
+
389
+ // "x<N>"
390
+ const xN = work.match(/\bx(\d+)\b/);
391
+ if (xN) {
392
+ const n = parseInt(xN[1], 10);
393
+ if (n > 0) {
394
+ maxIterations = n;
395
+ work = work.replace(xN[0], " ");
396
+ }
397
+ }
398
+
399
+ // "<N> times" / "max <N> [times|tries|iterations]" / "at most <N> [...]"
400
+ if (maxIterations === null) {
401
+ const alt = work.match(
402
+ /\b(?:(\d+)\s+times|max\s+(\d+)(?:\s+(?:times|tries|iterations))?|at\s+most\s+(\d+)(?:\s+(?:times|tries|iterations))?)\b/i
403
+ );
404
+ if (alt) {
405
+ const n = parseInt(alt[1] || alt[2] || alt[3], 10);
406
+ if (n > 0) {
407
+ maxIterations = n;
408
+ work = work.replace(alt[0], " ");
409
+ }
410
+ }
411
+ }
412
+
413
+ // ── Condition extraction ─────────────────────────────────────────
414
+ // Uses greedy match for the prompt prefix so multi-"until" text picks
415
+ // the last occurrence (the actual condition marker).
416
+
417
+ let untilCondition: string | null = null;
418
+ let promptText: string;
419
+
420
+ // Case 1: condition at end (most common) — "check ci until it passes"
421
+ const endMatch = work.match(/^(.*)\s*[,;]?\s*\b(until|stop\s+when)\s+(.+?)[\s,;.!]*$/i);
422
+
423
+ if (endMatch?.[1].trim()) {
424
+ promptText = endMatch[1];
425
+ untilCondition = endMatch[3].replace(/^["']|["']$/g, "");
426
+ } else {
427
+ // Case 2: condition at start with comma — "until it passes, check ci"
428
+ const startMatch = work.match(/^\s*\b(until|stop\s+when)\s+(.+?)\s*[,;]\s+(.+?)[\s,;.!]*$/i);
429
+
430
+ if (startMatch) {
431
+ untilCondition = startMatch[2].replace(/^["']|["']$/g, "");
432
+ promptText = startMatch[3];
433
+ } else {
434
+ // No condition marker, or condition-only with no prompt
435
+ promptText = work;
436
+ }
437
+ }
438
+
439
+ // ── Clean up ─────────────────────────────────────────────────────
440
+
441
+ const prompt = promptText.replace(/[,;]+/g, " ").replace(/\s+/g, " ").trim();
442
+
443
+ if (!prompt) return null;
444
+
445
+ if (untilCondition) {
446
+ untilCondition = untilCondition.replace(/\s+/g, " ").trim();
447
+ if (!untilCondition) untilCondition = null;
448
+ }
449
+
450
+ return {
451
+ action: "start",
452
+ intervalMs,
453
+ intervalLabel,
454
+ prompt,
455
+ maxIterations,
456
+ untilCondition,
457
+ };
458
+ }
459
+
460
+ /**
461
+ * Build a `/loop` command string from structured params.
462
+ *
463
+ * Produces valid strict syntax that `parseLoopArgs` can parse.
464
+ *
465
+ * @param params - Parsed loop start parameters
466
+ * @returns Command string like `/loop 2m until "it passes" check ci`
467
+ */
468
+ export function buildLoopCommand(params: Extract<LoopArgs, { action: "start" }>): string {
469
+ let cmd = `/loop ${params.intervalLabel}`;
470
+ if (params.maxIterations !== null) {
471
+ cmd += ` x${params.maxIterations}`;
472
+ }
473
+ if (params.untilCondition) {
474
+ cmd += ` until "${params.untilCondition}"`;
475
+ }
476
+ cmd += ` ${params.prompt}`;
477
+ return cmd;
478
+ }
479
+
280
480
  // ── Loop Lifecycle ───────────────────────────────────────────────────────────
281
481
 
282
482
  /**
@@ -469,14 +669,24 @@ export default function loopExtension(pi: ExtensionAPI): void {
469
669
  pi.registerCommand("loop", {
470
670
  description:
471
671
  "Run a prompt on a recurring interval. " +
472
- 'Syntax: /loop <interval> [x<N>] [until "<condition>"] <prompt>',
672
+ 'Syntax: /loop <interval> [x<N>] [until "<condition>"] <prompt>' +
673
+ "or describe in natural language: /loop check ci every 2m until it passes",
473
674
  handler: async (args, ctx) => {
474
675
  const parsed = parseLoopArgs(args);
475
676
 
476
677
  switch (parsed.action) {
477
- case "error":
678
+ case "error": {
679
+ // Try natural-language fallback before showing the error
680
+ const nlResult = parseNaturalLanguageLoop(args);
681
+ if (nlResult) {
682
+ const command = buildLoopCommand(nlResult);
683
+ ctx.ui.setEditorText(command);
684
+ ctx.ui.notify("Loop command generated — review and press Enter to start", "info");
685
+ return;
686
+ }
478
687
  ctx.ui.notify(parsed.message, "error");
479
688
  return;
689
+ }
480
690
 
481
691
  case "stop":
482
692
  if (!activeLoop) {