@0dai-dev/cli 4.2.0 → 4.3.4

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 (48) hide show
  1. package/README.md +30 -5
  2. package/bin/0dai.js +289 -60
  3. package/lib/commands/audit.js +13 -0
  4. package/lib/commands/auth.js +341 -98
  5. package/lib/commands/boneyard.js +44 -0
  6. package/lib/commands/ci.js +329 -0
  7. package/lib/commands/compliance.js +20 -0
  8. package/lib/commands/doctor.js +20 -1
  9. package/lib/commands/experience.js +5 -1
  10. package/lib/commands/feedback.js +92 -5
  11. package/lib/commands/gh.js +506 -0
  12. package/lib/commands/graph.js +78 -10
  13. package/lib/commands/heatmap.js +17 -0
  14. package/lib/commands/import_claude_code_agents.js +367 -0
  15. package/lib/commands/init.js +440 -28
  16. package/lib/commands/loop.js +108 -0
  17. package/lib/commands/mcp.js +410 -0
  18. package/lib/commands/models.js +27 -3
  19. package/lib/commands/paste.js +114 -0
  20. package/lib/commands/play.js +173 -0
  21. package/lib/commands/provider.js +69 -0
  22. package/lib/commands/quota.js +76 -0
  23. package/lib/commands/receipt.js +53 -0
  24. package/lib/commands/report.js +29 -2
  25. package/lib/commands/run.js +44 -4
  26. package/lib/commands/runner.js +527 -0
  27. package/lib/commands/session.js +1 -7
  28. package/lib/commands/standup.js +40 -0
  29. package/lib/commands/status.js +26 -1
  30. package/lib/commands/swarm.js +97 -4
  31. package/lib/commands/tui.js +81 -13
  32. package/lib/commands/usage.js +87 -0
  33. package/lib/commands/vault.js +246 -0
  34. package/lib/onboarding.js +9 -3
  35. package/lib/shared.js +29 -14
  36. package/lib/tui/index.mjs +571 -187
  37. package/lib/utils/auth.js +1 -0
  38. package/lib/utils/canonical-counts.js +54 -0
  39. package/lib/utils/diff-preview.js +192 -0
  40. package/lib/utils/identity.js +76 -18
  41. package/lib/utils/mcp-auth.js +607 -0
  42. package/lib/utils/plan.js +37 -2
  43. package/lib/vault/cipher.js +125 -0
  44. package/lib/vault/identity.js +122 -0
  45. package/lib/vault/index.js +184 -0
  46. package/lib/vault/storage.js +84 -0
  47. package/lib/wizard.js +19 -12
  48. package/package.json +2 -2
@@ -1,17 +1,147 @@
1
1
  "use strict";
2
+ const crypto = require("crypto");
2
3
  const shared = require("../shared");
3
4
  const {
4
- T, R, D, log,
5
+ T, R, D, W, log,
5
6
  fs, path,
6
7
  VERSION, SUPPORTED_CLIS,
8
+ CONFIG_DIR, PROJECTS_FILE,
7
9
  apiCall, makeEnsureAuthenticated, ensureLicenseActivation, loadAuthState,
8
10
  collectMetadata, buildProjectIdentity, registerProject,
9
11
  writeFiles, sendProjectHeartbeat, recordExperienceEvent,
10
12
  logFirstRunSuccess,
11
13
  } = shared;
12
- const { cmdAuthLogin } = require("./auth");
14
+ const { cmdAuthLogin, ensureAccountForActivation, parseActivationArgs } = require("./auth");
15
+ const { ensureGithubFlowPolicy, warnHooksPathDrift } = require("./gh");
16
+ const { renderFileMapDiff, confirmOrExit, shouldAutoYes } = require("../utils/diff-preview");
17
+ const { bootstrapMcp } = require("../utils/mcp-auth");
13
18
 
14
19
  const ensureAuthenticated = makeEnsureAuthenticated(cmdAuthLogin);
