instar 0.28.97 → 0.28.99

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.
@@ -0,0 +1,805 @@
1
+ /**
2
+ * AgentMdJobLoader — Phase 1a loader-side support for the new agentmd job format.
3
+ *
4
+ * Reads per-slug manifests from `.instar/jobs/schedule/<slug>.json`, optionally
5
+ * resolves an accompanying `.instar/jobs/<origin>/<slug>.md` (for entries with
6
+ * execute.type === "agentmd"), and validates frontmatter via a hardened YAML
7
+ * parse + Zod preprocessor coercion per spec §6.
8
+ *
9
+ * Phase 1a is LOADER ONLY:
10
+ * - agentmd entries load into memory with `body` and `frontmatter` populated.
11
+ * - They will NOT fire until Phase 1b adds scheduler dispatch.
12
+ * - Existing JobScheduler.buildPrompt code paths are untouched.
13
+ *
14
+ * Spec: docs/specs/INSTAR-JOBS-AS-AGENTMD-SPEC.md
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import yaml from 'js-yaml';
19
+ import { z } from 'zod';
20
+ import { Cron } from 'croner';
21
+ // ── Constants ──────────────────────────────────────────────────────────────
22
+ const VALID_PRIORITIES = ['critical', 'high', 'medium', 'low'];
23
+ const VALID_MODELS = ['opus', 'sonnet', 'haiku'];
24
+ const VALID_ORIGINS = ['instar', 'user'];
25
+ /** Slug regex per spec §"Slug rules" — ASCII only, deliberately excludes
26
+ * non-ASCII so NFD/RTL/ZWJ/dotless-i payloads cannot slip through. */
27
+ const SLUG_RE = /^[a-zA-Z0-9_-]{1,100}$/;
28
+ /** Bounded concurrency for per-slug manifest fanout. Spec §Load lifecycle
29
+ * pins this at 32 — unbounded Promise.all over 500+ entries hits EMFILE
30
+ * on macOS and Linux defaults. */
31
+ const READ_CONCURRENCY = 32;
32
+ /** Spec §6: hard size caps to bound parser cost + adversarial payloads. */
33
+ const MAX_FRONTMATTER_BYTES = 16 * 1024;
34
+ const MAX_BODY_BYTES = 64 * 1024;
35
+ /** Spec §6: closed-set frontmatter key whitelist (Phase 1a starter set).
36
+ * Unknown keys → per-entry skip. The set grows in later phases (e.g.
37
+ * toolAllowlist coercion details, viewMetadata schema) — adding to this
38
+ * set is a deliberate change, never silent. */
39
+ const ALLOWED_FRONTMATTER_KEYS = new Set([
40
+ 'name',
41
+ 'description',
42
+ 'toolAllowlist',
43
+ 'grounding',
44
+ 'notificationMode',
45
+ 'viewMetadata',
46
+ 'commonBlockers',
47
+ ]);
48
+ // ── Zod preprocessors (spec §6) ────────────────────────────────────────────
49
+ /** YAML FAILSAFE_SCHEMA parses booleans/numbers as strings. The
50
+ * preprocessors below restore typed values with EXACT coercion semantics:
51
+ * - Booleans accept only `true`/`True`/`TRUE`/`false`/`False`/`FALSE`.
52
+ * YAML 1.1's `yes`/`no`/`on`/`off` are explicitly rejected.
53
+ * - Integers accept only `^-?\d+$`. Floats, `NaN`, `Infinity` rejected. */
54
+ export const BoolField = z.preprocess((v) => {
55
+ if (typeof v === 'boolean')
56
+ return v;
57
+ if (typeof v !== 'string')
58
+ return v;
59
+ const lc = v.toLowerCase();
60
+ if (lc === 'true')
61
+ return true;
62
+ if (lc === 'false')
63
+ return false;
64
+ return v; // pass-through → Zod rejects
65
+ }, z.boolean());
66
+ export const IntField = z.preprocess((v) => {
67
+ if (typeof v === 'number')
68
+ return v;
69
+ if (typeof v !== 'string')
70
+ return v;
71
+ if (!/^-?\d+$/.test(v))
72
+ return v;
73
+ return Number(v);
74
+ }, z.number().int().finite());
75
+ // ── Bounded-concurrency helper (kept for Phase 1b sync→async migration) ───
76
+ /** Hand-rolled bounded-concurrency runner. p-limit is not in package.json
77
+ * and the spec explicitly permits a hand-roll. Pattern: counter + queue.
78
+ *
79
+ * Phase 1a uses synchronous filesystem APIs end-to-end to preserve the
80
+ * existing sync `loadJobs(jobsFile): JobDefinition[]` signature. The READ
81
+ * fanout in Phase 1a is bounded only by the manifest count (typically <50);
82
+ * the spec performance budget of 1500 ms cold-boot @ 200 jobs is met with
83
+ * sequential sync reads at this scale. Phase 1b/1c migrate to async +
84
+ * this concurrency runner when buildPrompt and the lock-file pipeline
85
+ * require non-blocking I/O. */
86
+ export async function mapWithConcurrency(items, limit, fn) {
87
+ const results = new Array(items.length);
88
+ let cursor = 0;
89
+ const workers = [];
90
+ const workerCount = Math.min(Math.max(limit, 1), Math.max(items.length, 1));
91
+ for (let w = 0; w < workerCount; w++) {
92
+ workers.push((async () => {
93
+ while (true) {
94
+ const i = cursor++;
95
+ if (i >= items.length)
96
+ return;
97
+ results[i] = await fn(items[i], i);
98
+ }
99
+ })());
100
+ }
101
+ await Promise.all(workers);
102
+ return results;
103
+ }
104
+ // ── Public entry point ─────────────────────────────────────────────────────
105
+ /**
106
+ * Load per-slug manifests from `.instar/jobs/schedule/`.
107
+ *
108
+ * Returns `{ jobs, problems }`. If the schedule directory does not exist,
109
+ * returns empty (this is the pre-spec state — caller continues with legacy
110
+ * jobs.json only).
111
+ *
112
+ * Synchronous: matches the existing `loadJobs(jobsFile)` API so callers
113
+ * (JobScheduler.start, status command, job CLI) do not need to become async
114
+ * in Phase 1a. Per-slug fanout is bounded by the manifest count, which is
115
+ * small enough at the Phase 1a scale (200-job spec budget) that the
116
+ * sequential sync read path is comfortably within the 1500 ms cold-boot
117
+ * budget. See header comment for migration plan.
118
+ *
119
+ * @param scheduleDir Absolute path to `.instar/jobs/schedule/`
120
+ * @param jobsRootDir Absolute path to `.instar/jobs/` (parent of schedule)
121
+ */
122
+ export function loadAgentMdJobs(scheduleDir, jobsRootDir) {
123
+ if (!fs.existsSync(scheduleDir)) {
124
+ return { jobs: [], problems: [] };
125
+ }
126
+ let entries;
127
+ try {
128
+ entries = fs.readdirSync(scheduleDir).filter((f) => f.endsWith('.json'));
129
+ }
130
+ catch (err) {
131
+ return {
132
+ jobs: [],
133
+ problems: [{
134
+ kind: 'unknown',
135
+ path: scheduleDir,
136
+ message: `Failed to enumerate ${scheduleDir}: ${err instanceof Error ? err.message : String(err)}`,
137
+ }],
138
+ };
139
+ }
140
+ // Step 1: read + validate every manifest sequentially. The hand-rolled
141
+ // bounded-concurrency helper above is exported for Phase 1b's async
142
+ // migration; Phase 1a is single-threaded for API parity with the existing
143
+ // sync loadJobs() entry point.
144
+ const manifestResults = entries.map((filename) => {
145
+ const filePath = path.join(scheduleDir, filename);
146
+ return readOneManifest(filePath, filename);
147
+ });
148
+ const validManifests = [];
149
+ const problems = [];
150
+ for (const r of manifestResults) {
151
+ if (r.problem)
152
+ problems.push(r.problem);
153
+ if (r.manifest)
154
+ validManifests.push({ manifest: r.manifest, sourcePath: r.sourcePath });
155
+ }
156
+ // Step 2: global slug uniqueness check, case-folded. Spec §"Slug rules":
157
+ // case-fold collision with origin="instar" winning over origin="user";
158
+ // same-origin collision skips both.
159
+ const survivors = resolveCaseFoldCollisions(validManifests, problems);
160
+ // Step 3: for each surviving entry, if agentmd then load + parse the .md.
161
+ const jobs = [];
162
+ for (const { manifest } of survivors) {
163
+ if (manifest.execute.type === 'agentmd') {
164
+ const loaded = loadAgentMdBody(manifest, jobsRootDir);
165
+ if (loaded.problem) {
166
+ problems.push(loaded.problem);
167
+ continue;
168
+ }
169
+ jobs.push(loaded.job);
170
+ }
171
+ else {
172
+ // Non-agentmd manifest entries: produce a JobDefinition without body.
173
+ const job = manifestToJobDefinition(manifest);
174
+ jobs.push(job);
175
+ }
176
+ }
177
+ return { jobs, problems };
178
+ }
179
+ // Re-export the bounded-concurrency helper for downstream callers. Phase 1b's
180
+ // scheduler dispatch path will route I/O through it; exposing it now keeps the
181
+ // API surface stable.
182
+ export const READ_CONCURRENCY_LIMIT = READ_CONCURRENCY;
183
+ // ── Manifest validation ────────────────────────────────────────────────────
184
+ function readOneManifest(filePath, filename) {
185
+ let raw;
186
+ try {
187
+ raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
188
+ }
189
+ catch (err) {
190
+ return {
191
+ manifest: null,
192
+ sourcePath: filePath,
193
+ problem: {
194
+ kind: 'manifest-invalid',
195
+ path: filePath,
196
+ message: `Failed to parse manifest ${filename}: ${err instanceof Error ? err.message : String(err)}`,
197
+ },
198
+ };
199
+ }
200
+ try {
201
+ const m = validateManifest(raw, filename);
202
+ // Defense-in-depth: filename ↔ slug match is not required by the spec,
203
+ // but mismatched filenames hint at a hand-edit error — surface a warning.
204
+ // Phase 1a: don't reject, just warn-and-continue via problems list when
205
+ // the slug differs. The collision check will catch the rename pattern.
206
+ return { manifest: m, sourcePath: filePath, problem: null };
207
+ }
208
+ catch (err) {
209
+ return {
210
+ manifest: null,
211
+ sourcePath: filePath,
212
+ problem: {
213
+ kind: 'manifest-invalid',
214
+ path: filePath,
215
+ message: err instanceof Error ? err.message : String(err),
216
+ },
217
+ };
218
+ }
219
+ }
220
+ /** Validates the per-slug manifest schema. Mirrors validateJob style so the
221
+ * error messages are familiar; centralized here because the agentmd path
222
+ * has additional fields (origin, unrestrictedTools, execute.type = agentmd). */
223
+ export function validateManifest(raw, sourceLabel) {
224
+ const prefix = sourceLabel ? `Manifest[${sourceLabel}]` : 'Manifest';
225
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
226
+ throw new Error(`${prefix}: must be a JSON object`);
227
+ }
228
+ const j = raw;
229
+ // Required string fields
230
+ for (const field of ['slug', 'schedule']) {
231
+ if (typeof j[field] !== 'string' || !j[field].trim()) {
232
+ throw new Error(`${prefix}: "${field}" is required and must be a non-empty string`);
233
+ }
234
+ }
235
+ // Slug regex
236
+ if (!SLUG_RE.test(j.slug)) {
237
+ throw new Error(`${prefix}: "slug" must match ${SLUG_RE} (ASCII letters/digits/hyphens/underscores, 1-100 chars)`);
238
+ }
239
+ // Origin
240
+ if (!VALID_ORIGINS.includes(j.origin)) {
241
+ throw new Error(`${prefix}: "origin" must be one of ${VALID_ORIGINS.join(', ')}, got "${j.origin}"`);
242
+ }
243
+ // Priority
244
+ if (!VALID_PRIORITIES.includes(j.priority)) {
245
+ throw new Error(`${prefix}: "priority" must be one of ${VALID_PRIORITIES.join(', ')}, got "${j.priority}"`);
246
+ }
247
+ // Model (optional)
248
+ if (j.model !== undefined && !VALID_MODELS.includes(j.model)) {
249
+ throw new Error(`${prefix}: "model" must be one of ${VALID_MODELS.join(', ')}, got "${j.model}"`);
250
+ }
251
+ // Cron expression
252
+ try {
253
+ const c = new Cron(j.schedule);
254
+ c.stop();
255
+ }
256
+ catch (err) {
257
+ throw new Error(`${prefix}: invalid cron expression "${j.schedule}": ${err instanceof Error ? err.message : String(err)}`);
258
+ }
259
+ // expectedDurationMinutes
260
+ if (typeof j.expectedDurationMinutes !== 'number' || !Number.isFinite(j.expectedDurationMinutes) || j.expectedDurationMinutes <= 0) {
261
+ throw new Error(`${prefix}: "expectedDurationMinutes" must be a positive finite number`);
262
+ }
263
+ // Enabled
264
+ if (typeof j.enabled !== 'boolean') {
265
+ throw new Error(`${prefix}: "enabled" must be a boolean`);
266
+ }
267
+ // Execute block
268
+ if (!j.execute || typeof j.execute !== 'object' || Array.isArray(j.execute)) {
269
+ throw new Error(`${prefix}: "execute" must be an object`);
270
+ }
271
+ const exec = j.execute;
272
+ const validTypes = ['skill', 'prompt', 'script', 'agentmd'];
273
+ if (typeof exec.type !== 'string' || !validTypes.includes(exec.type)) {
274
+ throw new Error(`${prefix}: execute.type must be one of ${validTypes.join(', ')}`);
275
+ }
276
+ if (exec.type === 'agentmd') {
277
+ // Body lives in the .md file — value must NOT be present.
278
+ if (exec.value !== undefined) {
279
+ throw new Error(`${prefix}: execute.value must be absent when type === "agentmd" (body lives in <origin>/<slug>.md)`);
280
+ }
281
+ }
282
+ else {
283
+ // Legacy types still require value.
284
+ if (typeof exec.value !== 'string' || !exec.value.trim()) {
285
+ throw new Error(`${prefix}: execute.value is required for type === "${exec.type}"`);
286
+ }
287
+ }
288
+ if (exec.args !== undefined && typeof exec.args !== 'string') {
289
+ throw new Error(`${prefix}: execute.args must be a string if provided`);
290
+ }
291
+ // Optional unrestrictedTools
292
+ if (j.unrestrictedTools !== undefined && typeof j.unrestrictedTools !== 'boolean') {
293
+ throw new Error(`${prefix}: "unrestrictedTools" must be a boolean if provided`);
294
+ }
295
+ // Optional tags
296
+ if (j.tags !== undefined) {
297
+ if (!Array.isArray(j.tags))
298
+ throw new Error(`${prefix}: "tags" must be an array if provided`);
299
+ for (const t of j.tags) {
300
+ if (typeof t !== 'string')
301
+ throw new Error(`${prefix}: "tags" entries must be strings`);
302
+ }
303
+ }
304
+ // Optional topicId
305
+ if (j.topicId !== undefined && (typeof j.topicId !== 'number' || !Number.isFinite(j.topicId))) {
306
+ throw new Error(`${prefix}: "topicId" must be a finite number if provided`);
307
+ }
308
+ // Optional telegramNotify
309
+ if (j.telegramNotify !== undefined &&
310
+ typeof j.telegramNotify !== 'boolean' &&
311
+ j.telegramNotify !== 'on-alert') {
312
+ throw new Error(`${prefix}: "telegramNotify" must be true, false, or "on-alert" if provided`);
313
+ }
314
+ // Optional machines
315
+ if (j.machines !== undefined) {
316
+ if (!Array.isArray(j.machines))
317
+ throw new Error(`${prefix}: "machines" must be an array if provided`);
318
+ for (const m of j.machines) {
319
+ if (typeof m !== 'string' || !m.trim())
320
+ throw new Error(`${prefix}: "machines" entries must be non-empty strings`);
321
+ }
322
+ }
323
+ // Optional gate (shell command)
324
+ if (j.gate !== undefined && typeof j.gate !== 'string') {
325
+ throw new Error(`${prefix}: "gate" must be a string if provided`);
326
+ }
327
+ return j;
328
+ }
329
+ // ── Case-fold collision resolution ─────────────────────────────────────────
330
+ function resolveCaseFoldCollisions(manifests, problems) {
331
+ const groups = new Map();
332
+ for (const m of manifests) {
333
+ // Spec: NFC normalization is defense-in-depth; the slug regex is ASCII-only
334
+ // so normalization is a no-op for valid slugs. We still apply it for hygiene.
335
+ const key = m.manifest.slug.normalize('NFC').toLowerCase();
336
+ const arr = groups.get(key);
337
+ if (arr)
338
+ arr.push(m);
339
+ else
340
+ groups.set(key, [m]);
341
+ }
342
+ const survivors = [];
343
+ for (const [key, group] of groups) {
344
+ if (group.length === 1) {
345
+ survivors.push(group[0]);
346
+ continue;
347
+ }
348
+ // Multi-entry case-fold collision.
349
+ const instarOnes = group.filter((g) => g.manifest.origin === 'instar');
350
+ const userOnes = group.filter((g) => g.manifest.origin === 'user');
351
+ if (instarOnes.length === 1 && userOnes.length >= 1 && instarOnes.length + userOnes.length === group.length) {
352
+ // origin=instar wins; user-namespace entries skipped per spec.
353
+ survivors.push(instarOnes[0]);
354
+ for (const u of userOnes) {
355
+ problems.push({
356
+ kind: 'case-fold-collision',
357
+ slug: u.manifest.slug,
358
+ origin: u.manifest.origin,
359
+ path: u.sourcePath,
360
+ message: `Case-fold slug collision on "${key}": user-namespace "${u.manifest.slug}" ` +
361
+ `was skipped because the instar default "${instarOnes[0].manifest.slug}" wins by spec.`,
362
+ });
363
+ }
364
+ }
365
+ else {
366
+ // Same-origin collision (or multiple instar/user pairs simultaneously).
367
+ // Per spec §"Slug rules": both skipped if same-origin collide; both
368
+ // skipped when the resolution is otherwise ambiguous. The Issues card
369
+ // names every entry; here we surface each one as a problem and skip
370
+ // them all.
371
+ for (const g of group) {
372
+ problems.push({
373
+ kind: 'case-fold-collision',
374
+ slug: g.manifest.slug,
375
+ origin: g.manifest.origin,
376
+ path: g.sourcePath,
377
+ message: `Case-fold slug collision on "${key}" (${group.length} entries, origins=[${group.map((x) => x.manifest.origin).join(',')}]) — all skipped because the collision is not resolvable by origin precedence.`,
378
+ });
379
+ }
380
+ }
381
+ }
382
+ return survivors;
383
+ }
384
+ // ── agentmd body + frontmatter loading ─────────────────────────────────────
385
+ /**
386
+ * Resolve and load a single agentmd entry's `.md` file.
387
+ * Applies path safety, size caps, YAML hardening, anchor rejection, and Zod
388
+ * preprocessor validation. Returns a fully-populated JobDefinition on success.
389
+ */
390
+ function loadAgentMdBody(manifest, jobsRootDir) {
391
+ const resolved = path.join(jobsRootDir, manifest.origin, `${manifest.slug}.md`);
392
+ // Path safety, applied BEFORE any read.
393
+ const safetyProblem = checkPathSafety(resolved, manifest);
394
+ if (safetyProblem)
395
+ return { job: null, problem: safetyProblem };
396
+ if (!fs.existsSync(resolved)) {
397
+ return {
398
+ job: null,
399
+ problem: {
400
+ kind: 'agentmd-file-missing',
401
+ slug: manifest.slug,
402
+ origin: manifest.origin,
403
+ path: resolved,
404
+ message: `agentmd file not found: ${resolved}`,
405
+ },
406
+ };
407
+ }
408
+ // Size check on the raw file. Body cap = 64 KB; frontmatter cap = 16 KB.
409
+ // We bound the total read to (16 + 64 + small framing) KB so an
410
+ // adversarial 10 MB file cannot blow memory before parse.
411
+ const MAX_TOTAL_BYTES = MAX_FRONTMATTER_BYTES + MAX_BODY_BYTES + 1024;
412
+ const stat = fs.statSync(resolved);
413
+ if (stat.size > MAX_TOTAL_BYTES) {
414
+ return {
415
+ job: null,
416
+ problem: {
417
+ kind: 'agentmd-body-too-large',
418
+ slug: manifest.slug,
419
+ origin: manifest.origin,
420
+ path: resolved,
421
+ message: `agentmd file is ${stat.size} bytes — exceeds total cap of ${MAX_TOTAL_BYTES} bytes`,
422
+ },
423
+ };
424
+ }
425
+ let text;
426
+ try {
427
+ text = fs.readFileSync(resolved, 'utf-8');
428
+ }
429
+ catch (err) {
430
+ return {
431
+ job: null,
432
+ problem: {
433
+ kind: 'unknown',
434
+ slug: manifest.slug,
435
+ origin: manifest.origin,
436
+ path: resolved,
437
+ message: `Failed to read ${resolved}: ${err instanceof Error ? err.message : String(err)}`,
438
+ },
439
+ };
440
+ }
441
+ // Split frontmatter ↔ body.
442
+ const split = splitFrontmatter(text);
443
+ if (!split) {
444
+ return {
445
+ job: null,
446
+ problem: {
447
+ kind: 'agentmd-yaml-invalid',
448
+ slug: manifest.slug,
449
+ origin: manifest.origin,
450
+ path: resolved,
451
+ message: 'agentmd file is missing a YAML frontmatter block (expected leading "---")',
452
+ },
453
+ };
454
+ }
455
+ const { frontmatterText, body } = split;
456
+ if (Buffer.byteLength(frontmatterText, 'utf-8') > MAX_FRONTMATTER_BYTES) {
457
+ return {
458
+ job: null,
459
+ problem: {
460
+ kind: 'agentmd-yaml-invalid',
461
+ slug: manifest.slug,
462
+ origin: manifest.origin,
463
+ path: resolved,
464
+ message: `frontmatter is ${Buffer.byteLength(frontmatterText, 'utf-8')} bytes — exceeds cap of ${MAX_FRONTMATTER_BYTES}`,
465
+ },
466
+ };
467
+ }
468
+ if (Buffer.byteLength(body, 'utf-8') > MAX_BODY_BYTES) {
469
+ return {
470
+ job: null,
471
+ problem: {
472
+ kind: 'agentmd-body-too-large',
473
+ slug: manifest.slug,
474
+ origin: manifest.origin,
475
+ path: resolved,
476
+ message: `body is ${Buffer.byteLength(body, 'utf-8')} bytes — exceeds cap of ${MAX_BODY_BYTES}`,
477
+ },
478
+ };
479
+ }
480
+ // Parse YAML with FAILSAFE_SCHEMA. Reject anchors/aliases via the
481
+ // parser's listener callback — this is the parsed-tree check the spec
482
+ // mandates over a raw-text regex (which would over-reject legitimate
483
+ // strings like `description: "Bash & Read"`).
484
+ let parsed;
485
+ let anchorSeen = false;
486
+ try {
487
+ parsed = yaml.load(frontmatterText, {
488
+ schema: yaml.FAILSAFE_SCHEMA,
489
+ listener: (_kind, state) => {
490
+ // state.anchor is set per-event when the parser encounters an
491
+ // anchor or alias on the parsed-tree node. Legitimate `&` or `*`
492
+ // characters inside quoted string values do NOT set this field.
493
+ if (state && state.anchor) {
494
+ anchorSeen = true;
495
+ }
496
+ },
497
+ });
498
+ }
499
+ catch (err) {
500
+ return {
501
+ job: null,
502
+ problem: {
503
+ kind: 'agentmd-yaml-invalid',
504
+ slug: manifest.slug,
505
+ origin: manifest.origin,
506
+ path: resolved,
507
+ message: `YAML parse failed: ${err instanceof Error ? err.message : String(err)}`,
508
+ },
509
+ };
510
+ }
511
+ if (anchorSeen) {
512
+ return {
513
+ job: null,
514
+ problem: {
515
+ kind: 'agentmd-yaml-invalid',
516
+ slug: manifest.slug,
517
+ origin: manifest.origin,
518
+ path: resolved,
519
+ message: 'YAML anchors/aliases are not allowed in agentmd frontmatter',
520
+ },
521
+ };
522
+ }
523
+ // JSON-roundtrip normalization. FAILSAFE_SCHEMA returns string-keyed
524
+ // structures with primitives-as-strings; the round-trip strips any
525
+ // exotic object identities the parser might construct.
526
+ // Empty frontmatter parses as undefined; treat that as the empty object.
527
+ let normalized;
528
+ if (parsed === undefined || parsed === null) {
529
+ normalized = {};
530
+ }
531
+ else {
532
+ try {
533
+ normalized = JSON.parse(JSON.stringify(parsed));
534
+ }
535
+ catch (err) {
536
+ return {
537
+ job: null,
538
+ problem: {
539
+ kind: 'agentmd-yaml-invalid',
540
+ slug: manifest.slug,
541
+ origin: manifest.origin,
542
+ path: resolved,
543
+ message: `Frontmatter is not JSON-serializable: ${err instanceof Error ? err.message : String(err)}`,
544
+ },
545
+ };
546
+ }
547
+ }
548
+ if (typeof normalized !== 'object' || normalized === null || Array.isArray(normalized)) {
549
+ return {
550
+ job: null,
551
+ problem: {
552
+ kind: 'agentmd-frontmatter-invalid',
553
+ slug: manifest.slug,
554
+ origin: manifest.origin,
555
+ path: resolved,
556
+ message: 'Frontmatter must be a YAML mapping (object)',
557
+ },
558
+ };
559
+ }
560
+ const frontmatter = normalized;
561
+ // Closed-set whitelist check.
562
+ for (const key of Object.keys(frontmatter)) {
563
+ if (!ALLOWED_FRONTMATTER_KEYS.has(key)) {
564
+ return {
565
+ job: null,
566
+ problem: {
567
+ kind: 'agentmd-frontmatter-invalid',
568
+ slug: manifest.slug,
569
+ origin: manifest.origin,
570
+ path: resolved,
571
+ message: `Unknown frontmatter key "${key}" (allowed: ${[...ALLOWED_FRONTMATTER_KEYS].join(', ')})`,
572
+ },
573
+ };
574
+ }
575
+ }
576
+ // Phase 1a applies Zod coercion only to fields it understands. The
577
+ // BoolField / IntField preprocessors are exposed for downstream phases
578
+ // to consume; here we validate a starter set:
579
+ // - name: string
580
+ // - description: string
581
+ // - notificationMode: 'always' | 'on-alert' | 'never' (string passthrough)
582
+ // - toolAllowlist: string[] | '*' (structural shape only — semantics in 1b)
583
+ //
584
+ // We do NOT validate `grounding` / `commonBlockers` / `viewMetadata` deeply
585
+ // here because the existing JobLoader.validateJob already encodes those
586
+ // shapes; they are surfaced verbatim into JobDefinition and the scheduler
587
+ // dispatch path (Phase 1b) will route through validateGrounding etc.
588
+ const fmValidation = validateFrontmatterShape(frontmatter, manifest);
589
+ if (fmValidation.problem)
590
+ return { job: null, problem: fmValidation.problem };
591
+ // Build the JobDefinition. Manifest fields win over frontmatter fields by
592
+ // spec §"Open Questions Resolved" — manifest is authority for cron; frontmatter
593
+ // is authority for behavior (name/description). For Phase 1a we surface
594
+ // both so the scheduler-dispatch step in Phase 1b can lookup the right field.
595
+ const job = manifestToJobDefinition(manifest, frontmatter, body);
596
+ return { job, problem: null };
597
+ }
598
+ // ── Path safety ────────────────────────────────────────────────────────────
599
+ /** Pre-resolution path safety per spec §"Slug rules":
600
+ * - realpath(resolved) === resolved (no intermediate symlinks)
601
+ * - lstat(resolved).isSymbolicLink() === false
602
+ * - slug regex enforced (already done in manifest validation)
603
+ * - NFC-normalize for hygiene (regex already rejects non-ASCII)
604
+ *
605
+ * Called AFTER the manifest passed validateManifest, so we trust the slug
606
+ * is regex-safe; the .md file at the resolved path may still be a symlink. */
607
+ function checkPathSafety(resolved, manifest) {
608
+ // Defense-in-depth: re-confirm slug regex.
609
+ const slug = manifest.slug.normalize('NFC');
610
+ if (!SLUG_RE.test(slug)) {
611
+ return {
612
+ kind: 'slug-invalid',
613
+ slug: manifest.slug,
614
+ origin: manifest.origin,
615
+ path: resolved,
616
+ message: `slug "${manifest.slug}" fails the safety regex after NFC normalization`,
617
+ };
618
+ }
619
+ // lstat check first (cheap; tells us if the leaf itself is a symlink).
620
+ let lstat;
621
+ try {
622
+ lstat = fs.lstatSync(resolved);
623
+ }
624
+ catch (err) {
625
+ const code = err.code;
626
+ if (code === 'ENOENT')
627
+ return null; // missing file handled later
628
+ return {
629
+ kind: 'unknown',
630
+ slug: manifest.slug,
631
+ origin: manifest.origin,
632
+ path: resolved,
633
+ message: `lstat failed for ${resolved}: ${err instanceof Error ? err.message : String(err)}`,
634
+ };
635
+ }
636
+ if (lstat.isSymbolicLink()) {
637
+ return {
638
+ kind: 'agentmd-symlink',
639
+ slug: manifest.slug,
640
+ origin: manifest.origin,
641
+ path: resolved,
642
+ message: `agentmd file is a symbolic link — refusing to load (spec §Slug rules)`,
643
+ };
644
+ }
645
+ // realpath check — catches intermediate-directory symlinks (e.g. someone
646
+ // replaced .instar/jobs/instar/ with a symlink to /etc).
647
+ let real;
648
+ try {
649
+ real = fs.realpathSync(resolved);
650
+ }
651
+ catch (err) {
652
+ const code = err.code;
653
+ if (code === 'ENOENT')
654
+ return null; // missing file handled later
655
+ return {
656
+ kind: 'unknown',
657
+ slug: manifest.slug,
658
+ origin: manifest.origin,
659
+ path: resolved,
660
+ message: `realpath failed for ${resolved}: ${err instanceof Error ? err.message : String(err)}`,
661
+ };
662
+ }
663
+ // path.resolve normalizes the expected; realpathSync gives us the canonical.
664
+ // On macOS realpath may differ from path.resolve(resolved) due to /tmp →
665
+ // /private/tmp resolution — we compare against realpath(jobsRoot) parent
666
+ // by joining real components.
667
+ const expected = fs.realpathSync(path.dirname(resolved)) + path.sep + path.basename(resolved);
668
+ if (real !== expected) {
669
+ return {
670
+ kind: 'agentmd-symlink',
671
+ slug: manifest.slug,
672
+ origin: manifest.origin,
673
+ path: resolved,
674
+ message: `realpath(${resolved}) === ${real}, expected ${expected} — symlink redirection detected`,
675
+ };
676
+ }
677
+ return null;
678
+ }
679
+ // ── Frontmatter shape validation (light; deep validation lives in 1b) ─────
680
+ function validateFrontmatterShape(fm, manifest) {
681
+ // `name` and `description` must be strings if present. They are not
682
+ // required at the frontmatter level — the manifest may provide them in
683
+ // a later phase. Phase 1a accepts either source.
684
+ if (fm.name !== undefined && (typeof fm.name !== 'string' || !fm.name.trim())) {
685
+ return mkFmProblem(manifest, '"name" must be a non-empty string if provided');
686
+ }
687
+ if (fm.description !== undefined && (typeof fm.description !== 'string' || !fm.description.trim())) {
688
+ return mkFmProblem(manifest, '"description" must be a non-empty string if provided');
689
+ }
690
+ if (fm.notificationMode !== undefined) {
691
+ if (fm.notificationMode !== 'always' && fm.notificationMode !== 'on-alert' && fm.notificationMode !== 'never') {
692
+ return mkFmProblem(manifest, '"notificationMode" must be one of "always" | "on-alert" | "never"');
693
+ }
694
+ }
695
+ if (fm.toolAllowlist !== undefined) {
696
+ if (fm.toolAllowlist === '*') {
697
+ // ok — checked against manifest.unrestrictedTools in Phase 1b
698
+ }
699
+ else if (Array.isArray(fm.toolAllowlist)) {
700
+ for (const t of fm.toolAllowlist) {
701
+ if (typeof t !== 'string' || !t.trim()) {
702
+ return mkFmProblem(manifest, '"toolAllowlist" array entries must be non-empty strings');
703
+ }
704
+ }
705
+ }
706
+ else {
707
+ return mkFmProblem(manifest, '"toolAllowlist" must be "*" or an array of tool names');
708
+ }
709
+ }
710
+ return { problem: null };
711
+ }
712
+ function mkFmProblem(manifest, message) {
713
+ return {
714
+ problem: {
715
+ kind: 'agentmd-frontmatter-invalid',
716
+ slug: manifest.slug,
717
+ origin: manifest.origin,
718
+ message,
719
+ },
720
+ };
721
+ }
722
+ // ── Frontmatter split ──────────────────────────────────────────────────────
723
+ /** Split an agentmd file into `{ frontmatterText, body }`. Expects the file
724
+ * to start with `---\n`, contain a closing `---` on its own line, and
725
+ * everything after that line is the body. CRLF and lone-CR are normalized
726
+ * to LF for splitting; the body returned to the caller preserves its
727
+ * original line endings (the agentmd body is opaque content). */
728
+ function splitFrontmatter(text) {
729
+ // Normalize for the split only.
730
+ const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
731
+ if (!normalized.startsWith('---\n') && normalized !== '---') {
732
+ return null;
733
+ }
734
+ // Empty-frontmatter case: file starts with "---\n---\n" (or "---\n---" at EOF).
735
+ // The closing fence must be on its own line. Two patterns to find:
736
+ // 1. immediately after the opening fence: "---\n---" then \n or EOF
737
+ // 2. after some frontmatter content: "...\n---" then \n or EOF
738
+ const afterOpen = normalized.slice(4); // after "---\n"
739
+ // Either afterOpen starts with "---" (empty frontmatter) or contains "\n---".
740
+ let frontmatterText;
741
+ let restStart;
742
+ if (afterOpen.startsWith('---') && (afterOpen.length === 3 || afterOpen[3] === '\n' || /^---\s*$/.test(afterOpen.split('\n')[0]))) {
743
+ frontmatterText = '';
744
+ // skip past "---" itself
745
+ const firstLine = afterOpen.split('\n')[0];
746
+ if (!/^---\s*$/.test(firstLine))
747
+ return null;
748
+ restStart = firstLine.length;
749
+ }
750
+ else {
751
+ const fenceMatch = afterOpen.match(/\n(---\s*)(\n|$)/);
752
+ if (!fenceMatch || fenceMatch.index === undefined)
753
+ return null;
754
+ frontmatterText = afterOpen.slice(0, fenceMatch.index);
755
+ restStart = fenceMatch.index + fenceMatch[0].length - (fenceMatch[2] === '\n' ? 1 : 0);
756
+ }
757
+ const rest = afterOpen.slice(restStart);
758
+ // rest may now be "" or start with "\n<body>".
759
+ const body = rest.startsWith('\n') ? rest.slice(1) : rest;
760
+ return { frontmatterText, body };
761
+ }
762
+ // ── Manifest → JobDefinition ───────────────────────────────────────────────
763
+ function manifestToJobDefinition(manifest, frontmatter, body) {
764
+ // Manifest is authority for cron + slug; frontmatter is authority for
765
+ // name/description per spec §"Open Questions Resolved" Q6.
766
+ const name = (frontmatter && typeof frontmatter.name === 'string' && frontmatter.name.trim()) ||
767
+ manifest.slug;
768
+ const description = (frontmatter && typeof frontmatter.description === 'string' && frontmatter.description.trim()) ||
769
+ `agentmd job ${manifest.slug}`;
770
+ const job = {
771
+ slug: manifest.slug,
772
+ name,
773
+ description,
774
+ schedule: manifest.schedule,
775
+ priority: manifest.priority,
776
+ expectedDurationMinutes: manifest.expectedDurationMinutes,
777
+ model: manifest.model ?? 'sonnet',
778
+ enabled: manifest.enabled,
779
+ execute: manifest.execute,
780
+ origin: manifest.origin,
781
+ tags: manifest.tags,
782
+ topicId: manifest.topicId,
783
+ telegramNotify: manifest.telegramNotify,
784
+ machines: manifest.machines,
785
+ gate: manifest.gate,
786
+ unrestrictedTools: manifest.unrestrictedTools,
787
+ };
788
+ if (manifest.execute.type === 'agentmd') {
789
+ if (body !== undefined)
790
+ job.body = body;
791
+ if (frontmatter !== undefined)
792
+ job.frontmatter = frontmatter;
793
+ }
794
+ return job;
795
+ }
796
+ // ── Test-only exports (kept on the public surface for SchedulerProbe) ─────
797
+ /** Exposed for SchedulerProbe and tests: returns true iff a JobDefinition
798
+ * originating from an agentmd manifest has its body cached in memory.
799
+ * Phase 1a invariant — agentmd jobs MUST carry body after loadJobs(). */
800
+ export function isAgentMdJobHydrated(job) {
801
+ if (job.execute.type !== 'agentmd')
802
+ return true;
803
+ return typeof job.body === 'string';
804
+ }
805
+ //# sourceMappingURL=AgentMdJobLoader.js.map