@openagt/skill-scheduler 0.0.0-stage → 0.1.11

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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/SKILL.md +28 -0
  3. package/bin/scheduler +11 -0
  4. package/dist/automation-file.d.ts +61 -0
  5. package/dist/automation-file.d.ts.map +1 -0
  6. package/dist/automation-file.js +153 -0
  7. package/dist/automation-file.js.map +1 -0
  8. package/dist/automation.d.ts +189 -0
  9. package/dist/automation.d.ts.map +1 -0
  10. package/dist/automation.js +237 -0
  11. package/dist/automation.js.map +1 -0
  12. package/dist/cleanup.d.ts +31 -0
  13. package/dist/cleanup.d.ts.map +1 -0
  14. package/dist/cleanup.js +20 -0
  15. package/dist/cleanup.js.map +1 -0
  16. package/dist/cli.d.ts +19 -0
  17. package/dist/cli.d.ts.map +1 -0
  18. package/dist/cli.js +409 -0
  19. package/dist/cli.js.map +1 -0
  20. package/dist/dashboard/dashboard.css +3 -0
  21. package/dist/dashboard/dashboard.js +2324 -0
  22. package/dist/hook-lines.d.ts +11 -0
  23. package/dist/hook-lines.d.ts.map +1 -0
  24. package/dist/hook-lines.js +12 -0
  25. package/dist/hook-lines.js.map +1 -0
  26. package/dist/index.d.ts +15 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +15 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/init.d.ts +40 -0
  31. package/dist/init.d.ts.map +1 -0
  32. package/dist/init.js +95 -0
  33. package/dist/init.js.map +1 -0
  34. package/dist/names.d.ts +85 -0
  35. package/dist/names.d.ts.map +1 -0
  36. package/dist/names.js +91 -0
  37. package/dist/names.js.map +1 -0
  38. package/dist/pace.d.ts +86 -0
  39. package/dist/pace.d.ts.map +1 -0
  40. package/dist/pace.js +99 -0
  41. package/dist/pace.js.map +1 -0
  42. package/dist/quota-boundary.d.ts +123 -0
  43. package/dist/quota-boundary.d.ts.map +1 -0
  44. package/dist/quota-boundary.js +181 -0
  45. package/dist/quota-boundary.js.map +1 -0
  46. package/dist/records.d.ts +45 -0
  47. package/dist/records.d.ts.map +1 -0
  48. package/dist/records.js +85 -0
  49. package/dist/records.js.map +1 -0
  50. package/dist/schedule.d.ts +256 -0
  51. package/dist/schedule.d.ts.map +1 -0
  52. package/dist/schedule.js +390 -0
  53. package/dist/schedule.js.map +1 -0
  54. package/dist/scheduler.d.ts +75 -0
  55. package/dist/scheduler.d.ts.map +1 -0
  56. package/dist/scheduler.js +244 -0
  57. package/dist/scheduler.js.map +1 -0
  58. package/dist/start-point.d.ts +40 -0
  59. package/dist/start-point.d.ts.map +1 -0
  60. package/dist/start-point.js +88 -0
  61. package/dist/start-point.js.map +1 -0
  62. package/dist/state.d.ts +194 -0
  63. package/dist/state.d.ts.map +1 -0
  64. package/dist/state.js +208 -0
  65. package/dist/state.js.map +1 -0
  66. package/dist/test-repo.d.ts +20 -0
  67. package/dist/test-repo.d.ts.map +1 -0
  68. package/dist/test-repo.js +56 -0
  69. package/dist/test-repo.js.map +1 -0
  70. package/dist/tick.d.ts +122 -0
  71. package/dist/tick.d.ts.map +1 -0
  72. package/dist/tick.js +334 -0
  73. package/dist/tick.js.map +1 -0
  74. package/dist/write-line.d.ts +38 -0
  75. package/dist/write-line.d.ts.map +1 -0
  76. package/dist/write-line.js +121 -0
  77. package/dist/write-line.js.map +1 -0
  78. package/package.json +65 -3
  79. package/README.md +0 -3
