@jinn-network/jinn-layer 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 (131) hide show
  1. package/dist/adapters/contribution-adapter.d.ts +18 -0
  2. package/dist/adapters/contribution-adapter.js +134 -0
  3. package/dist/adapters/corpus-adapter.d.ts +12 -0
  4. package/dist/adapters/corpus-adapter.js +212 -0
  5. package/dist/adapters/index.d.ts +17 -0
  6. package/dist/adapters/index.js +17 -0
  7. package/dist/adapters/json-map-store.d.ts +4 -0
  8. package/dist/adapters/json-map-store.js +31 -0
  9. package/dist/adapters/local-learning-adapter.d.ts +25 -0
  10. package/dist/adapters/local-learning-adapter.js +49 -0
  11. package/dist/adapters/skills-adapter.d.ts +8 -0
  12. package/dist/adapters/skills-adapter.js +55 -0
  13. package/dist/admission-policy.d.ts +38 -0
  14. package/dist/admission-policy.js +17 -0
  15. package/dist/bin/jinn-distill-mcp.d.ts +2 -0
  16. package/dist/bin/jinn-distill-mcp.js +10 -0
  17. package/dist/bin/jinn-layer.d.ts +7 -0
  18. package/dist/bin/jinn-layer.js +15 -0
  19. package/dist/bridge-fetch-evidence.d.ts +102 -0
  20. package/dist/bridge-fetch-evidence.js +677 -0
  21. package/dist/bridge-verdict-source.d.ts +47 -0
  22. package/dist/bridge-verdict-source.js +216 -0
  23. package/dist/bridge.d.ts +247 -0
  24. package/dist/bridge.js +439 -0
  25. package/dist/capture.d.ts +98 -0
  26. package/dist/capture.js +617 -0
  27. package/dist/cli.d.ts +148 -0
  28. package/dist/cli.js +2221 -0
  29. package/dist/cluster-selection.d.ts +34 -0
  30. package/dist/cluster-selection.js +135 -0
  31. package/dist/cluster.d.ts +89 -0
  32. package/dist/cluster.js +142 -0
  33. package/dist/consume.d.ts +139 -0
  34. package/dist/consume.js +280 -0
  35. package/dist/corpus-probes.d.ts +61 -0
  36. package/dist/corpus-probes.js +120 -0
  37. package/dist/corpus-store.d.ts +16 -0
  38. package/dist/corpus-store.js +101 -0
  39. package/dist/distill-captures.d.ts +56 -0
  40. package/dist/distill-captures.js +283 -0
  41. package/dist/distill-feedback.d.ts +20 -0
  42. package/dist/distill-feedback.js +30 -0
  43. package/dist/distill-llm.d.ts +138 -0
  44. package/dist/distill-llm.js +438 -0
  45. package/dist/distill-mcp-server.d.ts +30 -0
  46. package/dist/distill-mcp-server.js +281 -0
  47. package/dist/distill-mode.d.ts +62 -0
  48. package/dist/distill-mode.js +107 -0
  49. package/dist/distill-progress.d.ts +78 -0
  50. package/dist/distill-progress.js +69 -0
  51. package/dist/distill-prompt.d.ts +34 -0
  52. package/dist/distill-prompt.js +85 -0
  53. package/dist/distill-render.d.ts +123 -0
  54. package/dist/distill-render.js +303 -0
  55. package/dist/distill-runs.d.ts +28 -0
  56. package/dist/distill-runs.js +39 -0
  57. package/dist/distill-traces.d.ts +67 -0
  58. package/dist/distill-traces.js +211 -0
  59. package/dist/distill.d.ts +180 -0
  60. package/dist/distill.js +337 -0
  61. package/dist/distiller.d.ts +118 -0
  62. package/dist/distiller.js +142 -0
  63. package/dist/envelope.d.ts +9 -0
  64. package/dist/envelope.js +9 -0
  65. package/dist/eval-prep.d.ts +129 -0
  66. package/dist/eval-prep.js +523 -0
  67. package/dist/execution-publish.d.ts +62 -0
  68. package/dist/execution-publish.js +72 -0
  69. package/dist/gate.d.ts +39 -0
  70. package/dist/gate.js +106 -0
  71. package/dist/index.d.ts +49 -0
  72. package/dist/index.js +50 -0
  73. package/dist/ipfs-cid.d.ts +18 -0
  74. package/dist/ipfs-cid.js +149 -0
  75. package/dist/layer-default.d.ts +3 -0
  76. package/dist/layer-default.js +10 -0
  77. package/dist/ledger.d.ts +62 -0
  78. package/dist/ledger.js +104 -0
  79. package/dist/measurement.d.ts +90 -0
  80. package/dist/measurement.js +134 -0
  81. package/dist/path-safety.d.ts +19 -0
  82. package/dist/path-safety.js +303 -0
  83. package/dist/pipeline.d.ts +94 -0
  84. package/dist/pipeline.js +124 -0
  85. package/dist/plugin-wiring.d.ts +4 -0
  86. package/dist/plugin-wiring.js +54 -0
  87. package/dist/preview.d.ts +31 -0
  88. package/dist/preview.js +28 -0
  89. package/dist/process-contract.d.ts +303 -0
  90. package/dist/process-contract.js +118 -0
  91. package/dist/publish-skill.d.ts +86 -0
  92. package/dist/publish-skill.js +116 -0
  93. package/dist/publish.d.ts +294 -0
  94. package/dist/publish.js +1090 -0
  95. package/dist/scripts/scan-skills-registry.d.ts +24 -0
  96. package/dist/scripts/scan-skills-registry.js +207 -0
  97. package/dist/seed-import/curated-batch.d.ts +36 -0
  98. package/dist/seed-import/curated-batch.js +103 -0
  99. package/dist/seed-import/episode-execute.d.ts +50 -0
  100. package/dist/seed-import/episode-execute.js +235 -0
  101. package/dist/seed-import/episode-fetch.d.ts +119 -0
  102. package/dist/seed-import/episode-fetch.js +137 -0
  103. package/dist/seed-import/episode-plan.d.ts +11 -0
  104. package/dist/seed-import/episode-plan.js +27 -0
  105. package/dist/seed-import/episode-report.d.ts +39 -0
  106. package/dist/seed-import/episode-report.js +36 -0
  107. package/dist/seed-import/execute.d.ts +61 -0
  108. package/dist/seed-import/execute.js +245 -0
  109. package/dist/seed-import/fetch.d.ts +62 -0
  110. package/dist/seed-import/fetch.js +82 -0
  111. package/dist/seed-import/licence.d.ts +17 -0
  112. package/dist/seed-import/licence.js +36 -0
  113. package/dist/seed-import/plan.d.ts +10 -0
  114. package/dist/seed-import/plan.js +21 -0
  115. package/dist/seed-import/report.d.ts +34 -0
  116. package/dist/seed-import/report.js +31 -0
  117. package/dist/seed-import/state.d.ts +72 -0
  118. package/dist/seed-import/state.js +120 -0
  119. package/dist/signal.d.ts +36 -0
  120. package/dist/signal.js +48 -0
  121. package/dist/signing.d.ts +8 -0
  122. package/dist/signing.js +21 -0
  123. package/dist/skill-package.d.ts +70 -0
  124. package/dist/skill-package.js +141 -0
  125. package/dist/skill.d.ts +21 -0
  126. package/dist/skill.js +77 -0
  127. package/dist/snapshot-transcript.d.ts +54 -0
  128. package/dist/snapshot-transcript.js +117 -0
  129. package/dist/three-arm-run.d.ts +27 -0
  130. package/dist/three-arm-run.js +41 -0
  131. package/package.json +60 -0
