@sibylline-labs/eris 0.0.0-stage → 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.
Files changed (69) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +2 -0
  3. package/README.md +146 -2
  4. package/bin/eris.ts +42 -0
  5. package/docs/ARCHITECTURE.md +188 -0
  6. package/docs/COMPLETION.md +106 -0
  7. package/docs/ERRORS.md +632 -0
  8. package/docs/OUTPUT-CONTRACT.md +219 -0
  9. package/docs/README.md +30 -0
  10. package/docs/UPDATE.md +159 -0
  11. package/docs/YAK.md +395 -0
  12. package/docs/dev/ADDING-A-COMMAND.md +79 -0
  13. package/docs/dev/DEVELOPMENT.md +88 -0
  14. package/docs/dev/LAYOUT.md +74 -0
  15. package/docs/dev/PLUGINS.md +382 -0
  16. package/docs/dev/RELEASING.md +157 -0
  17. package/docs/dev/TESTING.md +84 -0
  18. package/docs/dev/surfaces/secrets.md +95 -0
  19. package/package.json +55 -4
  20. package/packages/yak-contract/package.json +28 -0
  21. package/packages/yak-contract/schema/yak-v2.schema.json +416 -0
  22. package/packages/yak-contract/src/index.ts +1 -0
  23. package/packages/yak-contract/src/v2.ts +375 -0
  24. package/plugins/index.ts +12 -0
  25. package/plugins/yak/handlers.ts +128 -0
  26. package/plugins/yak/index.ts +51 -0
  27. package/plugins/yak/login.ts +300 -0
  28. package/plugins/yak/yak-context.ts +280 -0
  29. package/plugins/yak/yak-identity.ts +399 -0
  30. package/plugins/yak/yak-redact.ts +686 -0
  31. package/plugins/yak/yak-setup.ts +133 -0
  32. package/plugins/yak/yak-submit.ts +1166 -0
  33. package/plugins/yak/yak.ts +485 -0
  34. package/src/build.ts +248 -0
  35. package/src/cli/args.ts +434 -0
  36. package/src/cli/commands/completion/bash.ts +24 -0
  37. package/src/cli/commands/completion/domain.ts +203 -0
  38. package/src/cli/commands/completion/emit.ts +246 -0
  39. package/src/cli/commands/completion/shell.ts +24 -0
  40. package/src/cli/commands/completion/zsh.ts +24 -0
  41. package/src/cli/commands/completion.ts +48 -0
  42. package/src/cli/commands/update/github.ts +328 -0
  43. package/src/cli/commands/update/opt-in.ts +32 -0
  44. package/src/cli/commands/update/refusal.ts +21 -0
  45. package/src/cli/commands/update/release.ts +351 -0
  46. package/src/cli/commands/update/replace.ts +143 -0
  47. package/src/cli/commands/update/semver.ts +48 -0
  48. package/src/cli/commands/update.ts +47 -0
  49. package/src/cli/commands/usage.ts +68 -0
  50. package/src/cli/commands/version.ts +29 -0
  51. package/src/cli/compose.ts +273 -0
  52. package/src/cli/output.ts +81 -0
  53. package/src/cli/plugin.ts +85 -0
  54. package/src/cli/registry.ts +180 -0
  55. package/src/cli/router.ts +31 -0
  56. package/src/cli/ts-verb.ts +126 -0
  57. package/src/main.ts +223 -0
  58. package/src/runtime/env.ts +16 -0
  59. package/src/runtime/eris-assets.d.ts +5 -0
  60. package/src/runtime/home.ts +306 -0
  61. package/src/runtime/logging.ts +43 -0
  62. package/src/runtime/process.ts +388 -0
  63. package/src/runtime/settings.ts +191 -0
  64. package/src/sdk.ts +62 -0
  65. package/testing/build-child.ts +55 -0
  66. package/testing/fake-runner.ts +23 -0
  67. package/testing/index.ts +16 -0
  68. package/ts/lib/eris-fail.ts +180 -0
  69. package/ts/lib/pool.ts +63 -0
