@0dai-dev/cli 4.2.0 → 4.3.5

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 (52) hide show
  1. package/README.md +98 -10
  2. package/bin/0dai.js +298 -60
  3. package/lib/commands/audit.js +13 -0
  4. package/lib/commands/auth.js +344 -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 +39 -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 +504 -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 +104 -7
  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 +30 -1
  30. package/lib/commands/swarm.js +97 -4
  31. package/lib/commands/tui.js +81 -13
  32. package/lib/commands/upgrade.js +58 -0
  33. package/lib/commands/usage.js +87 -0
  34. package/lib/commands/vault.js +246 -0
  35. package/lib/onboarding.js +9 -3
  36. package/lib/shared.js +29 -14
  37. package/lib/utils/activation_telemetry.js +156 -0
  38. package/lib/utils/auth.js +1 -0
  39. package/lib/utils/canonical-counts.js +54 -0
  40. package/lib/utils/constants.js +7 -0
  41. package/lib/utils/diff-preview.js +192 -0
  42. package/lib/utils/identity.js +76 -18
  43. package/lib/utils/mcp-auth.js +607 -0
  44. package/lib/utils/plan.js +47 -2
  45. package/lib/utils/run_cost.js +91 -0
  46. package/lib/vault/cipher.js +125 -0
  47. package/lib/vault/identity.js +122 -0
  48. package/lib/vault/index.js +184 -0
  49. package/lib/vault/storage.js +84 -0
  50. package/lib/wizard.js +19 -12
  51. package/package.json +8 -4
  52. package/lib/tui/index.mjs +0 -34610
@@ -1,17 +1,148 @@
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");
18
+ const { recordActivationInit } = require("../utils/activation_telemetry");
13
19
 
14
20
  const ensureAuthenticated = makeEnsureAuthenticated(cmdAuthLogin);
