@neurcode-ai/cli 0.9.27 → 0.9.29

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 (60) hide show
  1. package/dist/api-client.d.ts +58 -0
  2. package/dist/api-client.d.ts.map +1 -1
  3. package/dist/api-client.js +38 -0
  4. package/dist/api-client.js.map +1 -1
  5. package/dist/commands/allow.d.ts.map +1 -1
  6. package/dist/commands/allow.js +5 -19
  7. package/dist/commands/allow.js.map +1 -1
  8. package/dist/commands/apply.d.ts +1 -0
  9. package/dist/commands/apply.d.ts.map +1 -1
  10. package/dist/commands/apply.js +105 -46
  11. package/dist/commands/apply.js.map +1 -1
  12. package/dist/commands/init.d.ts +2 -0
  13. package/dist/commands/init.d.ts.map +1 -1
  14. package/dist/commands/init.js +83 -24
  15. package/dist/commands/init.js.map +1 -1
  16. package/dist/commands/plan.d.ts +4 -0
  17. package/dist/commands/plan.d.ts.map +1 -1
  18. package/dist/commands/plan.js +518 -42
  19. package/dist/commands/plan.js.map +1 -1
  20. package/dist/commands/policy.d.ts.map +1 -1
  21. package/dist/commands/policy.js +629 -0
  22. package/dist/commands/policy.js.map +1 -1
  23. package/dist/commands/prompt.d.ts +7 -1
  24. package/dist/commands/prompt.d.ts.map +1 -1
  25. package/dist/commands/prompt.js +130 -26
  26. package/dist/commands/prompt.js.map +1 -1
  27. package/dist/commands/ship.d.ts +32 -0
  28. package/dist/commands/ship.d.ts.map +1 -1
  29. package/dist/commands/ship.js +1404 -75
  30. package/dist/commands/ship.js.map +1 -1
  31. package/dist/commands/verify.d.ts +6 -0
  32. package/dist/commands/verify.d.ts.map +1 -1
  33. package/dist/commands/verify.js +542 -115
  34. package/dist/commands/verify.js.map +1 -1
  35. package/dist/index.js +89 -3
  36. package/dist/index.js.map +1 -1
  37. package/dist/utils/custom-policy-rules.d.ts +21 -0
  38. package/dist/utils/custom-policy-rules.d.ts.map +1 -0
  39. package/dist/utils/custom-policy-rules.js +71 -0
  40. package/dist/utils/custom-policy-rules.js.map +1 -0
  41. package/dist/utils/plan-cache.d.ts.map +1 -1
  42. package/dist/utils/plan-cache.js +4 -0
  43. package/dist/utils/plan-cache.js.map +1 -1
  44. package/dist/utils/policy-audit.d.ts +29 -0
  45. package/dist/utils/policy-audit.d.ts.map +1 -0
  46. package/dist/utils/policy-audit.js +208 -0
  47. package/dist/utils/policy-audit.js.map +1 -0
  48. package/dist/utils/policy-exceptions.d.ts +96 -0
  49. package/dist/utils/policy-exceptions.d.ts.map +1 -0
  50. package/dist/utils/policy-exceptions.js +389 -0
  51. package/dist/utils/policy-exceptions.js.map +1 -0
  52. package/dist/utils/policy-governance.d.ts +24 -0
  53. package/dist/utils/policy-governance.d.ts.map +1 -0
  54. package/dist/utils/policy-governance.js +124 -0
  55. package/dist/utils/policy-governance.js.map +1 -0
  56. package/dist/utils/policy-packs.d.ts +72 -1
  57. package/dist/utils/policy-packs.d.ts.map +1 -1
  58. package/dist/utils/policy-packs.js +285 -0
  59. package/dist/utils/policy-packs.js.map +1 -1
  60. package/package.json +1 -1
@@ -37,6 +37,7 @@ exports.detectIntentMode = detectIntentMode;
37
37
  exports.planCommand = planCommand;
38
38
  const fs_1 = require("fs");
39
39
  const path_1 = require("path");
40
+ const crypto_1 = require("crypto");
40
41
  const config_1 = require("../config");
41
42
  const api_client_1 = require("../api-client");
42
43
  const project_detector_1 = require("../utils/project-detector");
@@ -267,6 +268,125 @@ function renderCacheMissReason(reason, bestIntentSimilarity) {
267
268
  }
268
269
  return reasonText[reason];
269
270
  }
