engineering-behavior-observatory 0.2.5 → 0.2.6

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.
@@ -116,8 +116,8 @@ export async function readQualifiedRunCapture(bundleRoot) {
116
116
  const manifest = readManifest(root);
117
117
  await assertPersistedStructuralQualification(root, manifest);
118
118
  const qualification = await qualifyRunBundle(root);
119
- if (!qualification.semanticAnalysisUsable
120
- || !["qualified", "qualified-with-gaps"].includes(qualification.status)) {
119
+ if (!qualification.semanticAnalysisUsable || Object.entries(qualification.dimensions)
120
+ .some(([dimension, result]) => dimension !== "workspace" && result.status === "unqualified")) {
121
121
  const reasons = qualification.reasons.map(({ code }) => code).join(", ") || "unknown qualification failure";
122
122
  throw new Error(`Agent SDK normalization requires capture-qualified evidence: ${reasons}.`);
123
123
  }
@@ -677,11 +677,14 @@ function assertQualifiedInput(input) {
677
677
  throw new Error("Agent SDK normalization input identity is invalid.");
678
678
  }
679
679
  const kinds = new Set(input.records.map(({ record }) => record.kind));
680
- for (const required of ["session", "hook", "workspace", "manifest"]) {
680
+ for (const required of ["session", "hook", "manifest"]) {
681
681
  if (!kinds.has(required))
682
682
  throw new Error(`Capture-qualified Agent SDK input is missing required ${required} evidence.`);
683
683
  }
684
684
  const assessmentMode = input.records.map(assessmentModeOf).find((value) => value !== undefined);
685
+ if (assessmentMode !== "observational" && !kinds.has("workspace")) {
686
+ throw new Error("Capture-qualified verified input is missing required workspace evidence.");
687
+ }
685
688
  if (assessmentMode !== "observational" && !input.records.some(isVerifierRecord)) {
686
689
  throw new Error("Capture-qualified verified input is missing required verifier evidence.");
687
690
  }
@@ -720,7 +723,8 @@ async function assertPersistedStructuralQualification(root, manifest) {
720
723
  return;
721
724
  const report = asRecord(parseJson(await readVerifiedArtifact(root, descriptor.relativePath, digestFrom(descriptor.digest), MAX_EVIDENCE_BYTES)));
722
725
  const structural = asRecord(report?.structuralQualification);
723
- if (structural?.status === "unqualified" || structural?.semanticAnalysisUsable === false) {
726
+ if (structural?.semanticAnalysisUsable === false
727
+ || structural?.status === "unqualified" && structural?.semanticAnalysisUsable !== true) {
724
728
  throw new Error("Agent SDK normalization rejects the persisted unqualified structural capture report.");
725
729
  }
726
730
  }
@@ -102,8 +102,15 @@ export async function captureClaudeAgentSdkRun(options) {
102
102
  return workspace;
103
103
  },
104
104
  cleanup: async (context) => {
105
- if (workspace?.status === "ready")
106
- await captureWorkspace();
105
+ // Retain the source on packaging failure; the assembler records the outcome gap.
106
+ if (workspace?.status === "ready") {
107
+ try {
108
+ await captureWorkspace();
109
+ }
110
+ catch {
111
+ return;
112
+ }
113
+ }
107
114
  await options.workspace.cleanup?.(context);
108
115
  },
109
116
  };
@@ -89,8 +89,15 @@ export async function captureCodexAppServerRun(options) {
89
89
  return workspace;
90
90
  },
91
91
  cleanup: async (context) => {
92
- if (workspace?.status === "ready")
93
- await captureWorkspace();
92
+ // Packaging gaps must not rewrite native completion or delete recoverable work.
93
+ if (workspace?.status === "ready") {
94
+ try {
95
+ await captureWorkspace();
96
+ }
97
+ catch {
98
+ return;
99
+ }
100
+ }
94
101
  await options.workspace.cleanup?.(context);
95
102
  },
96
103
  };
@@ -119,8 +119,15 @@ export async function captureCursorSdkRun(options) {
119
119
  return workspace;
120
120
  },
121
121
  cleanup: async (context) => {
122
- if (workspace?.status === "ready")
123
- await captureWorkspace();
122
+ // Retain the source on packaging failure; the assembler records the outcome gap.
123
+ if (workspace?.status === "ready") {
124
+ try {
125
+ await captureWorkspace();
126
+ }
127
+ catch {
128
+ return;
129
+ }
130
+ }
124
131
  await options.workspace.cleanup?.(context);
125
132
  },
126
133
  };
@@ -130,7 +130,7 @@ export async function captureOpenHandsAgentServerRun(options) {
130
130
  catch (error) {
131
131
  if (workspaceOutcome === undefined) {
132
132
  workspaceCaptureError = error instanceof Error ? error.message : String(error);
133
- throw error;
133
+ return; // Preserve native completion and the source for later capture recovery.
134
134
  }
135
135
  }
136
136
  await options.workspace.cleanup?.(context);
@@ -318,7 +318,8 @@ export async function captureOpenHandsAgentServerRun(options) {
318
318
  const retainedWorkspacePath = workspace?.status === "ready" && workspaceOutcome === undefined
319
319
  ? workspace.path
320
320
  : undefined;
321
- if (!["qualified", "qualified-with-gaps"].includes(qualification.status)) {
321
+ if (!qualification.semanticAnalysisUsable || Object.entries(qualification.dimensions)
322
+ .some(([dimension, result]) => dimension !== "workspace" && result.status === "unqualified")) {
322
323
  return {
323
324
  attempt,
324
325
  manifest,
@@ -332,7 +333,8 @@ export async function captureOpenHandsAgentServerRun(options) {
332
333
  await validateUniformEvents(normalized.events, {
333
334
  resolve: (reference) => resolvesCaptureReference(capture, reference),
334
335
  });
335
- return { attempt, manifest, qualification, capture, normalized };
336
+ return { attempt, manifest, qualification, capture, normalized,
337
+ ...(retainedWorkspacePath === undefined ? {} : { retainedWorkspacePath }) };
336
338
  }
337
339
  catch (error) {
338
340
  return {
package/dist/src/pi.js CHANGED
@@ -150,8 +150,15 @@ export async function capturePiSdkRun(options) {
150
150
  return workspace;
151
151
  },
152
152
  cleanup: async (context) => {
153
- if (workspace?.status === "ready")
154
- await captureWorkspace();
153
+ // Retain the source on packaging failure; the assembler records the outcome gap.
154
+ if (workspace?.status === "ready") {
155
+ try {
156
+ await captureWorkspace();
157
+ }
158
+ catch {
159
+ return;
160
+ }
161
+ }
155
162
  await options.workspace.cleanup?.(context);
156
163
  },
157
164
  };
@@ -1,9 +1,9 @@
1
1
  import { execFile, spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { createReadStream, createWriteStream } from "node:fs";
4
- import { cp, lstat, mkdir, mkdtemp, readdir, realpath, rm, rmdir, utimes, writeFile } from "node:fs/promises";
4
+ import { cp, lstat, mkdir, mkdtemp, readdir, readlink, realpath, rm, rmdir, symlink, utimes, writeFile } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
- import { join, resolve, sep } from "node:path";
6
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import { promisify } from "node:util";
8
8
  import { Transform } from "node:stream";
9
9
  import { pipeline } from "node:stream/promises";
@@ -259,7 +259,33 @@ async function withWorkspaceOutcomeProjection(options, use) {
259
259
  throw new Error(`Workspace outcome exclusion "${name}" is invalid.`);
260
260
  }
261
261
  }
262
- if (exclusions.length === 0 && options.respectGitignore !== true && options.omitEmptyDirectories !== true) {
262
+ const absoluteLinks = [];
263
+ const sourceRoot = await realpath(finalPath);
264
+ const contained = (path) => {
265
+ const rel = relative(sourceRoot, path);
266
+ return !isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`);
267
+ };
268
+ const collectLinks = async (directory) => {
269
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
270
+ const path = join(directory, entry.name);
271
+ if (entry.isDirectory()) {
272
+ if (!exclusions.includes(entry.name))
273
+ await collectLinks(path);
274
+ }
275
+ else if (entry.isSymbolicLink()) {
276
+ const target = await readlink(path);
277
+ if (!isAbsolute(target))
278
+ continue;
279
+ const resolvedTarget = await realpath(path);
280
+ if (!contained(resolvedTarget)) {
281
+ throw new Error(`Workspace symbolic link escapes its root at "${relative(sourceRoot, path)}".`);
282
+ }
283
+ absoluteLinks.push({ path: relative(sourceRoot, path), target: relative(dirname(path), resolvedTarget) || "." });
284
+ }
285
+ }
286
+ };
287
+ await collectLinks(sourceRoot);
288
+ if (absoluteLinks.length === 0 && exclusions.length === 0 && options.respectGitignore !== true && options.omitEmptyDirectories !== true) {
263
289
  return use(startPath, finalPath);
264
290
  }
265
291
  await assertNoWorkspaceHardLinks(finalPath, new Set(exclusions));
@@ -274,6 +300,12 @@ async function withWorkspaceOutcomeProjection(options, use) {
274
300
  filter: async (source) => source === finalPath
275
301
  || !await isExcludedWorkspaceDirectory(finalPath, source, exclusions),
276
302
  });
303
+ // Only the derived capture is relocated; native workspace link text is untouched.
304
+ for (const link of absoluteLinks) {
305
+ const path = join(projectedPath, link.path);
306
+ await rm(path);
307
+ await symlink(link.target, path);
308
+ }
277
309
  if (options.respectGitignore === true)
278
310
  await removeIgnoredWorkspaceEntries(startPath, projectedPath);
279
311
  if (options.omitEmptyDirectories === true)
@@ -91,7 +91,7 @@ async function hashWorkspaceDirectory(directory, relativeDirectory, hash, includ
91
91
  const rel = relative(root, candidate);
92
92
  return !isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`);
93
93
  };
94
- if (isAbsolute(target) || !contained(resolve(dirname(path), target))) {
94
+ if (!isAbsolute(target) && !contained(resolve(dirname(path), target))) {
95
95
  throw new Error(`Workspace symbolic link escapes its root at "${relativePath}".`);
96
96
  }
97
97
  let resolved;
@@ -41,6 +41,20 @@ events can still explain a stop. Keep terminal state, infrastructure failure,
41
41
  and capture quality separate. Preserve `retainedWorkspacePath` when outcome
42
42
  packaging failed; that path is a recovery location, not qualified evidence.
43
43
 
44
+ A workspace packaging error does not change a completed observational execution
45
+ into a model or infrastructure failure. EBO omits the unpublishable workspace
46
+ artifact, retains the source for recovery, and records the outcome gap. Native
47
+ events, hooks, and telemetry remain available. Normalization and behavior analysis
48
+ can use qualified semantic evidence even when the workspace dimension is
49
+ unqualified; claims about missing work-product evidence must abstain. Verified
50
+ tasks still require their verifier and workspace binding.
51
+
52
+ Overall bundle qualification can remain `unqualified` because an outcome is
53
+ missing. Consumers should inspect `semanticAnalysisUsable` and the individual
54
+ qualification dimensions instead of treating that aggregate as a pipeline-wide
55
+ fatal error. Export independently enforces its sanitization and sharing policy;
56
+ successful capture is not permission to share.
57
+
44
58
  Capture reports retain packaging exceptions as `workspace-capture-error`
45
59
  missing-evidence entries. Inspect the detail before retrying capture against a
46
60
  retained workspace. A later successful capture is recovery evidence; it does
@@ -57,10 +71,13 @@ unless a fingerprint was recorded then. Do not count the recovery as another
57
71
  model trial or as an independent sample.
58
72
 
59
73
  Workspace outcomes preserve relative symbolic links whose lexical targets and
60
- resolved referents stay inside the workspace. Link text is hashed and copied
61
- verbatim, not replaced by the referent's bytes. Empty-directory omission keeps
62
- directories referenced by links. Absolute, escaping, dangling and
63
- cyclic links fail capture. Patch application or snapshot extraction must still
74
+ resolved referents stay inside the workspace. Contained absolute links are valid:
75
+ the derived capture rewrites them as relative links to their resolved in-workspace
76
+ targets, so the result survives relocation. The source workspace is not changed.
77
+ Relative link text is preserved, not replaced by the referent's bytes.
78
+ Empty-directory omission keeps directories referenced by links. External,
79
+ dangling and cyclic links prevent workspace artifact packaging, not native event
80
+ capture. Patch application or snapshot extraction must still
64
81
  reproduce the resulting tree digest. This does not relax task-archive admission
65
82
  or verifier sandbox rules.
66
83
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engineering-behavior-observatory",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Capture engineering-agent trajectories, evaluate behavior, and inspect cited evidence across harnesses.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -49,6 +49,7 @@
49
49
  "release/0.2.3/",
50
50
  "release/0.2.4/",
51
51
  "release/0.2.5/",
52
+ "release/0.2.6/",
52
53
  "schemas/",
53
54
  "scripts/atlas-grafana.sh"
54
55
  ],
@@ -0,0 +1,15 @@
1
+ # v0.2.6 known limitations
2
+
3
+ The [v0.2.5 limitations](../0.2.5/KNOWN_LIMITATIONS.md) continue to apply, except
4
+ that contained absolute workspace links are now supported.
5
+
6
+ - Link relocation describes a derived workspace artifact, not byte-identical
7
+ original link text. Source directories are not modified.
8
+ - Missing workspace evidence remains unqualified for work-product claims.
9
+ Semantic analysis may proceed only when its own evidence qualifies.
10
+ - A verified task whose verifier or workspace binding fails is not reported as
11
+ successful. Genuine harness, setup, persistence, and cleanup errors remain
12
+ distinct from workspace packaging gaps.
13
+ - Native telemetry receipt limits and provider evidence gaps are unchanged.
14
+ - No models are rerun to recover capture. A later recovered workspace cannot
15
+ prove its exact earlier state without a fingerprint recorded at that time.
@@ -0,0 +1,26 @@
1
+ # v0.2.6: Contain workspace capture failures
2
+
3
+ Workspace outcome capture accepts absolute symbolic links that resolve inside
4
+ the source workspace. The derived patch or snapshot uses portable relative
5
+ links; the source workspace is unchanged. External, dangling, and cyclic links
6
+ remain rejected for workspace packaging.
7
+
8
+ A workspace packaging failure no longer rewrites native completion as an
9
+ infrastructure failure. Capture records the missing outcome and retains the
10
+ source for recovery. Native semantic evidence can be normalized and analyzed
11
+ independently of workspace qualification. Verified task outcomes still require
12
+ a verifier bound to the captured workspace. Export safety checks are unchanged.
13
+
14
+ The run-manifest v1 schema now permits completed observational runs without a
15
+ workspace artifact. Consumers must use qualification dimensions when deciding
16
+ which evidence supports their analysis, rather than infer completeness from
17
+ terminal state.
18
+
19
+ Regression coverage includes absolute-link patch/snapshot relocation, preserved
20
+ source links, invalid-target rejection, cleanup containment, and normalization
21
+ with missing workspace evidence. A real retained xhigh trajectory generated 21
22
+ structural observations without its workspace artifact and without model calls.
23
+
24
+ See [known limitations](KNOWN_LIMITATIONS.md), the
25
+ [reproducibility manifest](reproducibility.json), and the
26
+ [evidence guide](../../docs/guides/evidence-and-sharing.md).
@@ -0,0 +1,74 @@
1
+ {
2
+ "schemaVersion": "ebo.release-reproducibility/v1",
3
+ "release": {
4
+ "name": "engineering-behavior-observatory",
5
+ "version": "0.2.6"
6
+ },
7
+ "runtime": {
8
+ "node": "24.19.0",
9
+ "claudeAgentSdk": "0.3.258",
10
+ "openhandsAgentServer": "1.46.0",
11
+ "deepseekClient": "0.1.1-rc.2",
12
+ "deepseekProtocol": "0.1.1-rc.2",
13
+ "deepseekRuntime": "0.1.1-rc.2",
14
+ "codexAppServer": "0.153.4",
15
+ "grafana": "13.2.0",
16
+ "grafanaInfinity": "4.0.0",
17
+ "piSdk": "0.85.1",
18
+ "cursorSdk": "1.0.31"
19
+ },
20
+ "commands": [
21
+ "npm ci",
22
+ "npm run acceptance"
23
+ ],
24
+ "fixtureCoverage": {
25
+ "agent-sdk": "frozen queue entry through qualified capture, approved export, normalization, configurable judging, review, aggregation, and Atlas",
26
+ "openhands-agent-server": "pinned REST/WebSocket stream-final reconciliation, workspace/outcome evidence, partial capture, and retained evaluation",
27
+ "deepseek-harness": "official-client JSON-RPC composition, receipt-to-idle completion, stderr, interruption, shutdown, swaps, and retained evaluation",
28
+ "codex-app-server": "owned stdio lifecycle, full history, OTLP, interruption/failure, export, and retained evaluation; configurable workspace-write networking with explicit offline queue propagation",
29
+ "acceptance-cases": "seeded ordering, secret scanning, partial attempts, native references, abstention, disputes, denominators, and unsupported comparisons",
30
+ "pi-sdk": "frozen queue, passive hooks, durable session, partial cleanup, export, retained normalization and evaluation",
31
+ "cursor-sdk": "frozen queue, official store, callbacks/history, bounded cancellation, export, retained normalization and evaluation",
32
+ "workspace-capture": "Contained absolute links relocated in derived captures; workspace packaging gaps preserve native completion and allow semantic analysis independently"
33
+ },
34
+ "determinism": {
35
+ "stable": [
36
+ "task, run, attempt, event, assertion, aggregate, and Atlas case identities",
37
+ "fixture, native-reference, normalized-dataset, source/cohort, archive, and package digests",
38
+ "seeded queue and review-sample ordering"
39
+ ],
40
+ "excluded": [
41
+ "native and EBO observation timestamps",
42
+ "lifecycle start/finish timestamps",
43
+ "provider timing and usage",
44
+ "temporary workspace and output paths"
45
+ ]
46
+ },
47
+ "fixtures": {
48
+ "tests/fixtures/task-packet.valid.v1.json": "ac2ef1043c0cc16bf4c21d05c1dc880ca1b4b8cc2f9ea7653328e91f0b0e2681",
49
+ "test/fixtures/agent-sdk-normalizer/complete.input.json": "7285dee088517e5de38ceda84fc7c58d9303949c7448ab3b5e10a5a2d2ebdfe3",
50
+ "test/fixtures/agent-sdk-normalizer/complete.expected.jsonl": "d32fa3a013bc179f1673f7e1a89b8a721bb8ddaf63eba5bc8a3f14f1cc148ed8",
51
+ "contracts/openhands-agent-server-v1.44.1.json": "e7a07977688a0703b15751a8a1ab17f29be45f9fdf438017e0dbcbc49cca37b0",
52
+ "contracts/openhands-agent-server-v1.46.0.json": "455cdddd2c206cb2f20245c2189b92e773657ec14d651236baa532b2dbadeb28",
53
+ "test/fixtures/openhands/v1.44.1/streamed-events.json": "d751083ba2ef94c9865117db481cc2f4933772bbbbb6b9fcf60571563715c1ea",
54
+ "test/fixtures/openhands/v1.44.1/final-events.json": "551db9904e860004d604c923692b5c5d3aaee966220492214c60003d2df415b6",
55
+ "test/fixtures/deepseek/golden-success.json": "f3867d38169bf0e28f64aba1dd60553f21cf07f41f81b4bad48261e49fa3ca88",
56
+ "test/fixtures/deepseek/golden-interrupted.json": "79e1f8404df1ee3b562d0bdbc0b2ee712ffc6d5774992b92970814ea815677b7",
57
+ "test/fixtures/deepseek/compositions/minimal/composition.json": "72fe4368e105f1bad82b7fdf8380e92c9fa689d8985f6ab91f534f0baf3eda02",
58
+ "contracts/codex-app-server-0.153.4/manifest.json": "e62281ff5e5d1cc71d07763b85e997ccc9ddbad9aa82a469da4181ee4b1890a8",
59
+ "test/fixtures/codex/legacy-0.150.1.dataset.json": "f71906b009e546afbf3b4e79396f223181382f3b3d8795e9cfcd032398a9d6bf",
60
+ "test/fixtures/behavior-assertions/abstained.json": "7f1962864f664c794925a67d93074e7b150af0f57c58041a81e062990d641197",
61
+ "test/fixtures/behavior-assertions/disputed.review.json": "450d3d9efe7feefaf7500b3ad20ab2803b412c7e53b91763125e15a83be87981",
62
+ "test/fixtures/comparison/exact.json": "cafa68e94b8d50dc308ddcfc2e73aa139c828b4ecd1e1c762bbc51bf1b44501e",
63
+ "test/fixtures/structural-observations/golden.json": "bad71a3a93524b76c0a2fdbb58e98e1e65be46cae14acf18ac89d9ba76e03e8a",
64
+ "test/atlas-fixture.ts": "9f03ce9cbcd0e5d3e3a5916866554ceb1c21f1c8712e58529499c1ea499f048d",
65
+ "test/pi.test.ts": "a00997c5617ca178e19b25b0d483cbdce8ce7c306d4c871557945b31a2e657cf",
66
+ "test/cursor-sdk.test.ts": "753094f127b11dc392ec2244c9edddabec27aa94347bb6159a292129ed2a2822",
67
+ "examples/cursor-sdk/README.md": "a2cf481123e7f3deb1c5a682ba208a775c3f91c35e1a6c1a428c517c79692618",
68
+ "examples/cursor-sdk/capture-profile.json": "d1a5787875daf5523b3783f259b891c256a904cbb10279d5be214af846305a2d",
69
+ "examples/cursor-sdk/harness.json": "2f1da6f2b17258d0ee59431f687696901ce3692589175d182f3a4bbe8dd4d689",
70
+ "examples/cursor-sdk/model.json": "ad8f31ff5d2387c02d79226679022358afafc61ba5dda73040e3f754530a230a",
71
+ "examples/cursor-sdk/native-limits.json": "40fe451f56b0863e79b42f5c85defbce678e1dc90dd248d15c430db176d71511",
72
+ "examples/cursor-sdk/native-tool-policy.json": "0fef98b36cd39de82914c5b068022c1335dadda24b7d6e7df398b2430e6a16e0"
73
+ }
74
+ }
package/release/README.md CHANGED
@@ -5,6 +5,7 @@ For downloads and published artifacts, see
5
5
 
6
6
  | Version | Changes and verification | Support boundary |
7
7
  | :--- | :--- | :--- |
8
+ | [0.2.6](0.2.6/README.md) | Portable contained absolute links; isolate workspace packaging failures from native completion and semantic analysis | [Known limitations](0.2.6/KNOWN_LIMITATIONS.md) |
8
9
  | [0.2.5](0.2.5/README.md) | Preserve contained workspace symlinks and macOS AppleDouble files | [Known limitations](0.2.5/KNOWN_LIMITATIONS.md) |
9
10
  | [0.2.4](0.2.4/README.md) | Stream workspace snapshots and integrity checks; support archives up to 1 GiB | [Known limitations](0.2.4/KNOWN_LIMITATIONS.md) |
10
11
  | [0.2.3](0.2.3/README.md) | Retain workspace capture errors in partial-bundle reports | [Known limitations](0.2.3/KNOWN_LIMITATIONS.md) |
@@ -179,7 +179,6 @@
179
179
  {
180
180
  "if": { "properties": { "state": { "const": "completed" } }, "required": ["state"] },
181
181
  "then": {
182
- "required": ["workspaceArtifactId"],
183
182
  "properties": {
184
183
  "failureClass": { "const": "none" },
185
184
  "stopReason": { "const": "none" }