@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,300 @@
1
+ /**
2
+ * login.ts — `eris login` and `eris logout`, loaded only when one dispatches
3
+ * (the manifest is ./index.ts).
4
+ *
5
+ * `eris login` is an RFC 8628 device-authorization client. It starts a grant at
6
+ * `<url>/v1/device/code`, shows the user code and the verification URI (and
7
+ * opens a browser when it can), then polls `<url>/v1/device/token` until the
8
+ * code is approved, denied or expired. The bearer it is handed goes into
9
+ * `~/.eris/yaks.json`, the one file `eris yak` reads (./yak-identity.ts), with
10
+ * the url beside it. The url comes from `resolveUrl`, the one resolver `eris
11
+ * yak` also reads: `--url`, else `ERIS_YAKS_URL`, else the file's own `url`,
12
+ * else `DEFAULT_YAKS_URL`, so there is always an endpoint (the parser refuses
13
+ * an empty `--url`).
14
+ *
15
+ * `eris logout` removes the token from that file and keeps the url. No route
16
+ * lets a bearer revoke itself, so the token stays valid server-side until it is
17
+ * revoked from the dashboard's API tokens settings; the command says so.
18
+ *
19
+ * Invariants:
20
+ * - Neither the device code nor the bearer is ever written to stdout or
21
+ * stderr. The user code is the one value shown, and it is not a secret on
22
+ * its own.
23
+ * - A grant that does not end approved writes nothing: the file is replaced
24
+ * once, after a token has been received, through `writeYaksFileConfig`
25
+ * (created 0600, renamed into place).
26
+ * - The poll interval only grows: `slow_down` raises it for every later poll
27
+ * (to the interval it carries, else by 5 s); a 429 waits out `retry-after`
28
+ * for one poll. The code's `expires_in` ends the loop locally even if the
29
+ * server never says `expired_token`.
30
+ * - Two error shapes are read: RFC 6749's `{error: "<code>"}` (every 400) and
31
+ * the gateway's `{error: {code, message}}` (429, 503). A 5xx or a dropped
32
+ * connection while polling is retried up to `MAX_TRANSIENT` times in a
33
+ * row, so a blip after the user approved does not throw the approval away.
34
+ * - Text from the server is shown only after control characters are
35
+ * stripped, so a response cannot drive the terminal.
36
+ */
37
+
38
+ import { hostname as osHostname } from "node:os";
39
+ import { renderFailureBlock, renderWarnLines, readArgs, type VerbContext } from "../../src/cli/plugin";
40
+ import plugin from "./index";
41
+ import { DEFAULT_YAKS_URL, readYaksFileConfig, resolveUrl, runBounded, writeYaksFileConfig, yaksConfigPath } from "./yak-identity";
42
+ import { promises as fs } from "node:fs";
43
+
44
+ /** The `docs/ERRORS.md` slugs this module raises (`tests/errors-doc.test.ts`). */
45
+ export type LoginSlug =
46
+ | "login-unreachable"
47
+ | "login-refused"
48
+ | "login-denied"
49
+ | "login-expired"
50
+ | "login-env-token-overrides";
51
+
52
+ const DEVICE_AUTHORIZATION_PATH = "/v1/device/code";
53
+ const DEVICE_TOKEN_PATH = "/v1/device/token";
54
+ const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
55
+ /** RFC 8628 §3.5: what `slow_down` adds when it carries no interval. */
56
+ const SLOW_DOWN_STEP_MS = 5_000;
57
+ /** Consecutive 5xx or dropped polls tolerated before giving up. */
58
+ const MAX_TRANSIENT = 3;
59
+ /** One request's whole exchange, headers and body. */
60
+ const REQUEST_TIMEOUT_MS = 15_000;
61
+ /** The gateway caps a device label at 128 characters. */
62
+ const MAX_LABEL = 128;
63
+
64
+ export const LOGIN_USAGE = `Usage: eris login [--url <url>] [--no-browser]
65
+
66
+ Sign this machine in with a device code: approve the code shown here in your
67
+ browser, and the token is written to ~/.eris/yaks.json (mode 0600), where
68
+ 'eris yak' reads it. ERIS_YAKS_TOKEN, when set, still wins over the file.
69
+
70
+ --url <url> the endpoint (default: $ERIS_YAKS_URL, then the url
71
+ already in ~/.eris/yaks.json, then ${DEFAULT_YAKS_URL})
72
+ --no-browser print the link without trying to open a browser
73
+ `;
74
+
75
+ export const LOGOUT_USAGE = `Usage: eris logout
76
+
77
+ Remove this machine's token from ~/.eris/yaks.json, keeping the url. The token
78
+ stays valid until it is revoked under Settings > API Tokens.
79
+ `;
80
+
81
+ export interface LoginDeps {
82
+ home?: string;
83
+ hostname?: () => string;
84
+ now?: () => number;
85
+ sleep?: (ms: number) => Promise<void>;
86
+ /** Open `url` in a browser, best effort. The default does so only when
87
+ * stderr is a terminal. */
88
+ openBrowser?: (url: string) => Promise<void>;
89
+ }
90
+
91
+ type Grant = {
92
+ deviceCode: string;
93
+ userCode: string;
94
+ verificationUri: string;
95
+ expiresInMs: number;
96
+ intervalMs: number;
97
+ };
98
+
99
+ type Exchange = { kind: "response"; status: number; headers: Headers; body: unknown } | { kind: "unreachable"; reason: string };
100
+
101
+ /** Drop control characters, so text a server sent cannot drive the terminal. */
102
+ function printable(text: string): string {
103
+ return text.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
104
+ }
105
+
106
+ /** The error code from either shape: RFC `{error: "<code>"}` or the gateway's `{error: {code}}`. */
107
+ function errorCode(status: number, body: unknown): string {
108
+ if (typeof body === "object" && body !== null) {
109
+ const error = (body as Record<string, unknown>).error;
110
+ if (typeof error === "string") return printable(error);
111
+ if (typeof error === "object" && error !== null) {
112
+ const { code, message } = error as Record<string, unknown>;
113
+ if (typeof code === "string") return printable(typeof message === "string" ? `${code}: ${message}` : code);
114
+ }
115
+ }
116
+ return `HTTP ${status}`;
117
+ }
118
+
119
+ async function post(url: string, form: Record<string, string>): Promise<Exchange> {
120
+ try {
121
+ const res = await fetch(url, {
122
+ method: "POST",
123
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
124
+ body: new URLSearchParams(form).toString(),
125
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
126
+ });
127
+ let body: unknown = null;
128
+ try {
129
+ body = await res.json();
130
+ } catch {
131
+ // A body that is not JSON reads as no body; the status still decides.
132
+ }
133
+ return { kind: "response", status: res.status, headers: res.headers, body };
134
+ } catch (err) {
135
+ const name = (err as { name?: string }).name;
136
+ return { kind: "unreachable", reason: name === "TimeoutError" ? `no answer within ${REQUEST_TIMEOUT_MS / 1000}s` : printable((err as Error).message) };
137
+ }
138
+ }
139
+
140
+ function readGrant(body: unknown): Grant | null {
141
+ if (typeof body !== "object" || body === null) return null;
142
+ const b = body as Record<string, unknown>;
143
+ const { device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval } = b;
144
+ if (typeof device_code !== "string" || typeof user_code !== "string" || typeof verification_uri !== "string") return null;
145
+ if (typeof expires_in !== "number" || !(expires_in > 0)) return null;
146
+ return {
147
+ deviceCode: device_code,
148
+ userCode: printable(user_code),
149
+ verificationUri: printable(typeof verification_uri_complete === "string" ? verification_uri_complete : verification_uri),
150
+ expiresInMs: expires_in * 1000,
151
+ intervalMs: (typeof interval === "number" && interval > 0 ? interval : 5) * 1000,
152
+ };
153
+ }
154
+
155
+ /** `<host> (eris)`, or `<host> (eris, claude-code)` under Claude Code: the
156
+ * name the minted token carries in the dashboard's token list. */
157
+ function deviceLabel(host: string, env: Record<string, string | undefined>): string {
158
+ const short = host.split(".")[0] || "unknown-host";
159
+ const harness = env.CLAUDECODE === "1" ? ", claude-code" : "";
160
+ return `${short} (eris${harness})`.slice(0, MAX_LABEL);
161
+ }
162
+
163
+ async function defaultOpenBrowser(url: string): Promise<void> {
164
+ if (!process.stderr.isTTY) return;
165
+ const cmd = process.platform === "darwin" ? ["open", url] : process.platform === "linux" ? ["xdg-open", url] : null;
166
+ if (cmd !== null) await runBounded(cmd, process.cwd(), 5_000);
167
+ }
168
+
169
+ function fail(ctx: VerbContext, slug: LoginSlug | "yak-config-permissive" | "yak-config-malformed" | "yak-config-unwritable", what: string, fix: string): number {
170
+ ctx.stderr.write(renderFailureBlock({ slug, what, fix }, ctx.erisDir));
171
+ return 1;
172
+ }
173
+
174
+ export async function runLogin(args: string[], ctx: VerbContext, deps: LoginDeps = {}): Promise<number> {
175
+ const a = readArgs(plugin.rows, "login", args, ctx, { help: LOGIN_USAGE });
176
+ if (a === null) return 0;
177
+ const home = deps.home ?? ctx.env.HOME ?? "";
178
+ const now = deps.now ?? Date.now;
179
+ const sleep = deps.sleep ?? ((ms: number) => Bun.sleep(ms));
180
+ const openBrowser = deps.openBrowser ?? defaultOpenBrowser;
181
+
182
+ const file = await readYaksFileConfig(home);
183
+ if ("reason" in file) return fail(ctx, file.slug, file.reason, `fix or remove ${yaksConfigPath(home)}, then run 'eris login' again`);
184
+
185
+ const url = resolveUrl(file.value, ctx.env, a.flags["--url"]).value;
186
+ const base = url.replace(/\/+$/, "");
187
+
188
+ // --- start -------------------------------------------------------------------
189
+ const started = await post(`${base}${DEVICE_AUTHORIZATION_PATH}`, { device: deviceLabel((deps.hostname ?? osHostname)(), ctx.env) });
190
+ if (started.kind === "unreachable") {
191
+ return fail(ctx, "login-unreachable", `cannot reach ${printable(base)}: ${started.reason}`, "check the url and your connection, then run 'eris login' again");
192
+ }
193
+ const grant = started.status === 200 ? readGrant(started.body) : null;
194
+ if (grant === null) {
195
+ const why = started.status === 200 ? "a malformed device authorization response" : errorCode(started.status, started.body);
196
+ return fail(ctx, "login-refused", `${printable(base)} refused to start a device login (${why})`, "check the url points at an endpoint with device login, then run 'eris login' again");
197
+ }
198
+
199
+ const opens = !a.flags["--no-browser"] && /^https?:\/\//i.test(grant.verificationUri);
200
+ ctx.stderr.write(
201
+ `To sign this machine in, open:\n\n ${grant.verificationUri}\n\nand check it shows the code: ${grant.userCode}\n` +
202
+ `Waiting for approval (the code expires in ${Math.round(grant.expiresInMs / 60_000)} min)...\n`,
203
+ );
204
+ if (opens) await openBrowser(grant.verificationUri).catch(() => undefined);
205
+
206
+ // --- poll --------------------------------------------------------------------
207
+ const deadline = now() + grant.expiresInMs;
208
+ let interval = grant.intervalMs;
209
+ let wait = interval;
210
+ let transient = 0;
211
+ for (;;) {
212
+ await sleep(wait);
213
+ wait = interval;
214
+ if (now() >= deadline) {
215
+ return fail(ctx, "login-expired", "the device code expired before it was approved", "run 'eris login' again and approve the new code within its lifetime");
216
+ }
217
+ const polled = await post(`${base}${DEVICE_TOKEN_PATH}`, { grant_type: DEVICE_CODE_GRANT_TYPE, device_code: grant.deviceCode });
218
+
219
+ if (polled.kind === "unreachable" || polled.status >= 500) {
220
+ if (++transient > MAX_TRANSIENT) {
221
+ const why = polled.kind === "unreachable" ? polled.reason : errorCode(polled.status, polled.body);
222
+ return fail(ctx, "login-unreachable", `lost ${printable(base)} while waiting for approval (${why})`, "run 'eris login' again");
223
+ }
224
+ continue;
225
+ }
226
+ transient = 0;
227
+
228
+ if (polled.status === 429) {
229
+ const retryAfter = Number(polled.headers.get("retry-after"));
230
+ if (Number.isFinite(retryAfter) && retryAfter * 1000 > wait) wait = retryAfter * 1000;
231
+ continue;
232
+ }
233
+
234
+ if (polled.status === 200) {
235
+ const body = polled.body as Record<string, unknown> | null;
236
+ const token = body?.access_token;
237
+ if (typeof token !== "string" || token === "" || String(body?.token_type).toLowerCase() !== "bearer") {
238
+ return fail(ctx, "login-refused", "the endpoint approved the code but sent no bearer token", "run 'eris login' again");
239
+ }
240
+ const written = await writeYaksFileConfig({ url, token }, home);
241
+ if ("reason" in written) return fail(ctx, written.slug, written.reason, `make ${yaksConfigPath(home)} writable, then run 'eris login' again`);
242
+ if (file.value.token !== undefined && file.value.token !== token) {
243
+ ctx.stderr.write("replaced the token that was in the file; revoke it under Settings > API Tokens if nothing else uses it\n");
244
+ }
245
+ if (ctx.env.ERIS_YAKS_TOKEN) {
246
+ ctx.stderr.write(renderWarnLines("ERIS_YAKS_TOKEN is set and overrides the token just written; unset it to use this login", "login-env-token-overrides"));
247
+ }
248
+ const name = typeof body?.name === "string" ? printable(body.name) : "eris";
249
+ ctx.stdout.write(`logged in: token "${name}" written to ${written.value}\n`);
250
+ return 0;
251
+ }
252
+
253
+ const code = errorCode(polled.status, polled.body);
254
+ if (code === "authorization_pending") continue;
255
+ if (code === "slow_down") {
256
+ const raised = (polled.body as Record<string, unknown>).interval;
257
+ interval = typeof raised === "number" && raised * 1000 > interval ? raised * 1000 : interval + SLOW_DOWN_STEP_MS;
258
+ wait = interval;
259
+ continue;
260
+ }
261
+ if (code === "access_denied") {
262
+ return fail(ctx, "login-denied", "the sign-in was denied in the browser", "run 'eris login' again if that was not intended");
263
+ }
264
+ if (code === "expired_token") {
265
+ return fail(ctx, "login-expired", "the device code expired before it was approved", "run 'eris login' again and approve the new code within its lifetime");
266
+ }
267
+ return fail(ctx, "login-refused", `the endpoint refused the device code (${code})`, "run 'eris login' again");
268
+ }
269
+ }
270
+
271
+ export async function runLogout(args: string[], ctx: VerbContext, deps: Pick<LoginDeps, "home"> = {}): Promise<number> {
272
+ const a = readArgs(plugin.rows, "logout", args, ctx, { help: LOGOUT_USAGE });
273
+ if (a === null) return 0;
274
+ const home = deps.home ?? ctx.env.HOME ?? "";
275
+ const path = yaksConfigPath(home);
276
+
277
+ const file = await readYaksFileConfig(home);
278
+ if ("reason" in file) return fail(ctx, file.slug, file.reason, `fix or remove ${path}`);
279
+
280
+ if (file.value.token === undefined) {
281
+ ctx.stdout.write(`not logged in: no token in ${path}\n`);
282
+ } else {
283
+ if (file.value.url === undefined) {
284
+ try {
285
+ await fs.rm(path, { force: true });
286
+ } catch (err) {
287
+ return fail(ctx, "yak-config-unwritable", `cannot remove ${path}: ${(err as NodeJS.ErrnoException).code ?? "error"}`, `remove ${path} by hand`);
288
+ }
289
+ } else {
290
+ const written = await writeYaksFileConfig({ url: file.value.url }, home);
291
+ if ("reason" in written) return fail(ctx, written.slug, written.reason, `make ${path} writable, then run 'eris logout' again`);
292
+ }
293
+ ctx.stdout.write(`logged out: removed the token from ${path}\n`);
294
+ ctx.stderr.write("the token stays valid until it is revoked under Settings > API Tokens\n");
295
+ }
296
+ if (ctx.env.ERIS_YAKS_TOKEN) {
297
+ ctx.stderr.write(renderWarnLines("ERIS_YAKS_TOKEN is still set, so 'eris yak' keeps authenticating with it", "login-env-token-overrides"));
298
+ }
299
+ return 0;
300
+ }
@@ -0,0 +1,280 @@
1
+ /**
2
+ * yak-context.ts — best-effort provenance gather for `eris yak`.
3
+ *
4
+ * Produces a `YakProvenance` record (`@sibylline-labs/yak-contract`) — six
5
+ * optional categories, each a permissive record:
6
+ * `execution`, `harness`, `device`, `process`, `repo`, `git`. NEVER a gate,
7
+ * NEVER a delay a caller should notice: every individual probe is wrapped so
8
+ * one failure drops exactly that key, and the WHOLE gather is time-capped so
9
+ * a slow filesystem or a hung `git` cannot make `eris yak` itself hang.
10
+ *
11
+ * Explicitly OUT of this module, because a provenance probe must never spawn
12
+ * work it cannot bound or reap, or prompt the user:
13
+ *
14
+ * - no cloud CLI, no keychain read
15
+ * - no config merge (no root/leaf deep-merge, excludes or overrides) —
16
+ * `repo.service` below is a raw single-file peek at the nearest
17
+ * `eris.yaml`
18
+ * - no unbounded fan-out — exactly five bounded, individually-reaped `git`
19
+ * subprocesses (`gatherGit`), run in parallel, each capped at
20
+ * `GIT_PROBE_TIMEOUT_MS`
21
+ *
22
+ * `provenance` is redacted too: an origin URL can carry a token. That
23
+ * redaction pass is `plugins/yak/yak-redact.ts`, applied by the submission
24
+ * core (`yak-submit.ts`), which is where the whole payload — `summary`, `body`,
25
+ * `evidence`, AND this provenance record — gets one redaction pass before
26
+ * key derivation. This module only gathers; it does not redact.
27
+ */
28
+
29
+ import { promises as fs } from "node:fs";
30
+ import { dirname, join } from "node:path";
31
+ import { hostname } from "node:os";
32
+ import type { YakProvenance } from "eris/yak-contract";
33
+ import { asText, runBounded, type Runner } from "./yak-identity";
34
+
35
+ const GIT_PROBE_TIMEOUT_MS = 500;
36
+ /** Overall wall-clock budget for the whole gather. Generous headroom over
37
+ * the five GIT_PROBE_TIMEOUT_MS probes (which run in PARALLEL, so their
38
+ * worst case is ~one timeout, not five) plus the synchronous/fs-only
39
+ * categories, while still being "an agent won't notice this" fast for a
40
+ * mid-task CLI call. If this fires, every category still in flight is
41
+ * simply omitted — never awaited to completion, never thrown. */
42
+ const OVERALL_GATHER_TIMEOUT_MS = 1_500;
43
+ /** Filesystem-root backstop for the `repo.service` walk-up. A real checkout
44
+ * never nests this deep; this exists so a symlink cycle or an unexpectedly
45
+ * deep tree can't turn a "cheap peek" into an unbounded walk. */
46
+ const REPO_WALK_MAX_DEPTH = 64;
47
+
48
+ export type ProvenanceOptions = {
49
+ env?: Record<string, string | undefined>;
50
+ cwd?: string;
51
+ pid?: number;
52
+ ppid?: number;
53
+ run?: Runner;
54
+ };
55
+
56
+ /**
57
+ * Gather best-effort provenance. Never throws, never exceeds
58
+ * `OVERALL_GATHER_TIMEOUT_MS` in wall time regardless of how slow any single
59
+ * probe is, and every category it returns has at least one key (an empty
60
+ * category is omitted rather than emitted as `{}`: absent means nothing was
61
+ * measured, so an empty object would claim a measurement that never happened).
62
+ */
63
+ export async function gatherProvenance(opts: ProvenanceOptions = {}): Promise<YakProvenance> {
64
+ const env = opts.env ?? process.env;
65
+ const cwd = opts.cwd ?? process.cwd();
66
+ const pid = opts.pid ?? process.pid;
67
+ const ppid = opts.ppid ?? process.ppid;
68
+ const run = opts.run ?? runBounded;
69
+
70
+ const provenance: YakProvenance = {};
71
+
72
+ const categories: Array<[keyof YakProvenance, Promise<Record<string, unknown> | undefined>]> = [
73
+ ["execution", Promise.resolve(safeRecord(() => gatherExecution(env)))],
74
+ ["harness", Promise.resolve(safeRecord(() => gatherHarness(env)))],
75
+ ["device", Promise.resolve(safeRecord(() => gatherDevice()))],
76
+ ["process", Promise.resolve(safeRecord(() => gatherProcess(env, cwd, pid, ppid)))],
77
+ ["repo", safeRecordAsync(() => gatherRepo(cwd))],
78
+ ["git", safeRecordAsync(() => gatherGit(cwd, run))],
79
+ ];
80
+
81
+ let timer: ReturnType<typeof setTimeout> | undefined;
82
+ const deadline = new Promise<void>((resolve) => {
83
+ timer = setTimeout(resolve, OVERALL_GATHER_TIMEOUT_MS);
84
+ });
85
+
86
+ try {
87
+ await Promise.race([
88
+ Promise.allSettled(
89
+ categories.map(([key, p]) =>
90
+ p.then((rec) => {
91
+ if (rec !== undefined) provenance[key] = rec;
92
+ }),
93
+ ),
94
+ ),
95
+ deadline,
96
+ ]);
97
+ } finally {
98
+ if (timer !== undefined) clearTimeout(timer);
99
+ }
100
+
101
+ return provenance;
102
+ }
103
+
104
+ // --- per-field helpers --------------------------------------------------------
105
+
106
+ /** Set `rec[key]` only when `producer()` returns a defined value; a thrown
107
+ * error or an `undefined` return drops exactly this ONE field and nothing
108
+ * else in the record. */
109
+ function trySet<T>(rec: Record<string, unknown>, key: string, producer: () => T | undefined): void {
110
+ try {
111
+ const value = producer();
112
+ if (value !== undefined) rec[key] = value;
113
+ } catch {
114
+ // this field drops; every other field in the same record is unaffected
115
+ }
116
+ }
117
+
118
+ function nonEmpty(value: string | undefined): string | undefined {
119
+ return value && value.length > 0 ? value : undefined;
120
+ }
121
+
122
+ /** Wrap a whole category producer: catch anything it throws, and treat a
123
+ * zero-key result as ABSENT (omit the category) rather than emitting `{}` —
124
+ * a category nobody could speak to is different from a category that spoke
125
+ * and said nothing, and only the record's own fields (via `trySet`) can
126
+ * legitimately be empty of content. */
127
+ function safeRecord(producer: () => Record<string, unknown>): Record<string, unknown> | undefined {
128
+ try {
129
+ const rec = producer();
130
+ return Object.keys(rec).length > 0 ? rec : undefined;
131
+ } catch {
132
+ return undefined;
133
+ }
134
+ }
135
+
136
+ async function safeRecordAsync(
137
+ producer: () => Promise<Record<string, unknown>>,
138
+ ): Promise<Record<string, unknown> | undefined> {
139
+ try {
140
+ const rec = await producer();
141
+ return Object.keys(rec).length > 0 ? rec : undefined;
142
+ } catch {
143
+ return undefined;
144
+ }
145
+ }
146
+
147
+ // --- execution / harness / device / process (sync, no I/O) ------------------
148
+
149
+ /** Session identity — what task this yak belongs to, mirroring the
150
+ * `resolveExecutionId` ladder's own rungs (`yak-identity.ts`) as raw facts
151
+ * rather than the one resolved value, so a reader can see which rungs were
152
+ * actually populated in this process's environment. */
153
+ function gatherExecution(env: Record<string, string | undefined>): Record<string, unknown> {
154
+ const rec: Record<string, unknown> = {};
155
+ trySet(rec, "claude_code_session_id", () => nonEmpty(env.CLAUDE_CODE_SESSION_ID));
156
+ trySet(rec, "claude_code_host_session_id", () => nonEmpty(env.CLAUDE_CODE_HOST_SESSION_ID));
157
+ trySet(rec, "entrypoint", () => nonEmpty(env.CLAUDE_CODE_ENTRYPOINT));
158
+ return rec;
159
+ }
160
+
161
+ /** Which harness is running, and what version of it — the three env vars
162
+ * actually OBSERVED set in a live Claude Code session (`CLAUDECODE=1`,
163
+ * `AI_AGENT=claude-code_2-1-222_agent`, `CLAUDE_AGENT_SDK_VERSION=0.3.222`),
164
+ * not a guessed name. `AI_AGENT`'s value already embeds a version-shaped
165
+ * string; `CLAUDE_AGENT_SDK_VERSION` is a second, more literal version
166
+ * fact when the harness sets it. */
167
+ function gatherHarness(env: Record<string, string | undefined>): Record<string, unknown> {
168
+ const rec: Record<string, unknown> = {};
169
+ trySet(rec, "claudecode", () => nonEmpty(env.CLAUDECODE));
170
+ trySet(rec, "ai_agent", () => nonEmpty(env.AI_AGENT));
171
+ trySet(rec, "claude_agent_sdk_version", () => nonEmpty(env.CLAUDE_AGENT_SDK_VERSION));
172
+ return rec;
173
+ }
174
+
175
+ function gatherDevice(): Record<string, unknown> {
176
+ const rec: Record<string, unknown> = {};
177
+ trySet(rec, "hostname", () => nonEmpty(hostname()));
178
+ trySet(rec, "platform", () => nonEmpty(process.platform));
179
+ trySet(rec, "arch", () => nonEmpty(process.arch));
180
+ return rec;
181
+ }
182
+
183
+ function gatherProcess(
184
+ env: Record<string, string | undefined>,
185
+ cwd: string,
186
+ pid: number,
187
+ ppid: number,
188
+ ): Record<string, unknown> {
189
+ const rec: Record<string, unknown> = {};
190
+ trySet(rec, "cwd", () => nonEmpty(cwd));
191
+ trySet(rec, "pid", () => pid);
192
+ trySet(rec, "ppid", () => ppid);
193
+ trySet(rec, "shell", () => nonEmpty(env.SHELL));
194
+ return rec;
195
+ }
196
+
197
+ // --- repo (a raw single-file peek, never a config merge) -------------------
198
+
199
+ async function gatherRepo(cwd: string): Promise<Record<string, unknown>> {
200
+ const rec: Record<string, unknown> = {};
201
+ const service = await findNearestErisYamlService(cwd);
202
+ if (service !== undefined) rec.service = service;
203
+ return rec;
204
+ }
205
+
206
+ /**
207
+ * Walk up from `startDir` looking for the nearest `eris.yaml` and read its
208
+ * top-level `service:` field directly — no root/leaf merge, no
209
+ * excludes/overrides. Bounded two ways: a hard iteration ceiling
210
+ * (`REPO_WALK_MAX_DEPTH`) and stopping at the enclosing git checkout's root
211
+ * (a `.git` entry, file or directory), so this cheap peek never wanders into
212
+ * a sibling repo above the one it was invoked from.
213
+ */
214
+ async function findNearestErisYamlService(startDir: string): Promise<string | undefined> {
215
+ let dir = startDir;
216
+ for (let i = 0; i < REPO_WALK_MAX_DEPTH; i++) {
217
+ const candidate = join(dir, "eris.yaml");
218
+ try {
219
+ const raw = await fs.readFile(candidate, "utf8");
220
+ const parsed = Bun.YAML.parse(raw);
221
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
222
+ const service = (parsed as Record<string, unknown>).service;
223
+ if (typeof service === "string" && service.length > 0) return service;
224
+ }
225
+ // A manifest exists here but has no usable `service:` — it is still
226
+ // the NEAREST one, so stop rather than walking past it.
227
+ return undefined;
228
+ } catch (err) {
229
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") return undefined; // unreadable — drop, never throw
230
+ }
231
+
232
+ if (await isGitCheckoutRoot(dir)) return undefined; // reached the repo root with no manifest
233
+
234
+ const parent = dirname(dir);
235
+ if (parent === dir) return undefined; // filesystem root
236
+ dir = parent;
237
+ }
238
+ return undefined;
239
+ }
240
+
241
+ async function isGitCheckoutRoot(dir: string): Promise<boolean> {
242
+ try {
243
+ await fs.access(join(dir, ".git"));
244
+ return true;
245
+ } catch {
246
+ return false;
247
+ }
248
+ }
249
+
250
+ // --- git (five bounded, individually-reaped subprocesses) -------------------
251
+
252
+ async function gatherGit(cwd: string, run: Runner): Promise<Record<string, unknown>> {
253
+ const rec: Record<string, unknown> = {};
254
+
255
+ const [root, origin, branch, sha, status] = await Promise.all([
256
+ run(["git", "rev-parse", "--show-toplevel"], cwd, GIT_PROBE_TIMEOUT_MS),
257
+ run(["git", "config", "--get", "remote.origin.url"], cwd, GIT_PROBE_TIMEOUT_MS),
258
+ run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd, GIT_PROBE_TIMEOUT_MS),
259
+ run(["git", "rev-parse", "HEAD"], cwd, GIT_PROBE_TIMEOUT_MS),
260
+ run(["git", "status", "--porcelain"], cwd, GIT_PROBE_TIMEOUT_MS),
261
+ ]);
262
+
263
+ const rootText = asText(root);
264
+ if (rootText !== undefined) rec.root = rootText;
265
+ const originText = asText(origin);
266
+ if (originText !== undefined) rec.origin_url = originText;
267
+ const branchText = asText(branch);
268
+ if (branchText !== undefined) rec.branch = branchText;
269
+ const shaText = asText(sha);
270
+ if (shaText !== undefined) rec.head_sha = shaText;
271
+
272
+ // `dirty` is a BOOLEAN fact, not free text: absent only when the PROBE
273
+ // itself failed (no `git`, not a repo, timeout) — never when the tree is
274
+ // simply clean, which is a legitimate `false` produced by EMPTY stdout on
275
+ // exit 0. Routing this through `asText` (which treats empty as absent)
276
+ // would make a dirty tree indistinguishable from an unmeasured one.
277
+ if (status.ok) rec.dirty = status.stdout.trim().length > 0;
278
+
279
+ return rec;
280
+ }