emdash-emit 0.1.12 → 0.1.14

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 (94) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +6 -0
  3. package/dist/cli/commands/conflicts.d.ts.map +1 -1
  4. package/dist/cli/commands/conflicts.js +5 -3
  5. package/dist/cli/commands/conflicts.js.map +1 -1
  6. package/dist/cli/commands/delete.d.ts.map +1 -1
  7. package/dist/cli/commands/delete.js +2 -0
  8. package/dist/cli/commands/delete.js.map +1 -1
  9. package/dist/cli/commands/doctor.d.ts.map +1 -1
  10. package/dist/cli/commands/doctor.js +5 -0
  11. package/dist/cli/commands/doctor.js.map +1 -1
  12. package/dist/cli/commands/pull.d.ts.map +1 -1
  13. package/dist/cli/commands/pull.js +9 -1
  14. package/dist/cli/commands/pull.js.map +1 -1
  15. package/dist/cli/commands/push.d.ts.map +1 -1
  16. package/dist/cli/commands/push.js +5 -1
  17. package/dist/cli/commands/push.js.map +1 -1
  18. package/dist/cli/commands/status.d.ts.map +1 -1
  19. package/dist/cli/commands/status.js +16 -2
  20. package/dist/cli/commands/status.js.map +1 -1
  21. package/dist/cli/context.d.ts.map +1 -1
  22. package/dist/cli/context.js +12 -1
  23. package/dist/cli/context.js.map +1 -1
  24. package/dist/db/cache.d.ts +10 -2
  25. package/dist/db/cache.d.ts.map +1 -1
  26. package/dist/db/cache.js +223 -102
  27. package/dist/db/cache.js.map +1 -1
  28. package/dist/mcp/heartbeat.d.ts +7 -1
  29. package/dist/mcp/heartbeat.d.ts.map +1 -1
  30. package/dist/mcp/heartbeat.js +5 -2
  31. package/dist/mcp/heartbeat.js.map +1 -1
  32. package/dist/mcp/server.d.ts +1 -0
  33. package/dist/mcp/server.d.ts.map +1 -1
  34. package/dist/mcp/server.js +6 -1
  35. package/dist/mcp/server.js.map +1 -1
  36. package/dist/mcp/tools/sync-conflicts.d.ts.map +1 -1
  37. package/dist/mcp/tools/sync-conflicts.js +28 -5
  38. package/dist/mcp/tools/sync-conflicts.js.map +1 -1
  39. package/dist/mcp/tools/sync-delete.d.ts.map +1 -1
  40. package/dist/mcp/tools/sync-delete.js +5 -0
  41. package/dist/mcp/tools/sync-delete.js.map +1 -1
  42. package/dist/mcp/tools/sync-doctor.d.ts.map +1 -1
  43. package/dist/mcp/tools/sync-doctor.js +5 -0
  44. package/dist/mcp/tools/sync-doctor.js.map +1 -1
  45. package/dist/mcp/tools/sync-pull.d.ts.map +1 -1
  46. package/dist/mcp/tools/sync-pull.js +8 -0
  47. package/dist/mcp/tools/sync-pull.js.map +1 -1
  48. package/dist/mcp/tools/sync-push.d.ts.map +1 -1
  49. package/dist/mcp/tools/sync-push.js +10 -0
  50. package/dist/mcp/tools/sync-push.js.map +1 -1
  51. package/dist/mcp/tools/sync-status.d.ts.map +1 -1
  52. package/dist/mcp/tools/sync-status.js +7 -1
  53. package/dist/mcp/tools/sync-status.js.map +1 -1
  54. package/dist/sync/conflicts.d.ts +1 -1
  55. package/dist/sync/conflicts.d.ts.map +1 -1
  56. package/dist/sync/conflicts.js +54 -4
  57. package/dist/sync/conflicts.js.map +1 -1
  58. package/dist/sync/delete.d.ts +1 -0
  59. package/dist/sync/delete.d.ts.map +1 -1
  60. package/dist/sync/delete.js +1 -1
  61. package/dist/sync/delete.js.map +1 -1
  62. package/dist/sync/doctor.d.ts +1 -0
  63. package/dist/sync/doctor.d.ts.map +1 -1
  64. package/dist/sync/doctor.js +84 -19
  65. package/dist/sync/doctor.js.map +1 -1
  66. package/dist/sync/merge.d.ts +11 -0
  67. package/dist/sync/merge.d.ts.map +1 -1
  68. package/dist/sync/merge.js +53 -6
  69. package/dist/sync/merge.js.map +1 -1
  70. package/dist/sync/orchestrator.d.ts +2 -2
  71. package/dist/sync/orchestrator.d.ts.map +1 -1
  72. package/dist/sync/orchestrator.js +5 -1
  73. package/dist/sync/orchestrator.js.map +1 -1
  74. package/dist/sync/pull.d.ts +11 -0
  75. package/dist/sync/pull.d.ts.map +1 -1
  76. package/dist/sync/pull.js +133 -16
  77. package/dist/sync/pull.js.map +1 -1
  78. package/dist/sync/push.d.ts +2 -0
  79. package/dist/sync/push.d.ts.map +1 -1
  80. package/dist/sync/push.js +237 -71
  81. package/dist/sync/push.js.map +1 -1
  82. package/dist/sync/status.d.ts +2 -0
  83. package/dist/sync/status.d.ts.map +1 -1
  84. package/dist/sync/status.js +4 -1
  85. package/dist/sync/status.js.map +1 -1
  86. package/dist/sync/taxonomies.d.ts +7 -0
  87. package/dist/sync/taxonomies.d.ts.map +1 -1
  88. package/dist/sync/taxonomies.js +28 -1
  89. package/dist/sync/taxonomies.js.map +1 -1
  90. package/dist/sync/terms.d.ts +5 -0
  91. package/dist/sync/terms.d.ts.map +1 -1
  92. package/dist/sync/terms.js +20 -3
  93. package/dist/sync/terms.js.map +1 -1
  94. package/package.json +1 -1
