@indigoai-us/hq-cli 5.290.0 → 5.291.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/assets/scaffold/core/scripts/hook-lib.sh +345 -50
  3. package/assets/scaffold/core/scripts/hq-session.sh +182 -21
  4. package/assets/scaffold/core/scripts/jobs-schedule-parse.sh +310 -0
  5. package/assets/scaffold/core/scripts/jobs-validate.sh +451 -0
  6. package/assets/scaffold/core/scripts/lib/session-id.sh +3 -2
  7. package/assets/scaffold/core/scripts/lib/work-mesh-enqueue.sh +2 -1
  8. package/dist/command-catalog.generated.d.ts +59 -0
  9. package/dist/command-catalog.generated.js +73 -0
  10. package/dist/commands/cloud.d.ts +26 -14
  11. package/dist/commands/cloud.js +207 -56
  12. package/dist/commands/core-jobs-native.d.ts +8 -0
  13. package/dist/commands/core-jobs-native.js +624 -0
  14. package/dist/commands/core-native-jobs-flag.d.ts +4 -0
  15. package/dist/commands/core-native-jobs-flag.js +7 -0
  16. package/dist/commands/core-policy.js +24 -5
  17. package/dist/commands/core.js +7 -0
  18. package/dist/commands/integrations-core.js +7 -0
  19. package/dist/commands/lanes.js +111 -35
  20. package/dist/commands/members.js +5 -0
  21. package/dist/commands/mesh.js +46 -25
  22. package/dist/commands/personal-targeted-push-flag.d.ts +4 -0
  23. package/dist/commands/personal-targeted-push-flag.js +7 -0
  24. package/dist/commands/personal-targeted-push-paths.d.ts +3 -0
  25. package/dist/commands/personal-targeted-push-paths.js +67 -0
  26. package/dist/commands/secrets.js +9 -0
  27. package/dist/commands/skill.d.ts +3 -0
  28. package/dist/commands/skill.js +28 -0
  29. package/dist/fast-core.js +1 -0
  30. package/dist/lib/billing/free-month.d.ts +18 -0
  31. package/dist/lib/billing/free-month.js +31 -0
  32. package/dist/lib/billing/plan-limit-refusals.d.ts +69 -0
  33. package/dist/lib/billing/plan-limit-refusals.js +187 -0
  34. package/dist/lib/billing/plan-lock.d.ts +2 -6
  35. package/dist/lib/billing/plan-lock.js +5 -16
  36. package/dist/lib/core-utils/secret-patterns.d.ts +3 -1
  37. package/dist/lib/core-utils/secret-patterns.js +3 -2
  38. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  39. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  40. package/dist/lib/lanes/autopilot.d.ts +69 -0
  41. package/dist/lib/lanes/autopilot.js +418 -0
  42. package/dist/lib/lanes/mesh-events.js +2 -0
  43. package/dist/lib/lanes/store.js +11 -6
  44. package/dist/lib/mesh/api.js +4 -0
  45. package/dist/lib/mesh/live/daemon/state.d.ts +7 -1
  46. package/dist/lib/mesh/live/daemon/state.js +34 -2
  47. package/dist/main.js +23 -12
  48. package/dist/utils/plan-gate-error.d.ts +24 -0
  49. package/dist/utils/plan-gate-error.js +219 -9
  50. package/package.json +2 -2