271
+ function hasPersistedPlanId(planId) {
272
+ return typeof planId === 'string' && planId.trim().length > 0 && planId !== 'unknown';
273
+ }
274
+ function toUnixPath(filePath) {
275
+ return filePath.replace(/\\/g, '/');
276
+ }
277
+ function parsePositiveInt(raw) {
278
+ if (!raw)
279
+ return null;
280
+ const parsed = Number(raw);
281
+ if (!Number.isFinite(parsed) || parsed <= 0)
282
+ return null;
283
+ return Math.floor(parsed);
284
+ }
285
+ function parseSnapshotMode(raw) {
286
+ if (!raw)
287
+ return null;
288
+ const normalized = raw.trim().toLowerCase();
289
+ if (normalized === 'auto' || normalized === 'full' || normalized === 'off') {
290
+ return normalized;
291
+ }
292
+ return null;
293
+ }
294
+ function resolveSnapshotMode(optionMode) {
295
+ if (process.env.NEURCODE_PLAN_SKIP_SNAPSHOTS === '1') {
296
+ return 'off';
297
+ }
298
+ const optionParsed = parseSnapshotMode(optionMode);
299
+ const envMode = parseSnapshotMode(process.env.NEURCODE_PLAN_SNAPSHOT_MODE);
300
+ return optionParsed || envMode || 'auto';
301
+ }
302
+ function resolveSnapshotMaxFiles(mode, optionValue) {
303
+ const envOverride = parsePositiveInt(process.env.NEURCODE_PLAN_SNAPSHOT_MAX_FILES);
304
+ const configured = optionValue && optionValue > 0 ? Math.floor(optionValue) : envOverride;
305
+ if (configured && configured > 0)
306
+ return configured;
307
+ return mode === 'full' ? 500 : 40;
308
+ }
309
+ function resolveSnapshotBudgetMs(mode, optionValue) {
310
+ const envOverride = parsePositiveInt(process.env.NEURCODE_PLAN_SNAPSHOT_BUDGET_MS);
311
+ const configured = optionValue && optionValue > 0 ? Math.floor(optionValue) : envOverride;
312
+ if (typeof configured === 'number' && configured > 0)
313
+ return configured;
314
+ return mode === 'full' ? 0 : 60_000;
315
+ }
316
+ function resolveSnapshotMaxBytes(mode) {
317
+ const envOverride = parsePositiveInt(process.env.NEURCODE_PLAN_SNAPSHOT_MAX_BYTES);
318
+ if (typeof envOverride === 'number' && envOverride > 0)
319
+ return envOverride;
320
+ return mode === 'full' ? 0 : 256 * 1024;
321
+ }
322
+ function resolveSnapshotBatchSize(mode) {
323
+ const envOverride = parsePositiveInt(process.env.NEURCODE_PLAN_SNAPSHOT_BATCH_SIZE);
324
+ if (typeof envOverride === 'number' && envOverride > 0) {
325
+ return Math.min(100, Math.max(1, envOverride));
326
+ }
327
+ return mode === 'full' ? 30 : 20;
328
+ }
329
+ function resolveSnapshotBatchTimeoutMs(singleRequestTimeoutMs, mode) {
330
+ const envOverride = parsePositiveInt(process.env.NEURCODE_PLAN_SNAPSHOT_BATCH_TIMEOUT_MS);
331
+ if (typeof envOverride === 'number' && envOverride > 0)
332
+ return envOverride;
333
+ const multiplier = mode === 'full' ? 5 : 4;
334
+ return Math.max(singleRequestTimeoutMs * multiplier, 12000);
335
+ }
336
+ function isBatchSnapshotEndpointUnsupported(error) {
337
+ if (!(error instanceof Error))
338
+ return false;
339
+ const message = error.message.toLowerCase();
340
+ return (message.includes('status 404') ||
341
+ message.includes('not found') ||
342
+ message.includes('not_found') ||
343
+ message.includes('method not allowed'));
344
+ }
345
+ function getSnapshotManifestPath(projectRoot) {
346
+ return (0, path_1.join)(projectRoot, '.neurcode', 'snapshot-manifest.json');
347
+ }
348
+ function loadSnapshotManifest(projectRoot) {
349
+ const manifestPath = getSnapshotManifestPath(projectRoot);
350
+ if (!(0, fs_1.existsSync)(manifestPath)) {
351
+ return {
352
+ version: 1,
353
+ updatedAt: new Date().toISOString(),
354
+ entries: {},
355
+ };
356
+ }
357
+ try {
358
+ const parsed = JSON.parse((0, fs_1.readFileSync)(manifestPath, 'utf-8'));
359
+ if (parsed && parsed.version === 1 && parsed.entries && typeof parsed.entries === 'object') {
360
+ return {
361
+ version: 1,
362
+ updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
363
+ entries: parsed.entries,
364
+ };
365
+ }
366
+ }
367
+ catch {
368
+ // Ignore manifest parse errors and start fresh.
369
+ }
370
+ return {
371
+ version: 1,
372
+ updatedAt: new Date().toISOString(),
373
+ entries: {},
374
+ };
375
+ }
376
+ function saveSnapshotManifest(projectRoot, manifest) {
377
+ const manifestPath = getSnapshotManifestPath(projectRoot);
378
+ const manifestDir = (0, path_1.join)(projectRoot, '.neurcode');
379
+ if (!(0, fs_1.existsSync)(manifestDir)) {
380
+ (0, fs_1.mkdirSync)(manifestDir, { recursive: true });
381
+ }
382
+ (0, fs_1.writeFileSync)(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
383
+ }
384
+ function computeSnapshotHash(content) {
385
+ return (0, crypto_1.createHash)('sha256').update(content, 'utf-8').digest('hex');
386
+ }
387
+ function emitPlanJson(payload) {
388
+ console.log(JSON.stringify(payload, null, 2));
389
+ }
270
390
  function emitCachedPlanHit(input) {
271
391
  if (input.touchKey) {
272
392
  try {
@@ -283,14 +403,33 @@ function emitCachedPlanHit(input) {
283
403
  else {
284
404
  console.log(chalk.dim(`⚡ Using cached plan (created: ${createdAtLabel})\n`));
285
405
  }
286
- displayPlan(input.response.plan);
287
- console.log(chalk.dim(`\nGenerated at: ${new Date(input.response.timestamp).toLocaleString()} (cached)`));
288
- if (input.response.planId && input.response.planId !== 'unknown') {
289
- console.log(chalk.bold.cyan(`\n📌 Plan ID: ${input.response.planId} (Cached)`));
290
- console.log(chalk.dim(' Run \'neurcode prompt\' to generate a Cursor/AI prompt.'));
406
+ const persistedPlan = hasPersistedPlanId(input.response.planId);
407
+ if (input.jsonMode) {
408
+ emitPlanJson({
409
+ success: persistedPlan,
410
+ cached: true,
411
+ mode: input.intentMode,
412
+ planId: persistedPlan ? input.response.planId : null,
413
+ sessionId: input.response.sessionId || null,
414
+ projectId: input.projectId || null,
415
+ timestamp: input.response.timestamp,
416
+ telemetry: input.response.telemetry,
417
+ message: persistedPlan
418
+ ? `Using ${input.mode === 'near' ? 'near-' : ''}cached plan`
419
+ : 'Plan generated but could not be persisted (missing planId)',
420
+ plan: input.response.plan,
421
+ });
422
+ }
423
+ else {
424
+ displayPlan(input.response.plan);
425
+ console.log(chalk.dim(`\nGenerated at: ${new Date(input.response.timestamp).toLocaleString()} (cached)`));
426
+ if (persistedPlan) {
427
+ console.log(chalk.bold.cyan(`\n📌 Plan ID: ${input.response.planId} (Cached)`));
428
+ console.log(chalk.dim(' Run \'neurcode prompt\' to generate a Cursor/AI prompt.'));
429
+ }
291
430
  }
292
431
  try {
293
- if (input.response.planId && input.response.planId !== 'unknown') {
432
+ if (persistedPlan) {
294
433
  (0, state_1.setActivePlanId)(input.response.planId);
295
434
  (0, state_1.setLastPlanGeneratedAt)(new Date().toISOString());
296
435
  }
@@ -311,15 +450,47 @@ function emitCachedPlanHit(input) {
311
450
  catch {
312
451
  // ignore state write errors
313
452
  }
453
+ return persistedPlan;
314
454
  }
315
455
  async function planCommand(intent, options) {
316
456
  try {
317
457
  if (!intent || !intent.trim()) {
458
+ if (options.json) {
459
+ emitPlanJson({
460
+ success: false,
461
+ cached: false,
462
+ mode: 'implementation',
463
+ planId: null,
464
+ sessionId: null,
465
+ projectId: options.projectId || null,
466
+ timestamp: new Date().toISOString(),
467
+ message: 'Intent cannot be empty',
468
+ });
469
+ }
318
470
  console.error(chalk.red('❌ Error: Intent cannot be empty. What are you building?'));
319
471
  console.log(chalk.dim('Usage: neurcode plan "<your intent description>"'));
320
472
  console.log(chalk.dim('Example: neurcode plan "Add user authentication to login page"'));
321
473
  process.exit(1);
322
474
  }
475
+ if (options.snapshotMode) {
476
+ const parsedSnapshotMode = parseSnapshotMode(String(options.snapshotMode));
477
+ if (!parsedSnapshotMode) {
478
+ if (options.json) {
479
+ emitPlanJson({
480
+ success: false,
481
+ cached: false,
482
+ mode: 'implementation',
483
+ planId: null,
484
+ sessionId: null,
485
+ projectId: options.projectId || null,
486
+ timestamp: new Date().toISOString(),
487
+ message: `Invalid --snapshot-mode "${options.snapshotMode}". Expected: auto | full | off.`,
488
+ });
489
+ }
490
+ console.error(chalk.red(`❌ Invalid --snapshot-mode "${options.snapshotMode}". Expected: auto | full | off.`));
491
+ process.exit(1);
492
+ }
493
+ }
323
494
  // Load configuration first (needed for TicketService)
324
495
  const config = (0, config_1.loadConfig)();
325
496
  // API URL is automatically set to production - no need to check
@@ -376,14 +547,18 @@ async function planCommand(intent, options) {
376
547
  });
377
548
  const cached = (0, plan_cache_1.readCachedPlan)(cwd, key);
378
549
  if (cached) {
379
- emitCachedPlanHit({
550
+ const persisted = emitCachedPlanHit({
380
551
  cwd,
381
552
  response: cached.response,
382
553
  createdAt: cached.createdAt,
383
554
  mode: 'exact',
384
555
  orgId,
385
556
  projectId: finalProjectIdEarly,
557
+ jsonMode: options.json === true,
558
+ intentMode,
386
559
  });
560
+ if (!persisted)
561
+ process.exit(2);
387
562
  return;
388
563
  }
389
564
  const near = (0, plan_cache_1.findNearCachedPlan)(cwd, {
@@ -398,7 +573,7 @@ async function planCommand(intent, options) {
398
573
  minIntentSimilarity: nearIntentSimilarityFloor,
399
574
  });
400
575
  if (near) {
401
- emitCachedPlanHit({
576
+ const persisted = emitCachedPlanHit({
402
577
  cwd,
403
578
  response: near.entry.response,
404
579
  createdAt: near.entry.createdAt,
@@ -407,7 +582,11 @@ async function planCommand(intent, options) {
407
582
  orgId,
408
583
  projectId: finalProjectIdEarly,
409
584
  touchKey: near.entry.key,
585
+ jsonMode: options.json === true,
586
+ intentMode,
410
587
  });
588
+ if (!persisted)
589
+ process.exit(2);
411
590
  return;
412
591
  }
413
592
  const miss = (0, plan_cache_1.diagnosePlanCacheMiss)(cwd, {
@@ -586,14 +765,18 @@ async function planCommand(intent, options) {
586
765
  });
587
766
  const cached = (0, plan_cache_1.readCachedPlan)(cwd, key);
588
767
  if (cached) {
589
- emitCachedPlanHit({
768
+ const persisted = emitCachedPlanHit({
590
769
  cwd,
591
770
  response: cached.response,
592
771
  createdAt: cached.createdAt,
593
772
  mode: 'exact',
594
773
  orgId,
595
774
  projectId: finalProjectIdForGuard,
775
+ jsonMode: options.json === true,
776
+ intentMode,
596
777
  });
778
+ if (!persisted)
779
+ process.exit(2);
597
780
  return;
598
781
  }
599
782
  const near = (0, plan_cache_1.findNearCachedPlan)(cwd, {
@@ -608,7 +791,7 @@ async function planCommand(intent, options) {
608
791
  minIntentSimilarity: nearIntentSimilarityFloor,
609
792
  });
610
793
  if (near) {
611
- emitCachedPlanHit({
794
+ const persisted = emitCachedPlanHit({
612
795
  cwd,
613
796
  response: near.entry.response,
614
797
  createdAt: near.entry.createdAt,
@@ -617,7 +800,11 @@ async function planCommand(intent, options) {
617
800
  orgId,
618
801
  projectId: finalProjectIdForGuard,
619
802
  touchKey: near.entry.key,
803
+ jsonMode: options.json === true,
804
+ intentMode,
620
805
  });
806
+ if (!persisted)
807
+ process.exit(2);
621
808
  return;
622
809
  }
623
810
  const miss = (0, plan_cache_1.diagnosePlanCacheMiss)(cwd, {
@@ -876,8 +1063,9 @@ async function planCommand(intent, options) {
876
1063
  // Brain progression tracking should never block plan generation.
877
1064
  }
878
1065
  }
1066
+ const persistedPlanId = hasPersistedPlanId(response.planId);
879
1067
  // Persist in local cache for instant repeat plans.
880
- if (shouldUseCache && orgId && finalProjectIdForGuard) {
1068
+ if (persistedPlanId && shouldUseCache && orgId && finalProjectIdForGuard) {
881
1069
  // Recompute repo fingerprint right before writing to cache so Neurcode-managed
882
1070
  // housekeeping (e.g. `.neurcode/config.json`, `.gitignore` updates) doesn't
883
1071
  // cause immediate cache misses on the next identical run.
@@ -918,60 +1106,301 @@ async function planCommand(intent, options) {
918
1106
  if (orgId && finalProjectIdForGuard) {
919
1107
  (0, neurcode_context_1.appendPlanToOrgProjectMemory)(cwd, orgId, finalProjectIdForGuard, intent, response);
920
1108
  }
921
- // Pre-Flight Snapshot: Capture current state of files that will be MODIFIED
922
- // This ensures we have a baseline to revert to if AI destroys files
923
- const modifyFiles = response.plan.files.filter(f => f.action === 'MODIFY');
924
- const skipSnapshotsByEnv = process.env.NEURCODE_PLAN_SKIP_SNAPSHOTS === '1';
1109
+ // Pre-flight snapshots: capture current state for MODIFY targets.
1110
+ const modifyFiles = response.plan.files.filter((f) => f.action === 'MODIFY');
1111
+ const snapshotMode = resolveSnapshotMode(options.snapshotMode);
1112
+ const snapshotMaxFiles = resolveSnapshotMaxFiles(snapshotMode, options.snapshotMaxFiles);
1113
+ const snapshotBudgetMs = resolveSnapshotBudgetMs(snapshotMode, options.snapshotBudgetMs);
1114
+ const snapshotMaxBytes = resolveSnapshotMaxBytes(snapshotMode);
1115
+ let snapshotSummary;
925
1116
  if (isReadOnlyAnalysis) {
926
1117
  console.log(chalk.dim('\n🔎 Analysis mode: skipping pre-flight file snapshots'));
1118
+ snapshotSummary = {
1119
+ mode: snapshotMode,
1120
+ attempted: 0,
1121
+ processed: 0,
1122
+ saved: 0,
1123
+ failed: 0,
1124
+ skippedUnchanged: 0,
1125
+ skippedMissing: 0,
1126
+ skippedLarge: 0,
1127
+ skippedBudget: 0,
1128
+ capped: 0,
1129
+ usedBatchApi: false,
1130
+ batchFallbackToSingle: false,
1131
+ durationMs: 0,
1132
+ };
927
1133
  }
928
- else if (skipSnapshotsByEnv) {
929
- console.log(chalk.dim('\n⚡ Snapshot capture skipped by runtime flag (fast ship mode)'));
1134
+ else if (snapshotMode === 'off') {
1135
+ console.log(chalk.dim('\n⚡ Snapshot capture disabled (snapshot mode: off)'));
1136
+ snapshotSummary = {
1137
+ mode: snapshotMode,
1138
+ attempted: 0,
1139
+ processed: 0,
1140
+ saved: 0,
1141
+ failed: 0,
1142
+ skippedUnchanged: 0,
1143
+ skippedMissing: 0,
1144
+ skippedLarge: 0,
1145
+ skippedBudget: 0,
1146
+ capped: 0,
1147
+ usedBatchApi: false,
1148
+ batchFallbackToSingle: false,
1149
+ durationMs: 0,
1150
+ };
930
1151
  }
931
1152
  else if (modifyFiles.length > 0) {
932
- console.log(chalk.dim(`\n📸 Capturing pre-flight snapshots for ${modifyFiles.length} file(s)...`));
1153
+ const snapshotStartedAt = Date.now();
1154
+ const snapshotCandidates = modifyFiles.slice(0, snapshotMaxFiles);
1155
+ const cappedCount = Math.max(0, modifyFiles.length - snapshotCandidates.length);
1156
+ const snapshotTimeoutMs = Math.max(1000, parsePositiveInt(process.env.NEURCODE_PLAN_SNAPSHOT_TIMEOUT_MS) ?? (snapshotMode === 'full' ? 15000 : 8000));
1157
+ const snapshotConcurrency = Math.min(16, Math.max(1, parsePositiveInt(process.env.NEURCODE_PLAN_SNAPSHOT_CONCURRENCY) ?? (snapshotMode === 'full' ? 8 : 6)));
1158
+ const snapshotBatchSize = resolveSnapshotBatchSize(snapshotMode);
1159
+ const snapshotBatchTimeoutMs = resolveSnapshotBatchTimeoutMs(snapshotTimeoutMs, snapshotMode);
1160
+ const deadline = snapshotBudgetMs > 0 ? Date.now() + snapshotBudgetMs : 0;
1161
+ const forceResnapshot = process.env.NEURCODE_PLAN_SNAPSHOT_FORCE === '1';
1162
+ console.log(chalk.dim(`\n📸 Capturing pre-flight snapshots for ${snapshotCandidates.length}/${modifyFiles.length} file(s)` +
1163
+ ` [mode=${snapshotMode}, batch=${snapshotBatchSize}, fallback-concurrency=${snapshotConcurrency}` +
1164
+ `${snapshotBudgetMs > 0 ? `, budget=${snapshotBudgetMs}ms` : ', budget=unbounded'}]...`));
1165
+ const withTimeout = async (promise, timeoutMs) => {
1166
+ return await Promise.race([
1167
+ promise,
1168
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`snapshot timeout after ${timeoutMs}ms`)), timeoutMs)),
1169
+ ]);
1170
+ };
1171
+ const manifest = loadSnapshotManifest(cwd);
1172
+ let manifestDirty = false;
1173
+ const preparedSnapshots = [];
1174
+ let snapshotsProcessed = 0;
933
1175
  let snapshotsSaved = 0;
934
1176
  let snapshotsFailed = 0;
935
- for (const file of modifyFiles) {
1177
+ let snapshotsSkippedMissing = 0;
1178
+ let snapshotsSkippedUnchanged = 0;
1179
+ let snapshotsSkippedLarge = 0;
1180
+ let snapshotsSkippedBudget = 0;
1181
+ let usedBatchApi = false;
1182
+ let batchFallbackToSingle = false;
1183
+ for (const current of snapshotCandidates) {
1184
+ if (deadline > 0 && Date.now() >= deadline) {
1185
+ snapshotsSkippedBudget = snapshotCandidates.length - snapshotsProcessed;
1186
+ break;
1187
+ }
1188
+ snapshotsProcessed++;
936
1189
  try {
937
- // Resolve file path relative to current working directory
938
- const filePath = (0, path_1.resolve)(cwd, file.path);
939
- // Check if file exists locally
1190
+ const normalizedPath = toUnixPath(current.path);
1191
+ const filePath = (0, path_1.resolve)(cwd, current.path);
940
1192
  if (!(0, fs_1.existsSync)(filePath)) {
941
- console.log(chalk.yellow(` ⚠️ Skipping ${file.path} (file not found locally)`));
1193
+ snapshotsSkippedMissing++;
1194
+ if (process.env.DEBUG) {
1195
+ console.log(chalk.yellow(` ⚠️ Skipping ${current.path} (file not found locally)`));
1196
+ }
942
1197
  continue;
943
1198
  }
944
- // Read current file content
945
1199
  const fileContent = (0, fs_1.readFileSync)(filePath, 'utf-8');
946
- // Save as backup version with descriptive reason
947
- const reason = `Pre-Plan Snapshot for "${intent.trim()}"`;
948
- await client.saveFileVersion(file.path, fileContent, finalProjectId, reason, 'modify', // Pre-flight snapshot is a modification checkpoint
949
- 0, // No lines added yet (this is the baseline)
950
- 0 // No lines removed yet (this is the baseline)
951
- );
952
- snapshotsSaved++;
953
- console.log(chalk.green(` ✓ Snapshot saved: ${file.path}`));
1200
+ const fileSize = Buffer.byteLength(fileContent, 'utf-8');
1201
+ if (snapshotMaxBytes > 0 && fileSize > snapshotMaxBytes) {
1202
+ snapshotsSkippedLarge++;
1203
+ if (process.env.DEBUG) {
1204
+ console.log(chalk.yellow(` ⚠️ Skipping ${current.path} (size ${fileSize}B > ${snapshotMaxBytes}B)`));
1205
+ }
1206
+ continue;
1207
+ }
1208
+ const fileHash = computeSnapshotHash(fileContent);
1209
+ const previous = manifest.entries[normalizedPath];
1210
+ if (snapshotMode === 'auto' &&
1211
+ !forceResnapshot &&
1212
+ previous &&
1213
+ previous.sha256 === fileHash &&
1214
+ previous.size === fileSize) {
1215
+ snapshotsSkippedUnchanged++;
1216
+ continue;
1217
+ }
1218
+ preparedSnapshots.push({
1219
+ path: current.path,
1220
+ normalizedPath,
1221
+ content: fileContent,
1222
+ size: fileSize,
1223
+ sha256: fileHash,
1224
+ });
954
1225
  }
955
1226
  catch (error) {
956
1227
  snapshotsFailed++;
957
- console.warn(chalk.yellow(` ⚠️ Failed to save snapshot for ${file.path}: ${error instanceof Error ? error.message : 'Unknown error'}`));
958
- // Continue with other files even if one fails
1228
+ if (process.env.DEBUG) {
1229
+ console.warn(chalk.yellow(` ⚠️ Failed to prepare snapshot for ${current.path}: ${error instanceof Error ? error.message : 'Unknown error'}`));
1230
+ }
1231
+ }
1232
+ }
1233
+ const reason = `Pre-Plan Snapshot for "${intent.trim()}"`;
1234
+ const markSnapshotSaved = (snapshot, snapshotAt) => {
1235
+ snapshotsSaved++;
1236
+ manifest.entries[snapshot.normalizedPath] = {
1237
+ sha256: snapshot.sha256,
1238
+ size: snapshot.size,
1239
+ snapshotAt: snapshotAt || new Date().toISOString(),
1240
+ };
1241
+ manifestDirty = true;
1242
+ };
1243
+ let pendingFallbackSnapshots = [];
1244
+ if (preparedSnapshots.length > 0) {
1245
+ try {
1246
+ usedBatchApi = true;
1247
+ for (let idx = 0; idx < preparedSnapshots.length; idx += snapshotBatchSize) {
1248
+ if (deadline > 0 && Date.now() >= deadline) {
1249
+ const remaining = preparedSnapshots.length - idx;
1250
+ snapshotsSkippedBudget += Math.max(0, remaining);
1251
+ break;
1252
+ }
1253
+ const chunk = preparedSnapshots.slice(idx, idx + snapshotBatchSize);
1254
+ const result = await withTimeout(client.saveFileVersionsBatch(chunk.map((snapshot) => ({
1255
+ filePath: snapshot.path,
1256
+ fileContent: snapshot.content,
1257
+ changeType: 'modify',
1258
+ linesAdded: 0,
1259
+ linesRemoved: 0,
1260
+ })), finalProjectId, reason), snapshotBatchTimeoutMs);
1261
+ const chunkByPath = new Map();
1262
+ for (const snapshot of chunk) {
1263
+ chunkByPath.set(snapshot.path, snapshot);
1264
+ }
1265
+ for (const saved of result.saved || []) {
1266
+ const prepared = chunkByPath.get(saved.filePath);
1267
+ if (!prepared)
1268
+ continue;
1269
+ markSnapshotSaved(prepared, saved.version?.createdAt);
1270
+ chunkByPath.delete(saved.filePath);
1271
+ }
1272
+ for (const failed of result.failed || []) {
1273
+ if (chunkByPath.has(failed.filePath)) {
1274
+ chunkByPath.delete(failed.filePath);
1275
+ }
1276
+ snapshotsFailed++;
1277
+ if (process.env.DEBUG) {
1278
+ console.warn(chalk.yellow(` ⚠️ Failed to save snapshot for ${failed.filePath}: ${failed.error}`));
1279
+ }
1280
+ }
1281
+ // Any residual paths were not explicitly reported by API; preserve via fallback.
1282
+ if (chunkByPath.size > 0) {
1283
+ pendingFallbackSnapshots.push(...chunkByPath.values());
1284
+ }
1285
+ }
1286
+ }
1287
+ catch (error) {
1288
+ // Backward compatibility: old API versions won't have save-batch endpoint.
1289
+ if (isBatchSnapshotEndpointUnsupported(error)) {
1290
+ batchFallbackToSingle = true;
1291
+ pendingFallbackSnapshots = [...preparedSnapshots];
1292
+ if (process.env.DEBUG || !options.json) {
1293
+ console.log(chalk.dim(' Batch snapshot endpoint unavailable; falling back to single-file snapshot uploads.'));
1294
+ }
1295
+ }
1296
+ else {
1297
+ snapshotsFailed += preparedSnapshots.length;
1298
+ if (process.env.DEBUG) {
1299
+ console.warn(chalk.yellow(` ⚠️ Batch snapshot upload failed: ${error instanceof Error ? error.message : 'Unknown error'}`));
1300
+ }
1301
+ }
1302
+ }
1303
+ }
1304
+ if (pendingFallbackSnapshots.length > 0) {
1305
+ let fallbackCursor = 0;
1306
+ const fallbackWorkers = Array.from({ length: Math.min(snapshotConcurrency, pendingFallbackSnapshots.length) }, async () => {
1307
+ while (true) {
1308
+ const index = fallbackCursor++;
1309
+ if (index >= pendingFallbackSnapshots.length) {
1310
+ return;
1311
+ }
1312
+ const snapshot = pendingFallbackSnapshots[index];
1313
+ if (deadline > 0 && Date.now() >= deadline) {
1314
+ snapshotsSkippedBudget++;
1315
+ continue;
1316
+ }
1317
+ try {
1318
+ await withTimeout(client.saveFileVersion(snapshot.path, snapshot.content, finalProjectId, reason, 'modify', 0, 0), snapshotTimeoutMs);
1319
+ markSnapshotSaved(snapshot);
1320
+ }
1321
+ catch (error) {
1322
+ snapshotsFailed++;
1323
+ if (process.env.DEBUG) {
1324
+ console.warn(chalk.yellow(` ⚠️ Failed to save snapshot for ${snapshot.path}: ${error instanceof Error ? error.message : 'Unknown error'}`));
1325
+ }
1326
+ }
1327
+ }
1328
+ });
1329
+ await Promise.all(fallbackWorkers);
1330
+ }
1331
+ if (manifestDirty) {
1332
+ try {
1333
+ manifest.updatedAt = new Date().toISOString();
1334
+ saveSnapshotManifest(cwd, manifest);
1335
+ }
1336
+ catch (manifestError) {
1337
+ if (process.env.DEBUG) {
1338
+ console.warn(chalk.yellow(` ⚠️ Could not persist snapshot manifest: ${manifestError instanceof Error ? manifestError.message : 'Unknown error'}`));
1339
+ }
959
1340
  }
960
1341
  }
961
1342
  if (snapshotsSaved > 0) {
962
1343
  console.log(chalk.green(`\n✅ ${snapshotsSaved} pre-flight snapshot(s) saved successfully`));
963
- if (snapshotsFailed > 0) {
964
- console.log(chalk.yellow(` ${snapshotsFailed} snapshot(s) failed (plan will continue)`));
965
- }
966
- console.log(chalk.dim(' You can revert these files using: neurcode revert <filePath> --to-version <version>\n'));
1344
+ console.log(chalk.dim(' You can revert these files using: neurcode revert <filePath> --to-version <version>'));
967
1345
  }
968
1346
  else if (snapshotsFailed > 0) {
969
1347
  console.log(chalk.yellow(`\n⚠️ No snapshots were saved (${snapshotsFailed} failed)`));
970
- console.log(chalk.dim(' Plan will continue, but revert functionality may be limited\n'));
1348
+ console.log(chalk.dim(' Plan will continue, but revert functionality may be limited'));
971
1349
  }
972
1350
  else {
973
- console.log(chalk.dim('\n No files to snapshot\n'));
1351
+ console.log(chalk.dim('\n No new snapshots were required'));
1352
+ }
1353
+ if (snapshotsSkippedUnchanged > 0) {
1354
+ console.log(chalk.dim(` ${snapshotsSkippedUnchanged} file(s) skipped (unchanged since last snapshot)`));
1355
+ }
1356
+ if (snapshotsSkippedMissing > 0) {
1357
+ console.log(chalk.dim(` ${snapshotsSkippedMissing} file(s) skipped (not found locally)`));
1358
+ }
1359
+ if (snapshotsSkippedLarge > 0) {
1360
+ console.log(chalk.dim(` ${snapshotsSkippedLarge} file(s) skipped (size limit)`));
1361
+ }
1362
+ if (cappedCount > 0) {
1363
+ console.log(chalk.dim(` ${cappedCount} file(s) deferred by snapshot max-file cap (${snapshotMaxFiles})`));
974
1364
  }
1365
+ if (snapshotsSkippedBudget > 0) {
1366
+ console.log(chalk.dim(` ${snapshotsSkippedBudget} file(s) deferred by snapshot budget`));
1367
+ }
1368
+ if (usedBatchApi) {
1369
+ console.log(chalk.dim(` Snapshot transport: batch${batchFallbackToSingle ? ' (with single-file fallback)' : ''}`));
1370
+ }
1371
+ console.log('');
1372
+ snapshotSummary = {
1373
+ mode: snapshotMode,
1374
+ attempted: snapshotCandidates.length,
1375
+ processed: snapshotsProcessed,
1376
+ saved: snapshotsSaved,
1377
+ failed: snapshotsFailed,
1378
+ skippedUnchanged: snapshotsSkippedUnchanged,
1379
+ skippedMissing: snapshotsSkippedMissing,
1380
+ skippedLarge: snapshotsSkippedLarge,
1381
+ skippedBudget: snapshotsSkippedBudget,
1382
+ capped: cappedCount,
1383
+ usedBatchApi,
1384
+ batchFallbackToSingle,
1385
+ durationMs: Date.now() - snapshotStartedAt,
1386
+ };
1387
+ }
1388
+ else {
1389
+ snapshotSummary = {
1390
+ mode: snapshotMode,
1391
+ attempted: 0,
1392
+ processed: 0,
1393
+ saved: 0,
1394
+ failed: 0,
1395
+ skippedUnchanged: 0,
1396
+ skippedMissing: 0,
1397
+ skippedLarge: 0,
1398
+ skippedBudget: 0,
1399
+ capped: 0,
1400
+ usedBatchApi: false,
1401
+ batchFallbackToSingle: false,
1402
+ durationMs: 0,
1403
+ };
975
1404
  }
976
1405
  // Step 3: Post-Generation Hallucination Check (DEEP SCAN)
977
1406
  // Scan ALL plan content for phantom packages - not just summaries, but full proposed code
@@ -1105,6 +1534,26 @@ async function planCommand(intent, options) {
1105
1534
  console.log(chalk.green('✅ No hallucinations detected'));
1106
1535
  }
1107
1536
  }
1537
+ if (!persistedPlanId) {
1538
+ const missingPlanMessage = 'Plan generated but failed to persist (planId missing). Cannot continue with prompt/apply/ship.';
1539
+ if (options.json) {
1540
+ emitPlanJson({
1541
+ success: false,
1542
+ cached: false,
1543
+ mode: intentMode,
1544
+ planId: null,
1545
+ sessionId: response.sessionId || null,
1546
+ projectId: finalProjectId || null,
1547
+ timestamp: response.timestamp,
1548
+ telemetry: response.telemetry,
1549
+ snapshot: snapshotSummary,
1550
+ message: missingPlanMessage,
1551
+ plan: response.plan,
1552
+ });
1553
+ }
1554
+ console.error(chalk.red(`\n❌ ${missingPlanMessage}`));
1555
+ process.exit(2);
1556
+ }
1108
1557
  // Display the plan (AFTER hallucination warnings)
1109
1558
  if (isReadOnlyAnalysis) {
1110
1559
  const writableTargets = response.plan.files.filter((file) => file.action !== 'BLOCK').length;
@@ -1113,13 +1562,13 @@ async function planCommand(intent, options) {
1113
1562
  displayPlan(response.plan);
1114
1563
  console.log(chalk.dim(`\nGenerated at: ${new Date(response.timestamp).toLocaleString()}`));
1115
1564
  // Display plan ID if available
1116
- if (response.planId && response.planId !== 'unknown') {
1565
+ if (persistedPlanId) {
1117
1566
  console.log(chalk.bold.cyan(`\n📌 Plan ID: ${response.planId} (Saved)`));
1118
1567
  console.log(chalk.dim(' Run \'neurcode prompt \' to generate a Cursor/AI prompt. (Ready now)'));
1119
1568
  }
1120
1569
  // Save sessionId and planId to state file (.neurcode/config.json)
1121
1570
  try {
1122
- if (response.planId && response.planId !== 'unknown') {
1571
+ if (persistedPlanId) {
1123
1572
  // Save active plan ID (primary) and lastPlanId (backward compatibility)
1124
1573
  (0, state_1.setActivePlanId)(response.planId);
1125
1574
  (0, state_1.setLastPlanGeneratedAt)(new Date().toISOString());
@@ -1135,8 +1584,35 @@ async function planCommand(intent, options) {
1135
1584
  console.warn(chalk.yellow(`⚠️ Could not save sessionId/planId to state: ${stateError instanceof Error ? stateError.message : 'Unknown error'}`));
1136
1585
  }
1137
1586
  }
1587
+ if (options.json) {
1588
+ emitPlanJson({
1589
+ success: true,
1590
+ cached: false,
1591
+ mode: intentMode,
1592
+ planId: response.planId,
1593
+ sessionId: response.sessionId || null,
1594
+ projectId: finalProjectId || null,
1595
+ timestamp: response.timestamp,
1596
+ telemetry: response.telemetry,
1597
+ snapshot: snapshotSummary,
1598
+ message: 'Plan generated and persisted',
1599
+ plan: response.plan,
1600
+ });
1601
+ }
1138
1602
  }
1139
1603
  catch (error) {
1604
+ if (options.json) {
1605
+ emitPlanJson({
1606
+ success: false,
1607
+ cached: false,
1608
+ mode: 'implementation',
1609
+ planId: null,
1610
+ sessionId: null,
1611
+ projectId: options.projectId || null,
1612
+ timestamp: new Date().toISOString(),
1613
+ message: error instanceof Error ? error.message : 'Unknown error',
1614
+ });
1615
+ }
1140
1616
  console.error(chalk.red('\n❌ Error generating plan:'));
1141
1617
  if (error instanceof Error) {
1142
1618
  console.error(chalk.red(error.message));