20
+ const SYNC_FULL_CONTENT_LIMIT = 10000;
21
+ const CLOUD_INIT_CHECKPOINT_REL = path.join(".0dai", "cloud-init-checkpoint.json");
22
+
23
+ function hashFile(filePath) {
24
+ const hash = crypto.createHash("sha256");
25
+ const fd = fs.openSync(filePath, "r");
26
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
27
+ try {
28
+ let bytesRead = 0;
29
+ do {
30
+ bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
31
+ if (bytesRead > 0) hash.update(buffer.subarray(0, bytesRead));
32
+ } while (bytesRead > 0);
33
+ } finally {
34
+ fs.closeSync(fd);
35
+ }
36
+ return hash.digest("hex");
37
+ }
38
+
39
+ function describeCurrentFile(filePath, stat) {
40
+ if (stat.size < SYNC_FULL_CONTENT_LIMIT) return fs.readFileSync(filePath, "utf8");
41
+ return {
42
+ size: stat.size,
43
+ sha256: hashFile(filePath),
44
+ compare: "hash-only",
45
+ };
46
+ }
47
+
48
+ // detectRegistryDrift — Phase 1.5 of #2237 registry self-heal.
49
+ // Returns { drifted: bool, existingPath?: string, currentPath: string } when an
50
+ // entry with the same `name` is already in ~/.0dai/projects.json but points at
51
+ // a different directory than the one we're about to register. Caller decides
52
+ // what to do (warn, prompt, or auto-overwrite based on flags).
53
+ function detectRegistryDrift(target, name) {
54
+ const currentPath = path.resolve(target);
55
+ try {
56
+ if (!fs.existsSync(PROJECTS_FILE)) return { drifted: false, currentPath };
57
+ const raw = fs.readFileSync(PROJECTS_FILE, "utf8");
58
+ const data = JSON.parse(raw);
59
+ const projects = Array.isArray(data && data.projects) ? data.projects : [];
60
+ for (const entry of projects) {
61
+ if (!entry || typeof entry !== "object") continue;
62
+ if (entry.name !== name) continue;
63
+ if (!entry.path || typeof entry.path !== "string") continue;
64
+ if (entry.path === currentPath) continue;
65
+ return { drifted: true, existingPath: entry.path, currentPath, archived: entry.archived === true };
66
+ }
67
+ } catch {}
68
+ return { drifted: false, currentPath };
69
+ }
70
+
71
+ // guardRegistryDrift — returns true when caller may proceed with registration,
72
+ // false if the operator declined the overwrite. Async to support interactive
73
+ // prompt; honours --non-interactive (and --yes / common CI flags) by
74
+ // auto-overwriting silently. ODAI_REGISTRY_DRIFT_OVERWRITE=1 also bypasses
75
+ // the prompt for scripted runs.
76
+ async function guardRegistryDrift(target, name, args) {
77
+ const drift = detectRegistryDrift(target, name);
78
+ if (!drift.drifted) return true;
79
+ // Archived entries are not a real conflict — registry_audit.py archives
80
+ // missing paths; let init silently overwrite them.
81
+ if (drift.archived) return true;
82
+
83
+ const nonInteractive =
84
+ args.includes("--non-interactive") ||
85
+ args.includes("--yes") ||
86
+ args.includes("-y") ||
87
+ !process.stdout.isTTY ||
88
+ process.env.ODAI_REGISTRY_DRIFT_OVERWRITE === "1";
89
+
90
+ log(`${W}registry drift: project '${name}' is registered at ${drift.existingPath}${R}`);
91
+ console.log(` current cwd: ${drift.currentPath}`);
92
+ if (nonInteractive) {
93
+ console.log(` ${D}auto-overwriting registry entry (non-interactive mode)${R}`);
94
+ return true;
95
+ }
96
+
97
+ try {
98
+ const readline = require("readline");
99
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
100
+ const answer = await new Promise((resolve) => {
101
+ rl.question(` Overwrite registry entry to point at ${drift.currentPath}? [y/N] `, (a) => {
102
+ rl.close();
103
+ resolve(String(a || "").trim().toLowerCase());
104
+ });
105
+ });
106
+ if (answer === "y" || answer === "yes") return true;
107
+ } catch {
108
+ // readline failed (e.g. no TTY) — fall through to skip
109
+ }
110
+ console.log(
111
+ ` ${D}skipping registry update. Set ODAI_REGISTRY_DRIFT_OVERWRITE=1 or pass --non-interactive to overwrite.${R}`,
112
+ );
113
+ return false;
114
+ }
115
+
116
+ function registerProjectOrExit(target, name, stack) {
117
+ try {
118
+ return registerProject(target, name, stack, CONFIG_DIR, PROJECTS_FILE);
119
+ } catch (err) {
120
+ log(`error: failed to update project registry: ${err.message}`);
121
+ process.exit(1);
122
+ }
123
+ }
124
+
125
+ function collectCurrentAiFiles(target) {
126
+ const currentFiles = {};
127
+ const aiDir = path.join(target, "ai");
128
+ if (fs.existsSync(aiDir)) {
129
+ const walk = (dir) => {
130
+ for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
131
+ const p = path.join(dir, f.name);
132
+ if (f.isDirectory()) walk(p);
133
+ else {
134
+ try {
135
+ const stat = fs.statSync(p);
136
+ if (stat.isFile()) currentFiles[path.relative(target, p)] = describeCurrentFile(p, stat);
137
+ } catch {}
138
+ }
139
+ }
140
+ };
141
+ walk(aiDir);
142
+ }
143
+ return currentFiles;
144
+ }
15
145
 
