socialcli 0.3.2__tar.gz → 0.3.3__tar.gz

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 (170) hide show
  1. {socialcli-0.3.2 → socialcli-0.3.3}/LICENSE +3 -3
  2. {socialcli-0.3.2 → socialcli-0.3.3}/PKG-INFO +3 -3
  3. {socialcli-0.3.2 → socialcli-0.3.3}/README.md +2 -2
  4. {socialcli-0.3.2 → socialcli-0.3.3}/pyproject.toml +1 -1
  5. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/agent_errors.py +14 -3
  6. socialcli-0.3.3/socialctl/agent_guide.py +25 -0
  7. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/brands.py +26 -7
  8. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/cli.py +9 -7
  9. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/account_cli.py +3 -3
  10. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/inbox.py +10 -2
  11. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/inventory.py +47 -0
  12. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/inventory_cli.py +4 -2
  13. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/batches.py +42 -7
  14. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/changes.py +31 -9
  15. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/cli.py +33 -0
  16. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/community_changes.py +5 -0
  17. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/owned_changes.py +56 -19
  18. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/owned_cli.py +31 -33
  19. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/snippet_readback.py +14 -0
  20. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/youtube.py +10 -4
  21. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/youtube_community.py +8 -2
  22. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/youtube_resources.py +1 -1
  23. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/cadence.py +9 -1
  24. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/cli.py +168 -3
  25. socialcli-0.3.3/socialctl/native_schedule/preparation.py +94 -0
  26. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/report.py +1 -1
  27. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/operation_output.py +5 -4
  28. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/schedule_remote.py +3 -0
  29. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/ui/assets/agent-manual.md +120 -8
  30. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/ui/assets/agents.md +6 -3
  31. socialcli-0.3.3/socialctl/workspace.py +16 -0
  32. socialcli-0.3.2/socialctl/agent_guide.py +0 -23
  33. socialcli-0.3.2/socialctl/workspace.py +0 -8
  34. {socialcli-0.3.2 → socialcli-0.3.3}/.gitignore +0 -0
  35. {socialcli-0.3.2 → socialcli-0.3.3}/LICENSE-MIT-LEGACY +0 -0
  36. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/__init__.py +0 -0
  37. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/__main__.py +0 -0
  38. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/adapters/__init__.py +0 -0
  39. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/adapters/base.py +0 -0
  40. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/adapters/errores.py +0 -0
  41. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/adapters/facebook.py +0 -0
  42. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/adapters/instagram.py +0 -0
  43. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/adapters/tiktok.py +0 -0
  44. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/adapters/youtube.py +0 -0
  45. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/approval_cli.py +0 -0
  46. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/auth.py +0 -0
  47. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/authflow.py +0 -0
  48. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/capabilities.py +0 -0
  49. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/capabilities.yml +0 -0
  50. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/__init__.py +0 -0
  51. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/cli.py +0 -0
  52. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/client.py +0 -0
  53. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/credential_cli.py +0 -0
  54. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/credential_store.py +0 -0
  55. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/file_store.py +0 -0
  56. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/keychain.py +0 -0
  57. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/meta_user.py +0 -0
  58. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/rotation_cli.py +0 -0
  59. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/saas_client.py +0 -0
  60. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/connections/temporary_media.py +0 -0
  61. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/delivery_options.py +0 -0
  62. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/engagement.py +0 -0
  63. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/engagement_cli.py +0 -0
  64. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/executor.py +0 -0
  65. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/formatter.py +0 -0
  66. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/hosted_media.py +0 -0
  67. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/identity.py +0 -0
  68. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/inbox_cli.py +0 -0
  69. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/inventory_sources.py +0 -0
  70. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/__init__.py +0 -0
  71. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/batch_cli.py +0 -0
  72. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/comments_cli.py +0 -0
  73. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/community_batch_cli.py +0 -0
  74. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/community_batches.py +0 -0
  75. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/community_cli.py +0 -0
  76. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/community_schema.py +0 -0
  77. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/facebook_groups.py +0 -0
  78. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/groups_cli.py +0 -0
  79. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/inbox_readers.py +0 -0
  80. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/meta_changes.py +0 -0
  81. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/meta_cli.py +0 -0
  82. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/meta_client.py +0 -0
  83. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/meta_comment_inbox.py +0 -0
  84. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/meta_comments.py +0 -0
  85. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/meta_errors.py +0 -0
  86. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/meta_pagination.py +0 -0
  87. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/meta_schema.py +0 -0
  88. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/meta_webhooks.py +0 -0
  89. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/metadata_parts.py +0 -0
  90. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/owned_assets.py +0 -0
  91. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/owned_reorder.py +0 -0
  92. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/owned_schema.py +0 -0
  93. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/reply_context.py +0 -0
  94. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/reply_evidence.py +0 -0
  95. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/resource_changes.py +0 -0
  96. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/resource_cli.py +0 -0
  97. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/stories.py +0 -0
  98. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/stories_cli.py +0 -0
  99. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/youtube_metadata.py +0 -0
  100. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/management/youtube_owned.py +0 -0
  101. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/media.py +0 -0
  102. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/media_cli.py +0 -0
  103. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/media_ingress.py +0 -0
  104. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/media_preflight.py +0 -0
  105. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/media_registry.py +0 -0
  106. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/media_requirements.py +0 -0
  107. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/__init__.py +0 -0
  108. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/almacen.py +0 -0
  109. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/analytics_changes.py +0 -0
  110. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/analytics_cli.py +0 -0
  111. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/analytics_client.py +0 -0
  112. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/analytics_ingest.py +0 -0
  113. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/analytics_schema.py +0 -0
  114. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/base.py +0 -0
  115. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/facebook.py +0 -0
  116. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/insights.py +0 -0
  117. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/instagram.py +0 -0
  118. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/modelos.py +0 -0
  119. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/piezas.py +0 -0
  120. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/reach.py +0 -0
  121. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/reporting_client.py +0 -0
  122. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/resumen.py +0 -0
  123. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/slugs.py +0 -0
  124. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/tiktok.py +0 -0
  125. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/metricas/youtube.py +0 -0
  126. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/migration_cli.py +0 -0
  127. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/migration_files.py +0 -0
  128. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/models.py +0 -0
  129. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/__init__.py +0 -0
  130. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/api_delivery.py +0 -0
  131. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/api_reconcile.py +0 -0
  132. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/approval.py +0 -0
  133. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/dispatcher.py +0 -0
  134. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/facebook.py +0 -0
  135. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/facebook_reels.py +0 -0
  136. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/first_comment.py +0 -0
  137. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/handoff.py +0 -0
  138. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/instagram_delivery.py +0 -0
  139. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/instagram_runtime.py +0 -0
  140. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/inventory.py +0 -0
  141. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/migration.py +0 -0
  142. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/models.py +0 -0
  143. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/reconcile.py +0 -0
  144. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/remote_data.py +0 -0
  145. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/store.py +0 -0
  146. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/verification.py +0 -0
  147. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/native_schedule/youtube.py +0 -0
  148. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/plantillas/accounts.yml +0 -0
  149. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/plantillas/brand.md +0 -0
  150. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/plantillas/estrategia.md +0 -0
  151. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/postfile.py +0 -0
  152. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/preflight.py +0 -0
  153. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/publication_policy.py +0 -0
  154. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/publication_status.py +0 -0
  155. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/publication_steps.py +0 -0
  156. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/publish_output.py +0 -0
  157. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/publisher.py +0 -0
  158. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/queue_migration.py +0 -0
  159. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/read_budget.py +0 -0
  160. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/read_reports.py +0 -0
  161. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/rutas.py +0 -0
  162. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/schedule_sqlite.py +0 -0
  163. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/scheduler.py +0 -0
  164. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/scheduler_launchd.py +0 -0
  165. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/scheduler_systemd.py +0 -0
  166. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/source_links.py +0 -0
  167. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/ui/__init__.py +0 -0
  168. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/upload_consent.py +0 -0
  169. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/youtube_data.py +0 -0
  170. {socialcli-0.3.2 → socialcli-0.3.3}/socialctl/youtube_data_service.py +0 -0
