@pimmesz/afterburner 1.0.9 → 1.0.10

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.
@@ -417,37 +417,6 @@ function budgetFromUsageCache(cache, opts, nowMs) {
417
417
  return { budget: { sessionAvailable, weeklyRemainingPct, weeklyRemainingTokensEst } };
418
418
  }
419
419
 
420
- // src/core/store/run-store.ts
421
- import { appendFile, mkdir as mkdir2, readFile as readFile3 } from "fs/promises";
422
- import { dirname as dirname2 } from "path";
423
- var JsonlRunStore = class {
424
- constructor(filePath = defaultRunStorePath()) {
425
- this.filePath = filePath;
426
- }
427
- filePath;
428
- async append(record) {
429
- await mkdir2(dirname2(this.filePath), { recursive: true });
430
- await appendFile(this.filePath, `${JSON.stringify(record)}
431
- `, "utf8");
432
- }
433
- async list() {
434
- let raw;
435
- try {
436
- raw = await readFile3(this.filePath, "utf8");
437
- } catch (error) {
438
- if (error.code === "ENOENT") return [];
439
- throw error;
440
- }
441
- return raw.split("\n").filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
442
- }
443
- async hasOpenOrMergedPr(fingerprint) {
444
- const records = await this.list();
445
- return records.some(
446
- (r) => r.fingerprint === fingerprint && (r.outcome === "pr-opened" || !!r.prUrl)
447
- );
448
- }
449
- };
450
-
451
420
  // src/core/budget/claude-usage.ts
