owlrunkit 0.18.3 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +223 -52
  3. package/docs/architecture/OWLCODA_RUNKIT_0184_EXTERNAL_DELIVERY_INTAKE_PRODUCT_DECISION_20260812.md +209 -0
  4. package/docs/architecture/OWLCODA_RUNKIT_0190_CONSOLIDATED_DAILY_USE_PRODUCT_DECISION_20260812.md +97 -0
  5. package/docs/architecture/OWLCODA_RUNKIT_USAGE_MODE_SELECTION_PRODUCT_DECISION_20260811.md +8 -7
  6. package/docs/architecture/OWLCODA_RUN_KIT_CONTRACT_V0_2.md +57 -0
  7. package/integrations/codex/skills/owlcoda-runkit/SKILL.md +133 -77
  8. package/integrations/codex/skills/owlcoda-runkit/assets/templates/config.json +3 -3
  9. package/integrations/codex/skills/owlcoda-runkit/references/contract-v0.2.md +57 -0
  10. package/package.json +1 -1
  11. package/packages/attest/README.md +9 -5
  12. package/packages/attest/consumer-surface-v2.json +263 -0
  13. package/packages/attest/distribution-manifest-v1.json +14 -1
  14. package/packages/attest/schemas/quick-verification-receipt-v3.schema.json +244 -0
  15. package/packages/attest/schemas/workspace-snapshot-v2.schema.json +114 -0
  16. package/packages/attest/src/dependency-environment.mjs +128 -0
  17. package/packages/attest/src/index.mjs +1 -0
  18. package/packages/attest/src/quick-receipt-contract.mjs +205 -12
  19. package/packages/attest/src/quick.mjs +45 -4
  20. package/packages/attest/src/workspace-snapshot.mjs +143 -7
  21. package/registry-release-policy-v1.json +5 -5
  22. package/sbom.spdx.json +5 -5
  23. package/scripts/runkit-contract/assurance-router.mjs +25 -6
  24. package/scripts/runkit-contract/core-contract.mjs +29 -1
  25. package/scripts/runkit-contract/efficiency-metrics.mjs +559 -0
  26. package/scripts/runkit-contract/evidence-store.mjs +130 -0
  27. package/scripts/runkit-contract/onboarding-bootstrap.mjs +685 -0
  28. package/scripts/runkit-contract/onboarding-doctor.mjs +3 -2
  29. package/scripts/runkit-contract/outcome-presentation.mjs +171 -0
  30. package/scripts/runkit-contract/profile-onboarding.mjs +285 -17
  31. package/scripts/runkit-contract/project-cli-resolver.mjs +29 -5
  32. package/scripts/runkit-contract/project-external-delivery.mjs +707 -0
  33. package/scripts/runkit-contract/quick-evidence-reuse.mjs +590 -0
  34. package/scripts/runkit-contract/quick-isolation.mjs +314 -0
  35. package/scripts/runkit-contract/quick-metrics.mjs +51 -3
  36. package/scripts/runkit-contract/quick-receipt.mjs +31 -1
  37. package/scripts/runkit-contract/quick-verify.mjs +217 -26
  38. package/scripts/runkit-contract/quick-workspace-snapshot.mjs +1 -0
  39. package/scripts/runkit-contract/runkit-bootstrap.mjs +7 -1
  40. package/scripts/runkit-contract/runkit-cli.mjs +602 -152
  41. package/scripts/runkit-contract/runtime-transition.mjs +478 -0
  42. package/scripts/runkit-contract/schemas/assurance-route-v2.schema.json +26 -0
  43. package/scripts/runkit-contract/schemas/efficiency-event-v1.schema.json +56 -0
  44. package/scripts/runkit-contract/schemas/efficiency-metrics-v1.schema.json +220 -0
  45. package/scripts/runkit-contract/schemas/external-runtime-report-v1.schema.json +125 -0
  46. package/scripts/runkit-contract/schemas/onboarding-bootstrap-plan-v1.schema.json +168 -0
  47. package/scripts/runkit-contract/schemas/onboarding-bootstrap-receipt-v1.schema.json +130 -0
  48. package/scripts/runkit-contract/schemas/outcome-summary-v1.schema.json +83 -0
  49. package/scripts/runkit-contract/schemas/profile-detection-v3.schema.json +170 -0
  50. package/scripts/runkit-contract/schemas/quick-evidence-reuse-plan-v1.schema.json +59 -0
  51. package/scripts/runkit-contract/schemas/quick-evidence-reuse-receipt-v1.schema.json +79 -0
  52. package/scripts/runkit-contract/schemas/runtime-transition-receipt-v1.schema.json +185 -0
  53. package/scripts/runkit-contract/schemas/team-project-event-v3.schema.json +146 -0
  54. package/scripts/runkit-contract/schemas/team-project-event-v4.schema.json +142 -0
  55. package/scripts/runkit-contract/schemas/team-project-status-v2.schema.json +85 -0
  56. package/scripts/runkit-contract/schemas/usage-mode-recommendation-v2.schema.json +136 -0
  57. package/scripts/runkit-contract/team-project.mjs +1172 -22
  58. package/scripts/runkit-contract/usage-mode.mjs +28 -7
  59. package/scripts/runkit-contract/verification-envelope.mjs +15 -18