package/dist/sync/push.js CHANGED
@@ -31,11 +31,11 @@ import { classifyCodeFenceLines } from '../client/portable-text.js';
31
31
  import { computeArticleRelativePath, extractDatePrefixFromPath, parseMarkdown, serializeMarkdown, } from '../utils/frontmatter.js';
32
32
  import { loadLocalTaxonomyMaps, loadLocalTaxonomyCandidates } from './taxonomies.js';
33
33
  import { runPreflightChecks } from './preflight.js';
34
- import { calculateHash, CONFLICTS_SUBDIR, isQuarantinedRelativePath } from './pull.js';
34
+ import { calculateHash, CONFLICTS_SUBDIR, isQuarantinedRelativePath, quarantineLocalCopy, } from './pull.js';
35
35
  import { hybrid3WayMerge } from './merge.js';
36
36
  import { syncRemoteBylines } from './bylines.js';
37
37
  import { updateConfigSyncedVersion } from '../utils/config.js';
38
- import { fetchAssignedTaxonomies } from './terms.js';
38
+ import { fetchAssignedTaxonomies, computeTaxonomyHash } from './terms.js';
39
39
  import { fetchRemoteMediaDetail, resolveImageMimeType, toRootRelativeMediaUrl, uploadMediaFile, } from '../client/media.js';
40
40
  import { promoteDraftMediaFile } from './media.js';
41
41
  export class PushValidationError extends Error {
@@ -359,6 +359,19 @@ async function uploadAndRewriteDraftImages(body, draftImages, options, fetchFn)
359
359
  const rewrittenBody = rewriteDraftImagesInBody(body, draftImages, uploadedByAbsPath);
360
360
  return { body: rewrittenBody, uploadedByAbsPath };
361
361
  }