21
+ const SYNC_FULL_CONTENT_LIMIT = 10000;
22
+ const CLOUD_INIT_CHECKPOINT_REL = path.join(".0dai", "cloud-init-checkpoint.json");
23
+
24
+ function hashFile(filePath) {
25
+ const hash = crypto.createHash("sha256");
26
+ const fd = fs.openSync(filePath, "r");
27
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
28
+ try {
29
+ let bytesRead = 0;
30
+ do {
31
+ bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
32
+ if (bytesRead > 0) hash.update(buffer.subarray(0, bytesRead));
33
+ } while (bytesRead > 0);
34
+ } finally {
35
+ fs.closeSync(fd);
36
+ }
37
+ return hash.digest("hex");
38
+ }
39
+
40
+ function describeCurrentFile(filePath, stat) {
41
+ if (stat.size < SYNC_FULL_CONTENT_LIMIT) return fs.readFileSync(filePath, "utf8");
42
+ return {
43
+ size: stat.size,
44
+ sha256: hashFile(filePath),
45
+ compare: "hash-only",
46
+ };
47
+ }
48
+
49
+ // detectRegistryDrift — Phase 1.5 of #2237 registry self-heal.
50
+ // Returns { drifted: bool, existingPath?: string, currentPath: string } when an
51
+ // entry with the same `name` is already in ~/.0dai/projects.json but points at
52
+ // a different directory than the one we're about to register. Caller decides
53
+ // what to do (warn, prompt, or auto-overwrite based on flags).
54
+ function detectRegistryDrift(target, name) {
55
+ const currentPath = path.resolve(target);
56
+ try {
57
+ if (!fs.existsSync(PROJECTS_FILE)) return { drifted: false, currentPath };
58
+ const raw = fs.readFileSync(PROJECTS_FILE, "utf8");
59
+ const data = JSON.parse(raw);
60
+ const projects = Array.isArray(data && data.projects) ? data.projects : [];
61
+ for (const entry of projects) {
62
+ if (!entry || typeof entry !== "object") continue;
63
+ if (entry.name !== name) continue;
64
+ if (!entry.path || typeof entry.path !== "string") continue;
65
+ if (entry.path === currentPath) continue;
66
+ return { drifted: true, existingPath: entry.path, currentPath, archived: entry.archived === true };
67
+ }
68
+ } catch {}
69
+ return { drifted: false, currentPath };
70
+ }
71
+
72
+ // guardRegistryDrift — returns true when caller may proceed with registration,
73
+ // false if the operator declined the overwrite. Async to support interactive
74
+ // prompt; honours --non-interactive (and --yes / common CI flags) by
75
+ // auto-overwriting silently. ODAI_REGISTRY_DRIFT_OVERWRITE=1 also bypasses
76
+ // the prompt for scripted runs.
77
+ async function guardRegistryDrift(target, name, args) {
78
+ const drift = detectRegistryDrift(target, name);
79
+ if (!drift.drifted) return true;
80
+ // Archived entries are not a real conflict — registry_audit.py archives
81
+ // missing paths; let init silently overwrite them.
82
+ if (drift.archived) return true;
83
+
84
+ const nonInteractive =
85
+ args.includes("--non-interactive") ||
86
+ args.includes("--yes") ||
87
+ args.includes("-y") ||
88
+ !process.stdout.isTTY ||
89
+ process.env.ODAI_REGISTRY_DRIFT_OVERWRITE === "1";
90
+
91
+ log(`${W}registry drift: project '${name}' is registered at ${drift.existingPath}${R}`);
92
+ console.log(` current cwd: ${drift.currentPath}`);
93
+ if (nonInteractive) {
94
+ console.log(` ${D}auto-overwriting registry entry (non-interactive mode)${R}`);
95
+ return true;
96
+ }
97
+
98
+ try {
99
+ const readline = require("readline");
100
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
101
+ const answer = await new Promise((resolve) => {
102
+ rl.question(` Overwrite registry entry to point at ${drift.currentPath}? [y/N] `, (a) => {
103
+ rl.close();
104
+ resolve(String(a || "").trim().toLowerCase());
105
+ });
106
+ });
107
+ if (answer === "y" || answer === "yes") return true;
108
+ } catch {
109
+ // readline failed (e.g. no TTY) — fall through to skip
110
+ }
111
+ console.log(
112
+ ` ${D}skipping registry update. Set ODAI_REGISTRY_DRIFT_OVERWRITE=1 or pass --non-interactive to overwrite.${R}`,
113
+ );
114
+ return false;
115
+ }
116
+
117
+ function registerProjectOrExit(target, name, stack) {
118
+ try {
119
+ return registerProject(target, name, stack, CONFIG_DIR, PROJECTS_FILE);
120
+ } catch (err) {
121
+ log(`error: failed to update project registry: ${err.message}`);
122
+ process.exit(1);
123
+ }
124
+ }
125
+
126
+ function collectCurrentAiFiles(target) {
127
+ const currentFiles = {};
128
+ const aiDir = path.join(target, "ai");
129
+ if (fs.existsSync(aiDir)) {
130
+ const walk = (dir) => {
131
+ for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
132
+ const p = path.join(dir, f.name);
133
+ if (f.isDirectory()) walk(p);
134
+ else {
135
+ try {
136
+ const stat = fs.statSync(p);
137
+ if (stat.isFile()) currentFiles[path.relative(target, p)] = describeCurrentFile(p, stat);
138
+ } catch {}
139
+ }
140
+ }
141
+ };
142
+ walk(aiDir);
143
+ }
144
+ return currentFiles;
145
+ }
15
146
 
16
147
  // bindProjectForCloud — binds project to cloud via /v1/projects/bind
17
148
  async function bindProjectForCloud(target, metadata, identity) {
@@ -32,14 +163,138 @@ async function bindProjectForCloud(target, metadata, identity) {
32
163
  };
33
164
  }
34
165
 
