@0dai-dev/cli 4.3.5 → 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.
- package/bin/0dai.js +87 -10
- package/lib/commands/auth.js +53 -0
- package/lib/commands/detect.js +10 -4
- package/lib/commands/doctor.js +42 -17
- package/lib/commands/export.js +73 -0
- package/lib/commands/init.js +14 -4
- package/lib/commands/mcp.js +33 -3
- package/lib/commands/run.js +4 -1
- package/lib/commands/status.js +6 -1
- package/lib/commands/trust.js +286 -0
- package/lib/commands/vault.js +3 -1
- package/lib/shared.js +2 -2
- package/lib/utils/activation_telemetry.js +233 -11
- package/lib/utils/export-bundler.js +285 -0
- package/lib/utils/identity.js +14 -1
- package/package.json +2 -2
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// 0dai trust — pre-run blast-radius disclosure (F19 G3 / issue #3919).
|
|
3
|
+
//
|
|
4
|
+
// Renders, before any agent run, a human-readable summary of:
|
|
5
|
+
// (a) which paths agents may write vs which are protected
|
|
6
|
+
// (b) the authority matrix in force (AUTO / PICKER / FORBIDDEN per action x CLI)
|
|
7
|
+
// (c) what data leaves the repo
|
|
8
|
+
//
|
|
9
|
+
// WARM PATH (trust_scope.py found): delegates entirely to trust_scope.py, which
|
|
10
|
+
// imports the SAME config the guards enforce. No invented data.
|
|
11
|
+
//
|
|
12
|
+
// COLD PATH (trust_scope.py absent, i.e. pre-init repo): graceful degrade —
|
|
13
|
+
// prints a clearly-labeled static DEFAULT scope summary and exits 0 (issue #4007).
|
|
14
|
+
// This avoids the dead-end exit(1) that blocked maintainers evaluating blast-radius
|
|
15
|
+
// before running `0dai init`.
|
|
16
|
+
//
|
|
17
|
+
// Usage:
|
|
18
|
+
// 0dai trust [--json] [--target PATH]
|
|
19
|
+
|
|
20
|
+
const shared = require("../shared");
|
|
21
|
+
const { log, findRepoScript, spawnSync } = shared;
|
|
22
|
+
|
|
23
|
+
// ── COLD-PATH: static default-scope payload ───────────────────────────────────
|
|
24
|
+
//
|
|
25
|
+
// Emitted when trust_scope.py is unavailable (pre-init / fresh npm install).
|
|
26
|
+
//
|
|
27
|
+
// Honesty contract — mirrors trust_scope.py's section labels:
|
|
28
|
+
// [ENFORCED] = gated right now, regardless of init state
|
|
29
|
+
// [DECLARED] = policy exists but gate materialises only after `0dai init`
|
|
30
|
+
// [DEFAULT] = structural invariant described in docs, not a config file
|
|
31
|
+
//
|
|
32
|
+
// Why we are categorical rather than listing specific globs here:
|
|
33
|
+
// policy_enforcer.py (edit-time gate source) is NOT shipped in the npm bundle
|
|
34
|
+
// (package.json "files" includes only bin/, lib/, and scripts/ where scripts/
|
|
35
|
+
// carries only postinstall.js and build-tui.js — not policy_enforcer.py).
|
|
36
|
+
// Fabricating globs that might diverge from the enforced list would violate the
|
|
37
|
+
// honesty contract. Run `0dai trust` after `0dai init` for the exact enforced list.
|
|
38
|
+
//
|
|
39
|
+
// Egress facts are derived from shared.js code (checkVersion fires unconditionally)
|
|
40
|
+
// so they are the strongest honest cold signal we can disclose.
|
|
41
|
+
|
|
42
|
+
function _buildColdPayload(target) {
|
|
43
|
+
return {
|
|
44
|
+
trust_scope_available: false,
|
|
45
|
+
scope: "default-pre-init",
|
|
46
|
+
note:
|
|
47
|
+
"trust_scope.py not found — this repo has not run `0dai init` yet. " +
|
|
48
|
+
"Showing DEFAULT scope: what 0dai does BEFORE repo-specific config exists. " +
|
|
49
|
+
"This is NOT the enforced scope. Run `0dai init` then `0dai trust` again.",
|
|
50
|
+
docs: "https://0dai.dev/docs",
|
|
51
|
+
target,
|
|
52
|
+
generated_at: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
|
|
53
|
+
|
|
54
|
+
// ── A. Protected paths ────────────────────────────────────────────────
|
|
55
|
+
// Edit-time patterns come from policy_enforcer.py (not in npm bundle);
|
|
56
|
+
// merge-time patterns from ai/policy/path-protect.yaml (absent pre-init).
|
|
57
|
+
// Neither can be listed verbatim. Describe the categories honestly.
|
|
58
|
+
//
|
|
59
|
+
// NOTE: ai/policy/** and ai/contracts/** are MERGE-TIME protected (by
|
|
60
|
+
// path-protect.yaml / 0dai-merge), NOT edit-time (not in policy_enforcer.py
|
|
61
|
+
// PROTECTED_WRITE_PATTERNS). Do not conflate the two gates.
|
|
62
|
+
protected_paths: {
|
|
63
|
+
edit_time: {
|
|
64
|
+
status: "DEFAULT",
|
|
65
|
+
note:
|
|
66
|
+
"Edit-time gate is enforced by scripts/policy_enforcer.py via the 0dai MCP server. " +
|
|
67
|
+
"That file is not bundled in the npm package, so exact globs cannot be disclosed " +
|
|
68
|
+
"without a repo checkout. The categories below are indicative examples from the " +
|
|
69
|
+
"published source; run `0dai trust` after `0dai init` for the authoritative list.",
|
|
70
|
+
categories_indicative: [
|
|
71
|
+
"credential / secret files (.env, .env.*, secrets/**, infra/secrets/**)",
|
|
72
|
+
"AI runtime state (ai/meta/**, ai/work/**, ai/sessions/**, ai/feedback/**)",
|
|
73
|
+
"AI memory file (ai/memory/memory.jsonl)",
|
|
74
|
+
"Applied lock (ai/manifest/applied-lock.json)",
|
|
75
|
+
"Terraform state (terraform.tfstate*)",
|
|
76
|
+
"Legacy memory bank (memory-bank/**)",
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
merge_time: {
|
|
80
|
+
status: "DECLARED",
|
|
81
|
+
note:
|
|
82
|
+
"Merge-time gate reads ai/policy/path-protect.yaml (enforced by scripts/0dai-merge). " +
|
|
83
|
+
"Covers governance files including ai/policy/**, ai/contracts/**, and repo-specific " +
|
|
84
|
+
"protected paths declared by the maintainer. " +
|
|
85
|
+
"This file is generated by `0dai init`. Not present in a pre-init repo.",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
// ── B. Agent authority matrix ─────────────────────────────────────────
|
|
90
|
+
// The per-action grant table (AUTO / PICKER / FORBIDDEN per action-id x CLI)
|
|
91
|
+
// materialises from ai/contracts/agent-authority.yaml after `0dai init`.
|
|
92
|
+
// That file is NOT in the npm bundle and cannot be verified at cold runtime,
|
|
93
|
+
// so we describe the structure categorically — not a fabricated action table.
|
|
94
|
+
authority: {
|
|
95
|
+
status: "DEFAULT",
|
|
96
|
+
note:
|
|
97
|
+
"Per-action grant table (AUTO / PICKER / FORBIDDEN) materialises from " +
|
|
98
|
+
"ai/contracts/agent-authority.yaml after `0dai init`. " +
|
|
99
|
+
"Dispatch-gate enforcement (ODAI_AUTONOMY_MATRIX_GATE=1) is opt-in, not default-on. " +
|
|
100
|
+
"Before init, no repo-specific grants are enforced.",
|
|
101
|
+
structural_description: {
|
|
102
|
+
"AUTO": "agent may proceed unattended (e.g. issue create, read, CI runs)",
|
|
103
|
+
"PICKER": "human must confirm before action executes (e.g. admin merges, deploys, protected writes)",
|
|
104
|
+
"FORBIDDEN": "action is never permitted without an explicit override (e.g. force-push to main, editing constitution)",
|
|
105
|
+
},
|
|
106
|
+
note_on_defaults:
|
|
107
|
+
"After `0dai init`, high-risk actions (admin merge, deploy, editing governance files) " +
|
|
108
|
+
"default to PICKER or FORBIDDEN. Low-risk read and CI actions default to AUTO. " +
|
|
109
|
+
"Run `0dai trust` post-init to see the exact per-agent x per-action table.",
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
// ── C. Egress / telemetry ─────────────────────────────────────────────
|
|
113
|
+
// Derived from shared.js (checkVersion, apiCall). These apply
|
|
114
|
+
// unconditionally on every CLI run, regardless of init state.
|
|
115
|
+
egress: {
|
|
116
|
+
status: "DEFAULT",
|
|
117
|
+
note:
|
|
118
|
+
"Derived from cli source (shared.js checkVersion + apiCall). " +
|
|
119
|
+
"Unconditional items fire on every CLI run, pre-init included.",
|
|
120
|
+
unconditional: [
|
|
121
|
+
{
|
|
122
|
+
endpoint: "GET /v1/version (api.0dai.dev)",
|
|
123
|
+
trigger:
|
|
124
|
+
"Every CLI run, throttled to at most once per " +
|
|
125
|
+
"ODAI_UPDATE_CHECK_INTERVAL (default 3600 s). Checks for CLI updates.",
|
|
126
|
+
headers_sent: [
|
|
127
|
+
"X-Device-ID — sha256 of stable local machine identifiers (no PII)",
|
|
128
|
+
"X-CLI-Version — installed CLI version string",
|
|
129
|
+
],
|
|
130
|
+
request_body: null,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
on_explicit_command_only: [
|
|
134
|
+
{
|
|
135
|
+
endpoint: "POST /v1/graph/push",
|
|
136
|
+
trigger: "0dai graph push",
|
|
137
|
+
data: "graph-eligible artifact classes (no credentials, no file contents)",
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
endpoint: "POST /v1/report",
|
|
141
|
+
trigger: "0dai report push",
|
|
142
|
+
data: "cloud-sync-allowed artifact classes",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
endpoint: "POST /v1/feedback",
|
|
146
|
+
trigger: "0dai feedback push",
|
|
147
|
+
data: "user_feedback_payload",
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
endpoint: "POST /v1/licenses/activate",
|
|
151
|
+
trigger: "0dai activate",
|
|
152
|
+
data: "device_fingerprint + license_code",
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Return the static default-scope output for a cold (pre-init) repo.
|
|
161
|
+
*
|
|
162
|
+
* Exported for direct unit-testing — avoids fighting findRepoScript's
|
|
163
|
+
* __dirname-anchored candidate resolution in subprocess tests.
|
|
164
|
+
*
|
|
165
|
+
* @param {string} target - Repo path shown in output (informational; not read from disk).
|
|
166
|
+
* @param {boolean} asJson - Emit JSON string instead of human-readable text.
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
function renderColdScope(target, asJson) {
|
|
170
|
+
const payload = _buildColdPayload(target);
|
|
171
|
+
|
|
172
|
+
if (asJson) {
|
|
173
|
+
return JSON.stringify(payload, null, 2);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const HR = "─".repeat(64);
|
|
177
|
+
const HR2 = "═".repeat(64);
|
|
178
|
+
const lines = [];
|
|
179
|
+
|
|
180
|
+
lines.push("");
|
|
181
|
+
lines.push(HR2);
|
|
182
|
+
lines.push(" 0dai trust — pre-run blast-radius disclosure [DEFAULT (pre-init) scope]");
|
|
183
|
+
lines.push(HR2);
|
|
184
|
+
lines.push(` target: ${payload.target}`);
|
|
185
|
+
lines.push(` generated: ${payload.generated_at}`);
|
|
186
|
+
lines.push("");
|
|
187
|
+
lines.push(" NOTE trust_scope.py not found — this repo has not run `0dai init` yet.");
|
|
188
|
+
lines.push(" This output shows DEFAULT scope: what 0dai does BEFORE any");
|
|
189
|
+
lines.push(" repo-specific config exists. It is NOT the enforced scope.");
|
|
190
|
+
lines.push(" Run `0dai init`, then re-run `0dai trust` for the real scope.");
|
|
191
|
+
lines.push("");
|
|
192
|
+
lines.push(" Key: [ENFORCED] = gated now [DECLARED] = post-init only");
|
|
193
|
+
lines.push(" [DEFAULT] = structural invariant, not a config file");
|
|
194
|
+
lines.push("");
|
|
195
|
+
|
|
196
|
+
// ── A. Protected paths ────────────────────────────────────────────────────
|
|
197
|
+
lines.push(" A. Protected paths");
|
|
198
|
+
lines.push(" " + HR.slice(0, 60));
|
|
199
|
+
|
|
200
|
+
const et = payload.protected_paths.edit_time;
|
|
201
|
+
lines.push(` [${et.status}] Edit-time gate`);
|
|
202
|
+
lines.push(` ${et.note}`);
|
|
203
|
+
lines.push(" Indicative categories (run `0dai trust` post-init for the authoritative list):");
|
|
204
|
+
for (const cat of et.categories_indicative) {
|
|
205
|
+
lines.push(` - ${cat}`);
|
|
206
|
+
}
|
|
207
|
+
lines.push("");
|
|
208
|
+
|
|
209
|
+
const mt = payload.protected_paths.merge_time;
|
|
210
|
+
lines.push(` [${mt.status}] Merge-time gate`);
|
|
211
|
+
lines.push(` ${mt.note}`);
|
|
212
|
+
lines.push("");
|
|
213
|
+
|
|
214
|
+
// ── B. Agent authority matrix ─────────────────────────────────────────────
|
|
215
|
+
lines.push(" B. Agent authority matrix");
|
|
216
|
+
lines.push(" " + HR.slice(0, 60));
|
|
217
|
+
|
|
218
|
+
const auth = payload.authority;
|
|
219
|
+
lines.push(` [${auth.status}] ${auth.note}`);
|
|
220
|
+
lines.push("");
|
|
221
|
+
lines.push(" Decision levels:");
|
|
222
|
+
for (const [level, desc] of Object.entries(auth.structural_description)) {
|
|
223
|
+
lines.push(` ${level.padEnd(12)} ${desc}`);
|
|
224
|
+
}
|
|
225
|
+
lines.push("");
|
|
226
|
+
lines.push(` ${auth.note_on_defaults}`);
|
|
227
|
+
lines.push("");
|
|
228
|
+
|
|
229
|
+
// ── C. Data that leaves the repo ──────────────────────────────────────────
|
|
230
|
+
lines.push(" C. Data that leaves the repo");
|
|
231
|
+
lines.push(" " + HR.slice(0, 60));
|
|
232
|
+
|
|
233
|
+
const eg = payload.egress;
|
|
234
|
+
lines.push(` [${eg.status}] ${eg.note}`);
|
|
235
|
+
lines.push("");
|
|
236
|
+
lines.push(" Unconditional (every CLI run, pre-init included):");
|
|
237
|
+
for (const ep of eg.unconditional) {
|
|
238
|
+
lines.push(` * ${ep.endpoint}`);
|
|
239
|
+
lines.push(` trigger: ${ep.trigger}`);
|
|
240
|
+
for (const h of ep.headers_sent) {
|
|
241
|
+
lines.push(` header: ${h}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
lines.push("");
|
|
245
|
+
lines.push(" On explicit command only (not triggered by `0dai trust` itself):");
|
|
246
|
+
for (const ep of eg.on_explicit_command_only) {
|
|
247
|
+
lines.push(` * ${ep.endpoint}`);
|
|
248
|
+
lines.push(` trigger: ${ep.trigger}`);
|
|
249
|
+
lines.push(` data: ${ep.data}`);
|
|
250
|
+
}
|
|
251
|
+
lines.push("");
|
|
252
|
+
|
|
253
|
+
lines.push(HR);
|
|
254
|
+
lines.push(" Next steps to get the full enforced scope:");
|
|
255
|
+
lines.push(" 1. Run `0dai init` to generate ai/ config.");
|
|
256
|
+
lines.push(" 2. Run `0dai trust` again to see the repo-specific enforced scope.");
|
|
257
|
+
lines.push(` Docs: ${payload.docs}`);
|
|
258
|
+
lines.push(HR);
|
|
259
|
+
lines.push("");
|
|
260
|
+
|
|
261
|
+
return lines.join("\n");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function cmdTrust(target, args) {
|
|
265
|
+
const scriptPath = findRepoScript(target, "trust_scope.py");
|
|
266
|
+
|
|
267
|
+
// COLD PATH — trust_scope.py not found (pre-init / cold npm install).
|
|
268
|
+
// Graceful degrade: print static DEFAULT scope summary and exit 0.
|
|
269
|
+
// Clearly labeled as "DEFAULT (pre-init) scope" — cannot be mistaken for
|
|
270
|
+
// the repo-specific enforced scope rendered by trust_scope.py (WARM path).
|
|
271
|
+
if (!scriptPath) {
|
|
272
|
+
const asJson = !!(args && args.includes("--json"));
|
|
273
|
+
console.log(renderColdScope(target, asJson));
|
|
274
|
+
process.exit(0);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// WARM PATH — delegate to trust_scope.py (behaviour unchanged).
|
|
278
|
+
const forwarded = [scriptPath, "--target", target];
|
|
279
|
+
if (args && args.includes("--json")) forwarded.push("--json");
|
|
280
|
+
|
|
281
|
+
const result = spawnSync("python3", forwarded, { stdio: "inherit" });
|
|
282
|
+
if (typeof result.status === "number") process.exit(result.status);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
module.exports = { cmdTrust, renderColdScope };
|
package/lib/commands/vault.js
CHANGED
|
@@ -209,7 +209,9 @@ function cmdVaultDeferred(sub, args) {
|
|
|
209
209
|
|
|
210
210
|
function cmdVault(_target, sub, args) {
|
|
211
211
|
const command = sub && !sub.startsWith("-") ? sub : "";
|
|
212
|
-
|
|
212
|
+
// args is already args.slice(2) from bin/0dai.js (the tail after "vault <sub>"),
|
|
213
|
+
// so forwarded is the remaining flags/positionals unchanged.
|
|
214
|
+
const forwarded = command ? args : args.slice(1);
|
|
213
215
|
|
|
214
216
|
if (!command || command === "help" || command === "-h" || command === "--help") {
|
|
215
217
|
printUsage();
|
package/lib/shared.js
CHANGED
|
@@ -21,7 +21,7 @@ const { SUPPORTED_CLIS, MANIFEST_FILES, PROBE_DIRS, SETTINGS_PRESERVE_FIELDS } =
|
|
|
21
21
|
const {
|
|
22
22
|
deviceFingerprint, registerProject, projectIdFor,
|
|
23
23
|
getGitRemoteOrigin, inferProjectName, detectStackHint,
|
|
24
|
-
collectMetadata, buildProjectIdentity,
|
|
24
|
+
collectMetadata, buildProjectIdentity, hashManifestFiles,
|
|
25
25
|
} = require("./utils/identity");
|
|
26
26
|
const { PLAN_LEVELS, _detectPlanLocal, requirePlan, getSwarmQuotaLocal } = require("./utils/plan");
|
|
27
27
|
|
|
@@ -403,7 +403,7 @@ module.exports = {
|
|
|
403
403
|
// Identity
|
|
404
404
|
deviceFingerprint, registerProject, projectIdFor,
|
|
405
405
|
getGitRemoteOrigin, inferProjectName, detectStackHint,
|
|
406
|
-
collectMetadata, buildProjectIdentity,
|
|
406
|
+
collectMetadata, buildProjectIdentity, hashManifestFiles,
|
|
407
407
|
// Plan / Tier
|
|
408
408
|
_detectPlanLocal, requirePlan, getSwarmQuotaLocal,
|
|
409
409
|
// Project
|
|
@@ -3,6 +3,94 @@
|
|
|
3
3
|
const fs = require("fs");
|
|
4
4
|
const path = require("path");
|
|
5
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
|
+
|
|
6
94
|
function activationPath(target) {
|
|
7
95
|
return path.join(target, "ai", "meta", "telemetry", "activation.jsonl");
|
|
8
96
|
}
|
|
@@ -54,13 +142,26 @@ function parseTs(ts) {
|
|
|
54
142
|
return Number.isFinite(ms) ? ms : null;
|
|
55
143
|
}
|
|
56
144
|
|
|
57
|
-
//
|
|
58
|
-
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Recorders
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
// Idempotently append init telemetry on successful init.
|
|
150
|
+
function recordActivationInit(target, projectId, metadata = {}) {
|
|
59
151
|
try {
|
|
60
152
|
if (!projectId) return { fired: false };
|
|
61
153
|
const events = readEvents(target);
|
|
62
154
|
if (hasEvent(events, "init", projectId)) return { fired: false };
|
|
63
|
-
const
|
|
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
|
+
};
|
|
64
165
|
return { fired: appendEvent(target, entry) };
|
|
65
166
|
} catch {
|
|
66
167
|
return { fired: false };
|
|
@@ -68,7 +169,7 @@ function recordActivationInit(target, projectId) {
|
|
|
68
169
|
}
|
|
69
170
|
|
|
70
171
|
// Idempotently append first_task with duration_ms = first_task.ts - init.ts.
|
|
71
|
-
function recordActivationFirstTask(target, projectId) {
|
|
172
|
+
function recordActivationFirstTask(target, projectId, metadata = {}) {
|
|
72
173
|
try {
|
|
73
174
|
if (!projectId) return { fired: false, durationMs: null };
|
|
74
175
|
const events = readEvents(target);
|
|
@@ -87,11 +188,75 @@ function recordActivationFirstTask(target, projectId) {
|
|
|
87
188
|
}
|
|
88
189
|
}
|
|
89
190
|
|
|
191
|
+
const cohort = detectCohort(target);
|
|
90
192
|
const entry = {
|
|
91
193
|
event: "first_task",
|
|
92
194
|
ts,
|
|
93
195
|
project_id: projectId,
|
|
94
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,
|
|
95
260
|
};
|
|
96
261
|
const fired = appendEvent(target, entry);
|
|
97
262
|
return { fired, durationMs };
|
|
@@ -100,6 +265,10 @@ function recordActivationFirstTask(target, projectId) {
|
|
|
100
265
|
}
|
|
101
266
|
}
|
|
102
267
|
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Stats
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
103
272
|
function percentile(sorted, p) {
|
|
104
273
|
if (!sorted.length) return null;
|
|
105
274
|
if (sorted.length === 1) return sorted[0];
|
|
@@ -127,6 +296,41 @@ function getActivationDurationStats(target) {
|
|
|
127
296
|
};
|
|
128
297
|
}
|
|
129
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
|
+
|
|
130
334
|
function formatDurationMs(ms) {
|
|
131
335
|
if (ms == null) return "?";
|
|
132
336
|
if (ms < 1000) return `${ms}ms`;
|
|
@@ -136,21 +340,39 @@ function formatDurationMs(ms) {
|
|
|
136
340
|
|
|
137
341
|
function printActivationStats(target) {
|
|
138
342
|
const stats = getActivationDurationStats(target);
|
|
139
|
-
if (stats.count
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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 };
|
|
146
364
|
}
|
|
147
365
|
|
|
148
366
|
module.exports = {
|
|
149
367
|
activationPath,
|
|
150
368
|
readEvents,
|
|
369
|
+
detectCohort,
|
|
370
|
+
detectFirstMergedPrTs,
|
|
151
371
|
recordActivationInit,
|
|
152
372
|
recordActivationFirstTask,
|
|
373
|
+
recordActivationFirstMergedPR,
|
|
153
374
|
getActivationDurationStats,
|
|
375
|
+
getActivationMergedPrStats,
|
|
154
376
|
printActivationStats,
|
|
155
377
|
formatDurationMs,
|
|
156
378
|
};
|