@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.
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +146 -2
- package/bin/eris.ts +42 -0
- package/docs/ARCHITECTURE.md +188 -0
- package/docs/COMPLETION.md +106 -0
- package/docs/ERRORS.md +632 -0
- package/docs/OUTPUT-CONTRACT.md +219 -0
- package/docs/README.md +30 -0
- package/docs/UPDATE.md +159 -0
- package/docs/YAK.md +395 -0
- package/docs/dev/ADDING-A-COMMAND.md +79 -0
- package/docs/dev/DEVELOPMENT.md +88 -0
- package/docs/dev/LAYOUT.md +74 -0
- package/docs/dev/PLUGINS.md +382 -0
- package/docs/dev/RELEASING.md +157 -0
- package/docs/dev/TESTING.md +84 -0
- package/docs/dev/surfaces/secrets.md +95 -0
- package/package.json +55 -4
- package/packages/yak-contract/package.json +28 -0
- package/packages/yak-contract/schema/yak-v2.schema.json +416 -0
- package/packages/yak-contract/src/index.ts +1 -0
- package/packages/yak-contract/src/v2.ts +375 -0
- package/plugins/index.ts +12 -0
- package/plugins/yak/handlers.ts +128 -0
- package/plugins/yak/index.ts +51 -0
- package/plugins/yak/login.ts +300 -0
- package/plugins/yak/yak-context.ts +280 -0
- package/plugins/yak/yak-identity.ts +399 -0
- package/plugins/yak/yak-redact.ts +686 -0
- package/plugins/yak/yak-setup.ts +133 -0
- package/plugins/yak/yak-submit.ts +1166 -0
- package/plugins/yak/yak.ts +485 -0
- package/src/build.ts +248 -0
- package/src/cli/args.ts +434 -0
- package/src/cli/commands/completion/bash.ts +24 -0
- package/src/cli/commands/completion/domain.ts +203 -0
- package/src/cli/commands/completion/emit.ts +246 -0
- package/src/cli/commands/completion/shell.ts +24 -0
- package/src/cli/commands/completion/zsh.ts +24 -0
- package/src/cli/commands/completion.ts +48 -0
- package/src/cli/commands/update/github.ts +328 -0
- package/src/cli/commands/update/opt-in.ts +32 -0
- package/src/cli/commands/update/refusal.ts +21 -0
- package/src/cli/commands/update/release.ts +351 -0
- package/src/cli/commands/update/replace.ts +143 -0
- package/src/cli/commands/update/semver.ts +48 -0
- package/src/cli/commands/update.ts +47 -0
- package/src/cli/commands/usage.ts +68 -0
- package/src/cli/commands/version.ts +29 -0
- package/src/cli/compose.ts +273 -0
- package/src/cli/output.ts +81 -0
- package/src/cli/plugin.ts +85 -0
- package/src/cli/registry.ts +180 -0
- package/src/cli/router.ts +31 -0
- package/src/cli/ts-verb.ts +126 -0
- package/src/main.ts +223 -0
- package/src/runtime/env.ts +16 -0
- package/src/runtime/eris-assets.d.ts +5 -0
- package/src/runtime/home.ts +306 -0
- package/src/runtime/logging.ts +43 -0
- package/src/runtime/process.ts +388 -0
- package/src/runtime/settings.ts +191 -0
- package/src/sdk.ts +62 -0
- package/testing/build-child.ts +55 -0
- package/testing/fake-runner.ts +23 -0
- package/testing/index.ts +16 -0
- package/ts/lib/eris-fail.ts +180 -0
- package/ts/lib/pool.ts +63 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Yaks wire contract v2: the shapes an `eris` client posts
|
|
3
|
+
* to the Yaks ingest API, and the shapes the service stores and reports back.
|
|
4
|
+
*
|
|
5
|
+
* Derived from the v1 contract (contract version 1.0.0, which `eris yak`
|
|
6
|
+
* spoke before it imported this package). Whatever is not listed here is v1
|
|
7
|
+
* unchanged. The deltas:
|
|
8
|
+
* - `reporter` is gone. The server derives the tracker user from the caller's
|
|
9
|
+
* token. A client's git identity may still ride in `provenance.git` as a hint
|
|
10
|
+
* and is never an identity claim. Unknown keys are stripped, as in v1, so a
|
|
11
|
+
* client that still sends `reporter` is accepted and the field is dropped.
|
|
12
|
+
* - `route` is new and required: `workspace` files into the caller's own
|
|
13
|
+
* tenant, `vendor` into the vendor's.
|
|
14
|
+
* - `provenance.repo.target_repos` keeps the wire location v1's clients use and
|
|
15
|
+
* gains a type and bounds (the server's routing reads it).
|
|
16
|
+
* - `provenance.execution.rung` names which rung of the client's execution-id
|
|
17
|
+
* ladder produced `execution_id`. Its vocabulary holds v1's rungs and the
|
|
18
|
+
* generic harness rungs `ai-agent`, `codex` and `cursor`.
|
|
19
|
+
*
|
|
20
|
+
* Design invariants it encodes:
|
|
21
|
+
* - The tenant is never in the payload: it comes from the bearer token. The
|
|
22
|
+
* same holds for `source` on a stored observation.
|
|
23
|
+
* - Exactly three verbs: `issue`, `suggest`, `fatal`. A fourth is a change to
|
|
24
|
+
* this file.
|
|
25
|
+
* - A submission is immutable once accepted: `YakSubmission` is written once as
|
|
26
|
+
* the `data` blob on a `YakObservation` and never rewritten. Everything that
|
|
27
|
+
* changes as an observation is processed lives in sibling fields.
|
|
28
|
+
* - The idempotency key is SERVER-authoritative. The client may send one,
|
|
29
|
+
* advisory only. The server re-derives it from the payload it received via
|
|
30
|
+
* `deriveIdempotencyKey`, and that value is what the receipt reports and what
|
|
31
|
+
* dedup keys off. The derivation is v1's unchanged, and the server scopes the
|
|
32
|
+
* key per tenant. `route` is not an input: a vendor-routed report lands in the
|
|
33
|
+
* vendor's tenant scope and a workspace one in the caller's.
|
|
34
|
+
* - Bounded free text: `summary`, `body` and `evidence` are agent-authored, so
|
|
35
|
+
* the contract enforces maxima at the door as a validation failure, never a
|
|
36
|
+
* silent truncation. Truncating server-side would desynchronize the stored
|
|
37
|
+
* payload from the client's own key derivation.
|
|
38
|
+
* - `provenance` is best-effort: a permissive record, never a gate. A
|
|
39
|
+
* submission with none of it populated is still valid. Its two typed keys,
|
|
40
|
+
* `repo.target_repos` and `execution.rung`, are the only parts it validates.
|
|
41
|
+
* - Closed vocabularies are never guessed. Extending one is a change to this
|
|
42
|
+
* file, and the server is deployed before the clients that send the new
|
|
43
|
+
* value.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { z } from "zod";
|
|
47
|
+
|
|
48
|
+
/** The single version the service EMITS on every receipt: the version a fresh
|
|
49
|
+
* client should send. Separate from the ingest endpoint's own version. */
|
|
50
|
+
export const YAKS_CONTRACT_VERSION = "2.0.0";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The write plane's version window. A tuple, not a bare
|
|
54
|
+
* `z.literal(YAKS_CONTRACT_VERSION)`, even with one member: a bare literal bump
|
|
55
|
+
* rejects every in-flight submission from a client that has not yet upgraded, the
|
|
56
|
+
* instant the new version deploys.
|
|
57
|
+
*
|
|
58
|
+
* When `YAKS_CONTRACT_VERSION` bumps: append the new version, don't replace the
|
|
59
|
+
* old one. Narrow back to a single element only after every client has moved.
|
|
60
|
+
*/
|
|
61
|
+
export const ACCEPTED_YAKS_VERSIONS = ["2.0.0"] as const;
|
|
62
|
+
|
|
63
|
+
/** The write plane's version check. A response (`YakReceipt.contract_version`)
|
|
64
|
+
* keeps a literal: a receipt carries the one version that produced it. */
|
|
65
|
+
export const AcceptedYaksVersion = z.enum(ACCEPTED_YAKS_VERSIONS);
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Closed vocabularies
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/** Exactly these three, closed:
|
|
72
|
+
* issue : a harness defect the agent hit, confirmed or suspected
|
|
73
|
+
* suggest : an improvement to the harness (missing capability, better default)
|
|
74
|
+
* fatal : the harness itself is broken; clusters SEPARATELY from
|
|
75
|
+
* issue/suggest */
|
|
76
|
+
export const YakVerb = z.enum(["issue", "suggest", "fatal"]);
|
|
77
|
+
export type YakVerb = z.infer<typeof YakVerb>;
|
|
78
|
+
|
|
79
|
+
/** Where a submission files. `workspace` is the caller's own tenant. `vendor` is
|
|
80
|
+
* a report about eris itself, which flows into the vendor's tenant and never
|
|
81
|
+
* out of it. Required on the wire: a client always
|
|
82
|
+
* knows which it means, and a missing route is a client bug to refuse, not to
|
|
83
|
+
* default. */
|
|
84
|
+
export const YakRoute = z.enum(["workspace", "vendor"]);
|
|
85
|
+
export type YakRoute = z.infer<typeof YakRoute>;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The rung of the client's `execution_id` ladder that produced the value. The
|
|
89
|
+
* rungs hold genuinely different ids for one task, so which one fired decides
|
|
90
|
+
* the dedup grain a report lands at. The client owns the ladder's order and the
|
|
91
|
+
* environment it reads; the contract owns only the names.
|
|
92
|
+
*
|
|
93
|
+
* - v1's rungs, unchanged: `override` (an explicit id), `session` and
|
|
94
|
+
* `host-session` (the Claude Code session ids), `shell-session` (the
|
|
95
|
+
* invoking shell's id) and `shell` (the final fallback).
|
|
96
|
+
* - The generic harness rungs: `ai-agent` (the cross-vendor AI_AGENT
|
|
97
|
+
* identifier), `codex` and `cursor` (those harnesses' own session ids).
|
|
98
|
+
*
|
|
99
|
+
* The order of this list is not the ladder's order.
|
|
100
|
+
*/
|
|
101
|
+
export const YAK_EXECUTION_ID_RUNGS = ["override", "session", "host-session", "shell-session", "shell", "ai-agent", "codex", "cursor"] as const;
|
|
102
|
+
export const YakExecutionIdRung = z.enum(YAK_EXECUTION_ID_RUNGS);
|
|
103
|
+
export type YakExecutionIdRung = z.infer<typeof YakExecutionIdRung>;
|
|
104
|
+
|
|
105
|
+
/** Processing state of a stored observation. The `data` blob (the original
|
|
106
|
+
* `YakSubmission`) never changes; this is the only part of an observation row
|
|
107
|
+
* that does. */
|
|
108
|
+
export const YakObservationStatus = z.enum(["pending", "processed", "failed"]);
|
|
109
|
+
export type YakObservationStatus = z.infer<typeof YakObservationStatus>;
|
|
110
|
+
|
|
111
|
+
/** A cluster's kind mirrors the verb partition it exists for: `fatal` clusters
|
|
112
|
+
* never merge with `report` clusters (the `issue`/`suggest` pool), because only
|
|
113
|
+
* `fatal` escalates and a shared lineage would have to pick one behaviour for
|
|
114
|
+
* both. */
|
|
115
|
+
export const YakClusterKind = z.enum(["fatal", "report"]);
|
|
116
|
+
export type YakClusterKind = z.infer<typeof YakClusterKind>;
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Bounded free text
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Length ceilings for the agent-authored fields. These are the only thing
|
|
124
|
+
* standing between one submission and pathological storage or match cost,
|
|
125
|
+
* because the fields are free text an agent generated, not a bounded enum or
|
|
126
|
+
* identifier. Enforced as a validation failure, never a `.slice()`.
|
|
127
|
+
*/
|
|
128
|
+
/** ~one tracker issue-title line with the `[Yak][Fatal] ` prefix (13 chars)
|
|
129
|
+
* still comfortably inside a readable title. */
|
|
130
|
+
export const YAK_SUMMARY_MAX_LENGTH = 200;
|
|
131
|
+
/** A full incident narrative in prose. Matching runs against `summary`, not
|
|
132
|
+
* `body`; this bound is about storage and issue-description size. */
|
|
133
|
+
export const YAK_BODY_MAX_LENGTH = 4_000;
|
|
134
|
+
/** Evidence is often a pasted log excerpt or stack trace: it needs more room
|
|
135
|
+
* than prose, but is still bounded. */
|
|
136
|
+
export const YAK_EVIDENCE_MAX_LENGTH = 20_000;
|
|
137
|
+
/** `device_id` / `execution_id` are opaque identifiers, not free text, but they
|
|
138
|
+
* are caller-supplied strings that flow into the idempotency-key derivation:
|
|
139
|
+
* bounded for the same storage-hygiene reason, sized generously above any real
|
|
140
|
+
* identifier (UUID, hostname, PID composite). */
|
|
141
|
+
export const YAK_ID_MAX_LENGTH = 200;
|
|
142
|
+
/** The client-supplied (advisory) idempotency key. A sha256 hex digest is 64
|
|
143
|
+
* chars; headroom is for a future hash algorithm change on the client side,
|
|
144
|
+
* never load-bearing since the server always re-derives. */
|
|
145
|
+
export const YAK_IDEMPOTENCY_KEY_MAX_LENGTH = 128;
|
|
146
|
+
/** A target-repo hint is a short `owner/name` identifier, not a URL or a
|
|
147
|
+
* paste. Sized generously above any real repo name. */
|
|
148
|
+
export const YAK_REPO_HINT_MAX_LENGTH = 200;
|
|
149
|
+
/** More hints than any single report spans repos. */
|
|
150
|
+
export const YAK_REPO_HINTS_MAX_COUNT = 20;
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Submission-adjacent shapes
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A target-repo hint: `owner/name` by convention, and nested namespaces
|
|
158
|
+
* (`group/subgroup/project`) are legal because clients derive hints from git
|
|
159
|
+
* remotes. Bounded in length only. A shape check would turn a hint the client
|
|
160
|
+
* could not normalize into a refused report, and a hint is never worth that.
|
|
161
|
+
*/
|
|
162
|
+
export const YakRepoHint = z.string().min(1).max(YAK_REPO_HINT_MAX_LENGTH);
|
|
163
|
+
|
|
164
|
+
/** The `repo` category of provenance: free-form like the rest, with one typed
|
|
165
|
+
* key. `target_repos` is an array even for one hint, for a report that spans
|
|
166
|
+
* repos. */
|
|
167
|
+
export const YakRepoProvenance = z
|
|
168
|
+
.object({
|
|
169
|
+
target_repos: z.array(YakRepoHint).max(YAK_REPO_HINTS_MAX_COUNT).optional(),
|
|
170
|
+
})
|
|
171
|
+
.catchall(z.unknown());
|
|
172
|
+
export type YakRepoProvenance = z.infer<typeof YakRepoProvenance>;
|
|
173
|
+
|
|
174
|
+
/** The `execution` category of provenance: session facts the client read, with
|
|
175
|
+
* one typed key, the rung that produced `execution_id`. */
|
|
176
|
+
export const YakExecutionProvenance = z
|
|
177
|
+
.object({
|
|
178
|
+
rung: YakExecutionIdRung.optional(),
|
|
179
|
+
})
|
|
180
|
+
.catchall(z.unknown());
|
|
181
|
+
export type YakExecutionProvenance = z.infer<typeof YakExecutionProvenance>;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Best-effort submission metadata. Six categories the client MAY populate when
|
|
185
|
+
* it knows them; each is a permissive record because the shape of "what a
|
|
186
|
+
* harness knows about its own process" varies by caller and this contract
|
|
187
|
+
* deliberately does not gate on it. `.catchall()` additionally accepts unknown
|
|
188
|
+
* top-level keys, so a client that learns an unforeseen provenance category does
|
|
189
|
+
* not have to wait on a contract change to send it.
|
|
190
|
+
*
|
|
191
|
+
* `git` may carry the committer's identity (git config) as a hint. The reporter
|
|
192
|
+
* is the tracker user the server derives from the token, never this.
|
|
193
|
+
*
|
|
194
|
+
* Never validated as a gate beyond the two typed keys above: a submission with
|
|
195
|
+
* `provenance` entirely absent is still a fully valid `YakSubmission`. This
|
|
196
|
+
* exists to enrich triage, not to constrain ingress.
|
|
197
|
+
*/
|
|
198
|
+
export const YakProvenance = z
|
|
199
|
+
.object({
|
|
200
|
+
process: z.record(z.string(), z.unknown()).optional(),
|
|
201
|
+
device: z.record(z.string(), z.unknown()).optional(),
|
|
202
|
+
harness: z.record(z.string(), z.unknown()).optional(),
|
|
203
|
+
repo: YakRepoProvenance.optional(),
|
|
204
|
+
git: z.record(z.string(), z.unknown()).optional(),
|
|
205
|
+
execution: YakExecutionProvenance.optional(),
|
|
206
|
+
})
|
|
207
|
+
.catchall(z.unknown());
|
|
208
|
+
export type YakProvenance = z.infer<typeof YakProvenance>;
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Ingress
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The ingress payload. Durable the instant it is accepted: ingress validates,
|
|
216
|
+
* derives the authoritative idempotency key, and persists the observation. No
|
|
217
|
+
* tracker work happens on this path.
|
|
218
|
+
*/
|
|
219
|
+
export const YakSubmission = z.object({
|
|
220
|
+
schema_version: AcceptedYaksVersion,
|
|
221
|
+
verb: YakVerb,
|
|
222
|
+
route: YakRoute,
|
|
223
|
+
summary: z.string().min(1).max(YAK_SUMMARY_MAX_LENGTH),
|
|
224
|
+
body: z.string().min(1).max(YAK_BODY_MAX_LENGTH),
|
|
225
|
+
evidence: z.string().max(YAK_EVIDENCE_MAX_LENGTH).optional(),
|
|
226
|
+
device_id: z.string().min(1).max(YAK_ID_MAX_LENGTH),
|
|
227
|
+
execution_id: z.string().min(1).max(YAK_ID_MAX_LENGTH),
|
|
228
|
+
/** Client-derived, advisory only. The server always re-derives via
|
|
229
|
+
* `deriveIdempotencyKey` below and that value, never this one, is
|
|
230
|
+
* authoritative and is what the receipt reports. A mismatch is the server's
|
|
231
|
+
* concern to report and log, not this contract's. */
|
|
232
|
+
idempotency_key: z.string().max(YAK_IDEMPOTENCY_KEY_MAX_LENGTH).optional(),
|
|
233
|
+
provenance: YakProvenance.optional(),
|
|
234
|
+
});
|
|
235
|
+
export type YakSubmission = z.infer<typeof YakSubmission>;
|
|
236
|
+
|
|
237
|
+
/** What ingress returns for an accepted submission, whether newly accepted or a
|
|
238
|
+
* replay of one already seen (a replay returns the ORIGINAL receipt, not a
|
|
239
|
+
* fresh one). */
|
|
240
|
+
export const YakReceipt = z.object({
|
|
241
|
+
/** The stable id of the stored `YakObservation`. */
|
|
242
|
+
id: z.string(),
|
|
243
|
+
/** The server-derived, authoritative idempotency key: see
|
|
244
|
+
* `deriveIdempotencyKey` below. Always present, whether or not the client
|
|
245
|
+
* sent its own on the submission. */
|
|
246
|
+
idempotency_key: z.string(),
|
|
247
|
+
/** True when this insert lost a uniqueness race on the idempotency key to an
|
|
248
|
+
* already-stored observation, and the receipt describes that EARLIER
|
|
249
|
+
* observation, not a new one. */
|
|
250
|
+
replay: z.boolean(),
|
|
251
|
+
contract_version: z.literal(YAKS_CONTRACT_VERSION),
|
|
252
|
+
});
|
|
253
|
+
export type YakReceipt = z.infer<typeof YakReceipt>;
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Stored shapes: the read surface
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* One immutable submission plus its mutable processing state. `data` is written
|
|
261
|
+
* once at submit time and never rewritten; `status`, `cluster_id`, `attempts`,
|
|
262
|
+
* `next_attempt_at` and `last_error` are the only fields that change as the
|
|
263
|
+
* drain processes the row.
|
|
264
|
+
*/
|
|
265
|
+
export const YakObservation = z.object({
|
|
266
|
+
id: z.string(),
|
|
267
|
+
idempotency_key: z.string(),
|
|
268
|
+
verb: YakVerb,
|
|
269
|
+
summary: z.string(),
|
|
270
|
+
/** Identity of the ingesting caller, stamped by the service from the bearer
|
|
271
|
+
* token, never from the payload. */
|
|
272
|
+
source: z.string(),
|
|
273
|
+
received_at: z.string().datetime(),
|
|
274
|
+
status: YakObservationStatus,
|
|
275
|
+
/** Absent until the drain assigns this observation to a cluster. */
|
|
276
|
+
cluster_id: z.string().optional(),
|
|
277
|
+
attempts: z.number().int().nonnegative(),
|
|
278
|
+
/** Absent unless a prior attempt failed and this row is backing off. */
|
|
279
|
+
next_attempt_at: z.string().datetime().optional(),
|
|
280
|
+
last_error: z.string().optional(),
|
|
281
|
+
/** The immutable submission this observation was created from. */
|
|
282
|
+
data: YakSubmission,
|
|
283
|
+
});
|
|
284
|
+
export type YakObservation = z.infer<typeof YakObservation>;
|
|
285
|
+
|
|
286
|
+
/** One point in a cluster's issue-linkage history. A cluster accumulates one of
|
|
287
|
+
* these each time it is linked to a tracker issue, including every time a
|
|
288
|
+
* closed predecessor forces a NEW issue plus a relation (relate, never reopen)
|
|
289
|
+
* rather than reusing the old one. */
|
|
290
|
+
export const YakClusterIssueLink = z.object({
|
|
291
|
+
issue_id: z.string(),
|
|
292
|
+
issue_identifier: z.string(), // e.g. "ABC-123"
|
|
293
|
+
linked_at: z.string().datetime(),
|
|
294
|
+
/** Set once the drain observes this issue as closed (the trigger for minting
|
|
295
|
+
* the NEXT link in the history). Absent while the linked issue is open. */
|
|
296
|
+
closed_observed_at: z.string().datetime().optional(),
|
|
297
|
+
});
|
|
298
|
+
export type YakClusterIssueLink = z.infer<typeof YakClusterIssueLink>;
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* A persistent cluster of observations. `current_issue_id` /
|
|
302
|
+
* `current_issue_identifier` name the issue new comments land on right now;
|
|
303
|
+
* `issue_history` is the full lineage, every issue this cluster has ever been
|
|
304
|
+
* linked to, in order, so closing an issue never loses the connection between
|
|
305
|
+
* the old filing and the new one.
|
|
306
|
+
*/
|
|
307
|
+
export const YakCluster = z.object({
|
|
308
|
+
id: z.string(),
|
|
309
|
+
kind: YakClusterKind,
|
|
310
|
+
canonical_summary: z.string(),
|
|
311
|
+
keywords: z.array(z.string()).default([]),
|
|
312
|
+
observation_count: z.number().int().nonnegative(),
|
|
313
|
+
first_seen: z.string().datetime(),
|
|
314
|
+
last_seen: z.string().datetime(),
|
|
315
|
+
/** Absent only in the brief window between cluster creation and its first
|
|
316
|
+
* successful issue creation: every persisted cluster is expected to reach
|
|
317
|
+
* one. */
|
|
318
|
+
current_issue_id: z.string().optional(),
|
|
319
|
+
current_issue_identifier: z.string().optional(),
|
|
320
|
+
/** The full linkage history: see `YakClusterIssueLink`. Defaults to `[]` for
|
|
321
|
+
* a brand-new cluster not yet linked to anything. */
|
|
322
|
+
issue_history: z.array(YakClusterIssueLink).default([]),
|
|
323
|
+
});
|
|
324
|
+
export type YakCluster = z.infer<typeof YakCluster>;
|
|
325
|
+
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// Idempotency-key derivation: the load-bearing rule, unchanged from v1
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The canonical JSON encoding of the fields that make a submission a REPLAY of
|
|
332
|
+
* an earlier one. Key order is fixed (`summary`, `body`, `evidence`) because
|
|
333
|
+
* `JSON.stringify` serializes string keys in insertion order, so building this
|
|
334
|
+
* literal, rather than forwarding whatever key order the caller's own object
|
|
335
|
+
* had, is what makes the derivation stable however the caller built its
|
|
336
|
+
* `YakSubmission`.
|
|
337
|
+
*/
|
|
338
|
+
export function canonicalPayload(submission: Pick<YakSubmission, "summary" | "body" | "evidence">): string {
|
|
339
|
+
return JSON.stringify({
|
|
340
|
+
summary: submission.summary,
|
|
341
|
+
body: submission.body,
|
|
342
|
+
evidence: submission.evidence,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Web-standard crypto only, so the package runs unchanged in a Worker, bun or
|
|
347
|
+
* node. */
|
|
348
|
+
async function sha256Hex(text: string): Promise<string> {
|
|
349
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
350
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The authoritative idempotency-key derivation:
|
|
355
|
+
*
|
|
356
|
+
* canonicalPayload = JSON.stringify({ summary, body, evidence }) // fixed key order, no whitespace
|
|
357
|
+
* idempotency_key = sha256hex(`${device_id}\n${execution_id}\n${verb}\n${canonicalPayload}`)
|
|
358
|
+
*
|
|
359
|
+
* The `\n` delimiters are load-bearing, not decorative. Bare concatenation is
|
|
360
|
+
* PREFIX-AMBIGUOUS: `device_id="ab", execution_id="c"` and `device_id="a",
|
|
361
|
+
* execution_id="bc"` would serialize to the identical string and hash to the
|
|
362
|
+
* identical key, so two genuinely different callers would silently dedup into
|
|
363
|
+
* one. Joined with `\n` they serialize differently and so hash differently.
|
|
364
|
+
*
|
|
365
|
+
* Both halves call this: the client to compute its advisory key before it sends
|
|
366
|
+
* a request, and the server to compute the one value it stores and dedupes on.
|
|
367
|
+
* Re-deriving from the payload actually received, rather than trusting the
|
|
368
|
+
* client's value, is what makes the server authoritative. It is v1's derivation
|
|
369
|
+
* byte for byte, so a report replayed from a v1 client keys the same.
|
|
370
|
+
*/
|
|
371
|
+
export function deriveIdempotencyKey(submission: Pick<YakSubmission, "device_id" | "execution_id" | "verb" | "summary" | "body" | "evidence">): Promise<string> {
|
|
372
|
+
const canonical = canonicalPayload(submission);
|
|
373
|
+
const material = `${submission.device_id}\n${submission.execution_id}\n${submission.verb}\n${canonical}`;
|
|
374
|
+
return sha256Hex(material);
|
|
375
|
+
}
|
package/plugins/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plugins/index.ts — the plugins this build compiles in.
|
|
3
|
+
*
|
|
4
|
+
* src/main.ts composes this list with core's registry. Adding a plugin to the
|
|
5
|
+
* build is an import and an entry here; nothing under src/ changes. Order is
|
|
6
|
+
* the order of the plugin groups in `eris --help`. docs/dev/PLUGINS.md.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ErisPlugin } from "../src/cli/plugin";
|
|
10
|
+
import yak from "./yak";
|
|
11
|
+
|
|
12
|
+
export const PLUGINS: readonly ErisPlugin[] = [yak];
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* handlers.ts — `eris yak issue|suggest|fatal`,
|
|
3
|
+
* loaded only when `eris yak` dispatches (the manifest is ./index.ts).
|
|
4
|
+
*
|
|
5
|
+
* A thin dispatcher over the yak core (./yak.ts, `runYak`): this file
|
|
6
|
+
* owns the group usage, the unknown-verb block, and the per-call environment
|
|
7
|
+
* the core reads; the core owns each verb's grammar, its `--help`, and every
|
|
8
|
+
* receipt / warn / failure rendering. The three verbs share one body, so they
|
|
9
|
+
* share this one file rather than one file per sub.
|
|
10
|
+
*
|
|
11
|
+
* Invariants:
|
|
12
|
+
* - `ERIS_OUTPUT` carries the global `--output` (already stripped from argv)
|
|
13
|
+
* into the core, which reads it from its env.
|
|
14
|
+
* - `ERIS_YAK_SHELL_ID` is the pid of the caller's shell: the shim `exec`s
|
|
15
|
+
* bun, so this process's parent IS that shell. It is the
|
|
16
|
+
* `shell:<pid>` execution_id rung (plugins/yak/yak-identity.ts), constant across
|
|
17
|
+
* every call from one terminal so idempotency converges outside Claude Code.
|
|
18
|
+
* - A dependency the checkout cannot resolve WARNS and exits 0
|
|
19
|
+
* (`yak-deps-not-installed`), as a missing token does: the command that
|
|
20
|
+
* reports a broken harness must not become one more thing that stops the
|
|
21
|
+
* agent. Under `--output json` it is the bare record on stdout, stderr empty.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { CliExit, groupArgs, renderFailureBlock, type VerbContext } from "../../src/cli/plugin";
|
|
26
|
+
import plugin from "./index";
|
|
27
|
+
|
|
28
|
+
type RunYak = typeof import("./yak").runYak;
|
|
29
|
+
|
|
30
|
+
export interface YakVerbDeps {
|
|
31
|
+
/** The caller's shell pid. Defaults to this process's parent. */
|
|
32
|
+
ppid?: number;
|
|
33
|
+
cwd?: string;
|
|
34
|
+
/** Loads the core. Injected to exercise an uninstalled dependency. */
|
|
35
|
+
load?: () => Promise<RunYak>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The group usage's flag block: each spelling, then its description lines. */
|
|
39
|
+
const USAGE_FLAGS: readonly (readonly [string, ...string[]])[] = [
|
|
40
|
+
["--summary <text>", "required, <= 200 chars"],
|
|
41
|
+
["--body <text> | --body-file <path|->", "required (- reads stdin)"],
|
|
42
|
+
["--evidence <text> | --evidence-file <path|->", "optional, <= 20000 chars"],
|
|
43
|
+
["--repro <command>", "optional, <= 2000 chars — a repro command", "stored with the cluster"],
|
|
44
|
+
["--repo <owner/name>", "optional, repeatable — a target repo this", "report concerns; auto-derived from this", "process's own git remote when omitted"],
|
|
45
|
+
["--no-redact", "skip the secret-scrub pass (loud warning)"],
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/** Every description starts two columns past the longest spelling. */
|
|
49
|
+
const USAGE_COLUMN = Math.max(...USAGE_FLAGS.map(([flag]) => flag.length)) + 2;
|
|
50
|
+
const USAGE_FLAG_BLOCK = USAGE_FLAGS.flatMap(([flag, ...lines]) => lines.map((line, i) => ` ${(i === 0 ? flag : "").padEnd(USAGE_COLUMN)}${line}\n`)).join("");
|
|
51
|
+
|
|
52
|
+
export const YAK_USAGE = `Usage: eris yak <command>
|
|
53
|
+
|
|
54
|
+
Commands:
|
|
55
|
+
issue A harness/tooling defect, confirmed or suspected
|
|
56
|
+
suggest An improvement to the harness: a missing capability or a better way of working
|
|
57
|
+
fatal The harness itself is broken
|
|
58
|
+
|
|
59
|
+
${USAGE_FLAG_BLOCK}
|
|
60
|
+
Setup: 'eris login' writes ~/.eris/yaks.json ({"url", "token"}, mode 0600;
|
|
61
|
+
the url defaults to https://api.sibylline.group); or export ERIS_YAKS_URL /
|
|
62
|
+
ERIS_YAKS_TOKEN.
|
|
63
|
+
|
|
64
|
+
Run 'eris yak <command> --help' for the full flag reference.
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
/** A bun "cannot resolve a package" error, read from its structured fields. */
|
|
68
|
+
function isMissingPackage(e: unknown): boolean {
|
|
69
|
+
if (typeof e !== "object" || e === null) return false;
|
|
70
|
+
const { code, specifier } = e as Record<string, unknown>;
|
|
71
|
+
return (
|
|
72
|
+
(code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") &&
|
|
73
|
+
typeof specifier === "string" &&
|
|
74
|
+
!/^(\.|\/|[a-z][a-z0-9+.-]*:)/i.test(specifier)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function depsMissing(verb: string, ctx: VerbContext): number {
|
|
79
|
+
const what = "eris's node_modules is missing (or incomplete) — 'eris yak' cannot run its TypeScript core without it";
|
|
80
|
+
if (ctx.format === "json") {
|
|
81
|
+
const record = { verb, delivered: false, redactions: { count: 0, classes: [], skipped: false }, error: { slug: "yak-deps-not-installed", reason: what } };
|
|
82
|
+
ctx.stdout.write(`${JSON.stringify(record)}\n`);
|
|
83
|
+
} else {
|
|
84
|
+
ctx.stderr.write(renderFailureBlock({ slug: "yak-deps-not-installed", what, fix: `from ${ctx.erisDir}, run: bun install` }, ctx.erisDir));
|
|
85
|
+
}
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function runYakVerb(args: string[], ctx: VerbContext, deps: YakVerbDeps = {}): Promise<number> {
|
|
90
|
+
const g = groupArgs(plugin.rows, "yak", args);
|
|
91
|
+
if (g.kind === "bare" || g.kind === "help") {
|
|
92
|
+
ctx.stdout.write(YAK_USAGE);
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
if (g.kind === "unknown") {
|
|
96
|
+
ctx.stderr.write(
|
|
97
|
+
renderFailureBlock(
|
|
98
|
+
{ slug: "yak-unknown-verb", what: `unknown yak command '${g.sub}'`, fix: "use one of: issue, suggest, fatal\nrun 'eris yak --help' for the full command list" },
|
|
99
|
+
ctx.erisDir,
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
throw new CliExit(1);
|
|
103
|
+
}
|
|
104
|
+
const verb = g.sub;
|
|
105
|
+
|
|
106
|
+
let runYak: RunYak;
|
|
107
|
+
try {
|
|
108
|
+
runYak = await (deps.load ?? (async () => (await import("./yak")).runYak))();
|
|
109
|
+
} catch (e) {
|
|
110
|
+
if (isMissingPackage(e)) return depsMissing(verb, ctx);
|
|
111
|
+
throw e;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const result = await runYak({
|
|
115
|
+
argv: [verb, ...g.rest],
|
|
116
|
+
env: {
|
|
117
|
+
...ctx.env,
|
|
118
|
+
ERIS_DIR: ctx.erisDir,
|
|
119
|
+
ERIS_OUTPUT: ctx.format,
|
|
120
|
+
ERIS_YAK_SHELL_ID: String(deps.ppid ?? process.ppid),
|
|
121
|
+
},
|
|
122
|
+
home: ctx.env.HOME ?? homedir(),
|
|
123
|
+
cwd: deps.cwd,
|
|
124
|
+
});
|
|
125
|
+
if (result.stdout.length > 0) ctx.stdout.write(result.stdout);
|
|
126
|
+
if (result.stderr.length > 0) ctx.stderr.write(result.stderr);
|
|
127
|
+
return result.exitCode;
|
|
128
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The yak plugin's manifest: `eris yak issue|suggest|fatal`, and `eris login` /
|
|
3
|
+
* `eris logout`, which manage the bearer `eris yak` reads, as registry rows
|
|
4
|
+
* plus handler thunks (docs/dev/PLUGINS.md).
|
|
5
|
+
*
|
|
6
|
+
* Read on every command line, `eris completion` included: rows are inline
|
|
7
|
+
* data, and the handler module loads only when `eris yak` dispatches.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { definePlugin } from "../../src/cli/plugin";
|
|
11
|
+
|
|
12
|
+
/** The one grammar the three verbs share. */
|
|
13
|
+
const YAK_FLAGS = [
|
|
14
|
+
{ name: "--summary", takesValue: true },
|
|
15
|
+
{ name: "--body", takesValue: true },
|
|
16
|
+
{ name: "--body-file", takesValue: true, file: true },
|
|
17
|
+
{ name: "--evidence", takesValue: true },
|
|
18
|
+
{ name: "--evidence-file", takesValue: true, file: true },
|
|
19
|
+
{ name: "--repro", takesValue: true },
|
|
20
|
+
{ name: "--repo", takesValue: true, repeatable: true },
|
|
21
|
+
{ name: "--no-redact", takesValue: false },
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
export default definePlugin({
|
|
25
|
+
name: "yak",
|
|
26
|
+
rows: {
|
|
27
|
+
yak: { impl: "ts", summary: "Land a durable observation with a Yaks endpoint (issue, suggest, fatal)" },
|
|
28
|
+
"yak fatal": { summary: "The harness itself is broken", json: true, flags: YAK_FLAGS },
|
|
29
|
+
"yak issue": { summary: "A harness/tooling defect, confirmed or suspected", json: true, flags: YAK_FLAGS },
|
|
30
|
+
"yak suggest": { summary: "An improvement to the harness: a missing capability or a better way of working", json: true, flags: YAK_FLAGS },
|
|
31
|
+
login: {
|
|
32
|
+
impl: "ts",
|
|
33
|
+
summary: "Sign this machine in with a device code; writes the token 'eris yak' uses",
|
|
34
|
+
usage: "[--url <url>] [--no-browser]",
|
|
35
|
+
flags: [
|
|
36
|
+
{ name: "--url", takesValue: true },
|
|
37
|
+
{ name: "--no-browser", takesValue: false },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
logout: { impl: "ts", summary: "Remove this machine's token from ~/.eris/yaks.json" },
|
|
41
|
+
},
|
|
42
|
+
handlers: {
|
|
43
|
+
yak: async () => (await import("./handlers")).runYakVerb,
|
|
44
|
+
login: async () => (await import("./login")).runLogin,
|
|
45
|
+
logout: async () => (await import("./login")).runLogout,
|
|
46
|
+
},
|
|
47
|
+
jsonFix: ` eris yak issue|suggest|fatal {verb, delivered, id?, idempotency_key?, replay?,
|
|
48
|
+
contract_version?, key_mismatch?, redactions, error?}
|
|
49
|
+
(nothing on stderr, unlike every other surface here)
|
|
50
|
+
`,
|
|
51
|
+
});
|