@@ -0,0 +1,314 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import {
3
+ chmodSync,
4
+ copyFileSync,
5
+ existsSync,
6
+ lstatSync,
7
+ mkdirSync,
8
+ mkdtempSync,
9
+ readFileSync,
10
+ readdirSync,
11
+ readlinkSync,
12
+ realpathSync,
13
+ rmSync,
14
+ symlinkSync,
15
+ } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import path from "node:path";
18
+
19
+ import { captureDependencyEnvironment } from "../../packages/attest/src/dependency-environment.mjs";
20
+ import { sha256Bytes, sha256Canonical } from "./quick-canonical.mjs";
21
+
22
+ const CONTROL_ROOT = ".owlcoda/runkit";
23
+ const MAX_SOURCE_FILES = 100_000;
24
+ const MAX_SOURCE_BYTES = 2 * 1024 * 1024 * 1024;
25
+ const CACHE_ENVIRONMENT = Object.freeze({
26
+ TMPDIR: "tmp",
27
+ TMP: "tmp",
28
+ TEMP: "tmp",
29
+ XDG_CACHE_HOME: "cache/xdg",
30
+ npm_config_cache: "cache/npm",
31
+ NPM_CONFIG_CACHE: "cache/npm",
32
+ YARN_CACHE_FOLDER: "cache/yarn",
33
+ PIP_CACHE_DIR: "cache/pip",
34
+ UV_CACHE_DIR: "cache/uv",
35
+ CARGO_TARGET_DIR: "cache/cargo-target",
36
+ GOCACHE: "cache/go-build",
37
+ GOMODCACHE: "cache/go-mod",
38
+ PYTHONPYCACHEPREFIX: "cache/python-bytecode",
39
+ });
40
+
41
+ function git(root, args, options = {}) {
42
+ return execFileSync("git", args, {
43
+ cwd: root,
44
+ encoding: options.encoding ?? "utf8",
45
+ maxBuffer: 64 * 1024 * 1024,
46
+ stdio: ["ignore", "pipe", "pipe"],
47
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
48
+ });
49
+ }
50
+
51
+ function normalizePath(value) {
52
+ return value.split(path.sep).join("/");
53
+ }
54
+
55
+ function withinRoot(root, candidate) {
56
+ const relative = path.relative(root, candidate);
57
+ return relative === ""
58
+ || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
59
+ }
60
+
61
+ function rootsOverlap(left, right) {
62
+ return withinRoot(left, right) || withinRoot(right, left);
63
+ }
64
+
65
+ function canonicalRealDirectory(requested, label) {
66
+ const absolute = path.resolve(requested);
67
+ const stat = lstatSync(absolute);
68
+ if (stat.isSymbolicLink() || !stat.isDirectory() || realpathSync(absolute) !== absolute) {
69
+ throw new Error(`${label} must be a real directory without symlink traversal.`);
70
+ }
71
+ return absolute;
72
+ }
73
+
74
+ function sourcePaths(root) {
75
+ return git(root, ["ls-files", "-co", "--exclude-standard", "-z"])
76
+ .split("\0")
77
+ .filter(Boolean)
78
+ .map(normalizePath)
79
+ .filter(relativePath => relativePath !== CONTROL_ROOT && !relativePath.startsWith(`${CONTROL_ROOT}/`))
80
+ .sort((left, right) => left.localeCompare(right, "en"));
81
+ }
82
+
83
+ function symlinkHash(linkTarget) {
84
+ return sha256Bytes(Buffer.from(`symlink:${linkTarget}`, "utf8"));
85
+ }
86
+
87
+ function copyVisibleSource(root, consumerRoot) {
88
+ const files = [];
89
+ let totalBytes = 0;
90
+ for (const relativePath of sourcePaths(root)) {
91
+ const source = path.resolve(root, ...relativePath.split("/"));
92
+ const target = path.resolve(consumerRoot, ...relativePath.split("/"));
93
+ if (!withinRoot(root, source) || !withinRoot(consumerRoot, target)) {
94
+ throw new Error(`Isolated source path escapes its root: ${relativePath}`);
95
+ }
96
+ const stat = lstatSync(source);
97
+ mkdirSync(path.dirname(target), { recursive: true });
98
+ let hash;
99
+ if (stat.isSymbolicLink()) {
100
+ const linkTarget = readlinkSync(source);
101
+ if (path.isAbsolute(linkTarget)) {
102
+ throw new Error(`Isolated source copy rejects an absolute symlink: ${relativePath}`);
103
+ }
104
+ const resolvedTarget = realpathSync(path.resolve(path.dirname(source), linkTarget));
105
+ if (!withinRoot(root, resolvedTarget)) {
106
+ throw new Error(`Isolated source copy rejects a symlink escape: ${relativePath}`);
107
+ }
108
+ symlinkSync(linkTarget, target);
109
+ hash = symlinkHash(linkTarget);
110
+ } else if (stat.isFile()) {
111
+ totalBytes += stat.size;
112
+ copyFileSync(source, target);
113
+ chmodSync(target, stat.mode & 0o777);
114
+ hash = sha256Bytes(readFileSync(source));
115
+ } else {
116
+ throw new Error(`Isolated source copy does not support a directory or special Git entry: ${relativePath}`);
117
+ }
118
+ files.push({ path: relativePath, sha256: hash });
119
+ if (files.length > MAX_SOURCE_FILES || totalBytes > MAX_SOURCE_BYTES) {
120
+ throw new Error("Isolated source copy exceeds the bounded file or byte limit.");
121
+ }
122
+ }
123
+ return {
124
+ files,
125
+ fileCount: files.length,
126
+ totalBytes,
127
+ fingerprint: sha256Canonical(files),
128
+ };
129
+ }
130
+
131
+ function projectNodeModules(dependencyRoot, consumerRoot) {
132
+ if (dependencyRoot === null) return null;
133
+ const sourceRoot = path.join(dependencyRoot.root, "node_modules");
134
+ const targetRoot = path.join(consumerRoot, "node_modules");
135
+ mkdirSync(targetRoot);
136
+ for (const name of readdirSync(sourceRoot).sort((left, right) => left.localeCompare(right, "en"))) {
137
+ const source = path.join(sourceRoot, name);
138
+ const target = path.join(targetRoot, name);
139
+ const stat = lstatSync(source);
140
+ if (name === ".bin") {
141
+ mkdirSync(target);
142
+ for (const binName of readdirSync(source).sort((left, right) => left.localeCompare(right, "en"))) {
143
+ const sourceBin = path.join(source, binName);
144
+ const targetBin = path.join(target, binName);
145
+ const binStat = lstatSync(sourceBin);
146
+ if (binStat.isSymbolicLink()) {
147
+ const realBin = realpathSync(sourceBin);
148
+ symlinkSync(realBin, targetBin, "file");
149
+ } else if (binStat.isFile()) {
150
+ copyFileSync(sourceBin, targetBin);
151
+ chmodSync(targetBin, binStat.mode & 0o777);
152
+ }
153
+ }
154
+ continue;
155
+ }
156
+ if (name.startsWith("@") && stat.isDirectory() && !stat.isSymbolicLink()) {
157
+ mkdirSync(target);
158
+ for (const child of readdirSync(source).sort((left, right) => left.localeCompare(right, "en"))) {
159
+ symlinkSync(realpathSync(path.join(source, child)), path.join(target, child), "dir");
160
+ }
161
+ continue;
162
+ }
163
+ if (stat.isFile()) {
164
+ copyFileSync(source, target);
165
+ continue;
166
+ }
167
+ symlinkSync(realpathSync(source), target, "dir");
168
+ }
169
+ return targetRoot;
170
+ }
171
+
172
+ function isolatedEnvironment(consumerRoot, dependencyEnvironment) {
173
+ const controlRoot = path.join(consumerRoot, ".owlrunkit-isolation");
174
+ const overrides = {};
175
+ for (const [name, relativePath] of Object.entries(CACHE_ENVIRONMENT)) {
176
+ const target = path.join(controlRoot, relativePath);
177
+ mkdirSync(target, { recursive: true });
178
+ overrides[name] = target;
179
+ }
180
+ overrides.OWLRUNKIT_CONSUMER_ROOT = consumerRoot;
181
+ overrides.PWD = consumerRoot;
182
+ overrides.INIT_CWD = consumerRoot;
183
+ if (dependencyEnvironment !== null) {
184
+ overrides.OWLRUNKIT_DEPENDENCY_ROOT = dependencyEnvironment.root;
185
+ overrides.NODE_PATH = [
186
+ path.join(consumerRoot, "node_modules"),
187
+ process.env.NODE_PATH,
188
+ ].filter(Boolean).join(path.delimiter);
189
+ overrides.PATH = [
190
+ path.join(consumerRoot, "node_modules", ".bin"),
191
+ process.env.PATH,
192
+ ].filter(Boolean).join(path.delimiter);
193
+ }
194
+ return {
195
+ controlRoot,
196
+ overrides,
197
+ environmentVariables: Object.keys(overrides).sort(),
198
+ };
199
+ }
200
+
201
+ export function prepareQuickIsolation({ workspaceRoot, dependencyRoot }) {
202
+ const sourceRoot = canonicalRealDirectory(workspaceRoot, "Source workspace");
203
+ const consumerRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "owlrunkit-quick-consumer-")));
204
+ try {
205
+ const sourceCopy = copyVisibleSource(sourceRoot, consumerRoot);
206
+ const dependencyEnvironment = captureDependencyEnvironment(dependencyRoot);
207
+ if (dependencyEnvironment !== null && rootsOverlap(sourceRoot, dependencyEnvironment.root)) {
208
+ throw new Error("Dependency root and source workspace must be disjoint real directories.");
209
+ }
210
+ projectNodeModules(dependencyEnvironment, consumerRoot);
211
+ const environment = isolatedEnvironment(consumerRoot, dependencyEnvironment);
212
+ return {
213
+ schemaVersion: "OwlCodaRunKitQuickIsolationV1",
214
+ sourceRoot,
215
+ consumerRoot,
216
+ sourceCopy,
217
+ dependencyEnvironment,
218
+ environment,
219
+ };
220
+ } catch (error) {
221
+ rmSync(consumerRoot, { recursive: true, force: true });
222
+ throw error;
223
+ }
224
+ }
225
+
226
+ function currentConsumerFiles(consumerRoot) {
227
+ const files = [];
228
+ const visit = (directory, prefix) => {
229
+ for (const name of readdirSync(directory).sort((left, right) => left.localeCompare(right, "en"))) {
230
+ if (prefix === "" && ["node_modules", ".owlrunkit-isolation"].includes(name)) continue;
231
+ const absolute = path.join(directory, name);
232
+ const relativePath = prefix ? `${prefix}/${name}` : name;
233
+ const stat = lstatSync(absolute);
234
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
235
+ visit(absolute, relativePath);
236
+ } else if (stat.isSymbolicLink()) {
237
+ files.push({ path: relativePath, sha256: symlinkHash(readlinkSync(absolute)) });
238
+ } else if (stat.isFile()) {
239
+ files.push({ path: relativePath, sha256: sha256Bytes(readFileSync(absolute)) });
240
+ } else {
241
+ files.push({ path: relativePath, sha256: "special" });
242
+ }
243
+ if (files.length > MAX_SOURCE_FILES) {
244
+ throw new Error("Isolated consumer delta exceeds the bounded file limit.");
245
+ }
246
+ }
247
+ };
248
+ visit(consumerRoot, "");
249
+ return files;
250
+ }
251
+
252
+ function ignoredByGit(sourceRoot, relativePath) {
253
+ if (relativePath === CONTROL_ROOT || relativePath.startsWith(`${CONTROL_ROOT}/`)) return true;
254
+ try {
255
+ git(sourceRoot, ["check-ignore", "--no-index", "-q", "--", relativePath]);
256
+ return true;
257
+ } catch {
258
+ return false;
259
+ }
260
+ }
261
+
262
+ export function inspectQuickIsolation(isolation) {
263
+ const before = new Map(isolation.sourceCopy.files.map(row => [row.path, row.sha256]));
264
+ const afterRows = currentConsumerFiles(isolation.consumerRoot);
265
+ const after = new Map(afterRows.map(row => [row.path, row.sha256]));
266
+ const changed = [...new Set([...before.keys(), ...after.keys()])]
267
+ .filter(relativePath => before.get(relativePath) !== after.get(relativePath))
268
+ .sort((left, right) => left.localeCompare(right, "en"));
269
+ const sourcePaths = [];
270
+ const ignoredPaths = [];
271
+ for (const relativePath of changed) {
272
+ if (!before.has(relativePath) && ignoredByGit(isolation.sourceRoot, relativePath)) {
273
+ ignoredPaths.push(relativePath);
274
+ } else {
275
+ sourcePaths.push(relativePath);
276
+ }
277
+ }
278
+ let dependencyEnvironmentAfter = null;
279
+ let dependencyChanged = false;
280
+ if (isolation.dependencyEnvironment !== null) {
281
+ try {
282
+ dependencyEnvironmentAfter = captureDependencyEnvironment(
283
+ isolation.dependencyEnvironment.root,
284
+ );
285
+ dependencyChanged = dependencyEnvironmentAfter.fingerprint
286
+ !== isolation.dependencyEnvironment.fingerprint;
287
+ } catch {
288
+ dependencyChanged = true;
289
+ }
290
+ }
291
+ return {
292
+ consumerDelta: { sourcePaths, ignoredPaths },
293
+ dependencyEnvironmentAfter,
294
+ dependencyChanged,
295
+ };
296
+ }
297
+
298
+ export function removeQuickIsolation(isolation) {
299
+ try {
300
+ rmSync(isolation.consumerRoot, { recursive: true, force: true });
301
+ return "removed";
302
+ } catch {
303
+ return "cleanup_failed";
304
+ }
305
+ }
306
+
307
+ export function dependencyEnvironmentMatches(binding) {
308
+ if (binding === null) return true;
309
+ try {
310
+ return captureDependencyEnvironment(binding.root).fingerprint === binding.fingerprint;
311
+ } catch {
312
+ return false;
313
+ }
314
+ }
@@ -22,6 +22,13 @@ const INVALID_ATTESTATION_ISSUES = new Set([
22
22
  "attestation_material_missing",
23
23
  "receipt_material_hash_mismatch",
24
24
  ]);