362
+ function throwRemoteArticleDeleted(collection, id, targetFilePath, endpoint) {
363
+ throw new ApiError({
364
+ endpoint,
365
+ code: 'REMOTE_ARTICLE_DELETED',
366
+ status: 404,
367
+ message: `Article ${id} (${targetFilePath}) was deleted on the remote server.`,
368
+ suggestion: [
369
+ '1. Keep your edits and re-create as a new article: remove the `id:` field from frontmatter, then "emit push <file>".',
370
+ `2. Discard local edits and untrack: "emit pull ${id} --force" (local copy is preserved under posts/conflicts/)`,
371
+ `3. Delete with rescue backup: "emit delete ${targetFilePath} --discard-local-edits"`,
372
+ ].join('\n '),
373
+ });
374
+ }
362
375
  /**
363
376
  * Pushes a local markdown article to remote EmDash with full preflight guards,
364
377
  * two-tier lock lease protection, and automatic 3-way merge on conflict.
@@ -446,8 +459,7 @@ export async function pushArticle(targetFilePath, options) {
446
459
  };
447
460
  }
448
461
  }
449
- // 1. Run Preflight Guards G2 - G11
450
- // Guard against blind re-push when article is marked delete_pending (unconfirmed remote deletion)
462
+ // 1. Guard against blind re-push when article is marked delete_pending (G16)
451
463
  if (syncState?.deletePending === 1 && !options.revive && !options.force) {
452
464
  throw new ApiError({
453
465
  endpoint: `/_emdash/api/content/${collection}/${id}`,
@@ -457,16 +469,8 @@ export async function pushArticle(targetFilePath, options) {
457
469
  suggestion: `To intentionally revive and push this article to remote, pass --revive (or --force): "emit push ${targetFilePath} --revive"`,
458
470
  });
459
471
  }
460
- // Guard against blind re-push when previous taxonomy conflict is still pending
461
- if (syncState?.conflictPending === 1 && !options.resolveTaxonomy && !options.force) {
462
- throw new ApiError({
463
- endpoint: `/_emdash/api/content/${collection}/${id}`,
464
- code: 'TAXONOMY_CONFLICT_PENDING',
465
- status: 409,
466
- message: `Article ${id} (${targetFilePath}) has an unresolved taxonomy conflict from a previous sync.`,
467
- suggestion: `Pass --resolve-taxonomy=union|local|remote (or --force) to explicitly resolve and confirm: "emit push ${targetFilePath} --resolve-taxonomy=union"`,
468
- });
469
- }
472
+ // 2. Preflight Guards (including G6: UNRESOLVED_CONFLICT_MARKERS)
473
+ // Evaluated before taxonomy conflict pending so markers in markdown body are caught first
470
474
  const preflight = runPreflightChecks(targetFilePath, parsed, {
471
475
  allowPublish: options.allowPublish,
472
476
  knownTaxonomies,
@@ -480,6 +484,31 @@ export async function pushArticle(targetFilePath, options) {
480
484
  }
481
485
  // Update parsed with cleaned body (e.g. trimmed duplicate heading)
482
486
  parsed.body = preflight.cleanedBody;
487
+ // 3. Guard against blind re-push when taxonomy conflict is still pending (G15)
488
+ const isTaxConflictPending = syncState?.taxonomyConflictPending !== undefined
489
+ ? syncState.taxonomyConflictPending === 1
490
+ : syncState?.conflictPending === 1 && (syncState?.bodyConflictPending ?? 0) === 0;
491
+ if (isTaxConflictPending && !options.resolveTaxonomy && !options.force) {
492
+ throw new ApiError({
493
+ endpoint: `/_emdash/api/content/${collection}/${id}`,
494
+ code: 'TAXONOMY_CONFLICT_PENDING',
495
+ status: 409,
496
+ message: `Article ${id} (${targetFilePath}) has an unresolved taxonomy conflict from a previous sync.`,
497
+ suggestion: `Pass --resolve-taxonomy=union|local|remote (or --force) to explicitly resolve and confirm: "emit push ${targetFilePath} --resolve-taxonomy=union"`,
498
+ });
499
+ }
500
+ // 4. Guard against blind re-push when previous taxonomy update failed (G17)
501
+ if (syncState?.taxonomyApplyPending === 1 &&
502
+ !options.allowUnappliedTaxonomies &&
503
+ !options.force) {
504
+ throw new ApiError({
505
+ endpoint: `/_emdash/api/content/${collection}/${id}`,
506
+ code: 'TAXONOMY_APPLY_PENDING',
507
+ status: 409,
508
+ message: `Article ${id} (${targetFilePath}) has an unconfirmed taxonomy assignment from a previous failed update.`,
509
+ suggestion: `Run "emit pull-taxonomies" to synchronize taxonomy definitions, then re-push (or pass --allow-unapplied-taxonomies / --force).`,
510
+ });
511
+ }
483
512
  const currentRev = syncState?.remoteRev || baseSnapshot?.rev || '';
484
513
  const warnings = [...preflight.warnings];
485
514
  // Load local bylines map for converting frontmatter byline slugs to ULIDs
@@ -508,8 +537,9 @@ export async function pushArticle(targetFilePath, options) {
508
537
  const isNewArticle = !syncState;
509
538
  // Helper to synchronize taxonomy assignments
510
539
  const syncArticleTaxonomies = async (articleId) => {
540
+ const failures = [];
511
541
  if (!parsed.frontmatter.taxonomies)
512
- return;
542
+ return failures;
513
543
  const taxMaps = taxonomyMaps;
514
544
  const updateTaxonomy = async (taxonomyName, termSlugsOrIds) => {
515
545
  const slugMap = taxMaps.slugToId[taxonomyName] || {};
@@ -535,7 +565,7 @@ export async function pushArticle(targetFilePath, options) {
535
565
  : null;
536
566
  const prevTerms = taxonomyName === 'category' ? baseFm?.taxonomies?.categories : baseFm?.taxonomies?.tags;
537
567
  if (prevTerms && prevTerms.length > 0) {
538
- if (!options.allowWipeTaxonomies && !options.force) {
568
+ if (!options.allowWipeTaxonomies && !options.force && !options.resolveTaxonomy) {
539
569
  throw new ApiError({
540
570
  endpoint: `/_emdash/api/content/${collection}/${articleId}/terms/${taxonomyName}`,
541
571
  code: 'DESTRUCTIVE_TAXONOMY_WIPE',
@@ -558,6 +588,12 @@ export async function pushArticle(targetFilePath, options) {
558
588
  });
559
589
  if (!termRes.ok) {
560
590
  const errBody = await termRes.text().catch(() => '');
591
+ failures.push({
592
+ taxonomy: taxonomyName,
593
+ status: termRes.status,
594
+ error: errBody || termRes.statusText,
595
+ endpoint: termsUrl,
596
+ });
561
597
  warnings.push(`Failed to update ${taxonomyName} terms: ${termRes.statusText} ${errBody}`);
562
598
  }
563
599
  };
@@ -567,6 +603,7 @@ export async function pushArticle(targetFilePath, options) {
567
603
  if (parsed.frontmatter.taxonomies.tags) {
568
604
  await updateTaxonomy('tag', parsed.frontmatter.taxonomies.tags);
569
605
  }
606
+ return failures;
570
607
  };
571
608
  if (isNewArticle) {
572
609
  // [P2-P4] Upload/resolve standalone draft images and rewrite body
@@ -600,7 +637,8 @@ export async function pushArticle(targetFilePath, options) {
600
637
  // Update parsed frontmatter with the actual ID returned by EmDash
601
638
  parsed.frontmatter.id = actualId;
602
639
  // Sync taxonomies for newly created article
603
- await syncArticleTaxonomies(actualId);
640
+ const taxFailures = await syncArticleTaxonomies(actualId);
641
+ const hasTaxFailures = taxFailures.length > 0;
604
642
  const currentPrefix = extractDatePrefixFromPath(fullPath);
605
643
  const dateInput = parsed.frontmatter.publishedAt || currentPrefix || createdItem.createdAt || null;
606
644
  const newRelPath = computeArticleRelativePath(parsed.frontmatter.collection, parsed.frontmatter.status, actualId, parsed.frontmatter.title || parsed.frontmatter.slug, dateInput);
@@ -625,6 +663,9 @@ export async function pushArticle(targetFilePath, options) {
625
663
  }
626
664
  // [P6] Promote uploaded draft images from media/drafts/ into media/ and commit media_state
627
665
  finalizePromotedDraftImages(preflight.draftImages, uploadedByAbsPath, options, warnings);
666
+ const finalTaxHash = hasTaxFailures
667
+ ? null
668
+ : computeTaxonomyHash(parsed.frontmatter.taxonomies?.categories, parsed.frontmatter.taxonomies?.tags);
628
669
  // Update Base snapshot and Sync state
629
670
  options.cache.saveBaseSnapshot({
630
671
  id: actualId,
@@ -643,8 +684,23 @@ export async function pushArticle(targetFilePath, options) {
643
684
  slug: parsed.frontmatter.slug,
644
685
  lastSyncedAt: Math.floor(Date.now() / 1000),
645
686
  conflictPending: 0,
687
+ bodyConflictPending: 0,
688
+ taxonomyConflictPending: 0,
646
689
  conflictCopyHash: null,
690
+ conflictCopyPath: null,
691
+ conflictAt: null,
692
+ remoteTaxonomyHash: finalTaxHash,
693
+ taxonomyApplyPending: hasTaxFailures ? 1 : 0,
647
694
  });
695
+ if (hasTaxFailures) {
696
+ throw new ApiError({
697
+ endpoint: taxFailures[0].endpoint,
698
+ code: 'TAXONOMY_UPDATE_FAILED',
699
+ status: taxFailures[0].status || 400,
700
+ message: `Failed to update ${taxFailures.map((f) => f.taxonomy).join(', ')} terms on remote: ${taxFailures.map((f) => f.error).join('; ')}`,
701
+ suggestion: 'Run "emit pull-taxonomies" to update local definitions, verify taxonomy permissions, and retry push.',
702
+ });
703
+ }
648
704
  return {
649
705
  success: true,
650
706
  id: actualId,
@@ -665,14 +721,66 @@ export async function pushArticle(targetFilePath, options) {
665
721
  let payload = localToApiPayload(parsed, currentRev, effectiveOptions);
666
722
  warnings.push(...preserveOrResolveFeaturedImage(payload, parsed.frontmatter.featuredImage, options.cache, id, options.workspaceRoot));
667
723
  const putUrl = new URL(`/_emdash/api/content/${collection}/${id}`, options.origin).toString();
668
- let res = await fetchFn(putUrl, {
669
- method: 'PUT',
670
- headers: {
671
- Authorization: `Bearer ${options.token}`,
672
- 'Content-Type': 'application/json',
673
- },
674
- body: JSON.stringify(payload),
675
- });
724
+ // R1: Pre-PUT Taxonomy Audit to detect out-of-band remote taxonomy changes
725
+ let remoteTaxAudit = null;
726
+ let taxonomyDivergenceDetected = false;
727
+ const baseFm = baseSnapshot?.frontmatterYaml
728
+ ? JSON.parse(baseSnapshot.frontmatterYaml)
729
+ : null;
730
+ const hasLocalTaxonomies = Boolean(parsed.frontmatter.taxonomies &&
731
+ (parsed.frontmatter.taxonomies.categories !== undefined ||
732
+ parsed.frontmatter.taxonomies.tags !== undefined));
733
+ const hasBaseTaxonomies = Boolean(baseFm?.taxonomies && (baseFm.taxonomies.categories || baseFm.taxonomies.tags));
734
+ if ((hasLocalTaxonomies || hasBaseTaxonomies) && !options.noTaxonomyAudit) {
735
+ remoteTaxAudit = await fetchAssignedTaxonomies({
736
+ origin: options.origin,
737
+ token: options.token,
738
+ collection,
739
+ id,
740
+ fetchFn,
741
+ });
742
+ if (remoteTaxAudit.categories === null && remoteTaxAudit.tags === null) {
743
+ warnings.push('⚠️ Remote taxonomy state unknown (could not audit remote terms).');
744
+ }
745
+ else {
746
+ if (remoteTaxAudit.categories === null) {
747
+ warnings.push('⚠️ Remote category taxonomy state unknown (could not audit categories).');
748
+ }
749
+ if (remoteTaxAudit.tags === null) {
750
+ warnings.push('⚠️ Remote tag taxonomy state unknown (could not audit tags).');
751
+ }
752
+ const baselineTaxHash = syncState?.remoteTaxonomyHash ||
753
+ (baseFm?.taxonomies
754
+ ? computeTaxonomyHash(baseFm.taxonomies.categories, baseFm.taxonomies.tags)
755
+ : null);
756
+ const currentRemoteCats = remoteTaxAudit.categories !== null
757
+ ? remoteTaxAudit.categories
758
+ : baseFm?.taxonomies?.categories || [];
759
+ const currentRemoteTags = remoteTaxAudit.tags !== null ? remoteTaxAudit.tags : baseFm?.taxonomies?.tags || [];
760
+ const currentRemoteTaxHash = computeTaxonomyHash(currentRemoteCats, currentRemoteTags);
761
+ if (baselineTaxHash && currentRemoteTaxHash !== baselineTaxHash) {
762
+ taxonomyDivergenceDetected = true;
763
+ }
764
+ }
765
+ }
766
+ let res = taxonomyDivergenceDetected
767
+ ? {
768
+ status: 409,
769
+ ok: false,
770
+ json: async () => ({ error: { code: 'TAXONOMY_DIVERGENCE' } }),
771
+ text: async () => '',
772
+ }
773
+ : await fetchFn(putUrl, {
774
+ method: 'PUT',
775
+ headers: {
776
+ Authorization: `Bearer ${options.token}`,
777
+ 'Content-Type': 'application/json',
778
+ },
779
+ body: JSON.stringify(payload),
780
+ });
781
+ if (!taxonomyDivergenceDetected && (res.status === 404 || res.status === 410)) {
782
+ throwRemoteArticleDeleted(collection, id, targetFilePath, putUrl);
783
+ }
676
784
  // 3. Handle 409 Conflict
677
785
  if (res.status === 409) {
678
786
  const errJson = (await res.json().catch(() => ({})));
@@ -686,7 +794,7 @@ export async function pushArticle(targetFilePath, options) {
686
794
  suggestion: 'Wait for the other user to finish editing or release their lock before retrying.',
687
795
  });
688
796
  }
689
- // True revision mismatch conflict: fetch remote latest and run Hybrid 3-Way Merge
797
+ // True revision mismatch conflict or taxonomy divergence: fetch remote latest and run Hybrid 3-Way Merge
690
798
  const getUrl = new URL(`/_emdash/api/content/${collection}/${id}`, options.origin).toString();
691
799
  const getRes = await fetchFn(getUrl, {
692
800
  method: 'GET',
@@ -696,6 +804,9 @@ export async function pushArticle(targetFilePath, options) {
696
804
  },
697
805
  });
698
806
  if (!getRes.ok) {
807
+ if (getRes.status === 404 || getRes.status === 410) {
808
+ throwRemoteArticleDeleted(collection, id, targetFilePath, getUrl);
809
+ }
699
810
  throw new ApiError({
700
811
  message: `Failed to fetch latest remote version during conflict resolution: ${getRes.statusText}`,
701
812
  code: 'CONFLICT_REMOTE_FETCH_FAILED',
@@ -706,14 +817,15 @@ export async function pushArticle(targetFilePath, options) {
706
817
  }
707
818
  const remoteItem = (await getRes.json());
708
819
  const remoteParsed = apiResponseToLocal(remoteItem, options);
709
- // Fetch remote taxonomy assignments because GET /content/{collection}/{id} does not include them!
710
- const remoteTax = await fetchAssignedTaxonomies({
711
- origin: options.origin,
712
- token: options.token,
713
- collection,
714
- id,
715
- fetchFn,
716
- });
820
+ // Fetch remote taxonomy assignments (reuse remoteTaxAudit if available)
821
+ const remoteTax = remoteTaxAudit ||
822
+ (await fetchAssignedTaxonomies({
823
+ origin: options.origin,
824
+ token: options.token,
825
+ collection,
826
+ id,
827
+ fetchFn,
828
+ }));
717
829
  if (!remoteParsed.frontmatter.taxonomies) {
718
830
  remoteParsed.frontmatter.taxonomies = {};
719
831
  }
@@ -745,10 +857,14 @@ export async function pushArticle(targetFilePath, options) {
745
857
  const mergeResult = hybrid3WayMerge(parsed, baseInfo, {
746
858
  frontmatter: remoteParsed.frontmatter,
747
859
  body: remoteParsed.body,
860
+ }, {
861
+ strategy: options.resolveTaxonomy,
748
862
  });
749
863
  warnings.push(...mergeResult.warnings);
750
- // Check destructive taxonomy wipe guard during 3-way merge
751
- if (mergeResult.wipeWarnings && mergeResult.wipeWarnings.length > 0) {
864
+ // Check destructive taxonomy wipe guard during 3-way merge (exempt if strategy is explicitly specified)
865
+ if (!options.resolveTaxonomy &&
866
+ mergeResult.wipeWarnings &&
867
+ mergeResult.wipeWarnings.length > 0) {
752
868
  if (!options.allowWipeTaxonomies && !options.force) {
753
869
  const details = mergeResult.wipeWarnings
754
870
  .map((w) => `${w.name}: clearing ${w.remoteTerms.length} assignment(s)`)
@@ -773,30 +889,35 @@ export async function pushArticle(targetFilePath, options) {
773
889
  warnings.push(`🏷️ Merged remote taxonomy change(s) for ${item.name}: ${parts.join(' ')}`);
774
890
  }
775
891
  }
776
- // If taxonomy conflict occurred, check if resolveTaxonomy strategy was explicitly provided
777
- if (mergeResult.taxonomyConflicts && mergeResult.taxonomyConflicts.length > 0) {
778
- if (options.resolveTaxonomy || options.force) {
779
- const strategy = options.resolveTaxonomy || 'union';
780
- if (strategy === 'local') {
781
- mergeResult.frontmatter.taxonomies = parsed.frontmatter.taxonomies;
782
- }
783
- else if (strategy === 'remote') {
784
- mergeResult.frontmatter.taxonomies = remoteParsed.frontmatter.taxonomies;
785
- }
786
- else {
787
- // 'union'
788
- const unionTaxonomies = {};
789
- const allTaxNames = new Set([
790
- ...Object.keys(parsed.frontmatter.taxonomies || {}),
791
- ...Object.keys(remoteParsed.frontmatter.taxonomies || {}),
792
- ]);
793
- for (const tName of allTaxNames) {
794
- const lTerms = parsed.frontmatter.taxonomies?.[tName] || [];
795
- const rTerms = remoteParsed.frontmatter.taxonomies?.[tName] || [];
796
- unionTaxonomies[tName] = Array.from(new Set([...lTerms, ...rTerms]));
797
- }
798
- mergeResult.frontmatter.taxonomies = unionTaxonomies;
892
+ // Log strategy adoptions
893
+ if (mergeResult.strategyAdoptions && mergeResult.strategyAdoptions.length > 0) {
894
+ for (const item of mergeResult.strategyAdoptions) {
895
+ const parts = [];
896
+ if (item.discardedAdded.length > 0)
897
+ parts.push(`+${item.discardedAdded.join(', ')}`);
898
+ if (item.discardedRemoved.length > 0)
899
+ parts.push(`-${item.discardedRemoved.join(', ')}`);
900
+ const discardedStr = parts.length > 0 ? `: discarded ${parts.join(' ')}` : '';
901
+ warnings.push(`🏷️ Resolved '${item.name}' with strategy '${item.side}'${discardedStr}.`);
902
+ }
903
+ }
904
+ // Fallback post-merge resolution only if strategy was NOT specified and taxonomy conflicts occurred
905
+ if (!options.resolveTaxonomy &&
906
+ mergeResult.taxonomyConflicts &&
907
+ mergeResult.taxonomyConflicts.length > 0) {
908
+ if (options.force) {
909
+ const strategy = 'union';
910
+ const unionTaxonomies = {};
911
+ const allTaxNames = new Set([
912
+ ...Object.keys(parsed.frontmatter.taxonomies || {}),
913
+ ...Object.keys(remoteParsed.frontmatter.taxonomies || {}),
914
+ ]);
915
+ for (const tName of allTaxNames) {
916
+ const lTerms = parsed.frontmatter.taxonomies?.[tName] || [];
917
+ const rTerms = remoteParsed.frontmatter.taxonomies?.[tName] || [];
918
+ unionTaxonomies[tName] = Array.from(new Set([...lTerms, ...rTerms]));
799
919
  }
920
+ mergeResult.frontmatter.taxonomies = unionTaxonomies;
800
921
  warnings.push(`Resolved taxonomy conflicts using strategy '${strategy}'.`);
801
922
  }
802
923
  else {
@@ -805,26 +926,36 @@ export async function pushArticle(targetFilePath, options) {
805
926
  }
806
927
  }
807
928
  if (mergeResult.hasConflict) {
808
- const isTaxonomyOnlyConflict = Boolean(mergeResult.taxonomyConflicts && mergeResult.taxonomyConflicts.length > 0) &&
809
- !mergeResult.body.includes('<<<<<<< local');
929
+ const isTaxonomyConflict = Boolean(mergeResult.taxonomyConflicts && mergeResult.taxonomyConflicts.length > 0);
930
+ const hasBodyConflict = Boolean(mergeResult.body.includes('<<<<<<< LOCAL') || mergeResult.body.includes('<<<<<<< local'));
931
+ const isTaxonomyOnlyConflict = isTaxonomyConflict && !hasBodyConflict;
810
932
  const conflictedMarkdown = serializeMarkdown(mergeResult.frontmatter, mergeResult.body, options.candidates);
933
+ let quarantineRel = null;
811
934
  // Only write conflict markers into the article body if body actually has conflicts
812
935
  if (!isTaxonomyOnlyConflict) {
936
+ const currentDisk = fs.readFileSync(fullPath, 'utf8');
937
+ quarantineRel = quarantineLocalCopy(options.workspaceRoot, collection, targetFilePath, currentDisk, { isForce: false });
813
938
  fs.writeFileSync(fullPath, conflictedMarkdown, 'utf8');
814
939
  }
815
940
  const conflictedHash = calculateHash(conflictedMarkdown);
816
941
  // Update sync state:
817
- // Do NOT overwrite localHash if taxonomy-only conflict, so `status` continues to report `modified`!
942
+ // Do NOT overwrite localHash with marker hash, so `status` remains clean when markers are cleaned up!
818
943
  options.cache.saveSyncState({
819
944
  id,
820
945
  localFilePath: targetFilePath,
821
- localHash: isTaxonomyOnlyConflict ? syncState?.localHash || '' : conflictedHash,
946
+ localHash: syncState?.localHash || '',
822
947
  remoteRev: remoteParsed.rev,
823
948
  status: parsed.frontmatter.status,
824
949
  slug: parsed.frontmatter.slug,
825
950
  lastSyncedAt: Math.floor(Date.now() / 1000),
826
951
  conflictPending: 1,
952
+ bodyConflictPending: hasBodyConflict ? 1 : 0,
953
+ taxonomyConflictPending: isTaxonomyConflict ? 1 : 0,
827
954
  conflictCopyHash: conflictedHash,
955
+ conflictCopyPath: quarantineRel,
956
+ conflictAt: Math.floor(Date.now() / 1000),
957
+ remoteTaxonomyHash: computeTaxonomyHash(remoteParsed.frontmatter.taxonomies?.categories, remoteParsed.frontmatter.taxonomies?.tags),
958
+ taxonomyApplyPending: 0,
828
959
  });
829
960
  options.cache.saveBaseSnapshot({
830
961
  id,
@@ -858,17 +989,23 @@ export async function pushArticle(targetFilePath, options) {
858
989
  body: JSON.stringify(payload),
859
990
  });
860
991
  if (!res.ok) {
992
+ if (res.status === 404 || res.status === 410) {
993
+ throwRemoteArticleDeleted(collection, id, targetFilePath, putUrl);
994
+ }
861
995
  const errText = await res.text().catch(() => '');
862
- // Check if server rejected the merged taxonomy union (e.g. single category limit)
996
+ // Check if server rejected the merged taxonomy union or strategy (e.g. single category limit)
863
997
  if ((res.status === 400 || res.status === 422) &&
864
- mergeResult.autoMergedTaxonomies &&
865
- mergeResult.autoMergedTaxonomies.length > 0) {
998
+ ((mergeResult.autoMergedTaxonomies && mergeResult.autoMergedTaxonomies.length > 0) ||
999
+ (mergeResult.strategyAdoptions && mergeResult.strategyAdoptions.length > 0))) {
1000
+ const wasStrategy = mergeResult.strategyAdoptions && mergeResult.strategyAdoptions.length > 0;
866
1001
  throw new ApiError({
867
1002
  message: `Remote server rejected the merged taxonomy union (HTTP ${res.status}): ${errText || res.statusText}`,
868
1003
  code: 'TAXONOMY_UNION_REJECTED',
869
1004
  status: res.status,
870
1005
  endpoint: putUrl,
871
- suggestion: 'The server may enforce constraints such as a single category per post. Pass --resolve-taxonomy=local or --resolve-taxonomy=remote to choose one side explicitly.',
1006
+ suggestion: wasStrategy
1007
+ ? 'The remote server rejected the taxonomy adopted by your strategy (e.g. single-category constraint). Switch strategy with "--resolve-taxonomy=remote" or edit the frontmatter manually before pushing.'
1008
+ : 'The remote server enforces constraints that reject combining local and remote taxonomy terms (e.g. single-category constraint). Resolve by explicitly choosing a side: "emit push <file> --resolve-taxonomy=local" (or remote), or edit the frontmatter manually.',
872
1009
  });
873
1010
  }
874
1011
  throw new ApiError({
@@ -881,6 +1018,9 @@ export async function pushArticle(targetFilePath, options) {
881
1018
  }
882
1019
  }
883
1020
  if (!res.ok) {
1021
+ if (res.status === 404 || res.status === 410) {
1022
+ throwRemoteArticleDeleted(collection, id, targetFilePath, putUrl);
1023
+ }
884
1024
  const errText = await res.text().catch(() => '');
885
1025
  throw new ApiError({
886
1026
  message: `Push failed: ${res.statusText}${errText ? ` - ${errText}` : ''}`,
@@ -894,7 +1034,8 @@ export async function pushArticle(targetFilePath, options) {
894
1034
  const newRev = extractRev(updatedJson, currentRev);
895
1035
  // 4. Post-push updates & Path tracking
896
1036
  // Sync taxonomies on updated article
897
- await syncArticleTaxonomies(id);
1037
+ const taxFailures = await syncArticleTaxonomies(id);
1038
+ const hasTaxFailures = taxFailures.length > 0;
898
1039
  const currentPrefix = extractDatePrefixFromPath(fullPath);
899
1040
  const existingSync = options.cache.getSyncState(parsed.frontmatter.id);
900
1041
  const dateInput = parsed.frontmatter.publishedAt ||
@@ -932,11 +1073,21 @@ export async function pushArticle(targetFilePath, options) {
932
1073
  // [P6] Promote uploaded draft images from media/drafts/ into media/ and commit media_state
933
1074
  finalizePromotedDraftImages(preflight.draftImages, uploadedByAbsPath, options, warnings);
934
1075
  // Update Base snapshot
1076
+ const savedFrontmatter = { ...parsed.frontmatter };
1077
+ if (hasTaxFailures && baseFm?.taxonomies) {
1078
+ savedFrontmatter.taxonomies = { ...baseFm.taxonomies };
1079
+ }
1080
+ const finalTaxHash = hasTaxFailures
1081
+ ? syncState?.remoteTaxonomyHash ||
1082
+ (baseFm?.taxonomies
1083
+ ? computeTaxonomyHash(baseFm.taxonomies.categories, baseFm.taxonomies.tags)
1084
+ : null)
1085
+ : computeTaxonomyHash(parsed.frontmatter.taxonomies?.categories, parsed.frontmatter.taxonomies?.tags);
935
1086
  options.cache.saveBaseSnapshot({
936
1087
  id,
937
1088
  rev: finalRev,
938
1089
  bodyMarkdown: parsed.body,
939
- frontmatterYaml: JSON.stringify(parsed.frontmatter),
1090
+ frontmatterYaml: JSON.stringify(savedFrontmatter),
940
1091
  hash: newHash,
941
1092
  savedAt: Math.floor(Date.now() / 1000),
942
1093
  });
@@ -949,9 +1100,24 @@ export async function pushArticle(targetFilePath, options) {
949
1100
  status: parsed.frontmatter.status,
950
1101
  slug: parsed.frontmatter.slug,
951
1102
  lastSyncedAt: Math.floor(Date.now() / 1000),
952
- conflictPending: 0,
953
- conflictCopyHash: null,
1103
+ conflictPending: hasTaxFailures ? (syncState?.conflictPending ?? 0) : 0,
1104
+ bodyConflictPending: hasTaxFailures ? (syncState?.bodyConflictPending ?? 0) : 0,
1105
+ taxonomyConflictPending: hasTaxFailures ? (syncState?.taxonomyConflictPending ?? 0) : 0,
1106
+ conflictCopyHash: hasTaxFailures ? syncState?.conflictCopyHash : null,
1107
+ conflictCopyPath: hasTaxFailures ? syncState?.conflictCopyPath : null,
1108
+ conflictAt: hasTaxFailures ? syncState?.conflictAt : null,
1109
+ remoteTaxonomyHash: finalTaxHash,
1110
+ taxonomyApplyPending: hasTaxFailures ? 1 : 0,
954
1111
  });
1112
+ if (hasTaxFailures) {
1113
+ throw new ApiError({
1114
+ endpoint: taxFailures[0].endpoint,
1115
+ code: 'TAXONOMY_UPDATE_FAILED',
1116
+ status: taxFailures[0].status || 400,
1117
+ message: `Failed to update ${taxFailures.map((f) => f.taxonomy).join(', ')} terms on remote: ${taxFailures.map((f) => f.error).join('; ')}`,
1118
+ suggestion: 'Run "emit pull-taxonomies" to update local definitions, verify taxonomy permissions, and retry push.',
1119
+ });
1120
+ }
955
1121
  updateConfigSyncedVersion(options.workspaceRoot);
956
1122
  return {
957
1123
  success: true,