@deftai/directive-content 0.55.2 → 0.56.1

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 (217) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +2 -2
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +47 -1
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/scripts/_agents_md.py +494 -0
  9. package/scripts/_cache_fetch.py +635 -0
  10. package/scripts/_cache_quota.py +529 -0
  11. package/scripts/_cache_refresh.py +163 -0
  12. package/scripts/_cache_validate.py +209 -0
  13. package/scripts/_content_root.py +42 -0
  14. package/scripts/_doctor_state.py +277 -0
  15. package/scripts/_event_detect.py +305 -0
  16. package/scripts/_events.py +514 -0
  17. package/scripts/_lifecycle_hygiene.py +568 -0
  18. package/scripts/_pathspec.py +91 -0
  19. package/scripts/_policy_show_cli.py +266 -0
  20. package/scripts/_precutover.py +92 -0
  21. package/scripts/_project_context.py +224 -0
  22. package/scripts/_project_definition_io.py +164 -0
  23. package/scripts/_relocate_snapshot.py +209 -0
  24. package/scripts/_relocate_states.py +343 -0
  25. package/scripts/_resolve_preflight_path.py +152 -0
  26. package/scripts/_safe_subprocess.py +167 -0
  27. package/scripts/_session_start_hook.py +205 -0
  28. package/scripts/_sor_gate_diff.py +365 -0
  29. package/scripts/_stdio_utf8.py +59 -0
  30. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  31. package/scripts/_triage_classify_cli.py +122 -0
  32. package/scripts/_triage_queue_cli.py +625 -0
  33. package/scripts/_triage_scope_cli.py +343 -0
  34. package/scripts/_triage_scope_drift_cli.py +121 -0
  35. package/scripts/_triage_scope_ignores.py +286 -0
  36. package/scripts/_triage_scope_milestone.py +432 -0
  37. package/scripts/_triage_scope_mutations.py +337 -0
  38. package/scripts/_triage_scope_renderers.py +207 -0
  39. package/scripts/_triage_smoketest_stages.py +674 -0
  40. package/scripts/_triage_subscribe_cli.py +140 -0
  41. package/scripts/_triage_welcome_cli.py +421 -0
  42. package/scripts/_vbrief_build.py +239 -0
  43. package/scripts/_vbrief_fidelity.py +479 -0
  44. package/scripts/_vbrief_legacy.py +589 -0
  45. package/scripts/_vbrief_reconciliation.py +883 -0
  46. package/scripts/_vbrief_routing.py +277 -0
  47. package/scripts/_vbrief_safety.py +778 -0
  48. package/scripts/_vbrief_sources.py +312 -0
  49. package/scripts/_vbrief_speckit.py +262 -0
  50. package/scripts/_vbrief_story_quality.py +353 -0
  51. package/scripts/_vbrief_validation.py +299 -0
  52. package/scripts/build_dist.py +412 -0
  53. package/scripts/cache.py +1078 -0
  54. package/scripts/cache_scanner.py +745 -0
  55. package/scripts/candidates_log.py +432 -0
  56. package/scripts/capacity_backfill.py +680 -0
  57. package/scripts/capacity_show.py +653 -0
  58. package/scripts/ci_local.py +689 -0
  59. package/scripts/code_structure_validate.py +765 -0
  60. package/scripts/codebase_default_extractor.py +495 -0
  61. package/scripts/codebase_map.py +304 -0
  62. package/scripts/codebase_map_fresh.py +104 -0
  63. package/scripts/codebase_projection_registry.py +94 -0
  64. package/scripts/codebase_provider.py +582 -0
  65. package/scripts/doctor.py +2257 -0
  66. package/scripts/framework_commands.py +505 -0
  67. package/scripts/gh_rest.py +882 -0
  68. package/scripts/github_auth_modes.py +437 -0
  69. package/scripts/github_body.py +292 -0
  70. package/scripts/ip_risk.py +531 -0
  71. package/scripts/issue_emit.py +670 -0
  72. package/scripts/issue_ingest.py +1064 -0
  73. package/scripts/migrate_preflight.py +418 -0
  74. package/scripts/migrate_vbrief.py +2677 -0
  75. package/scripts/monitor_pr.py +401 -0
  76. package/scripts/pack_migrate_lessons.py +336 -0
  77. package/scripts/pack_migrate_patterns.py +254 -0
  78. package/scripts/pack_migrate_rules.py +350 -0
  79. package/scripts/pack_migrate_skills.py +423 -0
  80. package/scripts/pack_migrate_strategies.py +311 -0
  81. package/scripts/pack_migrate_swarm_spec.py +250 -0
  82. package/scripts/pack_render.py +434 -0
  83. package/scripts/packs_slice.py +712 -0
  84. package/scripts/platform_capabilities.py +336 -0
  85. package/scripts/policy.py +2826 -0
  86. package/scripts/policy_set.py +324 -0
  87. package/scripts/pr_check_closing_keywords.py +524 -0
  88. package/scripts/pr_check_protected_issues.py +267 -0
  89. package/scripts/pr_merge_readiness.py +1004 -0
  90. package/scripts/pr_wait_mergeable.py +669 -0
  91. package/scripts/prd_render.py +159 -0
  92. package/scripts/preflight_architecture_sor.py +974 -0
  93. package/scripts/preflight_branch.py +289 -0
  94. package/scripts/preflight_cache.py +974 -0
  95. package/scripts/preflight_gh.py +721 -0
  96. package/scripts/preflight_implementation.py +272 -0
  97. package/scripts/preflight_story_start.py +838 -0
  98. package/scripts/preflight_wip_cap.py +149 -0
  99. package/scripts/probe_session.py +545 -0
  100. package/scripts/project_render.py +293 -0
  101. package/scripts/quarantine_ext.py +237 -0
  102. package/scripts/reconcile_issues.py +1442 -0
  103. package/scripts/refresh-path.ps1 +107 -0
  104. package/scripts/release.py +2030 -0
  105. package/scripts/release_e2e.py +1011 -0
  106. package/scripts/release_publish.py +486 -0
  107. package/scripts/release_rollback.py +980 -0
  108. package/scripts/relocate.py +1034 -0
  109. package/scripts/resolve_changelog_unreleased.py +667 -0
  110. package/scripts/resolve_version.py +490 -0
  111. package/scripts/resume_conditions.py +706 -0
  112. package/scripts/ritual_sentinel.py +609 -0
  113. package/scripts/roadmap_render.py +635 -0
  114. package/scripts/rule_ownership_lint.py +325 -0
  115. package/scripts/scm.py +591 -0
  116. package/scripts/scope_audit_log.py +387 -0
  117. package/scripts/scope_decompose.py +654 -0
  118. package/scripts/scope_demote.py +509 -0
  119. package/scripts/scope_lifecycle.py +1126 -0
  120. package/scripts/scope_undo.py +772 -0
  121. package/scripts/session_start.py +406 -0
  122. package/scripts/setup_ghx.py +339 -0
  123. package/scripts/setup_windows.ps1 +220 -0
  124. package/scripts/slice_audit.py +585 -0
  125. package/scripts/slice_record.py +530 -0
  126. package/scripts/slice_record_existing.py +692 -0
  127. package/scripts/slug_normalize.py +178 -0
  128. package/scripts/spec_render.py +477 -0
  129. package/scripts/spec_validate.py +238 -0
  130. package/scripts/subagent_monitor.py +658 -0
  131. package/scripts/swarm_complete_cohort.py +644 -0
  132. package/scripts/swarm_launch.py +1206 -0
  133. package/scripts/swarm_readiness.py +554 -0
  134. package/scripts/swarm_verify_review_clean.py +438 -0
  135. package/scripts/swarm_worktrees.py +497 -0
  136. package/scripts/toolchain-check.py +52 -0
  137. package/scripts/triage_actions.py +871 -0
  138. package/scripts/triage_bootstrap.py +1153 -0
  139. package/scripts/triage_bulk.py +630 -0
  140. package/scripts/triage_classify.py +932 -0
  141. package/scripts/triage_help.py +1685 -0
  142. package/scripts/triage_queue.py +1944 -0
  143. package/scripts/triage_reconcile.py +581 -0
  144. package/scripts/triage_refresh.py +643 -0
  145. package/scripts/triage_scope.py +999 -0
  146. package/scripts/triage_scope_drift.py +575 -0
  147. package/scripts/triage_smoketest.py +396 -0
  148. package/scripts/triage_subscribe.py +399 -0
  149. package/scripts/triage_summary.py +1011 -0
  150. package/scripts/triage_welcome.py +1178 -0
  151. package/scripts/ts_check_lane.py +86 -0
  152. package/scripts/validate-links.py +64 -0
  153. package/scripts/validate_strategy_output.py +212 -0
  154. package/scripts/vbrief_activate.py +228 -0
  155. package/scripts/vbrief_migrate_conformance.py +368 -0
  156. package/scripts/vbrief_reconcile_graph.py +306 -0
  157. package/scripts/vbrief_reconcile_labels.py +460 -0
  158. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  159. package/scripts/vbrief_validate.py +1195 -0
  160. package/scripts/verify-stubs.py +61 -0
  161. package/scripts/verify_capacity.py +160 -0
  162. package/scripts/verify_encoding.py +699 -0
  163. package/scripts/verify_hooks_installed.py +206 -0
  164. package/scripts/verify_investigation.py +360 -0
  165. package/scripts/verify_judgment_gates.py +827 -0
  166. package/scripts/verify_no_task_runtime.py +171 -0
  167. package/scripts/verify_scm_boundary.py +509 -0
  168. package/scripts/verify_session_ritual.py +389 -0
  169. package/scripts/verify_tools.py +426 -0
  170. package/scripts/verify_vbrief_conformance.py +478 -0
  171. package/tasks/architecture.yml +13 -0
  172. package/tasks/cache.yml +69 -0
  173. package/tasks/capacity.yml +38 -0
  174. package/tasks/change.yml +46 -0
  175. package/tasks/changelog.yml +24 -0
  176. package/tasks/ci.yml +49 -0
  177. package/tasks/codebase.yml +47 -0
  178. package/tasks/commit.yml +30 -0
  179. package/tasks/core.yml +126 -0
  180. package/tasks/deployments.yml +54 -0
  181. package/tasks/framework.yml +74 -0
  182. package/tasks/install.yml +60 -0
  183. package/tasks/issue.yml +50 -0
  184. package/tasks/migrate.yml +73 -0
  185. package/tasks/packs.yml +92 -0
  186. package/tasks/policy.yml +75 -0
  187. package/tasks/pr.yml +89 -0
  188. package/tasks/prd.yml +39 -0
  189. package/tasks/project.yml +27 -0
  190. package/tasks/reconcile.yml +32 -0
  191. package/tasks/relocate.yml +56 -0
  192. package/tasks/roadmap.yml +28 -0
  193. package/tasks/scm.yml +126 -0
  194. package/tasks/scope-undo.yml +36 -0
  195. package/tasks/scope.yml +141 -0
  196. package/tasks/session.yml +19 -0
  197. package/tasks/setup.yml +37 -0
  198. package/tasks/slice.yml +69 -0
  199. package/tasks/spec.yml +41 -0
  200. package/tasks/swarm.yml +85 -0
  201. package/tasks/toolchain.yml +13 -0
  202. package/tasks/triage-actions.yml +94 -0
  203. package/tasks/triage-bootstrap.yml +43 -0
  204. package/tasks/triage-bulk.yml +75 -0
  205. package/tasks/triage-classify.yml +30 -0
  206. package/tasks/triage-queue.yml +50 -0
  207. package/tasks/triage-reconcile.yml +29 -0
  208. package/tasks/triage-scope-drift.yml +29 -0
  209. package/tasks/triage-scope.yml +31 -0
  210. package/tasks/triage-smoketest.yml +33 -0
  211. package/tasks/triage-subscribe.yml +36 -0
  212. package/tasks/triage-summary.yml +29 -0
  213. package/tasks/triage-welcome.yml +32 -0
  214. package/tasks/ts.yml +328 -0
  215. package/tasks/vbrief.yml +206 -0
  216. package/tasks/verify.yml +292 -0
  217. package/templates/agents-entry.md +1 -1