16
146
  // bindProjectForCloud — binds project to cloud via /v1/projects/bind
17
147
  async function bindProjectForCloud(target, metadata, identity) {
@@ -32,14 +162,138 @@ async function bindProjectForCloud(target, metadata, identity) {
32
162
  };
33
163
  }
34
164
 
165
+ function cloudInitCheckpointPath(target) {
166
+ return path.join(target, CLOUD_INIT_CHECKPOINT_REL);
167
+ }
168
+
169
+ function _hasAuthToken() {
170
+ const auth = loadAuthState();
171
+ return !!(auth && (auth.api_key || auth.access_token || auth.token));
172
+ }
173
+
174
+ function _quoteCommandArg(value) {
175
+ const text = String(value || "");
176
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(text)) return text;
177
+ return `'${text.replace(/'/g, "'\"'\"'")}'`;
178
+ }
179
+
180
+ function _initCommandName(args = []) {
181
+ const first = String((Array.isArray(args) ? args : [])[0] || "");
182
+ return first === "init-existing" ? "init-existing" : "init";
183
+ }
184
+
185
+ function _resumeArgs(args = []) {
186
+ const raw = Array.isArray(args) ? args : [];
187
+ const withoutCommand = raw[0] === "init" || raw[0] === "init-existing" ? raw.slice(1) : raw.slice();
188
+ const skipWithValue = new Set(["--auth-code", "--oauth-code", "--exchange-code", "--code", "--activation-code", "--redeem-code", "--plan-code"]);
189
+ const filtered = [];
190
+ for (let i = 0; i < withoutCommand.length; i++) {
191
+ const arg = String(withoutCommand[i] || "");
192
+ if (!arg || arg === "--resume") continue;
193
+ if (skipWithValue.has(arg)) { i++; continue; }
194
+ if ([...skipWithValue].some((name) => arg.startsWith(`${name}=`))) continue;
195
+ filtered.push(arg);
196
+ }
197
+ return filtered;
198
+ }
199
+
200
+ function buildCloudInitResumeCommand(target, args = []) {
201
+ const parts = ["0dai", _initCommandName(args), "--target", _quoteCommandArg(path.resolve(target)), "--resume"];
202
+ for (const arg of _resumeArgs(args)) parts.push(_quoteCommandArg(arg));
203
+ return parts.join(" ");
204
+ }
205
+
206
+ function writeCloudInitCheckpoint(target, details = {}) {
207
+ const checkpointPath = cloudInitCheckpointPath(target);
208
+ const checkpoint = {
209
+ version: 1,
210
+ command: _initCommandName(details.args || []),
211
+ stage: details.stage || "auth_required",
212
+ reason: details.reason || "missing_auth",
213
+ target: path.resolve(target),
214
+ created_at: new Date().toISOString(),
215
+ next_command: "0dai auth login --device --no-browser",
216
+ resume_command: buildCloudInitResumeCommand(target, details.args || []),
217
+ };
218
+ fs.mkdirSync(path.dirname(checkpointPath), { recursive: true, mode: 0o700 });
219
+ fs.writeFileSync(checkpointPath, JSON.stringify(checkpoint, null, 2) + "\n", { mode: 0o600 });
220
+ return checkpoint;
221
+ }
222
+
223
+ function clearCloudInitCheckpoint(target) {
224
+ try { fs.unlinkSync(cloudInitCheckpointPath(target)); } catch {}
225
+ }
226
+
227
+ function maybePauseCloudInitForAuth(target, args = [], options = {}) {
228
+ if (options.dryRun || options.localMode) return false;
229
+ if (_hasAuthToken()) return false;
230
+ const parsed = parseActivationArgs(args, { genericCode: "activation" });
231
+ if (parsed.authCode) return false;
232
+ if (process.stdout.isTTY && process.stdin.isTTY) return false;
233
+
234
+ const checkpoint = writeCloudInitCheckpoint(target, {
235
+ args,
236
+ stage: "auth_required",
237
+ reason: "missing_auth",
238
+ });
239
+ log("authentication required for init");
240
+ console.log(` ${D}Checkpoint: ${CLOUD_INIT_CHECKPOINT_REL}${R}`);
241
+ console.log(` ${D}Run: ${checkpoint.next_command}${R}`);
242
+ console.log(` ${D}Then: ${checkpoint.resume_command}${R}`);
243
+ process.exit(1);
244
+ }
245
+
246
+ async function cmdProjectBind(target, args = []) {
247
+ const json = args.includes("--json");
248
+ const authStatus = await ensureAuthenticated("project bind");
249
+ const license = await ensureLicenseActivation();
250
+ const metadata = collectMetadata(target);
251
+ const identity = buildProjectIdentity(target, metadata);
252
+ const boundProject = await bindProjectForCloud(target, metadata, identity);
253
+ const stack = boundProject.stack || identity.stack || "unknown";
254
+ if (await guardRegistryDrift(target, path.basename(target), args)) {
255
+ registerProjectOrExit(target, path.basename(target), stack);
256
+ }
257
+ const heartbeat = await sendProjectHeartbeat(target, identity, { stack }, {
258
+ project_id: boundProject.project_id || identity.project_id,
259
+ }).catch((err) => ({ error: err.message || String(err) }));
260
+ const payload = {
261
+ ok: true,
262
+ account: {
263
+ email: authStatus.email || null,
264
+ plan: authStatus.plan || license.plan || "free",
265
+ activation: license.status || "active",
266
+ },
267
+ project: {
268
+ project_id: boundProject.project_id || identity.project_id,
269
+ name: boundProject.name || identity.project_name,
270
+ stack,
271
+ binding_status: boundProject.binding_status || "bound",
272
+ },
273
+ heartbeat: !(heartbeat && heartbeat.error),
274
+ };
275
+ if (json) {
276
+ console.log(JSON.stringify(payload, null, 2));
277
+ } else {
278
+ log("project bound");
279
+ console.log(` account: ${payload.account.email || "unknown"} · plan: ${payload.account.plan} · activation: ${payload.account.activation}`);
280
+ console.log(` project: ${payload.project.project_id}`);
281
+ if (!payload.heartbeat) console.log(` ${D}heartbeat deferred; run 0dai status to inspect local state${R}`);
282
+ }
283
+ return payload;
284
+ }
285
+
35
286
  async function cmdInit(target, args = []) {
36
287
  const dryRun = args.includes("--dry-run");
37
288
  const minimal = args.includes("--minimal");
38
289
  const noWizard = args.includes("--no-wizard");
290
+ const localMode = args.includes("--local");
39
291
 
40
292
  if (fs.existsSync(path.join(target, "ai", "VERSION"))) {
41
293
  const v = fs.readFileSync(path.join(target, "ai", "VERSION"), "utf8").trim();
42
294
  log(`ai/ layer already exists (v${v}). Run '0dai sync' to update.`);
295
+ if (args.includes("--resume")) clearCloudInitCheckpoint(target);
296
+ if (!dryRun) await runMcpBootstrap(target, args);
43
297
  return;
44
298
  }
45
299
 
@@ -58,12 +312,13 @@ async function cmdInit(target, args = []) {
58
312
  }
59
313
 
60
314
  // First-run wizard (unless --no-wizard or non-interactive)
61
- if (!noWizard && !dryRun && !minimal) {
315
+ if (!noWizard && !dryRun && !minimal && !localMode) {
62
316
  try {
63
317
  const { runWizard, isInteractive } = require("../wizard");
64
318
  if (isInteractive()) {
65
319
  const result = await runWizard(target);
66
320
  if (result.completed) {
321
+ await runMcpBootstrap(target, args);
67
322
  try {
68
323
  const ob = require("../onboarding");
69
324
  ob.trackFirstInit(target);
@@ -71,9 +326,25 @@ async function cmdInit(target, args = []) {
71
326
  } catch {}
72
327
  return;
73
328
  }
329
+ if (result.cancelled) return;
74
330
  }
75
331
  } catch {}
76
332
  }
333
+ if (localMode) {
334
+ const { runWizard } = require("../wizard");
335
+ const result = await runWizard(target, { forceLocal: true });
336
+ if (result.completed) {
337
+ await runMcpBootstrap(target, args);
338
+ try {
339
+ const ob = require("../onboarding");
340
+ ob.trackFirstInit(target);
341
+ ob.showWhatsNext("local", false);
342
+ } catch {}
343
+ return;
344
+ }
345
+ }
346
+
347
+ maybePauseCloudInitForAuth(target, args, { dryRun, localMode });
77
348
 
78
349
  const isTTY = process.stdout.isTTY;
79
350
  let spinner = null;
@@ -83,7 +354,7 @@ async function cmdInit(target, args = []) {
83
354
 
84
355
  const metadata = collectMetadata(target);
85
356
  const { projectFiles, manifestContents, clis } = metadata;
86
- const authStatus = await ensureAuthenticated("init");
357
+ const authStatus = await ensureAccountForActivation("init", args);
87
358
  const license = await ensureLicenseActivation();
88
359
  const identity = buildProjectIdentity(target, metadata);
89
360
  const boundProject = await bindProjectForCloud(target, metadata, identity);
@@ -123,6 +394,11 @@ async function cmdInit(target, args = []) {
123
394
  return;
124
395
  }
125
396
  writeFiles(target, result.files || {});
397
+ const envManifest = normalizeEnvironmentManifest(target);
398
+ if (envManifest.changed) {
399
+ log("environment manifest target normalized: ai/manifest/environment.yaml");
400
+ }
401
+ await runMcpBootstrap(target, args);
126
402
 
127
403
  // Ensure ai/VERSION matches CLI version
128
404
  const versionFile = path.join(target, "ai", "VERSION");
@@ -136,8 +412,15 @@ async function cmdInit(target, args = []) {
136
412
  if (!text.includes(".0dai")) fs.appendFileSync(gi, "\n.0dai/\n");
137
413
  } catch {}
138
414
 
139
- // Register in global portfolio
140
- registerProject(target, path.basename(target), result.stack);
415
+ const gitPolicy = ensureGithubFlowPolicy(target);
416
+ if (gitPolicy && gitPolicy.protection && gitPolicy.protection.skipped) {
417
+ console.log(` ${D}branch protection: ${gitPolicy.protection.reason}${R}`);
418
+ }
419
+
420
+ // Register in global portfolio (Phase 1.5 #2237: drift guard)
421
+ if (await guardRegistryDrift(target, path.basename(target), args)) {
422
+ registerProjectOrExit(target, path.basename(target), result.stack);
423
+ }
141
424
 
142
425
  log(`initialized (${result.file_count || "?"} files)`);
143
426
  console.log(` account: ${authStatus.email} · plan: ${authStatus.plan || license.plan || "free"} · activation: ${license.status}`);
@@ -196,6 +479,82 @@ async function cmdInit(target, args = []) {
196
479
  stack_detected: result.stack || "?", _auto: true, _plan: result.plan || "trial",
197
480
  _cli_version: VERSION, _files_generated: result.file_count || 0,
198
481
  }}).catch(() => {});
482
+ clearCloudInitCheckpoint(target);
483
+ }
484
+
485
+ async function runMcpBootstrap(target, args = []) {
486
+ const result = await bootstrapMcp(target, args, (message) => log(message));
487
+ if (!result.ok) {
488
+ log(`warn: MCP bootstrap skipped: ${result.warnings.join("; ")}`);
489
+ return result;
490
+ }
491
+ if (result.config) {
492
+ const verbs = [];
493
+ if (result.config.added && result.config.added.length) verbs.push(`${result.config.added.length} server(s) added`);
494
+ if (result.config.updated && result.config.updated.length) verbs.push(`${result.config.updated.length} server(s) reset`);
495
+ if (result.config.changed) {
496
+ log(`MCP config ready (${verbs.join(", ") || "updated"}): .mcp.json`);
497
+ }
498
+ }
499
+ if (result.settings && result.settings.changed) {
500
+ log("Claude project MCP auto-load enabled: .claude/settings.json");
501
+ }
502
+ if (result.auth) {
503
+ if (result.auth.status === "preserved") {
504
+ log("MCP auth token preserved");
505
+ } else if (result.auth.status === "written") {
506
+ log(`MCP auth token stored: ${result.auth.tokenPath}`);
507
+ } else if (result.auth.status === "disabled") {
508
+ log("MCP cloud auth skipped (--no-mcp-auth)");
509
+ } else if (result.auth.status === "skipped") {
510
+ console.log(` ${D}MCP cloud auth skipped. In Claude Code, run /mcp Authenticate for claude.ai 0dai${R}`);
511
+ }
512
+ }
513
+ for (const warning of result.warnings || []) log(`warn: ${warning}`);
514
+ return result;
515
+ }
516
+
517
+ function normalizeEnvironmentManifest(target) {
518
+ const filePath = path.join(target, "ai", "manifest", "environment.yaml");
519
+ if (!fs.existsSync(filePath)) return { path: filePath, changed: false };
520
+ const original = fs.readFileSync(filePath, "utf8");
521
+ const lines = original.split(/\n/);
522
+ let inWorkspace = false;
523
+ let sawTarget = false;
524
+ let sawCwd = false;
525
+ const next = [];
526
+ for (const line of lines) {
527
+ if (/^\S/.test(line) && !line.startsWith("workspace:")) inWorkspace = false;
528
+ if (line.trim() === "workspace:") {
529
+ inWorkspace = true;
530
+ next.push(line);
531
+ continue;
532
+ }
533
+ if (inWorkspace && /^ target:\s*/.test(line)) {
534
+ next.push(" target: .");
535
+ sawTarget = true;
536
+ continue;
537
+ }
538
+ if (inWorkspace && /^ cwd:\s*/.test(line)) {
539
+ next.push(" cwd: .");
540
+ sawCwd = true;
541
+ continue;
542
+ }
543
+ if (inWorkspace && /^available_clis:\s*$/.test(line)) {
544
+ if (!sawTarget) next.push(" target: .");
545
+ if (!sawCwd) next.push(" cwd: .");
546
+ inWorkspace = false;
547
+ }
548
+ next.push(line);
549
+ }
550
+ if (inWorkspace) {
551
+ if (!sawTarget) next.push(" target: .");
552
+ if (!sawCwd) next.push(" cwd: .");
553
+ }
554
+ const rendered = next.join("\n");
555
+ if (rendered === original) return { path: filePath, changed: false };
556
+ fs.writeFileSync(filePath, rendered, "utf8");
557
+ return { path: filePath, changed: true };
199
558
  }
200
559
 
201
560
  async function cmdSync(target, args = []) {
@@ -240,24 +599,9 @@ async function cmdSync(target, args = []) {
240
599
  const license = await ensureLicenseActivation();
241
600
  const boundProject = await bindProjectForCloud(target, metadata, identity);
242
601
 
243
- // Collect current ai/ files
244
- const currentFiles = {};
245
- const aiDir = path.join(target, "ai");
246
- if (fs.existsSync(aiDir)) {
247
- const walk = (dir) => {
248
- for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
249
- const p = path.join(dir, f.name);
250
- if (f.isDirectory()) walk(p);
251
- else {
252
- try {
253
- const stat = fs.statSync(p);
254
- if (stat.size < 10000) currentFiles[path.relative(target, p)] = fs.readFileSync(p, "utf8");
255
- } catch {}
256
- }
257
- }
258
- };
259
- walk(aiDir);
260
- }
602
+ // Collect current ai/ files. Small files send content; larger files send
603
+ // hash descriptors so the server can compare without a full upload.
604
+ const currentFiles = collectCurrentAiFiles(target);
261
605
 
262
606
  if (dryRun) log(`${D}dry-run: checking what sync would change...${R}`);
263
607
  if (force && !dryRun) log(`${T}force mode: will overwrite native configs from ai/ source${R}`);
@@ -284,7 +628,14 @@ async function cmdSync(target, args = []) {
284
628
  const files = Object.keys(updated);
285
629
  if (files.length) {
286
630
  log(`${D}dry-run: would update ${files.length} file(s):${R}`);
287
- for (const f of files) console.log(` ${D}~ ${f}${R}`);
631
+ const { diff, summary } = renderFileMapDiff(updated, currentFiles);
632
+ for (const f of summary.added) console.log(` ${D}+ ${f}${R}`);
633
+ for (const f of summary.modified) console.log(` ${D}~ ${f}${R}`);
634
+ for (const f of summary.removed) console.log(` ${D}- ${f}${R}`);
635
+ if (diff && !args.includes("--no-diff")) {
636
+ console.log("");
637
+ console.log(diff);
638
+ }
288
639
  } else {
289
640
  log(`${D}dry-run: nothing to update${R}`);
290
641
  }
@@ -292,6 +643,23 @@ async function cmdSync(target, args = []) {
292
643
  }
293
644
  const changedCount = Object.keys(updated).length;
294
645
  if (changedCount) {
646
+ if (!quiet && !shouldAutoYes(args)) {
647
+ const { diff, summary } = renderFileMapDiff(updated, currentFiles);
648
+ log(`sync would change ${changedCount} file(s):`);
649
+ for (const f of summary.added) console.log(` ${D}+ ${f}${R}`);
650
+ for (const f of summary.modified) console.log(` ${D}~ ${f}${R}`);
651
+ for (const f of summary.removed) console.log(` ${D}- ${f}${R}`);
652
+ if (diff && !args.includes("--no-diff")) {
653
+ console.log("");
654
+ console.log(diff);
655
+ console.log("");
656
+ }
657
+ const ok = await confirmOrExit({ args, quiet, message: "Apply sync?", defaultYes: false });
658
+ if (!ok) {
659
+ log("aborted by user (no files changed). Re-run with --yes to skip this prompt.");
660
+ return;
661
+ }
662
+ }
295
663
  writeFiles(target, updated);
296
664
  if (!quiet) {
297
665
  for (const f of Object.keys(updated)) console.log(` ~ ${f}`);
@@ -301,6 +669,31 @@ async function cmdSync(target, args = []) {
301
669
  } else {
302
670
  log("already up to date");
303
671
  }
672
+ const envManifest = normalizeEnvironmentManifest(target);
673
+ if (!quiet && envManifest.changed) {
674
+ log("environment manifest target normalized: ai/manifest/environment.yaml");
675
+ }
676
+ await runMcpBootstrap(target, [...args, "--no-mcp-auth"]);
677
+
678
+ // Round-trip imported personas back to native agent dirs (Phase 2 of #2197).
679
+ // Currently supports claude-code only. Persona files marked
680
+ // `imported_from: "claude-code"` are mirrored into `.claude/agents/<name>.md`
681
+ // so users can edit either side without losing parity.
682
+ // Phase 2.5 (deferred): full export — generate agents for personas that
683
+ // were authored in 0dai but not imported. Tracked in #2197.
684
+ try {
685
+ const { syncImportedClaudeCodeAgents } = require("./import_claude_code_agents");
686
+ const rt = syncImportedClaudeCodeAgents(target, { dryRun });
687
+ if (!quiet && rt.written.length) {
688
+ log(`round-trip: wrote ${rt.written.length} claude-code agent file(s) from imported personas`);
689
+ for (const w of rt.written) {
690
+ const rel = path.relative(target, w.outPath).replace(/\\/g, "/");
691
+ console.log(` -> ${rel}`);
692
+ }
693
+ }
694
+ } catch (err) {
695
+ if (!quiet) console.log(` ${D}round-trip skipped: ${err.message}${R}`);
696
+ }
304
697
 
305
698
  // --force: also overwrite native configs (CLAUDE.md, AGENTS.md, etc.) from ai/ source
306
699
  if (force && result.native_configs) {
@@ -332,6 +725,7 @@ async function cmdSync(target, args = []) {
332
725
  if (!quiet) {
333
726
  console.log(` account: ${authStatus.email} · plan: ${authStatus.plan || license.plan || "free"} · activation: ${license.status}`);
334
727
  console.log(` project: ${boundProject.project_id || identity.project_id}`);
728
+ warnHooksPathDrift(target);
335
729
  }
336
730
 
337
731
  // Ensure ai/VERSION matches CLI version after successful sync
@@ -343,8 +737,10 @@ async function cmdSync(target, args = []) {
343
737
  }
344
738
  } catch {}
345
739
 
346
- // Update portfolio registry
347
- registerProject(target, path.basename(target), stack);
740
+ // Update portfolio registry (Phase 1.5 #2237: drift guard)
741
+ if (await guardRegistryDrift(target, path.basename(target), args)) {
742
+ registerProjectOrExit(target, path.basename(target), stack);
743
+ }
348
744
  await sendProjectHeartbeat(target, identity, result, {
349
745
  project_id: boundProject.project_id || identity.project_id,
350
746
  }).catch(() => {});
@@ -412,4 +808,20 @@ function buildLocalSyncPreview(target, { version, stack, cliVersion }) {
412
808
  };
413
809
  }
414
810
 
415
- module.exports = { cmdInit, cmdSync, buildLocalSyncPreview };
811
+ module.exports = {
812
+ cmdInit,
813
+ cmdSync,
814
+ cmdProjectBind,
815
+ buildLocalSyncPreview,
816
+ runMcpBootstrap,
817
+ bindProjectForCloud,
818
+ cloudInitCheckpointPath,
819
+ writeCloudInitCheckpoint,
820
+ clearCloudInitCheckpoint,
821
+ buildCloudInitResumeCommand,
822
+ collectCurrentAiFiles,
823
+ hashFile,
824
+ detectRegistryDrift,
825
+ guardRegistryDrift,
826
+ SYNC_FULL_CONTENT_LIMIT,
827
+ };
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * `0dai loop ack` — operator acknowledgement stub for the proactive loop
5
+ * (issue #2235).
6
+ *
7
+ * This is intentionally a sketch. v1 only writes one JSON line to
8
+ * `ai/meta/telemetry/operator-ack.jsonl`. There is no server round-trip,
9
+ * no signed token, no rate-limit. The dead man's switch (Python side,
10
+ * `scripts/dead_man_switch.py`) reads that file and pauses the loop if
11
+ * the most recent ack is older than 24h.
12
+ *
13
+ * TODO (v1.1): authenticate the ack against the 0dai control plane so an
14
+ * ack from a stolen developer machine cannot un-pause the loop.
15
+ * TODO (v1.1): print the most recent ack timestamp + the time until the
16
+ * dead-man would trip again, so the operator sees the runway.
17
+ * TODO (v1.x): expose `0dai loop status` and `0dai loop pause`.
18
+ */
19
+
20
+ const shared = require("../shared");
21
+ const { log, T, R, D, fs, path } = shared;
22
+
23
+ function nowIso() {
24
+ return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
25
+ }
26
+
27
+ function detectOpId() {
28
+ // Best-effort identity. We do not exfiltrate this anywhere — it stays in a
29
+ // local jsonl. Order: $ODAI_OP_ID, $USER, $LOGNAME, "unknown".
30
+ return process.env.ODAI_OP_ID
31
+ || process.env.USER
32
+ || process.env.LOGNAME
33
+ || "unknown";
34
+ }
35
+
36
+ function ackPath(target) {
37
+ return path.join(target, "ai", "meta", "telemetry", "operator-ack.jsonl");
38
+ }
39
+
40
+ function cmdLoopAck(target, args) {
41
+ const noteIdx = args.indexOf("--note");
42
+ const note = noteIdx >= 0 && args[noteIdx + 1] ? args[noteIdx + 1] : "";
43
+
44
+ const file = ackPath(target);
45
+ try {
46
+ fs.mkdirSync(path.dirname(file), { recursive: true });
47
+ } catch (e) {
48
+ log(`error: cannot create telemetry dir: ${e.message}`);
49
+ process.exit(1);
50
+ }
51
+
52
+ const entry = { ts: nowIso(), op_id: detectOpId() };
53
+ if (note) entry.note = note.slice(0, 240);
54
+
55
+ try {
56
+ fs.appendFileSync(file, JSON.stringify(entry) + "\n");
57
+ } catch (e) {
58
+ log(`error: cannot write ack: ${e.message}`);
59
+ process.exit(1);
60
+ }
61
+
62
+ log(`${T}loop ack${R} recorded at ${entry.ts} (op_id=${entry.op_id})`);
63
+ console.log(` ${D}wrote ${file}${R}`);
64
+ console.log(` ${D}TODO v1.1: server-side ack + signed token${R}`);
65
+ }
66
+
67
+ function cmdLoopStatus(target) {
68
+ const file = ackPath(target);
69
+ if (!fs.existsSync(file)) {
70
+ log("no ack history yet — run `0dai loop ack` to start the dead-man clock");
71
+ return;
72
+ }
73
+ let lastTs = null;
74
+ try {
75
+ const lines = fs.readFileSync(file, "utf8").trim().split("\n").filter(Boolean);
76
+ for (const ln of lines) {
77
+ try {
78
+ const row = JSON.parse(ln);
79
+ if (row && row.ts) lastTs = row.ts;
80
+ } catch { /* ignore malformed */ }
81
+ }
82
+ } catch (e) {
83
+ log(`error: cannot read ack file: ${e.message}`);
84
+ process.exit(1);
85
+ }
86
+ if (!lastTs) {
87
+ log("ack file present but no parsable rows");
88
+ return;
89
+ }
90
+ const ageMs = Date.now() - new Date(lastTs).getTime();
91
+ const ageHours = (ageMs / 3_600_000).toFixed(1);
92
+ log(`last ack: ${lastTs} (${ageHours}h ago)`);
93
+ console.log(` ${D}dead-man trips at 24h since last ack${R}`);
94
+ }
95
+
96
+ function cmdLoop(target, sub, args) {
97
+ const subArgs = args.slice(2);
98
+ if (sub === "ack") return cmdLoopAck(target, subArgs);
99
+ if (sub === "status") return cmdLoopStatus(target);
100
+ console.log("Usage: 0dai loop [ack [--note '...'] | status]");
101
+ console.log("");
102
+ console.log(" ack Refresh dead-man-switch operator ack timestamp");
103
+ console.log(" status Show last ack and runway until dead-man trips");
104
+ console.log("");
105
+ console.log(` ${D}see ai/docs/proactive-loop-v1.md for the full design${R}`);
106
+ }
107
+
108
+ module.exports = { cmdLoop, cmdLoopAck, cmdLoopStatus };