@@ -0,0 +1,256 @@
1
+ import { type AgentName } from './names.js';
2
+ import { type Interval } from './pace.js';
3
+ /**
4
+ * The schedule (#2022): the scheduled commands of a project, each brought by the skill it runs.
5
+ * The tool knows no command by name; a skill says it can be scheduled with a `schedule` in the
6
+ * front matter of its `SKILL.md`, and a skill that is not in the project has no command here.
7
+ *
8
+ * ---
9
+ * name: update-tickets
10
+ * disable-model-invocation: true
11
+ * schedule:
12
+ * every: 15m
13
+ * when: npx @openagt/skill-tickets@0.1 meta | jq …
14
+ * waits-for: when an issue changed since the last import
15
+ * ---
16
+ *
17
+ * A skill with several modes lists one row per mode, each with the `word` the skill gets as its
18
+ * argument (`triage quick`, `triage consensual`):
19
+ *
20
+ * schedule:
21
+ * - word: quick
22
+ * every: 6h
23
+ * - word: consensual
24
+ * every: 7d
25
+ *
26
+ * A command is named as a person types it, without the slash: the skill's folder name, then the
27
+ * row's word when it has one. The whole name is the command's identity (its prompt, its switch,
28
+ * its interval, its cap, its run records).
29
+ *
30
+ * `when` is a shell command, run at the repository root. The command is due while the check
31
+ * exits 0 and prints something other than an empty JSON value. The check may read `$LAST_RUN`,
32
+ * the time its command last started on any machine, or was switched on on this one when that is
33
+ * later: a check that asks what is new since then goes quiet once a run was started for it.
34
+ * Inside a single-quoted `jq` program the shell does not fill it in: `env.LAST_RUN` reads it there. `every` is how often at most: the
35
+ * command is due only once that long has passed since its last recorded start. A row carries one
36
+ * or both; with both, the command starts only when both hold. `waits-for` is one plain line
37
+ * saying what the check waits for, for a person: a check is a shell line nothing can turn into a
38
+ * sentence. `agents` is how many runs of the command may be in flight at once, across every
39
+ * machine that shares the repository, from 1 to 99. `check-every: <N>s`, from 10 to 59 seconds,
40
+ * lets the check run that often between the minute's ticks, for a row whose answer is waited for:
41
+ * its check must be cheap to run so often.
42
+ *
43
+ * A row with `each: true` has its check print a list, one item per run, each with its words. An
44
+ * item that names a run, `{ "run": "<id>", "text": "…" }`, continues that ended run with the
45
+ * command and its words, on the coding agent, model and publish level its own record names; only
46
+ * a run this machine started, as only this machine holds its conversation. An item that names what
47
+ * a new run is for, `{ "for": "<what>", "text": "…" }`, starts one, on the row's agent and model as
48
+ * any row's run, its record naming what it is for: one run for it, across every machine, so the
49
+ * check names it next time by its run. `"branch": "<name>"` beside it has the run work on that
50
+ * branch, one that exists, instead of a fresh one. Such a check is told `$SWITCHED_ON` too, when
51
+ * the command was switched on on this machine: what came before, it leaves. What the words and
52
+ * what a run is for are about is the check's and the skill's: the scheduler knows nothing of it.
53
+ *
54
+ * Whether a command runs, and how far its runs publish, is not the skill's to say: each person
55
+ * sets both on their own machine, in the tool's state. The pace and the number of agents are
56
+ * where the skill starts: each person may set their own there too.
57
+ *
58
+ * A `schedule` the reader cannot read is skipped and named, so a typo stands down one skill's
59
+ * commands and says so rather than silently doing nothing.
60
+ *
61
+ * The schedule is read from every folder a coding agent reads skills from, so a project's skills
62
+ * are all listed; a command whose skill is not in the folder of the agent that runs scheduled
63
+ * commands is listed and never started, and the tick says so.
64
+ */
65
+ /** One command as its skill schedules it. */
66
+ export interface ScheduledCommand {
67
+ /** The command as typed without its slash: the skill's folder name, then at most one word the skill gets as its argument (`triage quick`). */
68
+ name: string;
69
+ /** The check, a shell command line; absent when the row paces by time alone. */
70
+ when?: string;
71
+ /** How often at most, where no person set another pace: the least time since the command's last recorded start. */
72
+ every?: Interval;
73
+ /** What the check waits for, in one plain line for a person. */
74
+ waitsFor?: string;
75
+ /** How often the check may run between the minute's ticks, when the row says: its check is cheap and its answer waited for. */
76
+ checkEvery?: {
77
+ ms: number;
78
+ text: string;
79
+ };
80
+ /** Runs in flight at once, across every machine, where no person set another number. */
81
+ cap: number;
82
+ /** The skills folder the skill was read from (`.claude/skills`). */
83
+ dir: string;
84
+ /** Every skills folder that holds the skill: a coding agent can run the command only when its own folder is among them. */
85
+ dirs: string[];
86
+ /** The coding agent the skill is made for, when it names one: a run is on it where no person picked another. */
87
+ agent?: AgentName;
88
+ /** The model the skill is made for, when it names one: a model of the agent the skill names, of Claude Code when it names none. A run on that agent starts on it where no person picked another; a run on another agent does not. */
89
+ model?: string;
90
+ /** What the skill does, in its own words: the `description` of its front matter, when it has one. */
91
+ description?: string;
92
+ /** Whether the row's check prints one item per run: a run it continues, on that run's own coding agent, model and publish level, or what a new run is for. */
93
+ each?: true;
94
+ /**
95
+ * The text of a command that is no skill: an automation a person keeps on this machine alone.
96
+ * Its text is in no checkout, so a run of it is handed the text with its prompt, which is the
97
+ * automation's name. A skill's command has none: its run is sent the command
98
+ * (`/update-tickets`), and the coding agent reads the skill's text from the checkout.
99
+ */
100
+ text?: string;
101
+ /**
102
+ * Whether the command is a person's own automation whose file reads as the tool writes one, and
103
+ * is no link: the tool can show it, save it again and remove it. Neither a skill a person or a
104
+ * package wrote, nor an automation changed by hand into something the tool does not write. The
105
+ * one place that says so: the command line asks the schedule.
106
+ */
107
+ editable?: true;
108
+ }
109
+ /** The schedule as read. */
110
+ export interface Schedule {
111
+ commands: ScheduledCommand[];
112
+ /** The skills whose `schedule` could not be read, each with why; and, marked `own`, the automations kept on this machine that are not listed. */
113
+ unreadable: {
114
+ skill: string;
115
+ reason: string;
116
+ own?: true;
117
+ }[];
118
+ /** The names a skill of the project and an automation kept on this machine both have: neither is listed, and this machine's picks under such a name are taken back. */
119
+ clashes?: string[];
120
+ }
121
+ /**
122
+ * The commands a skill schedules, out of its `SKILL.md` in the skills folder `dir`: none when the
123
+ * file has no front matter or the front matter no `schedule`. One row or a list of rows; any row
124
+ * that cannot be read makes the whole `schedule` unreadable, and the reason names the row. Pure.
125
+ */
126
+ export declare function skillSchedule(skill: string, md: string, dir: string): Schedule;
127
+ /**
128
+ * The project's schedule: every skill's commands, the skills in name order, then the automations
129
+ * a person keeps on this machine alone, in name order. Skills are read from each folder a coding
130
+ * agent reads skills from, the folder of the agent that runs scheduled commands first; a skill in
131
+ * two folders is read once, from the first folder that holds its `SKILL.md`. A project with no
132
+ * skills folder and no automation of the person's schedules nothing.
133
+ *
134
+ * An automation kept on this machine is one file, written like a skill's: its front matter holds
135
+ * one row of schedule, and its text is what a run is handed. One that cannot be listed is named
136
+ * with why. One whose name a skill of the project has too is not listed, and neither are that
137
+ * skill's commands: a run is counted by its command's name and this machine's picks are kept
138
+ * under it, so the two cannot be told apart.
139
+ */
140
+ export declare function readSchedule(repo: string): Promise<Schedule>;
141
+ /**
142
+ * The text of a skill file after its front matter, without its outer blank lines and without NUL
143
+ * characters: what an agent is told. The front matter ends where the schedule's reader ends it,
144
+ * at the first line that opens with three dashes, whatever follows them on that line.
145
+ */
146
+ export declare function skillText(md: string): string;
147
+ /** The file a command's skill is, from the repository root: the skills folder it was read from, or the one given, the command's first word, the skill's file (`.claude/skills/triage/SKILL.md` for `triage quick`). */
148
+ export declare function skillFile(command: Pick<ScheduledCommand, 'name' | 'dir'>, dir?: string): string;
149
+ /**
150
+ * The coding agents that can run a command: those whose own skills folder holds its skill. An
151
+ * automation kept on this machine is no skill: a run is handed its text, so any agent can.
152
+ */
153
+ export declare function ableAgents(command: Pick<ScheduledCommand, 'dirs' | 'text'>): AgentName[];
154
+ /**
155
+ * The coding agent a command is made for: the one its skill names, else Claude Code when it can
156
+ * run the command, else the one that can. Where a run is on when no person picked another, and
157
+ * the agent the skill's own model goes with.
158
+ */
159
+ export declare function homeAgent(command: Pick<ScheduledCommand, 'agent' | 'dirs' | 'text'>): AgentName;
160
+ /**
161
+ * The prompt a command runs with: its slash command, which the agent's harness expands, the word
162
+ * after it handed to the skill. An automation kept on this machine is no command in a run's
163
+ * checkout: its prompt is its name alone, with no slash, and its text is handed over with it
164
+ * ({@link ownAttached}). Either way the prompt names the command, which is what its runs are
165
+ * counted by, whatever its text says and however the text changes.
166
+ */
167
+ export declare function commandPrompt(command: Pick<ScheduledCommand, 'name' | 'text'>): string;
168
+ /**
169
+ * What a run of an automation kept on this machine is handed with its prompt: the automation's
170
+ * text, and after it, when a check started the run, the sentence that says so and what the check
171
+ * printed.
172
+ */
173
+ export declare function ownAttached(text: string, found?: {
174
+ stdout: string;
175
+ lastRun: string;
176
+ }): string;
177
+ /** What is said before a check's output to a run of an automation kept on this machine: its work is said by the text above, not by a command. */
178
+ export declare const FOUND_OPENING_OWN = "The scheduler starts this when its check prints something, and this time the check printed what is below. It says why this run started; what the work is, the text above says.";
179
+ /**
180
+ * The time a check is given as `$LAST_RUN`: an ISO time in UTC to the whole second
181
+ * (`2026-10-09T10:00:00Z`), the shape `gh --search`, a GitHub `since` and `jq`'s `fromdate` all
182
+ * read. The fraction of a second is dropped, never rounded up: a thing that came in that same
183
+ * second is found twice rather than never.
184
+ */
185
+ export declare function lastRunValue(iso: string): string;
186
+ /**
187
+ * What a run is handed with its prompt when a check started it: {@link FOUND_OPENING}, then what
188
+ * the check printed, so an agent whose command asks for the new thing has it. The command's prompt
189
+ * stays the command alone: it is what the run is counted under.
190
+ *
191
+ * A NUL character is dropped: the text travels as a command-line argument, which can hold none.
192
+ * Output past {@link FOUND_MAX} characters is cut at the end of the last whole line that fits,
193
+ * mid-line when its first line alone is longer. A last line then says how much was printed and
194
+ * the time the check was given: the next check asks from this run's start, so what was cut is
195
+ * found again only by an agent that asks from that earlier time itself.
196
+ */
197
+ export declare function checkFound(stdout: string, lastRun: string): string;
198
+ /** What a check printed, as a run is handed it and as a person trying the check is shown it: without NUL characters and outer blank lines, cut when it is long, the cut said in a last line. */
199
+ export declare function foundOutput(stdout: string, lastRun: string): string;
200
+ /**
201
+ * What is said before a check's output. The agent has never seen the check, and a check may print
202
+ * only a sign that there is work (one issue of five that changed): so the words say what the
203
+ * output is, and that the command, not the output, says what the work is.
204
+ */
205
+ export declare const FOUND_OPENING = "The scheduler starts this command when its check prints something, and this time the check printed what is below. It says why this run started; what the work is, the command says.";
206
+ /** An item a row with `each` printed: a run to continue, with its words. */
207
+ export interface ResumeItem {
208
+ run: string;
209
+ text: string;
210
+ }
211
+ /** An item a row with `each` printed: what a new run is for, its words, and the branch it works on when one is named. */
212
+ export interface StartItem {
213
+ for: string;
214
+ text: string;
215
+ branch?: string;
216
+ }
217
+ /**
218
+ * What a row with `each` printed: each run it names with its words, a run named twice once with
219
+ * its words joined, and each new run it asks for, what it is for named twice once, the same way.
220
+ * Nothing for output that says no work ({@link isDue}); a reason for output that is no such list.
221
+ */
222
+ export declare function eachItems(stdout: string): {
223
+ resumes: ResumeItem[];
224
+ starts: StartItem[];
225
+ } | string;
226
+ /**
227
+ * What a run a row resumes is told: the command, as a run it starts is sent, then
228
+ * {@link RESUMED_OPENING} and the check's words for that run; for an automation kept on this
229
+ * machine, its name, its text, then the opening and the words. One text: a resume takes no
230
+ * attached text.
231
+ */
232
+ export declare function resumeText(command: Pick<ScheduledCommand, 'name' | 'text'>, words: string, lastRun: string): string;
233
+ /** What is said before the check's words to a run a row resumes: the run has never seen the check, and the command, not the words, says what the work is. */
234
+ export declare const RESUMED_OPENING = "The scheduler continues this run with this command when its check names the run, and this time the check said what is below for it. It says why the run continues; what the work is, the command says.";
235
+ /** The same for a run an automation kept on this machine resumes: its work is said by the text above. */
236
+ export declare const RESUMED_OPENING_OWN = "The scheduler continues this run with this when its check names the run, and this time the check said what is below for it. It says why the run continues; what the work is, the text above says.";
237
+ /**
238
+ * The command a run's prompt is counted under, so a run a person started counts against that
239
+ * command's cap and interval like a scheduled one; nothing for a prompt that names none.
240
+ *
241
+ * A prompt that opens with a slash names a skill's command: the scheduled command whose name it
242
+ * is (`/triage quick` → `triage quick`), else the one its first word is (`/work-queue now` →
243
+ * `work-queue`). A prompt with no slash names no skill's command: it is a run of an automation
244
+ * kept on this machine when it is that automation's name and nothing else (`answer-comments`).
245
+ * The two never cross: a skill's command typed by hand is no run of an automation of that name,
246
+ * and an automation's run is none of a skill's.
247
+ */
248
+ export declare function promptCommand(prompt: string, schedule: Schedule): string | undefined;
249
+ /**
250
+ * Whether a check's output says the command is due (#1774): the output parsed as JSON is
251
+ * something other than empty — `[]`, `{}`, `null`, `false`, `""`, `0` and no output at all are not
252
+ * due. Every skill command prints JSON on stdout, so a check like `npx @openagt/skill-queue@0.1` needs no piping.
253
+ * Output that is not JSON counts by its text: anything non-blank is due.
254
+ */
255
+ export declare function isDue(stdout: string): boolean;
256
+ //# sourceMappingURL=schedule.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schedule.d.ts","sourceRoot":"","sources":["../src/schedule.ts"],"names":[],"mappings":"AAKA,OAAO,EAAiK,KAAK,SAAS,EAAE,MAAM,YAAY,CAAA;AAC1M,OAAO,EAAiB,KAAK,QAAQ,EAAE,MAAM,WAAW,CAAA;AAExD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6DG;AAEH,6CAA6C;AAC7C,MAAM,WAAW,gBAAgB;IAC/B,8IAA8I;IAC9I,IAAI,EAAE,MAAM,CAAA;IACZ,gFAAgF;IAChF,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,mHAAmH;IACnH,KAAK,CAAC,EAAE,QAAQ,CAAA;IAChB,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,+HAA+H;IAC/H,UAAU,CAAC,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAA;IACzC,wFAAwF;IACxF,GAAG,EAAE,MAAM,CAAA;IACX,oEAAoE;IACpE,GAAG,EAAE,MAAM,CAAA;IACX,2HAA2H;IAC3H,IAAI,EAAE,MAAM,EAAE,CAAA;IACd,gHAAgH;IAChH,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,qOAAqO;IACrO,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,qGAAqG;IACrG,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,8JAA8J;IAC9J,IAAI,CAAC,EAAE,IAAI,CAAA;IACX;;;;;OAKG;IACH,IAAI,CAAC,EAAE,MAAM,CAAA;IACb;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,IAAI,CAAA;CAChB;AAED,4BAA4B;AAC5B,MAAM,WAAW,QAAQ;IACvB,QAAQ,EAAE,gBAAgB,EAAE,CAAA;IAC5B,iJAAiJ;IACjJ,UAAU,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,IAAI,CAAA;KAAE,EAAE,CAAA;IAC3D,uKAAuK;IACvK,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;CACnB;AASD;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,QAAQ,CA2B9E;AAmDD;;;;;;;;;;;;GAYG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC,CAwDlE;AAUD;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAG5C;AAED,uNAAuN;AACvN,wBAAgB,SAAS,CAAC,OAAO,EAAE,IAAI,CAAC,gBAAgB,EAAE,MAAM,GAAG,KAAK,CAAC,EAAE,GAAG,GAAE,MAAoB,GAAG,MAAM,CAE5G;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,gBAAgB,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,SAAS,EAAE,CAExF;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,IAAI,CAAC,gBAAgB,EAAE,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC,GAAG,SAAS,CAI/F;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,IAAI,CAAC,gBAAgB,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,MAAM,CAEtF;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAE7F;AAED,iJAAiJ;AACjJ,eAAO,MAAM,iBAAiB,mLAAmL,CAAA;AAEjN;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAElE;AAED,gMAAgM;AAChM,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAQnE;AAED;;;;GAIG;AACH,eAAO,MAAM,aAAa,wLAAwL,CAAA;AAElN,4EAA4E;AAC5E,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;CACb;AAED,yHAAyH;AACzH,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,OAAO,EAAE,UAAU,EAAE,CAAC;IAAC,MAAM,EAAE,SAAS,EAAE,CAAA;CAAE,GAAG,MAAM,CA4BjG;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,gBAAgB,EAAE,MAAM,GAAG,MAAM,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAInH;AAED,6JAA6J;AAC7J,eAAO,MAAM,eAAe,2MAA2M,CAAA;AAEvO,yGAAyG;AACzG,eAAO,MAAM,mBAAmB,sMAAsM,CAAA;AAEtO;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,GAAG,MAAM,GAAG,SAAS,CAQpF;AAED;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAa7C"}
@@ -0,0 +1,390 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { parse as parseYaml } from 'yaml';
4
+ import { HARNESS_SKILL_DIRS } from '@openagt/skill-branches';
5
+ import { automationOf, isPlainFile, standsAlone } from './automation-file.js';
6
+ import { AGENTS, AGENT_SKILLS_DIR, DEFAULT_AGENT, DEFAULT_CAP, FOUND_MAX, MAX_AGENTS, OWN_AUTOMATIONS_DIR, OWN_TEXT_MAX, RUN_SKILLS_DIR, SKILL_FILE, isAgent, isAgents } from './names.js';
7
+ import { parseInterval } from './pace.js';
8
+ const SKILL = /^[a-z0-9][a-z0-9-]*$/;
9
+ const WORD = SKILL;
10
+ const ROW_KEYS = ['word', 'every', 'when', 'waits-for', 'check-every', 'agents', 'agent', 'model', 'each'];
11
+ /** `check-every`: a whole number of seconds from 10 to 59, and `s`. */
12
+ const CHECK_EVERY = /^([1-5][0-9])s$/;
13
+ /**
14
+ * The commands a skill schedules, out of its `SKILL.md` in the skills folder `dir`: none when the
15
+ * file has no front matter or the front matter no `schedule`. One row or a list of rows; any row
16
+ * that cannot be read makes the whole `schedule` unreadable, and the reason names the row. Pure.
17
+ */
18
+ export function skillSchedule(skill, md, dir) {
19
+ const unreadable = (reason) => ({ commands: [], unreadable: [{ skill, reason }] });
20
+ const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md);
21
+ if (!front)
22
+ return { commands: [], unreadable: [] };
23
+ let data;
24
+ try {
25
+ data = parseYaml(front[1]);
26
+ }
27
+ catch {
28
+ // Only a skill that tries to schedule something is named: a coding agent reads front matter
29
+ // more loosely than YAML does, and a skill with no `schedule` is none of this tool's business.
30
+ return /^schedule\s*:/m.test(front[1]) ? unreadable('the front matter is not YAML') : { commands: [], unreadable: [] };
31
+ }
32
+ const written = isMap(data) ? data['schedule'] : undefined;
33
+ if (written === undefined)
34
+ return { commands: [], unreadable: [] };
35
+ // A `schedule:` with nothing after it is YAML's null: no row, like an empty list.
36
+ const rows = Array.isArray(written) ? written : written === null ? [] : [written];
37
+ if (rows.length === 0)
38
+ return unreadable('the schedule lists no row');
39
+ const said = isMap(data) ? data['description'] : undefined;
40
+ const description = typeof said === 'string' && said.trim() ? { description: said.trim() } : {};
41
+ const commands = [];
42
+ for (const row of rows) {
43
+ const command = parseRow(skill, row, dir);
44
+ if (typeof command === 'string')
45
+ return unreadable(rows.length > 1 ? `row ${commands.length + 1}: ${command}` : command);
46
+ if (commands.some(c => c.name === command.name))
47
+ return unreadable(`two rows are named ${command.name}`);
48
+ commands.push({ ...command, ...description });
49
+ }
50
+ return { commands, unreadable: [] };
51
+ }
52
+ /**
53
+ * One row: `every: <N><m|h|d|w|mo>`, `when: <shell line>`, `waits-for: <one line>`, `check-every: <N>s`, `agents: <N>`,
54
+ * `word: <the skill's argument>`, `each: true`. At least one of `every` and `when`, else nothing says when.
55
+ * `every: 0m` is refused rather than read as "always", which is the key being absent. A key the
56
+ * reader does not know is refused too: it is a typo, or a rule this tool would not follow.
57
+ * `waits-for` says what the check waits for, so a row without `when` has none. A row with `each`
58
+ * needs `when`, which names the runs.
59
+ * Answers the command, or why the row cannot be read.
60
+ */
61
+ function parseRow(skill, row, dir) {
62
+ if (!isMap(row))
63
+ return 'a row is a list of keys';
64
+ const unknown = Object.keys(row).find(key => !ROW_KEYS.includes(key));
65
+ if (unknown !== undefined)
66
+ return `unknown key ${unknown}`;
67
+ const { word, every, when, agents, agent, model, each } = row;
68
+ const waitsFor = row['waits-for'];
69
+ const checkEvery = row['check-every'];
70
+ if (word !== undefined && !(typeof word === 'string' && WORD.test(word)))
71
+ return 'word is one word of lower-case letters, digits and dashes';
72
+ const asEvery = every === undefined ? undefined : parseInterval(String(every));
73
+ if (every !== undefined && !asEvery)
74
+ return 'every is a number from 1 to 9999 and a unit, m, h, d, w or mo (15m, 6h, 7d, 2w, 1mo)';
75
+ if (when !== undefined && !(typeof when === 'string' && when.trim()))
76
+ return 'when is a shell command line';
77
+ if (waitsFor !== undefined && !(typeof waitsFor === 'string' && waitsFor.trim() && !waitsFor.trim().includes('\n')))
78
+ return 'waits-for is one line of text';
79
+ if (agents !== undefined && !isAgents(agents))
80
+ return `agents is a whole number from 1 to ${MAX_AGENTS}`;
81
+ if (agent !== undefined && !isAgent(agent))
82
+ return `agent is one of ${AGENTS.join(', ')}`;
83
+ if (model !== undefined && !(typeof model === 'string' && /^\S+$/.test(model)))
84
+ return 'model is one word, the id its coding agent knows the model by';
85
+ if (when === undefined && every === undefined)
86
+ return 'neither every nor when says when';
87
+ if (waitsFor !== undefined && when === undefined)
88
+ return 'waits-for says what when waits for, and there is no when';
89
+ if (checkEvery !== undefined && !CHECK_EVERY.test(String(checkEvery)))
90
+ return 'check-every is a number of seconds from 10 to 59 and s (15s)';
91
+ if (checkEvery !== undefined && when === undefined)
92
+ return 'check-every says how often when runs, and there is no when';
93
+ if (each !== undefined && each !== true)
94
+ return 'each is true, or left out';
95
+ if (each === true && when === undefined)
96
+ return 'a row with each needs when, the check that names the runs';
97
+ return {
98
+ name: word === undefined ? skill : `${skill} ${word}`,
99
+ ...(typeof when === 'string' ? { when: when.trim() } : {}),
100
+ ...(asEvery ? { every: asEvery } : {}),
101
+ ...(typeof waitsFor === 'string' ? { waitsFor: waitsFor.trim() } : {}),
102
+ ...(checkEvery !== undefined ? { checkEvery: { ms: Number(CHECK_EVERY.exec(String(checkEvery))[1]) * 1000, text: String(checkEvery) } } : {}),
103
+ cap: isAgents(agents) ? agents : DEFAULT_CAP,
104
+ dir,
105
+ dirs: [dir],
106
+ ...(isAgent(agent) ? { agent } : {}),
107
+ ...(typeof model === 'string' ? { model } : {}),
108
+ ...(each === true ? { each: true } : {}),
109
+ };
110
+ }
111
+ function isMap(value) {
112
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
113
+ }
114
+ /**
115
+ * The project's schedule: every skill's commands, the skills in name order, then the automations
116
+ * a person keeps on this machine alone, in name order. Skills are read from each folder a coding
117
+ * agent reads skills from, the folder of the agent that runs scheduled commands first; a skill in
118
+ * two folders is read once, from the first folder that holds its `SKILL.md`. A project with no
119
+ * skills folder and no automation of the person's schedules nothing.
120
+ *
121
+ * An automation kept on this machine is one file, written like a skill's: its front matter holds
122
+ * one row of schedule, and its text is what a run is handed. One that cannot be listed is named
123
+ * with why. One whose name a skill of the project has too is not listed, and neither are that
124
+ * skill's commands: a run is counted by its command's name and this machine's picks are kept
125
+ * under it, so the two cannot be told apart.
126
+ */
127
+ export async function readSchedule(repo) {
128
+ const files = new Map();
129
+ for (const dir of [RUN_SKILLS_DIR, ...HARNESS_SKILL_DIRS.filter(d => d !== RUN_SKILLS_DIR)]) {
130
+ const entries = await readdir(join(repo, dir)).catch(() => []);
131
+ for (const skill of entries) {
132
+ if (!SKILL.test(skill))
133
+ continue;
134
+ const known = files.get(skill);
135
+ // A skill in two folders is read once, from the first; the other folder is only noted, by whether the skill's file is there.
136
+ const md = known ? ((await isFile(join(repo, dir, skill, SKILL_FILE))) ? known.md : undefined) : await readFile(join(repo, dir, skill, SKILL_FILE), 'utf8').catch(() => undefined);
137
+ if (md === undefined)
138
+ continue;
139
+ if (known)
140
+ known.dirs.push(dir);
141
+ else
142
+ files.set(skill, { md, dir, dirs: [dir] });
143
+ }
144
+ }
145
+ const schedule = { commands: [], unreadable: [] };
146
+ for (const skill of [...files.keys()].sort()) {
147
+ const { md, dir, dirs } = files.get(skill);
148
+ const read = skillSchedule(skill, md, dir);
149
+ // A shared automation is a skill the tool wrote: its file reads as the tool writes one, alone in its folder where the tool saves them, a folder that is no link.
150
+ const editable = automationOf(skill, md) !== undefined && (await standsAlone(repo, skill));
151
+ schedule.commands.push(...read.commands.map(command => ({ ...command, dirs, ...(editable ? { editable: true } : {}) })));
152
+ schedule.unreadable.push(...read.unreadable);
153
+ }
154
+ const kept = (await readdir(join(repo, OWN_AUTOMATIONS_DIR)).catch(() => [])).filter(file => file.endsWith('.md')).sort();
155
+ for (const file of kept) {
156
+ const name = file.slice(0, -3);
157
+ const unlisted = (reason) => void schedule.unreadable.push({ skill: name, reason, own: true });
158
+ if (!SKILL.test(name)) {
159
+ unlisted('its file is named with something other than lower-case letters, digits and dashes');
160
+ continue;
161
+ }
162
+ const md = await readFile(join(repo, OWN_AUTOMATIONS_DIR, file), 'utf8').catch(() => undefined);
163
+ if (md === undefined) {
164
+ unlisted('its file cannot be read');
165
+ continue;
166
+ }
167
+ if (files.has(name)) {
168
+ // One name, two things: a run is counted by its command's name, and this machine's picks are
169
+ // kept under it. Neither is listed, and the picks are taken back, so that the skill's command
170
+ // does not start on the switch the automation was given, now or once the file is gone.
171
+ const before = schedule.commands.length;
172
+ schedule.commands = schedule.commands.filter(c => c.name.split(' ')[0] !== name);
173
+ schedule.clashes = [...(schedule.clashes ?? []), name];
174
+ unlisted(`a skill of the project has this name too${schedule.commands.length < before ? ', so neither is listed' : ''}: rename or remove ${OWN_AUTOMATIONS_DIR}/${file}, then switch on what you want`);
175
+ continue;
176
+ }
177
+ const read = skillSchedule(name, md, OWN_AUTOMATIONS_DIR);
178
+ const text = skillText(md);
179
+ if (read.unreadable.length > 0)
180
+ unlisted(read.unreadable[0].reason);
181
+ else if (read.commands.length === 0)
182
+ unlisted('it has no schedule');
183
+ else if (read.commands.length !== 1 || read.commands[0].name !== name)
184
+ unlisted('it has one row, with no word');
185
+ else if (text === '')
186
+ unlisted('it has no text after its front matter');
187
+ else if (text.length > OWN_TEXT_MAX)
188
+ unlisted(`its text is ${text.length} characters, and ${OWN_TEXT_MAX} is the most`);
189
+ else
190
+ schedule.commands.push({ ...read.commands[0], text, ...(automationOf(name, md) !== undefined && (await isPlainFile(join(repo, OWN_AUTOMATIONS_DIR, file))) ? { editable: true } : {}) });
191
+ }
192
+ return schedule;
193
+ }
194
+ /** Whether a path is a file, a link to one included: a skill linked into an agent's folder is a skill there. */
195
+ function isFile(path) {
196
+ return stat(path).then(found => found.isFile(), () => false);
197
+ }
198
+ /**
199
+ * The text of a skill file after its front matter, without its outer blank lines and without NUL
200
+ * characters: what an agent is told. The front matter ends where the schedule's reader ends it,
201
+ * at the first line that opens with three dashes, whatever follows them on that line.
202
+ */
203
+ export function skillText(md) {
204
+ const front = /^---\r?\n[\s\S]*?\r?\n---[^\n]*(\n|$)/.exec(md);
205
+ return (front ? md.slice(front[0].length) : md).replaceAll('\0', '').trim();
206
+ }
207
+ /** The file a command's skill is, from the repository root: the skills folder it was read from, or the one given, the command's first word, the skill's file (`.claude/skills/triage/SKILL.md` for `triage quick`). */
208
+ export function skillFile(command, dir = command.dir) {
209
+ return `${dir}/${command.name.split(' ')[0]}/${SKILL_FILE}`;
210
+ }
211
+ /**
212
+ * The coding agents that can run a command: those whose own skills folder holds its skill. An
213
+ * automation kept on this machine is no skill: a run is handed its text, so any agent can.
214
+ */
215
+ export function ableAgents(command) {
216
+ return AGENTS.filter(agent => command.text !== undefined || command.dirs.includes(AGENT_SKILLS_DIR[agent]));
217
+ }
218
+ /**
219
+ * The coding agent a command is made for: the one its skill names, else Claude Code when it can
220
+ * run the command, else the one that can. Where a run is on when no person picked another, and
221
+ * the agent the skill's own model goes with.
222
+ */
223
+ export function homeAgent(command) {
224
+ if (command.agent !== undefined)
225
+ return command.agent;
226
+ const able = ableAgents(command);
227
+ return able.includes(DEFAULT_AGENT) ? DEFAULT_AGENT : (able[0] ?? DEFAULT_AGENT);
228
+ }
229
+ /**
230
+ * The prompt a command runs with: its slash command, which the agent's harness expands, the word
231
+ * after it handed to the skill. An automation kept on this machine is no command in a run's
232
+ * checkout: its prompt is its name alone, with no slash, and its text is handed over with it
233
+ * ({@link ownAttached}). Either way the prompt names the command, which is what its runs are
234
+ * counted by, whatever its text says and however the text changes.
235
+ */
236
+ export function commandPrompt(command) {
237
+ return command.text === undefined ? `/${command.name}` : command.name;
238
+ }
239
+ /**
240
+ * What a run of an automation kept on this machine is handed with its prompt: the automation's
241
+ * text, and after it, when a check started the run, the sentence that says so and what the check
242
+ * printed.
243
+ */
244
+ export function ownAttached(text, found) {
245
+ return found ? `${text}\n\n${FOUND_OPENING_OWN}\n${foundOutput(found.stdout, found.lastRun)}` : text;
246
+ }
247
+ /** What is said before a check's output to a run of an automation kept on this machine: its work is said by the text above, not by a command. */
248
+ export const FOUND_OPENING_OWN = 'The scheduler starts this when its check prints something, and this time the check printed what is below. It says why this run started; what the work is, the text above says.';
249
+ /**
250
+ * The time a check is given as `$LAST_RUN`: an ISO time in UTC to the whole second
251
+ * (`2026-10-09T10:00:00Z`), the shape `gh --search`, a GitHub `since` and `jq`'s `fromdate` all
252
+ * read. The fraction of a second is dropped, never rounded up: a thing that came in that same
253
+ * second is found twice rather than never.
254
+ */
255
+ export function lastRunValue(iso) {
256
+ return new Date(Math.floor(Date.parse(iso) / 1000) * 1000).toISOString().replace('.000Z', 'Z');
257
+ }
258
+ /**
259
+ * What a run is handed with its prompt when a check started it: {@link FOUND_OPENING}, then what
260
+ * the check printed, so an agent whose command asks for the new thing has it. The command's prompt
261
+ * stays the command alone: it is what the run is counted under.
262
+ *
263
+ * A NUL character is dropped: the text travels as a command-line argument, which can hold none.
264
+ * Output past {@link FOUND_MAX} characters is cut at the end of the last whole line that fits,
265
+ * mid-line when its first line alone is longer. A last line then says how much was printed and
266
+ * the time the check was given: the next check asks from this run's start, so what was cut is
267
+ * found again only by an agent that asks from that earlier time itself.
268
+ */
269
+ export function checkFound(stdout, lastRun) {
270
+ return `${FOUND_OPENING}\n${foundOutput(stdout, lastRun)}`;
271
+ }
272
+ /** What a check printed, as a run is handed it and as a person trying the check is shown it: without NUL characters and outer blank lines, cut when it is long, the cut said in a last line. */
273
+ export function foundOutput(stdout, lastRun) {
274
+ const printed = stdout.replaceAll('\0', '').trim();
275
+ if (printed.length <= FOUND_MAX)
276
+ return printed;
277
+ const fits = printed.slice(0, FOUND_MAX);
278
+ // A line that ends exactly at the limit is whole: the line end is the next character.
279
+ const lineEnd = printed[FOUND_MAX] === '\n' ? FOUND_MAX : fits.lastIndexOf('\n');
280
+ const shown = lineEnd > 0 ? fits.slice(0, lineEnd) : fits;
281
+ return `${shown}\n(cut: the check printed ${printed.length} characters, these are the first ${shown.length}; it asked what is new since ${lastRun})`;
282
+ }
283
+ /**
284
+ * What is said before a check's output. The agent has never seen the check, and a check may print
285
+ * only a sign that there is work (one issue of five that changed): so the words say what the
286
+ * output is, and that the command, not the output, says what the work is.
287
+ */
288
+ export const FOUND_OPENING = 'The scheduler starts this command when its check prints something, and this time the check printed what is below. It says why this run started; what the work is, the command says.';
289
+ /**
290
+ * What a row with `each` printed: each run it names with its words, a run named twice once with
291
+ * its words joined, and each new run it asks for, what it is for named twice once, the same way.
292
+ * Nothing for output that says no work ({@link isDue}); a reason for output that is no such list.
293
+ */
294
+ export function eachItems(stdout) {
295
+ if (!isDue(stdout))
296
+ return { resumes: [], starts: [] };
297
+ let value;
298
+ try {
299
+ value = JSON.parse(stdout);
300
+ }
301
+ catch {
302
+ return 'the check printed no JSON';
303
+ }
304
+ if (!Array.isArray(value))
305
+ return 'the check printed no list';
306
+ const runs = new Map();
307
+ const starts = new Map();
308
+ for (const item of value) {
309
+ const field = (key) => (isMap(item) && typeof item[key] === 'string' && item[key].trim() ? item[key].trim() : undefined);
310
+ const [text, run, what, branch] = [field('text'), field('run'), field('for'), field('branch')];
311
+ const named = isMap(item) && item['branch'] !== undefined;
312
+ if (text === undefined || (run === undefined) === (what === undefined) || (named && (branch === undefined || run !== undefined)))
313
+ return 'an item of the list is no { "run": "<id>", "text": "…" } and no { "for": "<what>", "text": "…" }, with "branch" only beside "for"';
314
+ if (run !== undefined)
315
+ runs.set(run, [...(runs.get(run) ?? []), text]);
316
+ else {
317
+ // What a run is for named twice: its words joined, on the first branch named.
318
+ const before = starts.get(what);
319
+ const on = before?.branch ?? branch;
320
+ starts.set(what, { texts: [...(before?.texts ?? []), text], ...(on !== undefined ? { branch: on } : {}) });
321
+ }
322
+ }
323
+ return {
324
+ resumes: [...runs].map(([run, texts]) => ({ run, text: texts.join('\n\n') })),
325
+ starts: [...starts].map(([what, { texts, branch }]) => ({ for: what, text: texts.join('\n\n'), ...(branch !== undefined ? { branch } : {}) })),
326
+ };
327
+ }
328
+ /**
329
+ * What a run a row resumes is told: the command, as a run it starts is sent, then
330
+ * {@link RESUMED_OPENING} and the check's words for that run; for an automation kept on this
331
+ * machine, its name, its text, then the opening and the words. One text: a resume takes no
332
+ * attached text.
333
+ */
334
+ export function resumeText(command, words, lastRun) {
335
+ const found = foundOutput(words, lastRun);
336
+ const said = command.text !== undefined ? `${command.text}\n\n${RESUMED_OPENING_OWN}\n${found}` : `${RESUMED_OPENING}\n${found}`;
337
+ return `${commandPrompt(command)}\n\n${said}`;
338
+ }
339
+ /** What is said before the check's words to a run a row resumes: the run has never seen the check, and the command, not the words, says what the work is. */
340
+ export const RESUMED_OPENING = 'The scheduler continues this run with this command when its check names the run, and this time the check said what is below for it. It says why the run continues; what the work is, the command says.';
341
+ /** The same for a run an automation kept on this machine resumes: its work is said by the text above. */
342
+ export const RESUMED_OPENING_OWN = 'The scheduler continues this run with this when its check names the run, and this time the check said what is below for it. It says why the run continues; what the work is, the text above says.';
343
+ /**
344
+ * The command a run's prompt is counted under, so a run a person started counts against that
345
+ * command's cap and interval like a scheduled one; nothing for a prompt that names none.
346
+ *
347
+ * A prompt that opens with a slash names a skill's command: the scheduled command whose name it
348
+ * is (`/triage quick` → `triage quick`), else the one its first word is (`/work-queue now` →
349
+ * `work-queue`). A prompt with no slash names no skill's command: it is a run of an automation
350
+ * kept on this machine when it is that automation's name and nothing else (`answer-comments`).
351
+ * The two never cross: a skill's command typed by hand is no run of an automation of that name,
352
+ * and an automation's run is none of a skill's.
353
+ */
354
+ export function promptCommand(prompt, schedule) {
355
+ const typed = prompt.trim();
356
+ if (!typed.startsWith('/'))
357
+ return schedule.commands.some(c => c.text !== undefined && c.name === typed) ? typed : undefined;
358
+ const skills = schedule.commands.filter(c => c.text === undefined);
359
+ const named = typed.slice(1);
360
+ if (skills.some(c => c.name === named))
361
+ return named;
362
+ const first = named.split(/\s+/)[0];
363
+ return skills.some(c => c.name === first) ? first : undefined;
364
+ }
365
+ /**
366
+ * Whether a check's output says the command is due (#1774): the output parsed as JSON is
367
+ * something other than empty — `[]`, `{}`, `null`, `false`, `""`, `0` and no output at all are not
368
+ * due. Every skill command prints JSON on stdout, so a check like `npx @openagt/skill-queue@0.1` needs no piping.
369
+ * Output that is not JSON counts by its text: anything non-blank is due.
370
+ */
371
+ export function isDue(stdout) {
372
+ const text = stdout.trim();
373
+ if (!text)
374
+ return false;
375
+ let value;
376
+ try {
377
+ value = JSON.parse(text);
378
+ }
379
+ catch {
380
+ return true;
381
+ }
382
+ if (value === null || value === false || value === '' || value === 0)
383
+ return false;
384
+ if (Array.isArray(value))
385
+ return value.length > 0;
386
+ if (typeof value === 'object')
387
+ return Object.keys(value).length > 0;
388
+ return true;
389
+ }
390
+ //# sourceMappingURL=schedule.js.map