@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,485 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * yak.ts — the `eris yak` CLI core.
4
+ *
5
+ * `plugins/yak/handlers.ts` is the thin dispatcher in front of it (group
6
+ * usage, the unknown-verb block, the per-call env). Every actual
7
+ * behaviour — argv parsing, input resolution, identity/provenance,
8
+ * redaction, submission, and the receipt/failure/warn rendering — lives
9
+ * here and in the modules this file composes
10
+ * (`plugins/yak/yak-identity.ts`, `plugins/yak/yak-context.ts`,
11
+ * `plugins/yak/yak-submit.ts`, `ts/lib/eris-fail.ts`).
12
+ *
13
+ * The whole orchestration is `runYak()`, a PURE function of its inputs
14
+ * (argv, env, and a handful of injectable I/O dependencies) that returns
15
+ * `{stdout, stderr, exitCode}` as plain strings rather than writing to the
16
+ * real streams or calling `process.exit` itself. That is what makes the
17
+ * full outcome table (fresh 200 / replay / 400 / 401 / 404 / timeout /
18
+ * connection-refused / every misuse case) testable in `bun:test` with an
19
+ * injected `fetchImpl`, without spawning a real `bun` subprocess per case
20
+ * — and it is what lets a test assert directly that stdout carries only
21
+ * the payload (`docs/OUTPUT-CONTRACT.md` §1), because the two streams are
22
+ * genuinely separate strings here, not shared process fds a test would
23
+ * have to intercept. The `import.meta.main` guard below is the only place
24
+ * that touches the real world.
25
+ *
26
+ * The misuse-vs-environment exit split is enforced entirely in this file's outcome
27
+ * mapping: an agent-misuse outcome (bad argv, unreadable/missing
28
+ * input, a stdin TTY with no pipe, an over-bound field) renders the
29
+ * `eris-fail.ts` four-line block and exits 1; every environment/config/
30
+ * transport outcome (unresolved token/url/device-id, an unreachable or
31
+ * non-2xx endpoint, a timeout) renders one concise `WARN` and exits 0 — the
32
+ * operator's problem, never the task's.
33
+ */
34
+
35
+ import { homedir } from "node:os";
36
+ import {
37
+ readYaksFileConfig,
38
+ resolveToken,
39
+ resolveUrl,
40
+ resolveDeviceId,
41
+ resolveExecutionId,
42
+ } from "./yak-identity";
43
+ import { erisHome, groupArgs, readArgs, renderFailureBlock, renderWarnLines } from "../../src/cli/plugin";
44
+ import plugin from "./index";
45
+ import { gatherProvenance } from "./yak-context";
46
+ import {
47
+ resolveTextInputs,
48
+ submitYak,
49
+ toOutputRecord,
50
+ type RawTextInputs,
51
+ type RedactionSummary,
52
+ type SubmitResult,
53
+ type TextInputDeps,
54
+ type YakOutputRecord,
55
+ } from "./yak-submit";
56
+ import { formatRedactionSummary } from "./yak-redact";
57
+ import type { YakVerb } from "eris/yak-contract";
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Argv parsing
61
+ // ---------------------------------------------------------------------------
62
+
63
+ const VERBS: readonly YakVerb[] = ["issue", "suggest", "fatal"];
64
+
65
+ /** The manifest's subs are exactly the contract's verbs; this narrows one. */
66
+ const isVerb = (s: string): s is YakVerb => (VERBS as readonly string[]).includes(s);
67
+
68
+ /** A refusal `readArgs` hands to `refuse`: `parseArgv` returns it as an
69
+ * outcome instead of writing it, and `runYak` renders it (block or record). */
70
+ class Refused {
71
+ constructor(readonly outcome: ArgvOutcome) {}
72
+ }
73
+
74
+ /** Where `readArgs` writes the help it prints: `runYak` writes yak's own. */
75
+ const DISCARD = { erisDir: "", stdout: { write: () => true }, stderr: { write: () => true } };
76
+
77
+
78
+ // A `…Slug`-named union: `tests/errors-doc.test.ts` harvests its members and
79
+ // fails when a member has no docs/ERRORS.md heading. Keep the type's name
80
+ // ending in `Slug`; a union named otherwise is invisible to it.
81
+ export type ArgvMisuseSlug =
82
+ | "yak-unknown-verb"
83
+ | "yak-unknown-flag"
84
+ | "yak-positional-arg"
85
+ | "yak-flag-missing-value";
86
+
87
+ export interface ParsedFlags {
88
+ summary?: string;
89
+ bodyInline?: string;
90
+ bodyFile?: string;
91
+ evidenceInline?: string;
92
+ evidenceFile?: string;
93
+ /** a repro command to attach to this yak, stored with the
94
+ * cluster it lands on (`provenance.repro.command` on the wire — see
95
+ * `plugins/yak/yak-submit.ts`'s `SubmitParams.repro`). Inline text only, like
96
+ * `--summary` — no `--repro-file`/stdin variant, since a repro is a
97
+ * command, not a document. Parsed uniformly across all three verbs
98
+ * (like every other flag here) rather than gated to `issue` alone: the
99
+ * parser has no per-verb flag sets today, and an improvement
100
+ * `suggest` or a harness `fatal` can just as usefully carry one. */
101
+ repro?: string;
102
+ /** zero or more `--repo <owner/name>` target-repo hints, in the
103
+ * order given — repeatable, unlike every other flag here, so a report
104
+ * that spans repos can name each one (`provenance.repo.target_repos` on
105
+ * the wire — see `plugins/yak/yak-submit.ts`'s `SubmitParams.repoHints`).
106
+ * `undefined` (never `[]`) when `--repo` was never passed at all — that
107
+ * absence is what lets `submitYak` fall back to auto-deriving a hint
108
+ * from the invoking process's own git remote instead. */
109
+ repoHints?: string[];
110
+ noRedact: boolean;
111
+ }
112
+
113
+ export type ArgvOutcome =
114
+ | { kind: "help" }
115
+ // `verb` is OPTIONAL, not absent-by-design —
116
+ // it's populated on every misuse this parser can produce AFTER `verbArg`
117
+ // has already been matched against the registry's yak subs (an unknown flag, a flag missing its
118
+ // value), so `runYak` can still emit the documented `--output json`
119
+ // record on those outcomes. It stays undefined for the one misuse this
120
+ // parser produces BEFORE that point (`yak-unknown-verb` itself) — a case
121
+ // the dispatcher (`plugins/yak/handlers.ts`) never actually forwards to
122
+ // this file in production (it answers an unknown verb itself),
123
+ // so there is no real verb to report there.
124
+ | { kind: "misuse"; reason: string; slug: ArgvMisuseSlug; verb?: YakVerb; fix?: string }
125
+ | { kind: "ok"; verb: YakVerb; flags: ParsedFlags };
126
+
127
+ /**
128
+ * `--output` is deliberately NOT a flag this parser knows: it is the
129
+ * global `--output json|text` selector, parsed once and STRIPPED before
130
+ * dispatch by `src/cli/output.ts` in the real `eris` entrypoint
131
+ * (`docs/OUTPUT-CONTRACT.md` §2) — by the time `plugins/yak/handlers.ts`
132
+ * calls `runYak`, it is already gone from argv. The resolved format instead
133
+ * reaches the core as `ERIS_OUTPUT` in THIS SINGLE invocation's environment
134
+ * (never a persistent exported var — `docs/OUTPUT-CONTRACT.md` §2 is
135
+ * explicit that there is deliberately no such env var for a user's shell
136
+ * to set; the dispatcher sets it per call). Passing `--output` here would
137
+ * therefore either shadow a flag with no local meaning or silently do
138
+ * nothing on the one path that matters (through `eris`) while working only
139
+ * when yak.ts is invoked directly — worse than refusing it as unknown.
140
+ */
141
+ export function parseArgv(argv: string[]): ArgvOutcome {
142
+ const g = groupArgs(plugin.rows, "yak", argv);
143
+ if (g.kind === "bare" || g.kind === "help") return { kind: "help" };
144
+ if (g.kind === "unknown" || !isVerb(g.sub)) {
145
+ return {
146
+ kind: "misuse",
147
+ reason: `unknown verb '${g.sub}' — expected one of: ${VERBS.join(", ")}`,
148
+ slug: "yak-unknown-verb",
149
+ };
150
+ }
151
+ const verbArg = g.sub;
152
+ let p;
153
+ try {
154
+ p = readArgs(plugin.rows, `yak ${verbArg}`, g.rest, DISCARD, {
155
+ refuse: (e, what) => {
156
+ // A bare word is the most common first guess (`eris yak issue "text"`), so it names
157
+ // the two flags the text has to ride on rather than reading as an unknown flag.
158
+ if (e.kind === "unexpected-arg") {
159
+ throw new Refused({
160
+ kind: "misuse",
161
+ reason: `${what}; eris yak takes its text through flags: --summary and --body (or --body-file)`,
162
+ slug: "yak-positional-arg",
163
+ verb: verbArg,
164
+ fix: `eris yak ${verbArg} --summary "<one line>" --body "<what happened>"`,
165
+ });
166
+ }
167
+ const slug = e.kind === "missing-value" ? "yak-flag-missing-value" : "yak-unknown-flag";
168
+ throw new Refused({ kind: "misuse", reason: what, slug, verb: verbArg });
169
+ },
170
+ });
171
+ } catch (e) {
172
+ if (e instanceof Refused) return e.outcome;
173
+ throw e;
174
+ }
175
+ if (p === null) return { kind: "help" };
176
+ const f = p.flags;
177
+ const flags: ParsedFlags = { noRedact: f["--no-redact"] };
178
+ if (f["--summary"] !== undefined) flags.summary = f["--summary"];
179
+ if (f["--body"] !== undefined) flags.bodyInline = f["--body"];
180
+ if (f["--body-file"] !== undefined) flags.bodyFile = f["--body-file"];
181
+ if (f["--evidence"] !== undefined) flags.evidenceInline = f["--evidence"];
182
+ if (f["--evidence-file"] !== undefined) flags.evidenceFile = f["--evidence-file"];
183
+ if (f["--repro"] !== undefined) flags.repro = f["--repro"];
184
+ if (f["--repo"].length > 0) flags.repoHints = [...f["--repo"]];
185
+ return { kind: "ok", verb: verbArg, flags };
186
+ }
187
+
188
+ const USAGE = `usage: eris yak <issue|suggest|fatal> [flags]
189
+
190
+ Land a durable, deduplicated observation with the Yaks endpoint. For a
191
+ harness/tooling problem hit mid-task — never for the task's own outcome.
192
+
193
+ verbs:
194
+ issue a harness/tooling defect, confirmed or suspected
195
+ suggest an improvement to the harness: a missing capability or a better way of working
196
+ fatal the harness itself is broken
197
+
198
+ flags:
199
+ --summary <text> required, <= 200 chars
200
+ --body <text> required (mutually exclusive with --body-file)
201
+ --body-file <path|-> required (mutually exclusive with --body); - reads stdin
202
+ --evidence <text> optional, <= 20000 chars
203
+ --evidence-file <path|-> optional (mutually exclusive with --evidence); - reads stdin
204
+ --repro <command> optional, <= 2000 chars — a command that reproduces this,
205
+ stored with the cluster
206
+ --repo <owner/name> optional, repeatable, <= 200 chars each, <= 20 total — a
207
+ target repo this report concerns; auto-derived from this
208
+ process's own git remote when omitted
209
+ --no-redact skip the secret-scrub pass (loud warning)
210
+ --help, -h show this message
211
+
212
+ Setup: 'eris login' writes ~/.eris/yaks.json ({"url", "token"}, mode 0600;
213
+ the url defaults to https://api.sibylline.group); or export ERIS_YAKS_URL /
214
+ ERIS_YAKS_TOKEN.
215
+ `;
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Orchestration
219
+ // ---------------------------------------------------------------------------
220
+
221
+ export interface RunYakOptions {
222
+ argv: string[];
223
+ env?: Record<string, string | undefined>;
224
+ cwd?: string;
225
+ ppid?: number;
226
+ /** Home directory identity/config files resolve under — defaults to the
227
+ * real `homedir()`. Overridden in tests so nothing ever touches this
228
+ * machine's real `~/.eris`. */
229
+ home?: string;
230
+ fetchImpl?: typeof fetch;
231
+ timeoutMs?: number;
232
+ isStdinTTY?: TextInputDeps["isStdinTTY"];
233
+ readStdin?: TextInputDeps["readStdin"];
234
+ readFile?: TextInputDeps["readFile"];
235
+ }
236
+
237
+ export interface RunYakResult {
238
+ stdout: string;
239
+ stderr: string;
240
+ exitCode: number;
241
+ }
242
+
243
+ // `skipped` mirrors `flags.noRedact` for the warns raised BEFORE `submitYak`
244
+ // is ever called (config/token/url/device-id resolution) — those never
245
+ // build a `RedactionSummary` of their own, so this is the only way the
246
+ // record can say whether `--no-redact` was set (stderr's `yak-no-redact` WARN is unconditionally suppressed under
247
+ // `--output json` now, same as every other diagnostic — see `runYak`).
248
+ function warnOutputRecord(verb: YakVerb, slug: string, reason: string, skipped: boolean): YakOutputRecord {
249
+ return { verb, delivered: false, redactions: { count: 0, classes: [], skipped }, error: { slug, reason } };
250
+ }
251
+
252
+ // output-contract fix: under `--output json`, `eris yak` writes
253
+ // EXACTLY one JSON record to stdout and NOTHING to stderr — `prefix`
254
+ // (the `--no-redact` WARN, when set) used to reach stderr unconditionally
255
+ // here, even in json mode, which is the exact leak this fix closes. Text
256
+ // mode is byte-identical to before.
257
+ function warnResult(
258
+ verb: YakVerb,
259
+ slug: string,
260
+ reason: string,
261
+ jsonOutput: boolean,
262
+ prefix: string,
263
+ skipped: boolean,
264
+ ): RunYakResult {
265
+ const stdout = jsonOutput ? `${JSON.stringify(warnOutputRecord(verb, slug, reason, skipped))}\n` : "";
266
+ const stderr = jsonOutput ? "" : prefix + renderWarnLines(reason, slug);
267
+ return { stdout, stderr, exitCode: 0 };
268
+ }
269
+
270
+ // A misuse outcome used to leave stdout
271
+ // empty UNCONDITIONALLY — docs/YAK.md and docs/OUTPUT-CONTRACT.md both
272
+ // promise "one bare record on stdout regardless of outcome" and tell a
273
+ // consumer to branch on `delivered`, never `id == null`, so a `--output
274
+ // json` caller got nothing to parse on the one outcome class (agent
275
+ // misuse) it's most likely to need to detect programmatically.
276
+ //
277
+ // `verb` is optional because ONE of this function's three call sites
278
+ // (`parseArgv`'s own `yak-unknown-verb`) can fire before a verb is known
279
+ // at all — and that specific case is unreachable through the real `eris`
280
+ // entrypoint (`plugins/yak/handlers.ts` only ever forwards `issue|suggest|fatal`),
281
+ // so there's nothing dishonest about `stdout` staying empty there: no
282
+ // `verb` value would be true. Every OTHER misuse this file produces has a
283
+ // real verb by the time it reaches here (threaded through `ArgvOutcome`'s
284
+ // `verb?` field, or already destructured from `parsed.verb`) and gets the
285
+ // documented record.
286
+ //
287
+ // `redaction` mirrors `formatRedactionSummary`'s wiring on the warn/
288
+ // delivered paths: when it's present and non-empty, the count/class
289
+ // breakdown prints on stderr in text mode (ahead of the failure block, same
290
+ // order as `--no-redact`'s warning) and rides along in the JSON record's
291
+ // `redactions` field either way — never silently dropped just because the
292
+ // outcome was a failure rather than a success.
293
+ //
294
+ // `skipped` is the fallback used ONLY when `redaction` itself is absent
295
+ // (the argv-level and resolveTextInputs misuses, which never build a
296
+ // `RedactionSummary` of their own) — when `redaction` IS present (the
297
+ // submitYak-derived misuses), it already carries the correct `skipped`
298
+ // value from `params.noRedact` and this parameter is ignored.
299
+ //
300
+ // output-contract fix: under `--output json`, stderr is now
301
+ // EMPTY on every misuse — the four-line failure block used to reach
302
+ // stderr unconditionally, even in json mode, which is the exact leak
303
+ // this fix closes (`ERROR .../slug:.../fix:.../docs:...` alongside a
304
+ // stdout JSON record the caller was told to parse instead). Text mode is
305
+ // byte-identical to before.
306
+ function misuseResult(
307
+ slug: string,
308
+ reason: string,
309
+ erisHomeDir: string,
310
+ prefix: string,
311
+ jsonOutput: boolean,
312
+ verb?: YakVerb,
313
+ redaction?: RedactionSummary,
314
+ skipped = false,
315
+ fix?: string,
316
+ ): RunYakResult {
317
+ const redactions = redaction ?? { count: 0, classes: [], skipped };
318
+ const redactionLine = !jsonOutput && redactions.count > 0 ? formatRedactionSummary(redactions) : "";
319
+ const stdout =
320
+ jsonOutput && verb !== undefined
321
+ ? `${JSON.stringify({ verb, delivered: false, redactions, error: { slug, reason } } satisfies YakOutputRecord)}\n`
322
+ : "";
323
+ const stderr = jsonOutput ? "" : prefix + redactionLine + renderFailureBlock({ slug, what: reason, ...(fix ? { fix } : {}) }, erisHomeDir);
324
+ return { stdout, stderr, exitCode: 1 };
325
+ }
326
+
327
+ /**
328
+ * Run the full `eris yak` pipeline and return `{stdout, stderr, exitCode}`
329
+ * — never writes to a real stream, never calls `process.exit`. Every
330
+ * dependency that touches the outside world (env, fs, stdin, network,
331
+ * `~/.eris`) is injectable via `opts`, defaulting to the real thing.
332
+ */
333
+ export async function runYak(opts: RunYakOptions): Promise<RunYakResult> {
334
+ const env = opts.env ?? process.env;
335
+ const jsonOutput = env.ERIS_OUTPUT === "json";
336
+ const erisHomeDir = erisHome(env);
337
+ const home = opts.home ?? homedir();
338
+
339
+ const parsed = parseArgv(opts.argv);
340
+
341
+ if (parsed.kind === "help") {
342
+ return { stdout: USAGE, stderr: "", exitCode: 0 };
343
+ }
344
+ if (parsed.kind === "misuse") {
345
+ return misuseResult(parsed.slug, parsed.reason, erisHomeDir, "", jsonOutput, parsed.verb, undefined, false, parsed.fix);
346
+ }
347
+
348
+ const { verb, flags } = parsed;
349
+ const noRedactWarning = flags.noRedact
350
+ ? renderWarnLines(
351
+ "--no-redact is set — the secret-scrub pass will NOT run on this submission",
352
+ "yak-no-redact",
353
+ )
354
+ : "";
355
+
356
+ const rawInputs: RawTextInputs = {
357
+ summary: flags.summary,
358
+ bodyInline: flags.bodyInline,
359
+ bodyFile: flags.bodyFile,
360
+ evidenceInline: flags.evidenceInline,
361
+ evidenceFile: flags.evidenceFile,
362
+ };
363
+ const textDeps: Partial<TextInputDeps> = {};
364
+ if (opts.readFile) textDeps.readFile = opts.readFile;
365
+ if (opts.readStdin) textDeps.readStdin = opts.readStdin;
366
+ if (opts.isStdinTTY) textDeps.isStdinTTY = opts.isStdinTTY;
367
+
368
+ const inputsOutcome = await resolveTextInputs(rawInputs, textDeps);
369
+ if ("reason" in inputsOutcome) {
370
+ return misuseResult(
371
+ inputsOutcome.slug,
372
+ inputsOutcome.reason,
373
+ erisHomeDir,
374
+ noRedactWarning,
375
+ jsonOutput,
376
+ verb,
377
+ undefined,
378
+ flags.noRedact,
379
+ );
380
+ }
381
+ const { summary, body, evidence } = inputsOutcome.value;
382
+
383
+ // --- identity: config file, token, url, device id ------------------------
384
+ const fileConfigOutcome = await readYaksFileConfig(home);
385
+ if ("reason" in fileConfigOutcome) {
386
+ return warnResult(verb, fileConfigOutcome.slug, fileConfigOutcome.reason, jsonOutput, noRedactWarning, flags.noRedact);
387
+ }
388
+ const fileConfig = fileConfigOutcome.value;
389
+
390
+ const tokenOutcome = resolveToken(fileConfig, env);
391
+ if ("reason" in tokenOutcome) {
392
+ return warnResult(verb, tokenOutcome.slug, tokenOutcome.reason, jsonOutput, noRedactWarning, flags.noRedact);
393
+ }
394
+
395
+ const urlOutcome = resolveUrl(fileConfig, env);
396
+
397
+ const deviceIdOutcome = await resolveDeviceId(home);
398
+ if ("reason" in deviceIdOutcome) {
399
+ return warnResult(verb, deviceIdOutcome.slug, deviceIdOutcome.reason, jsonOutput, noRedactWarning, flags.noRedact);
400
+ }
401
+
402
+ const executionId = resolveExecutionId(env, opts.ppid ?? process.ppid).value;
403
+ const provenance = await gatherProvenance({ env, cwd: opts.cwd, ppid: opts.ppid });
404
+
405
+ // --- submit ---------------------------------------------------------------
406
+ const result: SubmitResult = await submitYak({
407
+ verb,
408
+ summary,
409
+ body,
410
+ evidence,
411
+ repro: flags.repro,
412
+ repoHints: flags.repoHints,
413
+ noRedact: flags.noRedact,
414
+ deviceId: deviceIdOutcome.value,
415
+ executionId,
416
+ provenance,
417
+ url: urlOutcome.value,
418
+ token: tokenOutcome.value,
419
+ fetchImpl: opts.fetchImpl,
420
+ timeoutMs: opts.timeoutMs,
421
+ });
422
+
423
+ if (result.status === "misuse") {
424
+ return misuseResult(result.slug, result.reason, erisHomeDir, noRedactWarning, jsonOutput, verb, result.redaction);
425
+ }
426
+
427
+ if (result.status === "warn") {
428
+ const stdout = jsonOutput ? `${JSON.stringify(toOutputRecord(verb, result))}\n` : "";
429
+ // The count/class breakdown belongs on stderr in text mode too, not
430
+ // only inside `--output json`'s `redactions` field (docs/YAK.md) —
431
+ // `result.redaction` is only present once redaction has
432
+ // actually run (a network-outcome warn, not an earlier config warn).
433
+ //
434
+ // output-contract fix: `stderr` is now EMPTY under
435
+ // `--output json` — `noRedactWarning`/`redactionLine`/the WARN line
436
+ // used to reach stderr unconditionally, even in json mode. Every one
437
+ // of those facts is already recoverable from `stdout`'s record: the
438
+ // WARN's own slug/reason are `error.{slug,reason}`, the redaction
439
+ // count/classes/skip are `redactions`.
440
+ const redactionLine =
441
+ !jsonOutput && result.redaction && result.redaction.count > 0 ? formatRedactionSummary(result.redaction) : "";
442
+ const stderr = jsonOutput ? "" : noRedactWarning + redactionLine + renderWarnLines(result.reason, result.slug);
443
+ return { stdout, stderr, exitCode: 0 };
444
+ }
445
+
446
+ // delivered
447
+ let stdout: string;
448
+ if (jsonOutput) {
449
+ stdout = `${JSON.stringify(toOutputRecord(verb, result))}\n`;
450
+ } else {
451
+ stdout = `${result.receipt.replay ? "already recorded" : "recorded"}: ${result.receipt.id}\n`;
452
+ }
453
+ // output-contract fix: `stderr` is EMPTY under `--output json`.
454
+ // The `--no-redact` WARN is recoverable from `redactions.skipped`, the
455
+ // redaction count/class breakdown from `redactions.{count,classes}`, and
456
+ // the key-mismatch WARN from `key_mismatch` — all on the one stdout
457
+ // record (`toOutputRecord`) rather than a second, silently-dropped
458
+ // stream.
459
+ let stderr = "";
460
+ if (!jsonOutput) {
461
+ stderr = noRedactWarning;
462
+ // Same rule (see above) on the successful-delivery path.
463
+ if (result.redaction.count > 0) {
464
+ stderr += formatRedactionSummary(result.redaction);
465
+ }
466
+ if (result.keyMismatch) {
467
+ stderr += renderWarnLines(
468
+ "locally-derived idempotency key does not match the receipt's — possible canonicalization drift between this CLI and the Yaks endpoint",
469
+ "yak-key-mismatch",
470
+ );
471
+ }
472
+ }
473
+ return { stdout, stderr, exitCode: 0 };
474
+ }
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // Real entry point
478
+ // ---------------------------------------------------------------------------
479
+
480
+ if (import.meta.main) {
481
+ const result = await runYak({ argv: process.argv.slice(2) });
482
+ if (result.stdout.length > 0) process.stdout.write(result.stdout);
483
+ if (result.stderr.length > 0) process.stderr.write(result.stderr);
484
+ process.exit(result.exitCode);
485
+ }