@0dai-dev/cli 4.3.4 → 4.3.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.
@@ -0,0 +1,378 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Cohort detection
8
+ // ---------------------------------------------------------------------------
9
+
10
+ // Slug fragments that identify the 0dai self-build (dogfood) repo.
11
+ // Any project whose scrubbed remote origin contains one of these strings is
12
+ // classified as "dogfood". Everything else is "external".
13
+ // Limitation (honest): until real external users exist, the "external" bucket
14
+ // will be empty — local-only repos (no remote) also default to "dogfood"
15
+ // because there is no reliable signal to distinguish them from the self-build.
16
+ const DOGFOOD_ORIGIN_FRAGMENTS = ["iGeezmo/0dai", "0dai-dev/0dai", "0dai/0dai"];
17
+
18
+ /**
19
+ * Classify the repo at `target` as "dogfood" or "external".
20
+ *
21
+ * Heuristic: if the git remote.origin.url (scrubbed of credentials) contains
22
+ * a known 0dai repo slug, the cohort is "dogfood". Otherwise "external".
23
+ * Falls back to "dogfood" when no remote is present (local-only repos).
24
+ *
25
+ * Honesty caveat: this is a name-match heuristic, not cryptographic identity.
26
+ * It will mis-classify a fork that still has the upstream remote as dogfood.
27
+ * It defaults to "dogfood" for local-only repos, so it NEVER over-counts
28
+ * external activations — it may under-count them.
29
+ *
30
+ * @param {string} target - absolute path to the repo root
31
+ * @returns {"dogfood"|"external"}
32
+ */
33
+ function detectCohort(target) {
34
+ try {
35
+ const { execFileSync } = require("child_process");
36
+ const rawUrl = execFileSync(
37
+ "git", ["config", "--get", "remote.origin.url"],
38
+ { cwd: target, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 3000 },
39
+ ).trim();
40
+ if (!rawUrl) return "dogfood";
41
+ // Scrub credentials (https://user:token@host → https://host)
42
+ const url = rawUrl.replace(/^(https?:\/\/)[^@/\s]+@/, "$1");
43
+ for (const frag of DOGFOOD_ORIGIN_FRAGMENTS) {
44
+ if (url.includes(frag)) return "dogfood";
45
+ }
46
+ return "external";
47
+ } catch {
48
+ // No git, no remote, or git error → safe fallback
49
+ return "dogfood";
50
+ }
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Merged-PR detection via git log
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * Detect the timestamp of the earliest merge commit in the repo's local history.
59
+ * Looks for commits that git identifies as merge commits (two+ parents), which
60
+ * covers both GitHub squash+merge and traditional merge strategies.
61
+ *
62
+ * Returns ISO timestamp of the earliest such commit, or null if none found.
63
+ *
64
+ * Limitation: reads only local history — will miss PRs merged on the remote
65
+ * before the repo was cloned if those commits were not fetched. That is
66
+ * acceptable: we record the first merge *experienced* in this checkout.
67
+ *
68
+ * @param {string} target - absolute path to the repo root
69
+ * @returns {string|null} ISO timestamp or null
70
+ */
71
+ function detectFirstMergedPrTs(target) {
72
+ try {
73
+ const { execFileSync } = require("child_process");
74
+ // --merges selects only merge commits; --reverse gives oldest first; -1 takes the first
75
+ const out = execFileSync(
76
+ "git",
77
+ ["log", "--all", "--merges", "--format=%aI\t%s", "--reverse", "--max-count=1"],
78
+ { cwd: target, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5000 },
79
+ ).trim();
80
+ if (!out) return null;
81
+ const tab = out.indexOf("\t");
82
+ if (tab < 0) return null;
83
+ const ts = out.slice(0, tab).trim();
84
+ return ts || null;
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Core helpers
92
+ // ---------------------------------------------------------------------------
93
+
94
+ function activationPath(target) {
95
+ return path.join(target, "ai", "meta", "telemetry", "activation.jsonl");
96
+ }
97
+
98
+ function nowIso() {
99
+ return new Date().toISOString();
100
+ }
101
+
102
+ function ensureDir(filePath) {
103
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
104
+ }
105
+
106
+ function readEvents(target) {
107
+ const file = activationPath(target);
108
+ if (!fs.existsSync(file)) return [];
109
+ const lines = fs.readFileSync(file, "utf8").trim().split("\n").filter(Boolean);
110
+ const events = [];
111
+ for (const ln of lines) {
112
+ try {
113
+ const row = JSON.parse(ln);
114
+ if (row && row.event) events.push(row);
115
+ } catch { /* ignore malformed lines */ }
116
+ }
117
+ return events;
118
+ }
119
+
120
+ function appendEvent(target, entry) {
121
+ const file = activationPath(target);
122
+ try {
123
+ ensureDir(file);
124
+ fs.appendFileSync(file, JSON.stringify(entry) + "\n", "utf8");
125
+ return true;
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ function hasEvent(events, eventName, projectId) {
132
+ return events.some((e) => e.event === eventName && e.project_id === projectId);
133
+ }
134
+
135
+ function findInitTs(events, projectId) {
136
+ const init = events.find((e) => e.event === "init" && e.project_id === projectId);
137
+ return init && init.ts ? init.ts : null;
138
+ }
139
+
140
+ function parseTs(ts) {
141
+ const ms = Date.parse(ts);
142
+ return Number.isFinite(ms) ? ms : null;
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Recorders
147
+ // ---------------------------------------------------------------------------
148
+
149
+ // Idempotently append init telemetry on successful init.
150
+ function recordActivationInit(target, projectId, metadata = {}) {
151
+ try {
152
+ if (!projectId) return { fired: false };
153
+ const events = readEvents(target);
154
+ if (hasEvent(events, "init", projectId)) return { fired: false };
155
+ const cohort = detectCohort(target);
156
+ const stack = metadata.stack_detected || metadata.stack || "unknown";
157
+ const entry = {
158
+ event: "init",
159
+ ts: nowIso(),
160
+ project_id: projectId,
161
+ repo_fingerprint_hash: projectId,
162
+ stack_detected: stack,
163
+ cohort,
164
+ };
165
+ return { fired: appendEvent(target, entry) };
166
+ } catch {
167
+ return { fired: false };
168
+ }
169
+ }
170
+
171
+ // Idempotently append first_task with duration_ms = first_task.ts - init.ts.
172
+ function recordActivationFirstTask(target, projectId, metadata = {}) {
173
+ try {
174
+ if (!projectId) return { fired: false, durationMs: null };
175
+ const events = readEvents(target);
176
+ if (hasEvent(events, "first_task", projectId)) {
177
+ return { fired: false, durationMs: null };
178
+ }
179
+
180
+ const initTs = findInitTs(events, projectId);
181
+ const ts = nowIso();
182
+ let durationMs = null;
183
+ if (initTs) {
184
+ const initMs = parseTs(initTs);
185
+ const taskMs = parseTs(ts);
186
+ if (initMs != null && taskMs != null) {
187
+ durationMs = Math.max(0, taskMs - initMs);
188
+ }
189
+ }
190
+
191
+ const cohort = detectCohort(target);
192
+ const entry = {
193
+ event: "first_task",
194
+ ts,
195
+ project_id: projectId,
196
+ duration_ms: durationMs,
197
+ cohort,
198
+ outcome: metadata.outcome || "task_queued",
199
+ };
200
+ if (Number.isFinite(metadata.task_count)) entry.task_count = metadata.task_count;
201
+ const fired = appendEvent(target, entry);
202
+ return { fired, durationMs };
203
+ } catch {
204
+ return { fired: false, durationMs: null };
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Idempotently append a `first_merged_pr` activation event.
210
+ *
211
+ * This event marks the end-to-end activation funnel: init → first_task →
212
+ * first_merged_pr. It is what M6 "activation p50 <5min (measured)" needs to
213
+ * be verifiable end-to-end.
214
+ *
215
+ * Detection: inspects `git log --merges` for the earliest merge commit in
216
+ * the local history. If none exists yet, does nothing (caller should invoke
217
+ * again after a merge occurs, e.g. from `0dai status` or `0dai ci`).
218
+ *
219
+ * Fields emitted:
220
+ * - event: "first_merged_pr"
221
+ * - ts: ISO timestamp of the first merge commit (reflects when the PR was
222
+ * actually merged, not wall-clock detection time)
223
+ * - project_id: hashed repo identity (no raw origin stored — privacy-safe)
224
+ * - cohort: "dogfood" | "external"
225
+ * - duration_ms: ms between init event and the merge timestamp (null if no
226
+ * init event found in the local activation.jsonl)
227
+ *
228
+ * @param {string} target - absolute path to the repo root
229
+ * @param {string} projectId - opaque project identifier
230
+ * @returns {{ fired: boolean, durationMs: number|null }}
231
+ */
232
+ function recordActivationFirstMergedPR(target, projectId) {
233
+ try {
234
+ if (!projectId) return { fired: false, durationMs: null };
235
+ const events = readEvents(target);
236
+ if (hasEvent(events, "first_merged_pr", projectId)) {
237
+ return { fired: false, durationMs: null };
238
+ }
239
+
240
+ const mergeTs = detectFirstMergedPrTs(target);
241
+ if (!mergeTs) return { fired: false, durationMs: null };
242
+
243
+ const initTs = findInitTs(events, projectId);
244
+ let durationMs = null;
245
+ if (initTs) {
246
+ const initMs = parseTs(initTs);
247
+ const mergeMs = parseTs(mergeTs);
248
+ if (initMs != null && mergeMs != null) {
249
+ durationMs = Math.max(0, mergeMs - initMs);
250
+ }
251
+ }
252
+
253
+ const cohort = detectCohort(target);
254
+ const entry = {
255
+ event: "first_merged_pr",
256
+ ts: mergeTs,
257
+ project_id: projectId,
258
+ cohort,
259
+ duration_ms: durationMs,
260
+ };
261
+ const fired = appendEvent(target, entry);
262
+ return { fired, durationMs };
263
+ } catch {
264
+ return { fired: false, durationMs: null };
265
+ }
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Stats
270
+ // ---------------------------------------------------------------------------
271
+
272
+ function percentile(sorted, p) {
273
+ if (!sorted.length) return null;
274
+ if (sorted.length === 1) return sorted[0];
275
+ const idx = (sorted.length - 1) * p;
276
+ const lo = Math.floor(idx);
277
+ const hi = Math.ceil(idx);
278
+ if (lo === hi) return sorted[lo];
279
+ const weight = idx - lo;
280
+ return Math.round(sorted[lo] * (1 - weight) + sorted[hi] * weight);
281
+ }
282
+
283
+ function getActivationDurationStats(target) {
284
+ const events = readEvents(target);
285
+ const durations = events
286
+ .filter((e) => e.event === "first_task"
287
+ && typeof e.duration_ms === "number"
288
+ && Number.isFinite(e.duration_ms))
289
+ .map((e) => e.duration_ms)
290
+ .sort((a, b) => a - b);
291
+
292
+ return {
293
+ count: durations.length,
294
+ p50_ms: percentile(durations, 0.5),
295
+ p90_ms: percentile(durations, 0.9),
296
+ };
297
+ }
298
+
299
+ /**
300
+ * Stats over `first_merged_pr` events — parallel to getActivationDurationStats.
301
+ * Used by `0dai status` to surface end-to-end activation timing.
302
+ *
303
+ * @param {string} target
304
+ * @returns {{ count: number, p50_ms: number|null, p90_ms: number|null,
305
+ * cohorts: { dogfood: number, external: number } }}
306
+ */
307
+ function getActivationMergedPrStats(target) {
308
+ const events = readEvents(target);
309
+ const mergedPrEvents = events.filter((e) => e.event === "first_merged_pr");
310
+
311
+ const durations = mergedPrEvents
312
+ .filter((e) => typeof e.duration_ms === "number" && Number.isFinite(e.duration_ms))
313
+ .map((e) => e.duration_ms)
314
+ .sort((a, b) => a - b);
315
+
316
+ const cohorts = { dogfood: 0, external: 0 };
317
+ for (const e of mergedPrEvents) {
318
+ if (e.cohort === "external") cohorts.external++;
319
+ else cohorts.dogfood++;
320
+ }
321
+
322
+ return {
323
+ count: durations.length,
324
+ p50_ms: percentile(durations, 0.5),
325
+ p90_ms: percentile(durations, 0.9),
326
+ cohorts,
327
+ };
328
+ }
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // Display helpers
332
+ // ---------------------------------------------------------------------------
333
+
334
+ function formatDurationMs(ms) {
335
+ if (ms == null) return "?";
336
+ if (ms < 1000) return `${ms}ms`;
337
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
338
+ return `${(ms / 60000).toFixed(1)}m`;
339
+ }
340
+
341
+ function printActivationStats(target) {
342
+ const stats = getActivationDurationStats(target);
343
+ if (stats.count > 0) {
344
+ console.log(
345
+ ` activation TTFV: p50 ${formatDurationMs(stats.p50_ms)}`
346
+ + ` / p90 ${formatDurationMs(stats.p90_ms)}`
347
+ + ` (${stats.count} sample${stats.count === 1 ? "" : "s"})`,
348
+ );
349
+ }
350
+
351
+ const mrStats = getActivationMergedPrStats(target);
352
+ if (mrStats.count > 0) {
353
+ const cohortNote = mrStats.cohorts.external > 0
354
+ ? ` [dogfood: ${mrStats.cohorts.dogfood}, external: ${mrStats.cohorts.external}]`
355
+ : ` [dogfood only — no external activations yet]`;
356
+ console.log(
357
+ ` activation first-PR: p50 ${formatDurationMs(mrStats.p50_ms)}`
358
+ + ` / p90 ${formatDurationMs(mrStats.p90_ms)}`
359
+ + ` (${mrStats.count} sample${mrStats.count === 1 ? "" : "s"})${cohortNote}`,
360
+ );
361
+ }
362
+
363
+ return { ttfv: stats, firstPr: mrStats };
364
+ }
365
+
366
+ module.exports = {
367
+ activationPath,
368
+ readEvents,
369
+ detectCohort,
370
+ detectFirstMergedPrTs,
371
+ recordActivationInit,
372
+ recordActivationFirstTask,
373
+ recordActivationFirstMergedPR,
374
+ getActivationDurationStats,
375
+ getActivationMergedPrStats,
376
+ printActivationStats,
377
+ formatDurationMs,
378
+ };
@@ -46,6 +46,13 @@ const SUPPORTED_CLIS = [
46
46
  altAuth: null,
47
47
  agentFiles: [".qoder/settings.json"],
48
48
  },
49
+ {
50
+ name: "cursor", bin: "cursor-agent",
51
+ pkg: null, pkgType: "curl",
52
+ install: "curl https://cursor.com/install -fsS | bash",
53
+ altAuth: "Cursor Pro/Business subscription",
54
+ agentFiles: [".cursor/mcp.json", ".cursor/hooks.json"],
55
+ },
49
56
  ];
50
57
 
51
58
  const MANIFEST_FILES = [
@@ -0,0 +1,285 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) 2026 0dai.dev
3
+ //
4
+ // F14 G4 Phase 2 — tarball builder for `0dai export --all`.
5
+ //
6
+ // Honors the layout pinned in docs/governance/data-export-contract.md
7
+ // (F14 G4 Phase 1 / PR #3569). Phase 2 ships personas +
8
+ // path-protect.yaml as real data, usage-ledger.jsonl when present,
9
+ // and 6 placeholders for the rest.
10
+ //
11
+ // Uses `tar` shell command (always present on Linux/macOS) rather
12
+ // than the `tar` npm module to avoid a new runtime dep.
13
+
14
+ "use strict";
15
+
16
+ const crypto = require("crypto");
17
+ const fs = require("fs");
18
+ const os = require("os");
19
+ const path = require("path");
20
+ const { spawnSync } = require("child_process");
21
+
22
+ const PHASE = "F14 G4 Phase 2";
23
+ const SCHEMA_VERSION = "1";
24
+
25
+ const PLACEHOLDER_SURFACES = [
26
+ // (surface name, tarball relative path)
27
+ ["knowledge-graph", "knowledge-graph/entries.jsonl"],
28
+ ["audit-log", "audit-log/events.jsonl"],
29
+ ["team-config", "team/config.yaml"],
30
+ ["repo-links", "repo-links.json"],
31
+ ["webhooks", "webhooks.yaml"],
32
+ ["feature-flags", "feature-flags.yaml"],
33
+ ];
34
+
35
+ function sha256OfFile(filePath) {
36
+ const hash = crypto.createHash("sha256");
37
+ const fd = fs.openSync(filePath, "r");
38
+ const buf = Buffer.allocUnsafe(1024 * 1024);
39
+ try {
40
+ let n = 0;
41
+ do {
42
+ n = fs.readSync(fd, buf, 0, buf.length, null);
43
+ if (n > 0) hash.update(buf.subarray(0, n));
44
+ } while (n > 0);
45
+ } finally {
46
+ fs.closeSync(fd);
47
+ }
48
+ return hash.digest("hex");
49
+ }
50
+
51
+ function sha256OfBytes(bytes) {
52
+ return crypto.createHash("sha256").update(bytes).digest("hex");
53
+ }
54
+
55
+ function listPersonas(sourceRoot) {
56
+ const personasDir = path.join(sourceRoot, "ai", "personas");
57
+ if (!fs.existsSync(personasDir)) return [];
58
+ return fs.readdirSync(personasDir)
59
+ .filter((name) => name.endsWith(".yaml") || name.endsWith(".yml"))
60
+ .map((name) => ({
61
+ name,
62
+ sourcePath: path.join(personasDir, name),
63
+ }));
64
+ }
65
+
66
+ function pathProtectPath(sourceRoot) {
67
+ const p = path.join(sourceRoot, "ai", "policy", "path-protect.yaml");
68
+ return fs.existsSync(p) ? p : null;
69
+ }
70
+
71
+ function usageLedgerPath(sourceRoot) {
72
+ const p = path.join(sourceRoot, "ai", "telemetry", "usage-ledger.jsonl");
73
+ return fs.existsSync(p) ? p : null;
74
+ }
75
+
76
+ function readme(tenantId) {
77
+ return `# 0dai export bundle
78
+
79
+ Generated by \`0dai export --all\` (F14 G4 Phase 2).
80
+
81
+ Verify per docs/governance/data-export-contract.md.
82
+
83
+ Tenant: ${tenantId}
84
+ Schema version: ${SCHEMA_VERSION}
85
+ `;
86
+ }
87
+
88
+ function placeholderPayload(surface) {
89
+ return JSON.stringify({
90
+ _status: "not-implemented",
91
+ _surface: surface,
92
+ _since: PHASE,
93
+ _note: "Phase 3 will populate this surface; today it ships as a placeholder so re-import tooling can validate the layout.",
94
+ }, null, 2) + "\n";
95
+ }
96
+
97
+ async function buildExportTarball({ sourceRoot, outputPath }) {
98
+ if (!sourceRoot || !fs.existsSync(sourceRoot)) {
99
+ throw new Error(`source root not found: ${sourceRoot}`);
100
+ }
101
+ const outDir = path.dirname(path.resolve(outputPath));
102
+ if (!fs.existsSync(outDir)) {
103
+ throw new Error(`output dir does not exist: ${outDir}`);
104
+ }
105
+
106
+ const stagingRoot = fs.mkdtempSync(path.join(os.tmpdir(), "0dai-export-"));
107
+ try {
108
+ // README.md
109
+ const tenantId = path.basename(sourceRoot);
110
+ const readmeBytes = Buffer.from(readme(tenantId), "utf8");
111
+ fs.writeFileSync(path.join(stagingRoot, "README.md"), readmeBytes);
112
+
113
+ // tenant.json (stub — Phase 3 populates from API)
114
+ const tenantBytes = Buffer.from(JSON.stringify({
115
+ _status: "stub",
116
+ _since: PHASE,
117
+ tenant_id: tenantId,
118
+ exported_at: new Date().toISOString(),
119
+ }, null, 2) + "\n", "utf8");
120
+ fs.writeFileSync(path.join(stagingRoot, "tenant.json"), tenantBytes);
121
+
122
+ // personas/<name>.yaml (real data)
123
+ const personasOutDir = path.join(stagingRoot, "personas");
124
+ fs.mkdirSync(personasOutDir, { recursive: true });
125
+ const personas = listPersonas(sourceRoot);
126
+ const personaManifest = [];
127
+ for (const p of personas) {
128
+ const dest = path.join(personasOutDir, p.name);
129
+ fs.copyFileSync(p.sourcePath, dest);
130
+ personaManifest.push({
131
+ path: `personas/${p.name}`,
132
+ bytes: fs.statSync(dest).size,
133
+ sha256: sha256OfFile(dest),
134
+ });
135
+ }
136
+
137
+ // path-protect.yaml (real data, if present)
138
+ const pp = pathProtectPath(sourceRoot);
139
+ let pathProtectManifest = null;
140
+ if (pp) {
141
+ const dest = path.join(stagingRoot, "path-protect.yaml");
142
+ fs.copyFileSync(pp, dest);
143
+ pathProtectManifest = {
144
+ path: "path-protect.yaml",
145
+ bytes: fs.statSync(dest).size,
146
+ sha256: sha256OfFile(dest),
147
+ };
148
+ }
149
+
150
+ // usage/history.jsonl (real data from ai/telemetry/usage-ledger.jsonl, if present)
151
+ // Schema is the LedgerEntry dataclass in scripts/usage_ledger.py (~line 92):
152
+ // ts, task_id, agent, model, model_tier, tokens_input/output/total, cost_usd, plan, ...
153
+ const ul = usageLedgerPath(sourceRoot);
154
+ let usageLedgerManifest = null;
155
+ if (ul) {
156
+ const usageOutDir = path.join(stagingRoot, "usage");
157
+ fs.mkdirSync(usageOutDir, { recursive: true });
158
+ const dest = path.join(usageOutDir, "history.jsonl");
159
+ fs.copyFileSync(ul, dest);
160
+ usageLedgerManifest = {
161
+ path: "usage/history.jsonl",
162
+ bytes: fs.statSync(dest).size,
163
+ sha256: sha256OfFile(dest),
164
+ };
165
+ }
166
+
167
+ // 6 placeholder surfaces
168
+ const placeholderManifest = [];
169
+ for (const [surface, relPath] of PLACEHOLDER_SURFACES) {
170
+ const dest = path.join(stagingRoot, relPath);
171
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
172
+ const payload = placeholderPayload(surface);
173
+ fs.writeFileSync(dest, payload, "utf8");
174
+ placeholderManifest.push({
175
+ path: relPath,
176
+ bytes: Buffer.byteLength(payload, "utf8"),
177
+ sha256: sha256OfBytes(Buffer.from(payload, "utf8")),
178
+ status: "not-implemented",
179
+ });
180
+ }
181
+
182
+ // MANIFEST.json (must come last so other files' checksums are settled)
183
+ const manifest = {
184
+ schema_version: SCHEMA_VERSION,
185
+ generated_at: new Date().toISOString(),
186
+ since: PHASE,
187
+ tenant_id: tenantId,
188
+ files: [
189
+ { path: "README.md", bytes: readmeBytes.length, sha256: sha256OfBytes(readmeBytes) },
190
+ { path: "tenant.json", bytes: tenantBytes.length, sha256: sha256OfBytes(tenantBytes) },
191
+ ...personaManifest,
192
+ ...(pathProtectManifest ? [pathProtectManifest] : []),
193
+ ...(usageLedgerManifest ? [usageLedgerManifest] : []),
194
+ ...placeholderManifest,
195
+ ],
196
+ };
197
+ fs.writeFileSync(
198
+ path.join(stagingRoot, "MANIFEST.json"),
199
+ JSON.stringify(manifest, null, 2) + "\n",
200
+ "utf8",
201
+ );
202
+
203
+ // Create gzipped tarball.
204
+ const r = spawnSync("tar", ["-czf", path.resolve(outputPath), "-C", stagingRoot, "."]);
205
+ if (r.status !== 0) {
206
+ const stderr = r.stderr ? r.stderr.toString("utf8") : "(no stderr)";
207
+ throw new Error(`tar exited ${r.status}: ${stderr}`);
208
+ }
209
+
210
+ // F14 G4 Phase 3 — try cosign keyless signing. Falls back to
211
+ // empty .sig + .crt stubs (preserving the layout contract)
212
+ // when cosign is absent or the operator opts out.
213
+ const sigPath = `${outputPath}.sig`;
214
+ const crtPath = `${outputPath}.crt`;
215
+ const signResult = trySignTarball({
216
+ tarballPath: path.resolve(outputPath),
217
+ sigPath,
218
+ crtPath,
219
+ });
220
+
221
+ return {
222
+ tarballPath: path.resolve(outputPath),
223
+ sigPath,
224
+ crtPath,
225
+ sha256: sha256OfFile(outputPath),
226
+ signed: signResult.signed,
227
+ signSkipReason: signResult.skipReason,
228
+ counts: {
229
+ personas: personaManifest.length,
230
+ pathProtect: pathProtectManifest ? 1 : 0,
231
+ usageLedger: usageLedgerManifest ? 1 : 0,
232
+ placeholders: placeholderManifest.length,
233
+ },
234
+ };
235
+ } finally {
236
+ // Best-effort cleanup; ignore errors.
237
+ try { fs.rmSync(stagingRoot, { recursive: true, force: true }); } catch { /* ignore */ }
238
+ }
239
+ }
240
+
241
+ function trySignTarball({ tarballPath, sigPath, crtPath }) {
242
+ // F14 G4 Phase 3. Trust anchor matches the F16 G2 release-signing
243
+ // pipeline (release-artifact-signing.yml from PR #3521).
244
+ //
245
+ // Skipped when:
246
+ // - ODAI_EXPORT_SKIP_SIGN=1 is set (explicit opt-out)
247
+ // - cosign binary is not on PATH (local dev without sigstore)
248
+ // - cosign exits non-zero (Fulcio OIDC unavailable, etc.)
249
+ // In every skip case we write empty .sig + .crt stubs so the
250
+ // tarball layout contract from F14 G4 Phase 1 (#3569) holds.
251
+ if (process.env.ODAI_EXPORT_SKIP_SIGN === "1") {
252
+ fs.writeFileSync(sigPath, "");
253
+ fs.writeFileSync(crtPath, "");
254
+ return { signed: false, skipReason: "ODAI_EXPORT_SKIP_SIGN=1" };
255
+ }
256
+ // Probe for cosign on PATH without bombing the export when absent.
257
+ const probe = spawnSync("cosign", ["version"], { stdio: ["ignore", "pipe", "pipe"] });
258
+ if (probe.error || probe.status !== 0) {
259
+ fs.writeFileSync(sigPath, "");
260
+ fs.writeFileSync(crtPath, "");
261
+ return { signed: false, skipReason: "cosign not on PATH" };
262
+ }
263
+ const env = { ...process.env, COSIGN_EXPERIMENTAL: process.env.COSIGN_EXPERIMENTAL || "1" };
264
+ const r = spawnSync(
265
+ "cosign",
266
+ [
267
+ "sign-blob",
268
+ "--yes",
269
+ "--output-signature", sigPath,
270
+ "--output-certificate", crtPath,
271
+ tarballPath,
272
+ ],
273
+ { env, stdio: ["ignore", "pipe", "pipe"] },
274
+ );
275
+ if (r.status !== 0) {
276
+ // Leave (or write) empty stubs so the layout still holds.
277
+ try { fs.writeFileSync(sigPath, ""); } catch { /* ignore */ }
278
+ try { fs.writeFileSync(crtPath, ""); } catch { /* ignore */ }
279
+ const stderr = r.stderr ? r.stderr.toString("utf8").trim().slice(0, 200) : `exit ${r.status}`;
280
+ return { signed: false, skipReason: `cosign sign-blob failed: ${stderr}` };
281
+ }
282
+ return { signed: true, skipReason: null };
283
+ }
284
+
285
+ module.exports = { buildExportTarball, listPersonas, pathProtectPath, usageLedgerPath, trySignTarball, PLACEHOLDER_SURFACES, PHASE, SCHEMA_VERSION };
@@ -148,6 +148,19 @@ function detectStackHint(projectFiles, manifestContents) {
148
148
  return "unknown";
149
149
  }
150
150
 
151
+ /**
152
+ * Return a {filename: sha256hex} map for the given manifest contents.
153
+ * The raw content never leaves the machine — only the hash is exported.
154
+ */
155
+ function hashManifestFiles(manifestContents) {
156
+ const crypto = require("crypto");
157
+ const hashes = {};
158
+ for (const [name, content] of Object.entries(manifestContents)) {
159
+ hashes[name] = crypto.createHash("sha256").update(content, "utf8").digest("hex");
160
+ }
161
+ return hashes;
162
+ }
163
+
151
164
  function collectMetadata(target) {
152
165
  const projectFiles = [];
153
166
  const manifestContents = {};
@@ -201,5 +214,5 @@ module.exports = {
201
214
  deviceFingerprint, registerProject, projectIdFor, projectIdSeed,
202
215
  scrubRemoteUrl, readProjectManifest, readDiscovery,
203
216
  getGitRemoteOrigin, inferProjectName, detectStackHint,
204
- collectMetadata, buildProjectIdentity,
217
+ collectMetadata, buildProjectIdentity, hashManifestFiles,
205
218
  };