@deftai/directive-content 0.55.1 → 0.56.0

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 (220) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +13 -3
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +82 -11
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scripts/_agents_md.py +494 -0
  10. package/scripts/_cache_fetch.py +635 -0
  11. package/scripts/_cache_quota.py +529 -0
  12. package/scripts/_cache_refresh.py +163 -0
  13. package/scripts/_cache_validate.py +209 -0
  14. package/scripts/_content_root.py +42 -0
  15. package/scripts/_doctor_state.py +277 -0
  16. package/scripts/_event_detect.py +305 -0
  17. package/scripts/_events.py +514 -0
  18. package/scripts/_lifecycle_hygiene.py +568 -0
  19. package/scripts/_pathspec.py +91 -0
  20. package/scripts/_policy_show_cli.py +266 -0
  21. package/scripts/_precutover.py +92 -0
  22. package/scripts/_project_context.py +224 -0
  23. package/scripts/_project_definition_io.py +164 -0
  24. package/scripts/_relocate_snapshot.py +209 -0
  25. package/scripts/_relocate_states.py +343 -0
  26. package/scripts/_resolve_preflight_path.py +152 -0
  27. package/scripts/_safe_subprocess.py +167 -0
  28. package/scripts/_session_start_hook.py +205 -0
  29. package/scripts/_sor_gate_diff.py +365 -0
  30. package/scripts/_stdio_utf8.py +59 -0
  31. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  32. package/scripts/_triage_classify_cli.py +122 -0
  33. package/scripts/_triage_queue_cli.py +625 -0
  34. package/scripts/_triage_scope_cli.py +343 -0
  35. package/scripts/_triage_scope_drift_cli.py +121 -0
  36. package/scripts/_triage_scope_ignores.py +286 -0
  37. package/scripts/_triage_scope_milestone.py +432 -0
  38. package/scripts/_triage_scope_mutations.py +337 -0
  39. package/scripts/_triage_scope_renderers.py +207 -0
  40. package/scripts/_triage_smoketest_stages.py +674 -0
  41. package/scripts/_triage_subscribe_cli.py +140 -0
  42. package/scripts/_triage_welcome_cli.py +421 -0
  43. package/scripts/_vbrief_build.py +239 -0
  44. package/scripts/_vbrief_fidelity.py +479 -0
  45. package/scripts/_vbrief_legacy.py +589 -0
  46. package/scripts/_vbrief_reconciliation.py +883 -0
  47. package/scripts/_vbrief_routing.py +277 -0
  48. package/scripts/_vbrief_safety.py +778 -0
  49. package/scripts/_vbrief_sources.py +312 -0
  50. package/scripts/_vbrief_speckit.py +262 -0
  51. package/scripts/_vbrief_story_quality.py +353 -0
  52. package/scripts/_vbrief_validation.py +299 -0
  53. package/scripts/build_dist.py +412 -0
  54. package/scripts/cache.py +1078 -0
  55. package/scripts/cache_scanner.py +745 -0
  56. package/scripts/candidates_log.py +432 -0
  57. package/scripts/capacity_backfill.py +680 -0
  58. package/scripts/capacity_show.py +653 -0
  59. package/scripts/ci_local.py +689 -0
  60. package/scripts/code_structure_validate.py +765 -0
  61. package/scripts/codebase_default_extractor.py +495 -0
  62. package/scripts/codebase_map.py +304 -0
  63. package/scripts/codebase_map_fresh.py +104 -0
  64. package/scripts/codebase_projection_registry.py +94 -0
  65. package/scripts/codebase_provider.py +582 -0
  66. package/scripts/doctor.py +2257 -0
  67. package/scripts/framework_commands.py +505 -0
  68. package/scripts/gh_rest.py +882 -0
  69. package/scripts/github_auth_modes.py +437 -0
  70. package/scripts/github_body.py +292 -0
  71. package/scripts/ip_risk.py +531 -0
  72. package/scripts/issue_emit.py +670 -0
  73. package/scripts/issue_ingest.py +1064 -0
  74. package/scripts/migrate_preflight.py +418 -0
  75. package/scripts/migrate_vbrief.py +2677 -0
  76. package/scripts/monitor_pr.py +401 -0
  77. package/scripts/pack_migrate_lessons.py +336 -0
  78. package/scripts/pack_migrate_patterns.py +254 -0
  79. package/scripts/pack_migrate_rules.py +350 -0
  80. package/scripts/pack_migrate_skills.py +423 -0
  81. package/scripts/pack_migrate_strategies.py +311 -0
  82. package/scripts/pack_migrate_swarm_spec.py +250 -0
  83. package/scripts/pack_render.py +434 -0
  84. package/scripts/packs_slice.py +712 -0
  85. package/scripts/platform_capabilities.py +336 -0
  86. package/scripts/policy.py +2826 -0
  87. package/scripts/policy_set.py +324 -0
  88. package/scripts/pr_check_closing_keywords.py +524 -0
  89. package/scripts/pr_check_protected_issues.py +267 -0
  90. package/scripts/pr_merge_readiness.py +1004 -0
  91. package/scripts/pr_wait_mergeable.py +669 -0
  92. package/scripts/prd_render.py +159 -0
  93. package/scripts/preflight_architecture_sor.py +974 -0
  94. package/scripts/preflight_branch.py +289 -0
  95. package/scripts/preflight_cache.py +974 -0
  96. package/scripts/preflight_gh.py +721 -0
  97. package/scripts/preflight_implementation.py +272 -0
  98. package/scripts/preflight_story_start.py +838 -0
  99. package/scripts/preflight_wip_cap.py +149 -0
  100. package/scripts/probe_session.py +545 -0
  101. package/scripts/project_render.py +293 -0
  102. package/scripts/quarantine_ext.py +237 -0
  103. package/scripts/reconcile_issues.py +1442 -0
  104. package/scripts/refresh-path.ps1 +107 -0
  105. package/scripts/release.py +2030 -0
  106. package/scripts/release_e2e.py +1011 -0
  107. package/scripts/release_publish.py +486 -0
  108. package/scripts/release_rollback.py +980 -0
  109. package/scripts/relocate.py +1034 -0
  110. package/scripts/resolve_changelog_unreleased.py +667 -0
  111. package/scripts/resolve_version.py +490 -0
  112. package/scripts/resume_conditions.py +706 -0
  113. package/scripts/ritual_sentinel.py +609 -0
  114. package/scripts/roadmap_render.py +635 -0
  115. package/scripts/rule_ownership_lint.py +325 -0
  116. package/scripts/scm.py +591 -0
  117. package/scripts/scope_audit_log.py +387 -0
  118. package/scripts/scope_decompose.py +654 -0
  119. package/scripts/scope_demote.py +509 -0
  120. package/scripts/scope_lifecycle.py +1126 -0
  121. package/scripts/scope_undo.py +772 -0
  122. package/scripts/session_start.py +406 -0
  123. package/scripts/setup_ghx.py +339 -0
  124. package/scripts/setup_windows.ps1 +220 -0
  125. package/scripts/slice_audit.py +585 -0
  126. package/scripts/slice_record.py +530 -0
  127. package/scripts/slice_record_existing.py +692 -0
  128. package/scripts/slug_normalize.py +178 -0
  129. package/scripts/spec_render.py +477 -0
  130. package/scripts/spec_validate.py +238 -0
  131. package/scripts/subagent_monitor.py +658 -0
  132. package/scripts/swarm_complete_cohort.py +644 -0
  133. package/scripts/swarm_launch.py +1206 -0
  134. package/scripts/swarm_readiness.py +554 -0
  135. package/scripts/swarm_verify_review_clean.py +438 -0
  136. package/scripts/swarm_worktrees.py +497 -0
  137. package/scripts/toolchain-check.py +52 -0
  138. package/scripts/triage_actions.py +871 -0
  139. package/scripts/triage_bootstrap.py +1153 -0
  140. package/scripts/triage_bulk.py +630 -0
  141. package/scripts/triage_classify.py +932 -0
  142. package/scripts/triage_help.py +1685 -0
  143. package/scripts/triage_queue.py +1944 -0
  144. package/scripts/triage_reconcile.py +581 -0
  145. package/scripts/triage_refresh.py +643 -0
  146. package/scripts/triage_scope.py +999 -0
  147. package/scripts/triage_scope_drift.py +575 -0
  148. package/scripts/triage_smoketest.py +396 -0
  149. package/scripts/triage_subscribe.py +399 -0
  150. package/scripts/triage_summary.py +1011 -0
  151. package/scripts/triage_welcome.py +1178 -0
  152. package/scripts/ts_check_lane.py +86 -0
  153. package/scripts/validate-links.py +64 -0
  154. package/scripts/validate_strategy_output.py +212 -0
  155. package/scripts/vbrief_activate.py +228 -0
  156. package/scripts/vbrief_migrate_conformance.py +368 -0
  157. package/scripts/vbrief_reconcile_graph.py +306 -0
  158. package/scripts/vbrief_reconcile_labels.py +460 -0
  159. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  160. package/scripts/vbrief_validate.py +1195 -0
  161. package/scripts/verify-stubs.py +61 -0
  162. package/scripts/verify_capacity.py +160 -0
  163. package/scripts/verify_encoding.py +699 -0
  164. package/scripts/verify_hooks_installed.py +206 -0
  165. package/scripts/verify_investigation.py +360 -0
  166. package/scripts/verify_judgment_gates.py +827 -0
  167. package/scripts/verify_no_task_runtime.py +171 -0
  168. package/scripts/verify_scm_boundary.py +509 -0
  169. package/scripts/verify_session_ritual.py +389 -0
  170. package/scripts/verify_tools.py +426 -0
  171. package/scripts/verify_vbrief_conformance.py +478 -0
  172. package/skills/deft-directive-swarm/SKILL.md +7 -26
  173. package/skills/deft-directive-sync/SKILL.md +1 -1
  174. package/tasks/architecture.yml +13 -0
  175. package/tasks/cache.yml +69 -0
  176. package/tasks/capacity.yml +38 -0
  177. package/tasks/change.yml +46 -0
  178. package/tasks/changelog.yml +24 -0
  179. package/tasks/ci.yml +49 -0
  180. package/tasks/codebase.yml +47 -0
  181. package/tasks/commit.yml +30 -0
  182. package/tasks/core.yml +126 -0
  183. package/tasks/deployments.yml +54 -0
  184. package/tasks/framework.yml +74 -0
  185. package/tasks/install.yml +60 -0
  186. package/tasks/issue.yml +50 -0
  187. package/tasks/migrate.yml +73 -0
  188. package/tasks/packs.yml +92 -0
  189. package/tasks/policy.yml +75 -0
  190. package/tasks/pr.yml +89 -0
  191. package/tasks/prd.yml +39 -0
  192. package/tasks/project.yml +27 -0
  193. package/tasks/reconcile.yml +32 -0
  194. package/tasks/relocate.yml +56 -0
  195. package/tasks/roadmap.yml +28 -0
  196. package/tasks/scm.yml +126 -0
  197. package/tasks/scope-undo.yml +36 -0
  198. package/tasks/scope.yml +141 -0
  199. package/tasks/session.yml +19 -0
  200. package/tasks/setup.yml +37 -0
  201. package/tasks/slice.yml +69 -0
  202. package/tasks/spec.yml +41 -0
  203. package/tasks/swarm.yml +85 -0
  204. package/tasks/toolchain.yml +13 -0
  205. package/tasks/triage-actions.yml +94 -0
  206. package/tasks/triage-bootstrap.yml +43 -0
  207. package/tasks/triage-bulk.yml +75 -0
  208. package/tasks/triage-classify.yml +30 -0
  209. package/tasks/triage-queue.yml +50 -0
  210. package/tasks/triage-reconcile.yml +29 -0
  211. package/tasks/triage-scope-drift.yml +29 -0
  212. package/tasks/triage-scope.yml +31 -0
  213. package/tasks/triage-smoketest.yml +33 -0
  214. package/tasks/triage-subscribe.yml +36 -0
  215. package/tasks/triage-summary.yml +29 -0
  216. package/tasks/triage-welcome.yml +32 -0
  217. package/tasks/ts.yml +328 -0
  218. package/tasks/vbrief.yml +206 -0
  219. package/tasks/verify.yml +292 -0
  220. package/templates/agents-entry.md +2 -2