@@ -1,7 +1,7 @@
1
1
  SocialCli Proprietary Software License
2
- Version 1 — October 24, 2026
3
- Copyright (c) 2026 Engels Florentino. All rights reserved.
4
- SocialCli is operated by Florentino.pro.
2
+ Version 1.1 — October 24, 2026
3
+ Copyright (c) 2026 Florentino.pro. All rights reserved.
4
+ Florentino.pro is the copyright holder, licensor and operator of SocialCli.
5
5
 
6
6
  1. Scope
7
7
  This license applies to SocialCli releases beginning with version 0.3.2 and
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: socialcli
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: SocialCli: publish and manage creator-owned social content from the terminal.
5
5
  Project-URL: Homepage, https://socialcli.app
6
6
  Project-URL: Documentation, https://socialcli.app/ai-agents.html
@@ -22,7 +22,7 @@ Description-Content-Type: text/markdown
22
22
 
23
23
  # SocialCli
24
24
 
25
- **Current stable version: 0.3.1 (published on PyPI). Source preparation: 0.3.2.** SocialCli SaaS is the current SocialCli
25
+ **Current stable version: 0.3.2 (published on PyPI).** SocialCli SaaS is the current SocialCli
26
26
  product. The repository root on `main` is its single development workspace;
27
27
  the CLI, hosted connection service and account/billing components live together
28
28
  here. Earlier versions remain in Git history for reference and migration.
@@ -58,7 +58,7 @@ Stripe uses test mode. Planned access costs
58
58
  USD 10/month after 14 free days from the first successful connection. No real
59
59
  subscriptions are collected in this pilot.
60
60
 
61
- The current stable client is **0.3.1**, published on TestPyPI and PyPI after
61
+ The current stable client is **0.3.2**, published on TestPyPI and PyPI after
62
62
  reviewed CI, matching artifact hashes and clean-install verification.
63
63
  The hosted service is managed and verified separately from the CLI release. The client's wheel and
64
64
  source archive are verified against the
@@ -2,7 +2,7 @@
2
2
 
3
3
  # SocialCli
4
4
 
5
- **Current stable version: 0.3.1 (published on PyPI). Source preparation: 0.3.2.** SocialCli SaaS is the current SocialCli
5
+ **Current stable version: 0.3.2 (published on PyPI).** SocialCli SaaS is the current SocialCli
6
6
  product. The repository root on `main` is its single development workspace;
7
7
  the CLI, hosted connection service and account/billing components live together
8
8
  here. Earlier versions remain in Git history for reference and migration.
@@ -38,7 +38,7 @@ Stripe uses test mode. Planned access costs
38
38
  USD 10/month after 14 free days from the first successful connection. No real
39
39
  subscriptions are collected in this pilot.
40
40
 
41
- The current stable client is **0.3.1**, published on TestPyPI and PyPI after
41
+ The current stable client is **0.3.2**, published on TestPyPI and PyPI after
42
42
  reviewed CI, matching artifact hashes and clean-install verification.
43
43
  The hosted service is managed and verified separately from the CLI release. The client's wheel and