@@ -0,0 +1,1034 @@
1
+ #!/usr/bin/env python3
2
+ """relocate.py -- wipe-and-reinstall relocator for #992 PR2.
3
+
4
+ The relocator migrates a consumer project from any of the broken-or-legacy
5
+ install states (A pure ``deft/`` / B pure ``.deft/core/`` / C hybrid both /
6
+ D AGENTS.md only) to the canonical v0.27 layout::
7
+
8
+ <project-root>/
9
+ .deft/core/ -- read-only packaged framework assets (per #11)
10
+ .deft-cache/ -- gitignored runtime cache
11
+ AGENTS.md -- managed-section v2 (#768)
12
+ .gitignore -- contains local Deft runtime entries
13
+
14
+ State detection (A-G) and customization probing live in
15
+ :mod:`scripts._relocate_states`; snapshot tarball logic lives in
16
+ :mod:`scripts._relocate_snapshot`. This split keeps every module under
17
+ the deft 1000-line MUST limit (mirrors the
18
+ ``cache.py`` / ``_cache_validate.py`` / ``_cache_fetch.py`` precedent
19
+ from #883).
20
+
21
+ Public CLI surface
22
+ ------------------
23
+
24
+ ::
25
+
26
+ python scripts/relocate.py [--project-root PATH]
27
+ [--framework-source PATH]
28
+ [--force]
29
+ [--confirm | --no-confirm]
30
+ [--dry-run]
31
+ [--rollback [--snapshot PATH]]
32
+ [--no-snapshot]
33
+ [--json] [--quiet]
34
+
35
+ Three load-bearing invariants (active vBRIEF DesignChoice):
36
+
37
+ - **WIPE-NOT-DIFF-MERGE**: one code path idempotent across A/B/C/D/F.
38
+ - **BOOTSTRAP NEVER SELF-DESTRUCTIVE**: ``main()`` self-detects whether
39
+ the running script lives inside the wipe-target tree
40
+ (``<project-root>/deft/`` or ``<project-root>/.deft/core/``) and on
41
+ detection performs an in-process **self-bootstrap** -- the framework is
42
+ copied to an OS temp directory and the relocator is re-launched from
43
+ the temp copy with a ``--bootstrapped-from-temp`` sentinel. The temp
44
+ copy proceeds with the wipe + redeposit while the in-place tree is no
45
+ longer holding live import handles. This eliminates the v0.27.0
46
+ webinstaller dependency for the relocation path (#1015 self-bootstrap).
47
+ - **AUTO-PROMPT NEVER AUTO-WIPE**: bare invocation prompts ``[y/N]``;
48
+ ``--confirm`` skips the prompt for scripted use; ``--dry-run`` reports
49
+ the plan without I/O.
50
+
51
+ Pre-flight hard-fail (without ``--force``):
52
+
53
+ - Customized framework dir (any file diff vs ``--framework-source``).
54
+ - Active swarm (any ``vbrief/active/*.vbrief.json`` with
55
+ ``plan.status == "running"``).
56
+
57
+ Three-state exit:
58
+
59
+ - ``0`` -- success / dry-run / no-op / rollback succeeded.
60
+ - ``1`` -- preflight refused, wipe failed, or operator declined prompt.
61
+ - ``2`` -- config error (self-detect, missing framework source).
62
+
63
+ Refs: parent issue https://github.com/deftai/directive/issues/992;
64
+ companion task ``tasks/relocate.yml``;
65
+ companion tests ``tests/relocate/test_state_matrix.py`` (states A-G)
66
+ and ``tests/relocate/test_preflight.py`` (--force gate).
67
+ """
68
+
69
+ from __future__ import annotations
70
+
71
+ import argparse
72
+ import json
73
+ import shutil
74
+ import subprocess
75
+ import sys
76
+ import tempfile
77
+ from collections.abc import Callable, Iterable
78
+ from dataclasses import dataclass, field
79
+ from pathlib import Path
80
+ from typing import IO
81
+
82
+ # Make sibling scripts importable when this file is dispatched via
83
+ # ``python scripts/relocate.py`` from a Taskfile or webinstaller bootstrap.
84
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
85
+
86
+ from _content_root import content_root # noqa: E402
87
+ from _relocate_snapshot import ( # noqa: E402 -- intentional sys.path tweak
88
+ SnapshotError,
89
+ create_snapshot as _create_snapshot,
90
+ extract_snapshot as _extract_snapshot,
91
+ snapshot_path as _snapshot_path,
92
+ )
93
+ from _relocate_states import ( # noqa: E402
94
+ active_swarm_paths,
95
+ advise_external_hardcodes as _advise_external_hardcodes,
96
+ customization_paths,
97
+ detect_active_swarm,
98
+ detect_install_state,
99
+ is_framework_customized,
100
+ iter_files,
101
+ )
102
+ from _stdio_utf8 import reconfigure_stdio # noqa: E402
103
+ from _triage_bootstrap_gitignore import ( # noqa: E402
104
+ FORBIDDEN_BLANKET_EVAL_LINES,
105
+ GITIGNORE_DEFT_RUNTIME_SENTINELS,
106
+ GITIGNORE_EVAL_ENTRIES,
107
+ strip_gitignore_inline_comment,
108
+ )
109
+
110
+ reconfigure_stdio()
111
+
112
+ __all__ = [
113
+ "AGENTS_MANAGED_CLOSE",
114
+ "AGENTS_MANAGED_OPEN",
115
+ "BOOTSTRAP_TEMP_PREFIX",
116
+ "BOOTSTRAP_FRAMEWORK_NAME",
117
+ "CANONICAL_FRAMEWORK_DIR",
118
+ "EXIT_CONFIG_ERROR",
119
+ "EXIT_FAILURE",
120
+ "EXIT_SUCCESS",
121
+ "FRAMEWORK_DEPOSIT_EXCLUSIONS",
122
+ "GITIGNORE_LINES",
123
+ "LEGACY_FRAMEWORK_DIR",
124
+ "RelocateError",
125
+ "RelocatePlan",
126
+ "STATE_DESCRIPTIONS",
127
+ "VBRIEF_LIFECYCLE_DIRS",
128
+ "active_swarm_paths",
129
+ "advise_external_hardcodes",
130
+ "build_relocate_plan",
131
+ "create_snapshot",
132
+ "customization_paths",
133
+ "detect_active_swarm",
134
+ "detect_install_state",
135
+ "extract_snapshot",
136
+ "is_framework_customized",
137
+ "main",
138
+ "regenerate_agents_md",
139
+ "render_managed_section",
140
+ "self_bootstrap_to_temp",
141
+ "wipe_and_reinstall",
142
+ ]
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Constants
146
+ # ---------------------------------------------------------------------------
147
+
148
+ EXIT_SUCCESS: int = 0
149
+ EXIT_FAILURE: int = 1
150
+ EXIT_CONFIG_ERROR: int = 2
151
+
152
+ CANONICAL_FRAMEWORK_DIR: str = ".deft/core"
153
+ LEGACY_FRAMEWORK_DIR: str = "deft"
154
+
155
+ #: Managed-section markers (#768 + #992 PR1 marker bump v1 -> v2;
156
+ #: #1046 PR-B AC-5 bump v2 -> v3 with refresh provenance attributes
157
+ #: emitted by ``run::cmd_agents_refresh``). Mirrored from the in-tree
158
+ #: ``run`` script's constants verbatim. The v2 form is parsed for one
159
+ #: release cycle (v0.28 only; v0.29 deprecates v2) via
160
+ #: ``scripts/_relocate_states.py::_AGENTS_MANAGED_OPEN_RE``.
161
+ AGENTS_MANAGED_OPEN: str = "<!-- deft:managed-section v3 -->"
162
+ AGENTS_MANAGED_CLOSE: str = "<!-- /deft:managed-section -->"
163
+
164
+ #: Top-level entries excluded from the framework deposit.
165
+ FRAMEWORK_DEPOSIT_EXCLUSIONS: tuple[str, ...] = (
166
+ ".git",
167
+ ".github",
168
+ ".githooks",
169
+ ".venv",
170
+ ".pytest_cache",
171
+ ".ruff_cache",
172
+ ".mypy_cache",
173
+ ".idea",
174
+ ".vscode",
175
+ ".deft",
176
+ ".deft-cache",
177
+ "__pycache__",
178
+ "node_modules",
179
+ "dist",
180
+ "build",
181
+ "session.txt",
182
+ "session2.txt",
183
+ "PRD.md",
184
+ "PROJECT.md",
185
+ "SPECIFICATION.md",
186
+ )
187
+
188
+ #: vbrief subdirs the relocator NEVER deposits (lifecycle is consumer-owned).
189
+ #: ``vbrief/schemas/`` and the ``vbrief/vbrief.md`` template ARE deposited.
190
+ VBRIEF_LIFECYCLE_DIRS: tuple[str, ...] = (
191
+ "active",
192
+ "pending",
193
+ "proposed",
194
+ "completed",
195
+ "cancelled",
196
+ ".eval",
197
+ )
198
+
199
+ #: ``.gitignore`` baseline the relocator ensures present after a relocate.
200
+ #:
201
+ #: F2 canonical-default decision (#1015): the canonical relocator default
202
+ #: gitignores ``.deft-cache/`` (runtime cache, snapshot tarballs, audit log
203
+ #: -- mirrors the #845 / #883 hidden-namespace gitignore convention), the
204
+ #: selective ``.deft`` runtime sentinels written by the session ritual, and
205
+ #: the operator-private ``vbrief/.eval/`` audit-log state. The framework deposit
206
+ #: at ``.deft/core/`` is INTENTIONALLY NOT auto-gitignored: per #11 the
207
+ #: ``.deft/core/`` tree is read-only packaged framework assets that ship
208
+ #: with the consumer's repo for reproducibility. Auto-gitignoring it would
209
+ #: silently break that contract on every v0.27.0 install already in the
210
+ #: wild. Active scope vBRIEF Outcome narrative #992 mentions "include
211
+ #: .deft/core/" in passing but the canonical Test narrative + #845
212
+ #: precedent + the v0.27.0-shipped behaviour all align with the baseline
213
+ #: pinned here. Consumers who deliberately want their framework dir
214
+ #: gitignored can append ``.deft/`` to their own ``.gitignore`` manually --
215
+ #: the relocator does NOT take that decision on the operator's behalf.
216
+ #:
217
+ #: #1251 / #1464: the eval state is gitignored via the SELECTIVE per-file
218
+ #: entries imported from ``_triage_bootstrap_gitignore`` (the single source
219
+ #: of truth shared with the bootstrap and installer rails), NOT a blanket
220
+ #: ``vbrief/.eval/`` line -- the blanket would hide the team-shared,
221
+ #: TRACKED ``slices.jsonl`` / ``README.md`` (#1132 / D13). A pre-existing
222
+ #: blanket is healed (stripped) by ``_ensure_gitignore_lines`` on upgrade.
223
+ GITIGNORE_LINES: tuple[str, ...] = (
224
+ ".deft-cache/",
225
+ *GITIGNORE_DEFT_RUNTIME_SENTINELS,
226
+ *GITIGNORE_EVAL_ENTRIES,
227
+ )
228
+
229
+ #: Sentinel argv flag the relocator passes to its own re-launch from an OS
230
+ #: temp directory. Consumers MUST NOT set this manually; the bootstrap path
231
+ #: is the only correct producer (see :func:`self_bootstrap_to_temp`).
232
+ BOOTSTRAP_SENTINEL: str = "--bootstrapped-from-temp"
233
+
234
+ #: tempfile prefix used by :func:`self_bootstrap_to_temp` so the OS temp
235
+ #: cleanup heuristics (and ``task verify:cache``-style pruning) can locate
236
+ #: stale relocator copies after a botched run.
237
+ BOOTSTRAP_TEMP_PREFIX: str = "deft-relocator-"
238
+
239
+ #: Subdirectory name under the temp dir that hosts the framework copy.
240
+ #: The fixed ``deft`` name is canonical (matches a fresh git clone shape)
241
+ #: so the temp child can compute ``framework-source`` deterministically.
242
+ BOOTSTRAP_FRAMEWORK_NAME: str = "deft"
243
+
244
+ STATE_DESCRIPTIONS: dict[str, str] = {
245
+ "A": "pure deft/ (legacy install)",
246
+ "B": "pure .deft/core/ (current installer output, marker may be stale)",
247
+ "C": "hybrid both deft/ and .deft/core/ (broken)",
248
+ "D": "AGENTS.md only (broken partial install)",
249
+ "E": "customized framework dir (preserve-and-warn)",
250
+ "F": "missing vbrief/ (greenfield-ish)",
251
+ "G": "active swarm worktree (running plan.status -- hard-fail without --force)",
252
+ "CANONICAL": "no relocate needed -- canonical .deft/core/ with no legacy",
253
+ }
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # Errors / dataclass
258
+ # ---------------------------------------------------------------------------
259
+
260
+
261
+ class RelocateError(RuntimeError):
262
+ """Generic relocator failure (preflight, wipe, copy, rollback)."""
263
+
264
+ def __init__(self, message: str, *, exit_code: int = EXIT_FAILURE) -> None:
265
+ super().__init__(message)
266
+ self.exit_code = exit_code
267
+
268
+
269
+ @dataclass
270
+ class RelocatePlan:
271
+ """Snapshot of what ``wipe_and_reinstall`` would do; no I/O performed."""
272
+
273
+ project_root: Path
274
+ framework_source: Path
275
+ state: str
276
+ state_description: str
277
+ legacy_dir: Path
278
+ canonical_dir: Path
279
+ legacy_present: bool
280
+ canonical_present: bool
281
+ framework_customized: bool
282
+ customization_paths: list[str]
283
+ active_swarm: bool
284
+ active_swarm_paths: list[str]
285
+ needs_relocate: bool
286
+ needs_force: bool
287
+ snapshot_path: Path | None
288
+ advisory_hits: list[tuple[str, int, str]] = field(default_factory=list)
289
+
290
+
291
+ # ---------------------------------------------------------------------------
292
+ # Re-export public helpers from the split modules
293
+ # ---------------------------------------------------------------------------
294
+
295
+
296
+ def advise_external_hardcodes(
297
+ project_root: Path, *, token: str = "deft/run"
298
+ ) -> list[tuple[str, int, str]]:
299
+ """Pass-through to :func:`_relocate_states.advise_external_hardcodes`."""
300
+ return _advise_external_hardcodes(project_root, token=token)
301
+
302
+
303
+ def create_snapshot(
304
+ project_root: Path,
305
+ *,
306
+ snapshot_path: Path | None = None,
307
+ timestamp: str | None = None,
308
+ ) -> Path:
309
+ """Pass-through to :func:`_relocate_snapshot.create_snapshot`."""
310
+ return _create_snapshot(project_root, target=snapshot_path, timestamp=timestamp)
311
+
312
+
313
+ def extract_snapshot(project_root: Path, *, snapshot: Path | None = None) -> Path:
314
+ """Pass-through to :func:`_relocate_snapshot.extract_snapshot`."""
315
+ try:
316
+ return _extract_snapshot(project_root, snapshot=snapshot)
317
+ except SnapshotError as exc:
318
+ raise RelocateError(str(exc), exit_code=exc.exit_code) from exc
319
+
320
+
321
+ # ---------------------------------------------------------------------------
322
+ # Self-detect (never wipe the framework that hosts the running script)
323
+ # ---------------------------------------------------------------------------
324
+
325
+
326
+ def _running_inside_wipe_target(
327
+ *,
328
+ script_path: Path,
329
+ project_root: Path,
330
+ ) -> tuple[bool, Path | None]:
331
+ """Return ``(True, offending_dir)`` iff the script lives inside a wipe target."""
332
+ try:
333
+ resolved_script = script_path.resolve()
334
+ resolved_root = project_root.resolve()
335
+ except OSError:
336
+ return (False, None)
337
+ candidates = (
338
+ (resolved_root / LEGACY_FRAMEWORK_DIR).resolve(),
339
+ (resolved_root / CANONICAL_FRAMEWORK_DIR).resolve(),
340
+ )
341
+ for candidate in candidates:
342
+ if not candidate.exists():
343
+ continue
344
+ try:
345
+ resolved_script.relative_to(candidate)
346
+ except ValueError:
347
+ continue
348
+ return (True, candidate)
349
+ return (False, None)
350
+
351
+
352
+ # ---------------------------------------------------------------------------
353
+ # Self-bootstrap (#1015): copy framework to OS temp + re-launch from there
354
+ # ---------------------------------------------------------------------------
355
+
356
+
357
+ #: Top-level entries skipped when copying the in-place framework into the
358
+ #: OS temp dir for self-bootstrap. Mirrors :data:`FRAMEWORK_DEPOSIT_EXCLUSIONS`
359
+ #: with the addition of repo-internal noise that does not need to travel
360
+ #: with the relocator (the bootstrap copy only needs the relocator + its
361
+ #: dependencies + the AGENTS.md template).
362
+ _BOOTSTRAP_COPY_EXCLUSIONS: frozenset[str] = frozenset(
363
+ {
364
+ ".git",
365
+ ".github",
366
+ ".githooks",
367
+ ".venv",
368
+ ".pytest_cache",
369
+ ".ruff_cache",
370
+ ".mypy_cache",
371
+ ".idea",
372
+ ".vscode",
373
+ ".deft",
374
+ ".deft-cache",
375
+ "__pycache__",
376
+ "node_modules",
377
+ "dist",
378
+ "build",
379
+ "session.txt",
380
+ "session2.txt",
381
+ }
382
+ )
383
+
384
+
385
+ def _bootstrap_copy_ignore(_src: str, names: list[str]) -> set[str]:
386
+ """``shutil.copytree`` ignore callback skipping repo-noise top-level dirs."""
387
+ return {n for n in names if n in _BOOTSTRAP_COPY_EXCLUSIONS}
388
+
389
+
390
+ def _argv_strip_framework_source(argv: Iterable[str]) -> list[str]:
391
+ """Strip ``--framework-source <path>`` (and ``--framework-source=<path>``).
392
+
393
+ Used by :func:`self_bootstrap_to_temp` to rebuild the child argv with the
394
+ temp framework path injected in place of whatever the parent invocation
395
+ pointed at (typically the in-place wipe target).
396
+ """
397
+ out: list[str] = []
398
+ skip_next = False
399
+ for token in argv:
400
+ if skip_next:
401
+ skip_next = False
402
+ continue
403
+ if token == "--framework-source":
404
+ skip_next = True
405
+ continue
406
+ if token.startswith("--framework-source="):
407
+ continue
408
+ out.append(token)
409
+ return out
410
+
411
+
412
+ _BootstrapRunner = Callable[[list[str]], int]
413
+ _BootstrapTempFactory = Callable[[], Path]
414
+
415
+
416
+ def self_bootstrap_to_temp(
417
+ *,
418
+ in_place_framework: Path,
419
+ argv: Iterable[str],
420
+ runner: _BootstrapRunner | None = None,
421
+ temp_factory: _BootstrapTempFactory | None = None,
422
+ ) -> int:
423
+ """Copy ``in_place_framework`` to OS temp + re-launch the relocator from there.
424
+
425
+ The parent process that invoked this helper is running from inside the
426
+ wipe target (e.g. ``<consumer>/.deft/core/scripts/relocate.py``). To
427
+ avoid the parent's import handles racing the child's wipe, we copy the
428
+ in-place tree to an isolated OS temp directory and re-launch the
429
+ relocator from the temp copy with the :data:`BOOTSTRAP_SENTINEL` flag
430
+ set (which suppresses the self-detect on the child run). The parent
431
+ waits for the child to complete and propagates its exit code so the
432
+ operator-facing surface is identical to a direct invocation from a
433
+ fresh git clone.
434
+
435
+ The temp directory is intentionally NOT auto-cleaned: leaving the copy
436
+ behind aids forensic inspection if the relocate fails, and the OS
437
+ cleanup heuristics (plus any future ``task verify:cache`` prune) will
438
+ reclaim the space without operator intervention.
439
+
440
+ Parameters are kwarg-only so the test seam (``runner`` /
441
+ ``temp_factory``) does not collide with positional re-ordering on a
442
+ future API tweak.
443
+ """
444
+ factory = temp_factory or (
445
+ lambda: Path(tempfile.mkdtemp(prefix=BOOTSTRAP_TEMP_PREFIX))
446
+ )
447
+ temp_root = factory()
448
+ temp_framework = temp_root / BOOTSTRAP_FRAMEWORK_NAME
449
+ shutil.copytree(
450
+ in_place_framework,
451
+ temp_framework,
452
+ ignore=_bootstrap_copy_ignore,
453
+ symlinks=False,
454
+ )
455
+ temp_script = temp_framework / "scripts" / "relocate.py"
456
+ if not temp_script.is_file(): # pragma: no cover -- defensive guard
457
+ raise RelocateError(
458
+ f"self-bootstrap copy is missing scripts/relocate.py at {temp_script}",
459
+ exit_code=EXIT_CONFIG_ERROR,
460
+ )
461
+ stripped_argv = _argv_strip_framework_source(argv)
462
+ child_argv = [
463
+ sys.executable,
464
+ str(temp_script),
465
+ *stripped_argv,
466
+ "--framework-source",
467
+ str(temp_framework),
468
+ BOOTSTRAP_SENTINEL,
469
+ ]
470
+ run: _BootstrapRunner = runner or _default_subprocess_runner
471
+ return run(child_argv)
472
+
473
+
474
+ def _default_subprocess_runner(argv: list[str]) -> int:
475
+ """Default child-runner -- ``subprocess.run`` with inherited stdio."""
476
+ completed = subprocess.run(argv, check=False) # noqa: S603 -- argv built locally
477
+ return completed.returncode
478
+
479
+
480
+ # ---------------------------------------------------------------------------
481
+ # AGENTS.md re-render (#768 marker v2)
482
+ # ---------------------------------------------------------------------------
483
+
484
+
485
+ def render_managed_section(framework_source: Path) -> str:
486
+ """Return the rendered managed-section block from the framework template."""
487
+ template_path = content_root(framework_source) / "templates" / "agents-entry.md"
488
+ if not template_path.is_file():
489
+ raise RelocateError(
490
+ f"framework source missing AGENTS.md template at {template_path}",
491
+ exit_code=EXIT_CONFIG_ERROR,
492
+ )
493
+ text = template_path.read_text(encoding="utf-8").replace("\r\n", "\n")
494
+ open_idx = text.find(AGENTS_MANAGED_OPEN)
495
+ close_idx = text.find(AGENTS_MANAGED_CLOSE)
496
+ if open_idx < 0 or close_idx < 0 or close_idx <= open_idx:
497
+ return text
498
+ end = close_idx + len(AGENTS_MANAGED_CLOSE)
499
+ return text[open_idx:end]
500
+
501
+
502
+ def regenerate_agents_md(project_root: Path, framework_source: Path) -> str:
503
+ """Re-render AGENTS.md with the v2 managed-section block.
504
+
505
+ Three cases:
506
+
507
+ - **No AGENTS.md** -> write the rendered section as the file body.
508
+ - **AGENTS.md exists with markers** -> byte-replace the bracketed
509
+ block in place; content above and below is preserved verbatim.
510
+ - **AGENTS.md exists without markers** -> wrap the existing content
511
+ and append the rendered section beneath, mirroring
512
+ ``_wrap_legacy_in_markers`` semantics from the in-tree ``run``
513
+ script (#794).
514
+ """
515
+ rendered = render_managed_section(framework_source)
516
+ agents_md = project_root / "AGENTS.md"
517
+ if not agents_md.is_file():
518
+ new_content = rendered + "\n"
519
+ agents_md.write_text(new_content, encoding="utf-8", newline="\n")
520
+ return new_content
521
+ existing = agents_md.read_text(encoding="utf-8", errors="replace")
522
+ normalised = existing.replace("\r\n", "\n")
523
+ open_idx = normalised.find(AGENTS_MANAGED_OPEN)
524
+ close_idx = normalised.find(AGENTS_MANAGED_CLOSE)
525
+ if open_idx < 0 or close_idx < 0 or close_idx <= open_idx:
526
+ body = normalised.rstrip("\n")
527
+ new_content = (body + "\n\n" + rendered + "\n") if body else rendered + "\n"
528
+ else:
529
+ end = close_idx + len(AGENTS_MANAGED_CLOSE)
530
+ existing_block = normalised[open_idx:end]
531
+ new_content = normalised.replace(existing_block, rendered, 1)
532
+ if not new_content.endswith("\n"):
533
+ new_content += "\n"
534
+ agents_md.write_text(new_content, encoding="utf-8", newline="\n")
535
+ return new_content
536
+
537
+
538
+ # ---------------------------------------------------------------------------
539
+ # .gitignore upkeep
540
+ # ---------------------------------------------------------------------------
541
+
542
+
543
+ def _ensure_gitignore_lines(project_root: Path, lines: Iterable[str] = GITIGNORE_LINES) -> bool:
544
+ """Ensure ``lines`` are present in ``<project-root>/.gitignore`` and HEAL a
545
+ pre-existing forbidden blanket ``vbrief/.eval/`` line. Returns True if changed.
546
+
547
+ #1464: the pre-#1251 deposit rails appended a blanket ``vbrief/.eval/``
548
+ line that silently hides the TRACKED ``slices.jsonl`` / ``README.md`` from
549
+ git. On upgrade the relocator now STRIPS that blanket -- using the same
550
+ forbidden-set + inline-comment-strip the bootstrap rail uses
551
+ (``FORBIDDEN_BLANKET_EVAL_LINES`` / ``strip_gitignore_inline_comment``) --
552
+ before appending the selective per-file entries, so ``task relocate`` heals
553
+ an already-broken repo instead of re-depositing the blanket. The selective
554
+ entries themselves (``vbrief/.eval/candidates.jsonl`` etc.) are never
555
+ treated as the blanket because the forbidden set matches the bare directory
556
+ line only.
557
+ """
558
+ gitignore = project_root / ".gitignore"
559
+ existing = ""
560
+ if gitignore.is_file():
561
+ existing = gitignore.read_text(encoding="utf-8", errors="replace")
562
+
563
+ # Heal: drop any forbidden blanket line, tolerating a trailing inline
564
+ # comment (e.g. ``vbrief/.eval/ # legacy``). Every other line is kept
565
+ # verbatim so operator-authored content is preserved byte-for-byte.
566
+ kept: list[str] = []
567
+ blanket_removed = False
568
+ for raw in existing.splitlines():
569
+ if strip_gitignore_inline_comment(raw) in FORBIDDEN_BLANKET_EVAL_LINES:
570
+ blanket_removed = True
571
+ continue
572
+ kept.append(raw)
573
+ healed = "\n".join(kept)
574
+ if kept and existing.endswith("\n"):
575
+ healed += "\n"
576
+
577
+ # Membership uses the SAME inline-comment strip as the installer (Go
578
+ # `present` map) and the bootstrap rail (#1464): an operator-annotated
579
+ # entry like ``vbrief/.eval/candidates.jsonl # added manually`` must be
580
+ # recognised as already present so the canonical line is not re-deposited
581
+ # as a duplicate. A whitespace-only strip would diverge the three rails.
582
+ existing_lines = {
583
+ stripped
584
+ for ln in kept
585
+ if (stripped := strip_gitignore_inline_comment(ln))
586
+ }
587
+ additions = [ln for ln in lines if ln.strip() not in existing_lines]
588
+ if not blanket_removed and not additions:
589
+ return False
590
+
591
+ body = healed
592
+ if additions:
593
+ if body and not body.endswith("\n"):
594
+ body += "\n"
595
+ if body and not body.endswith("\n\n"):
596
+ body += "\n"
597
+ body += "# Added by deft relocator (#992 PR2; #1464 selective vbrief/.eval/)\n"
598
+ body += "\n".join(additions) + "\n"
599
+ gitignore.write_text(body, encoding="utf-8", newline="\n")
600
+ return True
601
+
602
+
603
+ # ---------------------------------------------------------------------------
604
+ # Framework deposit
605
+ # ---------------------------------------------------------------------------
606
+
607
+
608
+ def _deposit_filter(src_root: Path, candidate: Path) -> bool:
609
+ """Return True iff ``candidate`` should be deposited under ``.deft/core/``."""
610
+ try:
611
+ rel = candidate.relative_to(src_root)
612
+ except ValueError:
613
+ return False
614
+ parts = rel.parts
615
+ if not parts:
616
+ return False
617
+ first = parts[0]
618
+ if first in FRAMEWORK_DEPOSIT_EXCLUSIONS:
619
+ return False
620
+ if first == "vbrief" and len(parts) >= 2:
621
+ second = parts[1]
622
+ if second in VBRIEF_LIFECYCLE_DIRS:
623
+ return False
624
+ if second == "PROJECT-DEFINITION.vbrief.json":
625
+ return False
626
+ return True
627
+
628
+
629
+ def _deposit_framework(framework_source: Path, target: Path) -> int:
630
+ """Copy ``framework_source`` -> ``target`` filtered by :func:`_deposit_filter`."""
631
+ target.mkdir(parents=True, exist_ok=True)
632
+ written = 0
633
+ for src in iter_files(framework_source):
634
+ if not _deposit_filter(framework_source, src):
635
+ continue
636
+ rel = src.relative_to(framework_source)
637
+ dest = target / rel
638
+ dest.parent.mkdir(parents=True, exist_ok=True)
639
+ shutil.copy2(src, dest)
640
+ written += 1
641
+ return written
642
+
643
+
644
+ # ---------------------------------------------------------------------------
645
+ # Plan builder + wipe orchestrator
646
+ # ---------------------------------------------------------------------------
647
+
648
+
649
+ def build_relocate_plan(
650
+ project_root: Path,
651
+ *,
652
+ framework_source: Path,
653
+ force: bool = False,
654
+ ) -> RelocatePlan:
655
+ """Compute the full state vector + planned action without performing I/O."""
656
+ legacy = project_root / LEGACY_FRAMEWORK_DIR
657
+ canonical = project_root / CANONICAL_FRAMEWORK_DIR
658
+ state = detect_install_state(project_root, framework_source=framework_source)
659
+
660
+ custom_paths: list[str] = []
661
+ if legacy.is_dir():
662
+ custom_paths.extend(customization_paths(legacy, framework_source))
663
+ if canonical.is_dir():
664
+ custom_paths.extend(customization_paths(canonical, framework_source))
665
+ framework_customized = bool(custom_paths)
666
+ swarm_paths = active_swarm_paths(project_root)
667
+ active_swarm = bool(swarm_paths)
668
+
669
+ needs_relocate = state != "CANONICAL"
670
+ needs_force = framework_customized or active_swarm
671
+ snap = _snapshot_path(project_root) if needs_relocate else None
672
+
673
+ return RelocatePlan(
674
+ project_root=project_root,
675
+ framework_source=framework_source,
676
+ state=state,
677
+ state_description=STATE_DESCRIPTIONS.get(state, "(unknown state)"),
678
+ legacy_dir=legacy,
679
+ canonical_dir=canonical,
680
+ legacy_present=legacy.is_dir(),
681
+ canonical_present=canonical.is_dir(),
682
+ framework_customized=framework_customized,
683
+ customization_paths=sorted(set(custom_paths)),
684
+ active_swarm=active_swarm,
685
+ active_swarm_paths=swarm_paths,
686
+ needs_relocate=needs_relocate,
687
+ needs_force=needs_force and not force,
688
+ snapshot_path=snap,
689
+ )
690
+
691
+
692
+ def wipe_and_reinstall(
693
+ plan: RelocatePlan,
694
+ *,
695
+ skip_snapshot: bool = False,
696
+ snapshot_override: Path | None = None,
697
+ ) -> Path | None:
698
+ """Execute the plan: snapshot -> wipe -> deposit -> AGENTS.md -> .gitignore."""
699
+ if not plan.needs_relocate:
700
+ return None
701
+ snap: Path | None = None
702
+ if not skip_snapshot:
703
+ snap = _create_snapshot(
704
+ plan.project_root,
705
+ target=snapshot_override or plan.snapshot_path,
706
+ )
707
+ if plan.legacy_dir.is_dir():
708
+ shutil.rmtree(plan.legacy_dir)
709
+ if plan.canonical_dir.is_dir():
710
+ shutil.rmtree(plan.canonical_dir)
711
+ _deposit_framework(plan.framework_source, plan.canonical_dir)
712
+ regenerate_agents_md(plan.project_root, plan.framework_source)
713
+ _ensure_gitignore_lines(plan.project_root)
714
+ return snap
715
+
716
+
717
+ # ---------------------------------------------------------------------------
718
+ # CLI
719
+ # ---------------------------------------------------------------------------
720
+
721
+
722
+ def _build_parser() -> argparse.ArgumentParser:
723
+ parser = argparse.ArgumentParser(
724
+ prog="relocate",
725
+ description=(
726
+ "Wipe-and-reinstall relocator (#992 PR2). Migrates a consumer "
727
+ "project from any A/B/C/D install state to the canonical "
728
+ ".deft/core/ layout. Snapshot-rollback path included; "
729
+ "auto-prompt never auto-wipe; preflight hard-fails on "
730
+ "customized framework or active swarm without --force."
731
+ ),
732
+ )
733
+ parser.add_argument(
734
+ "--project-root",
735
+ type=Path,
736
+ default=Path.cwd(),
737
+ help="Consumer project root (defaults to CWD).",
738
+ )
739
+ parser.add_argument(
740
+ "--framework-source",
741
+ type=Path,
742
+ default=Path(__file__).resolve().parents[1],
743
+ help=(
744
+ "Path to a fresh framework copy (typically a temp dir created "
745
+ "by the webinstaller bootstrap). Defaults to the deft repo "
746
+ "root containing this script's parent."
747
+ ),
748
+ )
749
+ parser.add_argument(
750
+ "--force",
751
+ action="store_true",
752
+ help=(
753
+ "Override the preflight hard-fail gate (customized framework "
754
+ "or active swarm). Snapshot is still written."
755
+ ),
756
+ )
757
+ confirm = parser.add_mutually_exclusive_group()
758
+ confirm.add_argument(
759
+ "--confirm",
760
+ action="store_true",
761
+ help="Skip the interactive y/N prompt before wiping.",
762
+ )
763
+ confirm.add_argument(
764
+ "--no-confirm",
765
+ action="store_true",
766
+ help="Force the interactive y/N prompt even on non-tty stdin.",
767
+ )
768
+ parser.add_argument(
769
+ "--dry-run",
770
+ action="store_true",
771
+ help="Print the plan without performing any I/O.",
772
+ )
773
+ parser.add_argument(
774
+ "--rollback",
775
+ action="store_true",
776
+ help="Extract the most recent snapshot back into project root.",
777
+ )
778
+ parser.add_argument(
779
+ "--snapshot",
780
+ type=Path,
781
+ default=None,
782
+ help="Override the snapshot path used by --rollback (or by the next snapshot write).",
783
+ )
784
+ parser.add_argument(
785
+ "--no-snapshot",
786
+ action="store_true",
787
+ help="Skip the snapshot write before wiping (not recommended).",
788
+ )
789
+ parser.add_argument(
790
+ "--json",
791
+ action="store_true",
792
+ help="Emit a machine-readable JSON object on stdout.",
793
+ )
794
+ parser.add_argument(
795
+ "--quiet",
796
+ action="store_true",
797
+ help="Suppress informational status lines (errors still print).",
798
+ )
799
+ parser.add_argument(
800
+ BOOTSTRAP_SENTINEL,
801
+ dest="bootstrapped_from_temp",
802
+ action="store_true",
803
+ help=argparse.SUPPRESS,
804
+ )
805
+ return parser
806
+
807
+
808
+ def _emit_status(message: str, *, stream: IO[str] = sys.stdout, quiet: bool = False) -> None:
809
+ if quiet:
810
+ return
811
+ print(message, file=stream)
812
+
813
+
814
+ def _print_plan(plan: RelocatePlan, *, json_mode: bool, quiet: bool) -> None:
815
+ if json_mode:
816
+ payload = {
817
+ "state": plan.state,
818
+ "state_description": plan.state_description,
819
+ "needs_relocate": plan.needs_relocate,
820
+ "needs_force": plan.needs_force,
821
+ "framework_customized": plan.framework_customized,
822
+ "active_swarm": plan.active_swarm,
823
+ "customization_paths": plan.customization_paths,
824
+ "active_swarm_paths": plan.active_swarm_paths,
825
+ "legacy_present": plan.legacy_present,
826
+ "canonical_present": plan.canonical_present,
827
+ "snapshot_path": str(plan.snapshot_path) if plan.snapshot_path else None,
828
+ "project_root": str(plan.project_root),
829
+ "framework_source": str(plan.framework_source),
830
+ }
831
+ print(json.dumps(payload, sort_keys=True, indent=2))
832
+ return
833
+ if quiet:
834
+ return
835
+ print(f"[relocate] state = {plan.state} ({plan.state_description})")
836
+ print(f"[relocate] project_root = {plan.project_root}")
837
+ print(f"[relocate] framework_source = {plan.framework_source}")
838
+ print(f"[relocate] legacy_present = {plan.legacy_present}")
839
+ print(f"[relocate] canonical_present= {plan.canonical_present}")
840
+ print(f"[relocate] active_swarm = {plan.active_swarm}")
841
+ if plan.active_swarm_paths:
842
+ print("[relocate] active_swarm_paths:")
843
+ for p in plan.active_swarm_paths:
844
+ print(f" - {p}")
845
+ print(f"[relocate] framework_customized = {plan.framework_customized}")
846
+ if plan.customization_paths:
847
+ print("[relocate] customization_paths (preserved-files list):")
848
+ for p in plan.customization_paths:
849
+ print(f" - {p}")
850
+ print(f"[relocate] needs_relocate = {plan.needs_relocate}")
851
+ print(f"[relocate] needs_force_gate = {plan.needs_force}")
852
+ if plan.snapshot_path:
853
+ print(f"[relocate] snapshot_target = {plan.snapshot_path}")
854
+
855
+
856
+ def _confirm_prompt(*, no_confirm: bool, stdin: IO[str] | None = None) -> bool:
857
+ """Ask the operator to confirm the wipe. Default *no*."""
858
+ sin = stdin or sys.stdin
859
+ if not no_confirm and not sin.isatty():
860
+ # Non-interactive without --no-confirm: refuse to wipe by default
861
+ # (mirrors #884 ghx-install consent gate's default-deny on non-tty).
862
+ return False
863
+ print(
864
+ "[relocate] Wipe-and-reinstall the framework deposit into "
865
+ ".deft/core/? This is non-reversible without the snapshot. [y/N]: ",
866
+ end="",
867
+ flush=True,
868
+ )
869
+ try:
870
+ line = sin.readline()
871
+ except (EOFError, KeyboardInterrupt):
872
+ return False
873
+ return (line or "").strip().lower() in ("y", "yes")
874
+
875
+
876
+ def _enforce_force_gate(plan: RelocatePlan) -> None:
877
+ """Raise :class:`RelocateError` (exit 1) when the gate refuses the wipe."""
878
+ if not plan.needs_force:
879
+ return
880
+ parts: list[str] = []
881
+ if plan.framework_customized:
882
+ parts.append(
883
+ "framework dir is customized -- preserved-files list:\n "
884
+ + "\n ".join(plan.customization_paths)
885
+ )
886
+ if plan.active_swarm:
887
+ parts.append(
888
+ "active swarm worktree -- vbrief/active/* with plan.status=running:\n "
889
+ + "\n ".join(plan.active_swarm_paths)
890
+ )
891
+ raise RelocateError(
892
+ "preflight hard-fail; pass --force to override:\n" + "\n".join(parts)
893
+ )
894
+
895
+
896
+ def _run_relocate(
897
+ args: argparse.Namespace,
898
+ *,
899
+ raw_argv: list[str] | None = None,
900
+ ) -> int:
901
+ project_root: Path = args.project_root.resolve()
902
+ framework_source: Path = args.framework_source.resolve()
903
+
904
+ detected, offending = _running_inside_wipe_target(
905
+ script_path=Path(__file__),
906
+ project_root=project_root,
907
+ )
908
+ if detected and not args.bootstrapped_from_temp:
909
+ # #1015 self-bootstrap: instead of fail-loud (the v0.27.0 behaviour
910
+ # that produced a half-promise UX for state-A consumers running
911
+ # ``<framework>/run relocate`` from in-place), copy the framework to
912
+ # an OS temp dir and re-launch the relocator from there. The temp
913
+ # copy holds no live import handles into the wipe target so the
914
+ # downstream wipe is safe.
915
+ in_place_framework = offending or framework_source
916
+ _emit_status(
917
+ f"[relocate] self-bootstrap: relocator lives inside wipe target "
918
+ f"{in_place_framework}; copying framework to OS temp and "
919
+ "re-launching relocator from there.",
920
+ quiet=args.quiet,
921
+ )
922
+ try:
923
+ return self_bootstrap_to_temp(
924
+ in_place_framework=in_place_framework,
925
+ argv=raw_argv if raw_argv is not None else sys.argv[1:],
926
+ )
927
+ except (OSError, shutil.Error, RelocateError) as exc:
928
+ print(
929
+ f"[relocate] FATAL: self-bootstrap failed -- {exc}",
930
+ file=sys.stderr,
931
+ )
932
+ return EXIT_CONFIG_ERROR
933
+
934
+ if not framework_source.is_dir():
935
+ print(
936
+ f"[relocate] FATAL: --framework-source {framework_source} is not a directory.",
937
+ file=sys.stderr,
938
+ )
939
+ return EXIT_CONFIG_ERROR
940
+
941
+ if args.rollback:
942
+ try:
943
+ extracted = extract_snapshot(project_root, snapshot=args.snapshot)
944
+ except RelocateError as exc:
945
+ print(f"[relocate] FATAL: {exc}", file=sys.stderr)
946
+ return exc.exit_code
947
+ _emit_status(
948
+ f"[relocate] rollback complete -- restored from {extracted}",
949
+ quiet=args.quiet,
950
+ )
951
+ return EXIT_SUCCESS
952
+
953
+ plan = build_relocate_plan(
954
+ project_root,
955
+ framework_source=framework_source,
956
+ force=args.force,
957
+ )
958
+ _print_plan(plan, json_mode=args.json, quiet=args.quiet)
959
+
960
+ if not plan.needs_relocate:
961
+ _emit_status(
962
+ "[relocate] project is already canonical -- no action needed.",
963
+ quiet=args.quiet,
964
+ )
965
+ return EXIT_SUCCESS
966
+
967
+ if args.dry_run:
968
+ _emit_status(
969
+ "[relocate] --dry-run: no I/O performed; re-run without --dry-run to apply.",
970
+ quiet=args.quiet,
971
+ )
972
+ return EXIT_SUCCESS
973
+
974
+ try:
975
+ _enforce_force_gate(plan)
976
+ except RelocateError as exc:
977
+ print(f"[relocate] FATAL: {exc}", file=sys.stderr)
978
+ return exc.exit_code
979
+
980
+ if not args.confirm and not _confirm_prompt(no_confirm=args.no_confirm):
981
+ print(
982
+ "[relocate] aborted -- operator declined the wipe prompt.",
983
+ file=sys.stderr,
984
+ )
985
+ return EXIT_FAILURE
986
+
987
+ try:
988
+ snap = wipe_and_reinstall(
989
+ plan,
990
+ skip_snapshot=args.no_snapshot,
991
+ snapshot_override=args.snapshot,
992
+ )
993
+ except (RelocateError, OSError, shutil.Error) as exc:
994
+ print(f"[relocate] FATAL: {exc}", file=sys.stderr)
995
+ return EXIT_FAILURE
996
+
997
+ if snap is not None:
998
+ _emit_status(f"[relocate] snapshot written to {snap}", quiet=args.quiet)
999
+
1000
+ advisory = advise_external_hardcodes(project_root)
1001
+ if advisory:
1002
+ _emit_status(
1003
+ "[relocate] advisory -- found legacy `deft/run` references "
1004
+ "outside .deft/core/. These are NOT auto-rewritten; fix manually:",
1005
+ quiet=args.quiet,
1006
+ stream=sys.stderr,
1007
+ )
1008
+ for path, lineno, text in advisory:
1009
+ _emit_status(
1010
+ f" {path}:{lineno}: {text}",
1011
+ quiet=args.quiet,
1012
+ stream=sys.stderr,
1013
+ )
1014
+
1015
+ _emit_status(
1016
+ "[relocate] wipe-and-reinstall complete -- canonical .deft/core/ in place.",
1017
+ quiet=args.quiet,
1018
+ )
1019
+ return EXIT_SUCCESS
1020
+
1021
+
1022
+ def main(argv: Iterable[str] | None = None) -> int:
1023
+ parser = _build_parser()
1024
+ raw_argv = list(argv) if argv is not None else list(sys.argv[1:])
1025
+ args = parser.parse_args(raw_argv)
1026
+ try:
1027
+ return _run_relocate(args, raw_argv=raw_argv)
1028
+ except KeyboardInterrupt:
1029
+ print("[relocate] interrupted by operator.", file=sys.stderr)
1030
+ return EXIT_FAILURE
1031
+
1032
+
1033
+ if __name__ == "__main__": # pragma: no cover -- thin CLI shim
1034
+ raise SystemExit(main())