166
+ function cloudInitCheckpointPath(target) {
167
+ return path.join(target, CLOUD_INIT_CHECKPOINT_REL);
168
+ }
169
+
170
+ function _hasAuthToken() {
171
+ const auth = loadAuthState();
172
+ return !!(auth && (auth.api_key || auth.access_token || auth.token));
173
+ }
174
+
175
+ function _quoteCommandArg(value) {
176
+ const text = String(value || "");
177
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(text)) return text;
178
+ return `'${text.replace(/'/g, "'\"'\"'")}'`;
179
+ }
180
+
181
+ function _initCommandName(args = []) {
182
+ const first = String((Array.isArray(args) ? args : [])[0] || "");
183
+ return first === "init-existing" ? "init-existing" : "init";
184
+ }
185
+
186
+ function _resumeArgs(args = []) {
187
+ const raw = Array.isArray(args) ? args : [];
188
+ const withoutCommand = raw[0] === "init" || raw[0] === "init-existing" ? raw.slice(1) : raw.slice();
189
+ const skipWithValue = new Set(["--auth-code", "--oauth-code", "--exchange-code", "--code", "--activation-code", "--redeem-code", "--plan-code"]);
190
+ const filtered = [];
191
+ for (let i = 0; i < withoutCommand.length; i++) {
192
+ const arg = String(withoutCommand[i] || "");
193
+ if (!arg || arg === "--resume") continue;
194
+ if (skipWithValue.has(arg)) { i++; continue; }
195
+ if ([...skipWithValue].some((name) => arg.startsWith(`${name}=`))) continue;
196
+ filtered.push(arg);
197
+ }
198
+ return filtered;
199
+ }
200
+
201
+ function buildCloudInitResumeCommand(target, args = []) {
202
+ const parts = ["0dai", _initCommandName(args), "--target", _quoteCommandArg(path.resolve(target)), "--resume"];
203
+ for (const arg of _resumeArgs(args)) parts.push(_quoteCommandArg(arg));
204
+ return parts.join(" ");
205
+ }
206
+
207
+ function writeCloudInitCheckpoint(target, details = {}) {
208
+ const checkpointPath = cloudInitCheckpointPath(target);
209
+ const checkpoint = {
210
+ version: 1,
211
+ command: _initCommandName(details.args || []),
212
+ stage: details.stage || "auth_required",
213
+ reason: details.reason || "missing_auth",
214
+ target: path.resolve(target),
215
+ created_at: new Date().toISOString(),
216
+ next_command: "0dai auth login --device --no-browser",
217
+ resume_command: buildCloudInitResumeCommand(target, details.args || []),
218
+ };
219
+ fs.mkdirSync(path.dirname(checkpointPath), { recursive: true, mode: 0o700 });
220
+ fs.writeFileSync(checkpointPath, JSON.stringify(checkpoint, null, 2) + "\n", { mode: 0o600 });
221
+ return checkpoint;
222
+ }
223
+
224
+ function clearCloudInitCheckpoint(target) {
225
+ try { fs.unlinkSync(cloudInitCheckpointPath(target)); } catch {}
226
+ }
227
+
228
+ function maybePauseCloudInitForAuth(target, args = [], options = {}) {
229
+ if (options.dryRun || options.localMode) return false;
230
+ if (_hasAuthToken()) return false;
231
+ const parsed = parseActivationArgs(args, { genericCode: "activation" });
232
+ if (parsed.authCode) return false;
233
+ if (process.stdout.isTTY && process.stdin.isTTY) return false;
234
+
235
+ const checkpoint = writeCloudInitCheckpoint(target, {
236
+ args,
237
+ stage: "auth_required",
238
+ reason: "missing_auth",
239
+ });
240
+ log("authentication required for init");
241
+ console.log(` ${D}Checkpoint: ${CLOUD_INIT_CHECKPOINT_REL}${R}`);
242
+ console.log(` ${D}Run: ${checkpoint.next_command}${R}`);
243
+ console.log(` ${D}Then: ${checkpoint.resume_command}${R}`);
244
+ process.exit(1);
245
+ }
246
+
247
+ async function cmdProjectBind(target, args = []) {
248
+ const json = args.includes("--json");
249
+ const authStatus = await ensureAuthenticated("project bind");
250
+ const license = await ensureLicenseActivation();
251
+ const metadata = collectMetadata(target);
252
+ const identity = buildProjectIdentity(target, metadata);
253
+ const boundProject = await bindProjectForCloud(target, metadata, identity);
254
+ const stack = boundProject.stack || identity.stack || "unknown";
255
+ if (await guardRegistryDrift(target, path.basename(target), args)) {
256
+ registerProjectOrExit(target, path.basename(target), stack);
257
+ }
258
+ const heartbeat = await sendProjectHeartbeat(target, identity, { stack }, {
259
+ project_id: boundProject.project_id || identity.project_id,
260
+ }).catch((err) => ({ error: err.message || String(err) }));
261
+ const payload = {
262
+ ok: true,
263
+ account: {
264
+ email: authStatus.email || null,
265
+ plan: authStatus.plan || license.plan || "free",
266
+ activation: license.status || "active",
267
+ },
268
+ project: {
269
+ project_id: boundProject.project_id || identity.project_id,
270
+ name: boundProject.name || identity.project_name,
271
+ stack,
272
+ binding_status: boundProject.binding_status || "bound",
273
+ },
274
+ heartbeat: !(heartbeat && heartbeat.error),
275
+ };
276
+ if (json) {
277
+ console.log(JSON.stringify(payload, null, 2));
278
+ } else {
279
+ log("project bound");
280
+ console.log(` account: ${payload.account.email || "unknown"} · plan: ${payload.account.plan} · activation: ${payload.account.activation}`);
281
+ console.log(` project: ${payload.project.project_id}`);
282
+ if (!payload.heartbeat) console.log(` ${D}heartbeat deferred; run 0dai status to inspect local state${R}`);
283
+ }
284
+ return payload;
285
+ }
286
+
35
287
  async function cmdInit(target, args = []) {
36
288
  const dryRun = args.includes("--dry-run");
37
289
  const minimal = args.includes("--minimal");
38
290
  const noWizard = args.includes("--no-wizard");
291
+ const localMode = args.includes("--local");
39
292
 
40
293
  if (fs.existsSync(path.join(target, "ai", "VERSION"))) {
41
294
  const v = fs.readFileSync(path.join(target, "ai", "VERSION"), "utf8").trim();
42
295
  log(`ai/ layer already exists (v${v}). Run '0dai sync' to update.`);
296
+ if (args.includes("--resume")) clearCloudInitCheckpoint(target);
297
+ if (!dryRun) await runMcpBootstrap(target, args);
43
298
  return;
44
299
  }
45
300
 
@@ -58,12 +313,13 @@ async function cmdInit(target, args = []) {
58
313
  }
59
314
 
60
315
  // First-run wizard (unless --no-wizard or non-interactive)
61
- if (!noWizard && !dryRun && !minimal) {
316
+ if (!noWizard && !dryRun && !minimal && !localMode) {
62
317
  try {
63
318
  const { runWizard, isInteractive } = require("../wizard");
64
319
  if (isInteractive()) {
65
320
  const result = await runWizard(target);
66
321
  if (result.completed) {
322
+ await runMcpBootstrap(target, args);
67
323
  try {
68
324
  const ob = require("../onboarding");
69
325
  ob.trackFirstInit(target);
@@ -71,9 +327,25 @@ async function cmdInit(target, args = []) {
71
327
  } catch {}
72
328
  return;
73
329
  }
330
+ if (result.cancelled) return;
74
331
  }
75
332
  } catch {}
76
333
  }
334
+ if (localMode) {
335
+ const { runWizard } = require("../wizard");
336
+ const result = await runWizard(target, { forceLocal: true });
337
+ if (result.completed) {
338
+ await runMcpBootstrap(target, args);
339
+ try {
340
+ const ob = require("../onboarding");
341
+ ob.trackFirstInit(target);
342
+ ob.showWhatsNext("local", false);
343
+ } catch {}
344
+ return;
345
+ }
346
+ }
347
+
348
+ maybePauseCloudInitForAuth(target, args, { dryRun, localMode });
77
349
 
78
350
  const isTTY = process.stdout.isTTY;
79
351
  let spinner = null;
@@ -83,7 +355,7 @@ async function cmdInit(target, args = []) {
83
355
 
84
356
  const metadata = collectMetadata(target);
85
357
  const { projectFiles, manifestContents, clis } = metadata;
86
- const authStatus = await ensureAuthenticated("init");
358
+ const authStatus = await ensureAccountForActivation("init", args);
87
359
  const license = await ensureLicenseActivation();
88
360
  const identity = buildProjectIdentity(target, metadata);
89
361
  const boundProject = await bindProjectForCloud(target, metadata, identity);
@@ -123,6 +395,11 @@ async function cmdInit(target, args = []) {
123
395
  return;
124
396
  }
125
397
  writeFiles(target, result.files || {});
398
+ const envManifest = normalizeEnvironmentManifest(target);
399
+ if (envManifest.changed) {
400
+ log("environment manifest target normalized: ai/manifest/environment.yaml");
401
+ }
402
+ await runMcpBootstrap(target, args);
126
403
 
127
404
  // Ensure ai/VERSION matches CLI version
128
405
  const versionFile = path.join(target, "ai", "VERSION");
@@ -136,8 +413,15 @@ async function cmdInit(target, args = []) {
136
413
  if (!text.includes(".0dai")) fs.appendFileSync(gi, "\n.0dai/\n");
137
414
  } catch {}
138
415
 
139
- // Register in global portfolio
140
- registerProject(target, path.basename(target), result.stack);
416
+ const gitPolicy = ensureGithubFlowPolicy(target);
417
+ if (gitPolicy && gitPolicy.protection && gitPolicy.protection.skipped) {
418
+ console.log(` ${D}branch protection: ${gitPolicy.protection.reason}${R}`);
419
+ }
420
+
421
+ // Register in global portfolio (Phase 1.5 #2237: drift guard)
422
+ if (await guardRegistryDrift(target, path.basename(target), args)) {
423
+ registerProjectOrExit(target, path.basename(target), result.stack);
424
+ }
141
425
 
142
426
  log(`initialized (${result.file_count || "?"} files)`);
143
427
  console.log(` account: ${authStatus.email} · plan: ${authStatus.plan || license.plan || "free"} · activation: ${license.status}`);
@@ -180,6 +464,8 @@ async function cmdInit(target, args = []) {
180
464
  // First-run proof gate (issue #342). All 4 gates pass once we reach here:
181
465
  // license active (line above), project bound, ai/ layer written, heartbeat sent.
182
466
  // Idempotent — only fires once per project. See docs/first-run.md.
467
+ recordActivationInit(target, boundProject.project_id || identity.project_id);
468
+
183
469
  const firstRun = logFirstRunSuccess(target, {
184
470
  license: true,
185
471
  project_bound: true,
@@ -196,6 +482,136 @@ async function cmdInit(target, args = []) {
196
482
  stack_detected: result.stack || "?", _auto: true, _plan: result.plan || "trial",
197
483
  _cli_version: VERSION, _files_generated: result.file_count || 0,
198
484
  }}).catch(() => {});
485
+ clearCloudInitCheckpoint(target);
486
+ }
487
+
488
+ async function runMcpBootstrap(target, args = []) {
489
+ const result = await bootstrapMcp(target, args, (message) => log(message));
490
+ if (!result.ok) {
491
+ log(`warn: MCP bootstrap skipped: ${result.warnings.join("; ")}`);
492
+ return result;
493
+ }
494
+ if (result.config) {
495
+ const verbs = [];
496
+ if (result.config.added && result.config.added.length) verbs.push(`${result.config.added.length} server(s) added`);
497
+ if (result.config.updated && result.config.updated.length) verbs.push(`${result.config.updated.length} server(s) reset`);
498
+ if (result.config.changed) {
499
+ log(`MCP config ready (${verbs.join(", ") || "updated"}): .mcp.json`);
500
+ }
501
+ }
502
+ if (result.settings && result.settings.changed) {
503
+ log("Claude project MCP auto-load enabled: .claude/settings.json");
504
+ }
505
+ if (result.auth) {
506
+ if (result.auth.status === "preserved") {
507
+ log("MCP auth token preserved");
508
+ } else if (result.auth.status === "written") {
509
+ log(`MCP auth token stored: ${result.auth.tokenPath}`);
510
+ } else if (result.auth.status === "disabled") {
511
+ log("MCP cloud auth skipped (--no-mcp-auth)");
512
+ } else if (result.auth.status === "skipped") {
513
+ console.log(` ${D}MCP cloud auth skipped. In Claude Code, run /mcp Authenticate for claude.ai 0dai${R}`);
514
+ }
515
+ }
516
+ for (const warning of result.warnings || []) log(`warn: ${warning}`);
517
+ return result;
518
+ }
519
+
520
+ function normalizeEnvironmentManifest(target) {
521
+ const filePath = path.join(target, "ai", "manifest", "environment.yaml");
522
+ if (!fs.existsSync(filePath)) return { path: filePath, changed: false };
523
+ const original = fs.readFileSync(filePath, "utf8");
524
+ const lines = original.split(/\n/);
525
+ let inWorkspace = false;
526
+ let sawTarget = false;
527
+ let sawCwd = false;
528
+ const next = [];
529
+ for (const line of lines) {
530
+ if (/^\S/.test(line) && !line.startsWith("workspace:")) inWorkspace = false;
531
+ if (line.trim() === "workspace:") {
532
+ inWorkspace = true;
533
+ next.push(line);
534
+ continue;
535
+ }
536
+ if (inWorkspace && /^ target:\s*/.test(line)) {
537
+ next.push(" target: .");
538
+ sawTarget = true;
539
+ continue;
540
+ }
541
+ if (inWorkspace && /^ cwd:\s*/.test(line)) {
542
+ next.push(" cwd: .");
543
+ sawCwd = true;
544
+ continue;
545
+ }
546
+ if (inWorkspace && /^available_clis:\s*$/.test(line)) {
547
+ if (!sawTarget) next.push(" target: .");
548
+ if (!sawCwd) next.push(" cwd: .");
549
+ inWorkspace = false;
550
+ }
551
+ next.push(line);
552
+ }
553
+ if (inWorkspace) {
554
+ if (!sawTarget) next.push(" target: .");
555
+ if (!sawCwd) next.push(" cwd: .");
556
+ }
557
+ const rendered = next.join("\n");
558
+ if (rendered === original) return { path: filePath, changed: false };
559
+ fs.writeFileSync(filePath, rendered, "utf8");
560
+ return { path: filePath, changed: true };
561
+ }
562
+
563
+ // runDocLinkCheck — SPEC-028 Phase 2 (#2689). After the managed layer has been
564
+ // written, run scripts/doc_link_check.py to surface broken/closed cross-refs
565
+ // in ai/ and docs/. Warn-only by default so a flaky reference never blocks an
566
+ // otherwise successful sync; --strict-links promotes failures to a non-zero
567
+ // exit, and --skip-link-check bypasses the hook entirely.
568
+ //
569
+ // The hook intentionally stays silent when the script is missing (Phase 1 may
570
+ // not have shipped to a downstream project) and when python3 is unavailable.
571
+ // Output is streamed inline via stdio: "inherit" so authors see exact paths.
572
+ function runDocLinkCheck(target, args = [], options = {}) {
573
+ if (args.includes("--skip-link-check")) {
574
+ return { skipped: true, reason: "flag" };
575
+ }
576
+ const script = path.join(target, "scripts", "doc_link_check.py");
577
+ if (!fs.existsSync(script)) {
578
+ return { skipped: true, reason: "missing-script" };
579
+ }
580
+ const quiet = !!options.quiet;
581
+ const strict = args.includes("--strict-links");
582
+ const { spawnSync } = require("child_process");
583
+ if (!quiet) log("doc-link-check: scanning ai/**/*.md docs/**/*.md");
584
+ const result = spawnSync(
585
+ "python3",
586
+ [
587
+ script,
588
+ "--repo-root",
589
+ target,
590
+ "--paths",
591
+ "ai/**/*.md",
592
+ "docs/**/*.md",
593
+ "--format",
594
+ "md",
595
+ "--fail-on",
596
+ "broken,closed",
597
+ ],
598
+ { stdio: "inherit", cwd: target },
599
+ );
600
+ if (result.error && result.error.code === "ENOENT") {
601
+ if (!quiet) console.log(` ${D}doc-link-check skipped: python3 not found${R}`);
602
+ return { skipped: true, reason: "no-python" };
603
+ }
604
+ const status = typeof result.status === "number" ? result.status : 0;
605
+ if (status !== 0) {
606
+ if (strict) {
607
+ log(`${W}doc-link-check failed (exit ${status}); --strict-links is set${R}`);
608
+ process.exit(status);
609
+ }
610
+ if (!quiet) log(`${W}doc-link-check found issues (exit ${status}); warn-only (pass --strict-links to fail sync)${R}`);
611
+ return { ran: true, status, strict, broken: true };
612
+ }
613
+ if (!quiet) log("doc-link-check: clean");
614
+ return { ran: true, status: 0, strict, broken: false };
199
615
  }
200
616
 
201
617
  async function cmdSync(target, args = []) {
@@ -240,24 +656,9 @@ async function cmdSync(target, args = []) {
240
656
  const license = await ensureLicenseActivation();
241
657
  const boundProject = await bindProjectForCloud(target, metadata, identity);
242
658
 
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
- }
659
+ // Collect current ai/ files. Small files send content; larger files send
660
+ // hash descriptors so the server can compare without a full upload.
661
+ const currentFiles = collectCurrentAiFiles(target);
261
662
 
262
663
  if (dryRun) log(`${D}dry-run: checking what sync would change...${R}`);
263
664
  if (force && !dryRun) log(`${T}force mode: will overwrite native configs from ai/ source${R}`);
@@ -284,7 +685,14 @@ async function cmdSync(target, args = []) {
284
685
  const files = Object.keys(updated);
285
686
  if (files.length) {
286
687
  log(`${D}dry-run: would update ${files.length} file(s):${R}`);
287
- for (const f of files) console.log(` ${D}~ ${f}${R}`);
688
+ const { diff, summary } = renderFileMapDiff(updated, currentFiles);
689
+ for (const f of summary.added) console.log(` ${D}+ ${f}${R}`);
690
+ for (const f of summary.modified) console.log(` ${D}~ ${f}${R}`);
691
+ for (const f of summary.removed) console.log(` ${D}- ${f}${R}`);
692
+ if (diff && !args.includes("--no-diff")) {
693
+ console.log("");
694
+ console.log(diff);
695
+ }
288
696
  } else {
289
697
  log(`${D}dry-run: nothing to update${R}`);
290
698
  }
@@ -292,6 +700,23 @@ async function cmdSync(target, args = []) {
292
700
  }
293
701
  const changedCount = Object.keys(updated).length;
294
702
  if (changedCount) {
703
+ if (!quiet && !shouldAutoYes(args)) {
704
+ const { diff, summary } = renderFileMapDiff(updated, currentFiles);
705
+ log(`sync would change ${changedCount} file(s):`);
706
+ for (const f of summary.added) console.log(` ${D}+ ${f}${R}`);
707
+ for (const f of summary.modified) console.log(` ${D}~ ${f}${R}`);
708
+ for (const f of summary.removed) console.log(` ${D}- ${f}${R}`);
709
+ if (diff && !args.includes("--no-diff")) {
710
+ console.log("");
711
+ console.log(diff);
712
+ console.log("");
713
+ }
714
+ const ok = await confirmOrExit({ args, quiet, message: "Apply sync?", defaultYes: false });
715
+ if (!ok) {
716
+ log("aborted by user (no files changed). Re-run with --yes to skip this prompt.");
717
+ return;
718
+ }
719
+ }
295
720
  writeFiles(target, updated);
296
721
  if (!quiet) {
297
722
  for (const f of Object.keys(updated)) console.log(` ~ ${f}`);
@@ -302,6 +727,37 @@ async function cmdSync(target, args = []) {
302
727
  log("already up to date");
303
728
  }
304
729
 
730
+ // SPEC-028 Phase 2 (#2689) — scan managed docs for broken cross-refs after
731
+ // the layer is on disk. Warn-only unless --strict-links; --skip-link-check
732
+ // bypasses. Runs even when no files changed so stale local refs surface on
733
+ // every sync.
734
+ runDocLinkCheck(target, args, { quiet });
735
+ const envManifest = normalizeEnvironmentManifest(target);
736
+ if (!quiet && envManifest.changed) {
737
+ log("environment manifest target normalized: ai/manifest/environment.yaml");
738
+ }
739
+ await runMcpBootstrap(target, [...args, "--no-mcp-auth"]);
740
+
741
+ // Round-trip imported personas back to native agent dirs (Phase 2 of #2197).
742
+ // Currently supports claude-code only. Persona files marked
743
+ // `imported_from: "claude-code"` are mirrored into `.claude/agents/<name>.md`
744
+ // so users can edit either side without losing parity.
745
+ // Phase 2.5 (deferred): full export — generate agents for personas that
746
+ // were authored in 0dai but not imported. Tracked in #2197.
747
+ try {
748
+ const { syncImportedClaudeCodeAgents } = require("./import_claude_code_agents");
749
+ const rt = syncImportedClaudeCodeAgents(target, { dryRun });
750
+ if (!quiet && rt.written.length) {
751
+ log(`round-trip: wrote ${rt.written.length} claude-code agent file(s) from imported personas`);
752
+ for (const w of rt.written) {
753
+ const rel = path.relative(target, w.outPath).replace(/\\/g, "/");
754
+ console.log(` -> ${rel}`);
755
+ }
756
+ }
757
+ } catch (err) {
758
+ if (!quiet) console.log(` ${D}round-trip skipped: ${err.message}${R}`);
759
+ }
760
+
305
761
  // --force: also overwrite native configs (CLAUDE.md, AGENTS.md, etc.) from ai/ source
306
762
  if (force && result.native_configs) {
307
763
  const NATIVE_CONFIGS = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", "opencode.json", ".cursorrules", ".windsurfrules", ".aider.conf.yml"];
@@ -332,6 +788,7 @@ async function cmdSync(target, args = []) {
332
788
  if (!quiet) {
333
789
  console.log(` account: ${authStatus.email} · plan: ${authStatus.plan || license.plan || "free"} · activation: ${license.status}`);
334
790
  console.log(` project: ${boundProject.project_id || identity.project_id}`);
791
+ warnHooksPathDrift(target);
335
792
  }
336
793
 
337
794
  // Ensure ai/VERSION matches CLI version after successful sync
@@ -343,8 +800,10 @@ async function cmdSync(target, args = []) {
343
800
  }
344
801
  } catch {}
345
802
 
346
- // Update portfolio registry
347
- registerProject(target, path.basename(target), stack);
803
+ // Update portfolio registry (Phase 1.5 #2237: drift guard)
804
+ if (await guardRegistryDrift(target, path.basename(target), args)) {
805
+ registerProjectOrExit(target, path.basename(target), stack);
806
+ }
348
807
  await sendProjectHeartbeat(target, identity, result, {
349
808
  project_id: boundProject.project_id || identity.project_id,
350
809
  }).catch(() => {});
@@ -412,4 +871,21 @@ function buildLocalSyncPreview(target, { version, stack, cliVersion }) {
412
871
  };
413
872
  }
414
873
 
415
- module.exports = { cmdInit, cmdSync, buildLocalSyncPreview };
874
+ module.exports = {
875
+ cmdInit,
876
+ cmdSync,
877
+ cmdProjectBind,
878
+ buildLocalSyncPreview,
879
+ runMcpBootstrap,
880
+ bindProjectForCloud,
881
+ cloudInitCheckpointPath,
882
+ writeCloudInitCheckpoint,
883
+ clearCloudInitCheckpoint,
884
+ buildCloudInitResumeCommand,
885
+ collectCurrentAiFiles,
886
+ hashFile,
887
+ detectRegistryDrift,
888
+ guardRegistryDrift,
889
+ runDocLinkCheck,
890
+ SYNC_FULL_CONTENT_LIMIT,
891
+ };