lastlight-shared 0.1.7 → 0.3.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.
@@ -0,0 +1,957 @@
1
+ /**
2
+ * The PURE half of the per-repository config layer (issue #180) — the schema,
3
+ * the operator bounds, and the validators/merger that enforce them.
4
+ *
5
+ * A managed repo may commit a `.lastlight/` directory that overrides a BOUNDED
6
+ * subset of Last Light's config for runs against that repo. The directory
7
+ * mirrors a deployment overlay's on-disk shape exactly:
8
+ *
9
+ * .lastlight/
10
+ * lastlight.yml # the config override
11
+ * workflows/prompts/*.md # prompt overrides
12
+ * skills/<name>/SKILL.md # skill overrides
13
+ * agent-context/*.md # persona/rules additions
14
+ *
15
+ * ── Why this lives in `lastlight-shared` ──────────────────────────────────
16
+ * Two consumers need exactly the same answers about a `.lastlight/` tree:
17
+ * - `lastlight-core` at runtime, after fetching the layer from GitHub
18
+ * (`apps/server/src/config/repo-config.ts`, which owns the impure half —
19
+ * the fetch, the TTL cache, the on-disk unpack — and re-exports everything
20
+ * here so its import surface is unchanged); and
21
+ * - the `lastlight` CLI, offline, inside a user's own code repo
22
+ * (`lastlight repo config validate`).
23
+ * The CLI must never gain a dependency edge to core, so the bounds logic sits
24
+ * here — the one package both already depend on. Nothing in this file touches
25
+ * the filesystem, the network, or runtime config: it is a function of its
26
+ * arguments, which is also what makes it directly unit-testable.
27
+ *
28
+ * ── The trust rule ────────────────────────────────────────────────────────
29
+ * The layer is ALWAYS read from the repo's **default branch**. Never a PR head.
30
+ * Never the sandbox checkout. That rule is enforced by the fetcher in core;
31
+ * this module only describes what may appear in the layer once it arrives.
32
+ *
33
+ * ── The failure rule ──────────────────────────────────────────────────────
34
+ * Warn, drop the bad bits, run anyway. A repo's config file must never fail a
35
+ * run. Invalid YAML drops the whole file; an unknown or out-of-bounds key drops
36
+ * just that key. Every rejection becomes a structured {@link RepoConfigWarning}
37
+ * so the dashboard/CLI can report it back to the repo's owners.
38
+ */
39
+ import { join, resolve, sep } from "node:path";
40
+ import { parse as parseYaml } from "yaml";
41
+ import { providerByPrefix, oauthProviderByModelPrefix } from "./providers.js";
42
+ import { DIAGNOSIS_CLASSES, defaultDependenciesConfig, defaultFixConfig, defaultReviewConfig, dependencyImpactRank, isDependencyImpact, isDiagnosisClass, isReviewTrigger, reviewTriggerRank, } from "./config-types.js";
43
+ // ---------------------------------------------------------------------------
44
+ // Bounds
45
+ // ---------------------------------------------------------------------------
46
+ /** Hard cap on the unpacked `.lastlight/` layer, in bytes. */
47
+ export const REPO_CONFIG_MAX_BYTES = 2 * 1024 * 1024;
48
+ /** Hard cap on the number of files in the unpacked layer. */
49
+ export const REPO_CONFIG_MAX_FILES = 200;
50
+ /** The config file inside `.lastlight/`. Exactly this name — no `.yaml` variant. */
51
+ export const REPO_CONFIG_FILE = "lastlight.yml";
52
+ /** Git filemode for a symlink blob. Rejected on sight — see {@link sanitizeRepoFiles}. */
53
+ const GIT_MODE_SYMLINK = "120000";
54
+ /**
55
+ * The allow-list a deployment gets when it says nothing. Kept as a constant so
56
+ * the normaliser, the docs, `config/default.yaml` and the CLI's offline
57
+ * validator can't drift apart.
58
+ *
59
+ * It MUST stay identical to `repoConfig.allowKeys` in
60
+ * `apps/server/config/default.yaml`: this list is what a deployment falls back
61
+ * to when config isn't in reach (`repoConfigPolicy()`'s no-config path, and the
62
+ * CLI's offline `lastlight repo config validate`), so a divergence tells repo
63
+ * owners their file is out of bounds when it isn't. Pinned by the
64
+ * `default allow-list` block in `apps/server/tests/config/repo-config-shared.test.ts`
65
+ * — the two drifted apart once already, silently.
66
+ */
67
+ export const DEFAULT_REPO_CONFIG_ALLOW_KEYS = [
68
+ "models",
69
+ "variants",
70
+ "crons",
71
+ "disabled.workflows",
72
+ "disabled.crons",
73
+ "approval",
74
+ "fix",
75
+ "dependencies",
76
+ "review",
77
+ ];
78
+ /**
79
+ * The bounds to assume when no deployment config is in reach — the shipped
80
+ * defaults. The offline CLI validator (`lastlight repo config validate`) uses
81
+ * this: it can't know the operator's narrowing, so it validates against the
82
+ * widest shipped policy and says so.
83
+ */
84
+ export function defaultRepoConfigPolicy() {
85
+ return {
86
+ enabled: true,
87
+ allowKeys: [...DEFAULT_REPO_CONFIG_ALLOW_KEYS],
88
+ allowedModels: null,
89
+ allowAssets: true,
90
+ };
91
+ }
92
+ /**
93
+ * Classify a path relative to `.lastlight/`, or `null` when it is not part of
94
+ * the layer at all.
95
+ *
96
+ * `null` is a routine answer, not an error: `.lastlight/` is shared real
97
+ * estate. With `buildAssets.location: repo` the build workflow commits its
98
+ * handoff docs to `.lastlight/<issueKey>/*.md`, and repos are free to keep
99
+ * other things there. Those are simply outside the layer — the warning path is
100
+ * reserved for files that LOOK like layer assets but have the wrong shape
101
+ * (see {@link sanitizeRepoFiles}).
102
+ */
103
+ export function repoLayerPathKind(path) {
104
+ if (path === REPO_CONFIG_FILE)
105
+ return "config";
106
+ if (/^workflows\/prompts\/(?:[^/]+\/)*[^/]+\.md$/.test(path))
107
+ return "prompt";
108
+ if (/^skills\/[^/]+\/(?:[^/]+\/)*[^/]+$/.test(path))
109
+ return "skill";
110
+ if (/^agent-context\/[^/]+\.md$/.test(path))
111
+ return "agent-context";
112
+ return null;
113
+ }
114
+ /** True when a path is a workflow DEFINITION — the one thing a repo may never contribute. */
115
+ export function isRepoWorkflowPath(path) {
116
+ return /^workflows\/[^/]+\.ya?ml$/.test(path);
117
+ }
118
+ /** Top-level directories inside `.lastlight/` that the layer claims. */
119
+ const LAYER_DIRS = ["workflows/", "skills/", "agent-context/"];
120
+ // ---------------------------------------------------------------------------
121
+ // File-level guards
122
+ // ---------------------------------------------------------------------------
123
+ /**
124
+ * Reject a relative path that can't be safely joined onto a root. Modelled on
125
+ * `assertSafeRelative` in `./workflow-loader.ts`, but returning a reason
126
+ * instead of throwing — a hostile repo must not be able to abort a run by
127
+ * committing a bad filename.
128
+ */
129
+ function unsafeRelativeReason(path) {
130
+ if (!path)
131
+ return "path is empty";
132
+ if (path.startsWith("/") || /^[A-Za-z]:/.test(path))
133
+ return "path is absolute";
134
+ if (path.includes("\0"))
135
+ return "path contains a NUL byte";
136
+ if (path.includes("\\"))
137
+ return "path contains a backslash";
138
+ const segments = path.split("/");
139
+ if (segments.some((s) => s === "" || s === "." || s === ".."))
140
+ return "path contains a traversal segment";
141
+ return null;
142
+ }
143
+ /** True when `filePath` resolves inside `root`. Same check as the workflow loader's `isInside`. */
144
+ function isInside(filePath, root) {
145
+ const r = resolve(root);
146
+ const f = resolve(filePath);
147
+ return f === r || f.startsWith(r + sep);
148
+ }
149
+ /**
150
+ * Apply every file-level bound to a `.lastlight/` subtree.
151
+ *
152
+ * Pure — takes the blobs, returns the ones that may be written to disk plus a
153
+ * warning per rejection. The caps are enforced here as well as at fetch time
154
+ * (the client stops downloading at its own limits) because this function is the
155
+ * last gate before anything touches the filesystem, and defence in depth is
156
+ * cheap.
157
+ *
158
+ * Generic in the file type so a caller carrying a richer blob shape (core's
159
+ * `RepoConfigFile`) gets its own type back rather than a widened one.
160
+ */
161
+ export function sanitizeRepoFiles(files, policy, repo) {
162
+ const warnings = [];
163
+ const accepted = [];
164
+ const warn = (code, path, message) => warnings.push({ code, repo, path, message });
165
+ let bytes = 0;
166
+ let unrecognised = 0;
167
+ let assetsDropped = 0;
168
+ for (const file of files) {
169
+ const path = file.path;
170
+ const unsafe = unsafeRelativeReason(path);
171
+ if (unsafe) {
172
+ warn("path-escape", path, `Ignored .lastlight/${path}: ${unsafe}.`);
173
+ continue;
174
+ }
175
+ // Second, independent check: even a segment-clean path must land under the
176
+ // layer root once joined. Cheap, and catches anything the string checks miss.
177
+ if (!isInside(join("/layer-root", path), "/layer-root")) {
178
+ warn("path-escape", path, `Ignored .lastlight/${path}: it escapes the layer directory.`);
179
+ continue;
180
+ }
181
+ // Symlinks are the classic unpack escape — a `skills/x/SKILL.md` symlink
182
+ // pointing at /etc/passwd or at the harness's own secrets would otherwise be
183
+ // read as layer content. Only regular files are ever materialized.
184
+ if (file.mode === GIT_MODE_SYMLINK) {
185
+ warn("symlink", path, `Ignored .lastlight/${path}: symlinks are not allowed in a repo config layer.`);
186
+ continue;
187
+ }
188
+ if (file.mode !== "100644" && file.mode !== "100755") {
189
+ warn("symlink", path, `Ignored .lastlight/${path}: only regular files are allowed (mode ${file.mode}).`);
190
+ continue;
191
+ }
192
+ // Repos may contribute PROMPTS, never workflows: a workflow YAML defines
193
+ // phases, skills and permission profiles, which is the operator's call.
194
+ if (isRepoWorkflowPath(path)) {
195
+ warn("workflow-not-allowed", path, `Ignored .lastlight/${path}: a repo may override prompts (workflows/prompts/*.md), not workflow definitions.`);
196
+ continue;
197
+ }
198
+ const kind = repoLayerPathKind(path);
199
+ if (!kind) {
200
+ // Inside a layer directory but the wrong shape → the repo probably meant
201
+ // it as an asset. Counted and reported once, so a big stray directory
202
+ // can't flood the warning list.
203
+ if (LAYER_DIRS.some((d) => path.startsWith(d)))
204
+ unrecognised++;
205
+ continue;
206
+ }
207
+ if (kind !== "config" && !policy.allowAssets) {
208
+ assetsDropped++;
209
+ continue;
210
+ }
211
+ if (accepted.length >= REPO_CONFIG_MAX_FILES) {
212
+ warn("file-count-cap", path, `Ignored .lastlight/${path}: the repo config layer is capped at ${REPO_CONFIG_MAX_FILES} files.`);
213
+ continue;
214
+ }
215
+ if (bytes + file.content.length > REPO_CONFIG_MAX_BYTES) {
216
+ warn("size-cap", path, `Ignored .lastlight/${path}: the repo config layer is capped at ${REPO_CONFIG_MAX_BYTES} bytes.`);
217
+ continue;
218
+ }
219
+ bytes += file.content.length;
220
+ accepted.push(file);
221
+ }
222
+ if (unrecognised > 0) {
223
+ warn("unrecognised-asset", ".lastlight", `Ignored ${unrecognised} file(s) under .lastlight/: a repo layer may contain ${REPO_CONFIG_FILE}, ` +
224
+ `workflows/prompts/*.md, skills/<name>/SKILL.md and agent-context/*.md.`);
225
+ }
226
+ if (assetsDropped > 0) {
227
+ warn("assets-not-allowed", ".lastlight", `Ignored ${assetsDropped} asset file(s) under .lastlight/: this deployment sets repoConfig.allowAssets: false.`);
228
+ }
229
+ return { accepted, warnings };
230
+ }
231
+ // ---------------------------------------------------------------------------
232
+ // Config-level bounds
233
+ // ---------------------------------------------------------------------------
234
+ /**
235
+ * Parse a repo's `lastlight.yml`. Malformed YAML, or YAML that isn't a mapping,
236
+ * drops the WHOLE file — a half-understood config file is more dangerous than
237
+ * none, and the repo gets a warning either way.
238
+ */
239
+ export function parseRepoConfigYaml(raw, repo) {
240
+ let parsed;
241
+ try {
242
+ parsed = parseYaml(raw);
243
+ }
244
+ catch (err) {
245
+ const message = err instanceof Error ? err.message : String(err);
246
+ return {
247
+ warnings: [
248
+ {
249
+ code: "invalid-yaml",
250
+ repo,
251
+ path: REPO_CONFIG_FILE,
252
+ message: `Ignored .lastlight/${REPO_CONFIG_FILE}: it is not valid YAML (${message}).`,
253
+ },
254
+ ],
255
+ };
256
+ }
257
+ // An empty file parses to null — legal, just carries nothing.
258
+ if (parsed === null || parsed === undefined)
259
+ return { warnings: [] };
260
+ if (!isPlainObject(parsed)) {
261
+ return {
262
+ warnings: [
263
+ {
264
+ code: "not-a-mapping",
265
+ repo,
266
+ path: REPO_CONFIG_FILE,
267
+ message: `Ignored .lastlight/${REPO_CONFIG_FILE}: the top level must be a mapping.`,
268
+ },
269
+ ],
270
+ };
271
+ }
272
+ return { config: parsed, warnings: [] };
273
+ }
274
+ /**
275
+ * True when `path` (a dotted config path) is admitted by the allow-list. An
276
+ * entry admits itself and everything beneath it — `models` admits
277
+ * `models.architect`; `disabled.workflows` does NOT admit `disabled.prompts`.
278
+ */
279
+ function isAllowedKey(path, allowKeys) {
280
+ return allowKeys.some((allowed) => path === allowed || path.startsWith(`${allowed}.`));
281
+ }
282
+ /**
283
+ * True when a dotted path is a PREFIX of some allow-list entry — i.e. the repo
284
+ * must be allowed to descend into it even though the container itself isn't
285
+ * directly settable (`disabled` for `disabled.workflows`).
286
+ */
287
+ function isAllowedPrefix(path, allowKeys) {
288
+ return allowKeys.some((allowed) => allowed.startsWith(`${path}.`));
289
+ }
290
+ /**
291
+ * Reduce a repo's raw `lastlight.yml` to the sub-tree it is actually allowed to
292
+ * contribute. Pure. Everything dropped produces a warning.
293
+ *
294
+ * `base` supplies the operator's current values, which the `approval` add-only
295
+ * rule needs: a repo may raise a gate, never lower one.
296
+ */
297
+ export function sanitizeRepoConfigLayer(raw, policy, base, repo) {
298
+ const warnings = [];
299
+ const layer = {};
300
+ if (!raw)
301
+ return { layer, warnings };
302
+ const warn = (code, path, message) => warnings.push({ code, repo, path, message });
303
+ for (const [key, value] of Object.entries(raw)) {
304
+ const allowed = isAllowedKey(key, policy.allowKeys);
305
+ const descendable = isAllowedPrefix(key, policy.allowKeys);
306
+ if (!allowed && !descendable) {
307
+ warn("key-not-allowed", key, `Ignored "${key}" in .lastlight/${REPO_CONFIG_FILE}: a repo may not set this key.`);
308
+ continue;
309
+ }
310
+ switch (key) {
311
+ case "models":
312
+ assignIfAny(layer, "models", sanitizeModels(value, policy, warn));
313
+ break;
314
+ case "variants":
315
+ assignIfAny(layer, "variants", sanitizeStringMap(value, "variants", policy, warn));
316
+ break;
317
+ case "crons":
318
+ // Accepted and deliberately NOT merged. Cron participation
319
+ // (`crons: {enable, disable}`) is read straight off the RAW layer by
320
+ // the scheduler at tick time — `repoCronPrefs` in
321
+ // `apps/server/src/cron/repo-crons.ts` — because it decides WHICH repos
322
+ // a cron fans out over, which happens before any run (and therefore
323
+ // before any merged per-run config) exists. So it is not part of
324
+ // {@link RepoMergedConfig} and has no merged-shape validator here; the
325
+ // `case` exists only to stop the `default:` branch reporting a
326
+ // "no repo-layer validator" warning for a block that is fully
327
+ // supported. Do NOT "fix" this by adding `crons` to the merged shape:
328
+ // that would give one block two owners with two bounds checks.
329
+ break;
330
+ case "disabled":
331
+ assignIfAny(layer, "disabled", sanitizeDisabled(value, policy, warn));
332
+ break;
333
+ case "approval":
334
+ assignIfAny(layer, "approval", sanitizeApproval(value, policy, base, warn));
335
+ break;
336
+ case "fix":
337
+ assignIfAny(layer, "fix", sanitizeFix(value, policy, base, warn));
338
+ break;
339
+ case "dependencies":
340
+ assignIfAny(layer, "dependencies", sanitizeDependencies(value, policy, base, warn));
341
+ break;
342
+ case "review":
343
+ assignIfAny(layer, "review", sanitizeReview(value, policy, base, warn));
344
+ break;
345
+ default:
346
+ // Allow-listed by the operator but not a key this module knows how to
347
+ // bound. Refusing is the safe direction: an unbounded pass-through
348
+ // would let an operator widen the layer past what's been reviewed.
349
+ warn("key-not-allowed", key, `Ignored "${key}" in .lastlight/${REPO_CONFIG_FILE}: it has no repo-layer validator.`);
350
+ }
351
+ }
352
+ return { layer, warnings };
353
+ }
354
+ /** Drop empty sub-trees so the merge never records a `repo` provenance for nothing. */
355
+ function assignIfAny(target, key, value) {
356
+ if (value && Object.keys(value).length > 0)
357
+ target[key] = value;
358
+ }
359
+ function sanitizeModels(raw, policy, warn) {
360
+ if (!isPlainObject(raw)) {
361
+ warn("invalid-value", "models", `Ignored "models" in .lastlight/${REPO_CONFIG_FILE}: it must be a mapping.`);
362
+ return undefined;
363
+ }
364
+ const out = {};
365
+ for (const [task, spec] of Object.entries(raw)) {
366
+ const path = `models.${task}`;
367
+ // Re-checked per leaf, not just on the `models` container: an operator can
368
+ // narrow the allow-list to a single task (`models.triage`), and the leaf is
369
+ // the only place that distinction exists.
370
+ if (!isAllowedKey(path, policy.allowKeys)) {
371
+ warn("key-not-allowed", path, `Ignored "${path}": a repo may not set this key.`);
372
+ continue;
373
+ }
374
+ if (typeof spec !== "string" || !spec.trim()) {
375
+ warn("invalid-value", path, `Ignored "${path}": a model must be a non-empty "provider/model" string.`);
376
+ continue;
377
+ }
378
+ const model = spec.trim();
379
+ const prefix = model.split("/")[0] ?? "";
380
+ if (!prefix || !model.includes("/") || (!providerByPrefix(prefix) && !oauthProviderByModelPrefix(prefix))) {
381
+ warn("unknown-provider", path, `Ignored "${path}": "${model}" is not a "provider/model" spec Last Light can wire.`);
382
+ continue;
383
+ }
384
+ // A non-null allowedModels is the operator saying "exactly these" — an
385
+ // exact-match list, never a prefix rule, so it can't be widened by a repo
386
+ // appending to a model id.
387
+ if (policy.allowedModels !== null && !policy.allowedModels.includes(model)) {
388
+ warn("model-not-allowed", path, `Ignored "${path}": "${model}" is not in this deployment's repoConfig.allowedModels.`);
389
+ continue;
390
+ }
391
+ out[task] = model;
392
+ }
393
+ return out;
394
+ }
395
+ function sanitizeStringMap(raw, path, policy, warn) {
396
+ if (!isPlainObject(raw)) {
397
+ warn("invalid-value", path, `Ignored "${path}" in .lastlight/${REPO_CONFIG_FILE}: it must be a mapping.`);
398
+ return undefined;
399
+ }
400
+ const out = {};
401
+ for (const [key, value] of Object.entries(raw)) {
402
+ const leaf = `${path}.${key}`;
403
+ if (!isAllowedKey(leaf, policy.allowKeys)) {
404
+ warn("key-not-allowed", leaf, `Ignored "${leaf}": a repo may not set this key.`);
405
+ continue;
406
+ }
407
+ if (typeof value !== "string" || !value.trim()) {
408
+ warn("invalid-value", leaf, `Ignored "${leaf}": it must be a non-empty string.`);
409
+ continue;
410
+ }
411
+ out[key] = value.trim();
412
+ }
413
+ return out;
414
+ }
415
+ function sanitizeDisabled(raw, policy, warn) {
416
+ if (!isPlainObject(raw)) {
417
+ warn("invalid-value", "disabled", `Ignored "disabled" in .lastlight/${REPO_CONFIG_FILE}: it must be a mapping.`);
418
+ return undefined;
419
+ }
420
+ const out = {};
421
+ for (const [key, value] of Object.entries(raw)) {
422
+ const path = `disabled.${key}`;
423
+ if (!isAllowedKey(path, policy.allowKeys)) {
424
+ warn("key-not-allowed", path, `Ignored "${path}": a repo may not set this key.`);
425
+ continue;
426
+ }
427
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string" || !v.trim())) {
428
+ warn("invalid-value", path, `Ignored "${path}": it must be an array of non-empty strings.`);
429
+ continue;
430
+ }
431
+ // Same unsafe-name check `validateAssets` applies to the operator's own
432
+ // disabled lists — a name is a logical workflow/cron name, never a path.
433
+ const names = value.map((v) => v.trim());
434
+ const bad = names.find((n) => n.includes("/") || n.includes(".."));
435
+ if (bad) {
436
+ warn("invalid-value", path, `Ignored "${path}": "${bad}" is not a valid workflow/cron name.`);
437
+ continue;
438
+ }
439
+ out[key] = names;
440
+ }
441
+ return out;
442
+ }
443
+ function sanitizeApproval(raw, policy, base, warn) {
444
+ if (!isPlainObject(raw)) {
445
+ warn("invalid-value", "approval", `Ignored "approval" in .lastlight/${REPO_CONFIG_FILE}: it must be a mapping.`);
446
+ return undefined;
447
+ }
448
+ const baseApproval = isPlainObject(base.value.approval) ? base.value.approval : {};
449
+ const out = {};
450
+ for (const [gate, value] of Object.entries(raw)) {
451
+ const path = `approval.${gate}`;
452
+ if (!isAllowedKey(path, policy.allowKeys)) {
453
+ warn("key-not-allowed", path, `Ignored "${path}": a repo may not set this key.`);
454
+ continue;
455
+ }
456
+ if (typeof value !== "boolean") {
457
+ warn("invalid-value", path, `Ignored "${path}": an approval gate must be true or false.`);
458
+ continue;
459
+ }
460
+ // ADD-ONLY. A repo may demand more human oversight of runs against itself;
461
+ // it may never remove oversight the operator asked for. Clearing a gate the
462
+ // operator never set is a no-op, so every `false` is simply dropped — with a
463
+ // warning, so the repo learns why nothing happened.
464
+ if (value === false) {
465
+ const code = baseApproval[gate] === true ? "approval-downgrade" : "invalid-value";
466
+ warn(code, path, `Ignored "${path}: false": the repo approval layer is add-only — a repo can raise an approval gate, never clear one.`);
467
+ continue;
468
+ }
469
+ out[gate] = true;
470
+ }
471
+ return out;
472
+ }
473
+ // ---------------------------------------------------------------------------
474
+ // Policy blocks: fix / dependencies / review (issues #251, #252)
475
+ // ---------------------------------------------------------------------------
476
+ //
477
+ // One rule governs all three: **a repo may only ever be MORE conservative than
478
+ // the operator.** Structurally this is `sanitizeApproval` again — validate the
479
+ // leaf, compare it with the operator's effective value, and DROP anything that
480
+ // loosens (with a `policy-downgrade` warning so the repo learns why nothing
481
+ // happened). Dropping IS the clamp: the base already carries the operator's
482
+ // value, so a dropped leaf resolves to exactly it.
483
+ //
484
+ // A handful of leaves are operator-only rather than clamped — they control spend
485
+ // (`fix.escalateModelAfterAttempt`), a shared resource (`fix.gateTimeoutSeconds`)
486
+ // or an escape hatch that a `max()` clamp would weld shut for CI-less repos
487
+ // (`dependencies.minSettledChecks`). Those are reported as `key-not-allowed`,
488
+ // the same code an operator narrowing `allowKeys` produces, because from the
489
+ // repo's point of view it is the same answer: this key is not yours to set.
490
+ /**
491
+ * The operator's effective value for one policy block — the boot config's node
492
+ * over the shipped defaults, leaf by leaf.
493
+ *
494
+ * Falling back per leaf (rather than only when the whole node is missing) is
495
+ * what lets the CLI's offline validator work: it merges against an empty base
496
+ * and still clamps against the shipped values, which is the widest policy any
497
+ * deployment can have.
498
+ */
499
+ function operatorBlockNode(base, key) {
500
+ return isPlainObject(base.value[key]) ? base.value[key] : {};
501
+ }
502
+ /** A positive-integer leaf, or `undefined` when the value isn't one. */
503
+ function positiveInt(value) {
504
+ return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0
505
+ ? value
506
+ : undefined;
507
+ }
508
+ function sanitizeFix(raw, policy, base, warn) {
509
+ if (!isPlainObject(raw)) {
510
+ warn("invalid-value", "fix", `Ignored "fix" in .lastlight/${REPO_CONFIG_FILE}: it must be a mapping.`);
511
+ return undefined;
512
+ }
513
+ const defaults = defaultFixConfig();
514
+ const operatorRaw = operatorBlockNode(base, "fix");
515
+ const out = {};
516
+ for (const [key, value] of Object.entries(raw)) {
517
+ const path = `fix.${key}`;
518
+ if (!isAllowedKey(path, policy.allowKeys)) {
519
+ warn("key-not-allowed", path, `Ignored "${path}": a repo may not set this key.`);
520
+ continue;
521
+ }
522
+ switch (key) {
523
+ // Budget caps. `min(repo, operator)`: a repo may buy itself fewer
524
+ // attempts/iterations against its own PRs, never more.
525
+ case "maxAttempts":
526
+ case "localIterations":
527
+ case "maxFlakyDeferrals": {
528
+ const n = positiveInt(value);
529
+ if (n === undefined) {
530
+ warn("invalid-value", path, `Ignored "${path}": it must be a non-negative whole number.`);
531
+ continue;
532
+ }
533
+ const operator = positiveInt(operatorRaw[key]) ?? defaults[key];
534
+ if (n > operator) {
535
+ warn("policy-downgrade", path, `Ignored "${path}: ${n}": a repo may only lower this — this deployment allows at most ${operator}.`);
536
+ continue;
537
+ }
538
+ out[key] = n;
539
+ break;
540
+ }
541
+ // The cumulative cost brake. `null` means unbounded, so it is the LOOSEST
542
+ // value there is: a repo may only ever propose a real number, and only one
543
+ // at or below the operator's own ceiling.
544
+ case "maxCostUsd": {
545
+ const operator = typeof operatorRaw.maxCostUsd === "number" || operatorRaw.maxCostUsd === null
546
+ ? operatorRaw.maxCostUsd
547
+ : defaults.maxCostUsd;
548
+ if (value === null) {
549
+ if (operator === null) {
550
+ warn("invalid-value", path, `Ignored "${path}: null": this deployment already sets no cost ceiling.`);
551
+ }
552
+ else {
553
+ warn("policy-downgrade", path, `Ignored "${path}: null": null means "no ceiling", which is looser than this deployment's ${operator}.`);
554
+ }
555
+ continue;
556
+ }
557
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
558
+ warn("invalid-value", path, `Ignored "${path}": it must be a non-negative number (or null for no ceiling).`);
559
+ continue;
560
+ }
561
+ if (operator !== null && value > operator) {
562
+ warn("policy-downgrade", path, `Ignored "${path}: ${value}": a repo may only lower this — this deployment's ceiling is ${operator}.`);
563
+ continue;
564
+ }
565
+ out.maxCostUsd = value;
566
+ break;
567
+ }
568
+ // Subset only. Naming a class the operator doesn't retry would ADD a
569
+ // retryable failure mode, which is the loosening direction; the remaining
570
+ // (possibly empty) subset stands, because retrying less is always allowed.
571
+ case "retryableClasses": {
572
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string" || !v.trim())) {
573
+ warn("invalid-value", path, `Ignored "${path}": it must be an array of non-empty strings.`);
574
+ continue;
575
+ }
576
+ const operator = Array.isArray(operatorRaw.retryableClasses)
577
+ ? operatorRaw.retryableClasses.filter((v) => typeof v === "string")
578
+ : defaults.retryableClasses;
579
+ const raw = value.map((v) => v.trim());
580
+ // Against the closed enum FIRST, and reported separately from the
581
+ // subset clamp: "you spelled it wrong" and "the operator doesn't retry
582
+ // that" are different problems with different fixes, and a typo would
583
+ // otherwise be reported as a policy decision (#256).
584
+ const misspelt = raw.filter((n) => !isDiagnosisClass(n));
585
+ if (misspelt.length > 0) {
586
+ warn("invalid-value", path, `Dropped ${misspelt.map((n) => `"${n}"`).join(", ")} from "${path}": not a diagnosis class. ` +
587
+ `The five are: ${DIAGNOSIS_CLASSES.join(", ")}.`);
588
+ }
589
+ const names = raw.filter(isDiagnosisClass);
590
+ const added = names.filter((n) => !operator.includes(n));
591
+ if (added.length > 0) {
592
+ warn("policy-downgrade", path, `Dropped ${added.map((n) => `"${n}"`).join(", ")} from "${path}": a repo may only narrow the retryable ` +
593
+ `classes — this deployment retries ${operator.join(", ") || "(none)"}.`);
594
+ }
595
+ out.retryableClasses = names.filter((n) => operator.includes(n));
596
+ break;
597
+ }
598
+ case "escalateModelAfterAttempt":
599
+ case "gateTimeoutSeconds":
600
+ // Operator-only: one is spend control, the other is a shared-resource
601
+ // budget. Neither is a "how careful is this repo" dial.
602
+ warn("key-not-allowed", path, `Ignored "${path}": this key is set by the deployment operator only.`);
603
+ break;
604
+ default:
605
+ warn("invalid-value", path, `Ignored "${path}": it is not a key of the fix policy.`);
606
+ }
607
+ }
608
+ return out;
609
+ }
610
+ function sanitizeDependencies(raw, policy, base, warn) {
611
+ if (!isPlainObject(raw)) {
612
+ warn("invalid-value", "dependencies", `Ignored "dependencies" in .lastlight/${REPO_CONFIG_FILE}: it must be a mapping.`);
613
+ return undefined;
614
+ }
615
+ const defaults = defaultDependenciesConfig();
616
+ const operatorRaw = operatorBlockNode(base, "dependencies");
617
+ const out = {};
618
+ for (const [key, value] of Object.entries(raw)) {
619
+ const path = `dependencies.${key}`;
620
+ if (!isAllowedKey(path, policy.allowKeys)) {
621
+ warn("key-not-allowed", path, `Ignored "${path}": a repo may not set this key.`);
622
+ continue;
623
+ }
624
+ switch (key) {
625
+ // The lower tier on `none < low < medium < high` wins, so a repo can pull
626
+ // its own auto-merge ceiling down (all the way to `none`) but never up.
627
+ case "autoMergeMaxImpact": {
628
+ if (!isDependencyImpact(value)) {
629
+ warn("invalid-value", path, `Ignored "${path}": it must be one of none, low, medium, high.`);
630
+ continue;
631
+ }
632
+ const operator = isDependencyImpact(operatorRaw.autoMergeMaxImpact)
633
+ ? operatorRaw.autoMergeMaxImpact
634
+ : defaults.autoMergeMaxImpact;
635
+ if (dependencyImpactRank(value) > dependencyImpactRank(operator)) {
636
+ warn("policy-downgrade", path, `Ignored "${path}: ${value}": a repo may only lower this — this deployment auto-merges majors up to ` +
637
+ `"${operator}".`);
638
+ continue;
639
+ }
640
+ out.autoMergeMaxImpact = value;
641
+ break;
642
+ }
643
+ // Add-only `true`, exactly like an approval gate: a repo may demand that
644
+ // checks have settled before anything merges; it may not waive the
645
+ // operator's requirement.
646
+ case "requireSettledChecks": {
647
+ if (typeof value !== "boolean") {
648
+ warn("invalid-value", path, `Ignored "${path}": it must be true or false.`);
649
+ continue;
650
+ }
651
+ if (value === false) {
652
+ const code = operatorRaw.requireSettledChecks === true ? "policy-downgrade" : "invalid-value";
653
+ warn(code, path, `Ignored "${path}: false": this key is add-only — a repo can require settled checks, never waive them.`);
654
+ continue;
655
+ }
656
+ out.requireSettledChecks = true;
657
+ break;
658
+ }
659
+ case "auditComment": {
660
+ // Add-only `true`, like `requireSettledChecks` above.
661
+ //
662
+ // This was `free` on the reasoning that the comment is cosmetic. It is
663
+ // not: it is the AUDIT RECORD of a major version this deployment
664
+ // auto-merged into that repo, and the party it silences is the party
665
+ // being audited (#256). Turning it ON when the operator has it off is
666
+ // the conservative direction and stays allowed; turning it off is the
667
+ // one thing a repo may not do.
668
+ if (typeof value !== "boolean") {
669
+ warn("invalid-value", path, `Ignored "${path}": it must be true or false.`);
670
+ continue;
671
+ }
672
+ const operator = typeof operatorRaw.auditComment === "boolean"
673
+ ? operatorRaw.auditComment
674
+ : defaults.auditComment;
675
+ if (!value && operator) {
676
+ warn("policy-downgrade", path, `Ignored "${path}: false": this key is add-only — a repo may ask for the auto-merge ` +
677
+ `audit comment, never silence one this deployment requires.`);
678
+ continue;
679
+ }
680
+ out.auditComment = value;
681
+ break;
682
+ }
683
+ case "minSettledChecks":
684
+ // Operator-only (09 locked decision 18). §6.2 originally proposed
685
+ // `max(repo, operator)`, but that direction welds the escape hatch shut:
686
+ // a repo with no CI at all could only ever RAISE the number of settled
687
+ // checks an auto-merge needs, never lower it to 0.
688
+ warn("key-not-allowed", path, `Ignored "${path}": this key is set by the deployment operator only.`);
689
+ break;
690
+ default:
691
+ warn("invalid-value", path, `Ignored "${path}": it is not a key of the dependencies policy.`);
692
+ }
693
+ }
694
+ return out;
695
+ }
696
+ function sanitizeReview(raw, policy, base, warn) {
697
+ if (!isPlainObject(raw)) {
698
+ warn("invalid-value", "review", `Ignored "review" in .lastlight/${REPO_CONFIG_FILE}: it must be a mapping.`);
699
+ return undefined;
700
+ }
701
+ const defaults = defaultReviewConfig();
702
+ const operatorRaw = operatorBlockNode(base, "review");
703
+ const out = {};
704
+ for (const [key, value] of Object.entries(raw)) {
705
+ const path = `review.${key}`;
706
+ if (!isAllowedKey(path, policy.allowKeys)) {
707
+ warn("key-not-allowed", path, `Ignored "${path}": a repo may not set this key.`);
708
+ continue;
709
+ }
710
+ switch (key) {
711
+ case "trigger": {
712
+ // The LOWER automation tier wins (`on-request < after-checks < eager`),
713
+ // the same direction `dependencies.autoMergeMaxImpact` is clamped in.
714
+ //
715
+ // This was `free` on the reasoning that all three modes are equally
716
+ // "safe". They are not equally EXPENSIVE (#256): a repo committing
717
+ // `eager` against an `on-request` deployment buys itself a full agent
718
+ // run per push, on the operator's budget. Opting DOWN is still entirely
719
+ // its call.
720
+ if (!isReviewTrigger(value)) {
721
+ warn("invalid-value", path, `Ignored "${path}": it must be one of eager, after-checks, on-request.`);
722
+ continue;
723
+ }
724
+ const operator = isReviewTrigger(operatorRaw.trigger) ? operatorRaw.trigger : defaults.trigger;
725
+ if (reviewTriggerRank(value) > reviewTriggerRank(operator)) {
726
+ warn("policy-downgrade", path, `Ignored "${path}: ${value}": a repo may only ask for LESS review automation — ` +
727
+ `this deployment runs "${operator}".`);
728
+ continue;
729
+ }
730
+ out.trigger = value;
731
+ break;
732
+ }
733
+ case "requestLabel": {
734
+ // Free, but it is a LABEL name, never a path — same guard
735
+ // `sanitizeDisabled` applies to workflow/cron names.
736
+ if (value === null) {
737
+ out.requestLabel = null;
738
+ break;
739
+ }
740
+ if (typeof value !== "string" || !value.trim()) {
741
+ warn("invalid-value", path, `Ignored "${path}": it must be a non-empty label name (or null).`);
742
+ continue;
743
+ }
744
+ const label = value.trim();
745
+ if (label.includes("/") || label.includes("..")) {
746
+ warn("invalid-value", path, `Ignored "${path}": "${label}" is not a valid label name.`);
747
+ continue;
748
+ }
749
+ out.requestLabel = label;
750
+ break;
751
+ }
752
+ // Both add-only `true`: a repo may skip drafts and may ask for the check
753
+ // run; it may not force reviews onto drafts or suppress an operator's
754
+ // check (which a branch-protection rule may be requiring).
755
+ case "skipDraft":
756
+ case "postsCheck": {
757
+ if (typeof value !== "boolean") {
758
+ warn("invalid-value", path, `Ignored "${path}": it must be true or false.`);
759
+ continue;
760
+ }
761
+ if (value === false) {
762
+ const code = operatorRaw[key] === true ? "policy-downgrade" : "invalid-value";
763
+ warn(code, path, `Ignored "${path}: false": this key is add-only — a repo may only turn it on.`);
764
+ continue;
765
+ }
766
+ out[key] = true;
767
+ break;
768
+ }
769
+ default:
770
+ warn("invalid-value", path, `Ignored "${path}": it is not a key of the review policy.`);
771
+ }
772
+ }
773
+ return out;
774
+ }
775
+ // ---------------------------------------------------------------------------
776
+ // Resolution
777
+ // ---------------------------------------------------------------------------
778
+ /**
779
+ * Apply a repo's layer on top of the boot config and report what happened.
780
+ *
781
+ * PURE — no fs, no network, no runtime-config reads. Plain objects deep-merge
782
+ * key-by-key while arrays and scalars replace wholesale — byte-for-byte the
783
+ * semantics of the boot layers (see {@link mergeLayer}), so the repo layer can
784
+ * never acquire semantics the operator's layers don't have.
785
+ *
786
+ * Note on `disabled.*`: those are arrays, so a repo's list REPLACES the
787
+ * operator's rather than adding to it (locked precedence). Operators who don't
788
+ * want that remove `disabled.workflows` / `disabled.crons` from
789
+ * `repoConfig.allowKeys`.
790
+ *
791
+ * Passing `undefined` for `repoLayer` (no `.lastlight/`, fetch failed, feature
792
+ * disabled) returns the base unchanged with no warnings — the inert path.
793
+ */
794
+ export function resolveRepoConfig(base, policy, repoLayer) {
795
+ const warnings = repoLayer ? [...repoLayer.warnings] : [];
796
+ let layer = {};
797
+ if (repoLayer && policy.enabled) {
798
+ const sanitized = sanitizeRepoConfigLayer(repoLayer.config, policy, base, repoLayer.repo);
799
+ layer = sanitized.layer;
800
+ warnings.push(...sanitized.warnings);
801
+ }
802
+ const value = structuredClone(base.value);
803
+ const sources = structuredClone(base.sources);
804
+ mergeLayer(value, sources, layer, "repo");
805
+ return {
806
+ merged: shapeMerged(value),
807
+ sources: shapeSources(sources),
808
+ warnings,
809
+ };
810
+ }
811
+ /**
812
+ * Merge one layer INTO `value`/`sources` in place, tagging every leaf it
813
+ * supplies with `source`.
814
+ *
815
+ * THE single definition of Last Light's config-merge semantics: plain objects
816
+ * deep-merge key-by-key so each leaf resolves (and is attributed) on its own;
817
+ * arrays and scalars replace wholesale. Core's boot-layer resolver
818
+ * (`apps/server/src/config/config-resolve.ts`) re-exports this rather than
819
+ * carrying its own — the repo layer must merge exactly the way default/overlay/
820
+ * env do, or a repo could acquire precedence the operator's own layers don't
821
+ * have, and two implementations is exactly how that drift starts.
822
+ *
823
+ * It lives HERE, in the leaf package, because the direction of the dependency
824
+ * edge only permits it here: `lastlight-shared` may never depend on core.
825
+ */
826
+ export function mergeLayer(value, sources, layer, source) {
827
+ for (const [key, incoming] of Object.entries(layer)) {
828
+ if (isPlainObject(incoming)) {
829
+ const childValue = isPlainObject(value[key]) ? value[key] : {};
830
+ const childSources = isPlainObject(sources[key]) ? sources[key] : {};
831
+ mergeLayer(childValue, childSources, incoming, source);
832
+ value[key] = childValue;
833
+ sources[key] = childSources;
834
+ }
835
+ else {
836
+ value[key] = incoming;
837
+ sources[key] = source;
838
+ }
839
+ }
840
+ }
841
+ function shapeMerged(value) {
842
+ const disabled = isPlainObject(value.disabled) ? value.disabled : {};
843
+ return {
844
+ models: stringMap(value.models),
845
+ variants: stringMap(value.variants),
846
+ disabled: {
847
+ workflows: stringList(disabled.workflows),
848
+ crons: stringList(disabled.crons),
849
+ prompts: stringList(disabled.prompts),
850
+ skills: stringList(disabled.skills),
851
+ agentContext: stringList(disabled.agentContext),
852
+ },
853
+ approval: boolMap(value.approval),
854
+ fix: shapeFix(value.fix),
855
+ dependencies: shapeDependencies(value.dependencies),
856
+ review: shapeReview(value.review),
857
+ };
858
+ }
859
+ /**
860
+ * Project a merged `fix:` / `dependencies:` / `review:` node onto its full
861
+ * shape, falling back leaf-by-leaf to the shipped default.
862
+ *
863
+ * Total by design: a base built before these blocks existed (or the CLI's empty
864
+ * offline base) still yields a complete, usable policy, so no consumer has to
865
+ * carry its own "if undefined then" branch.
866
+ */
867
+ function shapeFix(raw) {
868
+ const d = defaultFixConfig();
869
+ const node = isPlainObject(raw) ? raw : {};
870
+ return {
871
+ maxAttempts: num(node.maxAttempts, d.maxAttempts),
872
+ localIterations: num(node.localIterations, d.localIterations),
873
+ gateTimeoutSeconds: num(node.gateTimeoutSeconds, d.gateTimeoutSeconds),
874
+ escalateModelAfterAttempt: num(node.escalateModelAfterAttempt, d.escalateModelAfterAttempt),
875
+ maxCostUsd: node.maxCostUsd === null ? null : num(node.maxCostUsd, d.maxCostUsd ?? 0),
876
+ maxFlakyDeferrals: num(node.maxFlakyDeferrals, d.maxFlakyDeferrals),
877
+ retryableClasses: Array.isArray(node.retryableClasses) ? stringList(node.retryableClasses) : d.retryableClasses,
878
+ };
879
+ }
880
+ function shapeDependencies(raw) {
881
+ const d = defaultDependenciesConfig();
882
+ const node = isPlainObject(raw) ? raw : {};
883
+ return {
884
+ autoMergeMaxImpact: isDependencyImpact(node.autoMergeMaxImpact) ? node.autoMergeMaxImpact : d.autoMergeMaxImpact,
885
+ requireSettledChecks: typeof node.requireSettledChecks === "boolean" ? node.requireSettledChecks : d.requireSettledChecks,
886
+ minSettledChecks: num(node.minSettledChecks, d.minSettledChecks),
887
+ auditComment: typeof node.auditComment === "boolean" ? node.auditComment : d.auditComment,
888
+ };
889
+ }
890
+ function shapeReview(raw) {
891
+ const d = defaultReviewConfig();
892
+ const node = isPlainObject(raw) ? raw : {};
893
+ return {
894
+ postsCheck: typeof node.postsCheck === "boolean" ? node.postsCheck : d.postsCheck,
895
+ trigger: isReviewTrigger(node.trigger) ? node.trigger : d.trigger,
896
+ requestLabel: typeof node.requestLabel === "string" && node.requestLabel.trim() ? node.requestLabel.trim() : null,
897
+ skipDraft: typeof node.skipDraft === "boolean" ? node.skipDraft : d.skipDraft,
898
+ };
899
+ }
900
+ function num(raw, fallback) {
901
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : fallback;
902
+ }
903
+ function shapeSources(sources) {
904
+ const disabled = isPlainObject(sources.disabled) ? sources.disabled : {};
905
+ const pick = (key) => (isConfigSource(disabled[key]) ? disabled[key] : "default");
906
+ return {
907
+ models: sourceMap(sources.models),
908
+ variants: sourceMap(sources.variants),
909
+ disabled: {
910
+ workflows: pick("workflows"),
911
+ crons: pick("crons"),
912
+ prompts: pick("prompts"),
913
+ skills: pick("skills"),
914
+ agentContext: pick("agentContext"),
915
+ },
916
+ approval: sourceMap(sources.approval),
917
+ fix: sourceMap(sources.fix),
918
+ dependencies: sourceMap(sources.dependencies),
919
+ review: sourceMap(sources.review),
920
+ };
921
+ }
922
+ /** Narrow an unknown provenance leaf to a {@link ConfigSource}. */
923
+ export function isConfigSource(value) {
924
+ return value === "default" || value === "overlay" || value === "env" || value === "repo";
925
+ }
926
+ function stringMap(raw) {
927
+ const out = {};
928
+ if (isPlainObject(raw))
929
+ for (const [k, v] of Object.entries(raw))
930
+ if (typeof v === "string")
931
+ out[k] = v;
932
+ return out;
933
+ }
934
+ function boolMap(raw) {
935
+ const out = {};
936
+ if (isPlainObject(raw))
937
+ for (const [k, v] of Object.entries(raw))
938
+ out[k] = v === true;
939
+ return out;
940
+ }
941
+ function sourceMap(raw) {
942
+ const out = {};
943
+ if (isConfigSource(raw))
944
+ return out;
945
+ if (isPlainObject(raw))
946
+ for (const [k, v] of Object.entries(raw))
947
+ if (isConfigSource(v))
948
+ out[k] = v;
949
+ return out;
950
+ }
951
+ function stringList(raw) {
952
+ return Array.isArray(raw) ? raw.filter((v) => typeof v === "string") : [];
953
+ }
954
+ function isPlainObject(value) {
955
+ return typeof value === "object" && value !== null && !Array.isArray(value);
956
+ }
957
+ //# sourceMappingURL=repo-config-schema.js.map