@0dai-dev/cli 4.3.5 → 4.3.7

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 (79) hide show
  1. package/README.md +12 -11
  2. package/bin/0dai.js +214 -40
  3. package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
  4. package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
  5. package/lib/ai/registry/mcp-catalog.json +98 -0
  6. package/lib/commands/auth.js +55 -1
  7. package/lib/commands/compliance.js +1 -1
  8. package/lib/commands/detect.js +10 -4
  9. package/lib/commands/doctor.js +545 -26
  10. package/lib/commands/experience.js +40 -5
  11. package/lib/commands/export.js +73 -0
  12. package/lib/commands/feedback.js +157 -15
  13. package/lib/commands/gh.js +26 -0
  14. package/lib/commands/graph.js +9 -4
  15. package/lib/commands/heatmap.js +1 -1
  16. package/lib/commands/init.js +222 -30
  17. package/lib/commands/mcp.js +129 -21
  18. package/lib/commands/models.js +138 -41
  19. package/lib/commands/provider.js +30 -59
  20. package/lib/commands/quota.js +1 -1
  21. package/lib/commands/receipt.js +1 -1
  22. package/lib/commands/run.js +18 -7
  23. package/lib/commands/runner.js +31 -1
  24. package/lib/commands/status.js +44 -11
  25. package/lib/commands/swarm.js +130 -12
  26. package/lib/commands/trust.js +286 -0
  27. package/lib/commands/update.js +184 -38
  28. package/lib/commands/usage.js +1 -1
  29. package/lib/commands/validate.js +32 -3
  30. package/lib/commands/vault.js +46 -9
  31. package/lib/python/__init__.py +0 -0
  32. package/lib/python/agent_quotas.py +525 -0
  33. package/lib/python/anomaly_alert.py +397 -0
  34. package/lib/python/anti_pattern_detector.py +799 -0
  35. package/lib/python/auth.py +443 -0
  36. package/lib/python/capi_profile_guard.py +477 -0
  37. package/lib/python/compliance_report.py +581 -0
  38. package/lib/python/drift_detector.py +388 -0
  39. package/lib/python/experience_pipeline.py +1130 -0
  40. package/lib/python/graph.py +19 -0
  41. package/lib/python/graph_core.py +293 -0
  42. package/lib/python/graph_io.py +179 -0
  43. package/lib/python/graph_legacy.py +2052 -0
  44. package/lib/python/graph_legacy_helpers.py +221 -0
  45. package/lib/python/graph_outcomes_core.py +85 -0
  46. package/lib/python/graph_queries.py +171 -0
  47. package/lib/python/graph_slice.py +198 -0
  48. package/lib/python/graph_slicer.py +576 -0
  49. package/lib/python/graph_slicer_cli.py +60 -0
  50. package/lib/python/graph_validation.py +64 -0
  51. package/lib/python/heatmap.py +934 -0
  52. package/lib/python/json_utils.py +193 -0
  53. package/lib/python/mcp_exposure_check.py +247 -0
  54. package/lib/python/model_router.py +1434 -0
  55. package/lib/python/project_manager.py +621 -0
  56. package/lib/python/provider_profiles.py +1618 -0
  57. package/lib/python/provider_registry.py +1211 -0
  58. package/lib/python/provider_registry_cli.py +125 -0
  59. package/lib/python/receipt_png.py +727 -0
  60. package/lib/python/structural_memory.py +325 -0
  61. package/lib/python/swarm_cost.py +177 -0
  62. package/lib/python/usage_ledger.py +569 -0
  63. package/lib/scripts/mcp_tier_config.py +240 -0
  64. package/lib/shared.js +97 -14
  65. package/lib/tui/index.mjs +35174 -0
  66. package/lib/utils/activation_telemetry.js +230 -11
  67. package/lib/utils/constants.js +7 -1
  68. package/lib/utils/export-bundler.js +285 -0
  69. package/lib/utils/identity.js +198 -1
  70. package/lib/utils/mcp-auth.js +81 -15
  71. package/lib/utils/plan.js +1 -1
  72. package/lib/vault/index.js +19 -3
  73. package/lib/vault/storage.js +21 -2
  74. package/lib/wizard.js +5 -2
  75. package/package.json +9 -3
  76. package/scripts/build-python-bundle.js +106 -0
  77. package/scripts/build-tui.js +14 -1
  78. package/scripts/harvest_experience.py +523 -0
  79. package/scripts/postinstall.js +15 -9
