@0dai-dev/cli 4.3.6 → 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 (75) hide show
  1. package/README.md +12 -11
  2. package/bin/0dai.js +127 -30
  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 +2 -1
  7. package/lib/commands/compliance.js +1 -1
  8. package/lib/commands/doctor.js +506 -12
  9. package/lib/commands/experience.js +40 -5
  10. package/lib/commands/feedback.js +157 -15
  11. package/lib/commands/gh.js +26 -0
  12. package/lib/commands/graph.js +9 -4
  13. package/lib/commands/heatmap.js +1 -1
  14. package/lib/commands/init.js +209 -27
  15. package/lib/commands/mcp.js +111 -33
  16. package/lib/commands/models.js +138 -41
  17. package/lib/commands/provider.js +30 -59
  18. package/lib/commands/quota.js +1 -1
  19. package/lib/commands/receipt.js +1 -1
  20. package/lib/commands/run.js +14 -6
  21. package/lib/commands/runner.js +31 -1
  22. package/lib/commands/status.js +38 -10
  23. package/lib/commands/swarm.js +130 -12
  24. package/lib/commands/update.js +184 -38
  25. package/lib/commands/usage.js +1 -1
  26. package/lib/commands/validate.js +32 -3
  27. package/lib/commands/vault.js +43 -8
  28. package/lib/python/__init__.py +0 -0
  29. package/lib/python/agent_quotas.py +525 -0
  30. package/lib/python/anomaly_alert.py +397 -0
  31. package/lib/python/anti_pattern_detector.py +799 -0
  32. package/lib/python/auth.py +443 -0
  33. package/lib/python/capi_profile_guard.py +477 -0
  34. package/lib/python/compliance_report.py +581 -0
  35. package/lib/python/drift_detector.py +388 -0
  36. package/lib/python/experience_pipeline.py +1130 -0
  37. package/lib/python/graph.py +19 -0
  38. package/lib/python/graph_core.py +293 -0
  39. package/lib/python/graph_io.py +179 -0
  40. package/lib/python/graph_legacy.py +2052 -0
  41. package/lib/python/graph_legacy_helpers.py +221 -0
  42. package/lib/python/graph_outcomes_core.py +85 -0
  43. package/lib/python/graph_queries.py +171 -0
  44. package/lib/python/graph_slice.py +198 -0
  45. package/lib/python/graph_slicer.py +576 -0
  46. package/lib/python/graph_slicer_cli.py +60 -0
  47. package/lib/python/graph_validation.py +64 -0
  48. package/lib/python/heatmap.py +934 -0
  49. package/lib/python/json_utils.py +193 -0
  50. package/lib/python/mcp_exposure_check.py +247 -0
  51. package/lib/python/model_router.py +1434 -0
  52. package/lib/python/project_manager.py +621 -0
  53. package/lib/python/provider_profiles.py +1618 -0
  54. package/lib/python/provider_registry.py +1211 -0
  55. package/lib/python/provider_registry_cli.py +125 -0
  56. package/lib/python/receipt_png.py +727 -0
  57. package/lib/python/structural_memory.py +325 -0
  58. package/lib/python/swarm_cost.py +177 -0
  59. package/lib/python/usage_ledger.py +569 -0
  60. package/lib/scripts/mcp_tier_config.py +240 -0
  61. package/lib/shared.js +95 -12
  62. package/lib/tui/index.mjs +35174 -0
  63. package/lib/utils/activation_telemetry.js +1 -4
  64. package/lib/utils/constants.js +7 -1
  65. package/lib/utils/identity.js +184 -0
  66. package/lib/utils/mcp-auth.js +81 -15
  67. package/lib/utils/plan.js +1 -1
  68. package/lib/vault/index.js +19 -3
  69. package/lib/vault/storage.js +21 -2
  70. package/lib/wizard.js +5 -2
  71. package/package.json +9 -3
  72. package/scripts/build-python-bundle.js +106 -0
  73. package/scripts/build-tui.js +14 -1
  74. package/scripts/harvest_experience.py +523 -0
  75. 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, hashManifestFiles,
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,7 +490,9 @@ 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)...`);
@@ -399,6 +534,7 @@ async function cmdInit(target, args = []) {
399
534
  return;
400
535
  }
401
536
  writeFiles(target, result.files || {});
537
+ ensureLiveManifestDefaults(target);
402
538
  const envManifest = normalizeEnvironmentManifest(target);
403
539
  if (envManifest.changed) {
404
540
  log("environment manifest target normalized: ai/manifest/environment.yaml");
@@ -410,12 +546,7 @@ async function cmdInit(target, args = []) {
410
546
  fs.mkdirSync(path.dirname(versionFile), { recursive: true });
411
547
  fs.writeFileSync(versionFile, VERSION + "\n", "utf8");
412
548
 
413
- // Add to .gitignore
414
- const gi = path.join(target, ".gitignore");
415
- try {
416
- const text = fs.existsSync(gi) ? fs.readFileSync(gi, "utf8") : "";
417
- if (!text.includes(".0dai")) fs.appendFileSync(gi, "\n.0dai/\n");
418
- } catch {}
549
+ ensureRuntimeGitignore(target);
419
550
 
420
551
  const gitPolicy = ensureGithubFlowPolicy(target);
421
552
  if (gitPolicy && gitPolicy.protection && gitPolicy.protection.skipped) {
@@ -501,6 +632,7 @@ async function runMcpBootstrap(target, args = []) {
501
632
  const verbs = [];
502
633
  if (result.config.added && result.config.added.length) verbs.push(`${result.config.added.length} server(s) added`);
503
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`);
504
636
  if (result.config.changed) {
505
637
  log(`MCP config ready (${verbs.join(", ") || "updated"}): .mcp.json`);
506
638
  }