44
44
  source archive are verified against the
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "socialcli"
3
- version = "0.3.2"
3
+ version = "0.3.3"
4
4
  description = "SocialCli: publish and manage creator-owned social content from the terminal."
5
5
  readme = "README.md"
6
6
  license = "LicenseRef-SocialCli-Proprietary"
@@ -61,6 +61,10 @@ _CATALOG = {
61
61
  'rate_limited': ('retry_later', 'Wait for the provider rate limit before a bounded read retry.'),
62
62
  'authorization_unavailable': ('user_action', 'Review the account authorization; this failure alone does not prove token expiry.'),
63
63
  'permissions_required': ('user_action', 'Review granted permissions and account eligibility with the owner.'),
64
+ 'private_video_comments_disabled': ('inspect', 'YouTube disables comments on private videos. No settings were changed. Choose an eligible video; do not change privacy without owner approval.'),
65
+ 'pilot_evidence_required': ('correct_input', 'Pilot evidence is required before persisting this API job. Run native-schedule pilot-template and preflight --eligibility FILE.'),
66
+ 'pilot_evidence_invalid': ('correct_input', 'The pilot eligibility file must match this account and reference an actual nonempty evidence file inside the brand workspace.'),
67
+ 'pilot_checklist_incomplete': ('user_action', 'The pilot checklist is incomplete. The owner must verify actual project and Studio evidence before its flags can be true.'),
64
68
  'comments_disabled': ('inspect', 'Comments are disabled on this video. Reconnecting does not enable them; do not change video settings without owner approval.'),
65
69
  'target_unavailable': ('correct_input', 'Verify the requested target and account before another read.'),
66
70
  'request_rejected': ('correct_input', 'Review supported arguments and target before another request.'),
@@ -74,6 +78,8 @@ _CATALOG = {
74
78
  'persistence_failed': ('repair_storage', 'Preserve verified observations and repair local persistence before collecting again.'),
75
79
  'unsupported_operation': ('unsupported', 'This integration does not support the requested operation.'),
76
80
  'account_not_configured': ('user_action', 'Ask the owner to select and authorize the intended account; never infer one.'),
81
+ 'workspace_unavailable': ('inspect_workspace', 'Cannot read the selected workspace. Check --root or SOCIALCLI_ROOT and local file permissions.'),
82
+ 'brand_not_found': ('correct_input', 'The named brand is not in the selected workspace. Check --brand and --root or SOCIALCLI_ROOT; do not select another brand automatically.'),
77
83
  'invalid_input': ('correct_input', 'Review the supported arguments and explicit workspace.'),
78
84
  'installation_access_required': ('sign_in', 'Ask the owner to sign in to SocialCli again.'),
79
85
  'invalid_report_options': ('correct_input', 'Review the report date, window, item limit and platform.'),
@@ -102,6 +108,7 @@ def error_for_code(code: str, *, read_only: bool = True) -> dict:
102
108
 
103
109
  def error_document(exc: BaseException, *, read_only: bool) -> dict:
104
110
  import httpx
111
+ from socialctl.brands import WorkspaceUnavailable, BrandNoEncontrada, AccountsInvalido, NombreDeMarcaInvalido
105
112
  from socialctl.read_budget import LocalConfigurationError
106
113
  from socialctl.auth import AuthError
107
114
  from socialctl.read_budget import ReadDeadlineExceeded, ReadResponseTooLarge
@@ -131,6 +138,11 @@ def error_document(exc: BaseException, *, read_only: bool) -> dict:
131
138
  code = 'operation_uncertain'
132
139
  elif isinstance(exc, (AuthError, YouTubeManagementError, ChangeError, ResourceError, CommentError, MetaError, ReadError)) and getattr(exc, '_socialcli_error_code', None) is not None:
133
140
  code = exc._socialcli_error_code
141
+ elif isinstance(exc, YouTubeDataBusy): code = 'local_data_busy'
142
+ elif isinstance(exc, YouTubeDataCleanupError): code = 'storage_failure'
143
+ elif isinstance(exc, WorkspaceUnavailable): code = 'workspace_unavailable'
144
+ elif isinstance(exc, BrandNoEncontrada): code = 'brand_not_found'
145
+ elif isinstance(exc, (AccountsInvalido, NombreDeMarcaInvalido)): code = 'invalid_input'
134
146
  elif isinstance(exc, ReadDeadlineExceeded): code = 'read_deadline'
135
147
  elif isinstance(exc, ReadResponseTooLarge): code = 'response_too_large'
136
148
  elif isinstance(exc, KeyboardInterrupt): code = 'read_interrupted'
@@ -157,8 +169,7 @@ def error_document(exc: BaseException, *, read_only: bool) -> dict:
157
169
  'configuration_missing': 'account_not_configured'}.get(exc.code, 'target_validation_failed')
158
170
  elif isinstance(exc, ResourceError): code = 'target_validation_failed'
159
171
  elif isinstance(exc, ChangeError): code = 'operation_rejected'
160
- elif isinstance(exc, YouTubeDataBusy): code = 'local_data_busy'
161
- elif isinstance(exc, (OSError, YouTubeDataCleanupError)): code = 'storage_failure'
172
+ elif isinstance(exc, OSError): code = 'storage_failure'
162
173
  elif isinstance(exc, ValueError): code = 'invalid_local_state'
163
174
  else: code = 'operation_failed'
164
175
  result = error_for_code(code,read_only=read_only)
@@ -189,7 +200,7 @@ def safe_diagnostic(value):
189
200
  'write_state': {'not_started', 'unknown', 'accepted'},
190
201
  'content_write_state': {'not_started'},
191
202
  'reason': {'thread_unverifiable', 'thread_identity_mismatch', 'thread_total_unverifiable', 'comment_binding_mismatch', 'parent_mismatch', 'replies_not_allowed', 'incomplete_reply_list', 'readback_mismatch'},
192
- 'provider_reason': {'commentsDisabled', 'quotaExceeded', 'dailyLimitExceeded', 'rateLimitExceeded', 'userRateLimitExceeded', 'insufficientPermissions', 'forbidden', 'authError', 'invalidValue', 'videoNotFound', 'commentNotFound', 'commentThreadNotFound', 'processingFailure', 'operationNotSupported'},
203
+ 'provider_reason': {'playlistNotFound', 'playlistItemNotFound', 'playlistItemsNotAccessible', 'commentsDisabled', 'quotaExceeded', 'dailyLimitExceeded', 'rateLimitExceeded', 'userRateLimitExceeded', 'insufficientPermissions', 'forbidden', 'authError', 'invalidValue', 'videoNotFound', 'commentNotFound', 'commentThreadNotFound', 'processingFailure', 'operationNotSupported'},
193
204
  }