452
421
  var ClaudeUsageBudgetProvider = class {
453
422
  constructor(opts) {
@@ -519,16 +488,6 @@ function createBudgetProvider(config, opts = {}) {
519
488
  };
520
489
  }
521
490
 
522
- // src/core/notify/console.ts
523
- var ConsoleNotifier = class {
524
- async notify(record) {
525
- const pr = record.prUrl ? ` pr=${record.prUrl}` : "";
526
- console.log(
527
- `[afterburner] ${record.outcome} ${record.category} "${record.title}" repo=${record.repoUrl} branch=${record.branch} est=${record.estCostSonnetTokens.toLocaleString("en-US")} sonnet-eq tokens${pr}`
528
- );
529
- }
530
- };
531
-
532
491
  // src/core/scheduler/gate.ts
533
492
  function shouldIgnite(budget, estCostSonnetTokens, config) {
534
493
  if (!Number.isFinite(budget.weeklyRemainingPct) || !Number.isFinite(budget.weeklyRemainingTokensEst) || !Number.isFinite(estCostSonnetTokens) || !Number.isFinite(config.safetyMarginTokens)) {
@@ -553,6 +512,228 @@ function shouldIgnite(budget, estCostSonnetTokens, config) {
553
512
  return { go: true, reason: "session available and estimated cost fits within weekly headroom" };
554
513
  }
555
514
 
515
+ // src/core/scheduler/native.ts
516
+ import { homedir as homedir2 } from "os";
517
+ import { join as join4 } from "path";
518
+ function parseSimpleCron(cron) {
519
+ const parts = cron.trim().split(/\s+/);
520
+ if (parts.length !== 5) throw unsupportedCron(cron);
521
+ const [minutePart, hourPart, dom, month, dow] = parts;
522
+ if (dom !== "*" || month !== "*" || dow !== "*") throw unsupportedCron(cron);
523
+ const minute = Number(minutePart);
524
+ if (!Number.isInteger(minute) || minute < 0 || minute > 59) throw unsupportedCron(cron);
525
+ const allHours = Array.from({ length: 24 }, (_, h) => h);
526
+ let hours;
527
+ if (hourPart === "*") {
528
+ hours = allHours;
529
+ } else if (/^\*\/\d+$/.test(hourPart)) {
530
+ const step = Number(hourPart.slice(2));
531
+ if (step < 1 || step > 23) throw unsupportedCron(cron);
532
+ hours = allHours.filter((h) => h % step === 0);
533
+ } else if (/^\d+(,\d+)*$/.test(hourPart)) {
534
+ hours = hourPart.split(",").map(Number);
535
+ if (hours.some((h) => h < 0 || h > 23)) throw unsupportedCron(cron);
536
+ } else {
537
+ throw unsupportedCron(cron);
538
+ }
539
+ return { minute, hours };
540
+ }
541
+ function unsupportedCron(cron) {
542
+ return new Error(
543
+ `Cron expression "${cron}" is too complex for native scheduler installation. Supported shapes: "M * * * *", "M H * * *", "M */N * * *", "M H1,H2 * * *". Use \`afterburner watch\` for full cron support, or install a native entry manually.`
544
+ );
545
+ }
546
+ function nextSimpleCronFire(schedule2, now, timezone) {
547
+ const wallClock = new Intl.DateTimeFormat("en-GB", {
548
+ timeZone: timezone,
549
+ hour: "2-digit",
550
+ minute: "2-digit",
551
+ hourCycle: "h23"
552
+ });
553
+ const start = Math.floor(now.getTime() / 6e4 + 1) * 6e4;
554
+ for (let i = 0; i < 49 * 60; i++) {
555
+ const candidate = new Date(start + i * 6e4);
556
+ const parts = wallClock.formatToParts(candidate);
557
+ const hour = Number(parts.find((p) => p.type === "hour")?.value);
558
+ const minute = Number(parts.find((p) => p.type === "minute")?.value);
559
+ if (minute === schedule2.minute && schedule2.hours.includes(hour)) return candidate;
560
+ }
561
+ throw new Error(`No fire time within 49h for the schedule; is timezone "${timezone}" valid?`);
562
+ }
563
+ var launchdPlistPath = () => join4(homedir2(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
564
+ var systemdUnitPaths = () => {
565
+ const unitDir = join4(homedir2(), ".config", "systemd", "user");
566
+ return {
567
+ service: join4(unitDir, "afterburner.service"),
568
+ timer: join4(unitDir, "afterburner.timer")
569
+ };
570
+ };
571
+ function scheduleArtifactPaths(platform) {
572
+ switch (platform) {
573
+ case "darwin":
574
+ return [launchdPlistPath()];
575
+ case "linux": {
576
+ const units = systemdUnitPaths();
577
+ return [units.service, units.timer];
578
+ }
579
+ case "win32":
580
+ return [];
581
+ }
582
+ }
583
+ function generateScheduleArtifacts(platform, opts) {
584
+ const schedule2 = parseSimpleCron(opts.cron);
585
+ const command = [
586
+ opts.nodePath,
587
+ opts.cliPath,
588
+ "run-once",
589
+ ...opts.configPath ? ["--config", opts.configPath] : []
590
+ ];
591
+ switch (platform) {
592
+ case "darwin":
593
+ return launchdArtifacts(schedule2, opts, command);
594
+ case "linux":
595
+ return systemdArtifacts(schedule2, opts, command);
596
+ case "win32":
597
+ return schtasksArtifacts(schedule2, opts, command);
598
+ }
599
+ }
600
+ var LAUNCHD_LABEL = "io.afterburner.run-once";
601
+ function launchdArtifacts(schedule2, opts, command) {
602
+ const plistPath = launchdPlistPath();
603
+ const intervals = schedule2.hours.map(
604
+ (hour) => ` <dict>
605
+ <key>Hour</key><integer>${hour}</integer>
606
+ <key>Minute</key><integer>${schedule2.minute}</integer>
607
+ </dict>`
608
+ ).join("\n");
609
+ const programArgs = command.map((arg) => ` <string>${escapeXml(arg)}</string>`).join("\n");
610
+ const content = `<?xml version="1.0" encoding="UTF-8"?>
611
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
612
+ <plist version="1.0">
613
+ <dict>
614
+ <key>Label</key>
615
+ <string>${LAUNCHD_LABEL}</string>
616
+ <key>ProgramArguments</key>
617
+ <array>
618
+ ${programArgs}
619
+ </array>
620
+ <key>StartCalendarInterval</key>
621
+ <array>
622
+ ${intervals}
623
+ </array>
624
+ <key>StandardOutPath</key>
625
+ <string>/tmp/afterburner.out.log</string>
626
+ <key>StandardErrorPath</key>
627
+ <string>/tmp/afterburner.err.log</string>
628
+ </dict>
629
+ </plist>
630
+ `;
631
+ return {
632
+ kind: "launchd",
633
+ files: [{ path: plistPath, content }],
634
+ activationHint: `launchctl load -w ${plistPath}
635
+ (note: launchd schedules in LOCAL time; your configured timezone "${opts.timezone}" is not applied)`,
636
+ removalHint: `launchctl unload -w ${plistPath} && rm ${plistPath}`
637
+ };
638
+ }
639
+ function systemdArtifacts(schedule2, opts, command) {
640
+ const { service: servicePath, timer: timerPath } = systemdUnitPaths();
641
+ const minute = String(schedule2.minute).padStart(2, "0");
642
+ const onCalendarLines = schedule2.hours.map(
643
+ (hour) => `OnCalendar=*-*-* ${String(hour).padStart(2, "0")}:${minute}:00 ${opts.timezone}`
644
+ ).join("\n");
645
+ const execStart = command.map((arg) => systemdQuote(arg)).join(" ");
646
+ const service = `[Unit]
647
+ Description=Afterburner single run (budget-gated, PR-only)
648
+
649
+ [Service]
650
+ Type=oneshot
651
+ ExecStart=${execStart}
652
+ `;
653
+ const timer = `[Unit]
654
+ Description=Afterburner schedule
655
+
656
+ [Timer]
657
+ ${onCalendarLines}
658
+ Persistent=false
659
+
660
+ [Install]
661
+ WantedBy=timers.target
662
+ `;
663
+ return {
664
+ kind: "systemd-user",
665
+ files: [
666
+ { path: servicePath, content: service },
667
+ { path: timerPath, content: timer }
668
+ ],
669
+ activationHint: `systemctl --user daemon-reload && systemctl --user enable --now afterburner.timer`,
670
+ removalHint: `systemctl --user disable --now afterburner.timer && rm ${servicePath} ${timerPath} && systemctl --user daemon-reload`
671
+ };
672
+ }
673
+ function schtasksArtifacts(schedule2, opts, command) {
674
+ const minute = String(schedule2.minute).padStart(2, "0");
675
+ const taskName = (hour) => `Afterburner-${String(hour).padStart(2, "0")}${minute}`;
676
+ const tr = command.map((arg) => `\\"${arg}\\"`).join(" ");
677
+ const createCommands = schedule2.hours.map(
678
+ (hour) => `schtasks /Create /TN "${taskName(hour)}" /TR "${tr}" /SC DAILY /ST ${String(hour).padStart(2, "0")}:${minute}`
679
+ ).join("\n");
680
+ const deleteCommands = schedule2.hours.map((hour) => `schtasks /Delete /TN "${taskName(hour)}" /F`).join("\n");
681
+ return {
682
+ kind: "schtasks",
683
+ files: [],
684
+ activationHint: `${createCommands}
685
+ (run from cmd.exe; note: schtasks schedules in LOCAL time; your configured timezone "${opts.timezone}" is not applied)`,
686
+ removalHint: deleteCommands
687
+ };
688
+ }
689
+ function escapeXml(value) {
690
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
691
+ }
692
+ function systemdQuote(value) {
693
+ return /[\s"']/.test(value) ? `"${value.replaceAll('"', '\\"')}"` : value;
694
+ }
695
+
696
+ // src/core/store/run-store.ts
697
+ import { appendFile, mkdir as mkdir2, readFile as readFile3 } from "fs/promises";
698
+ import { dirname as dirname2 } from "path";
699
+ var JsonlRunStore = class {
700
+ constructor(filePath = defaultRunStorePath()) {
701
+ this.filePath = filePath;
702
+ }
703
+ filePath;
704
+ async append(record) {
705
+ await mkdir2(dirname2(this.filePath), { recursive: true });
706
+ await appendFile(this.filePath, `${JSON.stringify(record)}
707
+ `, "utf8");
708
+ }
709
+ async list() {
710
+ let raw;
711
+ try {
712
+ raw = await readFile3(this.filePath, "utf8");
713
+ } catch (error) {
714
+ if (error.code === "ENOENT") return [];
715
+ throw error;
716
+ }
717
+ return raw.split("\n").filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
718
+ }
719
+ async hasOpenOrMergedPr(fingerprint) {
720
+ const records = await this.list();
721
+ return records.some(
722
+ (r) => r.fingerprint === fingerprint && (r.outcome === "pr-opened" || !!r.prUrl)
723
+ );
724
+ }
725
+ };
726
+
727
+ // src/core/notify/console.ts
728
+ var ConsoleNotifier = class {
729
+ async notify(record) {
730
+ const pr = record.prUrl ? ` pr=${record.prUrl}` : "";
731
+ console.log(
732
+ `[afterburner] ${record.outcome} ${record.category} "${record.title}" repo=${record.repoUrl} branch=${record.branch} est=${record.estCostSonnetTokens.toLocaleString("en-US")} sonnet-eq tokens${pr}`
733
+ );
734
+ }
735
+ };
736
+
556
737
  // src/core/orchestrator.ts
557
738
  async function runOnce(deps) {
558
739
  const { config } = deps;
@@ -736,7 +917,7 @@ function liveDowngradeReason(config, liveFlag, configPath) {
736
917
  // src/core/tasks/selector.ts
737
918
  import { readdir as readdir2, readFile as readFile4, stat as stat2 } from "fs/promises";
738
919
  import { existsSync as existsSync2 } from "fs";
739
- import { basename, extname, join as join4, relative } from "path";
920
+ import { basename, extname, join as join5, relative } from "path";
740
921
  var BASE_TASK_TOKENS = {
741
922
  security: 12e4,
742
923
  tests: 9e4,
@@ -855,7 +1036,7 @@ async function collectFiles(root) {
855
1036
  const entries = await readdir2(dir, { withFileTypes: true });
856
1037
  for (const entry of entries) {
857
1038
  if (results.length >= MAX_FILES) return;
858
- const fullPath = join4(dir, entry.name);
1039
+ const fullPath = join5(dir, entry.name);
859
1040
  if (entry.isDirectory()) {
860
1041
  if (!SKIP_DIRS.has(entry.name)) await walk(fullPath);
861
1042
  continue;
@@ -876,152 +1057,6 @@ async function collectFiles(root) {
876
1057
  return results;
877
1058
  }
878
1059
 
879
- // src/core/scheduler/native.ts
880
- import { homedir as homedir2 } from "os";
881
- import { join as join5 } from "path";
882
- function parseSimpleCron(cron) {
883
- const parts = cron.trim().split(/\s+/);
884
- if (parts.length !== 5) throw unsupportedCron(cron);
885
- const [minutePart, hourPart, dom, month, dow] = parts;
886
- if (dom !== "*" || month !== "*" || dow !== "*") throw unsupportedCron(cron);
887
- const minute = Number(minutePart);
888
- if (!Number.isInteger(minute) || minute < 0 || minute > 59) throw unsupportedCron(cron);
889
- const allHours = Array.from({ length: 24 }, (_, h) => h);
890
- let hours;
891
- if (hourPart === "*") {
892
- hours = allHours;
893
- } else if (/^\*\/\d+$/.test(hourPart)) {
894
- const step = Number(hourPart.slice(2));
895
- if (step < 1 || step > 23) throw unsupportedCron(cron);
896
- hours = allHours.filter((h) => h % step === 0);
897
- } else if (/^\d+(,\d+)*$/.test(hourPart)) {
898
- hours = hourPart.split(",").map(Number);
899
- if (hours.some((h) => h < 0 || h > 23)) throw unsupportedCron(cron);
900
- } else {
901
- throw unsupportedCron(cron);
902
- }
903
- return { minute, hours };
904
- }
905
- function unsupportedCron(cron) {
906
- return new Error(
907
- `Cron expression "${cron}" is too complex for native scheduler installation. Supported shapes: "M * * * *", "M H * * *", "M */N * * *", "M H1,H2 * * *". Use \`afterburner watch\` for full cron support, or install a native entry manually.`
908
- );
909
- }
910
- function generateScheduleArtifacts(platform, opts) {
911
- const schedule2 = parseSimpleCron(opts.cron);
912
- const command = [
913
- opts.nodePath,
914
- opts.cliPath,
915
- "run-once",
916
- ...opts.configPath ? ["--config", opts.configPath] : []
917
- ];
918
- switch (platform) {
919
- case "darwin":
920
- return launchdArtifacts(schedule2, opts, command);
921
- case "linux":
922
- return systemdArtifacts(schedule2, opts, command);
923
- case "win32":
924
- return schtasksArtifacts(schedule2, opts, command);
925
- }
926
- }
927
- var LAUNCHD_LABEL = "io.afterburner.run-once";
928
- function launchdArtifacts(schedule2, opts, command) {
929
- const plistPath = join5(homedir2(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
930
- const intervals = schedule2.hours.map(
931
- (hour) => ` <dict>
932
- <key>Hour</key><integer>${hour}</integer>
933
- <key>Minute</key><integer>${schedule2.minute}</integer>
934
- </dict>`
935
- ).join("\n");
936
- const programArgs = command.map((arg) => ` <string>${escapeXml(arg)}</string>`).join("\n");
937
- const content = `<?xml version="1.0" encoding="UTF-8"?>
938
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
939
- <plist version="1.0">
940
- <dict>
941
- <key>Label</key>
942
- <string>${LAUNCHD_LABEL}</string>
943
- <key>ProgramArguments</key>
944
- <array>
945
- ${programArgs}
946
- </array>
947
- <key>StartCalendarInterval</key>
948
- <array>
949
- ${intervals}
950
- </array>
951
- <key>StandardOutPath</key>
952
- <string>/tmp/afterburner.out.log</string>
953
- <key>StandardErrorPath</key>
954
- <string>/tmp/afterburner.err.log</string>
955
- </dict>
956
- </plist>
957
- `;
958
- return {
959
- kind: "launchd",
960
- files: [{ path: plistPath, content }],
961
- activationHint: `launchctl load -w ${plistPath}
962
- (note: launchd schedules in LOCAL time; your configured timezone "${opts.timezone}" is not applied)`,
963
- removalHint: `launchctl unload -w ${plistPath} && rm ${plistPath}`
964
- };
965
- }
966
- function systemdArtifacts(schedule2, opts, command) {
967
- const unitDir = join5(homedir2(), ".config", "systemd", "user");
968
- const servicePath = join5(unitDir, "afterburner.service");
969
- const timerPath = join5(unitDir, "afterburner.timer");
970
- const minute = String(schedule2.minute).padStart(2, "0");
971
- const onCalendarLines = schedule2.hours.map(
972
- (hour) => `OnCalendar=*-*-* ${String(hour).padStart(2, "0")}:${minute}:00 ${opts.timezone}`
973
- ).join("\n");
974
- const execStart = command.map((arg) => systemdQuote(arg)).join(" ");
975
- const service = `[Unit]
976
- Description=Afterburner single run (budget-gated, PR-only)
977
-
978
- [Service]
979
- Type=oneshot
980
- ExecStart=${execStart}
981
- `;
982
- const timer = `[Unit]
983
- Description=Afterburner schedule
984
-
985
- [Timer]
986
- ${onCalendarLines}
987
- Persistent=false
988
-
989
- [Install]
990
- WantedBy=timers.target
991
- `;
992
- return {
993
- kind: "systemd-user",
994
- files: [
995
- { path: servicePath, content: service },
996
- { path: timerPath, content: timer }
997
- ],
998
- activationHint: `systemctl --user daemon-reload && systemctl --user enable --now afterburner.timer`,
999
- removalHint: `systemctl --user disable --now afterburner.timer && rm ${servicePath} ${timerPath} && systemctl --user daemon-reload`
1000
- };
1001
- }
1002
- function schtasksArtifacts(schedule2, opts, command) {
1003
- const minute = String(schedule2.minute).padStart(2, "0");
1004
- const taskName = (hour) => `Afterburner-${String(hour).padStart(2, "0")}${minute}`;
1005
- const tr = command.map((arg) => `\\"${arg}\\"`).join(" ");
1006
- const createCommands = schedule2.hours.map(
1007
- (hour) => `schtasks /Create /TN "${taskName(hour)}" /TR "${tr}" /SC DAILY /ST ${String(hour).padStart(2, "0")}:${minute}`
1008
- ).join("\n");
1009
- const deleteCommands = schedule2.hours.map((hour) => `schtasks /Delete /TN "${taskName(hour)}" /F`).join("\n");
1010
- return {
1011
- kind: "schtasks",
1012
- files: [],
1013
- activationHint: `${createCommands}
1014
- (run from cmd.exe; note: schtasks schedules in LOCAL time; your configured timezone "${opts.timezone}" is not applied)`,
1015
- removalHint: deleteCommands
1016
- };
1017
- }
1018
- function escapeXml(value) {
1019
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
1020
- }
1021
- function systemdQuote(value) {
1022
- return /[\s"']/.test(value) ? `"${value.replaceAll('"', '\\"')}"` : value;
1023
- }
1024
-
1025
1060
  // src/core/scheduler/watch.ts
1026
1061
  import { schedule, validate } from "node-cron";
1027
1062
  function startWatch(opts) {
@@ -1071,13 +1106,17 @@ export {
1071
1106
  readUsageCache,
1072
1107
  writeUsageCache,
1073
1108
  budgetFromUsageCache,
1074
- looksRemoteRepoUrl,
1075
- JsonlRunStore,
1076
1109
  ClaudeUsageBudgetProvider,
1077
1110
  ManualBudgetProvider,
1078
1111
  createBudgetProvider,
1079
- ConsoleNotifier,
1080
1112
  shouldIgnite,
1113
+ parseSimpleCron,
1114
+ nextSimpleCronFire,
1115
+ scheduleArtifactPaths,
1116
+ generateScheduleArtifacts,
1117
+ looksRemoteRepoUrl,
1118
+ JsonlRunStore,
1119
+ ConsoleNotifier,
1081
1120
  runOnce,
1082
1121
  ApiKeyRunner,
1083
1122
  taskFingerprint,
@@ -1092,7 +1131,5 @@ export {
1092
1131
  BASE_TASK_TOKENS,
1093
1132
  createSelector,
1094
1133
  DeterministicTaskSelector,
1095
- parseSimpleCron,
1096
- generateScheduleArtifacts,
1097
1134
  startWatch
1098
1135
  };
package/dist/cli/index.js CHANGED
@@ -16,11 +16,15 @@ import {
16
16
  liveDowngradeReason,
17
17
  loadConfig,
18
18
  looksRemoteRepoUrl,
19
+ nextSimpleCronFire,
20
+ parseSimpleCron,
19
21
  readUsageCache,
20
22
  runOnce,
23
+ scheduleArtifactPaths,
24
+ shouldIgnite,
21
25
  startWatch,
22
26
  writeUsageCache
23
- } from "../chunk-OZAFLQDP.js";
27
+ } from "../chunk-Z3H5GIPM.js";
24
28
  import {
25
29
  MCP_STUB_MESSAGE
26
30
  } from "../chunk-2NSOEZWY.js";
@@ -31,7 +35,7 @@ import { Command } from "commander";
31
35
  // package.json
32
36
  var package_default = {
33
37
  name: "@pimmesz/afterburner",
34
- version: "1.0.9",
38
+ version: "1.0.10",
35
39
  description: "Convert idle Claude subscription quota into shippable engineering work: budget-aware trigger, bounded task selection, PR-only output.",
36
40
  license: "Apache-2.0",
37
41
  publishConfig: {
@@ -337,12 +341,120 @@ async function runDoctor(opts) {
337
341
  ${green(`${deco(emoji.rocket)}All checks passed.`)}` : `
338
342
  ${red(`${failures} check(s) failed.`)}`
339
343
  );
344
+ let summary;
345
+ if (failures === 0 && config && config.repos.length > 0) {
346
+ try {
347
+ summary = await buildRunSummary(config);
348
+ } catch {
349
+ }
350
+ }
340
351
  console.log(
341
352
  `
342
- ${renderDoctorNextSteps({ config, configPath, failed: results.filter((r) => !r.ok) })}`
353
+ ${renderDoctorNextSteps({
354
+ config,
355
+ configPath,
356
+ failed: results.filter((r) => !r.ok),
357
+ ...summary ? { summary } : {}
358
+ })}`
343
359
  );
344
360
  process.exitCode = failures === 0 ? 0 : 1;
345
361
  }
362
+ async function buildRunSummary(config) {
363
+ let budget = null;
364
+ try {
365
+ budget = await createBudgetProvider(config).provider.getBudget();
366
+ } catch {
367
+ }
368
+ const platform = process.platform;
369
+ const probeable = platform === "darwin" || platform === "linux";
370
+ const paths = probeable ? scheduleArtifactPaths(platform) : [];
371
+ const scheduleInstalled = probeable ? paths.every((p) => existsSync2(p)) : "unknown";
372
+ return renderRunSummary({
373
+ config,
374
+ budget,
375
+ scheduleInstalled,
376
+ scheduleKind: platform === "linux" ? "systemd-user" : "launchd",
377
+ systemTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
378
+ now: /* @__PURE__ */ new Date()
379
+ });
380
+ }
381
+ function renderRunSummary(opts) {
382
+ const { config, budget } = opts;
383
+ const repoNames = config.repos.map((r) => r.url);
384
+ const repoLabel = repoNames.length === 1 ? repoNames[0] : `${repoNames.length} repos`;
385
+ const categories = [
386
+ ...new Set(
387
+ config.repos.flatMap(
388
+ (r) => r.enabledTaskCategories.filter((c) => config.taskCategories[c].enabled)
389
+ )
390
+ )
391
+ ];
392
+ const what = categories.length === 0 ? `Would review ${repoLabel}, but every repo task category is switched off in taskCategories` : `Reviews ${repoLabel} for ${categories.join("/")} tasks`;
393
+ let why;
394
+ if (!budget) {
395
+ why = `Based on ${config.budget.provider}: no numbers available right now, so a run would refuse to start.`;
396
+ } else {
397
+ const gateConfig = {
398
+ minWeeklyHeadroomPct: config.budget.minWeeklyHeadroomPct,
399
+ safetyMarginTokens: config.budget.safetyMarginTokens,
400
+ requireSessionAvailable: config.budget.requireSessionAvailable
401
+ };
402
+ const fullSize = shouldIgnite(budget, config.agent.maxTaskTokens, gateConfig);
403
+ const anySize = fullSize.go ? fullSize : shouldIgnite(budget, 0, gateConfig);
404
+ const verdict = fullSize.go ? "the budget gate would let a run through right now" : anySize.go ? `a task at the ${formatTokens(config.agent.maxTaskTokens)}-token cap would not fit right now, though cheaper tasks still could` : `the budget gate would block any run right now (${fullSize.reason})`;
405
+ const usage = `${Math.round(budget.weeklyRemainingPct)}% of the weekly quota left (~${formatTokens(Math.round(budget.weeklyRemainingTokensEst))} tokens), 5-hour session ${budget.sessionAvailable ? "available" : "exhausted"}`;
406
+ why = `Based on ${config.budget.provider}: ${usage} \u2192 ${verdict}.`;
407
+ }
408
+ return `${section(emoji.thrust, "Summary")}
409
+ ${what} \u2014 at most one PR per run, up to ${formatTokens(config.agent.maxTaskTokens)} tokens.
410
+ ${renderWhen(opts)}
411
+ ${why}`;
412
+ }
413
+ function renderWhen(opts) {
414
+ const { config } = opts;
415
+ const cron = config.schedule.cron;
416
+ const cadence = describeCadence(cron);
417
+ if (opts.scheduleInstalled === false) {
418
+ return `When: no afterburner schedule is installed \u2014 it only runs when you start it; \`afterburner schedule install\` would run it ${cadence}.`;
419
+ }
420
+ if (opts.scheduleInstalled === "unknown") {
421
+ return `When: ${cadence}, once the entries from \`afterburner schedule install\` are active (not auto-detectable on this platform).`;
422
+ }
423
+ const zone = opts.scheduleKind === "systemd-user" ? config.schedule.timezone : opts.systemTimezone;
424
+ const kindLabel = opts.scheduleKind === "systemd-user" ? "systemd" : "launchd";
425
+ const zoneNote = opts.scheduleKind === "launchd" && zone !== config.schedule.timezone ? ` \u2014 launchd fires in local ${zone} time, not the configured ${config.schedule.timezone}` : "";
426
+ try {
427
+ const next = nextSimpleCronFire(parseSimpleCron(cron), opts.now, zone);
428
+ const formatted = new Intl.DateTimeFormat("en-GB", {
429
+ timeZone: zone,
430
+ weekday: "short",
431
+ day: "numeric",
432
+ month: "short",
433
+ hour: "2-digit",
434
+ minute: "2-digit",
435
+ hourCycle: "h23"
436
+ }).format(next);
437
+ return `When: next run ${formatted} (${zone}), then ${cadence} \u2014 via the installed ${kindLabel} entry, if activated and in sync with this config${zoneNote}.`;
438
+ } catch {
439
+ return `When: an installed ${kindLabel} entry runs it on cron '${cron}' (${zone}), if activated.`;
440
+ }
441
+ }
442
+ function describeCadence(cron) {
443
+ let minute;
444
+ let hours;
445
+ try {
446
+ ({ minute, hours } = parseSimpleCron(cron));
447
+ } catch {
448
+ return `on cron '${cron}'`;
449
+ }
450
+ if (hours.length === 24) return `every hour at minute ${minute}`;
451
+ const step2 = (hours[1] ?? 0) - (hours[0] ?? 0);
452
+ const uniform = hours.length > 1 && hours[0] === 0 && 24 % step2 === 0 && hours.length === 24 / step2 && hours.every((h, i) => h === i * step2);
453
+ if (uniform) return `every ${step2} hours at minute ${minute}`;
454
+ const mm = String(minute).padStart(2, "0");
455
+ const times = hours.map((h) => `${String(h).padStart(2, "0")}:${mm}`);
456
+ return `daily at ${times.join(", ")}`;
457
+ }
346
458
  function renderDoctorNextSteps(opts) {
347
459
  const next = section(emoji.rocket, "Next");
348
460
  if (!opts.config || !opts.configPath) {
@@ -379,13 +491,15 @@ ${nextCmd(cmd("doctor"))}`;
379
491
  Repos: ${repoLine}
380
492
  Engine: ${engineLine}
381
493
  Budget: ${budgetLine}
382
- Limits: at most one task per repo per run, capped at ${config.agent.maxTaskTokens.toLocaleString("en-US")} tokens, delivered as one PR on its own branch
494
+ Limits: at most one task per run, capped at ${config.agent.maxTaskTokens.toLocaleString("en-US")} tokens, delivered as one PR on its own branch
383
495
  Gate: ${gateLine}
384
496
  Safety: live PRs need BOTH a live engine in the config AND --live (two-part opt-in)
385
497
  Runs: only when you start them \u2014 \`afterburner schedule install\` (recommended OS scheduler; cron '${config.schedule.cron}' ${config.schedule.timezone}) or \`afterburner watch\` (foreground) make it recurring
386
498
  Stop: \`afterburner schedule uninstall\` (or Ctrl-C for watch); ${cmd("log")} lists every past run
387
499
 
388
- ${next}
500
+ ${opts.summary ? `${opts.summary}
501
+
502
+ ` : ""}${next}
389
503
  ${nextCmd(cmd("run-once --dry-run"), nextNote)}`;
390
504
  }
391
505
  function checkVersion(opts) {
@@ -45,7 +45,7 @@ import {
45
45
  summarizeTranscriptUsage,
46
46
  taskFingerprint,
47
47
  writeUsageCache
48
- } from "../chunk-OZAFLQDP.js";
48
+ } from "../chunk-Z3H5GIPM.js";
49
49
  export {
50
50
  ApiKeyRunner,
51
51
  BASE_TASK_TOKENS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pimmesz/afterburner",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
4
4
  "description": "Convert idle Claude subscription quota into shippable engineering work: budget-aware trigger, bounded task selection, PR-only output.",
5
5
  "license": "Apache-2.0",
6
6
  "publishConfig": {