@@ -3,23 +3,37 @@ const crypto = require("crypto");
3
3
  const shared = require("../shared");
4
4
  const {
5
5
  T, R, D, W, log,
6
- fs, path,
6
+ fs, path, os,
7
7
  VERSION, SUPPORTED_CLIS,
8
8
  CONFIG_DIR, PROJECTS_FILE,
9
9
  apiCall, makeEnsureAuthenticated, ensureLicenseActivation, loadAuthState,
10
- collectMetadata, buildProjectIdentity, registerProject,
11
- writeFiles, sendProjectHeartbeat, recordExperienceEvent,
12
- logFirstRunSuccess,
10
+ collectMetadata, buildProjectIdentity, registerProject, projectIdFor, hashManifestFiles,
11
+ loadProjectBinding, boundProjectIdentityFromBinding, validateProjectDisplayName,
12
+ writeProjectBinding,
13
+ writeFiles, missingLiveManifestDefaults, ensureLiveManifestDefaults,
14
+ sendProjectHeartbeat, recordExperienceEvent,
15
+ logFirstRunSuccess, checkVersion,
13
16
  } = shared;
14
17
  const { cmdAuthLogin, ensureAccountForActivation, parseActivationArgs } = require("./auth");
15
18
  const { ensureGithubFlowPolicy, warnHooksPathDrift } = require("./gh");
16
19
  const { renderFileMapDiff, confirmOrExit, shouldAutoYes } = require("../utils/diff-preview");
17
20
  const { bootstrapMcp } = require("../utils/mcp-auth");
18
21
  const { recordActivationInit } = require("../utils/activation_telemetry");
22
+ const { projectIdForExplicitRebindFromBinding, readProjectManifest } = require("../utils/identity");
19
23
 
20
24
  const ensureAuthenticated = makeEnsureAuthenticated(cmdAuthLogin);
21
25
  const SYNC_FULL_CONTENT_LIMIT = 10000;
22
26
  const CLOUD_INIT_CHECKPOINT_REL = path.join(".0dai", "cloud-init-checkpoint.json");
27
+ const INIT_RUNTIME_GITIGNORE_LINES = [
28
+ ".0dai/",
29
+ "ai/.backups/",
30
+ "ai/manifest/audit.jsonl",
31
+ "ai/manifest/wal.jsonl",
32
+ "ai/manifest/project_graph_usage.json",
33
+ "ai/experience/archive/",
34
+ "ai/experience/events/",
35
+ "ai/experience/warnings.json",
36
+ ];
23
37
 
24
38
  function hashFile(filePath) {
25
39
  const hash = crypto.createHash("sha256");
@@ -123,6 +137,16 @@ function registerProjectOrExit(target, name, stack) {
123
137
  }
124
138
  }
125
139
 