25
+ const V3_MUTATION_ISSUES = new Set([
26
+ "source_mutated_during_verification",
27
+ "bound_ignored_path_mutated_during_verification",
28
+ "dependency_environment_mutated_during_verification",
29
+ "consumer_source_mutated_during_verification",
30
+ "quick_isolation_cleanup_failed",
31
+ ]);
25
32
 
26
33
  function readHistoricalReceipt(receiptPath) {
27
34
  const { bytes } = readFileBytesBounded(receiptPath);
@@ -31,9 +38,12 @@ function readHistoricalReceipt(receiptPath) {
31
38
  && quickSnapshotFingerprintValid(receipt.workspaceAfter);
32
39
  const sourceChanged = receipt.workspaceBefore.sourceFingerprint
33
40
  !== receipt.workspaceAfter.sourceFingerprint;
41
+ const receiptV3 = receipt.schemaVersion === "OwlCodaQuickVerificationReceiptV3";
34
42
  const mutationBindingValid = receipt.mutationDecision === "source_unchanged"
35
- ? !sourceChanged && !receipt.issueCodes.includes("source_mutated_during_verification")
36
- : sourceChanged && receipt.issueCodes.includes("source_mutated_during_verification");
43
+ ? !sourceChanged && !receipt.issueCodes.some(issue => V3_MUTATION_ISSUES.has(issue))
44
+ : receiptV3
45
+ ? receipt.issueCodes.some(issue => V3_MUTATION_ISSUES.has(issue))
46
+ : sourceChanged && receipt.issueCodes.includes("source_mutated_during_verification");
37
47
  if (!snapshotsValid || !mutationBindingValid) {
38
48
  throw new Error("quick_receipt_source_binding_invalid");
39
49
  }
@@ -59,6 +69,44 @@ function trustedReceiptRoot(workspaceRoot) {
59
69
  return { status: "valid", root };
60
70
  }
61
71
 
72
+ export function readTrustedQuickReceiptHistory(workspaceRoot) {
73
+ const selected = trustedReceiptRoot(workspaceRoot);
74
+ if (selected.status !== "valid") {
75
+ return {
76
+ status: selected.status,
77
+ receiptPaths: [],
78
+ receipts: [],
79
+ invalidCount: selected.status === "invalid" ? 1 : 0,
80
+ };
81
+ }
82
+ const receiptPaths = readdirSync(selected.root, { withFileTypes: true })
83
+ .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
84
+ .map((entry) => path.join(selected.root, entry.name, "receipt.json"))
85
+ .filter(existsSync)
86
+ .sort();
87
+ const receipts = [];
88
+ let invalidCount = 0;
89
+ for (const receiptPath of receiptPaths) {
90
+ try {
91
+ const receipt = readHistoricalReceipt(receiptPath);
92
+ const attestation = attestQuickReceipt({ receiptPath, workspaceRoot });
93
+ if (attestation.issueCodes.some((issue) => INVALID_ATTESTATION_ISSUES.has(issue))) {
94
+ invalidCount += 1;
95
+ } else {
96
+ receipts.push({ receiptPath, receipt });
97
+ }
98
+ } catch {
99
+ invalidCount += 1;
100
+ }
101
+ }
102
+ return {
103
+ status: "valid",
104
+ receiptPaths,
105
+ receipts,
106
+ invalidCount,
107
+ };
108
+ }
109
+
62
110
  export function readLocalQuickMetrics(workspaceRoot, { verbose = false } = {}) {
63
111
  const selected = trustedReceiptRoot(workspaceRoot);
64
112
  if (selected.status === "invalid") {
@@ -100,7 +148,7 @@ export function readLocalQuickMetrics(workspaceRoot, { verbose = false } = {}) {
100
148
  const attestation = attestQuickReceipt({ receiptPath, workspaceRoot });
101
149
  if (attestation.issueCodes.some((issue) => INVALID_ATTESTATION_ISSUES.has(issue))) {
102
150
  totals.invalid += 1;
103
- } else if (receipt.mutationDecision === "invalidated_by_command_source_mutation") {
151
+ } else if (receipt.mutationDecision !== "source_unchanged") {
104
152
  totals.sourceMutated += 1;
105
153
  } else if (receipt.exitResult.exitCode === 0 && receipt.exitResult.signal === null) {
106
154
  totals.passed += 1;
@@ -1,6 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import {
3
+ chmodSync,
3
4
  closeSync,
5
+ constants,
6
+ copyFileSync,
4
7
  existsSync,
5
8
  fstatSync,
6
9
  lstatSync,
@@ -9,10 +12,12 @@ import {
9
12
  readFileSync,
10
13
  realpathSync,
11
14
  renameSync,
15
+ rmSync,
12
16
  writeFileSync,
13
17
  } from "node:fs";
14
18
  import path from "node:path";
15
19
 
20
+ import { persistEvidenceBytes } from "./evidence-store.mjs";
16
21
  import { sha256Bytes } from "./quick-canonical.mjs";
17
22
 
18
23
  const QUICK_ROOT = ".owlcoda/runkit/quick";
@@ -132,13 +137,27 @@ function summarizeOutputBytes(bytes) {
132
137
 
133
138
  function outputArtifact(workspaceRoot, filePath) {
134
139
  const bytes = readFileSync(filePath);
140
+ const evidence = persistEvidenceBytes({ workspaceRoot, bytes });
141
+ const stat = lstatSync(filePath);
142
+ if (stat.isSymbolicLink() || !stat.isFile()) {
143
+ throw new Error(`Quick output material is not a regular file: ${filePath}`);
144
+ }
145
+ const replacement = `${filePath}.cas-${process.pid}-${randomUUID()}`;
146
+ try {
147
+ copyFileSync(evidence.absolutePath, replacement, constants.COPYFILE_FICLONE);
148
+ chmodSync(replacement, 0o600);
149
+ renameSync(replacement, filePath);
150
+ } finally {
151
+ rmSync(replacement, { force: true });
152
+ }
135
153
  return {
136
154
  artifact: {
137
155
  path: path.relative(realpathSync(workspaceRoot), filePath).split(path.sep).join("/"),
138
- sha256: sha256Bytes(bytes),
156
+ sha256: evidence.sha256,
139
157
  sizeBytes: bytes.byteLength,
140
158
  },
141
159
  summary: summarizeOutputBytes(bytes),
160
+ evidence,
142
161
  };
143
162
  }
144
163
 
@@ -224,6 +243,7 @@ export function persistQuickReceipt({ workspaceRoot, store, receipt }) {
224
243
  },
225
244
  };
226
245
  atomicWriteJson(store.receiptPath, complete);
246
+ const evidenceRows = [stdout.evidence, stderr.evidence];
227
247
  return {
228
248
  receipt: complete,
229
249
  receiptPath: store.receiptPath,
@@ -236,6 +256,16 @@ export function persistQuickReceipt({ workspaceRoot, store, receipt }) {
236
256
  stdoutTruncated: stdout.summary.truncated,
237
257
  stderrTruncated: stderr.summary.truncated,
238
258
  },
259
+ evidenceStorage: {
260
+ mode: "content_addressed_sha256",
261
+ objectPaths: [...new Set(evidenceRows.map(row => row.path))].sort(),
262
+ uniqueBytes: evidenceRows
263
+ .filter(row => row.created)
264
+ .reduce((total, row) => total + row.sizeBytes, 0),
265
+ reusedBytes: evidenceRows
266
+ .filter(row => !row.created)
267
+ .reduce((total, row) => total + row.sizeBytes, 0),
268
+ },
239
269
  };
240
270
  }
241
271