@@ -0,0 +1,617 @@
1
+ /**
2
+ * Harness-layer capture path — the privacy surface (spec §5).
3
+ *
4
+ * `capture(task, opts)` runs a captured task through the mandatory scrub
5
+ * pipeline and returns a `PendingEnvelope`: the captured-but-not-yet-consented
6
+ * state. Per the 2026-07-02 schema review, consent flags on `TraceEnvelopeV0`
7
+ * are literal `true`, so the pre-consent state cannot BE a `TraceEnvelopeV0` —
8
+ * the pending wrapper converts to one only at consent (preview renders the
9
+ * would-be envelope; the Task 4 publish path performs the real conversion).
10
+ *
11
+ * Fail-closed: any scrub-stage failure throws `CaptureScrubError` — no
12
+ * envelope, no partial output, never a silent pass.
13
+ *
14
+ * Scrub pipeline: reuses the seller-side pipeline from
15
+ * `@jinn-network/core/scrub` (key-policy → openredaction →
16
+ * plain-patterns → secretlint → optional ML PII) via `scrubCaptureSpans()`.
17
+ * The email/home-path gap-closers this file originally carried graduated
18
+ * into the shared plain-patterns stage (#1330), so the daemon publish path
19
+ * and this capture path share one implementation.
20
+ *
21
+ * Fitting rule (docs/envelope-v0.md): the conversion MUST produce envelopes
22
+ * inside the frozen caps. Over-long sessions are head/tail-sampled to
23
+ * `MAX_STEPS` (never dropped); oversized step attributes are truncated with
24
+ * the cut keys listed in the step's `truncatedKeys` receipt (never silent —
25
+ * both surface in the `ScrubReport`).
26
+ *
27
+ * Plan: docs/superpowers/plans/2026-07-02-jinn-harness-network-v0-plan.md
28
+ * Task 3 (issue #1310).
29
+ */
30
+ import { buildScrubPipeline, DEFAULT_KEY_POLICY, ScrubPipeline, scrubCaptureSpans, } from '@jinn-network/core/scrub';
31
+ import { MAX_STEPS, MAX_STEP_ATTRIBUTES_BYTES, parseTraceEnvelopeV0, TRACE_ENVELOPE_SCHEMA_VERSION, } from './envelope.js';
32
+ // The captured-task schema + parser moved into @jinn-network/core in C2
33
+ // (#1833). Re-export them so every downstream `./capture.js` importer of
34
+ // these symbols keeps working unchanged. The scrub half of this file stays
35
+ // here (C5's move).
36
+ export { CapturedTaskSchema, parseCapturedTask } from '@jinn-network/core';
37
+ // A re-export does not create a local binding, and capture() below references
38
+ // CapturedTask and parseCapturedTask below — import them locally too.
39
+ import { parseCapturedTask } from '@jinn-network/core';
40
+ export const PENDING_ENVELOPE_KIND = 'jinn.pending-envelope.v0';
41
+ /** A scrub-stage failure. capture() is fail-closed: no envelope was produced. */
42
+ export class CaptureScrubError extends Error {
43
+ constructor(cause) {
44
+ const detail = cause instanceof Error ? cause.message : String(cause);
45
+ super(`scrub failed — capture aborted, no envelope produced (fail-closed): ${detail}`);
46
+ this.name = 'CaptureScrubError';
47
+ this.cause = cause;
48
+ }
49
+ }
50
+ /**
51
+ * ScrubPipeline that chains the base pipeline with the capture-local stages
52
+ * and records each run's input + result, in order. `scrubCaptureSpans()`
53
+ * runs the pipeline exactly once per span, in span order, so run index i
54
+ * corresponds to step i — that correspondence is what lets the redaction
55
+ * diff carry `before` values without re-implementing the span walk.
56
+ */
57
+ class RecordingScrubPipeline extends ScrubPipeline {
58
+ chain;
59
+ runs = [];
60
+ constructor(chain) {
61
+ super([]);
62
+ this.chain = chain;
63
+ }
64
+ get components() {
65
+ return this.chain.flatMap((p) => p.components);
66
+ }
67
+ async run(attributes) {
68
+ let current = attributes;
69
+ const redactions = [];
70
+ for (const pipeline of this.chain) {
71
+ const result = await pipeline.run(current);
72
+ current = result.attributes;
73
+ redactions.push(...result.redactions);
74
+ }
75
+ const result = { attributes: current, redactions };
76
+ this.runs.push({ before: attributes, result });
77
+ return result;
78
+ }
79
+ }
80
+ // ---------------------------------------------------------------------------
81
+ // Fitting (docs/envelope-v0.md, "Fitting rule")
82
+ // ---------------------------------------------------------------------------
83
+ const TRUNCATION_SUFFIX = '…[truncated]';
84
+ function serialisedBytes(value) {
85
+ return Buffer.byteLength(JSON.stringify(value), 'utf8');
86
+ }
87
+ /**
88
+ * Truncate a step's scrubbed attributes until they fit
89
+ * `MAX_STEP_ATTRIBUTES_BYTES`, cutting the largest value first. Returns the
90
+ * fitted attributes plus the cut keys — the visible truncation receipt.
91
+ */
92
+ function fitAttributes(attributes) {
93
+ const out = { ...attributes };
94
+ const cutsByKey = new Map();
95
+ // JSON escaping can make the serialised form of a cut string larger than
96
+ // the raw byte count suggests, so a single pass may land slightly over —
97
+ // the loop re-cuts (the same key if it is still the largest) until the
98
+ // step fits. Each iteration strictly shrinks the chosen value, so the only
99
+ // non-terminating shape is thousands of already-empty values: guard it.
100
+ let guard = Object.keys(out).length * 4 + 8;
101
+ while (serialisedBytes(out) > MAX_STEP_ATTRIBUTES_BYTES) {
102
+ guard -= 1;
103
+ const largest = Object.entries(out)
104
+ .sort((a, b) => serialisedBytes(b[1]) - serialisedBytes(a[1]))[0];
105
+ if (!largest || guard <= 0) {
106
+ throw new CaptureScrubError(new Error(`step attributes cannot be fitted under ${MAX_STEP_ATTRIBUTES_BYTES} bytes`));
107
+ }
108
+ const [key, value] = largest;
109
+ const overshoot = serialisedBytes(out) - MAX_STEP_ATTRIBUTES_BYTES;
110
+ const serialised = typeof value === 'string' ? value : JSON.stringify(value);
111
+ // Cut by bytes with a safety margin for JSON escaping + the suffix.
112
+ const keepBytes = Math.max(0, Buffer.byteLength(serialised, 'utf8') - overshoot - TRUNCATION_SUFFIX.length - 16);
113
+ const after = Buffer.from(serialised, 'utf8')
114
+ .subarray(0, keepBytes)
115
+ .toString('utf8')
116
+ // A byte cut can split a multi-byte character; drop the replacement char.
117
+ .replace(/�+$/, '') + TRUNCATION_SUFFIX;
118
+ out[key] = after;
119
+ const existing = cutsByKey.get(key);
120
+ if (existing) {
121
+ existing.after = after; // re-cut: keep the ORIGINAL value as `before`
122
+ }
123
+ else {
124
+ cutsByKey.set(key, { key, before: attributes[key], after });
125
+ }
126
+ }
127
+ const cuts = [...cutsByKey.values()];
128
+ return { attributes: out, truncatedKeys: cuts.map((c) => c.key), cuts };
129
+ }
130
+ /**
131
+ * Head/tail-sample an over-long step list down to `MAX_STEPS` (keep the
132
+ * first half and the last half; the middle is sampled out). Never drops the
133
+ * session — that is the fitting rule's explicit prohibition.
134
+ */
135
+ function sampleSteps(steps) {
136
+ if (steps.length <= MAX_STEPS)
137
+ return { steps, sampledOut: 0 };
138
+ const head = Math.ceil(MAX_STEPS / 2);
139
+ const tail = MAX_STEPS - head;
140
+ return {
141
+ steps: [...steps.slice(0, head), ...steps.slice(steps.length - tail)],
142
+ sampledOut: steps.length - MAX_STEPS,
143
+ };
144
+ }
145
+ // ---------------------------------------------------------------------------
146
+ // capture()
147
+ // ---------------------------------------------------------------------------
148
+ function fieldRef(stepIndex, key) {
149
+ return `steps[${stepIndex}].attributes[${JSON.stringify(key)}]`;
150
+ }
151
+ async function scrubStepObservations(steps, pipeline) {
152
+ const observations = [];
153
+ const redactions = [];
154
+ for (const [stepIndex, step] of steps.entries()) {
155
+ const events = [];
156
+ for (const [eventIndex, event] of (step.events ?? []).entries()) {
157
+ const originalAttributes = event.attributes ?? {};
158
+ const result = await pipeline.run(originalAttributes);
159
+ const fitted = fitAttributes(result.attributes);
160
+ for (const record of result.redactions) {
161
+ redactions.push({
162
+ field: `steps[${stepIndex}].events[${eventIndex}].attributes`
163
+ + `[${JSON.stringify(record.key)}]`,
164
+ stage: record.stage,
165
+ ...(record.detail ? { detail: record.detail } : {}),
166
+ before: originalAttributes[record.key],
167
+ after: fitted.attributes[record.key],
168
+ });
169
+ }
170
+ for (const cut of fitted.cuts) {
171
+ redactions.push({
172
+ field: `steps[${stepIndex}].events[${eventIndex}].attributes`
173
+ + `[${JSON.stringify(cut.key)}]`,
174
+ stage: 'fit',
175
+ detail: `truncated to fit ${MAX_STEP_ATTRIBUTES_BYTES} bytes`,
176
+ before: cut.before,
177
+ after: cut.after,
178
+ });
179
+ }
180
+ events.push({
181
+ timeUnixNano: event.timeUnixNano,
182
+ name: event.name,
183
+ ...(event.attributes !== undefined
184
+ ? { attributes: fitted.attributes }
185
+ : {}),
186
+ });
187
+ }
188
+ let status = step.status;
189
+ if (status?.message !== undefined) {
190
+ const before = { 'status.message': status.message };
191
+ const result = await pipeline.run(before);
192
+ const fitted = fitAttributes(result.attributes);
193
+ const after = fitted.attributes['status.message'];
194
+ for (const record of result.redactions) {
195
+ redactions.push({
196
+ field: `steps[${stepIndex}].status.message`,
197
+ stage: record.stage,
198
+ ...(record.detail ? { detail: record.detail } : {}),
199
+ before: status.message,
200
+ after,
201
+ });
202
+ }
203
+ for (const cut of fitted.cuts) {
204
+ redactions.push({
205
+ field: `steps[${stepIndex}].status.message`,
206
+ stage: 'fit',
207
+ detail: `truncated to fit ${MAX_STEP_ATTRIBUTES_BYTES} bytes`,
208
+ before: cut.before,
209
+ after: cut.after,
210
+ });
211
+ }
212
+ status = {
213
+ code: status.code,
214
+ ...(typeof after === 'string' && after.length > 0
215
+ ? { message: after }
216
+ : {}),
217
+ };
218
+ }
219
+ observations.push({
220
+ ...(step.events !== undefined ? { events } : {}),
221
+ ...(status !== undefined ? { status } : {}),
222
+ });
223
+ }
224
+ return { observations, redactions };
225
+ }
226
+ function episodeFactBag(task) {
227
+ return {
228
+ ...(task.session.kind !== undefined
229
+ ? { 'episodeFacts.session.kind': task.session.kind }
230
+ : {}),
231
+ ...(task.session.parentSessionId !== undefined
232
+ ? { 'episodeFacts.session.parentSessionId': task.session.parentSessionId }
233
+ : {}),
234
+ ...(task.task.repositorySlug !== undefined
235
+ ? { 'episodeFacts.task.repositorySlug': task.task.repositorySlug }
236
+ : {}),
237
+ ...(task.task.baseCommit !== undefined
238
+ ? { 'episodeFacts.task.baseCommit': task.task.baseCommit }
239
+ : {}),
240
+ ...(task.task.createdAt !== undefined
241
+ ? { 'episodeFacts.task.createdAt': task.task.createdAt }
242
+ : {}),
243
+ ...(task.task.instanceId !== undefined
244
+ ? { 'episodeFacts.task.instanceId': task.task.instanceId }
245
+ : {}),
246
+ ...(task.environment.skillsLoadout !== undefined
247
+ ? { 'episodeFacts.environment.skillsLoadout': task.environment.skillsLoadout }
248
+ : {}),
249
+ ...(task.environment.generatorModel !== undefined
250
+ ? {
251
+ 'episodeFacts.environment.generatorModel.id': task.environment.generatorModel.id,
252
+ ...(task.environment.generatorModel.provider !== undefined
253
+ ? {
254
+ 'episodeFacts.environment.generatorModel.provider': task.environment.generatorModel.provider,
255
+ }
256
+ : {}),
257
+ ...(task.environment.generatorModel.openWeights !== undefined
258
+ ? {
259
+ 'episodeFacts.environment.generatorModel.openWeights': task.environment.generatorModel.openWeights,
260
+ }
261
+ : {}),
262
+ 'episodeFacts.environment.generatorModel.source': task.environment.generatorModel.source,
263
+ }
264
+ : {}),
265
+ ...(task.environment.distributionClass !== undefined
266
+ ? {
267
+ 'episodeFacts.environment.distributionClass': task.environment.distributionClass,
268
+ }
269
+ : {}),
270
+ ...(task.environment.verifier !== undefined
271
+ ? {
272
+ 'episodeFacts.environment.verifier.type': task.environment.verifier.type,
273
+ 'episodeFacts.environment.verifier.failToPass': task.environment.verifier.failToPass,
274
+ 'episodeFacts.environment.verifier.passToPass': task.environment.verifier.passToPass,
275
+ ...(task.environment.verifier.evalSemanticsVersion !== undefined
276
+ ? {
277
+ 'episodeFacts.environment.verifier.evalSemanticsVersion': task.environment.verifier.evalSemanticsVersion,
278
+ }
279
+ : {}),
280
+ }
281
+ : {}),
282
+ 'episodeFacts.trajectoryKinds': task.steps.map((step) => step.kind),
283
+ ...(task.attemptGroup !== undefined
284
+ ? {
285
+ 'episodeFacts.attemptGroup.groupId': task.attemptGroup.groupId,
286
+ 'episodeFacts.attemptGroup.attemptId': task.attemptGroup.attemptId,
287
+ 'episodeFacts.attemptGroup.relatedAttemptRefs': task.attemptGroup.relatedAttemptRefs,
288
+ ...(task.attemptGroup.groupSize !== undefined
289
+ ? { 'episodeFacts.attemptGroup.groupSize': task.attemptGroup.groupSize }
290
+ : {}),
291
+ ...(task.attemptGroup.nPass !== undefined
292
+ ? { 'episodeFacts.attemptGroup.nPass': task.attemptGroup.nPass }
293
+ : {}),
294
+ ...(task.attemptGroup.nFail !== undefined
295
+ ? { 'episodeFacts.attemptGroup.nFail': task.attemptGroup.nFail }
296
+ : {}),
297
+ }
298
+ : {}),
299
+ ...(task.lineage !== undefined
300
+ ? {
301
+ 'episodeFacts.lineage.episodeId': task.lineage.episodeId,
302
+ ...(task.lineage.mintRef !== undefined
303
+ ? { 'episodeFacts.lineage.mintRef': task.lineage.mintRef }
304
+ : {}),
305
+ }
306
+ : {}),
307
+ };
308
+ }
309
+ /**
310
+ * Rebuild a CapturedTask using only scrubbed Episode-only values. Parsing the
311
+ * result keeps this boundary fail-closed if a scrub stage changes a structural
312
+ * enum/type or removes a required member of a present nested block.
313
+ */
314
+ function taskWithScrubbedEpisodeFacts(task, attributes) {
315
+ const fact = (key) => attributes[`episodeFacts.${key}`];
316
+ const kinds = fact('trajectoryKinds');
317
+ return parseCapturedTask({
318
+ ...task,
319
+ session: {
320
+ sessionId: task.session.sessionId,
321
+ capturedAt: task.session.capturedAt,
322
+ ...(fact('session.kind') !== undefined ? { kind: fact('session.kind') } : {}),
323
+ ...(fact('session.parentSessionId') !== undefined
324
+ ? { parentSessionId: fact('session.parentSessionId') }
325
+ : {}),
326
+ },
327
+ task: {
328
+ summary: task.task.summary,
329
+ distributionTags: task.task.distributionTags,
330
+ ...(fact('task.repositorySlug') !== undefined
331
+ ? { repositorySlug: fact('task.repositorySlug') }
332
+ : {}),
333
+ ...(fact('task.baseCommit') !== undefined
334
+ ? { baseCommit: fact('task.baseCommit') }
335
+ : {}),
336
+ ...(fact('task.createdAt') !== undefined
337
+ ? { createdAt: fact('task.createdAt') }
338
+ : {}),
339
+ ...(fact('task.instanceId') !== undefined
340
+ ? { instanceId: fact('task.instanceId') }
341
+ : {}),
342
+ },
343
+ environment: {
344
+ harness: task.environment.harness,
345
+ model: task.environment.model,
346
+ tools: task.environment.tools,
347
+ ...(fact('environment.skillsLoadout') !== undefined
348
+ ? { skillsLoadout: fact('environment.skillsLoadout') }
349
+ : {}),
350
+ ...(task.environment.generatorModel !== undefined
351
+ ? {
352
+ generatorModel: {
353
+ id: fact('environment.generatorModel.id'),
354
+ ...(fact('environment.generatorModel.provider') !== undefined
355
+ ? { provider: fact('environment.generatorModel.provider') }
356
+ : {}),
357
+ ...(fact('environment.generatorModel.openWeights') !== undefined
358
+ ? { openWeights: fact('environment.generatorModel.openWeights') }
359
+ : {}),
360
+ source: fact('environment.generatorModel.source'),
361
+ },
362
+ }
363
+ : {}),
364
+ ...(fact('environment.distributionClass') !== undefined
365
+ ? { distributionClass: fact('environment.distributionClass') }
366
+ : {}),
367
+ ...(task.environment.verifier !== undefined
368
+ ? {
369
+ verifier: {
370
+ type: fact('environment.verifier.type'),
371
+ failToPass: fact('environment.verifier.failToPass'),
372
+ passToPass: fact('environment.verifier.passToPass'),
373
+ ...(fact('environment.verifier.evalSemanticsVersion') !== undefined
374
+ ? {
375
+ evalSemanticsVersion: fact('environment.verifier.evalSemanticsVersion'),
376
+ }
377
+ : {}),
378
+ },
379
+ }
380
+ : {}),
381
+ },
382
+ steps: task.steps.map((step, index) => ({
383
+ ...step,
384
+ ...(Array.isArray(kinds) && kinds[index] !== undefined
385
+ ? { kind: kinds[index] }
386
+ : { kind: undefined }),
387
+ })),
388
+ ...(task.attemptGroup !== undefined
389
+ ? {
390
+ attemptGroup: {
391
+ groupId: fact('attemptGroup.groupId'),
392
+ attemptId: fact('attemptGroup.attemptId'),
393
+ relatedAttemptRefs: fact('attemptGroup.relatedAttemptRefs'),
394
+ ...(fact('attemptGroup.groupSize') !== undefined
395
+ ? { groupSize: fact('attemptGroup.groupSize') }
396
+ : {}),
397
+ ...(fact('attemptGroup.nPass') !== undefined
398
+ ? { nPass: fact('attemptGroup.nPass') }
399
+ : {}),
400
+ ...(fact('attemptGroup.nFail') !== undefined
401
+ ? { nFail: fact('attemptGroup.nFail') }
402
+ : {}),
403
+ },
404
+ }
405
+ : {}),
406
+ ...(task.lineage !== undefined
407
+ ? {
408
+ lineage: {
409
+ episodeId: fact('lineage.episodeId'),
410
+ ...(fact('lineage.mintRef') !== undefined
411
+ ? { mintRef: fact('lineage.mintRef') }
412
+ : {}),
413
+ },
414
+ }
415
+ : {}),
416
+ });
417
+ }
418
+ /**
419
+ * Run the captured task through the scrub pipeline and the fitting rule and
420
+ * return the pending (pre-consent) envelope plus its redaction diff.
421
+ * Fail-closed: throws `CaptureScrubError` on any scrub-stage failure.
422
+ */
423
+ export async function capture(task, opts = {}) {
424
+ const policy = opts.policy ?? DEFAULT_KEY_POLICY;
425
+ const base = opts.pipeline ?? buildScrubPipeline({
426
+ policy,
427
+ ...(opts.piiDetector ? { piiDetector: opts.piiDetector } : {}),
428
+ });
429
+ // The email/home-path gap-closers graduated into the shared pipeline
430
+ // (plain-patterns stage, #1330) — the base pipeline alone now covers the
431
+ // seeded-secrets fixture. Injected test pipelines take the same path.
432
+ const recorder = new RecordingScrubPipeline([base]);
433
+ let scrubbedSteps;
434
+ let summaryRun;
435
+ let factRun;
436
+ let scrubbedFactTask;
437
+ let scrubbedObservations;
438
+ let observationRedactions;
439
+ const summaryBag = {
440
+ 'task.summary': task.task.summary,
441
+ ...(task.outcome.summary !== undefined ? { 'outcome.summary': task.outcome.summary } : {}),
442
+ };
443
+ const factBag = episodeFactBag(task);
444
+ try {
445
+ scrubbedSteps = await scrubCaptureSpans(task.steps, recorder);
446
+ const observationResult = await scrubStepObservations(task.steps, base);
447
+ scrubbedObservations = observationResult.observations;
448
+ observationRedactions = observationResult.redactions;
449
+ // The envelope calls `task.summary` a "scrubbed one-line descriptor" —
450
+ // capture is where that becomes true. Same pipeline, pseudo-attribute bag.
451
+ summaryRun = await recorder.run(summaryBag);
452
+ factRun = await recorder.run(factBag);
453
+ scrubbedFactTask = taskWithScrubbedEpisodeFacts(task, factRun.attributes);
454
+ }
455
+ catch (err) {
456
+ throw new CaptureScrubError(err);
457
+ }
458
+ // Pair each scrubbed step with its pipeline run (one run per span, in
459
+ // order), then head/tail-sample. Redactions are reported only for retained
460
+ // steps: sampled-out steps never publish, so their diff is moot.
461
+ const paired = scrubbedSteps.map((step, i) => ({
462
+ step,
463
+ run: recorder.runs[i],
464
+ kind: scrubbedFactTask.steps[i].kind,
465
+ observation: scrubbedObservations[i],
466
+ }));
467
+ const { steps: retained, sampledOut } = sampleSteps(paired);
468
+ const redactions = [...observationRedactions];
469
+ if (sampledOut > 0) {
470
+ redactions.push({
471
+ field: 'steps',
472
+ stage: 'fit',
473
+ detail: `head/tail-sampled: ${sampledOut} middle step(s) removed to fit ${MAX_STEPS}`,
474
+ after: `${MAX_STEPS} steps`,
475
+ });
476
+ }
477
+ const draftSteps = retained.map(({ step, run }, index) => {
478
+ const fitted = fitAttributes(run.result.attributes);
479
+ for (const record of run.result.redactions) {
480
+ redactions.push({
481
+ field: fieldRef(index, record.key),
482
+ stage: record.stage,
483
+ ...(record.detail ? { detail: record.detail } : {}),
484
+ before: run.before[record.key],
485
+ after: fitted.attributes[record.key],
486
+ });
487
+ }
488
+ for (const cut of fitted.cuts) {
489
+ redactions.push({
490
+ field: fieldRef(index, cut.key),
491
+ stage: 'fit',
492
+ detail: `truncated to fit ${MAX_STEP_ATTRIBUTES_BYTES} bytes`,
493
+ before: cut.before,
494
+ after: cut.after,
495
+ });
496
+ }
497
+ return {
498
+ spanId: step.spanId,
499
+ parentSpanId: step.parentSpanId,
500
+ name: step.name,
501
+ startTimeUnixNano: step.startTimeUnixNano,
502
+ endTimeUnixNano: step.endTimeUnixNano,
503
+ attributes: fitted.attributes,
504
+ redactedKeys: step.redactedKeys,
505
+ ...(fitted.truncatedKeys.length > 0 ? { truncatedKeys: fitted.truncatedKeys } : {}),
506
+ };
507
+ });
508
+ for (const record of summaryRun.redactions) {
509
+ redactions.push({
510
+ field: record.key,
511
+ stage: record.stage,
512
+ ...(record.detail ? { detail: record.detail } : {}),
513
+ before: summaryBag[record.key],
514
+ after: summaryRun.attributes[record.key],
515
+ });
516
+ }
517
+ for (const record of factRun.redactions) {
518
+ redactions.push({
519
+ field: record.key,
520
+ stage: record.stage,
521
+ ...(record.detail ? { detail: record.detail } : {}),
522
+ before: factBag[record.key],
523
+ after: factRun.attributes[record.key],
524
+ });
525
+ }
526
+ // Summaries are content keys the policy never drops; assert the invariant
527
+ // rather than publish an envelope with a silently-missing summary.
528
+ const scrubbedTaskSummary = summaryRun.attributes['task.summary'];
529
+ if (typeof scrubbedTaskSummary !== 'string') {
530
+ throw new CaptureScrubError(new Error('scrub removed task.summary entirely'));
531
+ }
532
+ const scrubbedOutcomeSummary = summaryRun.attributes['outcome.summary'];
533
+ if (task.outcome.summary !== undefined && typeof scrubbedOutcomeSummary !== 'string') {
534
+ throw new CaptureScrubError(new Error('scrub removed outcome.summary entirely'));
535
+ }
536
+ const draft = {
537
+ schemaVersion: TRACE_ENVELOPE_SCHEMA_VERSION,
538
+ session: {
539
+ sessionId: task.session.sessionId,
540
+ capturedAt: task.session.capturedAt,
541
+ },
542
+ task: {
543
+ summary: scrubbedTaskSummary.trim(),
544
+ distributionTags: task.task.distributionTags,
545
+ },
546
+ environment: {
547
+ harness: task.environment.harness,
548
+ model: task.environment.model,
549
+ tools: task.environment.tools,
550
+ },
551
+ steps: draftSteps,
552
+ outcome: {
553
+ ...task.outcome,
554
+ ...(typeof scrubbedOutcomeSummary === 'string' ? { summary: scrubbedOutcomeSummary.trim() } : {}),
555
+ },
556
+ cost: task.cost,
557
+ provenance: task.provenance,
558
+ };
559
+ // Fail-closed schema check: the would-be envelope must already be valid at
560
+ // capture time (fitting worked, caps hold, strings are inside their caps).
561
+ // Throwing here beats surfacing a broken envelope at preview/publish.
562
+ parseTraceEnvelopeV0({
563
+ ...draft,
564
+ consent: { contributionConsent: true, scrubCompleted: true },
565
+ });
566
+ return {
567
+ kind: PENDING_ENVELOPE_KIND,
568
+ draft,
569
+ episodeFacts: {
570
+ session: {
571
+ ...(scrubbedFactTask.session.kind
572
+ ? { kind: scrubbedFactTask.session.kind }
573
+ : {}),
574
+ ...(scrubbedFactTask.session.parentSessionId
575
+ ? { parentSessionId: scrubbedFactTask.session.parentSessionId }
576
+ : {}),
577
+ },
578
+ task: {
579
+ ...(scrubbedFactTask.task.repositorySlug
580
+ ? { repositorySlug: scrubbedFactTask.task.repositorySlug }
581
+ : {}),
582
+ ...(scrubbedFactTask.task.baseCommit
583
+ ? { baseCommit: scrubbedFactTask.task.baseCommit }
584
+ : {}),
585
+ ...(scrubbedFactTask.task.createdAt !== undefined
586
+ ? { createdAt: scrubbedFactTask.task.createdAt }
587
+ : {}),
588
+ ...(scrubbedFactTask.task.instanceId
589
+ ? { instanceId: scrubbedFactTask.task.instanceId }
590
+ : {}),
591
+ },
592
+ environment: {
593
+ ...(scrubbedFactTask.environment.skillsLoadout
594
+ ? { skillsLoadout: scrubbedFactTask.environment.skillsLoadout }
595
+ : {}),
596
+ ...(scrubbedFactTask.environment.generatorModel
597
+ ? { generatorModel: scrubbedFactTask.environment.generatorModel }
598
+ : {}),
599
+ ...(scrubbedFactTask.environment.distributionClass
600
+ ? { distributionClass: scrubbedFactTask.environment.distributionClass }
601
+ : {}),
602
+ ...(scrubbedFactTask.environment.verifier
603
+ ? { verifier: scrubbedFactTask.environment.verifier }
604
+ : {}),
605
+ },
606
+ trajectoryKinds: retained.map(({ kind }) => kind),
607
+ trajectoryObservations: retained.map(({ observation }) => observation),
608
+ ...(scrubbedFactTask.attemptGroup
609
+ ? { attemptGroup: scrubbedFactTask.attemptGroup }
610
+ : {}),
611
+ ...(scrubbedFactTask.lineage
612
+ ? { lineage: scrubbedFactTask.lineage }
613
+ : {}),
614
+ },
615
+ redactions,
616
+ };
617
+ }