@@ -516,7 +648,7 @@ async function runMcpBootstrap(target, args = []) {
516
648
  } else if (result.auth.status === "disabled") {
517
649
  log("MCP cloud auth skipped (--no-mcp-auth)");
518
650
  } else if (result.auth.status === "skipped") {
519
- 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}`);
520
652
  }
521
653
  }
522
654
  for (const warning of result.warnings || []) log(`warn: ${warning}`);
@@ -620,10 +752,33 @@ function runDocLinkCheck(target, args = [], options = {}) {
620
752
  return { ran: true, status: 0, strict, broken: false };
621
753
  }
622
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
+
623
776
  async function cmdSync(target, args = []) {
624
- const dryRun = args.includes("--dry-run");
625
- 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;
626
780
  const force = args.includes("--force");
781
+ const forceTemplateReset = args.includes("--force-template-reset");
627
782
 
628
783
  // Quick local check: skip API if already at current version (unless dry-run or force)
629
784
  let version = "unknown";
@@ -631,12 +786,13 @@ async function cmdSync(target, args = []) {
631
786
 
632
787
  const metadata = collectMetadata(target);
633
788
  const { manifestContents, clis } = metadata;
634
- let stack = "generic", agents = [];
789
+ let discoveryStack = "generic", agents = [];
635
790
  try {
636
791
  const d = JSON.parse(fs.readFileSync(path.join(target, "ai", "manifest", "discovery.json"), "utf8"));
637
- stack = d.stack || "generic";
792
+ discoveryStack = d.stack || "generic";
638
793
  agents = d.selected_agents || [];
639
794
  } catch {}
795
+ const stack = chooseSyncStack(target, discoveryStack);
640
796
  const identity = buildProjectIdentity(target, metadata, stack);
641
797
 
642
798
  if (dryRun) {
@@ -677,6 +833,7 @@ async function cmdSync(target, args = []) {
677
833
  // server needs no raw manifest content — mirrors the /v1/init fix from #4030.
678
834
  manifest_files: hashManifestFiles(manifestContents),
679
835
  dry_run: dryRun, quiet, force,
836
+ force_template_reset: forceTemplateReset,
680
837
  project_name: identity.project_name,
681
838
  project_id: boundProject.project_id || identity.project_id,
682
839
  remote_origin: identity.remote_origin,
@@ -690,7 +847,11 @@ async function cmdSync(target, args = []) {
690
847
  process.exit(1);
691
848
  }
692
849
 
693
- 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 : [];
694
855
  if (dryRun) {
695
856
  const files = Object.keys(updated);
696
857
  if (files.length) {
@@ -706,8 +867,10 @@ async function cmdSync(target, args = []) {
706
867
  } else {
707
868
  log(`${D}dry-run: nothing to update${R}`);
708
869
  }
870
+ printProtectedSyncUpdates(protectedUpdates, { quiet });
709
871
  return;
710
872
  }
873
+ printProtectedSyncUpdates(protectedUpdates, { quiet });
711
874
  const changedCount = Object.keys(updated).length;
712
875
  if (changedCount) {
713
876
  if (!quiet && !shouldAutoYes(args)) {
@@ -772,12 +935,28 @@ async function cmdSync(target, args = []) {
772
935
  if (force && result.native_configs) {
773
936
  const NATIVE_CONFIGS = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", "opencode.json", ".cursorrules", ".windsurfrules", ".aider.conf.yml"];
774
937
  let overwritten = 0;
938
+ let backupDir = null;
775
939
  for (const name of NATIVE_CONFIGS) {
776
- if (result.native_configs[name]) {
777
- fs.writeFileSync(path.join(target, name), result.native_configs[name], "utf8");
778
- overwritten++;
779
- 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))}`);
780
956
  }
957
+ fs.writeFileSync(dest, next, "utf8");
958
+ overwritten++;
959
+ if (!quiet) console.log(` [force] ${name} overwritten from ai/ source`);
781
960
  }
782
961
  if (overwritten && !quiet) {
783
962
  log(`force: ${overwritten} native config file(s) overwritten`);
@@ -834,6 +1013,8 @@ function buildLocalSyncPreview(target, { version, stack, cliVersion }) {
834
1013
  "ai/manifest/project.yaml",
835
1014
  "ai/manifest/commands.yaml",
836
1015
  "ai/manifest/discovery.json",
1016
+ "ai/manifest/current_state.json",
1017
+ "ai/manifest/current_task.json",
837
1018
  ];
838
1019
  for (const rel of expectedAiFiles) {
839
1020
  if (!fs.existsSync(path.join(target, rel))) changes.push(`${rel} (missing)`);
@@ -892,6 +1073,7 @@ module.exports = {
892
1073
  writeCloudInitCheckpoint,
893
1074
  clearCloudInitCheckpoint,
894
1075
  buildCloudInitResumeCommand,
1076
+ ensureRuntimeGitignore,
895
1077
  collectCurrentAiFiles,
896
1078
  hashFile,
897
1079
  detectRegistryDrift,