140
+ function projectBindHostRegistryInfo(target) {
141
+ return {
142
+ scope: "host-local",
143
+ host: os.hostname(),
144
+ registry: PROJECTS_FILE,
145
+ project_path: path.resolve(target),
146
+ note: "0dai project bind writes this host's registry. A remote operator MCP server reads its own host registry and will not see this bind automatically.",
147
+ };
148
+ }
149
+
126
150
  function collectCurrentAiFiles(target) {
127
151
  const currentFiles = {};
128
152
  const aiDir = path.join(target, "ai");
@@ -225,6 +249,26 @@ function clearCloudInitCheckpoint(target) {
225
249
  try { fs.unlinkSync(cloudInitCheckpointPath(target)); } catch {}
226
250
  }
227
251
 
252
+ function ensureRuntimeGitignore(target) {
253
+ const gi = path.join(target, ".gitignore");
254
+ try {
255
+ const text = fs.existsSync(gi) ? fs.readFileSync(gi, "utf8") : "";
256
+ const existing = new Set(text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean));
257
+ const missing = INIT_RUNTIME_GITIGNORE_LINES.filter((line) => !existing.has(line));
258
+ if (!missing.length) return [];
259
+
260
+ let block = "";
261
+ if (text.length && !text.endsWith("\n")) block += "\n";
262
+ if (text.length) block += "\n";
263
+ block += "# 0dai runtime state\n";
264
+ block += `${missing.join("\n")}\n`;
265
+ fs.appendFileSync(gi, block, "utf8");
266
+ return missing;
267
+ } catch {
268
+ return [];
269
+ }
270
+ }
271
+
228
272
  function maybePauseCloudInitForAuth(target, args = [], options = {}) {
229
273
  if (options.dryRun || options.localMode) return false;
230
274
  if (_hasAuthToken()) return false;
@@ -232,6 +276,7 @@ function maybePauseCloudInitForAuth(target, args = [], options = {}) {
232
276
  if (parsed.authCode) return false;
233
277
  if (process.stdout.isTTY && process.stdin.isTTY) return false;
234
278
 
279
+ ensureRuntimeGitignore(target);
235
280
  const checkpoint = writeCloudInitCheckpoint(target, {
236
281
  args,
237
282
  stage: "auth_required",
@@ -241,23 +286,89 @@ function maybePauseCloudInitForAuth(target, args = [], options = {}) {
241
286
  console.log(` ${D}Checkpoint: ${CLOUD_INIT_CHECKPOINT_REL}${R}`);
242
287
  console.log(` ${D}Run: ${checkpoint.next_command}${R}`);
243
288
  console.log(` ${D}Then: ${checkpoint.resume_command}${R}`);
289
+ console.log(` ${D}Or run offline without signing in: 0dai init --local${R}`);
244
290
  process.exit(1);
245
291
  }
246
292
 
293
+ function parseProjectBindOptions(args = []) {
294
+ const options = { json: args.includes("--json"), name: "" };
295
+ for (let i = 0; i < args.length; i++) {
296
+ const arg = String(args[i] || "");
297
+ if (arg === "--name") {
298
+ const next = args[i + 1];
299
+ if (!next || String(next).startsWith("--")) {
300
+ return { ...options, error: "--name requires a project display name" };
301
+ }
302
+ const validated = validateProjectDisplayName(next);
303
+ if (!validated.ok) return { ...options, error: validated.error };
304
+ options.name = validated.name;
305
+ i++;
306
+ continue;
307
+ }
308
+ if (arg.startsWith("--name=")) {
309
+ const validated = validateProjectDisplayName(arg.slice("--name=".length));
310
+ if (!validated.ok) return { ...options, error: validated.error };
311
+ options.name = validated.name;
312
+ }
313
+ }
314
+ return options;
315
+ }
316
+
247
317
  async function cmdProjectBind(target, args = []) {
248
- const json = args.includes("--json");
318
+ const bindOptions = parseProjectBindOptions(args);
319
+ if (bindOptions.error) {
320
+ log(`error: ${bindOptions.error}`);
321
+ process.exit(1);
322
+ }
323
+ const json = bindOptions.json;
249
324
  const authStatus = await ensureAuthenticated("project bind");
250
325
  const license = await ensureLicenseActivation();
251
326
  const metadata = collectMetadata(target);
327
+ const existingBinding = loadProjectBinding(target);
328
+ const existingBoundIdentity = boundProjectIdentityFromBinding(target, existingBinding);
329
+ const explicitRebindProjectId = projectIdForExplicitRebindFromBinding(existingBinding);
252
330
  const identity = buildProjectIdentity(target, metadata);
331
+ if (bindOptions.name) {
332
+ identity.project_name = bindOptions.name;
333
+ identity.project_id = projectIdFor(target, bindOptions.name, identity.remote_origin || path.resolve(target));
334
+ }
335
+ if (existingBoundIdentity && existingBoundIdentity.project_id) {
336
+ identity.project_id = existingBoundIdentity.project_id;
337
+ } else if (explicitRebindProjectId) {
338
+ identity.project_id = explicitRebindProjectId;
339
+ }
253
340
  const boundProject = await bindProjectForCloud(target, metadata, identity);
341
+ const projectID = boundProject.project_id || identity.project_id;
342
+ const projectName = bindOptions.name || boundProject.name || identity.project_name;
343
+ identity.project_id = projectID;
344
+ identity.project_name = projectName;
254
345
  const stack = boundProject.stack || identity.stack || "unknown";
255
- if (await guardRegistryDrift(target, path.basename(target), args)) {
256
- registerProjectOrExit(target, path.basename(target), stack);
346
+ const registryName = bindOptions.name || path.basename(target);
347
+ if (await guardRegistryDrift(target, registryName, args)) {
348
+ registerProjectOrExit(target, registryName, stack);
257
349
  }
258
350
  const heartbeat = await sendProjectHeartbeat(target, identity, { stack }, {
259
- project_id: boundProject.project_id || identity.project_id,
351
+ project_id: projectID,
260
352
  }).catch((err) => ({ error: err.message || String(err) }));
353
+ const savedBinding = writeProjectBinding(target, {
354
+ identity,
355
+ server_project: {
356
+ ...boundProject,
357
+ project_id: projectID,
358
+ name: projectName,
359
+ stack,
360
+ binding_status: boundProject.binding_status || "bound",
361
+ },
362
+ project_id: projectID,
363
+ project_name: projectName,
364
+ stack,
365
+ binding_status: boundProject.binding_status || "bound",
366
+ binding_source: bindOptions.name ? "project bind --name" : "project bind",
367
+ account_email: authStatus.email || "",
368
+ account_plan: authStatus.plan || license.plan || "free",
369
+ activation_status: license.status || "active",
370
+ activation_id: license.activation_id || "",
371
+ });
261
372
  const payload = {
262
373
  ok: true,
263
374
  account: {
@@ -266,12 +377,18 @@ async function cmdProjectBind(target, args = []) {
266
377
  activation: license.status || "active",
267
378
  },
268
379
  project: {
269
- project_id: boundProject.project_id || identity.project_id,
270
- name: boundProject.name || identity.project_name,
380
+ project_id: projectID,
381
+ name: projectName,
271
382
  stack,
272
383
  binding_status: boundProject.binding_status || "bound",
273
384
  },
274
385
  heartbeat: !(heartbeat && heartbeat.error),
386
+ host_registry: projectBindHostRegistryInfo(target),
387
+ local_binding: {
388
+ target: savedBinding.target,
389
+ target_aliases: savedBinding.target_aliases || [],
390
+ updated_at: savedBinding.updated_at,
391
+ },
275
392
  };
276
393
  if (json) {
277
394
  console.log(JSON.stringify(payload, null, 2));
@@ -279,6 +396,8 @@ async function cmdProjectBind(target, args = []) {
279
396
  log("project bound");
280
397
  console.log(` account: ${payload.account.email || "unknown"} · plan: ${payload.account.plan} · activation: ${payload.account.activation}`);
281
398
  console.log(` project: ${payload.project.project_id}`);
399
+ console.log(` registry: ${payload.host_registry.registry}`);
400
+ console.log(` ${D}${payload.host_registry.note} See #4084.${R}`);
282
401
  if (!payload.heartbeat) console.log(` ${D}heartbeat deferred; run 0dai status to inspect local state${R}`);
283
402
  }
284
403
  return payload;
@@ -294,10 +413,22 @@ async function cmdInit(target, args = []) {
294
413
  const v = fs.readFileSync(path.join(target, "ai", "VERSION"), "utf8").trim();
295
414
  log(`ai/ layer already exists (v${v}). Run '0dai sync' to update.`);
296
415
  if (args.includes("--resume")) clearCloudInitCheckpoint(target);
297
- if (!dryRun) await runMcpBootstrap(target, args);
416
+ if (!dryRun) {
417
+ ensureRuntimeGitignore(target);
418
+ await runMcpBootstrap(target, args);
419
+ }
298
420
  return;
299
421
  }
300
422
 
423
+ if (!dryRun && !args.includes("--json")) {
424
+ await checkVersion({
425
+ force: true,
426
+ background: false,
427
+ timeoutMs: 1500,
428
+ initHint: true,
429
+ });
430
+ }
431
+
301
432
  // Pre-check: verify init quota before starting wizard (avoid 10 min wizard → "limit reached")
302
433
  if (!dryRun) {
303
434
  try {
@@ -319,6 +450,7 @@ async function cmdInit(target, args = []) {
319
450
  if (isInteractive()) {
320
451
  const result = await runWizard(target);
321
452
  if (result.completed) {
453
+ ensureRuntimeGitignore(target);
322
454
  await runMcpBootstrap(target, args);
323
455
  try {
324
456
  const ob = require("../onboarding");
@@ -335,6 +467,7 @@ async function cmdInit(target, args = []) {
335
467
  const { runWizard } = require("../wizard");
336
468
  const result = await runWizard(target, { forceLocal: true });
337
469
  if (result.completed) {
470
+ ensureRuntimeGitignore(target);
338
471
  await runMcpBootstrap(target, args);
339
472
  try {
340
473
  const ob = require("../onboarding");
@@ -357,18 +490,24 @@ async function cmdInit(target, args = []) {
357
490
  const { projectFiles, manifestContents, clis } = metadata;
358
491
  const authStatus = await ensureAccountForActivation("init", args);
359
492
  const license = await ensureLicenseActivation();
493
+ const explicitRebindProjectId = projectIdForExplicitRebindFromBinding(loadProjectBinding(target));
360
494
  const identity = buildProjectIdentity(target, metadata);
495
+ if (explicitRebindProjectId) identity.project_id = explicitRebindProjectId;
361
496
  const boundProject = await bindProjectForCloud(target, metadata, identity);
362
497
  if (dryRun) log(`${D}dry-run: would generate ai/ layer (${projectFiles.length} files, ${clis.length} CLIs)${R}`);
363
498
  if (spinner) spinner.start(`${dryRun ? "[dry-run] " : ""}Generating ai/ layer (${projectFiles.length} files, ${clis.length} CLIs)...`);
364
499
  else if (!dryRun) log(`sending to API (${projectFiles.length} files, ${clis.length} CLIs)...`);
365
500
  const result = await apiCall("/v1/init", {
366
501
  project_files: projectFiles,
367
- manifest_contents: manifestContents,
502
+ // Privacy: send only filenames + SHA-256 hashes, never raw content (closes #4016).
503
+ // Local detection (inferProjectName, detectStackHint) still reads content locally —
504
+ // the results are passed as project_name and stack so the server needs no raw content.
505
+ manifest_files: hashManifestFiles(manifestContents),
368
506
  available_clis: clis,
369
507
  dry_run: dryRun,
370
508
  minimal: minimal,
371
509
  project_name: identity.project_name,
510
+ stack: identity.stack,
372
511
  project_id: boundProject.project_id || identity.project_id,
373
512
  remote_origin: identity.remote_origin,
374
513
  origin: identity.origin,
@@ -395,6 +534,7 @@ async function cmdInit(target, args = []) {
395
534
  return;
396
535
  }
397
536
  writeFiles(target, result.files || {});
537
+ ensureLiveManifestDefaults(target);
398
538
  const envManifest = normalizeEnvironmentManifest(target);
399
539
  if (envManifest.changed) {
400
540
  log("environment manifest target normalized: ai/manifest/environment.yaml");
@@ -406,12 +546,7 @@ async function cmdInit(target, args = []) {
406
546
  fs.mkdirSync(path.dirname(versionFile), { recursive: true });
407
547
  fs.writeFileSync(versionFile, VERSION + "\n", "utf8");
408
548
 
409
- // Add to .gitignore
410
- const gi = path.join(target, ".gitignore");
411
- try {
412
- const text = fs.existsSync(gi) ? fs.readFileSync(gi, "utf8") : "";
413
- if (!text.includes(".0dai")) fs.appendFileSync(gi, "\n.0dai/\n");
414
- } catch {}
549
+ ensureRuntimeGitignore(target);
415
550
 
416
551
  const gitPolicy = ensureGithubFlowPolicy(target);
417
552
  if (gitPolicy && gitPolicy.protection && gitPolicy.protection.skipped) {
@@ -464,7 +599,9 @@ async function cmdInit(target, args = []) {
464
599
  // First-run proof gate (issue #342). All 4 gates pass once we reach here:
465
600
  // license active (line above), project bound, ai/ layer written, heartbeat sent.
466
601
  // Idempotent — only fires once per project. See docs/first-run.md.
467
- recordActivationInit(target, boundProject.project_id || identity.project_id);
602
+ recordActivationInit(target, boundProject.project_id || identity.project_id, {
603
+ stack: result.stack || identity.stack || "unknown",
604
+ });
468
605
 
469
606
  const firstRun = logFirstRunSuccess(target, {
470
607
  license: true,
@@ -495,6 +632,7 @@ async function runMcpBootstrap(target, args = []) {
495
632
  const verbs = [];
496
633
  if (result.config.added && result.config.added.length) verbs.push(`${result.config.added.length} server(s) added`);
497
634
  if (result.config.updated && result.config.updated.length) verbs.push(`${result.config.updated.length} server(s) reset`);
635
+ if (result.config.removed && result.config.removed.length) verbs.push(`${result.config.removed.length} server(s) removed`);
498
636
  if (result.config.changed) {
499
637
  log(`MCP config ready (${verbs.join(", ") || "updated"}): .mcp.json`);
500
638
  }
@@ -510,7 +648,7 @@ async function runMcpBootstrap(target, args = []) {
510
648
  } else if (result.auth.status === "disabled") {
511
649
  log("MCP cloud auth skipped (--no-mcp-auth)");
512
650
  } 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}`);
651
+ console.log(` ${D}MCP cloud auth skipped. In Claude Code, run /mcp Authenticate for claude_ai_0dai${R}`);
514
652
  }
515
653
  }
516
654
  for (const warning of result.warnings || []) log(`warn: ${warning}`);
@@ -614,10 +752,33 @@ function runDocLinkCheck(target, args = [], options = {}) {
614
752
  return { ran: true, status: 0, strict, broken: false };
615
753
  }
616
754
 
755
+ function printProtectedSyncUpdates(items, { quiet = false } = {}) {
756
+ if (quiet || !Array.isArray(items) || items.length === 0) return;
757
+ log(`${W}sync protected ${items.length} project-owned update(s)${R}`);
758
+ for (const item of items) {
759
+ const rel = item && item.path ? String(item.path) : "(unknown)";
760
+ const reason = item && item.reason ? String(item.reason) : "protected by default";
761
+ console.log(` ${D}! ${rel} — ${reason}${R}`);
762
+ }
763
+ console.log(` ${D}Pass --force-template-reset to apply these reset-style updates.${R}`);
764
+ }
765
+
766
+ function isGenericStackName(value) {
767
+ return ["", "generic", "unknown", "auto"].includes(String(value || "").trim().toLowerCase());
768
+ }
769
+
770
+ function chooseSyncStack(target, discoveryStack) {
771
+ const manifestStack = readProjectManifest(target).stack || "";
772
+ if (!isGenericStackName(manifestStack)) return manifestStack;
773
+ return discoveryStack || manifestStack || "generic";
774
+ }
775
+
617
776
  async function cmdSync(target, args = []) {
618
- const dryRun = args.includes("--dry-run");
619
- const quiet = args.includes("--quiet") || args.includes("-q");
777
+ const check = args.includes("--check");
778
+ const dryRun = args.includes("--dry-run") || check;
779
+ const quiet = args.includes("--quiet") || args.includes("-q") || check;
620
780
  const force = args.includes("--force");
781
+ const forceTemplateReset = args.includes("--force-template-reset");
621
782
 
622
783
  // Quick local check: skip API if already at current version (unless dry-run or force)
623
784
  let version = "unknown";
@@ -625,12 +786,13 @@ async function cmdSync(target, args = []) {
625
786
 
626
787
  const metadata = collectMetadata(target);
627
788
  const { manifestContents, clis } = metadata;
628
- let stack = "generic", agents = [];
789
+ let discoveryStack = "generic", agents = [];
629
790
  try {
630
791
  const d = JSON.parse(fs.readFileSync(path.join(target, "ai", "manifest", "discovery.json"), "utf8"));
631
- stack = d.stack || "generic";
792
+ discoveryStack = d.stack || "generic";
632
793
  agents = d.selected_agents || [];
633
794
  } catch {}
795
+ const stack = chooseSyncStack(target, discoveryStack);
634
796
  const identity = buildProjectIdentity(target, metadata, stack);
635
797
 
636
798
  if (dryRun) {
@@ -665,8 +827,13 @@ async function cmdSync(target, args = []) {
665
827
 
666
828
  const result = await apiCall("/v1/sync", {
667
829
  ai_version: version, stack, agents: agents.length ? agents : clis,
668
- current_files: currentFiles, manifest_contents: manifestContents,
830
+ current_files: currentFiles,
831
+ // Privacy: send only filenames + SHA-256 hashes, never raw content (closes #4031).
832
+ // Local detection (stack, agents) is derived locally and sent separately so the
833
+ // server needs no raw manifest content — mirrors the /v1/init fix from #4030.
834
+ manifest_files: hashManifestFiles(manifestContents),
669
835
  dry_run: dryRun, quiet, force,
836
+ force_template_reset: forceTemplateReset,
670
837
  project_name: identity.project_name,
671
838
  project_id: boundProject.project_id || identity.project_id,
672
839
  remote_origin: identity.remote_origin,
@@ -680,7 +847,11 @@ async function cmdSync(target, args = []) {
680
847
  process.exit(1);
681
848
  }
682
849
 
683
- const updated = result.files_updated || {};
850
+ const updated = {
851
+ ...(result.files_updated || {}),
852
+ ...missingLiveManifestDefaults(target, result.files_updated || {}),
853
+ };
854
+ const protectedUpdates = Array.isArray(result.protected_updates) ? result.protected_updates : [];
684
855
  if (dryRun) {
685
856
  const files = Object.keys(updated);
686
857
  if (files.length) {
@@ -696,8 +867,10 @@ async function cmdSync(target, args = []) {
696
867
  } else {
697
868
  log(`${D}dry-run: nothing to update${R}`);
698
869
  }
870
+ printProtectedSyncUpdates(protectedUpdates, { quiet });
699
871
  return;
700
872
  }
873
+ printProtectedSyncUpdates(protectedUpdates, { quiet });
701
874
  const changedCount = Object.keys(updated).length;
702
875
  if (changedCount) {
703
876
  if (!quiet && !shouldAutoYes(args)) {
@@ -762,12 +935,28 @@ async function cmdSync(target, args = []) {
762
935
  if (force && result.native_configs) {
763
936
  const NATIVE_CONFIGS = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", "opencode.json", ".cursorrules", ".windsurfrules", ".aider.conf.yml"];
764
937
  let overwritten = 0;
938
+ let backupDir = null;
765
939
  for (const name of NATIVE_CONFIGS) {
766
- if (result.native_configs[name]) {
767
- fs.writeFileSync(path.join(target, name), result.native_configs[name], "utf8");
768
- overwritten++;
769
- if (!quiet) console.log(` [force] ${name} overwritten from ai/ source`);
940
+ if (!result.native_configs[name]) continue;
941
+ const dest = path.join(target, name);
942
+ const next = result.native_configs[name];
943
+ // Back up a pre-existing, differing native config before overwriting, so
944
+ // a hand-edited CLAUDE.md/AGENTS.md/… is recoverable from ai/.backups
945
+ // (sync --force used to clobber these with no backup, #4363).
946
+ if (fs.existsSync(dest)) {
947
+ const existing = fs.readFileSync(dest, "utf8");
948
+ if (existing === next) continue;
949
+ if (!backupDir) {
950
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
951
+ backupDir = path.join(target, "ai", ".backups", timestamp);
952
+ fs.mkdirSync(backupDir, { recursive: true });
953
+ }
954
+ fs.writeFileSync(path.join(backupDir, name), existing, "utf8");
955
+ if (!quiet) console.log(` [force] backed up ${name} to ${path.relative(target, path.join(backupDir, name))}`);
770
956
  }
957
+ fs.writeFileSync(dest, next, "utf8");
958
+ overwritten++;
959
+ if (!quiet) console.log(` [force] ${name} overwritten from ai/ source`);
771
960
  }
772
961
  if (overwritten && !quiet) {
773
962
  log(`force: ${overwritten} native config file(s) overwritten`);
@@ -824,6 +1013,8 @@ function buildLocalSyncPreview(target, { version, stack, cliVersion }) {
824
1013
  "ai/manifest/project.yaml",
825
1014
  "ai/manifest/commands.yaml",
826
1015
  "ai/manifest/discovery.json",
1016
+ "ai/manifest/current_state.json",
1017
+ "ai/manifest/current_task.json",
827
1018
  ];
828
1019
  for (const rel of expectedAiFiles) {
829
1020
  if (!fs.existsSync(path.join(target, rel))) changes.push(`${rel} (missing)`);
@@ -882,6 +1073,7 @@ module.exports = {
882
1073
  writeCloudInitCheckpoint,
883
1074
  clearCloudInitCheckpoint,
884
1075
  buildCloudInitResumeCommand,
1076
+ ensureRuntimeGitignore,
885
1077
  collectCurrentAiFiles,
886
1078
  hashFile,
887
1079
  detectRegistryDrift,