@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,1166 @@
1
+ /**
2
+ * yak-submit.ts — input resolution + the submission core for `eris yak`.
3
+ *
4
+ * Two independently-testable halves:
5
+ *
6
+ * `resolveTextInputs` — turns the parsed `--summary`/`--body`/
7
+ * `--body-file`/`--evidence`/`--evidence-file` flags into three raw
8
+ * strings, reading file/stdin BYTES DIRECTLY (never through a bash
9
+ * variable — the `$(cmd)`-strips-trailing-newlines trap would silently
10
+ * change the idempotency key derived from those bytes). Catches every
11
+ * "agent misuse" input shape: missing required fields, mutually-exclusive
12
+ * sources, an unreadable file, and `--body-file -` with no pipe, which
13
+ * must fail immediately rather than hang forever on a read that will
14
+ * never return.
15
+ *
16
+ * `submitYak` — the pipeline in this EXACT order, which is load-bearing,
17
+ * not incidental:
18
+ *
19
+ * 1. (caller) resolve inputs — done by `resolveTextInputs`, above
20
+ * 2. redact summary/body/evidence/provenance (BEFORE key
21
+ * derivation, so the Actor's re-derived key agrees with ours —
22
+ * deriving from unredacted text guarantees a mismatch on every
23
+ * report)
24
+ * 3. derive the idempotency key from the REDACTED payload
25
+ * 4. omit `evidence` entirely when the (redacted) bytes are empty —
26
+ * `evidence: ""` and `evidence` absent derive DIFFERENT keys
27
+ * under `JSON.stringify` (frozen as a key vector in
28
+ * `tests/yak-contract-v2.test.ts`, and held at this seam by the
29
+ * "evidence '' ... SAME key" test in `tests/yak-submit.test.ts`),
30
+ * so treating an empty upload the same as no upload at all is what
31
+ * keeps two submissions of "no evidence" deduping against each
32
+ * other
33
+ * 5. validate locally against the contract package's zod schema BEFORE the
34
+ * request (an over-bound field is a local, actionable
35
+ * exit-1 error naming the field/length/max — never a silent
36
+ * server-side truncation, which would desync the stored payload
37
+ * from the key we already derived from it)
38
+ * 6. one bounded `fetch` POST (30s), no retry — a retry loop is
39
+ * latency an agent pays mid-task
40
+ * 7. compare our derived key against the receipt's `idempotency_key`
41
+ * and flag a mismatch — the ONLY drift signal available, since the
42
+ * contract has no mismatch field on the receipt itself
43
+ *
44
+ * Every failure this module can produce is typed and NEVER throws or
45
+ * exits — same `{ value } | { reason, slug }` discriminant
46
+ * `plugins/yak/yak-identity.ts` already established
47
+ * (`IdentityOutcome`/`IdentityOk`/`IdentityFailure`, reused here rather
48
+ * than re-invented), plus a third `SubmitResult` branch (`"delivered"`)
49
+ * for the one case that isn't a failure at all. Mapping a result to an
50
+ * exit code, stdout/stderr, and `--output json`'s bare record is
51
+ * `yak.ts`'s job (the agent-misuse-vs-environment split) — this
52
+ * module only classifies.
53
+ */
54
+
55
+ import { z, type ZodIssue } from "zod";
56
+ import {
57
+ YakSubmission,
58
+ YakReceipt,
59
+ YAKS_CONTRACT_VERSION,
60
+ YAK_REPO_HINT_MAX_LENGTH,
61
+ YAK_REPO_HINTS_MAX_COUNT,
62
+ deriveIdempotencyKey,
63
+ type YakVerb,
64
+ type YakProvenance,
65
+ } from "eris/yak-contract";
66
+ import { redact, redactDeep, type RedactionClass } from "./yak-redact";
67
+ import type { IdentityOutcome } from "./yak-identity";
68
+
69
+ /**
70
+ * A permissive twin of the contract package's `YakReceipt`
71
+ * (`@sibylline-labs/yak-contract`, `packages/yak-contract/src/v2.ts`) that
72
+ * accepts ANY `contract_version` string, not only the exact literal this CLI
73
+ * currently emits.
74
+ *
75
+ * `YakReceipt.contract_version` is
76
+ * `z.literal(YAKS_CONTRACT_VERSION)`. The REQUEST side already avoids this
77
+ * trap — `ACCEPTED_YAKS_VERSIONS` is a tuple specifically so a version bump
78
+ * is additive, per that file's own comment. The RESPONSE side has
79
+ * no such tuple, because a receipt "carries the one version that produced
80
+ * it" (same file) — that's a true statement about the SERVER's write, but
81
+ * this CLIENT re-parses that same literal on READ. The moment an Actor ships
82
+ * 2.1.0, every successful, DURABLY-STORED delivery would fail
83
+ * `YakReceipt.safeParse` here and report `delivered: false` — turning a
84
+ * contract_version bump into a false failure for every observation already
85
+ * accepted server-side. Leniency belongs on the reader, not the contract:
86
+ * this widens only the one field a version bump changes, and
87
+ * changes nothing else `YakReceipt` already validates (`id`,
88
+ * `idempotency_key`, `replay` all stay exactly as strict).
89
+ */
90
+ const LenientYakReceipt = YakReceipt.extend({ contract_version: z.string() });
91
+
92
+ /** The shape `submitYak` actually returns on delivery — `YakReceipt` with
93
+ * `contract_version` widened to `string` (see `LenientYakReceipt` above).
94
+ * Structurally compatible with the package's `YakReceipt` everywhere a
95
+ * `string` is expected, which is everywhere this repo reads the field. */
96
+ export type YakReceiptResponse = z.infer<typeof LenientYakReceipt>;
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Input resolution
100
+ // ---------------------------------------------------------------------------
101
+
102
+ export type InputMisuseSlug =
103
+ | "yak-summary-required"
104
+ | "yak-body-required"
105
+ | "yak-body-conflicting-source"
106
+ | "yak-evidence-conflicting-source"
107
+ | "yak-body-file-unreadable"
108
+ | "yak-evidence-file-unreadable"
109
+ | "yak-stdin-is-a-tty"
110
+ | "yak-stdin-conflict";
111
+
112
+ export interface RawTextInputs {
113
+ summary?: string;
114
+ bodyInline?: string;
115
+ bodyFile?: string;
116
+ evidenceInline?: string;
117
+ evidenceFile?: string;
118
+ }
119
+
120
+ export interface ResolvedTextInputs {
121
+ summary: string;
122
+ body: string;
123
+ /** Absent when no `--evidence`/`--evidence-file` was given AT ALL. An
124
+ * `--evidence-file` that resolves to zero bytes is normalized to
125
+ * absent too, one level up in `submitYak` (step 4, post-redaction) —
126
+ * not here, because redaction never turns empty into non-empty or vice
127
+ * versa (a class marker only ever replaces a MATCH), so doing the
128
+ * empty-check on raw vs. redacted bytes is equivalent; the actual
129
+ * normalization runs after redaction. */
130
+ evidence?: string;
131
+ }
132
+
133
+ export interface TextInputDeps {
134
+ /** Reads a file's bytes as UTF-8 text, unmodified — no trim, no
135
+ * newline normalization. Default: `Bun.file(path).text()`. */
136
+ readFile: (path: string) => Promise<string>;
137
+ /** Reads all of stdin as UTF-8 text, unmodified. Default: drains
138
+ * `process.stdin` via async iteration. It does not `.trim()` —
139
+ * trimming would change the bytes the idempotency key is derived from. */
140
+ readStdin: () => Promise<string>;
141
+ /** Whether stdin is a terminal — checked before ever attempting to
142
+ * read it for `--body-file -`/`--evidence-file -`, so a forgotten pipe
143
+ * fails immediately instead of hanging. Default:
144
+ * `Boolean(process.stdin.isTTY)`. */
145
+ isStdinTTY: () => boolean;
146
+ }
147
+
148
+ async function defaultReadFile(path: string): Promise<string> {
149
+ return Bun.file(path).text();
150
+ }
151
+
152
+ async function defaultReadStdin(): Promise<string> {
153
+ const chunks: Buffer[] = [];
154
+ for await (const chunk of process.stdin) {
155
+ chunks.push(chunk as Buffer);
156
+ }
157
+ return Buffer.concat(chunks).toString("utf-8");
158
+ }
159
+
160
+ function defaultIsStdinTTY(): boolean {
161
+ return Boolean(process.stdin.isTTY);
162
+ }
163
+
164
+ function defaultTextInputDeps(overrides: Partial<TextInputDeps> = {}): TextInputDeps {
165
+ return {
166
+ readFile: overrides.readFile ?? defaultReadFile,
167
+ readStdin: overrides.readStdin ?? defaultReadStdin,
168
+ isStdinTTY: overrides.isStdinTTY ?? defaultIsStdinTTY,
169
+ };
170
+ }
171
+
172
+ async function resolveOneField(
173
+ label: "body" | "evidence",
174
+ inline: string | undefined,
175
+ filePath: string | undefined,
176
+ deps: TextInputDeps,
177
+ ): Promise<IdentityOutcome<string | undefined, InputMisuseSlug>> {
178
+ if (inline !== undefined && filePath !== undefined) {
179
+ return {
180
+ reason: `--${label} and --${label}-file are mutually exclusive — pass exactly one`,
181
+ slug: `yak-${label}-conflicting-source` as InputMisuseSlug,
182
+ };
183
+ }
184
+ if (inline !== undefined) return { value: inline };
185
+ if (filePath === undefined) return { value: undefined };
186
+
187
+ if (filePath === "-") {
188
+ if (deps.isStdinTTY()) {
189
+ return {
190
+ reason: `--${label}-file - reads stdin, but stdin is a terminal — pipe input instead, e.g. cat report.txt | eris yak issue --summary "…" --${label}-file -`,
191
+ slug: "yak-stdin-is-a-tty",
192
+ };
193
+ }
194
+ try {
195
+ return { value: await deps.readStdin() };
196
+ } catch (err) {
197
+ return {
198
+ reason: `cannot read stdin for --${label}-file -: ${(err as Error).message}`,
199
+ slug: `yak-${label}-file-unreadable` as InputMisuseSlug,
200
+ };
201
+ }
202
+ }
203
+
204
+ try {
205
+ return { value: await deps.readFile(filePath) };
206
+ } catch (err) {
207
+ return {
208
+ reason: `cannot read --${label}-file ${filePath}: ${(err as Error).message}`,
209
+ slug: `yak-${label}-file-unreadable` as InputMisuseSlug,
210
+ };
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Resolve `summary`/`body`/`evidence` from the parsed flags. Required-field
216
+ * and mutual-exclusion checks run BEFORE any file/stdin I/O so a doomed
217
+ * call never blocks on a read it was always going to reject — in
218
+ * particular, the `--body-file - && --evidence-file -` conflict is
219
+ * checked first so neither read is ever attempted (only one stdin
220
+ * exists to give either of them).
221
+ */
222
+ export async function resolveTextInputs(
223
+ raw: RawTextInputs,
224
+ depsOverrides: Partial<TextInputDeps> = {},
225
+ ): Promise<IdentityOutcome<ResolvedTextInputs, InputMisuseSlug>> {
226
+ const deps = defaultTextInputDeps(depsOverrides);
227
+
228
+ if (raw.bodyFile === "-" && raw.evidenceFile === "-") {
229
+ return {
230
+ reason: "--body-file - and --evidence-file - both read stdin — only one stdin exists; pass the other as --evidence <text> or a real file path",
231
+ slug: "yak-stdin-conflict",
232
+ };
233
+ }
234
+
235
+ if (raw.summary === undefined || raw.summary.length === 0) {
236
+ return { reason: "missing required --summary", slug: "yak-summary-required" };
237
+ }
238
+
239
+ const bodyOutcome = await resolveOneField("body", raw.bodyInline, raw.bodyFile, deps);
240
+ if ("reason" in bodyOutcome) return bodyOutcome;
241
+ if (bodyOutcome.value === undefined || bodyOutcome.value.length === 0) {
242
+ return { reason: "missing required --body or --body-file", slug: "yak-body-required" };
243
+ }
244
+
245
+ const evidenceOutcome = await resolveOneField("evidence", raw.evidenceInline, raw.evidenceFile, deps);
246
+ if ("reason" in evidenceOutcome) return evidenceOutcome;
247
+
248
+ return {
249
+ value: {
250
+ summary: raw.summary,
251
+ body: bodyOutcome.value,
252
+ evidence: evidenceOutcome.value,
253
+ },
254
+ };
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Redaction summary — shared shape for the delivered/warn/misuse outcomes
259
+ // and the `--output json` record's `redactions: {count, classes}` field.
260
+ // ---------------------------------------------------------------------------
261
+
262
+ export interface RedactionSummary {
263
+ count: number;
264
+ classes: RedactionClass[];
265
+ /** True when `--no-redact` was set for THIS submission — the scrub pass
266
+ * never ran at all. Always present, never inferred from `count === 0`:
267
+ * a skipped pass and a pass that ran and found nothing both leave
268
+ * `count` at zero, and without this field the two are indistinguishable
269
+ * on the wire (output-contract fix — `--output json` used to
270
+ * print the `--no-redact` WARN on stderr as the only record of the
271
+ * skip; suppressing that line unconditionally would have silently lost
272
+ * the fact for every `--output json` caller). */
273
+ skipped: boolean;
274
+ }
275
+
276
+ function zeroRedactionSummary(skipped = false): RedactionSummary {
277
+ return { count: 0, classes: [], skipped };
278
+ }
279
+
280
+ /** The canonical zero-filled `Record<RedactionClass, number>` — derived
281
+ * from `redact("")` rather than re-declaring the class list here, so
282
+ * this file has exactly one dependency on `yak-redact.ts`'s class set
283
+ * (its own export) instead of a second, driftable copy. */
284
+ function zeroRedactionCounts(): Record<RedactionClass, number> {
285
+ return redact("").counts;
286
+ }
287
+
288
+ function mergeRedactionCounts(
289
+ parts: Array<Record<RedactionClass, number> | undefined>,
290
+ ): Record<RedactionClass, number> {
291
+ const merged = zeroRedactionCounts();
292
+ for (const part of parts) {
293
+ if (!part) continue;
294
+ for (const cls of Object.keys(merged) as RedactionClass[]) {
295
+ merged[cls] += part[cls];
296
+ }
297
+ }
298
+ return merged;
299
+ }
300
+
301
+ function summarizeRedactionCounts(
302
+ counts: Record<RedactionClass, number> | undefined,
303
+ skipped: boolean,
304
+ ): RedactionSummary {
305
+ if (!counts) return zeroRedactionSummary(skipped);
306
+ const classes = (Object.keys(counts) as RedactionClass[]).filter((c) => counts[c] > 0);
307
+ const count = classes.reduce((acc, c) => acc + counts[c], 0);
308
+ return { count, classes, skipped };
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // Submission core
313
+ // ---------------------------------------------------------------------------
314
+
315
+ /** One bounded request, no retry: 30s, the same bound as a
316
+ * `curl --max-time 30` call to the endpoint. */
317
+ export const DEFAULT_SUBMIT_TIMEOUT_MS = 30_000;
318
+
319
+ const YAKS_INGRESS_PATH = "/v1/yaks";
320
+
321
+ /** Warn-class slugs (environment/config/transport — never the
322
+ * agent's fault, so `yak.ts` maps every one of these to `WARN` + exit 0,
323
+ * never the exit-1 failure block). The network-outcome ones come from a fetch outcome this module
324
+ * observes directly. `yak-identity-id-too-long` and `yak-redaction-overflow`
325
+ * are both `too_big` zod failures on
326
+ * step 5, and both were previously misclassified as agent misuse even
327
+ * though neither is caused by anything the `eris yak` invocation itself
328
+ * controls — `device_id`/`execution_id` are resolved from an env var or a
329
+ * state file the caller never sees, and a redaction-caused overflow means
330
+ * the caller's ORIGINAL text was in bounds until our own scrub pass grew
331
+ * it past the limit. */
332
+ export type SubmitWarnSlug =
333
+ | "yak-unreachable"
334
+ | "yak-timeout"
335
+ | "yak-unauthorized"
336
+ | "yak-not-entitled"
337
+ | "yak-payload-too-large"
338
+ | "yak-transport-error"
339
+ | "yak-identity-id-too-long"
340
+ | "yak-redaction-overflow";
341
+
342
+ /**
343
+ * The endpoint's refusals that no retry fixes and the operator can act on:
344
+ * its HTTP status -> the warn slug plus the local remedy the reason ends with.
345
+ * Every other non-2xx stays `yak-transport-error` (an outage, a missing route,
346
+ * a proxy page), because no local remedy is known for it. All are WARN + exit
347
+ * 0 like the rest of this union.
348
+ *
349
+ * The remedies are local ones only. The reasons do not point at the vendor
350
+ * route; `docs/ERRORS.md` carries that pointer.
351
+ */
352
+ const REFUSAL_BY_STATUS: ReadonlyMap<number, { slug: SubmitWarnSlug; remedy: string }> = new Map([
353
+ [
354
+ 401,
355
+ {
356
+ slug: "yak-unauthorized",
357
+ remedy:
358
+ "the endpoint rejected the bearer token; check the token in ~/.eris/yaks.json or ERIS_YAKS_TOKEN, and that it is registered with the endpoint",
359
+ },
360
+ ],
361
+ [
362
+ 402,
363
+ {
364
+ slug: "yak-not-entitled",
365
+ remedy: "this workspace is not entitled to Yaks (its plan or seat does not include it)",
366
+ },
367
+ ],
368
+ [
369
+ 413,
370
+ {
371
+ slug: "yak-payload-too-large",
372
+ remedy: "the submission exceeded the endpoint's whole-body size cap; trim --evidence, --repro or the --repo hints",
373
+ },
374
+ ],
375
+ ]);
376
+
377
+ export type SubmitMisuseSlug =
378
+ | "yak-summary-too-long"
379
+ | "yak-body-too-long"
380
+ | "yak-evidence-too-long"
381
+ | "yak-repro-too-long"
382
+ | "yak-repo-too-long"
383
+ | "yak-repo-hints-too-many"
384
+ | "yak-payload-invalid";
385
+
386
+ /**
387
+ * Local length ceiling for `--repro <command>`. `repro` rides
388
+ * inside `provenance` (see `SubmitParams.repro`), which the contract
389
+ * validates only for its two typed keys (`repo.target_repos`,
390
+ * `execution.rung`) — "never validated as a gate" is `YakProvenance`'s own
391
+ * documented posture for the rest, because it's enrichment, not payload. A
392
+ * repro command is agent-authored free text
393
+ * like `summary`/`body`/`evidence`, though, so it gets the same local
394
+ * courtesy bound those three get (`docs/YAK.md`: "over-length fields fail
395
+ * locally... rather than letting the server truncate silently") even though
396
+ * nothing server-side would ever truncate it — this is about keeping a
397
+ * pasted multi-command script or a stray heredoc out of the server storage this
398
+ * repo doesn't control, not about the wire contract. Sized well above any
399
+ * real repro invocation (a shell one-liner, a short pytest/vitest selector,
400
+ * an `eris`/`curl` repro command) while still well under `YAK_BODY_MAX_LENGTH`
401
+ * (4,000) — a repro is a command, not a narrative.
402
+ */
403
+ export const YAK_REPRO_MAX_LENGTH = 2_000;
404
+
405
+ /**
406
+ * Bounds for `--repo <owner/name>` target-repo hints (repeatable —
407
+ * see `SubmitParams.repoHints`). A hint rides inside
408
+ * `provenance.repo.target_repos`, which the contract package types and
409
+ * bounds (`YakRepoProvenance`): the same constants are re-exported here so
410
+ * this file's local check and the contract's wire check are one number, not
411
+ * two that can drift. The local check still runs first so an over-bound hint
412
+ * fails with an actionable `--repo` message rather than a generic
413
+ * `yak-payload-invalid`. A hint is a short `owner/name` identifier (this CLI
414
+ * never validates it against a live repo list — see that field's own
415
+ * docstring), so 200 chars is generous headroom over any real one, and 20
416
+ * hints is generous headroom over any single report that genuinely spans
417
+ * that many repos.
418
+ */
419
+ export { YAK_REPO_HINT_MAX_LENGTH, YAK_REPO_HINTS_MAX_COUNT };
420
+
421
+ export type SubmitResult =
422
+ | {
423
+ status: "delivered";
424
+ receipt: YakReceiptResponse;
425
+ idempotencyKey: string;
426
+ /** True when our LOCALLY-derived key disagrees with the receipt's
427
+ * authoritative one — the only canonicalization-drift signal this
428
+ * contract exposes. Still a successful delivery; `yak.ts` surfaces this
429
+ * as an additional warning, not a different exit code. */
430
+ keyMismatch: boolean;
431
+ redaction: RedactionSummary;
432
+ }
433
+ | {
434
+ status: "warn";
435
+ slug: SubmitWarnSlug;
436
+ reason: string;
437
+ redaction?: RedactionSummary;
438
+ /** Present whenever the key was already derived before the warn
439
+ * fired (every network-outcome warn below) — absent for warns
440
+ * raised earlier in the pipeline (token/url unresolved), where no
441
+ * payload was ever built. */
442
+ idempotencyKey?: string;
443
+ }
444
+ | {
445
+ status: "misuse";
446
+ slug: SubmitMisuseSlug;
447
+ reason: string;
448
+ /** Always present: every `SubmitMisuseSlug` this module raises is a
449
+ * zod validation failure on step 5, which runs AFTER key
450
+ * derivation (step 3) — the key exists, it just describes a
451
+ * payload the schema rejected. */
452
+ idempotencyKey: string;
453
+ /** Also always present, for the same reason: step 5 runs AFTER step
454
+ * 2 (redact). `yak-summary-too-long` /
455
+ * `yak-body-too-long` / `yak-evidence-too-long`'s own `reason` text
456
+ * says "redaction may have grown it; check with --no-redact" — that
457
+ * claim needs the actual count/classes sitting beside it, on the
458
+ * SAME outcome, or the message tells the reader to check evidence
459
+ * the misuse result never carried. */
460
+ redaction: RedactionSummary;
461
+ };
462
+
463
+ export interface SubmitParams {
464
+ verb: YakVerb;
465
+ summary: string;
466
+ body: string;
467
+ /** Raw (pre-redaction) evidence text, or absent for "no evidence was
468
+ * given at all". An empty string here (an `--evidence-file` that
469
+ * resolved to zero bytes) is normalized to absent AFTER redaction —
470
+ * see step 4 in the file header. */
471
+ evidence?: string;
472
+ /** Raw (pre-redaction) `--repro <command>` text, or absent when
473
+ * no repro was given — a yak is not required to carry one. Redacted the
474
+ * same as `evidence` (a repro command can itself embed a credential, e.g.
475
+ * `curl -H "Authorization: Bearer …"`), bounded the same way (see
476
+ * `YAK_REPRO_MAX_LENGTH` below) and normalized empty-after-redaction to
477
+ * absent (the same rule `evidence` already follows). It is NOT part of
478
+ * the `YakSubmission` shape — the wire contract is the workspace package
479
+ * `@sibylline-labs/yak-contract`, which this CLI consumes and does not
480
+ * extend on its own — so it rides inside `provenance`
481
+ * instead, which `YakProvenance.catchall(z.unknown())` was built to carry
482
+ * exactly this kind of forward-compatible, not-yet-contracted field (see
483
+ * that type's own docstring: "a CLI that learns an unforeseen provenance
484
+ * category does not have to wait on a contract bump to send it"). Lands on
485
+ * the wire as `provenance.repro.command`. This CLI never executes it —
486
+ * submission is the whole of this module's contract with the string; any
487
+ * rerun is the Yaks endpoint's concern, sandboxed on that side
488
+ * (docs/YAK.md#repro). */
489
+ repro?: string;
490
+ /** Raw (pre-redaction) `--repo <owner/name>` hints, repeatable
491
+ * — zero or more target-repo hints naming the repo(s) this report
492
+ * concerns, so a dispatch/triage flow can read a target repo directly
493
+ * off the rendered issue instead of running a code search across the
494
+ * whole org. Redacted the same as
495
+ * `repro` and bounded the same way (see `YAK_REPO_HINT_MAX_LENGTH` /
496
+ * `YAK_REPO_HINTS_MAX_COUNT` below). NOT validated against a live repo
497
+ * list — an opaque hint, exactly like `repro` is an opaque command: this
498
+ * CLI never confirms the named repo actually exists.
499
+ *
500
+ * When absent or empty (no `--repo` given at all), `submitYak`
501
+ * auto-derives a single hint from `provenance.git.origin_url` — the same
502
+ * git remote `plugins/yak/yak-context.ts::gatherGit` already probes for every
503
+ * submission — so an agent filing from inside a repo checkout gets a
504
+ * hint without passing anything. Explicit `--repo` always wins over that
505
+ * derivation; the two are never merged together. It is NOT part of the
506
+ * `YakSubmission` top-level shape — like `repro`, it rides inside
507
+ * `provenance`, but unlike `repro` it lands in the ALREADY-typed `repo`
508
+ * category (`YakProvenance.repo`, which `yak-context.ts::gatherRepo`
509
+ * already populates with `service`) rather than a new catchall key — a
510
+ * target-repo hint is repo-scoped provenance, not a new kind of fact. Lands on the wire as
511
+ * `provenance.repo.target_repos` (an array, even for one hint: a report
512
+ * that spans repos carries more than one).
513
+ * This CLI never validates any of it beyond the local length/count
514
+ * bounds; a dispatch/triage flow reads it off the rendered issue, which
515
+ * the Actor owns (docs/YAK.md's own "everything after the receipt...
516
+ * belongs to the Actor, not this CLI"). */
517
+ repoHints?: string[];
518
+ /** `--no-redact` skips the scrub entirely, with a loud
519
+ * warning — printing that warning is `yak.ts`'s job; this module just
520
+ * honours the flag. */
521
+ noRedact: boolean;
522
+ deviceId: string;
523
+ executionId: string;
524
+ provenance?: YakProvenance;
525
+ /** The resolved Yaks endpoint base URL (no trailing slash required — this
526
+ * module strips one if present) and bearer token. Both are ALREADY
527
+ * resolved by the time this is called: `yak.ts` maps a token/url
528
+ * resolution failure to a warn outcome itself, using
529
+ * `plugins/yak/yak-identity.ts`'s own typed failures, before this function
530
+ * is ever invoked. */
531
+ url: string;
532
+ token: string;
533
+ /** Injectable for tests — defaults to the global `fetch`. */
534
+ fetchImpl?: typeof fetch;
535
+ timeoutMs?: number;
536
+ }
537
+
538
+ /** The pre-redaction raw values a `too_big` validation failure might name —
539
+ * needed to tell "the caller's own text was already too long" (misuse)
540
+ * apart from "our own resolution/redaction grew an in-bounds value past
541
+ * the limit" (warn). `device_id`/`execution_id` are never redacted, so
542
+ * their raw and post-redaction values are the same value either way. */
543
+ interface ValidationRawInputs {
544
+ summary: string;
545
+ body: string;
546
+ evidence: string | undefined;
547
+ deviceId: string;
548
+ executionId: string;
549
+ }
550
+
551
+ type ValidationFailureOutcome =
552
+ | { status: "misuse"; slug: SubmitMisuseSlug; reason: string }
553
+ | { status: "warn"; slug: SubmitWarnSlug; reason: string };
554
+
555
+ function classifyValidationFailure(
556
+ issues: ZodIssue[],
557
+ raw: ValidationRawInputs,
558
+ redactedSummary: string,
559
+ redactedBody: string,
560
+ redactedEvidence: string | undefined,
561
+ ): ValidationFailureOutcome {
562
+ const issue = issues[0]!;
563
+ const field = String(issue.path[0] ?? "(root)");
564
+
565
+ if (issue.code === "too_big") {
566
+ const max = typeof issue.maximum === "number" ? issue.maximum : Number(issue.maximum);
567
+
568
+ // device_id/execution_id: resolved from `~/.eris/device-id` or
569
+ // CLAUDE_CODE_SESSION_ID/CLAUDE_CODE_HOST_SESSION_ID/the shell ppid
570
+ // (plugins/yak/yak-identity.ts) — not a flag the `eris yak` caller passed,
571
+ // so an over-length value here is an identity-resolution problem, not
572
+ // agent misuse (a >200-char CLAUDE_CODE_SESSION_ID, or a corrupt
573
+ // device-id file).
574
+ if (field === "device_id" || field === "execution_id") {
575
+ const actualLength = field === "device_id" ? raw.deviceId.length : raw.executionId.length;
576
+ const source = field === "device_id" ? "~/.eris/device-id" : "CLAUDE_CODE_SESSION_ID/CLAUDE_CODE_HOST_SESSION_ID (or the shell ppid)";
577
+ return {
578
+ status: "warn",
579
+ slug: "yak-identity-id-too-long",
580
+ reason: `${field} is ${actualLength} chars, exceeds the max of ${max} — this comes from ${source}, not from anything this call passed; check that value, not the report`,
581
+ };
582
+ }
583
+
584
+ const isSummary = field === "summary";
585
+ const isEvidence = field === "evidence";
586
+ const rawLength = isSummary ? raw.summary.length : isEvidence ? (raw.evidence?.length ?? 0) : raw.body.length;
587
+ const redactedLength = isSummary
588
+ ? redactedSummary.length
589
+ : isEvidence
590
+ ? (redactedEvidence?.length ?? 0)
591
+ : redactedBody.length;
592
+
593
+ // The caller's ORIGINAL text was in bounds; the secret-scrub pass (a
594
+ // marker like «redacted:aws-key» is longer than most matches it
595
+ // replaces) is what pushed it over — our processing, not agent misuse.
596
+ if (rawLength <= max && redactedLength > max) {
597
+ return {
598
+ status: "warn",
599
+ slug: "yak-redaction-overflow",
600
+ reason: `${field} was ${rawLength} chars (within the ${max} max) before redaction, but the secret-scrub pass grew it to ${redactedLength} chars — the original text is fine; retry with --no-redact if it contains no real secret`,
601
+ };
602
+ }
603
+
604
+ const slug: SubmitMisuseSlug = isSummary ? "yak-summary-too-long" : isEvidence ? "yak-evidence-too-long" : "yak-body-too-long";
605
+ return {
606
+ status: "misuse",
607
+ slug,
608
+ reason: `${field} is ${redactedLength} chars, exceeds the max of ${max} — shorten it (redaction may have grown it; check with --no-redact) rather than relying on the server to truncate`,
609
+ };
610
+ }
611
+
612
+ return { status: "misuse", slug: "yak-payload-invalid", reason: `${field}: ${issue.message}` };
613
+ }
614
+
615
+ // ---------------------------------------------------------------------------
616
+ // Target-repo hints — `--repo <owner/name>`, repeatable
617
+ // ---------------------------------------------------------------------------
618
+
619
+ type RepoHintsCheckResult =
620
+ | { hints: string[] }
621
+ | { status: "warn"; slug: SubmitWarnSlug; reason: string }
622
+ | { status: "misuse"; slug: SubmitMisuseSlug; reason: string };
623
+
624
+ /**
625
+ * Bound-check + normalize the explicit `--repo` hints (already redacted,
626
+ * 1:1 with the raw array so a redaction-overflow can still be traced back to
627
+ * the original length — same pairing `classifyValidationFailure` uses for
628
+ * summary/body/evidence). Empty/whitespace-only hints are dropped rather
629
+ * than counted or rejected (an agent passing `--repo ""` gets the same
630
+ * outcome as not passing `--repo` at all — see `resolveTargetRepoHints`,
631
+ * which is what decides whether that's "explicit, deliberately empty" or
632
+ * "fall back to auto-derivation"). Dedupes CASE-INSENSITIVELY on the final
633
+ * (redacted, trimmed) text: `--repo
634
+ * owner/repo --repo Owner/Repo` names the same repo on every host that
635
+ * matters (GitHub/GitLab/git remotes are not case-sensitive identity for
636
+ * this purpose) — preserving first-seen ORIGINAL casing and first-seen
637
+ * order, so a `--repo` typo'd (or re-cased) twice does not land as a
638
+ * two-element array with two entries for the same repo.
639
+ */
640
+ function checkRepoHints(rawHints: string[] | undefined, redactedHints: string[] | undefined): RepoHintsCheckResult {
641
+ const redactedList = redactedHints ?? [];
642
+ const pairs = redactedList
643
+ .map((redactedText, i) => ({ raw: (rawHints?.[i] ?? redactedText).trim(), redacted: redactedText.trim() }))
644
+ .filter((p) => p.redacted.length > 0);
645
+
646
+ if (pairs.length > YAK_REPO_HINTS_MAX_COUNT) {
647
+ return {
648
+ status: "misuse",
649
+ slug: "yak-repo-hints-too-many",
650
+ reason: `${pairs.length} --repo hints given, exceeds the max of ${YAK_REPO_HINTS_MAX_COUNT} — narrow to the repos this report actually concerns`,
651
+ };
652
+ }
653
+
654
+ for (const { raw, redacted } of pairs) {
655
+ if (redacted.length <= YAK_REPO_HINT_MAX_LENGTH) continue;
656
+ if (raw.length <= YAK_REPO_HINT_MAX_LENGTH) {
657
+ return {
658
+ status: "warn",
659
+ slug: "yak-redaction-overflow",
660
+ reason: `a --repo hint was ${raw.length} chars (within the ${YAK_REPO_HINT_MAX_LENGTH} max) before redaction, but the secret-scrub pass grew it to ${redacted.length} chars — the original text is fine; retry with --no-redact if it contains no real secret`,
661
+ };
662
+ }
663
+ return {
664
+ status: "misuse",
665
+ slug: "yak-repo-too-long",
666
+ reason: `a --repo hint is ${redacted.length} chars, exceeds the max of ${YAK_REPO_HINT_MAX_LENGTH} — shorten it (redaction may have grown it; check with --no-redact) rather than relying on the server to truncate`,
667
+ };
668
+ }
669
+
670
+ const seen = new Set<string>();
671
+ const hints: string[] = [];
672
+ for (const { redacted } of pairs) {
673
+ const key = redacted.toLowerCase();
674
+ if (!seen.has(key)) {
675
+ seen.add(key);
676
+ hints.push(redacted); // preserve the first-seen occurrence's casing
677
+ }
678
+ }
679
+ return { hints };
680
+ }
681
+
682
+ /**
683
+ * Best-effort `owner[/group.../]name` extraction from a git remote origin
684
+ * URL — for auto-populating a target-repo hint when no `--repo` was given at
685
+ * all. Structurally parses the shapes `git remote -v` actually
686
+ * produces — `scheme://[user@]host/path(.git)?` (https, ssh, git, …),
687
+ * `user@host:path(.git)?` (scp-like syntax) — via `new URL()` for the former
688
+ * (verified: WHATWG's parser splits host from path correctly for `ssh://`
689
+ * and `git://` too, not only the "special" schemes) and a literal split for
690
+ * the latter, rather than a single scheme-agnostic pattern. A
691
+ * scheme-agnostic pattern is silently WRONG on two real shapes, not merely
692
+ * conservative:
693
+ *
694
+ * 1. It kept only the LAST TWO path segments, so a GitLab-style nested
695
+ * group truncates: `https://gitlab.example.com/group/subgroup/project`
696
+ * derived `"subgroup/project"`, silently dropping `group` — and
697
+ * nothing on the wire marks an auto-derived hint as auto-derived, so
698
+ * this wrong-but-plausible value would have reached
699
+ * `provenance.repo.target_repos` looking exactly as authoritative as a
700
+ * hand-typed one. Fixed by taking the FULL path (every segment) once a
701
+ * real host is structurally identified, never just a trailing two.
702
+ * 2. It could not tell a remote from a local checkout: `/home/user/repos/
703
+ * myproject` and `file:///home/user/repos/myproject` both derived
704
+ * `"repos/myproject"` — a fabricated, plausible-looking owner that is
705
+ * really just two path components of a local filesystem path. Fixed by
706
+ * refusing `file://` and any local-filesystem-shaped string (absolute,
707
+ * `./`/`../`-relative, or a Windows drive path) outright, rather than
708
+ * guessing from ANY 2+-segment string.
709
+ *
710
+ * Still degrades gracefully (returns `undefined`, never throws) on a shape
711
+ * with no reliable host at all: a bare relative local path, a hostname with
712
+ * no path, or a genuinely unparseable string. The one deliberate exception
713
+ * is a URL a redaction pass has already partly replaced with a
714
+ * `«redacted:…»` marker (`conn-string`'s pass replaces embedded
715
+ * `scheme://user:pass@host` credentials, leaving e.g.
716
+ * `«redacted:conn-string»/owner/name.git` as `origin_url`) — recovered
717
+ * conservatively, the LAST TWO segments only, because there is no
718
+ * structural signal for how many leading segments are real namespace vs.
719
+ * redaction residue in that one case.
720
+ *
721
+ * This hint is never validated against a live repo list (see
722
+ * `SubmitParams.repoHints`'s own docstring) — a wrong guess here is exactly
723
+ * as harmless as an agent typing the wrong `--repo` value, not a
724
+ * correctness bug to chase. The length bound is enforced by this function's
725
+ * one caller, `resolveTargetRepoHints`, not here.
726
+ */
727
+ function deriveRepoHintFromGitUrl(url: string | undefined): string | undefined {
728
+ if (url === undefined) return undefined;
729
+ const trimmed = url.trim();
730
+ if (trimmed.length === 0) return undefined;
731
+
732
+ // Refuse anything that IS (or looks like) a local filesystem path rather
733
+ // than a remote. None of these have an "owner" at
734
+ // all; guessing one from trailing path components fabricates a
735
+ // plausible-looking but bogus hint.
736
+ if (/^file:\/\//i.test(trimmed)) return undefined;
737
+ if (trimmed.startsWith("/") || trimmed.startsWith("./") || trimmed.startsWith("../")) return undefined;
738
+ if (/^[A-Za-z]:[\\/]/.test(trimmed)) return undefined; // a Windows drive path, e.g. C:\ or C:/
739
+
740
+ let pathPart: string | undefined;
741
+
742
+ // scp-like syntax: user@host:path — git@github.com:group/name(.git)
743
+ const scpMatch = /^[\w.-]+@[\w.-]+:(.+)$/.exec(trimmed);
744
+ if (scpMatch) {
745
+ pathPart = scpMatch[1];
746
+ } else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) {
747
+ // A real scheme://host/path URL (https, ssh, git, …).
748
+ try {
749
+ pathPart = new URL(trimmed).pathname;
750
+ } catch {
751
+ return undefined;
752
+ }
753
+ } else if (trimmed.includes("«redacted:")) {
754
+ // See the file header's "one deliberate exception" — a redacted-remnant
755
+ // URL, recovered conservatively (last two segments only).
756
+ const m = /([A-Za-z0-9][\w.-]*)\/([A-Za-z0-9][\w.-]*?)(?:\.git)?\/?$/.exec(trimmed);
757
+ return m ? `${m[1]}/${m[2]}` : undefined;
758
+ } else {
759
+ // No scheme, no scp syntax, no redaction marker — no structural signal
760
+ // this was ever a remote at all (e.g. a bare relative local path like
761
+ // `repos/myproject`). Refuse rather than guess.
762
+ return undefined;
763
+ }
764
+
765
+ if (pathPart === undefined) return undefined;
766
+ const segments = pathPart
767
+ .replace(/\.git\/?$/, "")
768
+ .split("/")
769
+ .filter((s) => s.length > 0);
770
+ // The FULL path once a real host is identified, not
771
+ // just the trailing two segments — a GitLab-style nested group must not
772
+ // be silently truncated.
773
+ if (segments.length < 2) return undefined;
774
+ return segments.join("/");
775
+ }
776
+
777
+ /**
778
+ * Resolve the final target-repo hint list for this submission: explicit
779
+ * `--repo` hints always win when ANY were given (even if every one of them
780
+ * turns out empty/whitespace-only after trimming — an agent that passed
781
+ * `--repo` at all made a deliberate choice not to rely on auto-derivation),
782
+ * falling back to a single git-remote-derived hint only when `--repo` was
783
+ * never passed. Returns `undefined` — never `[]` — when there is nothing to
784
+ * report either way (absent, not empty: nobody looked and found none,
785
+ * derivation just never had anything to go on).
786
+ *
787
+ * The auto-derived branch
788
+ * must clear the SAME `YAK_REPO_HINT_MAX_LENGTH` bound an explicit `--repo`
789
+ * hint is checked against in `checkRepoHints` — confirmed live, a crafted
790
+ * `origin_url` with a 50,000-char path segment reached the transport with
791
+ * no bound check firing at all, because `checkRepoHints` only ever runs
792
+ * over `params.repoHints` (explicit) and this branch bypassed it entirely.
793
+ * An auto-derived hint isn't agent-authored text, so an over-length one is
794
+ * not misuse and must not fail the whole submission (the same reasoning
795
+ * `device_id`/`execution_id` get for `yak-identity-id-too-long`) — but "not
796
+ * agent-authored" is not "exempt from the bound". Silently DROPPED rather
797
+ * than truncated: truncating an `owner/name` string is at least as likely
798
+ * to produce a different real repo's name as it is to produce something
799
+ * useful, and this is best-effort enrichment with an already-defined
800
+ * absent state (`undefined`) to fall back to — the same "nothing to report"
801
+ * outcome as an unparseable `origin_url`.
802
+ */
803
+ function resolveTargetRepoHints(
804
+ rawHints: string[] | undefined,
805
+ checked: RepoHintsCheckResult,
806
+ originUrl: unknown,
807
+ ): string[] | undefined {
808
+ if (rawHints !== undefined && rawHints.length > 0) {
809
+ // Explicit was given — "hints" is the only reachable branch of
810
+ // `checked` here (a warn/misuse always returns before this function is
811
+ // ever called; see the call site in `submitYak`).
812
+ const hints = "hints" in checked ? checked.hints : [];
813
+ return hints.length > 0 ? hints : undefined;
814
+ }
815
+ const derived = deriveRepoHintFromGitUrl(typeof originUrl === "string" ? originUrl : undefined);
816
+ if (derived === undefined || derived.length > YAK_REPO_HINT_MAX_LENGTH) return undefined;
817
+ return [derived];
818
+ }
819
+
820
+ /**
821
+ * The submission pipeline (see file header for the exact, load-bearing
822
+ * step order). Never throws — every failure mode, local or remote, comes
823
+ * back as a typed `SubmitResult`.
824
+ */
825
+ export async function submitYak(params: SubmitParams): Promise<SubmitResult> {
826
+ const fetchImpl = params.fetchImpl ?? fetch;
827
+ const timeoutMs = params.timeoutMs ?? DEFAULT_SUBMIT_TIMEOUT_MS;
828
+
829
+ // --- step 2: redact (BEFORE key derivation) ------------------------------
830
+ let redactedSummary = params.summary;
831
+ let redactedBody = params.body;
832
+ let redactedEvidence = params.evidence;
833
+ let redactedRepro = params.repro;
834
+ // redacted 1:1 with `params.repoHints` — same array shape and
835
+ // order, just each element passed through `redact()`. `undefined` when no
836
+ // `--repo` was given at all, the same absent-vs-empty posture `repro`
837
+ // already follows.
838
+ let redactedRepoHints = params.repoHints;
839
+ let redactedProvenance = params.provenance;
840
+ let redactionCounts: Record<RedactionClass, number> | undefined;
841
+
842
+ if (!params.noRedact) {
843
+ const summaryR = redact(params.summary);
844
+ const bodyR = redact(params.body);
845
+ const evidenceR = params.evidence !== undefined ? redact(params.evidence) : undefined;
846
+ const reproR = params.repro !== undefined ? redact(params.repro) : undefined;
847
+ const repoHintsR = params.repoHints !== undefined ? params.repoHints.map((h) => redact(h)) : undefined;
848
+ const provenanceR = params.provenance !== undefined ? redactDeep(params.provenance) : undefined;
849
+
850
+ redactedSummary = summaryR.text;
851
+ redactedBody = bodyR.text;
852
+ redactedEvidence = evidenceR?.text;
853
+ redactedRepro = reproR?.text;
854
+ redactedRepoHints = repoHintsR?.map((r) => r.text);
855
+ redactedProvenance = provenanceR?.value;
856
+
857
+ redactionCounts = mergeRedactionCounts([
858
+ summaryR.counts,
859
+ bodyR.counts,
860
+ evidenceR?.counts,
861
+ reproR?.counts,
862
+ ...(repoHintsR?.map((r) => r.counts) ?? []),
863
+ provenanceR?.counts,
864
+ ]);
865
+ }
866
+
867
+ const redaction = summarizeRedactionCounts(redactionCounts, params.noRedact);
868
+
869
+ // --- step 4: omit `evidence`/`repro` entirely when the (redacted) bytes
870
+ // are empty — applied here, before derivation, so step 3 below already
871
+ // sees the normalized value. Redaction can only ever replace a MATCH
872
+ // with a marker; it cannot turn empty text non-empty or vice versa, so
873
+ // checking "empty" on the redacted string is equivalent to checking the
874
+ // raw one and there is exactly one normalization point, not two.
875
+ const finalEvidence = redactedEvidence !== undefined && redactedEvidence.length > 0 ? redactedEvidence : undefined;
876
+ const finalRepro = redactedRepro !== undefined && redactedRepro.length > 0 ? redactedRepro : undefined;
877
+
878
+ // --- step 3: derive the idempotency key from the REDACTED, normalized
879
+ // payload — the Actor re-derives from what it actually received, so
880
+ // this must be the same text we're about to send, not the original.
881
+ // `repro` never participates: it isn't part of `canonicalPayload`
882
+ // (`@sibylline-labs/yak-contract`) — it rides inside `provenance`, which the
883
+ // key derivation deliberately excludes (only summary/body/evidence do,
884
+ // per that file's own docstring) — so attaching or changing a repro
885
+ // command never changes an observation's dedup identity.
886
+ const idempotencyKey = await deriveIdempotencyKey({
887
+ device_id: params.deviceId,
888
+ execution_id: params.executionId,
889
+ verb: params.verb,
890
+ summary: redactedSummary,
891
+ body: redactedBody,
892
+ evidence: finalEvidence,
893
+ });
894
+
895
+ // a repro command is agent-authored free text, like
896
+ // summary/body/evidence, so it gets the same local courtesy length bound
897
+ // those three get — see `YAK_REPRO_MAX_LENGTH`'s own docstring for why this
898
+ // is a courtesy (provenance isn't schema-bounded) rather than a wire-
899
+ // contract requirement. Checked here, after the key is derivable (every
900
+ // other misuse/warn this pipeline raises past this point carries one), and
901
+ // before `provenance` is built, so an over-bound repro never reaches the
902
+ // request at all — same "never let the server silently truncate it"
903
+ // posture as summary/body/evidence.
904
+ if (finalRepro !== undefined && finalRepro.length > YAK_REPRO_MAX_LENGTH) {
905
+ const rawReproLength = params.repro?.length ?? 0;
906
+ if (rawReproLength <= YAK_REPRO_MAX_LENGTH) {
907
+ return {
908
+ status: "warn",
909
+ slug: "yak-redaction-overflow",
910
+ reason: `repro was ${rawReproLength} chars (within the ${YAK_REPRO_MAX_LENGTH} max) before redaction, but the secret-scrub pass grew it to ${finalRepro.length} chars — the original text is fine; retry with --no-redact if it contains no real secret`,
911
+ redaction,
912
+ idempotencyKey,
913
+ };
914
+ }
915
+ return {
916
+ status: "misuse",
917
+ slug: "yak-repro-too-long",
918
+ reason: `repro is ${finalRepro.length} chars, exceeds the max of ${YAK_REPRO_MAX_LENGTH} — shorten it (redaction may have grown it; check with --no-redact) rather than relying on the server to truncate`,
919
+ idempotencyKey,
920
+ redaction,
921
+ };
922
+ }
923
+
924
+ // bound-check the explicit `--repo` hints the SAME way repro was
925
+ // just bound-checked above — an over-long or over-many hint never reaches
926
+ // the request at all (same posture). Auto-derivation from the
927
+ // git remote (`resolveTargetRepoHints`, below) is NEVER subject to these
928
+ // bounds — it isn't agent-authored text, same reasoning `device_id`/
929
+ // `execution_id` get for `yak-identity-id-too-long` above.
930
+ const repoHintsCheck = checkRepoHints(params.repoHints, redactedRepoHints);
931
+ if ("status" in repoHintsCheck) {
932
+ return { ...repoHintsCheck, idempotencyKey, redaction };
933
+ }
934
+ const finalRepoHints = resolveTargetRepoHints(params.repoHints, repoHintsCheck, redactedProvenance?.git?.origin_url);
935
+
936
+ // `repro` is not a field the contract's `YakSubmission` (or
937
+ // `YakProvenance`'s own typed categories) knows about — it rides in as an
938
+ // extra top-level provenance key, preserved on the wire by
939
+ // `YakProvenance.catchall(z.unknown())` (see `SubmitParams.repro`'s
940
+ // docstring). Merged in AFTER `redactedProvenance` is computed, using the
941
+ // ALREADY-redacted `finalRepro` from above — not a second `redactDeep`
942
+ // pass over it, which would be redundant (a marker never re-matches any
943
+ // class, `plugins/yak/yak-redact.ts`'s own idempotence guarantee) and would
944
+ // double-count it in `redactionCounts` if it weren't idempotent.
945
+ const provenanceWithRepro: YakProvenance | undefined =
946
+ finalRepro !== undefined
947
+ ? { ...(redactedProvenance ?? {}), repro: { command: finalRepro } }
948
+ : redactedProvenance;
949
+ // unlike `repro`, a target-repo hint lands in the ALREADY-typed
950
+ // `repo` category (`YakProvenance.repo`) rather than a new catchall key —
951
+ // see `SubmitParams.repoHints`'s docstring for why. Merged in the same
952
+ // post-redaction style, preserving any sibling field that category
953
+ // already carries (`repo.service`, from `yak-context.ts::gatherRepo`).
954
+ const existingRepoCategory = provenanceWithRepro?.repo;
955
+ const provenanceWithRepoHints: YakProvenance | undefined =
956
+ finalRepoHints !== undefined && finalRepoHints.length > 0
957
+ ? { ...(provenanceWithRepro ?? {}), repo: { ...(existingRepoCategory ?? {}), target_repos: finalRepoHints } }
958
+ : provenanceWithRepro;
959
+ const provenance =
960
+ provenanceWithRepoHints && Object.keys(provenanceWithRepoHints).length > 0 ? provenanceWithRepoHints : undefined;
961
+
962
+ // The reporter is never sent: the ingress derives the tracker user from the
963
+ // bearer token, so the contract has no field for one. `route: "workspace"`
964
+ // files the report into the caller's own tenant; it is not an input to the
965
+ // idempotency key.
966
+ const submission: YakSubmission = {
967
+ schema_version: YAKS_CONTRACT_VERSION,
968
+ verb: params.verb,
969
+ route: "workspace",
970
+ summary: redactedSummary,
971
+ body: redactedBody,
972
+ ...(finalEvidence !== undefined ? { evidence: finalEvidence } : {}),
973
+ device_id: params.deviceId,
974
+ execution_id: params.executionId,
975
+ idempotency_key: idempotencyKey,
976
+ ...(provenance ? { provenance } : {}),
977
+ };
978
+
979
+ // --- step 5: validate locally BEFORE the request ------------------------
980
+ const parsed = YakSubmission.safeParse(submission);
981
+ if (!parsed.success) {
982
+ const failure = classifyValidationFailure(
983
+ parsed.error.issues,
984
+ { summary: params.summary, body: params.body, evidence: params.evidence, deviceId: params.deviceId, executionId: params.executionId },
985
+ redactedSummary,
986
+ redactedBody,
987
+ finalEvidence,
988
+ );
989
+ if (failure.status === "warn") {
990
+ return { status: "warn", slug: failure.slug, reason: failure.reason, redaction, idempotencyKey };
991
+ }
992
+ return { status: "misuse", slug: failure.slug, reason: failure.reason, idempotencyKey, redaction };
993
+ }
994
+
995
+ // --- step 6: one bounded POST, no retry -----------------------------------
996
+ //
997
+ // The deadline covers the WHOLE exchange — connect, headers, AND body — not
998
+ // just the `fetchImpl()` call. `fetch()` in Bun/undici/browsers resolves as
999
+ // soon as headers arrive; `response.json()` then does a SEPARATE, unbounded
1000
+ // read of the body stream. A `finally` that cleared the timer right after
1001
+ // `fetchImpl()` (the original shape here) stopped covering the exchange the
1002
+ // instant headers landed — a server that sends headers with a
1003
+ // `content-length` and then never finishes the body would hang forever
1004
+ // past `timeoutMs`, the exact shape ("a command that never returns
1005
+ // mid-task") this file's own header promises `docs/YAK.md` does
1006
+ // not happen. `clearTimeout` now lives in a `finally` that wraps BOTH
1007
+ // awaits, so the same abort that would have covered only the headers phase
1008
+ // now also fires mid-`response.json()` and is caught by the second
1009
+ // try/catch below.
1010
+ const controller = new AbortController();
1011
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1012
+ const url = `${params.url.replace(/\/+$/, "")}${YAKS_INGRESS_PATH}`;
1013
+
1014
+ try {
1015
+ let response: Response;
1016
+ try {
1017
+ response = await fetchImpl(url, {
1018
+ method: "POST",
1019
+ headers: {
1020
+ "content-type": "application/json",
1021
+ authorization: `Bearer ${params.token}`,
1022
+ },
1023
+ body: JSON.stringify(parsed.data),
1024
+ signal: controller.signal,
1025
+ });
1026
+ } catch (err) {
1027
+ const name = (err as { name?: string }).name;
1028
+ if (name === "AbortError" || name === "TimeoutError") {
1029
+ return {
1030
+ status: "warn",
1031
+ slug: "yak-timeout",
1032
+ reason: `yaks endpoint did not respond within ${timeoutMs}ms (${url})`,
1033
+ redaction,
1034
+ idempotencyKey,
1035
+ };
1036
+ }
1037
+ return {
1038
+ status: "warn",
1039
+ slug: "yak-unreachable",
1040
+ reason: `cannot reach yaks endpoint at ${url}: ${(err as Error).message}`,
1041
+ redaction,
1042
+ idempotencyKey,
1043
+ };
1044
+ }
1045
+
1046
+ if (!response.ok) {
1047
+ const refusal = REFUSAL_BY_STATUS.get(response.status);
1048
+ return {
1049
+ status: "warn",
1050
+ slug: refusal?.slug ?? "yak-transport-error",
1051
+ reason: `yaks endpoint responded ${response.status}${response.statusText ? ` ${response.statusText}` : ""} for ${url}${refusal ? ` — ${refusal.remedy}` : ""}`,
1052
+ redaction,
1053
+ idempotencyKey,
1054
+ };
1055
+ }
1056
+
1057
+ let receiptJson: unknown;
1058
+ try {
1059
+ receiptJson = await response.json();
1060
+ } catch (err) {
1061
+ const name = (err as { name?: string }).name;
1062
+ if (name === "AbortError" || name === "TimeoutError") {
1063
+ return {
1064
+ status: "warn",
1065
+ slug: "yak-timeout",
1066
+ reason: `yaks endpoint did not finish sending its response body within ${timeoutMs}ms (${url})`,
1067
+ redaction,
1068
+ idempotencyKey,
1069
+ };
1070
+ }
1071
+ return {
1072
+ status: "warn",
1073
+ slug: "yak-transport-error",
1074
+ reason: `yaks endpoint returned an unparseable ${response.status}: ${(err as Error).message}`,
1075
+ redaction,
1076
+ idempotencyKey,
1077
+ };
1078
+ }
1079
+
1080
+ // LenientYakReceipt, not the package's YakReceipt directly — a
1081
+ // contract_version the package's literal doesn't know about (a bump the
1082
+ // server shipped before this CLI's package caught up) must not turn an
1083
+ // accepted, durably-stored delivery into a reported failure.
1084
+ const receiptParsed = LenientYakReceipt.safeParse(receiptJson);
1085
+ if (!receiptParsed.success) {
1086
+ return {
1087
+ status: "warn",
1088
+ slug: "yak-transport-error",
1089
+ reason: `yaks endpoint returned a ${response.status} that does not match the receipt contract`,
1090
+ redaction,
1091
+ idempotencyKey,
1092
+ };
1093
+ }
1094
+
1095
+ // --- step 7: compare our derived key against the receipt's -------------
1096
+ const keyMismatch = receiptParsed.data.idempotency_key !== idempotencyKey;
1097
+
1098
+ return {
1099
+ status: "delivered",
1100
+ receipt: receiptParsed.data,
1101
+ idempotencyKey,
1102
+ keyMismatch,
1103
+ redaction,
1104
+ };
1105
+ } finally {
1106
+ clearTimeout(timer);
1107
+ }
1108
+ }
1109
+
1110
+ // ---------------------------------------------------------------------------
1111
+ // `--output json` record
1112
+ // ---------------------------------------------------------------------------
1113
+
1114
+ export interface YakOutputRecord {
1115
+ verb: YakVerb;
1116
+ /** The field to branch on — NEVER `id == null` (same absent-vs-empty
1117
+ * discipline as `config show`'s tri-state `state` field). */
1118
+ delivered: boolean;
1119
+ id?: string;
1120
+ idempotency_key?: string;
1121
+ replay?: boolean;
1122
+ contract_version?: string;
1123
+ /** Present only when `delivered: true` — mirrors `SubmitResult`'s own
1124
+ * `keyMismatch` (output-contract fix). Before this field
1125
+ * existed, the only place the mismatch showed up was a `yak-key-mismatch`
1126
+ * WARN on stderr; suppressing every diagnostic under `--output json`
1127
+ * (this fix's whole point) would have made a canonicalization-drift
1128
+ * signal undetectable by a machine caller, since `idempotency_key` here
1129
+ * is the RECEIPT's key, never the locally-derived one the mismatch is
1130
+ * measured against. */
1131
+ key_mismatch?: boolean;
1132
+ redactions: RedactionSummary;
1133
+ error?: { slug: string; reason: string };
1134
+ }
1135
+
1136
+ export function toOutputRecord(verb: YakVerb, result: SubmitResult): YakOutputRecord {
1137
+ if (result.status === "delivered") {
1138
+ return {
1139
+ verb,
1140
+ delivered: true,
1141
+ id: result.receipt.id,
1142
+ idempotency_key: result.receipt.idempotency_key,
1143
+ replay: result.receipt.replay,
1144
+ contract_version: result.receipt.contract_version,
1145
+ key_mismatch: result.keyMismatch,
1146
+ redactions: result.redaction,
1147
+ };
1148
+ }
1149
+
1150
+ // "misuse" carries `redaction` same as
1151
+ // "warn" does (always present there — see the SubmitResult type) rather
1152
+ // than a silently-zeroed placeholder that would desync the JSON record's
1153
+ // `redactions` from the `reason` text a too-long slug prints ("redaction
1154
+ // may have grown it").
1155
+ const redactions =
1156
+ result.status === "warn" || result.status === "misuse"
1157
+ ? (result.redaction ?? zeroRedactionSummary())
1158
+ : zeroRedactionSummary();
1159
+ return {
1160
+ verb,
1161
+ delivered: false,
1162
+ ...(result.idempotencyKey !== undefined ? { idempotency_key: result.idempotencyKey } : {}),
1163
+ redactions,
1164
+ error: { slug: result.slug, reason: result.reason },
1165
+ };
1166
+ }