@monster-orange/den 0.0.0-stage → 0.1.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 (231) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +21 -0
  3. package/README.md +166 -2
  4. package/bin/den +61 -0
  5. package/docs/distributions.md +256 -0
  6. package/make/demo.mk +82 -0
  7. package/make/den.mk +80 -0
  8. package/make/journeys.mk +57 -0
  9. package/make/local_dev.mk +154 -0
  10. package/make/security.mk +59 -0
  11. package/make/test.mk +405 -0
  12. package/package.json +104 -4
  13. package/scripts/auth-configure.ts +415 -0
  14. package/scripts/heal-config.ts +92 -0
  15. package/scripts/jsonl-playwright-reporter.js +70 -0
  16. package/scripts/manifest-read.cjs +91 -0
  17. package/scripts/setup-1password.ts +570 -0
  18. package/scripts/setup-secrets.ts +149 -0
  19. package/scripts/view-results.js +156 -0
  20. package/service/install.sh +112 -0
  21. package/src/pillars/api/fixtures/index.ts +128 -0
  22. package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
  23. package/src/pillars/api/global-setup.ts +103 -0
  24. package/src/pillars/api/playwright.config.ts +105 -0
  25. package/src/pillars/api/schemas/example-api.json +110 -0
  26. package/src/pillars/api/tests/health/health.spec.ts +19 -0
  27. package/src/pillars/api/tests/health/manifest.yml +6 -0
  28. package/src/pillars/browser/fixtures/index.ts +232 -0
  29. package/src/pillars/browser/fixtures/package.json +10 -0
  30. package/src/pillars/browser/global-setup.ts +140 -0
  31. package/src/pillars/browser/global-teardown.ts +48 -0
  32. package/src/pillars/browser/playwright.config.ts +212 -0
  33. package/src/pillars/browser/setup.ts +219 -0
  34. package/src/pillars/journeys/app-config.ts +237 -0
  35. package/src/pillars/journeys/discover.ts +160 -0
  36. package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
  37. package/src/pillars/journeys/observability/events.ts +288 -0
  38. package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
  39. package/src/pillars/journeys/report/friction.ts +304 -0
  40. package/src/pillars/journeys/report/junit.ts +105 -0
  41. package/src/pillars/journeys/report/render.ts +273 -0
  42. package/src/pillars/journeys/report/results.ts +323 -0
  43. package/src/pillars/journeys/run.ts +673 -0
  44. package/src/pillars/journeys/runner.ts +1025 -0
  45. package/src/pillars/journeys/sandbox.ts +406 -0
  46. package/src/pillars/journeys/spec/loader.ts +152 -0
  47. package/src/pillars/journeys/spec/schema.ts +208 -0
  48. package/src/pillars/journeys/teardown.ts +98 -0
  49. package/src/pillars/journeys/tools/browser-session.ts +219 -0
  50. package/src/pillars/journeys/tools/browser-tool.ts +914 -0
  51. package/src/pillars/journeys/tools/http-tool.ts +564 -0
  52. package/src/pillars/journeys/tools/ledger.ts +278 -0
  53. package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
  54. package/src/pillars/journeys/verify/assertions.ts +502 -0
  55. package/src/pillars/journeys/verify/verdict.ts +71 -0
  56. package/src/pillars/perf/build-report.ts +36 -0
  57. package/src/pillars/perf/k6.config.js +112 -0
  58. package/src/pillars/perf/run.ts +345 -0
  59. package/src/pillars/perf/scenarios/load-profile.js +97 -0
  60. package/src/pillars/perf/staging.ts +73 -0
  61. package/src/pillars/security/discover.ts +108 -0
  62. package/src/pillars/security/findings.ts +151 -0
  63. package/src/pillars/security/plan.ts +355 -0
  64. package/src/pillars/security/run.ts +729 -0
  65. package/src/pillars/smoke/admin-probe.ts +68 -0
  66. package/src/pillars/smoke/base-url.ts +56 -0
  67. package/src/pillars/smoke/cleanup.ts +8 -0
  68. package/src/pillars/smoke/discover.ts +237 -0
  69. package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
  70. package/src/pillars/smoke/fixture-manager.ts +217 -0
  71. package/src/pillars/smoke/fixtures.ts +468 -0
  72. package/src/pillars/smoke/global-setup-all.ts +262 -0
  73. package/src/pillars/smoke/global-setup-global.ts +262 -0
  74. package/src/pillars/smoke/global-setup.ts +273 -0
  75. package/src/pillars/smoke/global-teardown-all.ts +8 -0
  76. package/src/pillars/smoke/global-teardown-global.ts +8 -0
  77. package/src/pillars/smoke/global-teardown.ts +139 -0
  78. package/src/pillars/smoke/playwright-all.config.ts +147 -0
  79. package/src/pillars/smoke/playwright-global.config.ts +141 -0
  80. package/src/pillars/smoke/playwright.config.ts +162 -0
  81. package/src/pillars/smoke/setup-env.ts +132 -0
  82. package/src/pillars/smoke/setup-global.ts +126 -0
  83. package/src/pillars/smoke/setup.ts +115 -0
  84. package/src/pillars/smoke/smoke-repo.ts +16 -0
  85. package/src/pillars/smoke/staging.ts +49 -0
  86. package/src/pillars/suites/run.ts +147 -0
  87. package/src/scripts/configure-aws-profiles.ts +114 -0
  88. package/src/scripts/register-repos.ts +307 -0
  89. package/src/scripts/setup-default-workspace.ts +203 -0
  90. package/src/shared/applications.ts +138 -0
  91. package/src/shared/auth/api-token.ts +416 -0
  92. package/src/shared/auth/auth0-helper.ts +284 -0
  93. package/src/shared/auth/headless-login.ts +265 -0
  94. package/src/shared/auth/jwt-helper.ts +186 -0
  95. package/src/shared/auth/sudo-vault.ts +69 -0
  96. package/src/shared/auth/test-accounts.ts +98 -0
  97. package/src/shared/auth/token-injection.ts +184 -0
  98. package/src/shared/auth/token-providers/auth0.ts +80 -0
  99. package/src/shared/auth/token-providers/index.ts +182 -0
  100. package/src/shared/auth/token-refresh.ts +273 -0
  101. package/src/shared/banner.ts +15 -0
  102. package/src/shared/brand.ts +79 -0
  103. package/src/shared/cli-tabs.ts +131 -0
  104. package/src/shared/constants.ts +128 -0
  105. package/src/shared/den-config.ts +566 -0
  106. package/src/shared/dev-metrics/emit.ts +354 -0
  107. package/src/shared/edge/config.ts +229 -0
  108. package/src/shared/edge/render.ts +221 -0
  109. package/src/shared/edge/runtime.ts +268 -0
  110. package/src/shared/edge/types.ts +62 -0
  111. package/src/shared/engine.ts +67 -0
  112. package/src/shared/environment/aws-profiles.ts +49 -0
  113. package/src/shared/environment/env-selector.ts +161 -0
  114. package/src/shared/environment/playwright-env.ts +82 -0
  115. package/src/shared/environment-manager/catalog.ts +77 -0
  116. package/src/shared/environment-manager/clone-guard.ts +163 -0
  117. package/src/shared/environment-manager/dag.ts +286 -0
  118. package/src/shared/environment-manager/default-workspace.ts +182 -0
  119. package/src/shared/environment-manager/derivation/alias.ts +90 -0
  120. package/src/shared/environment-manager/derivation/cache.ts +97 -0
  121. package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
  122. package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
  123. package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
  124. package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
  125. package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
  126. package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
  127. package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
  128. package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
  129. package/src/shared/environment-manager/derivation/scan.ts +160 -0
  130. package/src/shared/environment-manager/derivation/text.ts +40 -0
  131. package/src/shared/environment-manager/derivation/types.ts +69 -0
  132. package/src/shared/environment-manager/health.ts +589 -0
  133. package/src/shared/environment-manager/index.ts +6 -0
  134. package/src/shared/environment-manager/load-applications.ts +78 -0
  135. package/src/shared/environment-manager/log-tail.ts +212 -0
  136. package/src/shared/environment-manager/make-targets.ts +78 -0
  137. package/src/shared/environment-manager/opctl.ts +334 -0
  138. package/src/shared/environment-manager/readiness.ts +93 -0
  139. package/src/shared/environment-manager/register-local.ts +72 -0
  140. package/src/shared/environment-manager/secrets-check.ts +260 -0
  141. package/src/shared/environment-manager/service-env.ts +110 -0
  142. package/src/shared/environment-manager/service-runner.ts +1588 -0
  143. package/src/shared/environment-manager/up-plan.ts +302 -0
  144. package/src/shared/environment-manager/workspace-manager.ts +905 -0
  145. package/src/shared/environment-manager/workspaces.ts +860 -0
  146. package/src/shared/manifest/deps.ts +27 -0
  147. package/src/shared/manifest/migrate-manifest.ts +101 -0
  148. package/src/shared/manifest/read-manifest.ts +160 -0
  149. package/src/shared/manifest/schema.ts +299 -0
  150. package/src/shared/manifest/service-links.ts +93 -0
  151. package/src/shared/manifest/validate-manifest.ts +140 -0
  152. package/src/shared/manifest/workspace-manifests.ts +92 -0
  153. package/src/shared/open-url.ts +122 -0
  154. package/src/shared/org.ts +44 -0
  155. package/src/shared/paths.ts +178 -0
  156. package/src/shared/perf/optional-fields.ts +105 -0
  157. package/src/shared/perf/report-normalizer.ts +404 -0
  158. package/src/shared/perf/report-paths.ts +19 -0
  159. package/src/shared/perf/report-store.ts +110 -0
  160. package/src/shared/perf/report-types.ts +70 -0
  161. package/src/shared/perf/test-paths.ts +25 -0
  162. package/src/shared/process/log-rotation.ts +147 -0
  163. package/src/shared/process/spawn-streaming.ts +229 -0
  164. package/src/shared/product.ts +92 -0
  165. package/src/shared/profile.ts +141 -0
  166. package/src/shared/repo-kind.ts +32 -0
  167. package/src/shared/reporting/jsonl-reporter.ts +131 -0
  168. package/src/shared/reporting/slack-reporter.ts +340 -0
  169. package/src/shared/results/result-reader.ts +266 -0
  170. package/src/shared/results/result-writer.ts +168 -0
  171. package/src/shared/secrets/doppler.ts +126 -0
  172. package/src/shared/secrets/env.ts +48 -0
  173. package/src/shared/secrets/index.ts +197 -0
  174. package/src/shared/secrets/keys.ts +33 -0
  175. package/src/shared/secrets/onepassword.ts +75 -0
  176. package/src/shared/secrets/types.ts +52 -0
  177. package/src/shared/service-url.ts +119 -0
  178. package/src/shared/smoke/branch-resolver.ts +141 -0
  179. package/src/shared/smoke/git-remote.ts +70 -0
  180. package/src/shared/smoke/repo-cloner.ts +151 -0
  181. package/src/shared/stacks/config.ts +137 -0
  182. package/src/shared/stacks/manifest-commands.ts +88 -0
  183. package/src/shared/stacks/render.ts +253 -0
  184. package/src/shared/stacks/runtime.ts +334 -0
  185. package/src/shared/stacks/types.ts +53 -0
  186. package/src/shared/terminal/tabs.ts +212 -0
  187. package/src/shared/test-discovery/module-scanner.ts +569 -0
  188. package/src/shared/types/index.ts +678 -0
  189. package/src/shared/ui-marker.ts +19 -0
  190. package/src/ui/cli/applications-command.ts +116 -0
  191. package/src/ui/cli/config-command.ts +68 -0
  192. package/src/ui/cli/create-main.ts +9 -0
  193. package/src/ui/cli/create.ts +360 -0
  194. package/src/ui/cli/edge-command.ts +91 -0
  195. package/src/ui/cli/flag-parser.ts +246 -0
  196. package/src/ui/cli/graph-command.ts +524 -0
  197. package/src/ui/cli/health-display.ts +63 -0
  198. package/src/ui/cli/index.ts +3656 -0
  199. package/src/ui/cli/init.ts +855 -0
  200. package/src/ui/cli/invocation-target.ts +175 -0
  201. package/src/ui/cli/logs-command.ts +104 -0
  202. package/src/ui/cli/module-selection.ts +31 -0
  203. package/src/ui/cli/prompts.ts +767 -0
  204. package/src/ui/cli/results-viewer.ts +436 -0
  205. package/src/ui/cli/secrets-preflight.ts +91 -0
  206. package/src/ui/cli/stack-command.ts +144 -0
  207. package/src/ui/cli/stalled-setup-recovery.ts +129 -0
  208. package/src/ui/cli/test-runner.ts +1312 -0
  209. package/src/ui/cli/web-command.ts +1073 -0
  210. package/src/ui/server/changelog-parser.ts +94 -0
  211. package/src/ui/server/daemon-reaper.ts +129 -0
  212. package/src/ui/server/editor-manager.ts +301 -0
  213. package/src/ui/server/github-service.ts +223 -0
  214. package/src/ui/server/http-helpers.ts +98 -0
  215. package/src/ui/server/index.ts +3236 -0
  216. package/src/ui/server/log-history.ts +452 -0
  217. package/src/ui/server/perf-report.ts +42 -0
  218. package/src/ui/server/public/environments.html +494 -0
  219. package/src/ui/server/public/favicon.ico +0 -0
  220. package/src/ui/server/public/header.js +1371 -0
  221. package/src/ui/server/public/index.html +2595 -0
  222. package/src/ui/server/public/local-dev.html +7405 -0
  223. package/src/ui/server/public/logs.html +1489 -0
  224. package/src/ui/server/public/perf-report.js +359 -0
  225. package/src/ui/server/public/release.html +2111 -0
  226. package/src/ui/server/public/services.html +1098 -0
  227. package/src/ui/server/public/workspace-picker.js +69 -0
  228. package/src/ui/server/release-deploy-session.ts +246 -0
  229. package/src/ui/server/release-service.ts +1125 -0
  230. package/src/ui/server/sudo-store.ts +111 -0
  231. package/src/ui/server/workspace-routes.ts +2154 -0
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Journeys observability event bus — the single structured timeline of what
3
+ * a journey did, feeding three sinks from one source:
4
+ * 1. an in-memory list (the report reads it for the timeline + binding
5
+ * summary + denial list),
6
+ * 2. `events.jsonl` next to the transcript (post-mortem forensics),
7
+ * 3. an optional condensed live line to stdout (JOURNEYS_VERBOSE) so a
8
+ * stuck/looping run is obvious WHILE it runs, not after `report:`.
9
+ *
10
+ * The transcript.jsonl remains the raw SDK stream; events.jsonl is the
11
+ * harness's own interpretation — tool calls with results, ledger bindings
12
+ * (the `{<type>_id}` question answered directly), network interception hits,
13
+ * capability/policy denials, agent errors, and turn/token progress.
14
+ *
15
+ * Data-minimization (mirrors ledger.ts): events carry ONLY method +
16
+ * sanitized path + status + extracted id + tool name + a short human
17
+ * summary. Never bodies, headers, cookies, or credential material. The
18
+ * caller still passes secret values so the JSONL writer fails closed if one
19
+ * ever appears (defense in depth alongside TranscriptScrubber).
20
+ */
21
+
22
+ import * as fs from "fs";
23
+ import * as path from "path";
24
+ import type { HttpMethod, LedgerSource } from "../tools/ledger.js";
25
+
26
+ /** Monotonic-ish wall clock; injectable for deterministic tests. */
27
+ export type Clock = () => number;
28
+
29
+ /**
30
+ * One structured harness event. `kind` discriminates; every event carries a
31
+ * `seq` (emission order) and `tMs` (ms since bus creation) stamped by the
32
+ * bus, so consumers never compute timing themselves.
33
+ */
34
+ export type JourneyEvent = { seq: number; tMs: number } & JourneyEventBody;
35
+
36
+ export type JourneyEventBody =
37
+ | {
38
+ kind: "run_started";
39
+ journey: string;
40
+ env: string;
41
+ surface: string;
42
+ model: string;
43
+ }
44
+ | { kind: "turn_started"; turn: number }
45
+ | {
46
+ kind: "turn_ended";
47
+ turn: number;
48
+ /** Cumulative total tokens if the runtime reported usage this turn. */
49
+ totalTokens?: number;
50
+ }
51
+ | {
52
+ kind: "tool_call";
53
+ /** Tool wire name, e.g. "browser_navigate", "http_request". */
54
+ tool: string;
55
+ /** SDK call id, for correlating start/end if both are emitted. */
56
+ callId?: string;
57
+ status: "running" | "completed" | "error";
58
+ /** One-line, secret-free summary of args (e.g. `GET /api/v1/items`). */
59
+ summary?: string;
60
+ /** One-line, secret-free summary of the result (status, error code). */
61
+ result?: string;
62
+ }
63
+ | { kind: "thinking"; text: string }
64
+ | { kind: "assistant_text"; text: string }
65
+ | {
66
+ kind: "ledger_binding";
67
+ resourceType: string;
68
+ id: string;
69
+ method: HttpMethod;
70
+ pathValue: string;
71
+ status: number;
72
+ source: LedgerSource;
73
+ }
74
+ | {
75
+ kind: "unmanaged_mutation";
76
+ method: HttpMethod;
77
+ pathValue: string;
78
+ status: number;
79
+ source: LedgerSource;
80
+ }
81
+ | { kind: "interception_error"; message: string }
82
+ | {
83
+ kind: "snapshot";
84
+ action: string;
85
+ /** null when suppressed/failed — the reason is in `note`. */
86
+ pathValue: string | null;
87
+ note?: string;
88
+ }
89
+ | {
90
+ kind: "agent_error";
91
+ /** SDK RunResult.error.message (secret-redacted by the caller). */
92
+ message: string;
93
+ code?: string;
94
+ }
95
+ | { kind: "run_ended"; failureKind?: string; durationMs: number };
96
+
97
+ /**
98
+ * The surface the runner depends on — lets tests inject a recording fake
99
+ * without a filesystem. `JourneyEventBus` is the production implementation.
100
+ */
101
+ export interface JourneyEventSink {
102
+ emit(body: JourneyEventBody): void;
103
+ addSecret(value: string): void;
104
+ readonly events: readonly JourneyEvent[];
105
+ }
106
+
107
+ /** Sink config; all optional so tests can run the bus headless-silent. */
108
+ export interface JourneyEventBusOptions {
109
+ /** Absolute path for the JSONL sink; when omitted, nothing is written. */
110
+ filePath?: string;
111
+ /** Emit a condensed line per event to this writer (stdout when verbose). */
112
+ liveWriter?: (line: string) => void;
113
+ /** Secret values redacted before any event is written/printed. */
114
+ secrets?: readonly string[];
115
+ clock?: Clock;
116
+ }
117
+
118
+ /** A live secret slipped into an event — fail the run loudly (never write). */
119
+ export class EventLeakError extends Error {
120
+ constructor() {
121
+ super(
122
+ "Journey event persist blocked: an event matched a live secret value. " +
123
+ "The run fails closed (failure_kind=infra_error).",
124
+ );
125
+ this.name = "EventLeakError";
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Collects, persists, and (optionally) live-prints structured journey
131
+ * events. Single-threaded append; the JSONL write is synchronous so an
132
+ * event is durable the instant it is emitted (a crash mid-run still leaves
133
+ * a partial timeline on disk).
134
+ */
135
+ export class JourneyEventBus {
136
+ private readonly recorded: JourneyEvent[] = [];
137
+ private readonly filePath?: string;
138
+ private readonly liveWriter?: (line: string) => void;
139
+ private readonly secrets: string[];
140
+ private readonly clock: Clock;
141
+ private readonly startedAt: number;
142
+ private seqCounter = 0;
143
+
144
+ constructor(options: JourneyEventBusOptions = {}) {
145
+ this.filePath = options.filePath;
146
+ this.liveWriter = options.liveWriter;
147
+ this.secrets = (options.secrets ?? []).filter((v) => v.length > 0);
148
+ this.clock = options.clock ?? Date.now;
149
+ this.startedAt = this.clock();
150
+ if (this.filePath !== undefined) {
151
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
152
+ }
153
+ }
154
+
155
+ /** Events in emission order (report timeline reads this). */
156
+ get events(): readonly JourneyEvent[] {
157
+ return this.recorded;
158
+ }
159
+
160
+ /**
161
+ * Add live secret values to redact from future events. The runner learns
162
+ * the session token only after provisioning, so the bus is constructed
163
+ * with the Cursor key and the token is added once derived.
164
+ */
165
+ addSecret(value: string): void {
166
+ if (value.length > 0 && !this.secrets.includes(value)) {
167
+ this.secrets.push(value);
168
+ }
169
+ }
170
+
171
+ private redact(text: string): string {
172
+ let out = text;
173
+ for (const secret of this.secrets) {
174
+ out = out.split(secret).join("[REDACTED]");
175
+ }
176
+ return out;
177
+ }
178
+
179
+ /**
180
+ * Emit one event: stamp seq + tMs, redact strings, record, persist, and
181
+ * (if verbose) live-print. Throws EventLeakError if a raw secret survives
182
+ * redaction anywhere in the serialized event.
183
+ */
184
+ emit(body: JourneyEventBody): void {
185
+ const redactedBody = redactEventStrings(body, (t) => this.redact(t));
186
+ const event: JourneyEvent = {
187
+ seq: this.seqCounter++,
188
+ tMs: this.clock() - this.startedAt,
189
+ ...redactedBody,
190
+ };
191
+ const line = JSON.stringify(event);
192
+ for (const secret of this.secrets) {
193
+ if (line.includes(secret)) {
194
+ throw new EventLeakError();
195
+ }
196
+ }
197
+ this.recorded.push(event);
198
+ if (this.filePath !== undefined) {
199
+ fs.appendFileSync(this.filePath, `${line}\n`);
200
+ }
201
+ this.liveWriter?.(formatLiveLine(event));
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Map every user-supplied string field of an event through `fn` (redaction).
207
+ * Kept exhaustive-by-construction: only known string-bearing fields are
208
+ * touched, numbers/enums pass through.
209
+ */
210
+ function redactEventStrings(
211
+ body: JourneyEventBody,
212
+ fn: (t: string) => string,
213
+ ): JourneyEventBody {
214
+ switch (body.kind) {
215
+ case "tool_call":
216
+ return {
217
+ ...body,
218
+ summary: body.summary === undefined ? undefined : fn(body.summary),
219
+ result: body.result === undefined ? undefined : fn(body.result),
220
+ };
221
+ case "thinking":
222
+ case "assistant_text":
223
+ return { ...body, text: fn(body.text) };
224
+ case "interception_error":
225
+ return { ...body, message: fn(body.message) };
226
+ case "agent_error":
227
+ return { ...body, message: fn(body.message) };
228
+ default:
229
+ return body;
230
+ }
231
+ }
232
+
233
+ /** ANSI-free, one-line condensed rendering for the live/verbose stream. */
234
+ export function formatLiveLine(event: JourneyEvent): string {
235
+ const t = `${(event.tMs / 1000).toFixed(1)}s`;
236
+ switch (event.kind) {
237
+ case "run_started":
238
+ return ` [${t}] ▶ ${event.journey} (${event.surface}) model=${event.model}`;
239
+ case "turn_started":
240
+ return ` [${t}] turn ${event.turn}`;
241
+ case "turn_ended":
242
+ return ` [${t}] turn ${event.turn} ended${
243
+ event.totalTokens !== undefined ? ` (${event.totalTokens} tok)` : ""
244
+ }`;
245
+ case "tool_call": {
246
+ const icon =
247
+ event.status === "completed"
248
+ ? "✓"
249
+ : event.status === "error"
250
+ ? "✗"
251
+ : "…";
252
+ const parts = [` [${t}] ${icon} ${event.tool}`];
253
+ if (event.summary) parts.push(event.summary);
254
+ if (event.result) parts.push(`→ ${event.result}`);
255
+ return parts.join(" ");
256
+ }
257
+ case "thinking":
258
+ return ` [${t}] 💭 ${truncate(event.text, 100)}`;
259
+ case "assistant_text":
260
+ return ` [${t}] 🗣 ${truncate(event.text, 100)}`;
261
+ case "ledger_binding":
262
+ return ` [${t}] 🔗 bound ${event.resourceType}=${event.id} (${event.method} ${event.pathValue} → ${event.status})`;
263
+ case "unmanaged_mutation":
264
+ return ` [${t}] ⚠ unmanaged ${event.method} ${event.pathValue} → ${event.status}`;
265
+ case "interception_error":
266
+ return ` [${t}] ⚠ interception error: ${truncate(event.message, 120)}`;
267
+ case "snapshot":
268
+ return ` [${t}] 📸 ${event.action}${
269
+ event.pathValue === null
270
+ ? ` (no frame${event.note ? `: ${event.note}` : ""})`
271
+ : ""
272
+ }`;
273
+ case "agent_error":
274
+ return ` [${t}] ✗ agent error${event.code ? ` [${event.code}]` : ""}: ${truncate(event.message, 200)}`;
275
+ case "run_ended":
276
+ return ` [${t}] ■ ended${event.failureKind ? ` (${event.failureKind})` : " (ok)"} in ${event.durationMs}ms`;
277
+ default: {
278
+ // Exhaustiveness guard: a new event kind must extend this switch.
279
+ const _never: never = event;
280
+ return String(_never);
281
+ }
282
+ }
283
+ }
284
+
285
+ function truncate(text: string, max: number): string {
286
+ const flat = text.replace(/\s+/g, " ").trim();
287
+ return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
288
+ }
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Pure SDK-stream → JourneyEvent mapper. The runner streams opaque SDK
3
+ * messages; this module turns the interesting ones into condensed,
4
+ * secret-free harness events (tool calls with results, thinking,
5
+ * assistant text, per-turn usage). Kept pure + injectable-free so it is
6
+ * exhaustively unit-testable without an SDK or a live agent.
7
+ *
8
+ * Only the subset the operator cares about is mapped; system/request/task
9
+ * bookkeeping messages produce no events (the raw line is still in
10
+ * transcript.jsonl). Tool args/results are summarized to a single line —
11
+ * never the full payload — so events.jsonl stays a timeline, not a body
12
+ * dump (see events.ts data-minimization contract).
13
+ */
14
+
15
+ import type { SDKMessage } from "@cursor/sdk";
16
+ import type { JourneyEventBody } from "./events.js";
17
+
18
+ /**
19
+ * Stream state threaded across messages. The SDK streams assistant output
20
+ * one TOKEN per `assistant` message (verified on a staging transcript:
21
+ * 771 assistant frames for a handful of logical replies — "I'll", " run",
22
+ * " the", …). Emitting an event per frame produced 771 fake turns + 763
23
+ * one-word assistant_text lines and buried the real tool timeline. So we
24
+ * BUFFER consecutive text deltas here and flush a single coalesced
25
+ * assistant_text when a non-text signal (tool call / thinking / usage /
26
+ * end-of-stream) arrives. `turn` increments per usage-reported round, the
27
+ * only honest turn boundary the stream exposes.
28
+ */
29
+ export interface MapperState {
30
+ turn: number;
31
+ /** Accumulated assistant text since the last flush. */
32
+ textBuffer: string;
33
+ }
34
+
35
+ export function createMapperState(): MapperState {
36
+ return { turn: 0, textBuffer: "" };
37
+ }
38
+
39
+ /**
40
+ * Flush any buffered assistant text as one coalesced event. Called by the
41
+ * mapper before emitting a non-text event, and by the runner at end-of-stream
42
+ * so a trailing reply is not lost.
43
+ */
44
+ export function flushAssistantText(state: MapperState): JourneyEventBody[] {
45
+ const text = state.textBuffer.trim();
46
+ state.textBuffer = "";
47
+ return text.length > 0 ? [{ kind: "assistant_text", text }] : [];
48
+ }
49
+
50
+ /**
51
+ * Map one SDK message to zero or more journey events, mutating `state`.
52
+ * Returns [] for messages with no operator signal (the buffer may still be
53
+ * mutated). The runner MUST call flushAssistantText(state) after the stream
54
+ * ends to emit any trailing buffered reply.
55
+ */
56
+ export function sdkMessageToEvents(
57
+ message: SDKMessage,
58
+ state: MapperState,
59
+ ): JourneyEventBody[] {
60
+ switch (message.type) {
61
+ case "assistant":
62
+ return mapAssistant(message, state);
63
+ case "tool_call": {
64
+ // Custom tools are routed through the SDK's MCP bridge, so the wire
65
+ // `name` is the literal "mcp" and the real tool + args are nested under
66
+ // args.{toolName,args}. Unwrap so the timeline shows `browser_navigate`,
67
+ // not `mcp` (verified against a staging transcript, 2026-07-16).
68
+ const { tool, args } = unwrapMcp(message.name, message.args);
69
+ return [
70
+ ...flushAssistantText(state),
71
+ {
72
+ kind: "tool_call",
73
+ tool,
74
+ callId: message.call_id,
75
+ status: message.status,
76
+ summary: summarizeArgs(tool, args),
77
+ result: summarizeResult(message.status, message.result),
78
+ },
79
+ ];
80
+ }
81
+ case "thinking":
82
+ return message.text.trim().length > 0
83
+ ? [
84
+ ...flushAssistantText(state),
85
+ { kind: "thinking", text: message.text },
86
+ ]
87
+ : [];
88
+ case "usage":
89
+ // A usage message is the SDK's real turn boundary — flush the reply,
90
+ // count the turn, and report cumulative tokens.
91
+ state.turn += 1;
92
+ return [
93
+ ...flushAssistantText(state),
94
+ {
95
+ kind: "turn_ended",
96
+ turn: state.turn,
97
+ totalTokens: message.usage.totalTokens,
98
+ },
99
+ ];
100
+ default:
101
+ // system / user / status / request / task — no timeline signal.
102
+ return [];
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Handle one streamed assistant frame. Text blocks are buffered (coalesced
108
+ * across the many one-token frames); an inline tool_use block flushes the
109
+ * buffer and emits a running tool_call (the SDK also emits a standalone
110
+ * `tool_call` line for execution status, so this is the intent marker).
111
+ */
112
+ function mapAssistant(
113
+ message: Extract<SDKMessage, { type: "assistant" }>,
114
+ state: MapperState,
115
+ ): JourneyEventBody[] {
116
+ // Defensive: a malformed/partial assistant frame (missing message.content)
117
+ // must never crash the stream loop — the raw line is still in
118
+ // transcript.jsonl; we just skip block-level events for it.
119
+ const content = message.message?.content;
120
+ if (!Array.isArray(content)) {
121
+ return [];
122
+ }
123
+
124
+ const events: JourneyEventBody[] = [];
125
+ for (const block of content) {
126
+ if (block.type === "text") {
127
+ state.textBuffer += block.text;
128
+ } else {
129
+ events.push(...flushAssistantText(state));
130
+ const { tool, args } = unwrapMcp(block.name, block.input);
131
+ events.push({
132
+ kind: "tool_call",
133
+ tool,
134
+ callId: block.id,
135
+ status: "running",
136
+ summary: summarizeArgs(tool, args),
137
+ });
138
+ }
139
+ }
140
+ return events;
141
+ }
142
+
143
+ /**
144
+ * Unwrap the SDK's MCP tool envelope. Custom tools surface on the stream as
145
+ * `name:"mcp"` with `args:{providerIdentifier, toolName, args}`; return the
146
+ * inner tool name + its real args. Non-MCP calls pass through unchanged.
147
+ */
148
+ export function unwrapMcp(
149
+ name: string,
150
+ args: unknown,
151
+ ): { tool: string; args: unknown } {
152
+ if (name !== "mcp" || typeof args !== "object" || args === null) {
153
+ return { tool: name, args };
154
+ }
155
+ const bag = args as Record<string, unknown>;
156
+ const toolName = typeof bag.toolName === "string" ? bag.toolName : "mcp";
157
+ return { tool: toolName, args: bag.args };
158
+ }
159
+
160
+ /**
161
+ * Peel the MCP result envelope to the tool's own payload. Custom-tool
162
+ * results arrive as `{ status, value: { content: [{ text: { text } }],
163
+ * isError } }`; return either the inner text (browser tools) or the parsed
164
+ * JSON payload (http_request returns a JSON string) so summarizeResult can
165
+ * read status codes / error codes. Non-MCP results pass through.
166
+ */
167
+ function unwrapMcpResult(result: unknown): {
168
+ payload: unknown;
169
+ isError: boolean;
170
+ } {
171
+ if (typeof result !== "object" || result === null) {
172
+ return { payload: result, isError: false };
173
+ }
174
+ const bag = result as Record<string, unknown>;
175
+ const value = bag.value;
176
+ if (typeof value !== "object" || value === null) {
177
+ return { payload: result, isError: false };
178
+ }
179
+ const valueBag = value as Record<string, unknown>;
180
+ const isError = valueBag.isError === true;
181
+ const content = valueBag.content;
182
+ if (!Array.isArray(content) || content.length === 0) {
183
+ return { payload: result, isError };
184
+ }
185
+ const first = content[0] as { text?: { text?: unknown } } | undefined;
186
+ const text = first?.text?.text;
187
+ if (typeof text !== "string") {
188
+ return { payload: result, isError };
189
+ }
190
+ // http_request returns a JSON string; browser tools return prose.
191
+ try {
192
+ return { payload: JSON.parse(text), isError };
193
+ } catch {
194
+ return { payload: text, isError };
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Build a one-line, secret-free summary of tool args. For the HTTP tool we
200
+ * surface `METHOD /sanitized/path`; for browser tools the target
201
+ * URL/selector; everything else falls back to the arg keys so we never dump
202
+ * a full payload (which could carry a body/token) into the timeline.
203
+ */
204
+ export function summarizeArgs(tool: string, args: unknown): string | undefined {
205
+ if (typeof args !== "object" || args === null) return undefined;
206
+ const bag = args as Record<string, unknown>;
207
+
208
+ if (tool === "http_request" || tool === "journeys_http_request") {
209
+ const method =
210
+ typeof bag.method === "string" ? bag.method.toUpperCase() : "GET";
211
+ const target =
212
+ typeof bag.path === "string"
213
+ ? bag.path
214
+ : typeof bag.url === "string"
215
+ ? bag.url
216
+ : "";
217
+ return `${method} ${sanitizeForSummary(target)}`.trim();
218
+ }
219
+
220
+ if (tool.startsWith("browser_")) {
221
+ if (typeof bag.url === "string") return sanitizeForSummary(bag.url);
222
+ if (typeof bag.selector === "string") return `selector=${bag.selector}`;
223
+ if (typeof bag.text === "string") return `text="${truncate(bag.text, 40)}"`;
224
+ if (typeof bag.action === "string") return bag.action;
225
+ }
226
+
227
+ const keys = Object.keys(bag);
228
+ return keys.length > 0 ? `{${keys.join(", ")}}` : undefined;
229
+ }
230
+
231
+ /**
232
+ * Summarize a tool result to one line. HTTP results expose their status
233
+ * code and any structured error code; browser results expose the leading
234
+ * line of their page summary; errors expose the error code/message. Never
235
+ * the full body.
236
+ */
237
+ export function summarizeResult(
238
+ status: "running" | "completed" | "error",
239
+ rawResult: unknown,
240
+ ): string | undefined {
241
+ if (status === "running") return undefined;
242
+ // Peel the MCP envelope (custom tools) → the tool's own payload + isError.
243
+ const { payload: result, isError } = unwrapMcpResult(rawResult);
244
+ if (result === undefined || result === null) {
245
+ return status === "error" || isError ? "ERROR" : undefined;
246
+ }
247
+
248
+ if (typeof result === "string") {
249
+ return withErrorPrefix(truncate(firstLine(result), 120), isError);
250
+ }
251
+
252
+ if (typeof result === "object") {
253
+ const bag = result as Record<string, unknown>;
254
+ // Scoped-tool error envelope: { error: { code, message } }.
255
+ if (typeof bag.error === "object" && bag.error !== null) {
256
+ const err = bag.error as Record<string, unknown>;
257
+ const code = typeof err.code === "string" ? err.code : "ERROR";
258
+ const msg = typeof err.message === "string" ? ` ${err.message}` : "";
259
+ return truncate(`${code}${msg}`, 120);
260
+ }
261
+ if (typeof bag.error === "string") {
262
+ return truncate(bag.error, 120);
263
+ }
264
+ // HTTP payload: { status, ... }.
265
+ if (typeof bag.status === "number") {
266
+ return `HTTP ${bag.status}`;
267
+ }
268
+ // Browser summary: { summary: "URL: ...\nTitle: ..." }.
269
+ if (typeof bag.summary === "string") {
270
+ return truncate(firstLine(bag.summary), 120);
271
+ }
272
+ }
273
+ return status === "error" ? "error" : "done";
274
+ }
275
+
276
+ /** Strip query/fragment/origin from a URL-ish string for the summary line. */
277
+ function sanitizeForSummary(pathOrUrl: string): string {
278
+ if (pathOrUrl.length === 0) return "";
279
+ try {
280
+ const url = new URL(pathOrUrl, "http://placeholder.invalid");
281
+ return url.pathname;
282
+ } catch {
283
+ // Not URL-shaped — return as-is minus any query tail.
284
+ return pathOrUrl.split("?")[0] ?? pathOrUrl;
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Prefix a summary with "ERROR " when the tool flagged an error — but only
290
+ * if the text doesn't already begin with "ERROR" (browser-tool failures
291
+ * already start with "ERROR ACTION_FAILED: …", which double-prefixed to the
292
+ * nonsensical "ERROR ERROR ACTION_FAILED" on the first staging run).
293
+ */
294
+ function withErrorPrefix(text: string, isError: boolean): string {
295
+ if (!isError) return text;
296
+ return /^error\b/i.test(text) ? text : `ERROR ${text}`;
297
+ }
298
+
299
+ function firstLine(text: string): string {
300
+ return text.split("\n")[0] ?? text;
301
+ }
302
+
303
+ function truncate(text: string, max: number): string {
304
+ const flat = text.replace(/\s+/g, " ").trim();
305
+ return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
306
+ }