engineering-behavior-observatory 0.2.4 → 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, 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));
@@ -268,11 +294,18 @@ async function withWorkspaceOutcomeProjection(options, use) {
268
294
  try {
269
295
  await cp(finalPath, projectedPath, {
270
296
  recursive: true,
297
+ verbatimSymlinks: true,
271
298
  preserveTimestamps: true,
272
299
  force: false,
273
300
  filter: async (source) => source === finalPath
274
301
  || !await isExcludedWorkspaceDirectory(finalPath, source, exclusions),
275
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
+ }
276
309
  if (options.respectGitignore === true)
277
310
  await removeIgnoredWorkspaceEntries(startPath, projectedPath);
278
311
  if (options.omitEmptyDirectories === true)
@@ -648,7 +681,7 @@ async function workspacePatchApplies(startingWorkspacePath, patch) {
648
681
  const applied = join(temporaryRoot, "workspace");
649
682
  const patchPath = join(temporaryRoot, "workspace.patch");
650
683
  try {
651
- await cp(resolve(startingWorkspacePath), applied, { recursive: true, preserveTimestamps: true, force: false });
684
+ await cp(resolve(startingWorkspacePath), applied, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true, force: false });
652
685
  if (patch.length > 0) {
653
686
  await writeFile(patchPath, patch, { flag: "wx", mode: 0o600 });
654
687
  await execFileAsync("git", ["apply", "--check", "--binary", patchPath], { cwd: applied });
@@ -842,7 +875,7 @@ async function removeIgnoredWorkspaceEntries(startPath, finalPath) {
842
875
  const globalExcludes = join(temporaryRoot, "global-excludes");
843
876
  try {
844
877
  await writeFile(globalExcludes, "");
845
- await cp(startPath, baseline, { recursive: true, preserveTimestamps: true, force: false });
878
+ await cp(startPath, baseline, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true, force: false });
846
879
  await execFileAsync("git", ["init", "--quiet"], { cwd: baseline });
847
880
  await execFileAsync("git", ["add", "--force", "--all"], { cwd: baseline });
848
881
  for (const relativePath of await ignoredWorkspacePaths(baseline, finalPath, globalExcludes)) {
@@ -910,12 +943,31 @@ async function isExcludedWorkspaceDirectory(root, source, exclusions) {
910
943
  }
911
944
  return false;
912
945
  }
913
- async function removeEmptyDirectories(directory, root = directory) {
946
+ async function removeEmptyDirectories(directory, root = directory, targets) {
947
+ if (targets === undefined) {
948
+ targets = new Set();
949
+ async function collect(path) {
950
+ for (const entry of await readdir(path, { withFileTypes: true })) {
951
+ const child = join(path, entry.name);
952
+ if (entry.isDirectory())
953
+ await collect(child);
954
+ else if (entry.isSymbolicLink()) {
955
+ // Omission must not turn a valid link into a dangling one. Invalid
956
+ // links are rejected later by workspace hashing; no content is read.
957
+ try {
958
+ targets.add(await realpath(child));
959
+ }
960
+ catch { /* validated by hashing */ }
961
+ }
962
+ }
963
+ }
964
+ await collect(directory);
965
+ }
914
966
  for (const entry of await readdir(directory, { withFileTypes: true })) {
915
967
  if (entry.isDirectory())
916
- await removeEmptyDirectories(join(directory, entry.name), root);
968
+ await removeEmptyDirectories(join(directory, entry.name), root, targets);
917
969
  }
918
- if (directory !== root && (await readdir(directory)).length === 0)
970
+ if (directory !== root && !targets.has(await realpath(directory)) && (await readdir(directory)).length === 0)
919
971
  await rmdir(directory);
920
972
  }
921
973
  async function restoreProjectedDirectoryTimestamps(source, projected) {
@@ -938,7 +990,7 @@ async function workspacePatch(startPath, finalPath, finalTreeDigest) {
938
990
  const applied = join(temporaryRoot, "applied");
939
991
  const patchPath = join(temporaryRoot, "workspace.patch");
940
992
  try {
941
- await cp(startPath, worktree, { recursive: true, preserveTimestamps: true, force: false });
993
+ await cp(startPath, worktree, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true, force: false });
942
994
  await execFileAsync("git", ["init", "--quiet"], { cwd: worktree });
943
995
  await execFileAsync("git", ["add", "--force", "--all"], { cwd: worktree });
944
996
  // A tree is enough for the diff. A commit can spawn detached maintenance
@@ -951,7 +1003,7 @@ async function workspacePatch(startPath, finalPath, finalTreeDigest) {
951
1003
  for (const entry of await readdir(finalPath)) {
952
1004
  if (entry === ".git")
953
1005
  throw new Error("Workspace patches cannot retain Git administrative state.");
954
- await cp(join(finalPath, entry), join(worktree, entry), { recursive: true, preserveTimestamps: true, force: false });
1006
+ await cp(join(finalPath, entry), join(worktree, entry), { recursive: true, verbatimSymlinks: true, preserveTimestamps: true, force: false });
955
1007
  }
956
1008
  await execFileAsync("git", ["add", "--force", "--intent-to-add", "--all"], { cwd: worktree });
957
1009
  let stdout;
@@ -966,12 +1018,14 @@ async function workspacePatch(startPath, finalPath, finalTreeDigest) {
966
1018
  throw error;
967
1019
  }
968
1020
  const patch = Buffer.from(stdout);
969
- await cp(startPath, applied, { recursive: true, preserveTimestamps: true, force: false });
1021
+ await cp(startPath, applied, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true, force: false });
970
1022
  if (patch.length > 0) {
971
1023
  await writeFile(patchPath, patch, { flag: "wx", mode: 0o600 });
972
1024
  await execFileAsync("git", ["apply", "--binary", patchPath], { cwd: applied });
973
1025
  }
974
- const appliedTreeDigest = await digestWorkspaceTree(applied);
1026
+ // Git omits empty directories, including legitimate link targets. An
1027
+ // invalid reconstructed tree is a patch limitation, so use the snapshot.
1028
+ const appliedTreeDigest = await digestWorkspaceTree(applied).catch(() => undefined);
975
1029
  if (appliedTreeDigest !== finalTreeDigest) {
976
1030
  return undefined;
977
1031
  }
@@ -989,8 +1043,8 @@ async function workspaceSnapshot(finalPath, finalTreeDigest, use) {
989
1043
  const extracted = join(temporaryRoot, "extracted");
990
1044
  try {
991
1045
  await mkdir(snapshotParent);
992
- await cp(finalPath, snapshotRoot, { recursive: true, preserveTimestamps: true, force: false });
993
- const child = spawn(TAR_COMMAND, ["-czf", "-", "-C", snapshotParent, "workspace"], { stdio: ["ignore", "pipe", "pipe"] });
1046
+ await cp(finalPath, snapshotRoot, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true, force: false });
1047
+ const child = spawn(TAR_COMMAND, [...(process.platform === "darwin" ? ["--no-mac-metadata"] : []), "-czf", "-", "-C", snapshotParent, "workspace"], { stdio: ["ignore", "pipe", "pipe"] });
994
1048
  let stderr = "";
995
1049
  child.stderr.on("data", (chunk) => { stderr = (stderr + chunk.toString("utf8")).slice(-8192); });
996
1050
  const exited = new Promise((resolveExit, reject) => {
@@ -1012,7 +1066,16 @@ async function workspaceSnapshot(finalPath, finalTreeDigest, use) {
1012
1066
  await Promise.allSettled([exited, streamed]);
1013
1067
  }
1014
1068
  await mkdir(extracted, { mode: 0o700 });
1015
- await execFileAsync(TAR_COMMAND, ["-xzpf", snapshotPath, "-C", extracted]);
1069
+ // macOS libarchive consumes genuine ._* AppleDouble files even when its
1070
+ // metadata flags are disabled. Native pax preserves them with copyfile off.
1071
+ if (process.platform === "darwin") {
1072
+ await execFileAsync("/bin/pax", ["-rz", "-p", "p", "-f", snapshotPath], {
1073
+ cwd: extracted, env: { ...process.env, COPYFILE_DISABLE: "1" },
1074
+ });
1075
+ }
1076
+ else {
1077
+ await execFileAsync(TAR_COMMAND, ["-xzpf", snapshotPath, "-C", extracted]);
1078
+ }
1016
1079
  if (await digestWorkspaceTree(join(extracted, "workspace")) !== finalTreeDigest) {
1017
1080
  throw new Error("Bounded workspace snapshot cannot reproduce the final workspace tree.");
1018
1081
  }
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { createReadStream, createWriteStream } from "node:fs";
4
- import { chmod, lstat, mkdir, mkdtemp, open, readdir, readFile, realpath, rm, utimes } from "node:fs/promises";
4
+ import { chmod, lstat, mkdir, mkdtemp, open, readdir, readFile, readlink, realpath, rm, utimes } from "node:fs/promises";
5
5
  import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
6
6
  import { tmpdir } from "node:os";
7
7
  import { performance } from "node:perf_hooks";
@@ -72,10 +72,10 @@ async function digestWorkspaceEntries(workspacePath, includeTimestamps, signal)
72
72
  if (!metadata.isDirectory() || metadata.isSymbolicLink())
73
73
  throw new Error("Workspace root is not a directory.");
74
74
  hash.update(`root\0${metadata.mode & 4095n}\0${includeTimestamps ? `${workspaceTimestamp(metadata)}\0` : ""}`);
75
- await hashWorkspaceDirectory(root, "", hash, includeTimestamps, signal);
75
+ await hashWorkspaceDirectory(root, "", hash, includeTimestamps, signal, root);
76
76
  return `sha256:${hash.digest("hex")}`;
77
77
  }
78
- async function hashWorkspaceDirectory(directory, relativeDirectory, hash, includeTimestamps, signal) {
78
+ async function hashWorkspaceDirectory(directory, relativeDirectory, hash, includeTimestamps, signal, root = directory) {
79
79
  assertNotAborted(signal);
80
80
  const entries = await readdir(directory, { withFileTypes: true });
81
81
  entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
@@ -83,11 +83,31 @@ async function hashWorkspaceDirectory(directory, relativeDirectory, hash, includ
83
83
  const path = join(directory, entry.name);
84
84
  const relativePath = posix.join(relativeDirectory, entry.name);
85
85
  const metadata = await lstat(path, { bigint: true });
86
- if (metadata.isSymbolicLink())
87
- throw new Error(`Workspace contains a symbolic link at "${relativePath}".`);
88
- if (metadata.isDirectory()) {
86
+ if (metadata.isSymbolicLink()) {
87
+ // Preserve link text, never hash the referent through the link. Both its
88
+ // lexical target and resolved chain must stay in this workspace.
89
+ const target = await readlink(path);
90
+ const contained = (candidate) => {
91
+ const rel = relative(root, candidate);
92
+ return !isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`);
93
+ };
94
+ if (!isAbsolute(target) && !contained(resolve(dirname(path), target))) {
95
+ throw new Error(`Workspace symbolic link escapes its root at "${relativePath}".`);
96
+ }
97
+ let resolved;
98
+ try {
99
+ resolved = await realpath(path);
100
+ }
101
+ catch {
102
+ throw new Error(`Workspace symbolic link is dangling or cyclic at "${relativePath}".`);
103
+ }
104
+ if (!contained(resolved))
105
+ throw new Error(`Workspace symbolic link escapes its root at "${relativePath}".`);
106
+ hash.update(`symlink\0${relativePath}\0${metadata.mode & 4095n}\0${includeTimestamps ? `${workspaceTimestamp(metadata)}\0` : ""}${Buffer.byteLength(target)}\0${target}`);
107
+ }
108
+ else if (metadata.isDirectory()) {
89
109
  hash.update(`directory\0${relativePath}\0${metadata.mode & 4095n}\0${includeTimestamps ? `${workspaceTimestamp(metadata)}\0` : ""}`);
90
- await hashWorkspaceDirectory(path, relativePath, hash, includeTimestamps, signal);
110
+ await hashWorkspaceDirectory(path, relativePath, hash, includeTimestamps, signal, root);
91
111
  }
92
112
  else if (metadata.isFile()) {
93
113
  if (metadata.nlink > 1n)
@@ -41,11 +41,56 @@ 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
47
61
  not change the original attempt's recorded failure.
48
62
 
63
+ When the native run completed but capture failed, recover capture without
64
+ executing the task again. Materialize the same frozen starting workspace, use
65
+ the same capture exclusions, and assemble a separate bundle with byte-identical
66
+ native evidence plus the recovered workspace outcome. Record the original
67
+ manifest digest, recovery time, capture-code identity and qualification beside
68
+ the recovered bundle. Preserve the original failed bundle. A later workspace
69
+ capture cannot prove the retained directory was unchanged since execution ended
70
+ unless a fingerprint was recorded then. Do not count the recovery as another
71
+ model trial or as an independent sample.
72
+
73
+ Workspace outcomes preserve relative symbolic links whose lexical targets and
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
81
+ reproduce the resulting tree digest. This does not relax task-archive admission
82
+ or verifier sandbox rules.
83
+
84
+ On macOS, snapshot creation disables generated AppleDouble metadata. Snapshot
85
+ verification uses the built-in `/bin/pax` with `COPYFILE_DISABLE=1` so genuine
86
+ `._*` files remain files. macOS tar may otherwise consume them as metadata,
87
+ changing the reconstructed tree. To extract a snapshot for inspection on macOS:
88
+
89
+ ```sh
90
+ mkdir restored
91
+ (cd restored && COPYFILE_DISABLE=1 /bin/pax -rz -p p -f /absolute/path/workspace.tar.gz)
92
+ ```
93
+
49
94
  Workspace capture tries a patch up to 64 MiB, then falls back to a snapshot
50
95
  when the patch is too large or cannot reproduce the tree. Snapshots stream to
51
96
  disk with a 1 GiB compressed limit and are extracted to verify the tree digest
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engineering-behavior-observatory",
3
- "version": "0.2.4",
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": {
@@ -48,6 +48,8 @@
48
48
  "release/0.2.2/",
49
49
  "release/0.2.3/",
50
50
  "release/0.2.4/",
51
+ "release/0.2.5/",
52
+ "release/0.2.6/",
51
53
  "schemas/",
52
54
  "scripts/atlas-grafana.sh"
53
55
  ],
@@ -0,0 +1,12 @@
1
+ # v0.2.5 known limitations
2
+
3
+ The [v0.2.4 limitations](../0.2.4/KNOWN_LIMITATIONS.md) continue to apply.
4
+
5
+ - Symlink support applies to workspace outcome capture, not task-archive
6
+ admission or permission to read outside the workspace.
7
+ - On macOS, use the documented native `pax` command when inspecting snapshots
8
+ containing genuine AppleDouble files. Default `tar` extraction can consume them.
9
+ - Capture-only recovery can use retained evidence and workspace files without
10
+ rerunning a model, but cannot reconstruct missing files or prove an earlier
11
+ workspace state without a fingerprint captured at that time.
12
+ - Workspace snapshots remain restricted evidence, not sanitized partner exports.
@@ -0,0 +1,25 @@
1
+ # v0.2.5: Preserve framework files in workspace capture
2
+
3
+ Workspace capture now preserves contained relative symbolic links, including
4
+ framework link chains and links to empty directories. Absolute, escaping,
5
+ dangling, and cyclic links remain rejected. Copies retain the original link
6
+ text, and patches that cannot reproduce the tree fall back to a snapshot.
7
+
8
+ On macOS, snapshot verification uses native `pax` to preserve genuine AppleDouble
9
+ files that macOS `tar` otherwise consumes as metadata. Snapshots remain tar.gz
10
+ archives. No runtime dependencies, pins, or serialized artifact formats change.
11
+
12
+ ## Verification
13
+
14
+ Regression tests cover link preservation, invalid targets, empty target
15
+ directories, AppleDouble bytes, and long paths. A completed model attempt's
16
+ retained workspace was recovered as a 695,459,840-byte snapshot with a verified
17
+ extracted tree digest, without another model call or rewriting original evidence.
18
+ The recovery qualified with an explicit missing-telemetry-receipt gap; it records
19
+ the later capture time rather than claiming an exact end-of-turn snapshot.
20
+
21
+ Run `npm ci` and `npm run acceptance` on Node 24.19.0 for tests, documentation
22
+ and package checks, and two byte-identical package builds. See
23
+ [known limitations](KNOWN_LIMITATIONS.md), the
24
+ [reproducibility manifest](reproducibility.json), and the
25
+ [workspace inspection 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.5"
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 relative symlinks, empty link targets, AppleDouble files and long paths; streamed snapshots with verified tree digests and unchanged 1 GiB bound"
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
+ }
@@ -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,8 @@ 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) |
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) |
8
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) |
9
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) |
10
12
  | [0.2.2](0.2.2/README.md) | Configurable Codex tool networking, enabled by default for workspace-write | [Known limitations](0.2.2/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" }