@@ -0,0 +1,399 @@
1
+ /**
2
+ * yak-identity.ts — token/URL config resolution and caller identity for
3
+ * `eris yak`.
4
+ *
5
+ * Owns the parts of a yak submission that are ABOUT THE CALLER, not about the
6
+ * report: which Yaks endpoint to talk to, what bearer token authenticates the
7
+ * call, a stable per-machine `device_id`, and the task-scoped `execution_id`.
8
+ * `plugins/yak/yak-context.ts` (sibling
9
+ * file, same phase) owns the richer best-effort PROVENANCE record and reuses
10
+ * `runBounded` from here for its own bounded git probes.
11
+ *
12
+ * Every resolver returns a typed outcome — `{ value }` on success, `{ reason,
13
+ * slug }` on failure — and NEVER throws. A caller composing several of these
14
+ * tells them apart with one discriminant, `"reason" in result`. The exit split — agent misuse is a hard exit 1, an
15
+ * environment/config/transport problem is a warn + exit 0 — is made by
16
+ * `yak-submit.ts`/`yak.ts`, not here: this module only produces the typed
17
+ * failure and its slug, and leaves the exit-code mapping to the layer that
18
+ * knows the difference.
19
+ *
20
+ * Config file: ONE `~/.eris/yaks.json` holding
21
+ * `{ url?, token? }`, `0600`. `~/.eris` itself is `0755` (observed)
22
+ * — the FILE's mode is the only guard, so a group- or world-readable
23
+ * `yaks.json` is refused outright, never parsed, never logged. The token
24
+ * value itself never appears in any error message this module produces.
25
+ *
26
+ * `device-id`: minted once with
27
+ * `crypto.randomUUID()` if absent, written `0600` EXPLICITLY via `chmod`
28
+ * after creation — never inferred from the directory's `0755` mode or from
29
+ * whatever the process umask happens to be. Concurrency note: two literally
30
+ * simultaneous first-time callers may each mint their own uuid and race the
31
+ * final `rename` — POSIX rename is atomic, so the file itself is never
32
+ * corrupted, but the exact interleaving of "my rename finished" vs "the
33
+ * other racer's rename finished" is not observable from inside either call,
34
+ * so this is "concurrent-safe enough" (plan's own phrase) in the sense that
35
+ * matters: the file always holds one well-formed uuid and every caller after
36
+ * the dust settles reads the same value. It is not a distributed lock.
37
+ */
38
+
39
+ import { promises as fs } from "node:fs";
40
+ import { join } from "node:path";
41
+ import { homedir } from "node:os";
42
+
43
+ // --- Slugs ----------------------------------------------------------------
44
+
45
+ /**
46
+ * The `docs/ERRORS.md` slugs this module emits. `tests/errors-doc.test.ts`
47
+ * harvests the members of every `…Slug` union and fails when a `### <slug>`
48
+ * heading is missing.
49
+ */
50
+ export type YakIdentitySlug =
51
+ | "yak-config-permissive"
52
+ | "yak-config-malformed"
53
+ | "yak-config-unwritable"
54
+ | "yak-token-unresolved"
55
+ | "yak-device-id-unwritable";
56
+
57
+ /** One resolver's failure: a human-facing reason and its slug, paired on one
58
+ * line. */
59
+ export type IdentityFailure<TSlug extends string = YakIdentitySlug> = {
60
+ reason: string;
61
+ slug: TSlug;
62
+ };
63
+
64
+ /** One resolver's success. */
65
+ export type IdentityOk<T> = { value: T };
66
+
67
+ /** `{ value }` on success, `{ reason, slug }` on failure — discriminated the
68
+ * same way `PragmaRead` is: `"reason" in outcome`. */
69
+ export type IdentityOutcome<T, TSlug extends string = YakIdentitySlug> =
70
+ | IdentityOk<T>
71
+ | IdentityFailure<TSlug>;
72
+
73
+ // --- Paths ------------------------------------------------------------------
74
+
75
+ export function yaksConfigPath(home: string = homedir()): string {
76
+ return join(home, ".eris", "yaks.json");
77
+ }
78
+
79
+ export function deviceIdPath(home: string = homedir()): string {
80
+ return join(home, ".eris", "device-id");
81
+ }
82
+
83
+ // --- Token/URL config file ---------------------------------------------------
84
+
85
+ export type YaksFileConfig = { url?: string; token?: string };
86
+
87
+ /**
88
+ * Read `~/.eris/yaks.json`. An ABSENT file is not an error — `{ value: {} }`
89
+ * lets `ERIS_YAKS_TOKEN`/`ERIS_YAKS_URL` alone supply what's needed. A group-
90
+ * or world-READABLE file is refused before a single byte of its content is
91
+ * read: `fs.stat` runs, the mode bits are checked, and only then does
92
+ * `fs.readFile` run — so a permissive file's token can never reach a log
93
+ * line or an error message by construction, not by discipline.
94
+ */
95
+ export async function readYaksFileConfig(
96
+ home: string = homedir(),
97
+ ): Promise<IdentityOutcome<YaksFileConfig, "yak-config-permissive" | "yak-config-malformed">> {
98
+ const path = yaksConfigPath(home);
99
+
100
+ let stat: Awaited<ReturnType<typeof fs.stat>>;
101
+ try {
102
+ stat = await fs.stat(path);
103
+ } catch (err) {
104
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return { value: {} };
105
+ return {
106
+ reason: `cannot stat ${path}: ${(err as Error).message}`,
107
+ slug: "yak-config-malformed",
108
+ };
109
+ }
110
+
111
+ // Group-read (0o040) or other-read (0o004) — same two bits a `chmod 0644`
112
+ // sets. ~/.eris is 0755 (observed) so this is the only guard.
113
+ const groupOrWorldReadable = (stat.mode & 0o044) !== 0;
114
+ if (groupOrWorldReadable) {
115
+ const mode = (stat.mode & 0o777).toString(8).padStart(3, "0");
116
+ return {
117
+ reason: `${path} is group- or world-readable (mode ${mode}) — refusing to read a bearer-token file with permissive bits`,
118
+ slug: "yak-config-permissive",
119
+ };
120
+ }
121
+
122
+ let raw: string;
123
+ try {
124
+ raw = await fs.readFile(path, "utf8");
125
+ } catch (err) {
126
+ return {
127
+ reason: `cannot read ${path}: ${(err as Error).message}`,
128
+ slug: "yak-config-malformed",
129
+ };
130
+ }
131
+
132
+ let parsed: unknown;
133
+ try {
134
+ parsed = JSON.parse(raw);
135
+ } catch {
136
+ return { reason: `${path} is not valid JSON`, slug: "yak-config-malformed" };
137
+ }
138
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
139
+ return { reason: `${path} must be a JSON object`, slug: "yak-config-malformed" };
140
+ }
141
+
142
+ const obj = parsed as Record<string, unknown>;
143
+ const config: YaksFileConfig = {};
144
+ if (typeof obj.url === "string" && obj.url.length > 0) config.url = obj.url;
145
+ if (typeof obj.token === "string" && obj.token.length > 0) config.token = obj.token;
146
+ return { value: config };
147
+ }
148
+
149
+ /**
150
+ * Replace `~/.eris/yaks.json` with `config`, whole. The temp file sits in the
151
+ * same directory and is CREATED at 0600 (`wx`: it never exists readable, and
152
+ * an existing path is never followed), then renamed over the destination, so
153
+ * a reader sees the old file or the new one and never a half-written or
154
+ * readable one. `~/.eris` is created 0700 when absent. The failure names the
155
+ * path, never a field's value. Returns the path written.
156
+ */
157
+ export async function writeYaksFileConfig(
158
+ config: YaksFileConfig,
159
+ home: string = homedir(),
160
+ ): Promise<IdentityOutcome<string, "yak-config-unwritable">> {
161
+ const path = yaksConfigPath(home);
162
+ const tmp = join(home, ".eris", `.yaks.json.tmp-${process.pid}-${crypto.randomUUID()}`);
163
+ const body: YaksFileConfig = {};
164
+ if (config.url !== undefined) body.url = config.url;
165
+ if (config.token !== undefined) body.token = config.token;
166
+ try {
167
+ await fs.mkdir(join(home, ".eris"), { recursive: true, mode: 0o700 });
168
+ await fs.writeFile(tmp, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600, flag: "wx" });
169
+ await fs.rename(tmp, path);
170
+ return { value: path };
171
+ } catch (err) {
172
+ await fs.rm(tmp, { force: true }).catch(() => undefined);
173
+ return { reason: `cannot write ${path}: ${(err as NodeJS.ErrnoException).code ?? "error"}`, slug: "yak-config-unwritable" };
174
+ }
175
+ }
176
+
177
+ /** `ERIS_YAKS_TOKEN` env → `yaks.json`'s `token`. Never includes
178
+ * the resolved token's own value in a failure message — there IS no token on
179
+ * the failure path, by construction. */
180
+ export function resolveToken(
181
+ fileConfig: YaksFileConfig,
182
+ env: Record<string, string | undefined> = process.env,
183
+ ): IdentityOutcome<string, "yak-token-unresolved"> {
184
+ const fromEnv = env.ERIS_YAKS_TOKEN;
185
+ if (fromEnv && fromEnv.length > 0) return { value: fromEnv };
186
+ if (fileConfig.token && fileConfig.token.length > 0) return { value: fileConfig.token };
187
+ return {
188
+ reason: "no yaks token: run 'eris login', or set ERIS_YAKS_TOKEN",
189
+ slug: "yak-token-unresolved",
190
+ };
191
+ }
192
+
193
+ /** Where a Yaks endpoint lives when nothing names another one. */
194
+ export const DEFAULT_YAKS_URL = "https://api.sibylline.group";
195
+
196
+ /**
197
+ * The one Yaks base URL resolver, for `eris login` and every yak client:
198
+ * `--url` (the caller passes the flag's value) → `ERIS_YAKS_URL` env →
199
+ * `yaks.json`'s `url` → `DEFAULT_YAKS_URL`. An empty value at any step falls
200
+ * through to the next, so a URL always resolves; `source` says which step won.
201
+ */
202
+ export type YaksUrlSource = "flag" | "env" | "config" | "default";
203
+
204
+ export function resolveUrl(
205
+ fileConfig: YaksFileConfig,
206
+ env: Record<string, string | undefined> = process.env,
207
+ flag?: string,
208
+ ): { value: string; source: YaksUrlSource } {
209
+ if (flag !== undefined && flag.length > 0) return { value: flag, source: "flag" };
210
+ const fromEnv = env.ERIS_YAKS_URL;
211
+ if (fromEnv && fromEnv.length > 0) return { value: fromEnv, source: "env" };
212
+ if (fileConfig.url && fileConfig.url.length > 0) return { value: fileConfig.url, source: "config" };
213
+ return { value: DEFAULT_YAKS_URL, source: "default" };
214
+ }
215
+
216
+ // --- device_id ----------------------------------------------------------------
217
+
218
+ /**
219
+ * Read `~/.eris/device-id`, minting it once (uuid4, `0600`) if absent.
220
+ * Written via temp-file-in-the-same-dir + rename (the atomic-install idiom),
221
+ * so a
222
+ * reader never observes a partially-written id, and the mode is set with an
223
+ * explicit `chmod` after creation rather than trusted to `open()`'s mode
224
+ * argument (which is still subject to umask at the syscall level) or to the
225
+ * directory's `0755`.
226
+ */
227
+ export async function resolveDeviceId(
228
+ home: string = homedir(),
229
+ ): Promise<IdentityOutcome<string, "yak-device-id-unwritable">> {
230
+ const dir = join(home, ".eris");
231
+ const finalPath = deviceIdPath(home);
232
+
233
+ try {
234
+ const existing = (await fs.readFile(finalPath, "utf8")).trim();
235
+ if (existing.length > 0) return { value: existing };
236
+ } catch (err) {
237
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
238
+ return {
239
+ reason: `cannot read ${finalPath}: ${(err as Error).message}`,
240
+ slug: "yak-device-id-unwritable",
241
+ };
242
+ }
243
+ }
244
+
245
+ const minted = crypto.randomUUID();
246
+ const tmpPath = join(dir, `.device-id.tmp-${process.pid}-${crypto.randomUUID()}`);
247
+ try {
248
+ await fs.mkdir(dir, { recursive: true });
249
+ await fs.writeFile(tmpPath, `${minted}\n`, { mode: 0o600 });
250
+ await fs.chmod(tmpPath, 0o600); // explicit — never rely on umask or the 0755 directory mode
251
+ await fs.rename(tmpPath, finalPath);
252
+ } catch (err) {
253
+ await fs.rm(tmpPath, { force: true }).catch(() => undefined);
254
+ return {
255
+ reason: `cannot mint device id at ${finalPath}: ${(err as Error).message}`,
256
+ slug: "yak-device-id-unwritable",
257
+ };
258
+ }
259
+
260
+ // Best-effort convergence: re-read whatever ended up on disk so this call's
261
+ // return value matches the file even if another racer's rename landed
262
+ // after ours (see header — this is not a distributed lock).
263
+ try {
264
+ const onDisk = (await fs.readFile(finalPath, "utf8")).trim();
265
+ return { value: onDisk.length > 0 ? onDisk : minted };
266
+ } catch {
267
+ return { value: minted };
268
+ }
269
+ }
270
+
271
+ // --- execution_id ---------------------------------------------------------
272
+
273
+ /**
274
+ * `ERIS_YAK_EXECUTION_ID` → `CLAUDE_CODE_SESSION_ID` → `CLAUDE_CODE_HOST_SESSION_ID`
275
+ * → `ERIS_YAK_SHELL_ID` → `sh:<ppid>` (the shell-session rung is explained
276
+ * below). The rungs are NOT interchangeable values and
277
+ * a fallback — they can hold genuinely different ids under
278
+ * `CLAUDE_CODE_ENTRYPOINT=claude-desktop` (observed), which changes
279
+ * the dedup GRAIN a yak submission lands at. `rung` is returned alongside
280
+ * `value` so a caller that cares (logging, the receipt's provenance) can see
281
+ * which one fired without re-deriving the ladder.
282
+ *
283
+ * The id should be "shared by all reports in that task". Outside Claude Code,
284
+ * NEITHER `CLAUDE_CODE_SESSION_ID` nor `CLAUDE_CODE_HOST_SESSION_ID` is set,
285
+ * so every plain-terminal `eris yak` call fell straight to `sh:<ppid>` — and
286
+ * `ppid` there is `process.ppid` as OBSERVED BY `bun`, not by the human's
287
+ * shell: whenever a launcher forks between that shell and bun, the observed
288
+ * ppid is the fork's, fresh on EVERY invocation, so back-to-back `eris yak`
289
+ * calls from one terminal mint different execution_ids and therefore
290
+ * different idempotency keys, defeating dedup entirely. `ERIS_YAK_SHELL_ID`
291
+ * is the pid of the shell that invoked `eris` — set by the `eris yak` verb
292
+ * (`plugins/yak/handlers.ts`: the router's `process.ppid`, since the shim
293
+ * `exec`s bun) — which stays constant
294
+ * across every `eris yak` call from that terminal/session for as long as that
295
+ * shell lives. It sits between the CLAUDE_CODE rungs and the final `sh:<ppid>`
296
+ * fallback: still process-identity-based (a pid CAN theoretically be reused
297
+ * after that shell exits, the same caveat every rung below "override"
298
+ * already carries), but stable for exactly the unit wanted outside
299
+ * Claude Code — one human's one terminal session — where the two rungs above
300
+ * it are never set at all. The final `sh:<ppid>` rung is UNCHANGED and still
301
+ * fires for a caller that bypasses the `eris yak` verb entirely (invoking
302
+ * `yak.ts` directly, as this module's own tests do) — there, `ERIS_YAK_SHELL_ID`
303
+ * is legitimately absent and no shell-session value exists to prefer.
304
+ */
305
+ export type ExecutionIdRung = "override" | "session" | "host-session" | "shell-session" | "shell";
306
+
307
+ export function resolveExecutionId(
308
+ env: Record<string, string | undefined> = process.env,
309
+ ppid: number = process.ppid,
310
+ ): { value: string; rung: ExecutionIdRung } {
311
+ const override = env.ERIS_YAK_EXECUTION_ID;
312
+ if (override && override.length > 0) return { value: override, rung: "override" };
313
+ const session = env.CLAUDE_CODE_SESSION_ID;
314
+ if (session && session.length > 0) return { value: session, rung: "session" };
315
+ const hostSession = env.CLAUDE_CODE_HOST_SESSION_ID;
316
+ if (hostSession && hostSession.length > 0) return { value: hostSession, rung: "host-session" };
317
+ const shellId = env.ERIS_YAK_SHELL_ID;
318
+ if (shellId && shellId.length > 0) return { value: `shell:${shellId}`, rung: "shell-session" };
319
+ return { value: `sh:${ppid}`, rung: "shell" };
320
+ }
321
+
322
+ // --- Bounded subprocess runner (shared with yak-context.ts) -----------------
323
+
324
+ /**
325
+ * A subprocess's outcome: `{ ok: true, stdout }` on a clean exit, `{ ok:
326
+ * false }` on ANY failure (spawn error, non-zero exit, timeout kill). Kept
327
+ * apart from "trimmed non-empty or undefined" on purpose — `git status
328
+ * --porcelain` on a clean tree exits 0 with EMPTY stdout, and collapsing that
329
+ * to "absent" the way an empty free-text field would be would confuse
330
+ * "measured: clean" with "not measured".
331
+ * Callers that want "empty means absent" (a git sha, an origin url) call
332
+ * `asText` on the result; callers that need to tell
333
+ * "clean" from "unmeasured" (a dirty flag) read `.ok` and `.stdout` directly.
334
+ */
335
+ export type BoundedResult = { ok: true; stdout: string } | { ok: false };
336
+
337
+ export type Runner = (cmd: string[], cwd: string, timeoutMs: number) => Promise<BoundedResult>;
338
+
339
+ /**
340
+ * Spawn `cmd`, capture stdout, and NEVER throw or hang past `timeoutMs`: the
341
+ * process is killed on timeout and `proc.exited` is always awaited (in every
342
+ * branch, including the timeout/error ones) so nothing is left backgrounded.
343
+ * A process that is tracked by the wrong pid, or spawned and never reaped,
344
+ * outlives its caller and can hold a pipe open forever. `Bun.spawn` here is
345
+ * always awaited to exit before this function returns.
346
+ */
347
+ export async function runBounded(cmd: string[], cwd: string, timeoutMs: number): Promise<BoundedResult> {
348
+ let proc: ReturnType<typeof Bun.spawn>;
349
+ try {
350
+ proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "ignore", stdin: "ignore" });
351
+ } catch {
352
+ return { ok: false };
353
+ }
354
+
355
+ let timedOut = false;
356
+ const timer = setTimeout(() => {
357
+ timedOut = true;
358
+ try {
359
+ proc.kill();
360
+ } catch {
361
+ // already exited
362
+ }
363
+ }, timeoutMs);
364
+
365
+ try {
366
+ // `stdout: "pipe"` above guarantees a ReadableStream at runtime; Bun's
367
+ // overload resolution doesn't narrow `proc.stdout`'s type from a plain
368
+ // `string[]` cmd argument, so this cast just states what we already
369
+ // asked for.
370
+ const stdout = await new Response(proc.stdout as ReadableStream<Uint8Array>).text();
371
+ const code = await proc.exited;
372
+ if (code !== 0 || timedOut) return { ok: false };
373
+ return { ok: true, stdout };
374
+ } catch {
375
+ return { ok: false };
376
+ } finally {
377
+ clearTimeout(timer);
378
+ // Reap unconditionally: on the happy path `proc.exited` above already
379
+ // settled and this resolves immediately; on the throw path it may not
380
+ // have been awaited yet, so this is what guarantees the child is never
381
+ // left running past this function's return.
382
+ try {
383
+ proc.kill();
384
+ } catch {
385
+ // already exited
386
+ }
387
+ await proc.exited.catch(() => undefined);
388
+ }
389
+ }
390
+
391
+ /** Convenience for the common "trimmed non-empty string, else absent" case —
392
+ * a git sha/branch/origin/root. See
393
+ * `BoundedResult`'s header for why the boolean-fact case (dirty) must NOT
394
+ * go through this. */
395
+ export function asText(result: BoundedResult): string | undefined {
396
+ if (!result.ok) return undefined;
397
+ const trimmed = result.stdout.trim();
398
+ return trimmed.length > 0 ? trimmed : undefined;
399
+ }