lastlight-shared 0.1.6 → 0.2.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.
package/dist/index.d.ts CHANGED
@@ -19,3 +19,4 @@ export * from "./overlay-assets.js";
19
19
  export * from "./core-pin.js";
20
20
  export * from "./workflow-loader.js";
21
21
  export * from "./config-types.js";
22
+ export * from "./repo-config-schema.js";
package/dist/index.js CHANGED
@@ -19,4 +19,5 @@ export * from "./overlay-assets.js";
19
19
  export * from "./core-pin.js";
20
20
  export * from "./workflow-loader.js";
21
21
  export * from "./config-types.js";
22
+ export * from "./repo-config-schema.js";
22
23
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,cAAc,gBAAgB,CAAC;AAC/B,cAAc,YAAY,CAAC;AAC3B,cAAc,wBAAwB,CAAC;AACvC,cAAc,qBAAqB,CAAC;AACpC,cAAc,eAAe,CAAC;AAC9B,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,cAAc,gBAAgB,CAAC;AAC/B,cAAc,YAAY,CAAC;AAC3B,cAAc,wBAAwB,CAAC;AACvC,cAAc,qBAAqB,CAAC;AACpC,cAAc,eAAe,CAAC;AAC9B,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,yBAAyB,CAAC"}
@@ -0,0 +1,308 @@
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 type { DisabledConfig } from "./config-types.js";
40
+ /** Hard cap on the unpacked `.lastlight/` layer, in bytes. */
41
+ export declare const REPO_CONFIG_MAX_BYTES: number;
42
+ /** Hard cap on the number of files in the unpacked layer. */
43
+ export declare const REPO_CONFIG_MAX_FILES = 200;
44
+ /** The config file inside `.lastlight/`. Exactly this name — no `.yaml` variant. */
45
+ export declare const REPO_CONFIG_FILE = "lastlight.yml";
46
+ /**
47
+ * The operator's bounds on the per-repo config layer (issue #180). A repo may
48
+ * narrow its own behaviour within these bounds; it can never widen them, and
49
+ * violating them drops the offending key with a warning rather than failing
50
+ * the run.
51
+ *
52
+ * Trust note: the layer this policy bounds is always fetched from the repo's
53
+ * DEFAULT BRANCH. A PR head can't reach it, so a PR can't reconfigure the agent
54
+ * that reviews it. That rule lives in `apps/server/src/config/repo-config.ts`;
55
+ * this type only describes the bounds.
56
+ */
57
+ export interface RepoConfigPolicy {
58
+ /** Master switch. `false` ignores every repo's `.lastlight/` entirely (no fetch). */
59
+ enabled: boolean;
60
+ /**
61
+ * Config keys a repo may set, as dotted paths (`models`, `disabled.workflows`,
62
+ * …). A repo leaf is kept when some entry is that leaf's path or a prefix of
63
+ * it — so `models` admits `models.architect`, while `disabled.workflows` does
64
+ * NOT admit `disabled.prompts`. Everything else is dropped with a warning.
65
+ */
66
+ allowKeys: string[];
67
+ /**
68
+ * Model specs a repo may select. `null` (the default) means "any model whose
69
+ * `provider/` prefix is a provider Last Light knows how to wire" — the repo
70
+ * still can't invent a provider. A list restricts to exactly those specs.
71
+ */
72
+ allowedModels: string[] | null;
73
+ /**
74
+ * Whether the repo's asset overrides (`workflows/prompts/*.md`,
75
+ * `skills/<name>/SKILL.md`, `agent-context/*.md`) are unpacked and used.
76
+ * `false` keeps `lastlight.yml` only.
77
+ */
78
+ allowAssets: boolean;
79
+ }
80
+ /**
81
+ * The allow-list a deployment gets when it says nothing. Kept as a constant so
82
+ * the normaliser, the docs, `config/default.yaml` and the CLI's offline
83
+ * validator can't drift apart.
84
+ *
85
+ * It MUST stay identical to `repoConfig.allowKeys` in
86
+ * `apps/server/config/default.yaml`: this list is what a deployment falls back
87
+ * to when config isn't in reach (`repoConfigPolicy()`'s no-config path, and the
88
+ * CLI's offline `lastlight repo config validate`), so a divergence tells repo
89
+ * owners their file is out of bounds when it isn't. Pinned by the
90
+ * `default allow-list` block in `apps/server/tests/config/repo-config-shared.test.ts`
91
+ * — the two drifted apart once already, silently.
92
+ */
93
+ export declare const DEFAULT_REPO_CONFIG_ALLOW_KEYS: readonly string[];
94
+ /**
95
+ * The bounds to assume when no deployment config is in reach — the shipped
96
+ * defaults. The offline CLI validator (`lastlight repo config validate`) uses
97
+ * this: it can't know the operator's narrowing, so it validates against the
98
+ * widest shipped policy and says so.
99
+ */
100
+ export declare function defaultRepoConfigPolicy(): RepoConfigPolicy;
101
+ /** Which config layer supplied a resolved leaf. Mirrors core's `ConfigSource`. */
102
+ export type ConfigSource = "default" | "overlay" | "env" | "repo";
103
+ /** Why a piece of a repo's `.lastlight/` was dropped. */
104
+ export type RepoConfigWarningCode =
105
+ /** `lastlight.yml` did not parse as YAML — the whole file is ignored. */
106
+ "invalid-yaml"
107
+ /** `lastlight.yml` parsed to something that isn't a mapping. */
108
+ | "not-a-mapping"
109
+ /** The key isn't in the operator's `repoConfig.allowKeys`. */
110
+ | "key-not-allowed"
111
+ /** The key is allowed but the value has the wrong type/shape. */
112
+ | "invalid-value"
113
+ /** A model spec outside `repoConfig.allowedModels`. */
114
+ | "model-not-allowed"
115
+ /** A model spec whose `provider/` prefix isn't a provider we can wire. */
116
+ | "unknown-provider"
117
+ /** An `approval` entry that would clear a gate — the layer is add-only. */
118
+ | "approval-downgrade"
119
+ /** A file path that escapes the layer root. */
120
+ | "path-escape"
121
+ /** A symlink (or other non-regular blob) in the layer. */
122
+ | "symlink"
123
+ /** The layer exceeded {@link REPO_CONFIG_MAX_BYTES}. */
124
+ | "size-cap"
125
+ /** The layer exceeded {@link REPO_CONFIG_MAX_FILES}. */
126
+ | "file-count-cap"
127
+ /** A workflow YAML under `workflows/` — repos may contribute prompts, not workflows. */
128
+ | "workflow-not-allowed"
129
+ /** Asset files present but `repoConfig.allowAssets` is false. */
130
+ | "assets-not-allowed"
131
+ /** Files in a layer directory that don't match its expected shape. */
132
+ | "unrecognised-asset"
133
+ /** The GitHub fetch failed; the previous cached layer (if any) still stands. */
134
+ | "fetch-failed";
135
+ /**
136
+ * One structured, reportable rejection. Deliberately a plain data object (not
137
+ * an Error): these are collected, persisted in the cache sidecar and rendered
138
+ * by the dashboard/CLI, never thrown.
139
+ */
140
+ export interface RepoConfigWarning {
141
+ code: RepoConfigWarningCode;
142
+ /** `owner/repo` this warning belongs to, when known. */
143
+ repo?: string;
144
+ /** The config path (`models.architect`) or file path (`workflows/x.yaml`) at fault. */
145
+ path: string;
146
+ /** One-line human-readable explanation, safe to post back to the repo. */
147
+ message: string;
148
+ }
149
+ /**
150
+ * One blob of a `.lastlight/` tree, as handed to {@link sanitizeRepoFiles}.
151
+ * Structurally the same shape core's GitHub client produces (`RepoConfigFile`
152
+ * in `apps/server/src/engine/github/github.ts`) and the CLI reads off disk —
153
+ * declared here so the bounds logic needs no GitHub types.
154
+ */
155
+ export interface RepoLayerFile {
156
+ /** Path relative to `.lastlight/`. */
157
+ path: string;
158
+ /** Git filemode (`100644`, `100755`, `120000`, …). */
159
+ mode: string;
160
+ size: number;
161
+ content: Buffer;
162
+ }
163
+ /** A repo's fetched-and-unpacked `.lastlight/` layer. */
164
+ export interface RepoLayer {
165
+ /** `owner/repo`. */
166
+ repo: string;
167
+ /** The ref this layer was read from — always the repo's default branch. */
168
+ defaultBranch: string;
169
+ /** Git tree SHA of `.lastlight/` — the content identity used for conditional refetch. */
170
+ treeSha: string;
171
+ /** ETag of the default branch's root tree, for the cheap 304 path. */
172
+ etag?: string;
173
+ /** ISO timestamp of the last successful download (not of the last check). */
174
+ fetchedAt: string;
175
+ /**
176
+ * Absolute path of the unpacked tree. Mirrors an overlay root
177
+ * (`workflows/`, `skills/`, `agent-context/`), so it can be handed to the
178
+ * layer-aware asset loader directly.
179
+ */
180
+ root: string;
181
+ /** Parsed `lastlight.yml` — raw and UNVALIDATED; bounds are applied at resolve time. */
182
+ config?: Record<string, unknown>;
183
+ /** Accepted asset paths relative to {@link root}. Empty when `allowAssets` is false. */
184
+ assets: string[];
185
+ /** Everything dropped while fetching/unpacking this layer. */
186
+ warnings: RepoConfigWarning[];
187
+ }
188
+ /**
189
+ * The boot-resolved config the repo layer is applied on top of: the merged
190
+ * (default→overlay→env) values plus the matching provenance tree from
191
+ * `resolveConfigLayers`. Core builds one with `repoConfigBaseFromRuntime`.
192
+ */
193
+ export interface RepoConfigBase {
194
+ value: Record<string, unknown>;
195
+ sources: Record<string, unknown>;
196
+ }
197
+ /** The effective, repo-specific values for the keys a repo is allowed to touch. */
198
+ export interface RepoMergedConfig {
199
+ models: Record<string, string>;
200
+ variants: Record<string, string>;
201
+ /**
202
+ * Full disabled shape. Only `workflows` and `crons` are repo-settable by
203
+ * default; the rest always come from the operator's layers.
204
+ */
205
+ disabled: DisabledConfig;
206
+ approval: Record<string, boolean>;
207
+ }
208
+ /** Provenance mirror of {@link RepoMergedConfig} — each leaf tagged with its winning layer. */
209
+ export interface RepoConfigSources {
210
+ models: Record<string, ConfigSource>;
211
+ variants: Record<string, ConfigSource>;
212
+ disabled: Record<keyof DisabledConfig, ConfigSource>;
213
+ approval: Record<string, ConfigSource>;
214
+ }
215
+ /** Result of {@link resolveRepoConfig}. */
216
+ export interface ResolvedRepoConfig {
217
+ merged: RepoMergedConfig;
218
+ sources: RepoConfigSources;
219
+ /** Fetch/unpack warnings from the layer PLUS everything this resolve dropped. */
220
+ warnings: RepoConfigWarning[];
221
+ }
222
+ /** What role a path inside `.lastlight/` plays in the repo layer. */
223
+ export type RepoLayerPathKind = "config" | "prompt" | "skill" | "agent-context";
224
+ /**
225
+ * Classify a path relative to `.lastlight/`, or `null` when it is not part of
226
+ * the layer at all.
227
+ *
228
+ * `null` is a routine answer, not an error: `.lastlight/` is shared real
229
+ * estate. With `buildAssets.location: repo` the build workflow commits its
230
+ * handoff docs to `.lastlight/<issueKey>/*.md`, and repos are free to keep
231
+ * other things there. Those are simply outside the layer — the warning path is
232
+ * reserved for files that LOOK like layer assets but have the wrong shape
233
+ * (see {@link sanitizeRepoFiles}).
234
+ */
235
+ export declare function repoLayerPathKind(path: string): RepoLayerPathKind | null;
236
+ /** True when a path is a workflow DEFINITION — the one thing a repo may never contribute. */
237
+ export declare function isRepoWorkflowPath(path: string): boolean;
238
+ /**
239
+ * Apply every file-level bound to a `.lastlight/` subtree.
240
+ *
241
+ * Pure — takes the blobs, returns the ones that may be written to disk plus a
242
+ * warning per rejection. The caps are enforced here as well as at fetch time
243
+ * (the client stops downloading at its own limits) because this function is the
244
+ * last gate before anything touches the filesystem, and defence in depth is
245
+ * cheap.
246
+ *
247
+ * Generic in the file type so a caller carrying a richer blob shape (core's
248
+ * `RepoConfigFile`) gets its own type back rather than a widened one.
249
+ */
250
+ export declare function sanitizeRepoFiles<T extends RepoLayerFile>(files: readonly T[], policy: RepoConfigPolicy, repo?: string): {
251
+ accepted: T[];
252
+ warnings: RepoConfigWarning[];
253
+ };
254
+ /**
255
+ * Parse a repo's `lastlight.yml`. Malformed YAML, or YAML that isn't a mapping,
256
+ * drops the WHOLE file — a half-understood config file is more dangerous than
257
+ * none, and the repo gets a warning either way.
258
+ */
259
+ export declare function parseRepoConfigYaml(raw: string, repo?: string): {
260
+ config?: Record<string, unknown>;
261
+ warnings: RepoConfigWarning[];
262
+ };
263
+ /**
264
+ * Reduce a repo's raw `lastlight.yml` to the sub-tree it is actually allowed to
265
+ * contribute. Pure. Everything dropped produces a warning.
266
+ *
267
+ * `base` supplies the operator's current values, which the `approval` add-only
268
+ * rule needs: a repo may raise a gate, never lower one.
269
+ */
270
+ export declare function sanitizeRepoConfigLayer(raw: Record<string, unknown> | undefined, policy: RepoConfigPolicy, base: RepoConfigBase, repo?: string): {
271
+ layer: Record<string, unknown>;
272
+ warnings: RepoConfigWarning[];
273
+ };
274
+ /**
275
+ * Apply a repo's layer on top of the boot config and report what happened.
276
+ *
277
+ * PURE — no fs, no network, no runtime-config reads. Plain objects deep-merge
278
+ * key-by-key while arrays and scalars replace wholesale — byte-for-byte the
279
+ * semantics of the boot layers (see {@link mergeLayer}), so the repo layer can
280
+ * never acquire semantics the operator's layers don't have.
281
+ *
282
+ * Note on `disabled.*`: those are arrays, so a repo's list REPLACES the
283
+ * operator's rather than adding to it (locked precedence). Operators who don't
284
+ * want that remove `disabled.workflows` / `disabled.crons` from
285
+ * `repoConfig.allowKeys`.
286
+ *
287
+ * Passing `undefined` for `repoLayer` (no `.lastlight/`, fetch failed, feature
288
+ * disabled) returns the base unchanged with no warnings — the inert path.
289
+ */
290
+ export declare function resolveRepoConfig(base: RepoConfigBase, policy: RepoConfigPolicy, repoLayer?: RepoLayer): ResolvedRepoConfig;
291
+ /**
292
+ * Merge one layer INTO `value`/`sources` in place, tagging every leaf it
293
+ * supplies with `source`.
294
+ *
295
+ * THE single definition of Last Light's config-merge semantics: plain objects
296
+ * deep-merge key-by-key so each leaf resolves (and is attributed) on its own;
297
+ * arrays and scalars replace wholesale. Core's boot-layer resolver
298
+ * (`apps/server/src/config/config-resolve.ts`) re-exports this rather than
299
+ * carrying its own — the repo layer must merge exactly the way default/overlay/
300
+ * env do, or a repo could acquire precedence the operator's own layers don't
301
+ * have, and two implementations is exactly how that drift starts.
302
+ *
303
+ * It lives HERE, in the leaf package, because the direction of the dependency
304
+ * edge only permits it here: `lastlight-shared` may never depend on core.
305
+ */
306
+ export declare function mergeLayer(value: Record<string, unknown>, sources: Record<string, unknown>, layer: Record<string, unknown>, source: ConfigSource): void;
307
+ /** Narrow an unknown provenance leaf to a {@link ConfigSource}. */
308
+ export declare function isConfigSource(value: unknown): value is ConfigSource;