@@ -0,0 +1,624 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { JSON_SCHEMA, load, loadAll } from "js-yaml";
5
+ import { matchingSecretPatterns } from "../lib/core-utils/secret-patterns.js";
6
+ import { runBundledScript } from "../utils/run-bundled-script.js";
7
+ import { describeCoreCommand } from "./core-command-catalog.js";
8
+ import { coreNativeJobsEnabled } from "./core-native-jobs-flag.js";
9
+ const SCHEDULE_PARSE_ASSET = "core/scripts/jobs-schedule-parse.sh";
10
+ const VALIDATE_ASSET = "core/scripts/jobs-validate.sh";
11
+ const SCHEDULE_PARSE_USAGE = 'usage: jobs-schedule-parse.sh [--tz IANA] "<nl or cron schedule>"\n';
12
+ const VALIDATE_USAGE = "usage: jobs-validate.sh <file-or-dir> [file-or-dir...]\n";
13
+ const CRON_RE = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)$/;
14
+ const DAY_NAMES = {
15
+ sun: 0, sunday: 0, mon: 1, monday: 1, tue: 2, tues: 2, tuesday: 2,
16
+ wed: 3, wednesday: 3, thu: 4, thur: 4, thurs: 4, thursday: 4,
17
+ fri: 5, friday: 5, sat: 6, saturday: 6,
18
+ };
19
+ const DAY_LABELS = {
20
+ "0": "Sunday", "1": "Monday", "2": "Tuesday", "3": "Wednesday",
21
+ "4": "Thursday", "5": "Friday", "6": "Saturday", "1-5": "weekday",
22
+ "*": "day",
23
+ };
24
+ function parseClock(text) {
25
+ const match = /^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i.exec(text.trim().toLowerCase().replace(/ /g, ""));
26
+ if (!match)
27
+ return undefined;
28
+ let hour = Number.parseInt(match[1], 10);
29
+ const minute = Number.parseInt(match[2] || "0", 10);
30
+ const meridiem = match[3];
31
+ if (minute > 59)
32
+ return undefined;
33
+ if (meridiem) {
34
+ if (hour < 1 || hour > 12)
35
+ return undefined;
36
+ hour = meridiem === "am"
37
+ ? (hour === 12 ? 0 : hour)
38
+ : (hour === 12 ? 12 : hour + 12);
39
+ }
40
+ else if (hour > 23) {
41
+ return undefined;
42
+ }
43
+ return [hour, minute];
44
+ }
45
+ function formatClock(hour, minute) {
46
+ const hour12 = hour % 12 || 12;
47
+ return `${hour12}:${String(minute).padStart(2, "0")} ${hour < 12 ? "AM" : "PM"}`;
48
+ }
49
+ function humanizeCron(cron, timezone) {
50
+ const fields = cron.split(/\s+/);
51
+ if (fields.length !== 5)
52
+ return `Cron \`${cron}\` (${timezone})`;
53
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = fields;
54
+ if (/^\d+$/.test(minute) && /^\d+$/.test(hour) && dayOfMonth === "*"
55
+ && month === "*" && (dayOfWeek === "*" || dayOfWeek === "1-5")) {
56
+ const day = dayOfWeek === "1-5" ? "Every weekday" : "Every day";
57
+ return `${day} at ${formatClock(+hour, +minute)} (${timezone})`;
58
+ }
59
+ if (/^\d+$/.test(minute) && /^\d+$/.test(hour) && dayOfMonth === "*"
60
+ && month === "*" && /^\d+$/.test(dayOfWeek)) {
61
+ const day = DAY_LABELS[dayOfWeek] || `day ${dayOfWeek}`;
62
+ return `Every ${day} at ${formatClock(+hour, +minute)} (${timezone})`;
63
+ }
64
+ if (minute.startsWith("*/") && hour === "*" && dayOfMonth === "*"
65
+ && month === "*" && dayOfWeek === "*") {
66
+ return `Every ${minute.slice(2)} minutes (${timezone})`;
67
+ }
68
+ if (minute === "0" && hour.startsWith("*/") && dayOfMonth === "*"
69
+ && month === "*" && dayOfWeek === "*") {
70
+ return `Every ${hour.slice(2)} hours (${timezone})`;
71
+ }
72
+ if (minute === "0" && hour === "*" && dayOfMonth === "*"
73
+ && month === "*" && dayOfWeek === "*") {
74
+ return `Every hour on the hour (${timezone})`;
75
+ }
76
+ return `Cron \`${cron}\` (${timezone})`;
77
+ }
78
+ function parseNaturalLanguage(text) {
79
+ const normalized = text.trim().toLowerCase();
80
+ let match = /^every\s+(\d+)\s+minutes?$/.exec(normalized);
81
+ if (match) {
82
+ const minutes = Number(match[1]);
83
+ if (minutes < 1 || minutes > 59)
84
+ return [null, "minute interval must be 1..59"];
85
+ return [`*/${minutes} * * * *`, `Every ${minutes} minutes`];
86
+ }
87
+ if (normalized === "every hour" || normalized === "hourly") {
88
+ return ["0 * * * *", "Every hour on the hour"];
89
+ }
90
+ match = /^every\s+(\d+)\s+hours?$/.exec(normalized);
91
+ if (match) {
92
+ const hours = Number(match[1]);
93
+ if (hours < 1 || hours > 23)
94
+ return [null, "hour interval must be 1..23"];
95
+ return [`0 */${hours} * * *`, `Every ${hours} hours`];
96
+ }
97
+ match = /^(?:every\s+)?(weekday|weekdays|day|daily)\s+(?:at\s+)?(.+)$/.exec(normalized);
98
+ if (match) {
99
+ const clock = parseClock(match[2]);
100
+ if (!clock)
101
+ return [null, `could not parse time: ${match[2]}`];
102
+ const [hour, minute] = clock;
103
+ const weekdays = match[1] === "weekday" || match[1] === "weekdays";
104
+ return [
105
+ `${minute} ${hour} * * ${weekdays ? "1-5" : "*"}`,
106
+ `${weekdays ? "Every weekday" : "Every day"} at ${formatClock(hour, minute)}`,
107
+ ];
108
+ }
109
+ match = /^(?:every\s+)?(sun(?:day)?|mon(?:day)?|tue(?:s|sday)?|wed(?:nesday)?|thu(?:r|rs|rsday)?|fri(?:day)?|sat(?:urday)?)s?\s+(?:at\s+)?(.+)$/.exec(normalized);
110
+ if (match) {
111
+ const day = DAY_NAMES[match[1]];
112
+ const clock = parseClock(match[2]);
113
+ if (!clock)
114
+ return [null, `could not parse time: ${match[2]}`];
115
+ return [
116
+ `${clock[1]} ${clock[0]} * * ${day}`,
117
+ `Every ${DAY_LABELS[String(day)]} at ${formatClock(...clock)}`,
118
+ ];
119
+ }
120
+ match = /^at\s+(.+?)\s+every\s+(weekday|weekdays|day|daily)$/.exec(normalized);
121
+ if (match) {
122
+ const clock = parseClock(match[1]);
123
+ if (!clock)
124
+ return [null, `could not parse time: ${match[1]}`];
125
+ const weekdays = match[2] === "weekday" || match[2] === "weekdays";
126
+ return [
127
+ `${clock[1]} ${clock[0]} * * ${weekdays ? "1-5" : "*"}`,
128
+ `${weekdays ? "Every weekday" : "Every day"} at ${formatClock(...clock)}`,
129
+ ];
130
+ }
131
+ return [
132
+ null,
133
+ "unrecognized schedule — try 'every weekday at 9am', 'every day at 14:30', "
134
+ + "'every monday at 9am', a 5-field cron, or 'every 15 minutes'",
135
+ ];
136
+ }
137
+ function isValidTimezone(timezone) {
138
+ const wellFormed = /^(UTC|[A-Za-z0-9_+-]+(\/[A-Za-z0-9_+-]+)+)$/;
139
+ if (!wellFormed.test(timezone))
140
+ return false;
141
+ try {
142
+ new Intl.DateTimeFormat(undefined, { timeZone: timezone });
143
+ return true;
144
+ }
145
+ catch {
146
+ // Hosts without tzdata accept the same well-formed names as the shell.
147
+ return wellFormed.test(timezone);
148
+ }
149
+ }
150
+ export function detectHostTimezone(override, resolveLocaltime = fs.realpathSync, readTimedatectl = () => {
151
+ const result = spawnSync("timedatectl", ["show", "-p", "Timezone", "--value"], { encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] });
152
+ if (result.error || result.status !== 0)
153
+ return "";
154
+ return result.stdout || "";
155
+ }) {
156
+ if (override)
157
+ return override;
158
+ const environmentTimezone = (process.env.TZ || "").trim();
159
+ if (environmentTimezone && environmentTimezone.includes("/"))
160
+ return environmentTimezone;
161
+ try {
162
+ const realpath = resolveLocaltime("/etc/localtime");
163
+ const parts = realpath.split(path.sep).filter(Boolean);
164
+ const zoneinfo = parts.indexOf("zoneinfo");
165
+ if (zoneinfo >= 0 && parts.slice(zoneinfo + 1).length) {
166
+ return parts.slice(zoneinfo + 1).join("/");
167
+ }
168
+ }
169
+ catch {
170
+ // Ignore an unavailable local timezone link.
171
+ }
172
+ try {
173
+ const timezone = readTimedatectl().trim();
174
+ if (timezone && timezone.includes("/"))
175
+ return timezone;
176
+ }
177
+ catch {
178
+ // Ignore unavailable timedatectl.
179
+ }
180
+ return "UTC";
181
+ }
182
+ export function runScheduleParse(argv) {
183
+ let timezoneOverride = "";
184
+ const args = [...argv];
185
+ while (args.length) {
186
+ const argument = args[0];
187
+ if (argument === "--tz") {
188
+ if (args.length < 2) {
189
+ process.stderr.write(SCHEDULE_PARSE_USAGE);
190
+ return 2;
191
+ }
192
+ timezoneOverride = args[1];
193
+ args.splice(0, 2);
194
+ }
195
+ else if (argument === "--json") {
196
+ args.shift();
197
+ }
198
+ else if (argument === "-h" || argument === "--help") {
199
+ process.stderr.write(SCHEDULE_PARSE_USAGE);
200
+ return 2;
201
+ }
202
+ else if (argument === "--") {
203
+ args.shift();
204
+ break;
205
+ }
206
+ else if (argument.startsWith("-")) {
207
+ process.stderr.write(`jobs-schedule-parse: unknown flag: ${argument}\n${SCHEDULE_PARSE_USAGE}`);
208
+ return 2;
209
+ }
210
+ else {
211
+ break;
212
+ }
213
+ }
214
+ if (!args.length) {
215
+ process.stderr.write(SCHEDULE_PARSE_USAGE);
216
+ return 2;
217
+ }
218
+ const input = args.join(" ").trim();
219
+ if (!input) {
220
+ process.stdout.write('{"ok":false,"error":"empty schedule"}\n');
221
+ return 1;
222
+ }
223
+ const timezone = detectHostTimezone(timezoneOverride);
224
+ if (!isValidTimezone(timezone)) {
225
+ process.stdout.write(`${JSON.stringify({ ok: false, error: `invalid IANA timezone: ${timezone}` })}\n`);
226
+ return 1;
227
+ }
228
+ if (CRON_RE.test(input) && !/[A-Za-z]/.test(input)) {
229
+ process.stdout.write(`${JSON.stringify({ ok: true, cron: input, timezone, human: humanizeCron(input, timezone), source: "cron" })}\n`);
230
+ return 0;
231
+ }
232
+ const namedCron = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(mon-fri|monday-friday)$/i.exec(input);
233
+ if (namedCron) {
234
+ const cron = `${namedCron[1]} ${namedCron[2]} ${namedCron[3]} ${namedCron[4]} 1-5`;
235
+ process.stdout.write(`${JSON.stringify({ ok: true, cron, timezone, human: humanizeCron(cron, timezone), source: "cron" })}\n`);
236
+ return 0;
237
+ }
238
+ const [cron, message] = parseNaturalLanguage(input);
239
+ if (cron === null) {
240
+ process.stdout.write(`${JSON.stringify({ ok: false, error: message })}\n`);
241
+ return 1;
242
+ }
243
+ process.stdout.write(`${JSON.stringify({ ok: true, cron, timezone, human: `${message} (${timezone})`, source: "nl" })}\n`);
244
+ return 0;
245
+ }
246
+ function isValidCronField(value, minimum, maximum) {
247
+ return value.split(",").every((part) => {
248
+ if (part === "*")
249
+ return true;
250
+ if (/^\*\/[1-9]\d*$/.test(part))
251
+ return true;
252
+ if (/^(sun|mon|tue|wed|thu|fri|sat)$/i.test(part))
253
+ return maximum === 7;
254
+ if (/^(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)$/i.test(part))
255
+ return maximum === 12;
256
+ const range = /^(\d+)-(\d+)(?:\/([1-9]\d*))?$/.exec(part);
257
+ if (range) {
258
+ return Number(range[1]) >= minimum && Number(range[2]) <= maximum
259
+ && Number(range[1]) <= Number(range[2]);
260
+ }
261
+ const number = /^(\d+)(?:\/([1-9]\d*))?$/.exec(part);
262
+ if (!number)
263
+ return false;
264
+ return Number(number[1]) >= minimum && Number(number[1]) <= maximum;
265
+ });
266
+ }
267
+ function validateCron(file, cron, emit) {
268
+ const fields = cron.trim().split(/\s+/);
269
+ if (fields.length !== 5) {
270
+ emit(file, "schedule", `invalid cron — expected 5 fields (minute hour dom month dow), got ${fields.length}`);
271
+ return;
272
+ }
273
+ const names = ["minute", "hour", "day-of-month", "month", "day-of-week"];
274
+ const ranges = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]];
275
+ fields.forEach((field, index) => {
276
+ const [minimum, maximum] = ranges[index];
277
+ if (!isValidCronField(field, minimum, maximum)) {
278
+ emit(file, "schedule", `invalid cron ${names[index]} field '${field}'`);
279
+ }
280
+ });
281
+ }
282
+ function asText(value) {
283
+ if (value === null || value === undefined || value === false)
284
+ return "";
285
+ if (typeof value === "string")
286
+ return value;
287
+ if (typeof value === "object")
288
+ return JSON.stringify(value, null, 2);
289
+ return String(value);
290
+ }
291
+ function matchingJobSecretPatterns(value) {
292
+ // jobs-validate.sh calls grep with a leading-hyphen pattern and omits `--`.
293
+ // Preserve that shell behavior: grep interprets this class as an option.
294
+ return matchingSecretPatterns(value, { requireBoundary: false })
295
+ .filter((name) => name !== "Private key");
296
+ }
297
+ function validateCwd(file, field, cwd, emit) {
298
+ if (cwd.startsWith("/")) {
299
+ emit(file, field, "absolute cwd not allowed — use an HQ-root-relative path");
300
+ return;
301
+ }
302
+ if (cwd.split("/").includes("..")) {
303
+ emit(file, field, "cwd must not contain '..' path components");
304
+ }
305
+ }
306
+ function validateJobId(file, value, seenIds, emit) {
307
+ if (!value)
308
+ return;
309
+ if (!/^[a-z][a-z0-9-]{1,62}$/.test(value)) {
310
+ emit(file, "id", `must match [a-z][a-z0-9-]{1,62}, got '${value}'`);
311
+ }
312
+ if (seenIds.includes(value))
313
+ emit(file, "id", `duplicate id '${value}'`);
314
+ seenIds.push(value);
315
+ }
316
+ function validateExec(file, exec, emit) {
317
+ if (exec === undefined || exec === null) {
318
+ emit(file, "exec", "missing required field");
319
+ return;
320
+ }
321
+ if (typeof exec !== "object" || Array.isArray(exec)) {
322
+ emit(file, "exec", "must be an object with prompt or skill");
323
+ return;
324
+ }
325
+ const definition = exec;
326
+ const prompt = asText(definition.prompt);
327
+ const skill = asText(definition.skill);
328
+ if (!prompt && !skill)
329
+ emit(file, "exec", "must set prompt or skill");
330
+ else if (prompt && skill)
331
+ emit(file, "exec", "set exactly one of prompt or skill, not both");
332
+ for (const [field, value] of [["exec.prompt", prompt], ["exec.skill", skill]]) {
333
+ for (const name of matchingJobSecretPatterns(value)) {
334
+ emit(file, field, `inline credential-shaped value matched pattern '${name}' (names only; never values)`);
335
+ }
336
+ }
337
+ if (definition.args !== undefined) {
338
+ if (!definition.args || typeof definition.args !== "object" || Array.isArray(definition.args)) {
339
+ emit(file, "exec.args", "must be a mapping when present");
340
+ }
341
+ else {
342
+ const collectStrings = (value) => {
343
+ if (typeof value === "string")
344
+ return [value];
345
+ if (Array.isArray(value))
346
+ return value.flatMap(collectStrings);
347
+ if (value && typeof value === "object")
348
+ return Object.values(value).flatMap(collectStrings);
349
+ return [];
350
+ };
351
+ for (const value of collectStrings(definition.args)) {
352
+ for (const name of matchingJobSecretPatterns(value)) {
353
+ emit(file, "exec.args", `inline credential-shaped value matched pattern '${name}' (names only; never values)`);
354
+ }
355
+ }
356
+ }
357
+ }
358
+ const surface = asText(definition.surface);
359
+ if (surface && !["headless", "remote"].includes(surface)) {
360
+ emit(file, "exec.surface", `unknown surface '${surface}' (allowed: headless|remote)`);
361
+ }
362
+ for (const key of Object.keys(definition)) {
363
+ if (!["prompt", "skill", "args", "surface"].includes(key)) {
364
+ emit(file, `exec.${key}`, "unknown exec key (allowed: prompt, skill, args, surface)");
365
+ }
366
+ }
367
+ }
368
+ function validateRequirements(file, requirements, runtime, emit) {
369
+ if (requirements === undefined || requirements === null)
370
+ return;
371
+ if (!requirements || typeof requirements !== "object" || Array.isArray(requirements)) {
372
+ emit(file, "requirements", "must be a mapping");
373
+ return;
374
+ }
375
+ const value = requirements;
376
+ for (const key of Object.keys(value)) {
377
+ if (["runtime", "secrets", "company", "cwd", "tools"].includes(key))
378
+ continue;
379
+ const message = ["readiness", "last_probe", "last_run", "failure_class", "pending_probe"].includes(key)
380
+ ? "unknown requirements key (status belongs in hq-pro, not requirements)"
381
+ : "unknown requirements key (allowed: runtime, secrets, company, cwd, tools)";
382
+ emit(file, `requirements.${key}`, message);
383
+ }
384
+ const requiredRuntime = asText(value.runtime);
385
+ if (requiredRuntime && !["claude", "codex"].includes(requiredRuntime)) {
386
+ emit(file, "requirements.runtime", `unknown runtime '${requiredRuntime}' (allowed: claude|codex)`);
387
+ }
388
+ if (requiredRuntime && runtime && value.runtime !== runtime) {
389
+ emit(file, "requirements.runtime", `mismatches top-level runtime ('${requiredRuntime}' vs '${runtime}')`);
390
+ }
391
+ if (value.secrets !== undefined) {
392
+ if (!Array.isArray(value.secrets)) {
393
+ emit(file, "requirements.secrets", "must be an array of vault key names");
394
+ }
395
+ else {
396
+ for (const secret of value.secrets) {
397
+ const name = asText(secret);
398
+ for (const pattern of matchingJobSecretPatterns(name)) {
399
+ emit(file, "requirements.secrets", `inline credential-shaped value matched pattern '${pattern}' (names only; never values)`);
400
+ }
401
+ if (name && !/^[A-Za-z][A-Za-z0-9._:/-]{0,127}$/.test(name)) {
402
+ emit(file, "requirements.secrets", `invalid vault key name '${name}' (use names only)`);
403
+ }
404
+ if (name.includes("=")) {
405
+ emit(file, "requirements.secrets", "looks like KEY=value inline credential — names only");
406
+ }
407
+ }
408
+ }
409
+ }
410
+ if (value.tools !== undefined && !Array.isArray(value.tools)) {
411
+ emit(file, "requirements.tools", "must be an array of strings");
412
+ }
413
+ if (value.cwd)
414
+ validateCwd(file, "requirements.cwd", asText(value.cwd), emit);
415
+ const company = asText(value.company);
416
+ if (company && !/^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/.test(company)) {
417
+ emit(file, "requirements.company", `invalid company slug '${company}' (lowercase kebab/underscore)`);
418
+ }
419
+ }
420
+ function validateJob(file, document, seenIds, emit) {
421
+ if (!document || typeof document !== "object" || Array.isArray(document)) {
422
+ emit(file, "yaml", "job root must be a mapping");
423
+ return;
424
+ }
425
+ const job = document;
426
+ for (const field of ["id", "name", "schedule", "runtime", "timeout_seconds", "notify", "enabled", "owner", "created_at"]) {
427
+ const missing = field === "enabled" ? !Object.hasOwn(job, field) : asText(job[field]) === "";
428
+ if (missing)
429
+ emit(file, field, "missing required field");
430
+ }
431
+ const id = asText(job.id);
432
+ const schedule = asText(job.schedule);
433
+ const timezone = asText(job.timezone);
434
+ const runtime = asText(job.runtime);
435
+ const timeout = asText(job.timeout_seconds);
436
+ const notify = asText(job.notify);
437
+ if (job.timezone !== undefined && (typeof job.timezone !== "string" || !job.timezone)) {
438
+ emit(file, "timezone", "must be a non-empty string when present");
439
+ }
440
+ validateJobId(file, id, seenIds, emit);
441
+ if (schedule) {
442
+ const fields = schedule.trim().split(/\s+/);
443
+ if (fields.length !== 5) {
444
+ const message = /[a-z]/i.test(schedule)
445
+ ? "natural-language schedule is not allowed — store canonical cron only"
446
+ : `invalid cron — expected 5 fields (minute hour dom month dow), got ${fields.length}`;
447
+ emit(file, "schedule", message);
448
+ }
449
+ else {
450
+ validateCron(file, schedule, emit);
451
+ }
452
+ }
453
+ if (timezone && !(/^[A-Za-z_]+(\/[A-Za-z0-9_+-]+)+$/.test(timezone) || timezone === "UTC")) {
454
+ emit(file, "timezone", `not an IANA-looking timezone '${timezone}'`);
455
+ }
456
+ if (runtime && !["claude", "codex"].includes(runtime)) {
457
+ emit(file, "runtime", `unknown runtime '${runtime}' (allowed: claude|codex)`);
458
+ }
459
+ validateExec(file, job.exec, emit);
460
+ if (timeout) {
461
+ if (!/^\d+$/.test(timeout))
462
+ emit(file, "timeout_seconds", `must be an integer, got '${timeout}'`);
463
+ else if (+timeout < 60 || +timeout > 14400) {
464
+ emit(file, "timeout_seconds", `must be between 60 and 14400, got ${timeout}`);
465
+ }
466
+ }
467
+ if (notify && !["dm", "none", "profile"].includes(notify)) {
468
+ emit(file, "notify", `unknown notify '${notify}' (allowed: dm|none|profile)`);
469
+ }
470
+ if (job.enabled !== undefined && typeof job.enabled !== "boolean") {
471
+ emit(file, "enabled", `must be boolean true|false, got '${String(job.enabled)}'`);
472
+ }
473
+ const createdAt = asText(job.created_at);
474
+ if (createdAt && !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(createdAt)) {
475
+ emit(file, "created_at", `must be ISO-8601 datetime, got '${createdAt}'`);
476
+ }
477
+ validateRequirements(file, job.requirements, runtime, emit);
478
+ if (job.cwd)
479
+ validateCwd(file, "cwd", asText(job.cwd), emit);
480
+ }
481
+ function parseJobFile(contents) {
482
+ const documents = [];
483
+ loadAll(contents, (document) => documents.push(document), { schema: JSON_SCHEMA, json: true });
484
+ if (documents.length > 1)
485
+ return [];
486
+ const parsed = documents.length === 0
487
+ ? load(contents, { schema: JSON_SCHEMA, json: true })
488
+ : documents[0];
489
+ return expandYamlMergeKeys(parsed);
490
+ }
491
+ function expandYamlMergeKeys(value) {
492
+ if (Array.isArray(value))
493
+ return value.map(expandYamlMergeKeys);
494
+ if (!value || typeof value !== "object")
495
+ return value;
496
+ const mapping = value;
497
+ const merged = mapping["<<"];
498
+ const sources = Array.isArray(merged) ? merged : merged === undefined ? [] : [merged];
499
+ const result = {};
500
+ for (const source of [...sources].reverse()) {
501
+ if (source && typeof source === "object" && !Array.isArray(source)) {
502
+ Object.assign(result, expandYamlMergeKeys(source));
503
+ }
504
+ }
505
+ for (const [key, entry] of Object.entries(mapping)) {
506
+ if (key !== "<<")
507
+ result[key] = expandYamlMergeKeys(entry);
508
+ }
509
+ return result;
510
+ }
511
+ function collectJobFiles(args, emit) {
512
+ const files = [];
513
+ const collect = (argument) => {
514
+ try {
515
+ const stat = fs.statSync(argument);
516
+ if (stat.isFile()) {
517
+ if (/\.(yaml|yml)$/.test(argument))
518
+ files.push(argument);
519
+ else
520
+ emit(argument, "path", "not a .yaml/.yml job file");
521
+ return;
522
+ }
523
+ if (stat.isDirectory()) {
524
+ const walk = (directory) => {
525
+ const entries = fs.readdirSync(directory, { withFileTypes: true });
526
+ for (const entry of entries) {
527
+ const file = path.join(directory, entry.name);
528
+ if (entry.isDirectory())
529
+ walk(file);
530
+ else if (entry.isFile() && /\.(yaml|yml)$/.test(entry.name))
531
+ files.push(file);
532
+ }
533
+ };
534
+ walk(argument);
535
+ return;
536
+ }
537
+ emit(argument, "path", "file or directory not found");
538
+ }
539
+ catch {
540
+ emit(argument, "path", "file or directory not found");
541
+ }
542
+ };
543
+ args.forEach(collect);
544
+ return files;
545
+ }
546
+ function comparePathsLikeSort(left, right) {
547
+ return Buffer.compare(Buffer.from(left), Buffer.from(right));
548
+ }
549
+ export function validateJobs(args) {
550
+ if (!args.length) {
551
+ process.stderr.write(VALIDATE_USAGE);
552
+ return 2;
553
+ }
554
+ const errors = [];
555
+ const emit = (file, field, message) => {
556
+ errors.push(`jobs-validate: ${file}: ${field}: ${message}`);
557
+ };
558
+ const seenIds = [];
559
+ const files = collectJobFiles(args, emit).sort(comparePathsLikeSort);
560
+ if (!files.length) {
561
+ for (const error of errors)
562
+ process.stderr.write(`${error}\n`);
563
+ process.stderr.write("jobs-validate: no job YAML files found\n");
564
+ return 1;
565
+ }
566
+ for (const file of files) {
567
+ try {
568
+ validateJob(file, parseJobFile(fs.readFileSync(file, "utf8")), seenIds, emit);
569
+ }
570
+ catch {
571
+ emit(file, "yaml", "failed to parse YAML");
572
+ }
573
+ }
574
+ for (const error of errors)
575
+ process.stderr.write(`${error}\n`);
576
+ if (errors.length) {
577
+ process.stderr.write(`jobs-validate: ${errors.length} error(s)\n`);
578
+ return 1;
579
+ }
580
+ return 0;
581
+ }
582
+ export function registerCoreJobsNativeCommand(core) {
583
+ const jobs = core.command("jobs").description("Scheduled jobs helpers");
584
+ const register = (name, asset, summary, run) => {
585
+ const command = jobs.command(name)
586
+ .description(summary)
587
+ .helpOption(false)
588
+ .allowUnknownOption()
589
+ .allowExcessArguments(true)
590
+ .argument("[args...]", "Arguments passed to the scaffold helper")
591
+ .action(async (args, _options, current) => {
592
+ const argv = rawArgumentsForCommand(current, args);
593
+ if (!await coreNativeJobsEnabled()) {
594
+ const result = runBundledScript({ asset, args: argv, cwd: process.cwd() });
595
+ if (result.code !== 0)
596
+ process.exit(result.code);
597
+ return;
598
+ }
599
+ const result = run(argv);
600
+ if (result !== 0)
601
+ process.exit(result);
602
+ });
603
+ describeCoreCommand(command, { execution: "native", asset, root: "live" });
604
+ };
605
+ register("schedule-parse", SCHEDULE_PARSE_ASSET, "Resolve natural-language or raw cron schedules", runScheduleParse);
606
+ register("validate", VALIDATE_ASSET, "Validate scheduled-job YAML files", validateJobs);
607
+ }
608
+ export function rawArgumentsForCommand(command, fallback, raw = process.argv) {
609
+ const names = [];
610
+ for (let current = command; current?.parent; current = current.parent) {
611
+ names.unshift(current.name());
612
+ }
613
+ let start = -1;
614
+ let cursor = 0;
615
+ for (const name of names) {
616
+ const index = raw.indexOf(name, cursor);
617
+ if (index < 0)
618
+ return command.args.length ? command.args : fallback;
619
+ cursor = index + 1;
620
+ start = cursor;
621
+ }
622
+ return start >= 0 ? raw.slice(start) : (command.args.length ? command.args : fallback);
623
+ }
624
+ //# sourceMappingURL=core-jobs-native.js.map
@@ -0,0 +1,4 @@
1
+ /** Default-off rollout gate for the native scheduled-jobs helpers. */
2
+ export declare const CORE_NATIVE_JOBS_FLAG = "core-native-jobs";
3
+ export declare function coreNativeJobsEnabled(): Promise<boolean>;
4
+ //# sourceMappingURL=core-native-jobs-flag.d.ts.map
@@ -0,0 +1,7 @@
1
+ /** Default-off rollout gate for the native scheduled-jobs helpers. */
2
+ export const CORE_NATIVE_JOBS_FLAG = "core-native-jobs";
3
+ export async function coreNativeJobsEnabled() {
4
+ const { resolveProcessFlagGate } = await import("../lib/flag-registry.js");
5
+ return resolveProcessFlagGate(CORE_NATIVE_JOBS_FLAG, { fallback: false }, () => false);
6
+ }
7
+ //# sourceMappingURL=core-native-jobs-flag.js.map
@@ -205,6 +205,28 @@ function extractRulePaths(file) {
205
205
  }