@@ -0,0 +1,164 @@
1
+ """Atomic PROJECT-DEFINITION read/write helpers (D14 / #1133).
2
+
3
+ Shared by ``scripts/triage_subscribe.py`` and
4
+ ``scripts/triage_scope_drift.py`` for the typed-policy mutation
5
+ surface introduced by D14 (subscribe / unsubscribe / ignore verbs).
6
+
7
+ Mirrors the atomic-write pattern in ``scripts/cache.py::_atomic_write_text``
8
+ (tempfile + ``os.replace``) so a crash mid-write leaves the file
9
+ untouched. The lifecycle file (``vbrief/PROJECT-DEFINITION.vbrief.json``)
10
+ is the only file these helpers touch; the typed policy block lives at
11
+ ``data["plan"]["policy"]``.
12
+
13
+ Pure stdlib. No third-party dependencies.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextlib
19
+ import json
20
+ import os
21
+ import sys
22
+ import tempfile
23
+ import threading
24
+ import time
25
+ from collections.abc import Iterator
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ _PROJECT_DEFINITION_REL_PATH = "vbrief/PROJECT-DEFINITION.vbrief.json"
30
+ _mutation_thread_lock = threading.Lock()
31
+
32
+
33
+ class ProjectDefinitionIOError(Exception):
34
+ """Raised when the PROJECT-DEFINITION file is missing or malformed."""
35
+
36
+
37
+ def project_definition_path(project_root: Path) -> Path:
38
+ return project_root / _PROJECT_DEFINITION_REL_PATH
39
+
40
+
41
+ @contextlib.contextmanager
42
+ def project_definition_mutation_lock(project_root: Path) -> Iterator[None]:
43
+ """Serialise PROJECT-DEFINITION read-modify-write critical sections.
44
+
45
+ The sidecar ``<file>.lock`` is removed on exit -- on the happy path AND
46
+ on an exception -- so a clean mutation never leaves
47
+ ``vbrief/PROJECT-DEFINITION.vbrief.json.lock`` behind for ``git add -A``
48
+ to trap on the next chore commit (#1311). The unlink runs in a
49
+ ``finally`` while the in-process ``_mutation_thread_lock`` is still held,
50
+ so no concurrent in-process acquirer can race it, and is best-effort: a
51
+ cross-process holder (Windows keeps the open file locked) or a benign
52
+ POSIX unlink race simply leaves the (gitignored) sidecar in place rather
53
+ than raising.
54
+ """
55
+ path = project_definition_path(project_root)
56
+ lock_path = path.parent / (path.name + ".lock")
57
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
58
+ with _mutation_thread_lock:
59
+ try:
60
+ with open(lock_path, "a+b") as fh:
61
+ if not lock_path.stat().st_size:
62
+ fh.write(b"\0")
63
+ fh.flush()
64
+ fh.seek(0)
65
+ if sys.platform == "win32":
66
+ import msvcrt
67
+
68
+ acquired = False
69
+ deadline = time.monotonic() + 30.0
70
+ while True:
71
+ try:
72
+ msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
73
+ acquired = True
74
+ break
75
+ except OSError:
76
+ if time.monotonic() > deadline:
77
+ raise
78
+ time.sleep(0.02)
79
+ try:
80
+ yield
81
+ finally:
82
+ if acquired:
83
+ fh.seek(0)
84
+ with contextlib.suppress(OSError):
85
+ msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
86
+ else:
87
+ import fcntl
88
+
89
+ fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
90
+ try:
91
+ yield
92
+ finally:
93
+ fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
94
+ finally:
95
+ # The sidecar handle is closed by the `with open(...)` block above
96
+ # BEFORE this unlink (Windows refuses to delete an open file); the
97
+ # unlink is held under _mutation_thread_lock so it cannot race an
98
+ # in-process re-acquire, and is best-effort across processes
99
+ # (#1311 -- do not leave PROJECT-DEFINITION.vbrief.json.lock behind).
100
+ with contextlib.suppress(OSError):
101
+ lock_path.unlink()
102
+
103
+
104
+ def load_project_definition_for_mutation(
105
+ project_root: Path,
106
+ ) -> tuple[dict[str, Any], Path]:
107
+ """Read PROJECT-DEFINITION.vbrief.json and return ``(data, path)``.
108
+
109
+ Raises :class:`ProjectDefinitionIOError` if the file is missing or
110
+ cannot be parsed as a JSON object. The returned dict is a mutable
111
+ deep copy of the on-disk state; callers mutate it and pass it to
112
+ :func:`atomic_write_project_definition` to persist.
113
+ """
114
+ path = project_definition_path(project_root)
115
+ if not path.is_file():
116
+ raise ProjectDefinitionIOError(
117
+ f"PROJECT-DEFINITION not found at {path}; run task triage:welcome / "
118
+ "task triage:bootstrap to scaffold one first."
119
+ )
120
+ try:
121
+ raw = path.read_text(encoding="utf-8")
122
+ except OSError as exc:
123
+ raise ProjectDefinitionIOError(
124
+ f"Could not read PROJECT-DEFINITION at {path}: {exc}"
125
+ ) from exc
126
+ try:
127
+ data = json.loads(raw)
128
+ except json.JSONDecodeError as exc:
129
+ raise ProjectDefinitionIOError(
130
+ f"PROJECT-DEFINITION at {path} is not valid JSON: {exc}"
131
+ ) from exc
132
+ if not isinstance(data, dict):
133
+ raise ProjectDefinitionIOError(
134
+ f"PROJECT-DEFINITION at {path} top-level value is not a JSON object"
135
+ )
136
+ return data, path
137
+
138
+
139
+ def atomic_write_project_definition(path: Path, data: dict[str, Any]) -> None:
140
+ """Atomically write ``data`` to ``path`` as pretty-printed JSON.
141
+
142
+ Uses a tempfile + ``os.replace`` so the file is either fully
143
+ written or completely unchanged. The parent directory is created
144
+ on demand for first-write scenarios (fresh consumer installs).
145
+ """
146
+ path.parent.mkdir(parents=True, exist_ok=True)
147
+ payload = json.dumps(data, indent=2, ensure_ascii=False, sort_keys=False)
148
+ fd, tmp_name = tempfile.mkstemp(
149
+ prefix=path.name + ".", suffix=".tmp", dir=str(path.parent)
150
+ )
151
+ tmp = Path(tmp_name)
152
+ try:
153
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as fh:
154
+ fh.write(payload)
155
+ if not payload.endswith("\n"):
156
+ fh.write("\n")
157
+ fh.flush()
158
+ with contextlib.suppress(OSError):
159
+ os.fsync(fh.fileno())
160
+ os.replace(tmp, path)
161
+ except BaseException:
162
+ with contextlib.suppress(FileNotFoundError):
163
+ tmp.unlink()
164
+ raise
@@ -0,0 +1,209 @@
1
+ """_relocate_snapshot.py -- snapshot tarball helpers for scripts/relocate.py (#992 PR2).
2
+
3
+ Extracted from :mod:`scripts.relocate` to keep the parent under the deft
4
+ 1000-line MUST limit. Mirrors the
5
+ ``scripts/cache.py`` / ``scripts/_cache_validate.py`` /
6
+ ``scripts/_cache_fetch.py`` split pattern.
7
+
8
+ Public API:
9
+
10
+ - :func:`create_snapshot` -- tar the consumer pre-relocate state.
11
+ - :func:`extract_snapshot` -- untar a previously-written snapshot back.
12
+ - :func:`latest_snapshot` -- newest snapshot in ``.deft-cache/``.
13
+ - :func:`snapshot_path` -- conventional path for the next snapshot.
14
+ - :func:`snapshot_dir` -- ``<project-root>/.deft-cache``.
15
+ - :func:`utc_timestamp` -- ``YYYYMMDDTHHMMSSZ`` for snapshot filenames.
16
+
17
+ The snapshot is gzip-compressed tar with members rooted at
18
+ ``project_root`` so ``tar.extractall(project_root, filter='data')``
19
+ restores them directly into place.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import shutil
25
+ import tarfile
26
+ from datetime import UTC, datetime
27
+ from pathlib import Path
28
+
29
+ CANONICAL_FRAMEWORK_DIR: str = ".deft/core"
30
+ LEGACY_FRAMEWORK_DIR: str = "deft"
31
+ SNAPSHOT_PREFIX: str = "relocate-snapshot-"
32
+
33
+ #: Project-relative paths the snapshot tarball is allowed to capture or
34
+ #: leave residue for. The rollback path uses this set to clean any tracked
35
+ #: path that was NOT captured in the snapshot (i.e. was created by the
36
+ #: relocator post-snapshot) so the rollback restores a byte-equivalent
37
+ #: pre-relocate state for tracked files. F3 fix (#1015): without this
38
+ #: set the relocator-created ``.gitignore`` was left as residue after a
39
+ #: rollback when the pre-relocate project had no ``.gitignore``.
40
+ ROLLBACK_TRACKED_PATHS: tuple[str, ...] = (
41
+ LEGACY_FRAMEWORK_DIR,
42
+ CANONICAL_FRAMEWORK_DIR,
43
+ "AGENTS.md",
44
+ ".gitignore",
45
+ )
46
+
47
+
48
+ __all__ = [
49
+ "CANONICAL_FRAMEWORK_DIR",
50
+ "LEGACY_FRAMEWORK_DIR",
51
+ "ROLLBACK_TRACKED_PATHS",
52
+ "SNAPSHOT_PREFIX",
53
+ "SnapshotError",
54
+ "create_snapshot",
55
+ "extract_snapshot",
56
+ "latest_snapshot",
57
+ "snapshot_dir",
58
+ "snapshot_path",
59
+ "utc_timestamp",
60
+ ]
61
+
62
+
63
+ class SnapshotError(RuntimeError):
64
+ """Snapshot create / extract failure (raised with a descriptive message)."""
65
+
66
+ def __init__(self, message: str, *, exit_code: int = 1) -> None:
67
+ super().__init__(message)
68
+ self.exit_code = exit_code
69
+
70
+
71
+ def utc_timestamp() -> str:
72
+ """Return ``YYYYMMDDTHHMMSSZ`` suitable for the snapshot filename."""
73
+ return datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
74
+
75
+
76
+ def snapshot_dir(project_root: Path) -> Path:
77
+ return project_root / ".deft-cache"
78
+
79
+
80
+ def snapshot_path(project_root: Path, *, timestamp: str | None = None) -> Path:
81
+ stamp = timestamp or utc_timestamp()
82
+ return snapshot_dir(project_root) / f"{SNAPSHOT_PREFIX}{stamp}.tar.gz"
83
+
84
+
85
+ def latest_snapshot(project_root: Path) -> Path | None:
86
+ sdir = snapshot_dir(project_root)
87
+ if not sdir.is_dir():
88
+ return None
89
+ candidates = sorted(sdir.glob(f"{SNAPSHOT_PREFIX}*.tar.gz"))
90
+ return candidates[-1] if candidates else None
91
+
92
+
93
+ def create_snapshot(
94
+ project_root: Path,
95
+ *,
96
+ target: Path | None = None,
97
+ timestamp: str | None = None,
98
+ ) -> Path:
99
+ """Tarball the consumer's pre-relocate state into ``.deft-cache/``.
100
+
101
+ Captures (when present): every path in :data:`ROLLBACK_TRACKED_PATHS`
102
+ -- ``<project-root>/deft/``, ``<project-root>/.deft/core/``,
103
+ ``<project-root>/AGENTS.md``, and ``<project-root>/.gitignore``. The
104
+ tarball uses paths relative to ``project_root`` so
105
+ :func:`extract_snapshot` restores them directly. Tracked paths that
106
+ DID NOT exist pre-relocate are intentionally absent from the tarball;
107
+ the rollback path uses that absence to recognise relocator-created
108
+ residue and remove it (F3 fix #1015).
109
+ """
110
+ out = target or snapshot_path(project_root, timestamp=timestamp)
111
+ out.parent.mkdir(parents=True, exist_ok=True)
112
+ members = [project_root / name for name in ROLLBACK_TRACKED_PATHS]
113
+ captured = [m for m in members if m.exists()]
114
+ with tarfile.open(out, "w:gz") as tar:
115
+ for member in captured:
116
+ try:
117
+ arcname = member.relative_to(project_root).as_posix()
118
+ except ValueError:
119
+ arcname = member.name
120
+ tar.add(str(member), arcname=arcname, recursive=True)
121
+ return out
122
+
123
+
124
+ def extract_snapshot(
125
+ project_root: Path,
126
+ *,
127
+ snapshot: Path | None = None,
128
+ ) -> Path:
129
+ """Extract ``snapshot`` (or the most recent) back into ``project_root``.
130
+
131
+ Tracked-paths contract (F3 fix, #1015): every path in
132
+ :data:`ROLLBACK_TRACKED_PATHS` is reset before extraction --
133
+
134
+ - paths captured in the tarball are removed first (so a
135
+ partially-relocated tree doesn't carry stale bytes forward) then
136
+ re-extracted, and
137
+ - paths NOT captured in the tarball are removed unconditionally
138
+ because the relocator created them post-snapshot (e.g. a
139
+ relocator-created ``.gitignore`` when the pre-relocate project had
140
+ none). Without this step, ``git status --porcelain`` post-rollback
141
+ would surface ``?? .gitignore`` and break the byte-equivalent
142
+ pre-relocate-state contract.
143
+
144
+ The ``.deft-cache/`` directory (which hosts the snapshot tarball
145
+ itself) is intentionally NOT in the tracked-paths set -- removing it
146
+ would break re-rollback against the same snapshot.
147
+
148
+ Returns the snapshot path that was actually extracted (handy for the
149
+ operator-facing log line).
150
+ """
151
+ chosen = snapshot or latest_snapshot(project_root)
152
+ if chosen is None:
153
+ raise SnapshotError(
154
+ f"no snapshot found under {snapshot_dir(project_root)} -- "
155
+ "rollback requires either --snapshot PATH or a prior wipe-and-reinstall"
156
+ )
157
+ if not chosen.is_file():
158
+ raise SnapshotError(
159
+ f"snapshot path {chosen} does not exist or is not a file",
160
+ exit_code=2,
161
+ )
162
+
163
+ # F3 residue fix (#1015): clear EVERY tracked path before extracting
164
+ # the tarball. Captured tracked paths are then restored by
165
+ # ``extractall``; uncaptured tracked paths (relocator-created
166
+ # post-snapshot residue, e.g. a fresh ``.gitignore`` written by
167
+ # ``_ensure_gitignore_lines`` when the pre-relocate project had
168
+ # none) stay absent so ``git status --porcelain`` is clean.
169
+ # Symmetrically removing captured paths up front also guarantees no
170
+ # partially-relocated bytes survive into the rolled-back state.
171
+ for name in ROLLBACK_TRACKED_PATHS:
172
+ target = project_root / name
173
+ if target.is_dir() and not target.is_symlink():
174
+ shutil.rmtree(target)
175
+ elif target.is_file() or target.is_symlink():
176
+ target.unlink()
177
+
178
+ with tarfile.open(chosen, "r:gz") as tar:
179
+ _safe_extract(tar, project_root)
180
+ return chosen
181
+
182
+
183
+ def _captured_top_level_names(snapshot: Path) -> set[str]:
184
+ """Return the set of top-level paths captured in ``snapshot``.
185
+
186
+ Used by :func:`extract_snapshot` to distinguish tracked paths that
187
+ were captured (and thus will be restored by the tarball extract)
188
+ from tracked paths that the relocator created post-snapshot (which
189
+ must be removed without restoration to honour the byte-equivalent
190
+ pre-relocate-state contract -- F3 fix #1015).
191
+ """
192
+ with tarfile.open(snapshot, "r:gz") as tar:
193
+ names = tar.getnames()
194
+ return {n.split("/", 1)[0] for n in names if n}
195
+
196
+
197
+ def _safe_extract(tar: tarfile.TarFile, dest: Path) -> None:
198
+ """Reject path traversal before extracting (per Python 3.12 best practice)."""
199
+ dest_resolved = dest.resolve()
200
+ for member in tar.getmembers():
201
+ member_target = (dest / member.name).resolve()
202
+ try:
203
+ member_target.relative_to(dest_resolved)
204
+ except ValueError:
205
+ raise SnapshotError(
206
+ f"snapshot member {member.name!r} would extract outside {dest}",
207
+ exit_code=2,
208
+ ) from None
209
+ tar.extractall(dest, filter="data") # type: ignore[arg-type]