194
205
  clean = {key: val for key, val in value.items()
195
206
  if key in allowed and isinstance(val, str) and val in allowed[key]}
@@ -0,0 +1,25 @@
1
+ """Topic selection keeps advanced contracts available without bloating bootstrap."""
2
+ from importlib.resources import files
3
+ import importlib.metadata
4
+ import re
5
+
6
+ TOPICS={
7
+ 'setup':('Workspace and YouTube outcome recovery','What agents can do','Install the reviewed release','Authorize the installation, then the social account','Persist and recover','Publication gate','Explicit approval and preflight'),
8
+ 'publish':('Publication gate','Temporary Instagram media','Canonical publishing workflow','Check supplied media locally','Explicit approval and preflight'),
9
+ 'schedule':('Preparing YouTube schedules and interpreting inventory','Publication gate','Temporary Instagram media','Scheduling and temporary media','Native scheduling input examples (offline)','Explicit approval and preflight'),
10
+ 'inbox':('Preparing YouTube schedules and interpreting inventory','Workspace and YouTube outcome recovery','Publication gate','Read metrics and prepare engagement','Read diagnostics','Comment API evidence and limits','Explicit approval and preflight','Resumable batches and reply context','Purged reply history'),
11
+ 'errors':('Workspace and YouTube outcome recovery','Persist and recover','Recovery and error actions','Read diagnostics','Comment API evidence and limits','Explicit approval and preflight','Resumable batches and reply context','Purged reply history'),
12
+ 'permissions':('Installation connection access',),
13
+ }
14
+
15
+
16
+ def guide(topic):
17
+ if topic not in TOPICS and topic!='all':
18
+ raise ValueError('Choose a guide topic: '+', '.join(TOPICS)+', all.')
19
+ full=files('socialctl.ui').joinpath('assets/agent-manual.md').read_text(encoding='utf-8')
20
+ full = full.replace('{{SOCIALCLI_VERSION}}', importlib.metadata.version('socialcli'))
21
+ if topic=='all':return full
22
+ sections=re.split(r'(?=^## )',full,flags=re.MULTILINE)
23
+ chosen=[section for section in sections if any(section.startswith('## '+heading) for heading in TOPICS[topic])]
24
+ return ('# SocialCli agent guide: '+topic+'\n\n'+''.join(chosen)+
25
+ '\nNext: socialcli agent-guide publish; socialcli agent-guide schedule; socialcli agent-guide inbox; socialcli agent-guide errors; socialcli agent-guide permissions; socialcli agent-guide all.\n')
@@ -21,6 +21,13 @@ class BrandNoEncontrada(Exception):
21
21
  """The requested brand directory does not exist."""
22
22
 
23
23
 
24
+ class WorkspaceUnavailable(OSError):
25
+ """The selected workspace cannot be inspected safely."""
26
+
27
+ def __init__(self):
28
+ super().__init__('Cannot read the selected workspace. Check --root or SOCIALCLI_ROOT and local file permissions.')
29
+
30
+
24
31
  class BrandYaExiste(Exception):
25
32
  """The requested brand directory already exists."""
26
33
 
@@ -148,13 +155,28 @@ def crear_brand(raiz_social: Path, nombre: str) -> Brand:
148
155
 
149
156
 
150
157
  def cargar_brand(raiz_social: Path, nombre: str, *, cleanup: bool = True) -> Brand:
158
+ try:
159
+ brand = _read_brand(raiz_social, nombre)
160
+ except (OSError, RuntimeError) as exc:
161
+ raise WorkspaceUnavailable() from None
162
+ if cleanup:
163
+ from socialctl.youtube_data import enforce_youtube_data
164
+ enforce_youtube_data(brand.raiz)
165
+ return brand
166
+
167
+
168
+ def _read_brand(raiz_social: Path, nombre: str) -> Brand:
151
169
  """Load an existing explicitly named brand."""
152
170
  raiz = _validar_nombre_de_marca(raiz_social, nombre)
153
171
  if not raiz.is_dir():
