@jinn-network/jinn-layer 0.1.0-canary.3afae198

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 +104 -0
  26. package/dist/capture.js +622 -0
  27. package/dist/cli.d.ts +148 -0
  28. package/dist/cli.js +2230 -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 +452 -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 +89 -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 +339 -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 +238 -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,622 @@
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.kind ? { kind: record.kind } : {}),
166
+ ...(record.detail ? { detail: record.detail } : {}),
167
+ before: originalAttributes[record.key],
168
+ after: fitted.attributes[record.key],
169
+ });
170
+ }
171
+ for (const cut of fitted.cuts) {
172
+ redactions.push({
173
+ field: `steps[${stepIndex}].events[${eventIndex}].attributes`
174
+ + `[${JSON.stringify(cut.key)}]`,
175
+ stage: 'fit',
176
+ detail: `truncated to fit ${MAX_STEP_ATTRIBUTES_BYTES} bytes`,
177
+ before: cut.before,
178
+ after: cut.after,
179
+ });
180
+ }
181
+ events.push({
182
+ timeUnixNano: event.timeUnixNano,
183
+ name: event.name,
184
+ ...(event.attributes !== undefined
185
+ ? { attributes: fitted.attributes }
186
+ : {}),
187
+ });
188
+ }
189
+ let status = step.status;
190
+ if (status?.message !== undefined) {
191
+ const before = { 'status.message': status.message };
192
+ const result = await pipeline.run(before);
193
+ const fitted = fitAttributes(result.attributes);
194
+ const after = fitted.attributes['status.message'];
195
+ for (const record of result.redactions) {
196
+ redactions.push({
197
+ field: `steps[${stepIndex}].status.message`,
198
+ stage: record.stage,
199
+ ...(record.kind ? { kind: record.kind } : {}),
200
+ ...(record.detail ? { detail: record.detail } : {}),
201
+ before: status.message,
202
+ after,
203
+ });
204
+ }
205
+ for (const cut of fitted.cuts) {
206
+ redactions.push({
207
+ field: `steps[${stepIndex}].status.message`,
208
+ stage: 'fit',
209
+ detail: `truncated to fit ${MAX_STEP_ATTRIBUTES_BYTES} bytes`,
210
+ before: cut.before,
211
+ after: cut.after,
212
+ });
213
+ }
214
+ status = {
215
+ code: status.code,
216
+ ...(typeof after === 'string' && after.length > 0
217
+ ? { message: after }
218
+ : {}),
219
+ };
220
+ }
221
+ observations.push({
222
+ ...(step.events !== undefined ? { events } : {}),
223
+ ...(status !== undefined ? { status } : {}),
224
+ });
225
+ }
226
+ return { observations, redactions };
227
+ }
228
+ function episodeFactBag(task) {
229
+ return {
230
+ ...(task.session.kind !== undefined
231
+ ? { 'episodeFacts.session.kind': task.session.kind }
232
+ : {}),
233
+ ...(task.session.parentSessionId !== undefined
234
+ ? { 'episodeFacts.session.parentSessionId': task.session.parentSessionId }
235
+ : {}),
236
+ ...(task.task.repositorySlug !== undefined
237
+ ? { 'episodeFacts.task.repositorySlug': task.task.repositorySlug }
238
+ : {}),
239
+ ...(task.task.baseCommit !== undefined
240
+ ? { 'episodeFacts.task.baseCommit': task.task.baseCommit }
241
+ : {}),
242
+ ...(task.task.createdAt !== undefined
243
+ ? { 'episodeFacts.task.createdAt': task.task.createdAt }
244
+ : {}),
245
+ ...(task.task.instanceId !== undefined
246
+ ? { 'episodeFacts.task.instanceId': task.task.instanceId }
247
+ : {}),
248
+ ...(task.environment.skillsLoadout !== undefined
249
+ ? { 'episodeFacts.environment.skillsLoadout': task.environment.skillsLoadout }
250
+ : {}),
251
+ ...(task.environment.generatorModel !== undefined
252
+ ? {
253
+ 'episodeFacts.environment.generatorModel.id': task.environment.generatorModel.id,
254
+ ...(task.environment.generatorModel.provider !== undefined
255
+ ? {
256
+ 'episodeFacts.environment.generatorModel.provider': task.environment.generatorModel.provider,
257
+ }
258
+ : {}),
259
+ ...(task.environment.generatorModel.openWeights !== undefined
260
+ ? {
261
+ 'episodeFacts.environment.generatorModel.openWeights': task.environment.generatorModel.openWeights,
262
+ }
263
+ : {}),
264
+ 'episodeFacts.environment.generatorModel.source': task.environment.generatorModel.source,
265
+ }
266
+ : {}),
267
+ ...(task.environment.distributionClass !== undefined
268
+ ? {
269
+ 'episodeFacts.environment.distributionClass': task.environment.distributionClass,
270
+ }
271
+ : {}),
272
+ ...(task.environment.verifier !== undefined
273
+ ? {
274
+ 'episodeFacts.environment.verifier.type': task.environment.verifier.type,
275
+ 'episodeFacts.environment.verifier.failToPass': task.environment.verifier.failToPass,
276
+ 'episodeFacts.environment.verifier.passToPass': task.environment.verifier.passToPass,
277
+ ...(task.environment.verifier.evalSemanticsVersion !== undefined
278
+ ? {
279
+ 'episodeFacts.environment.verifier.evalSemanticsVersion': task.environment.verifier.evalSemanticsVersion,
280
+ }
281
+ : {}),
282
+ }
283
+ : {}),
284
+ 'episodeFacts.trajectoryKinds': task.steps.map((step) => step.kind),
285
+ ...(task.attemptGroup !== undefined
286
+ ? {
287
+ 'episodeFacts.attemptGroup.groupId': task.attemptGroup.groupId,
288
+ 'episodeFacts.attemptGroup.attemptId': task.attemptGroup.attemptId,
289
+ 'episodeFacts.attemptGroup.relatedAttemptRefs': task.attemptGroup.relatedAttemptRefs,
290
+ ...(task.attemptGroup.groupSize !== undefined
291
+ ? { 'episodeFacts.attemptGroup.groupSize': task.attemptGroup.groupSize }
292
+ : {}),
293
+ ...(task.attemptGroup.nPass !== undefined
294
+ ? { 'episodeFacts.attemptGroup.nPass': task.attemptGroup.nPass }
295
+ : {}),
296
+ ...(task.attemptGroup.nFail !== undefined
297
+ ? { 'episodeFacts.attemptGroup.nFail': task.attemptGroup.nFail }
298
+ : {}),
299
+ }
300
+ : {}),
301
+ ...(task.lineage !== undefined
302
+ ? {
303
+ 'episodeFacts.lineage.episodeId': task.lineage.episodeId,
304
+ ...(task.lineage.mintRef !== undefined
305
+ ? { 'episodeFacts.lineage.mintRef': task.lineage.mintRef }
306
+ : {}),
307
+ }
308
+ : {}),
309
+ };
310
+ }
311
+ /**
312
+ * Rebuild a CapturedTask using only scrubbed Episode-only values. Parsing the
313
+ * result keeps this boundary fail-closed if a scrub stage changes a structural
314
+ * enum/type or removes a required member of a present nested block.
315
+ */
316
+ function taskWithScrubbedEpisodeFacts(task, attributes) {
317
+ const fact = (key) => attributes[`episodeFacts.${key}`];
318
+ const kinds = fact('trajectoryKinds');
319
+ return parseCapturedTask({
320
+ ...task,
321
+ session: {
322
+ sessionId: task.session.sessionId,
323
+ capturedAt: task.session.capturedAt,
324
+ ...(fact('session.kind') !== undefined ? { kind: fact('session.kind') } : {}),
325
+ ...(fact('session.parentSessionId') !== undefined
326
+ ? { parentSessionId: fact('session.parentSessionId') }
327
+ : {}),
328
+ },
329
+ task: {
330
+ summary: task.task.summary,
331
+ distributionTags: task.task.distributionTags,
332
+ ...(fact('task.repositorySlug') !== undefined
333
+ ? { repositorySlug: fact('task.repositorySlug') }
334
+ : {}),
335
+ ...(fact('task.baseCommit') !== undefined
336
+ ? { baseCommit: fact('task.baseCommit') }
337
+ : {}),
338
+ ...(fact('task.createdAt') !== undefined
339
+ ? { createdAt: fact('task.createdAt') }
340
+ : {}),
341
+ ...(fact('task.instanceId') !== undefined
342
+ ? { instanceId: fact('task.instanceId') }
343
+ : {}),
344
+ },
345
+ environment: {
346
+ harness: task.environment.harness,
347
+ model: task.environment.model,
348
+ tools: task.environment.tools,
349
+ ...(fact('environment.skillsLoadout') !== undefined
350
+ ? { skillsLoadout: fact('environment.skillsLoadout') }
351
+ : {}),
352
+ ...(task.environment.generatorModel !== undefined
353
+ ? {
354
+ generatorModel: {
355
+ id: fact('environment.generatorModel.id'),
356
+ ...(fact('environment.generatorModel.provider') !== undefined
357
+ ? { provider: fact('environment.generatorModel.provider') }
358
+ : {}),
359
+ ...(fact('environment.generatorModel.openWeights') !== undefined
360
+ ? { openWeights: fact('environment.generatorModel.openWeights') }
361
+ : {}),
362
+ source: fact('environment.generatorModel.source'),
363
+ },
364
+ }
365
+ : {}),
366
+ ...(fact('environment.distributionClass') !== undefined
367
+ ? { distributionClass: fact('environment.distributionClass') }
368
+ : {}),
369
+ ...(task.environment.verifier !== undefined
370
+ ? {
371
+ verifier: {
372
+ type: fact('environment.verifier.type'),
373
+ failToPass: fact('environment.verifier.failToPass'),
374
+ passToPass: fact('environment.verifier.passToPass'),
375
+ ...(fact('environment.verifier.evalSemanticsVersion') !== undefined
376
+ ? {
377
+ evalSemanticsVersion: fact('environment.verifier.evalSemanticsVersion'),
378
+ }
379
+ : {}),
380
+ },
381
+ }
382
+ : {}),
383
+ },
384
+ steps: task.steps.map((step, index) => ({
385
+ ...step,
386
+ ...(Array.isArray(kinds) && kinds[index] !== undefined
387
+ ? { kind: kinds[index] }
388
+ : { kind: undefined }),
389
+ })),
390
+ ...(task.attemptGroup !== undefined
391
+ ? {
392
+ attemptGroup: {
393
+ groupId: fact('attemptGroup.groupId'),
394
+ attemptId: fact('attemptGroup.attemptId'),
395
+ relatedAttemptRefs: fact('attemptGroup.relatedAttemptRefs'),
396
+ ...(fact('attemptGroup.groupSize') !== undefined
397
+ ? { groupSize: fact('attemptGroup.groupSize') }
398
+ : {}),
399
+ ...(fact('attemptGroup.nPass') !== undefined
400
+ ? { nPass: fact('attemptGroup.nPass') }
401
+ : {}),
402
+ ...(fact('attemptGroup.nFail') !== undefined
403
+ ? { nFail: fact('attemptGroup.nFail') }
404
+ : {}),
405
+ },
406
+ }
407
+ : {}),
408
+ ...(task.lineage !== undefined
409
+ ? {
410
+ lineage: {
411
+ episodeId: fact('lineage.episodeId'),
412
+ ...(fact('lineage.mintRef') !== undefined
413
+ ? { mintRef: fact('lineage.mintRef') }
414
+ : {}),
415
+ },
416
+ }
417
+ : {}),
418
+ });
419
+ }
420
+ /**
421
+ * Run the captured task through the scrub pipeline and the fitting rule and
422
+ * return the pending (pre-consent) envelope plus its redaction diff.
423
+ * Fail-closed: throws `CaptureScrubError` on any scrub-stage failure.
424
+ */
425
+ export async function capture(task, opts = {}) {
426
+ const policy = opts.policy ?? DEFAULT_KEY_POLICY;
427
+ const base = opts.pipeline ?? buildScrubPipeline({
428
+ policy,
429
+ ...(opts.piiDetector ? { piiDetector: opts.piiDetector } : {}),
430
+ });
431
+ // The email/home-path gap-closers graduated into the shared pipeline
432
+ // (plain-patterns stage, #1330) — the base pipeline alone now covers the
433
+ // seeded-secrets fixture. Injected test pipelines take the same path.
434
+ const recorder = new RecordingScrubPipeline([base]);
435
+ let scrubbedSteps;
436
+ let summaryRun;
437
+ let factRun;
438
+ let scrubbedFactTask;
439
+ let scrubbedObservations;
440
+ let observationRedactions;
441
+ const summaryBag = {
442
+ 'task.summary': task.task.summary,
443
+ ...(task.outcome.summary !== undefined ? { 'outcome.summary': task.outcome.summary } : {}),
444
+ };
445
+ const factBag = episodeFactBag(task);
446
+ try {
447
+ scrubbedSteps = await scrubCaptureSpans(task.steps, recorder);
448
+ const observationResult = await scrubStepObservations(task.steps, base);
449
+ scrubbedObservations = observationResult.observations;
450
+ observationRedactions = observationResult.redactions;
451
+ // The envelope calls `task.summary` a "scrubbed one-line descriptor" —
452
+ // capture is where that becomes true. Same pipeline, pseudo-attribute bag.
453
+ summaryRun = await recorder.run(summaryBag);
454
+ factRun = await recorder.run(factBag);
455
+ scrubbedFactTask = taskWithScrubbedEpisodeFacts(task, factRun.attributes);
456
+ }
457
+ catch (err) {
458
+ throw new CaptureScrubError(err);
459
+ }
460
+ // Pair each scrubbed step with its pipeline run (one run per span, in
461
+ // order), then head/tail-sample. Redactions are reported only for retained
462
+ // steps: sampled-out steps never publish, so their diff is moot.
463
+ const paired = scrubbedSteps.map((step, i) => ({
464
+ step,
465
+ run: recorder.runs[i],
466
+ kind: scrubbedFactTask.steps[i].kind,
467
+ observation: scrubbedObservations[i],
468
+ }));
469
+ const { steps: retained, sampledOut } = sampleSteps(paired);
470
+ const redactions = [...observationRedactions];
471
+ if (sampledOut > 0) {
472
+ redactions.push({
473
+ field: 'steps',
474
+ stage: 'fit',
475
+ detail: `head/tail-sampled: ${sampledOut} middle step(s) removed to fit ${MAX_STEPS}`,
476
+ after: `${MAX_STEPS} steps`,
477
+ });
478
+ }
479
+ const draftSteps = retained.map(({ step, run }, index) => {
480
+ const fitted = fitAttributes(run.result.attributes);
481
+ for (const record of run.result.redactions) {
482
+ redactions.push({
483
+ field: fieldRef(index, record.key),
484
+ stage: record.stage,
485
+ ...(record.kind ? { kind: record.kind } : {}),
486
+ ...(record.detail ? { detail: record.detail } : {}),
487
+ before: run.before[record.key],
488
+ after: fitted.attributes[record.key],
489
+ });
490
+ }
491
+ for (const cut of fitted.cuts) {
492
+ redactions.push({
493
+ field: fieldRef(index, cut.key),
494
+ stage: 'fit',
495
+ detail: `truncated to fit ${MAX_STEP_ATTRIBUTES_BYTES} bytes`,
496
+ before: cut.before,
497
+ after: cut.after,
498
+ });
499
+ }
500
+ return {
501
+ spanId: step.spanId,
502
+ parentSpanId: step.parentSpanId,
503
+ name: step.name,
504
+ startTimeUnixNano: step.startTimeUnixNano,
505
+ endTimeUnixNano: step.endTimeUnixNano,
506
+ attributes: fitted.attributes,
507
+ redactedKeys: step.redactedKeys,
508
+ ...(fitted.truncatedKeys.length > 0 ? { truncatedKeys: fitted.truncatedKeys } : {}),
509
+ };
510
+ });
511
+ for (const record of summaryRun.redactions) {
512
+ redactions.push({
513
+ field: record.key,
514
+ stage: record.stage,
515
+ ...(record.kind ? { kind: record.kind } : {}),
516
+ ...(record.detail ? { detail: record.detail } : {}),
517
+ before: summaryBag[record.key],
518
+ after: summaryRun.attributes[record.key],
519
+ });
520
+ }
521
+ for (const record of factRun.redactions) {
522
+ redactions.push({
523
+ field: record.key,
524
+ stage: record.stage,
525
+ ...(record.kind ? { kind: record.kind } : {}),
526
+ ...(record.detail ? { detail: record.detail } : {}),
527
+ before: factBag[record.key],
528
+ after: factRun.attributes[record.key],
529
+ });
530
+ }
531
+ // Summaries are content keys the policy never drops; assert the invariant
532
+ // rather than publish an envelope with a silently-missing summary.
533
+ const scrubbedTaskSummary = summaryRun.attributes['task.summary'];
534
+ if (typeof scrubbedTaskSummary !== 'string') {
535
+ throw new CaptureScrubError(new Error('scrub removed task.summary entirely'));
536
+ }
537
+ const scrubbedOutcomeSummary = summaryRun.attributes['outcome.summary'];
538
+ if (task.outcome.summary !== undefined && typeof scrubbedOutcomeSummary !== 'string') {
539
+ throw new CaptureScrubError(new Error('scrub removed outcome.summary entirely'));
540
+ }
541
+ const draft = {
542
+ schemaVersion: TRACE_ENVELOPE_SCHEMA_VERSION,
543
+ session: {
544
+ sessionId: task.session.sessionId,
545
+ capturedAt: task.session.capturedAt,
546
+ },
547
+ task: {
548
+ summary: scrubbedTaskSummary.trim(),
549
+ distributionTags: task.task.distributionTags,
550
+ },
551
+ environment: {
552
+ harness: task.environment.harness,
553
+ model: task.environment.model,
554
+ tools: task.environment.tools,
555
+ },
556
+ steps: draftSteps,
557
+ outcome: {
558
+ ...task.outcome,
559
+ ...(typeof scrubbedOutcomeSummary === 'string' ? { summary: scrubbedOutcomeSummary.trim() } : {}),
560
+ },
561
+ cost: task.cost,
562
+ provenance: task.provenance,
563
+ };
564
+ // Fail-closed schema check: the would-be envelope must already be valid at
565
+ // capture time (fitting worked, caps hold, strings are inside their caps).
566
+ // Throwing here beats surfacing a broken envelope at preview/publish.
567
+ parseTraceEnvelopeV0({
568
+ ...draft,
569
+ consent: { contributionConsent: true, scrubCompleted: true },
570
+ });
571
+ return {
572
+ kind: PENDING_ENVELOPE_KIND,
573
+ draft,
574
+ episodeFacts: {
575
+ session: {
576
+ ...(scrubbedFactTask.session.kind
577
+ ? { kind: scrubbedFactTask.session.kind }
578
+ : {}),
579
+ ...(scrubbedFactTask.session.parentSessionId
580
+ ? { parentSessionId: scrubbedFactTask.session.parentSessionId }
581
+ : {}),
582
+ },
583
+ task: {
584
+ ...(scrubbedFactTask.task.repositorySlug
585
+ ? { repositorySlug: scrubbedFactTask.task.repositorySlug }
586
+ : {}),
587
+ ...(scrubbedFactTask.task.baseCommit
588
+ ? { baseCommit: scrubbedFactTask.task.baseCommit }
589
+ : {}),
590
+ ...(scrubbedFactTask.task.createdAt !== undefined
591
+ ? { createdAt: scrubbedFactTask.task.createdAt }
592
+ : {}),
593
+ ...(scrubbedFactTask.task.instanceId
594
+ ? { instanceId: scrubbedFactTask.task.instanceId }
595
+ : {}),
596
+ },
597
+ environment: {
598
+ ...(scrubbedFactTask.environment.skillsLoadout
599
+ ? { skillsLoadout: scrubbedFactTask.environment.skillsLoadout }
600
+ : {}),
601
+ ...(scrubbedFactTask.environment.generatorModel
602
+ ? { generatorModel: scrubbedFactTask.environment.generatorModel }
603
+ : {}),
604
+ ...(scrubbedFactTask.environment.distributionClass
605
+ ? { distributionClass: scrubbedFactTask.environment.distributionClass }
606
+ : {}),
607
+ ...(scrubbedFactTask.environment.verifier
608
+ ? { verifier: scrubbedFactTask.environment.verifier }
609
+ : {}),
610
+ },
611
+ trajectoryKinds: retained.map(({ kind }) => kind),
612
+ trajectoryObservations: retained.map(({ observation }) => observation),
613
+ ...(scrubbedFactTask.attemptGroup
614
+ ? { attemptGroup: scrubbedFactTask.attemptGroup }
615
+ : {}),
616
+ ...(scrubbedFactTask.lineage
617
+ ? { lineage: scrubbedFactTask.lineage }
618
+ : {}),
619
+ },
620
+ redactions,
621
+ };
622
+ }