@openagt/skill-scheduler 0.0.0-stage → 0.1.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.
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 +416 -0
  19. package/dist/cli.js.map +1 -0
  20. package/dist/dashboard/dashboard.css +3 -0
  21. package/dist/dashboard/dashboard.js +2323 -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 +83 -0
  35. package/dist/names.d.ts.map +1 -0
  36. package/dist/names.js +89 -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 +241 -0
  51. package/dist/schedule.d.ts.map +1 -0
  52. package/dist/schedule.js +379 -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 +119 -0
  71. package/dist/tick.d.ts.map +1 -0
  72. package/dist/tick.js +311 -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,241 @@
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 `resumes: true` starts no run: it continues runs that ended. Its check prints which,
44
+ * each with its words: `[{ "run": "<id>", "text": "…" }]`, and each run named is resumed with the
45
+ * command and its words, on the coding agent, model and publish level its own record names; so
46
+ * such a row names no `agent` and no `model`. Only a run this machine started is resumed: only
47
+ * this machine holds its conversation. What the words are about is the check's and the skill's:
48
+ * the scheduler knows nothing of it.
49
+ *
50
+ * Whether a command runs, and how far its runs publish, is not the skill's to say: each person
51
+ * sets both on their own machine, in the tool's state. The pace and the number of agents are
52
+ * where the skill starts: each person may set their own there too.
53
+ *
54
+ * A `schedule` the reader cannot read is skipped and named, so a typo stands down one skill's
55
+ * commands and says so rather than silently doing nothing.
56
+ *
57
+ * The schedule is read from every folder a coding agent reads skills from, so a project's skills
58
+ * are all listed; a command whose skill is not in the folder of the agent that runs scheduled
59
+ * commands is listed and never started, and the tick says so.
60
+ */
61
+ /** One command as its skill schedules it. */
62
+ export interface ScheduledCommand {
63
+ /** 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`). */
64
+ name: string;
65
+ /** The check, a shell command line; absent when the row paces by time alone. */
66
+ when?: string;
67
+ /** How often at most, where no person set another pace: the least time since the command's last recorded start. */
68
+ every?: Interval;
69
+ /** What the check waits for, in one plain line for a person. */
70
+ waitsFor?: string;
71
+ /** How often the check may run between the minute's ticks, when the row says: its check is cheap and its answer waited for. */
72
+ checkEvery?: {
73
+ ms: number;
74
+ text: string;
75
+ };
76
+ /** Runs in flight at once, across every machine, where no person set another number. */
77
+ cap: number;
78
+ /** The skills folder the skill was read from (`.claude/skills`). */
79
+ dir: string;
80
+ /** Every skills folder that holds the skill: a coding agent can run the command only when its own folder is among them. */
81
+ dirs: string[];
82
+ /** The coding agent the skill is made for, when it names one: a run is on it where no person picked another. */
83
+ agent?: AgentName;
84
+ /** 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. */
85
+ model?: string;
86
+ /** What the skill does, in its own words: the `description` of its front matter, when it has one. */
87
+ description?: string;
88
+ /** Whether the row resumes the runs its check names, and starts none: each goes on with its own coding agent, model and publish level. */
89
+ resumes?: true;
90
+ /**
91
+ * The text of a command that is no skill: an automation a person keeps on this machine alone.
92
+ * Its text is in no checkout, so a run of it is handed the text with its prompt, which is the
93
+ * automation's name. A skill's command has none: its run is sent the command
94
+ * (`/update-tickets`), and the coding agent reads the skill's text from the checkout.
95
+ */
96
+ text?: string;
97
+ /**
98
+ * Whether the command is a person's own automation whose file reads as the tool writes one, and
99
+ * is no link: the tool can show it, save it again and remove it. Neither a skill a person or a
100
+ * package wrote, nor an automation changed by hand into something the tool does not write. The
101
+ * one place that says so: the command line asks the schedule.
102
+ */
103
+ editable?: true;
104
+ }
105
+ /** The schedule as read. */
106
+ export interface Schedule {
107
+ commands: ScheduledCommand[];
108
+ /** The skills whose `schedule` could not be read, each with why; and, marked `own`, the automations kept on this machine that are not listed. */
109
+ unreadable: {
110
+ skill: string;
111
+ reason: string;
112
+ own?: true;
113
+ }[];
114
+ /** 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. */
115
+ clashes?: string[];
116
+ }
117
+ /**
118
+ * The commands a skill schedules, out of its `SKILL.md` in the skills folder `dir`: none when the
119
+ * file has no front matter or the front matter no `schedule`. One row or a list of rows; any row
120
+ * that cannot be read makes the whole `schedule` unreadable, and the reason names the row. Pure.
121
+ */
122
+ export declare function skillSchedule(skill: string, md: string, dir: string): Schedule;
123
+ /**
124
+ * The project's schedule: every skill's commands, the skills in name order, then the automations
125
+ * a person keeps on this machine alone, in name order. Skills are read from each folder a coding
126
+ * agent reads skills from, the folder of the agent that runs scheduled commands first; a skill in
127
+ * two folders is read once, from the first folder that holds its `SKILL.md`. A project with no
128
+ * skills folder and no automation of the person's schedules nothing.
129
+ *
130
+ * An automation kept on this machine is one file, written like a skill's: its front matter holds
131
+ * one row of schedule, and its text is what a run is handed. One that cannot be listed is named
132
+ * with why. One whose name a skill of the project has too is not listed, and neither are that
133
+ * skill's commands: a run is counted by its command's name and this machine's picks are kept
134
+ * under it, so the two cannot be told apart.
135
+ */
136
+ export declare function readSchedule(repo: string): Promise<Schedule>;
137
+ /**
138
+ * The text of a skill file after its front matter, without its outer blank lines and without NUL
139
+ * characters: what an agent is told. The front matter ends where the schedule's reader ends it,
140
+ * at the first line that opens with three dashes, whatever follows them on that line.
141
+ */
142
+ export declare function skillText(md: string): string;
143
+ /** 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`). */
144
+ export declare function skillFile(command: Pick<ScheduledCommand, 'name' | 'dir'>, dir?: string): string;
145
+ /**
146
+ * The coding agents that can run a command: those whose own skills folder holds its skill. An
147
+ * automation kept on this machine is no skill: a run is handed its text, so any agent can.
148
+ */
149
+ export declare function ableAgents(command: Pick<ScheduledCommand, 'dirs' | 'text'>): AgentName[];
150
+ /**
151
+ * The coding agent a command is made for: the one its skill names, else Claude Code when it can
152
+ * run the command, else the one that can. Where a run is on when no person picked another, and
153
+ * the agent the skill's own model goes with.
154
+ */
155
+ export declare function homeAgent(command: Pick<ScheduledCommand, 'agent' | 'dirs' | 'text'>): AgentName;
156
+ /**
157
+ * The prompt a command runs with: its slash command, which the agent's harness expands, the word
158
+ * after it handed to the skill. An automation kept on this machine is no command in a run's
159
+ * checkout: its prompt is its name alone, with no slash, and its text is handed over with it
160
+ * ({@link ownAttached}). Either way the prompt names the command, which is what its runs are
161
+ * counted by, whatever its text says and however the text changes.
162
+ */
163
+ export declare function commandPrompt(command: Pick<ScheduledCommand, 'name' | 'text'>): string;
164
+ /**
165
+ * What a run of an automation kept on this machine is handed with its prompt: the automation's
166
+ * text, and after it, when a check started the run, the sentence that says so and what the check
167
+ * printed.
168
+ */
169
+ export declare function ownAttached(text: string, found?: {
170
+ stdout: string;
171
+ lastRun: string;
172
+ }): string;
173
+ /** 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. */
174
+ 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.";
175
+ /**
176
+ * The time a check is given as `$LAST_RUN`: an ISO time in UTC to the whole second
177
+ * (`2026-10-09T10:00:00Z`), the shape `gh --search`, a GitHub `since` and `jq`'s `fromdate` all
178
+ * read. The fraction of a second is dropped, never rounded up: a thing that came in that same
179
+ * second is found twice rather than never.
180
+ */
181
+ export declare function lastRunValue(iso: string): string;
182
+ /**
183
+ * What a run is handed with its prompt when a check started it: {@link FOUND_OPENING}, then what
184
+ * the check printed, so an agent whose command asks for the new thing has it. The command's prompt
185
+ * stays the command alone: it is what the run is counted under.
186
+ *
187
+ * A NUL character is dropped: the text travels as a command-line argument, which can hold none.
188
+ * Output past {@link FOUND_MAX} characters is cut at the end of the last whole line that fits,
189
+ * mid-line when its first line alone is longer. A last line then says how much was printed and
190
+ * the time the check was given: the next check asks from this run's start, so what was cut is
191
+ * found again only by an agent that asks from that earlier time itself.
192
+ */
193
+ export declare function checkFound(stdout: string, lastRun: string): string;
194
+ /** 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. */
195
+ export declare function foundOutput(stdout: string, lastRun: string): string;
196
+ /**
197
+ * What is said before a check's output. The agent has never seen the check, and a check may print
198
+ * only a sign that there is work (one issue of five that changed): so the words say what the
199
+ * output is, and that the command, not the output, says what the work is.
200
+ */
201
+ 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.";
202
+ /**
203
+ * What a row that resumes printed: each run it names with its words, a run named twice once with
204
+ * its words joined. Nothing for output that says no work ({@link isDue}); a reason for output that
205
+ * is not `[{ "run": "<id>", "text": "…" }]`.
206
+ */
207
+ export declare function runsToResume(stdout: string): {
208
+ run: string;
209
+ text: string;
210
+ }[] | string;
211
+ /**
212
+ * What a run a row resumes is told: the command, as a run it starts is sent, then
213
+ * {@link RESUMED_OPENING} and the check's words for that run; for an automation kept on this
214
+ * machine, its name, its text, then the opening and the words. One text: a resume takes no
215
+ * attached text.
216
+ */
217
+ export declare function resumeText(command: Pick<ScheduledCommand, 'name' | 'text'>, words: string, lastRun: string): string;
218
+ /** 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. */
219
+ 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.";
220
+ /** The same for a run an automation kept on this machine resumes: its work is said by the text above. */
221
+ 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.";
222
+ /**
223
+ * The command a run's prompt is counted under, so a run a person started counts against that
224
+ * command's cap and interval like a scheduled one; nothing for a prompt that names none.
225
+ *
226
+ * A prompt that opens with a slash names a skill's command: the scheduled command whose name it
227
+ * is (`/triage quick` → `triage quick`), else the one its first word is (`/work-queue now` →
228
+ * `work-queue`). A prompt with no slash names no skill's command: it is a run of an automation
229
+ * kept on this machine when it is that automation's name and nothing else (`answer-comments`).
230
+ * The two never cross: a skill's command typed by hand is no run of an automation of that name,
231
+ * and an automation's run is none of a skill's.
232
+ */
233
+ export declare function promptCommand(prompt: string, schedule: Schedule): string | undefined;
234
+ /**
235
+ * Whether a check's output says the command is due (#1774): the output parsed as JSON is
236
+ * something other than empty — `[]`, `{}`, `null`, `false`, `""`, `0` and no output at all are not
237
+ * due. Every skill command prints JSON on stdout, so a check like `npx @openagt/skill-queue@0.1` needs no piping.
238
+ * Output that is not JSON counts by its text: anything non-blank is due.
239
+ */
240
+ export declare function isDue(stdout: string): boolean;
241
+ //# sourceMappingURL=schedule.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schedule.d.ts","sourceRoot":"","sources":["../src/schedule.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,379 @@
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', 'resumes'];
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>`, `resumes: 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 that resumes
58
+ * needs `when`, which names the runs, and takes no `agent` and no `model`: each run names its own.
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, resumes } = 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 (resumes !== undefined && resumes !== true)
94
+ return 'resumes is true, or left out';
95
+ if (resumes === true && when === undefined)
96
+ return 'a row that resumes needs when, the check that names the runs to resume';
97
+ if (resumes === true && (agent !== undefined || model !== undefined))
98
+ return `a row that resumes takes no ${agent !== undefined ? 'agent' : 'model'}: each run goes on with the coding agent and model its record names`;
99
+ return {
100
+ name: word === undefined ? skill : `${skill} ${word}`,
101
+ ...(typeof when === 'string' ? { when: when.trim() } : {}),
102
+ ...(asEvery ? { every: asEvery } : {}),
103
+ ...(typeof waitsFor === 'string' ? { waitsFor: waitsFor.trim() } : {}),
104
+ ...(checkEvery !== undefined ? { checkEvery: { ms: Number(CHECK_EVERY.exec(String(checkEvery))[1]) * 1000, text: String(checkEvery) } } : {}),
105
+ cap: isAgents(agents) ? agents : DEFAULT_CAP,
106
+ dir,
107
+ dirs: [dir],
108
+ ...(isAgent(agent) ? { agent } : {}),
109
+ ...(typeof model === 'string' ? { model } : {}),
110
+ ...(resumes === true ? { resumes: true } : {}),
111
+ };
112
+ }
113
+ function isMap(value) {
114
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
115
+ }
116
+ /**
117
+ * The project's schedule: every skill's commands, the skills in name order, then the automations
118
+ * a person keeps on this machine alone, in name order. Skills are read from each folder a coding
119
+ * agent reads skills from, the folder of the agent that runs scheduled commands first; a skill in
120
+ * two folders is read once, from the first folder that holds its `SKILL.md`. A project with no
121
+ * skills folder and no automation of the person's schedules nothing.
122
+ *
123
+ * An automation kept on this machine is one file, written like a skill's: its front matter holds
124
+ * one row of schedule, and its text is what a run is handed. One that cannot be listed is named
125
+ * with why. One whose name a skill of the project has too is not listed, and neither are that
126
+ * skill's commands: a run is counted by its command's name and this machine's picks are kept
127
+ * under it, so the two cannot be told apart.
128
+ */
129
+ export async function readSchedule(repo) {
130
+ const files = new Map();
131
+ for (const dir of [RUN_SKILLS_DIR, ...HARNESS_SKILL_DIRS.filter(d => d !== RUN_SKILLS_DIR)]) {
132
+ const entries = await readdir(join(repo, dir)).catch(() => []);
133
+ for (const skill of entries) {
134
+ if (!SKILL.test(skill))
135
+ continue;
136
+ const known = files.get(skill);
137
+ // 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.
138
+ 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);
139
+ if (md === undefined)
140
+ continue;
141
+ if (known)
142
+ known.dirs.push(dir);
143
+ else
144
+ files.set(skill, { md, dir, dirs: [dir] });
145
+ }
146
+ }
147
+ const schedule = { commands: [], unreadable: [] };
148
+ for (const skill of [...files.keys()].sort()) {
149
+ const { md, dir, dirs } = files.get(skill);
150
+ const read = skillSchedule(skill, md, dir);
151
+ // 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.
152
+ const editable = automationOf(skill, md) !== undefined && (await standsAlone(repo, skill));
153
+ schedule.commands.push(...read.commands.map(command => ({ ...command, dirs, ...(editable ? { editable: true } : {}) })));
154
+ schedule.unreadable.push(...read.unreadable);
155
+ }
156
+ const kept = (await readdir(join(repo, OWN_AUTOMATIONS_DIR)).catch(() => [])).filter(file => file.endsWith('.md')).sort();
157
+ for (const file of kept) {
158
+ const name = file.slice(0, -3);
159
+ const unlisted = (reason) => void schedule.unreadable.push({ skill: name, reason, own: true });
160
+ if (!SKILL.test(name)) {
161
+ unlisted('its file is named with something other than lower-case letters, digits and dashes');
162
+ continue;
163
+ }
164
+ const md = await readFile(join(repo, OWN_AUTOMATIONS_DIR, file), 'utf8').catch(() => undefined);
165
+ if (md === undefined) {
166
+ unlisted('its file cannot be read');
167
+ continue;
168
+ }
169
+ if (files.has(name)) {
170
+ // One name, two things: a run is counted by its command's name, and this machine's picks are
171
+ // kept under it. Neither is listed, and the picks are taken back, so that the skill's command
172
+ // does not start on the switch the automation was given, now or once the file is gone.
173
+ const before = schedule.commands.length;
174
+ schedule.commands = schedule.commands.filter(c => c.name.split(' ')[0] !== name);
175
+ schedule.clashes = [...(schedule.clashes ?? []), name];
176
+ 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`);
177
+ continue;
178
+ }
179
+ const read = skillSchedule(name, md, OWN_AUTOMATIONS_DIR);
180
+ const text = skillText(md);
181
+ if (read.unreadable.length > 0)
182
+ unlisted(read.unreadable[0].reason);
183
+ else if (read.commands.length === 0)
184
+ unlisted('it has no schedule');
185
+ else if (read.commands.length !== 1 || read.commands[0].name !== name)
186
+ unlisted('it has one row, with no word');
187
+ else if (text === '')
188
+ unlisted('it has no text after its front matter');
189
+ else if (text.length > OWN_TEXT_MAX)
190
+ unlisted(`its text is ${text.length} characters, and ${OWN_TEXT_MAX} is the most`);
191
+ else
192
+ schedule.commands.push({ ...read.commands[0], text, ...(automationOf(name, md) !== undefined && (await isPlainFile(join(repo, OWN_AUTOMATIONS_DIR, file))) ? { editable: true } : {}) });
193
+ }
194
+ return schedule;
195
+ }
196
+ /** Whether a path is a file, a link to one included: a skill linked into an agent's folder is a skill there. */
197
+ function isFile(path) {
198
+ return stat(path).then(found => found.isFile(), () => false);
199
+ }
200
+ /**
201
+ * The text of a skill file after its front matter, without its outer blank lines and without NUL
202
+ * characters: what an agent is told. The front matter ends where the schedule's reader ends it,
203
+ * at the first line that opens with three dashes, whatever follows them on that line.
204
+ */
205
+ export function skillText(md) {
206
+ const front = /^---\r?\n[\s\S]*?\r?\n---[^\n]*(\n|$)/.exec(md);
207
+ return (front ? md.slice(front[0].length) : md).replaceAll('\0', '').trim();
208
+ }
209
+ /** 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`). */
210
+ export function skillFile(command, dir = command.dir) {
211
+ return `${dir}/${command.name.split(' ')[0]}/${SKILL_FILE}`;
212
+ }
213
+ /**
214
+ * The coding agents that can run a command: those whose own skills folder holds its skill. An
215
+ * automation kept on this machine is no skill: a run is handed its text, so any agent can.
216
+ */
217
+ export function ableAgents(command) {
218
+ return AGENTS.filter(agent => command.text !== undefined || command.dirs.includes(AGENT_SKILLS_DIR[agent]));
219
+ }
220
+ /**
221
+ * The coding agent a command is made for: the one its skill names, else Claude Code when it can
222
+ * run the command, else the one that can. Where a run is on when no person picked another, and
223
+ * the agent the skill's own model goes with.
224
+ */
225
+ export function homeAgent(command) {
226
+ if (command.agent !== undefined)
227
+ return command.agent;
228
+ const able = ableAgents(command);
229
+ return able.includes(DEFAULT_AGENT) ? DEFAULT_AGENT : (able[0] ?? DEFAULT_AGENT);
230
+ }
231
+ /**
232
+ * The prompt a command runs with: its slash command, which the agent's harness expands, the word
233
+ * after it handed to the skill. An automation kept on this machine is no command in a run's
234
+ * checkout: its prompt is its name alone, with no slash, and its text is handed over with it
235
+ * ({@link ownAttached}). Either way the prompt names the command, which is what its runs are
236
+ * counted by, whatever its text says and however the text changes.
237
+ */
238
+ export function commandPrompt(command) {
239
+ return command.text === undefined ? `/${command.name}` : command.name;
240
+ }
241
+ /**
242
+ * What a run of an automation kept on this machine is handed with its prompt: the automation's
243
+ * text, and after it, when a check started the run, the sentence that says so and what the check
244
+ * printed.
245
+ */
246
+ export function ownAttached(text, found) {
247
+ return found ? `${text}\n\n${FOUND_OPENING_OWN}\n${foundOutput(found.stdout, found.lastRun)}` : text;
248
+ }
249
+ /** 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. */
250
+ 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.';
251
+ /**
252
+ * The time a check is given as `$LAST_RUN`: an ISO time in UTC to the whole second
253
+ * (`2026-10-09T10:00:00Z`), the shape `gh --search`, a GitHub `since` and `jq`'s `fromdate` all
254
+ * read. The fraction of a second is dropped, never rounded up: a thing that came in that same
255
+ * second is found twice rather than never.
256
+ */
257
+ export function lastRunValue(iso) {
258
+ return new Date(Math.floor(Date.parse(iso) / 1000) * 1000).toISOString().replace('.000Z', 'Z');
259
+ }
260
+ /**
261
+ * What a run is handed with its prompt when a check started it: {@link FOUND_OPENING}, then what
262
+ * the check printed, so an agent whose command asks for the new thing has it. The command's prompt
263
+ * stays the command alone: it is what the run is counted under.
264
+ *
265
+ * A NUL character is dropped: the text travels as a command-line argument, which can hold none.
266
+ * Output past {@link FOUND_MAX} characters is cut at the end of the last whole line that fits,
267
+ * mid-line when its first line alone is longer. A last line then says how much was printed and
268
+ * the time the check was given: the next check asks from this run's start, so what was cut is
269
+ * found again only by an agent that asks from that earlier time itself.
270
+ */
271
+ export function checkFound(stdout, lastRun) {
272
+ return `${FOUND_OPENING}\n${foundOutput(stdout, lastRun)}`;
273
+ }
274
+ /** 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. */
275
+ export function foundOutput(stdout, lastRun) {
276
+ const printed = stdout.replaceAll('\0', '').trim();
277
+ if (printed.length <= FOUND_MAX)
278
+ return printed;
279
+ const fits = printed.slice(0, FOUND_MAX);
280
+ // A line that ends exactly at the limit is whole: the line end is the next character.
281
+ const lineEnd = printed[FOUND_MAX] === '\n' ? FOUND_MAX : fits.lastIndexOf('\n');
282
+ const shown = lineEnd > 0 ? fits.slice(0, lineEnd) : fits;
283
+ return `${shown}\n(cut: the check printed ${printed.length} characters, these are the first ${shown.length}; it asked what is new since ${lastRun})`;
284
+ }
285
+ /**
286
+ * What is said before a check's output. The agent has never seen the check, and a check may print
287
+ * only a sign that there is work (one issue of five that changed): so the words say what the
288
+ * output is, and that the command, not the output, says what the work is.
289
+ */
290
+ 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.';
291
+ /**
292
+ * What a row that resumes printed: each run it names with its words, a run named twice once with
293
+ * its words joined. Nothing for output that says no work ({@link isDue}); a reason for output that
294
+ * is not `[{ "run": "<id>", "text": "…" }]`.
295
+ */
296
+ export function runsToResume(stdout) {
297
+ if (!isDue(stdout))
298
+ return [];
299
+ let value;
300
+ try {
301
+ value = JSON.parse(stdout);
302
+ }
303
+ catch {
304
+ return 'the check printed no JSON';
305
+ }
306
+ if (!Array.isArray(value))
307
+ return 'the check printed no list';
308
+ const runs = new Map();
309
+ for (const item of value) {
310
+ if (!isMap(item) || typeof item['run'] !== 'string' || !item['run'].trim() || typeof item['text'] !== 'string' || !item['text'].trim())
311
+ return 'an item of the list is no { "run": "<id>", "text": "…" }';
312
+ const id = item['run'].trim();
313
+ runs.set(id, [...(runs.get(id) ?? []), item['text'].trim()]);
314
+ }
315
+ return [...runs].map(([run, texts]) => ({ run, text: texts.join('\n\n') }));
316
+ }
317
+ /**
318
+ * What a run a row resumes is told: the command, as a run it starts is sent, then
319
+ * {@link RESUMED_OPENING} and the check's words for that run; for an automation kept on this
320
+ * machine, its name, its text, then the opening and the words. One text: a resume takes no
321
+ * attached text.
322
+ */
323
+ export function resumeText(command, words, lastRun) {
324
+ const found = foundOutput(words, lastRun);
325
+ const said = command.text !== undefined ? `${command.text}\n\n${RESUMED_OPENING_OWN}\n${found}` : `${RESUMED_OPENING}\n${found}`;
326
+ return `${commandPrompt(command)}\n\n${said}`;
327
+ }
328
+ /** 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. */
329
+ 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.';
330
+ /** The same for a run an automation kept on this machine resumes: its work is said by the text above. */
331
+ 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.';
332
+ /**
333
+ * The command a run's prompt is counted under, so a run a person started counts against that
334
+ * command's cap and interval like a scheduled one; nothing for a prompt that names none.
335
+ *
336
+ * A prompt that opens with a slash names a skill's command: the scheduled command whose name it
337
+ * is (`/triage quick` → `triage quick`), else the one its first word is (`/work-queue now` →
338
+ * `work-queue`). A prompt with no slash names no skill's command: it is a run of an automation
339
+ * kept on this machine when it is that automation's name and nothing else (`answer-comments`).
340
+ * The two never cross: a skill's command typed by hand is no run of an automation of that name,
341
+ * and an automation's run is none of a skill's.
342
+ */
343
+ export function promptCommand(prompt, schedule) {
344
+ const typed = prompt.trim();
345
+ if (!typed.startsWith('/'))
346
+ return schedule.commands.some(c => c.text !== undefined && c.name === typed) ? typed : undefined;
347
+ const skills = schedule.commands.filter(c => c.text === undefined);
348
+ const named = typed.slice(1);
349
+ if (skills.some(c => c.name === named))
350
+ return named;
351
+ const first = named.split(/\s+/)[0];
352
+ return skills.some(c => c.name === first) ? first : undefined;
353
+ }
354
+ /**
355
+ * Whether a check's output says the command is due (#1774): the output parsed as JSON is
356
+ * something other than empty — `[]`, `{}`, `null`, `false`, `""`, `0` and no output at all are not
357
+ * due. Every skill command prints JSON on stdout, so a check like `npx @openagt/skill-queue@0.1` needs no piping.
358
+ * Output that is not JSON counts by its text: anything non-blank is due.
359
+ */
360
+ export function isDue(stdout) {
361
+ const text = stdout.trim();
362
+ if (!text)
363
+ return false;
364
+ let value;
365
+ try {
366
+ value = JSON.parse(text);
367
+ }
368
+ catch {
369
+ return true;
370
+ }
371
+ if (value === null || value === false || value === '' || value === 0)
372
+ return false;
373
+ if (Array.isArray(value))
374
+ return value.length > 0;
375
+ if (typeof value === 'object')
376
+ return Object.keys(value).length > 0;
377
+ return true;
378
+ }
379
+ //# sourceMappingURL=schedule.js.map