154
- disponibles = sorted(
155
- p.name for p in raiz_social.iterdir()
156
- if p.is_dir() and (p / "brand.md").exists()
157
- )
172
+ disponibles = []
173
+ for p in raiz_social.iterdir():
174
+ try:
175
+ if p.is_dir() and (p / 'brand.md').exists():
176
+ disponibles.append(p.name)
177
+ except OSError:
178
+ continue
179
+ disponibles.sort()
158
180
  raise BrandNoEncontrada(
159
181
  f"brand does not exist: '{nombre}' in {raiz_social}. "
160
182
  f"Available brands: {', '.join(disponibles) or 'none'}"
@@ -183,9 +205,6 @@ def cargar_brand(raiz_social: Path, nombre: str, *, cleanup: bool = True) -> Bra
183
205
  f"'{fichero_cuentas}' must contain an account mapping (key: value); received {type(contenido_cuentas).__name__}"
184
206
  )
185
207
 
186
- if cleanup:
187
- from socialctl.youtube_data import enforce_youtube_data
188
- enforce_youtube_data(raiz)
189
208
  return Brand(
190
209
  nombre=nombre,
191
210
  raiz=raiz,
@@ -149,13 +149,15 @@ def show_delivery_options(
149
149
  ):
150
150
  """Explain delivery modes and limits; does not authenticate or send media."""
151
151
  from socialctl.delivery_options import delivery_options
152
- result = delivery_options(_cargar_marca(root, brand_nombre))
153
- if json_output:
154
- typer.echo(json.dumps(result, indent=2))
155
- else:
156
- typer.echo('SocialCli does not store customer media on its SaaS server.')
157
- for platform, row in result['platforms'].items():
158
- typer.echo(f"{platform}: {row['selected_delivery']} — {row['instruction']}")
152
+ from socialctl.operation_output import OperationOutput
153
+ with OperationOutput(json_output, read_only=True) as output:
154
+ result = delivery_options(output.brand(root, brand_nombre))
155
+ if json_output:
156
+ typer.echo(json.dumps(result, indent=2))
157
+ else:
158
+ typer.echo('SocialCli does not store customer media on its SaaS server.')
159
+ for platform, row in result['platforms'].items():
160
+ typer.echo(f"{platform}: {row['selected_delivery']} — {row['instruction']}")
159
161
 
160
162
 
161
163
  def _show_version(value: bool):
@@ -355,13 +355,13 @@ def attach(platform:Platform,brand_name:str=typer.Option(...,'--brand'),root:Pat
355
355
  client.request('GET','/api/saas/v1/connections/'+opaque(previous['connection_id']),
356
356
  secret=keychain.get(brand,platform,previous))
357
357
  except ServiceRejected as exc:
358
- # Browser revocation removes capabilities, so their status
359
- # becomes 404. This explicit attach still authenticates the
358
+ # Browser revocation removes capabilities (404); a new
359
+ # owner grant replaces them (409). Explicit attach authenticates the
360
360
  # current installation, requires its owner-approved platform
361
361
  # and existing grant, and checks the exact old account below.
362
362
  if (exc.status,exc.code) not in {
363
363
  (401,'installation_revoked'),(401,'installation_expired'),
364
- (404,'connection_unavailable')}:
364
+ (404,'connection_unavailable'),(409,'connection_replaced')}:
365
365
  raise
366
366
  else:raise ValueError('A local connection already exists. Review it before replacing it.')
367
367
  result=client.request('POST','/api/saas/v1/installations/'+opaque(saved['connection_id'])+'/attach',
@@ -206,7 +206,14 @@ def sync_brand_inbox(brand,*,since,budget,only=None):
206
206
  if inventory.get('failure_class'):
207
207
  errors.append({'platform': 'youtube',
208
208
  **inventory.get('error_detail', {'code': 'read_partial', 'message': 'Video inventory incomplete.'})})
209
- media=[v['remote_id'] for v in InventoryStore(brand,platform,account).list_items() if v.get('last_sync_id')==inventory['sync_id']]
209
+ from socialctl.inventory import youtube_target_context
210
+ saved_media = {v['remote_id']: v for v in inventory_store.list_items()}
211
+ media = [ident for ident, value in saved_media.items() if value.get('last_sync_id') == inventory['sync_id']]
212
+ for ident in inventory.get('missing_resource_ids', []):
213
+ errors.append({'platform': 'youtube', 'media_id': ident, 'code': 'target_unavailable',
214
+ 'message': 'YouTube did not return this video in the inventory detail read. Its current visibility and comment coverage are unknown.',
215
+ 'availability_reason': 'not_returned_by_youtube', 'stage': 'inventory_details',
216
+ **youtube_target_context(saved_media.get(ident))})
210
217
  complete=inventory['complete'] and inventory['details_complete']
211
218
  client=YouTubeInboxClient(brand,http)
212
219
  for ident in media:
@@ -224,7 +231,8 @@ def sync_brand_inbox(brand,*,since,budget,only=None):
224
231
  observed_keys.update(store.key(i) for i in items)
225
232
  row['scopes'].append({'media_id':ident,'filter':'published','complete':ok})
226
233
  complete=complete and ok
227
- errors.extend({**_problem_document(e),'platform':platform.value,'media_id':ident} for e in problems)
234
+ errors.extend({**_problem_document(e),'platform':platform.value,'media_id':ident,
235
+ **youtube_target_context(saved_media.get(ident))} for e in problems)
228
236
  else:
229
237
  meta=MetaClient(brand,platform,http)
230
238
  listing=meta.content_list('posts' if platform is Platform.FACEBOOK else 'media',for_comments=True)
@@ -146,7 +146,10 @@ def sync_content(brand, platform, client, *, max_pages=20, resume=False):
146
146
  run["failure_class"] = "page_limit"
147
147
  run["updated_at"] = now()
148
148
  state["sync"] = run
149
+ if platform is Platform.YOUTUBE:
150
+ verified_video_deletions(brand, state)
149
151
  store.save(state)
152
+ deleted_count = sum(row.get('presence') == 'deleted_verified' for row in state['items'].values())
150
153
  return {"version": 1, "brand": brand.nombre, "platform": platform.value,
151
154
  "account_id": probe.account_id, "identity": identity, "sync_id": run["id"],
152
155
  "complete": run["complete"], "failure_class": run["failure_class"],
@@ -156,4 +159,48 @@ def sync_content(brand, platform, client, *, max_pages=20, resume=False):
156
159
  "resume_available": run["resumable"], "pages": run["pages"],
157
160
  "observed_items": len(run["seen_ids"]), "stored_items": len(state["items"]),
158
161
  "missing_resource_ids": run["missing_resource_ids"], "deletion_proven": False,
162
+ "verified_deleted_items": deleted_count, "retained_items": len(state["items"]) - deleted_count,
163
+ "deletion_scope": "Per-item verified deletion evidence; absence from sync alone is not proof",
159
164
  "source": run.get("source"), "updated_at": run["updated_at"]}
165
+
166
+
167
+ def youtube_target_context(row):
168
+ """Last observed metadata is context, never a current visibility inference."""
169
+ row = row or {}
170
+ status = row.get('observed_status')
171
+ status = status if isinstance(status, dict) else {}
172
+ return {'video_title': row.get('title'), 'scheduled_publish_at': status.get('publishAt'),
173
+ 'last_observed_privacy': status.get('privacyStatus'),
174
+ 'metadata_observed_at': row.get('last_observed_at'),
175
+ 'metadata_basis': 'last_inventory_observation' if row else 'unavailable'}
176
+
177
+
178
+ def verified_video_deletions(brand, state):
179
+ """Join durable exact deletion receipts without erasing historical inventory."""
180
+ from socialctl.management.owned_changes import OwnedStore
181
+ store = OwnedStore(brand.raiz)
182
+ count = 0
183
+ for path in store.root.glob('*.json'):
184
+ try:
185
+ change = store.load(path.stem)
186
+ except (ValueError, OSError):
187
+ continue
188
+ if (change.target_brand != brand.nombre or change.target_account != state['account_id']
189
+ or change.edit.get('action') != 'video-delete' or change.resource != 'videos'
190
+ or change.status != 'verified' or change.verified is not True
191
+ or not change.receipt or change.receipt.get('http_status') != 204):
192
+ continue
193
+ events = [event for event in change.journal if event.get('event') == 'delete_readback'
194
+ and event.get('absent_observed') is True]
195
+ row = state['items'].get(change.target_id)
196
+ if not events or row is None:
197
+ continue
198
+ # A fresh provider observation wins over an older local deletion receipt.
199
+ if row.get('last_sync_id') == (state.get('sync') or {}).get('id'):
200
+ continue
201
+ row.update(presence='deleted_verified', deletion_proven=True,
202
+ deletion_evidence={'change_id': change.id, 'fingerprint': change.fingerprint,
203
+ 'verified_at': change.updated_at.isoformat(), 'http_status': 204,
204
+ 'basis': 'receipt_and_exact_id_absence'})
205
+ count += 1
206
+ return count
@@ -40,8 +40,10 @@ def guarded(operation, json_output):
40
40
  data = operation()
41
41
  except ReadError as exc:
42
42
  data = {"version": 1, "failure_class": exc.code}
43
- except Exception:
44
- data = {"version": 1, "failure_class": "local_state_invalid"}
43
+ except Exception as exc:
44
+ from socialctl.agent_errors import error_document
45
+ data = {"version": 1, "failure_class": "local_state_invalid",
46
+ "status": "error", "error": error_document(exc, read_only=True)}
45
47
  emit(data, json_output)
46
48
  if data.get("failure_class"):
47
49
  raise typer.Exit(1)
@@ -18,7 +18,7 @@ from socialctl.management.metadata_parts import KINDS, MetadataEdit, editable_pa
18
18
  from socialctl.management.youtube import (YouTubeManagementError, YouTubeUpdateConflict,
19
19
  YouTubeUpdateRejected)
20
20
  from socialctl.management.youtube_metadata import same_metadata_snapshot
21
- from socialctl.management.snippet_readback import snippet_mismatch_fields
21
+ from socialctl.management.snippet_readback import snippet_mismatch_fields, snippet_readback_evidence
22
22
 
23
23
 
24
24
  class BatchFile(BaseModel):
@@ -172,7 +172,7 @@ def _event(op, event, **details):
172
172
  op.journal.append({"at": now_utc().isoformat(), "event": event, **details})
173
173
 
174
174
 
175
- def _mismatch_paths(current, expected):
175
+ def _mismatch_paths(current, expected, *, before=None):
176
176
  """Return bounded field names without raw bodies or values."""
177
177
  mismatches = []
178
178
  parts = editable_parts(current.resource)
@@ -193,7 +193,9 @@ def _mismatch_paths(current, expected):
193
193
  except (AttributeError, TypeError, ValueError):
194
194
  pass
195
195
  if part == "snippet":
196
- mismatches.extend(f"snippet.{field}" for field in snippet_mismatch_fields(actual, wanted))
196
+ fields = (snippet_readback_evidence(actual, wanted, before=before.get('snippet', {}))['mismatch_fields']
197
+ if before is not None else snippet_mismatch_fields(actual, wanted))
198
+ mismatches.extend(f"snippet.{field}" for field in fields)
197
199
  continue
198
200
  for field in sorted(set(actual) | set(wanted)):
199
201
  if field not in actual or field not in wanted or actual[field] != wanted[field]:
@@ -217,9 +219,9 @@ def _apply_operation(client, store, batch, op):
217
219
  store.save(batch)
218
220
  return
219
221
  if op.status in {"applying", "uncertain"}:
220
- mismatches = _mismatch_paths(current, op.after)
222
+ mismatches = _mismatch_paths(current, op.after, before=op.before)
221
223
  op.status = "manual_review" if mismatches else "applied"
222
- _event(op, "reconciled_read_only", mismatch_paths=", ".join(mismatches))
224
+ _event(op, "reconciled_read_only", mismatch_paths=", ".join(mismatches), provider_added_fields=_added_fields(current, op))
223
225
  store.save(batch)
224
226
  return
225
227
  if not same_metadata_snapshot(op.observed, current.resource, allow_etag_only=True):
@@ -267,9 +269,9 @@ def _apply_operation(client, store, batch, op):
267
269
  for attempt in readback_attempts():
268
270
  try:
269
271
  verified = client.inspect(op.video_id)
270
- mismatches = _mismatch_paths(verified, op.after)
272
+ mismatches = _mismatch_paths(verified, op.after, before=op.before)
271
273
  op.status = "uncertain" if mismatches else "applied"
272
- _event(op, "read_back_" + op.status, mismatch_paths=", ".join(mismatches), attempt=str(attempt))
274
+ _event(op, "read_back_" + op.status, mismatch_paths=", ".join(mismatches), attempt=str(attempt), provider_added_fields=_added_fields(verified, op))
273
275
  store.save(batch)
274
276
  if not mismatches:
275
277
  break
@@ -333,3 +335,36 @@ def prepare_restore(client, store: BatchStore, original_id: str) -> Batch:
333
335
  operations.append(_operation(client, edit, resets=resets))
334
336
  return _save_new(store, _batch(client, operations, {str(source): source_hash},
335
337
  restored_from=original_id, unavailable=unavailable))
338
+
339
+
340
+ def _added_fields(current, op):
341
+ if 'snippet' not in op.after:
342
+ return ''
343
+ evidence = snippet_readback_evidence(editable_parts(current.resource)['snippet'],
344
+ op.after['snippet'], before=op.before.get('snippet', {}))
345
+ return ', '.join(evidence['provider_added_fields'])
346
+
347
+
348
+ def reconcile_batch(client, store, batch_id):
349
+ """Recover only attempted children; never execute untouched batch members."""
350
+ with store.apply_lock(batch_id):
351
+ batch = store.load(batch_id)
352
+ _bound(client, batch)
353
+ for op in batch.operations:
354
+ if op.status not in {'applying', 'uncertain', 'manual_review'}:
355
+ continue
356
+ if not any(event.get('event') == 'write_intent' for event in op.journal):
357
+ _event(op, 'recovery_blocked', reason='write_intent_missing')
358
+ continue
359
+ try:
360
+ current = client.inspect(op.video_id)
361
+ mismatches = _mismatch_paths(current, op.after, before=op.before)
362
+ op.status = 'manual_review' if mismatches else 'applied'
363
+ _event(op, 'reconciled_read_only', mismatch_paths=', '.join(mismatches),
364
+ provider_added_fields=_added_fields(current, op))
365
+ except (YouTubeManagementError, ChangeError, OSError) as exc:
366
+ from socialctl.agent_errors import error_document
367
+ _event(op, 'reconciliation_failed', error_detail_json=json.dumps(
368
+ error_document(exc, read_only=True), sort_keys=True))
369
+ store.save(batch)
370
+ return batch
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from socialctl.management.snippet_readback import same_snippet_readback, snippet_mismatch_fields, readback_attempts
5
+ from socialctl.management.snippet_readback import same_snippet_readback, snippet_mismatch_fields, readback_attempts, snippet_readback_evidence
6
6
 
7
7
  import fcntl
8
8
  import hashlib
@@ -445,14 +445,15 @@ def apply_youtube_change(
445
445
  if detail['retryable']:
446
446
  continue
447
447
  break
448
- mismatch = snippet_mismatch_fields(youtube.editable_snippet(verified), change.after)
448
+ evidence = snippet_readback_evidence(youtube.editable_snippet(verified), change.after, before=change.before)
449
+ mismatch = evidence['mismatch_fields']
449
450
  if not mismatch:
450
451
  break
451
452
  _append(change, 'verification_mismatch', fields=mismatch, attempt=attempt)
452
453
  store.save(change)
453
454
  else:
454
455
  verified = None
455
- if verified is None or not same_snippet_readback(youtube.editable_snippet(verified), change.after):
456
+ if verified is None or snippet_readback_evidence(youtube.editable_snippet(verified), change.after, before=change.before)['mismatch_fields']:
456
457
  change.status = 'uncertain'
457
458
  store.save(change)
458
459
  error = ChangeError('uncertain outcome: YouTube accepted PUT but complete readback was not verified; reconcile this ChangeSet without repeating the write')
@@ -463,38 +464,59 @@ def apply_youtube_change(
463
464
 
464
465
  change.status = "applied"
465
466
  change.verified = True
466
- _append(change, "verified_applied", etag=verified.etag)
467
+ _append(change, "verified_applied", etag=verified.etag, provider_added_fields=evidence["provider_added_fields"])
467
468
  store.save(change)
468
469
  return change
469
470
 
470
471
 
471
472
  def _reconcile(
472
- youtube: YouTubeManagementClient, store: ChangeStore, change: ChangeSet
473
+ youtube: YouTubeManagementClient, store: ChangeStore, change: ChangeSet, *, return_unresolved=False
473
474
  ) -> ChangeSet:
474
475
  """Reread only; never call update when resuming an uncertain attempt."""
475
476
  try:
476
477
  current = youtube.inspect(change.video_id)
477
478
  except YouTubeManagementError as exc:
478
- _append(change, "reconciliation_failed", error=str(exc))
479
+ from socialctl.agent_errors import error_document
480
+ detail = error_document(exc, read_only=True)
481
+ _append(change, "reconciliation_failed", error=detail['message'], error_detail=detail)
479
482
  store.save(change)
483
+ if return_unresolved:
484
+ return change
480
485
  raise ChangeError(
481
486
  "outcome remains uncertain: read-only reconciliation failed"
482
487
  ) from None
483
488
 
484
489
  snippet = youtube.editable_snippet(current)
485
- if same_snippet_readback(snippet, change.after):
490
+ evidence = snippet_readback_evidence(snippet, change.after, before=change.before)
491
+ if not evidence['mismatch_fields']:
486
492
  change.status = "applied"
487
493
  change.verified = True
488
- _append(change, "reconciled_applied", etag=current.etag)
494
+ _append(change, "reconciled_applied", etag=current.etag, provider_added_fields=evidence["provider_added_fields"])
489
495
  store.save(change)
490
496
  return change
491
497
 
492
498
  change.status = "manual_review"
493
499
  change.verified = False
494
500
  event = "reconciled_still_before" if _same_snippet(snippet, change.before) else "reconciled_diverged"
495
- _append(change, event)
501
+ _append(change, event, mismatch_fields=evidence['mismatch_fields'],
502
+ provider_added_fields=evidence['provider_added_fields'])
496
503
  store.save(change)
504
+ if return_unresolved:
505
+ return change
497
506
  raise ChangeError(
498
507
  "uncertain PUT requires manual review; reconciliation was read-only "
499
508
  "and did not repeat the write"
500
509
  )
510
+
511
+
512
+ def reconcile_youtube_change(youtube, store, change_id):
513
+ """Explicit recovery of an attempted V1 write, including manual review."""
514
+ with store.apply_lock(change_id):
515
+ change = store.load(change_id)
516
+ if youtube.configured_channel_id != change.target_account:
517
+ raise ChangeError('configured account no longer matches ChangeSet')
518
+ if change.status not in {'applying', 'uncertain', 'manual_review'}:
519
+ return change
520
+ if not any(event.get('event') == 'write_intent' for event in change.journal):
521
+ raise ChangeError('read-only recovery requires a recorded write intent')
522
+ return _reconcile(youtube, store, change, return_unresolved=True)
@@ -225,3 +225,36 @@ register_community(content_app, DEFAULT_ROOT)
225
225
  from socialctl.metricas.analytics_cli import register as register_analytics
226
226
 
227
227
  register_analytics(content_app, DEFAULT_ROOT)
228
+
229
+
230
+ @changes_app.command('reconcile')
231
+ def changes_reconcile(change_id: str, brand: str = typer.Option(..., '--brand'),
232
+ root: Path = typer.Option(DEFAULT_ROOT, '--root'),
233
+ json_output: bool = typer.Option(False, '--json')):
234
+ """Read-only V1/V2 metadata recovery. Never starts pending operations."""
235
+ from socialctl.management.changes import reconcile_youtube_change
236
+ from socialctl.management.batches import reconcile_batch
237
+ from socialctl.management.youtube_metadata import YouTubeMetadataClient
238
+ from socialctl.operation_output import OperationOutput
239
+ with OperationOutput(json_output, read_only=True):
240
+ selected = cargar_brand(root, brand)
241
+ single_store, batch_store = ChangeStore(selected.raiz), BatchStore(selected.raiz)
242
+ is_batch = batch_store.path_for(change_id).exists()
243
+ if is_batch and single_store.path_for(change_id).exists():
244
+ raise ChangeError('ambiguous ChangeSet ID in both metadata stores')
245
+ with make_http_client() as http:
246
+ if is_batch:
247
+ result = reconcile_batch(YouTubeMetadataClient(selected, http), batch_store, change_id)
248
+ verified = [op.id for op in result.operations if op.status == 'applied']
249
+ untouched = [op.id for op in result.operations if op.status == 'pending']
250
+ unresolved = [op.id for op in result.operations if op.id not in verified + untouched]
251
+ else:
252
+ result = reconcile_youtube_change(YouTubeManagementClient(selected, http), single_store, change_id)
253
+ verified = [result.id] if result.status == 'applied' and result.verified else []
254
+ untouched = [result.id] if result.status == 'proposed' else []
255
+ unresolved = [] if verified or untouched else [result.id]
256
+ payload = result.model_dump(mode='json')
257
+ payload['verification_summary'] = {'verified_ids': verified, 'unresolved_ids': unresolved, 'untouched_ids': untouched}
258
+ typer.echo(_json(payload))
259
+ if unresolved or untouched:
260
+ raise typer.Exit(1)
@@ -61,6 +61,11 @@ def baseline(client, edit):
61
61
  action = edit.action
62
62
  if action in {"add", "reply"}:
63
63
  video = client.inspect(edit.video_id)
64
+ if getattr(video, 'status', {}).get('privacyStatus') == 'private':
65
+ exc = ResourceError('YouTube disables comments on private videos. No settings were changed.')
66
+ exc._socialcli_error_code = 'private_video_comments_disabled'
67
+ exc._socialcli_diagnostic = {'source': 'local', 'stage': 'baseline_check', 'write_state': 'not_started'}
68
+ raise exc
64
69
  before = {"video_id": edit.video_id, "owner": video.channel_id}
65
70
  if action == "reply":
66
71
  thread = client.thread(edit.video_id, edit.thread_id)