206
206
  return [...paths].sort();
207
207
  }
208
+ function shellReferenceStem(reference) {
209
+ const lastDot = reference.lastIndexOf('.');
210
+ return lastDot === -1 ? reference : reference.slice(0, lastDot);
211
+ }
212
+ function shellLsExtensionGlobHasMatch(root, stem) {
213
+ const patternPath = path.join(root, `${stem}.*`);
214
+ const directory = path.dirname(patternPath);
215
+ const basenamePattern = path.basename(patternPath);
216
+ const literalPrefix = basenamePattern.slice(0, -1);
217
+ let entries;
218
+ try {
219
+ entries = fs.readdirSync(directory);
220
+ }
221
+ catch (error) {
222
+ if (expectedFsMiss(error))
223
+ return false;
224
+ throw error;
225
+ }
226
+ // Bash 5.2 enables globskipdots by default, so `.*` only matches when the
227
+ // directory has an actual hidden entry. readdir omits `.` and `..`.
228
+ return entries.some((entry) => entry.startsWith(literalPrefix));
229
+ }
208
230
  function refSummary(file, root) {
209
231
  const refs = extractRulePaths(file);
210
232
  const judged = refs.filter((reference) => {
@@ -228,11 +250,8 @@ function refSummary(file, root) {
228
250
  }
229
251
  if (fs.existsSync(path.join(root, reference)))
230
252
  continue;
231
- const extension = path.posix.extname(reference);
232
- const stem = extension ? reference.slice(0, -extension.length) : reference;
233
- const parent = path.join(root, path.posix.dirname(stem));
234
- const basename = path.posix.basename(stem);
235
- const hasSibling = listFiles(parent).some((name) => name.startsWith(`${basename}.`));
253
+ const stem = shellReferenceStem(reference);
254
+ const hasSibling = shellLsExtensionGlobHasMatch(root, stem);
236
255
  if (hasSibling)
237
256
  renamed.push(reference);
238
257
  else