@deftai/directive-content 0.55.2 → 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.
- package/.githooks/pre-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +2 -2
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +47 -1
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/scripts/_agents_md.py +494 -0
- package/scripts/_cache_fetch.py +635 -0
- package/scripts/_cache_quota.py +529 -0
- package/scripts/_cache_refresh.py +163 -0
- package/scripts/_cache_validate.py +209 -0
- package/scripts/_content_root.py +42 -0
- package/scripts/_doctor_state.py +277 -0
- package/scripts/_event_detect.py +305 -0
- package/scripts/_events.py +514 -0
- package/scripts/_lifecycle_hygiene.py +568 -0
- package/scripts/_pathspec.py +91 -0
- package/scripts/_policy_show_cli.py +266 -0
- package/scripts/_precutover.py +92 -0
- package/scripts/_project_context.py +224 -0
- package/scripts/_project_definition_io.py +164 -0
- package/scripts/_relocate_snapshot.py +209 -0
- package/scripts/_relocate_states.py +343 -0
- package/scripts/_resolve_preflight_path.py +152 -0
- package/scripts/_safe_subprocess.py +167 -0
- package/scripts/_session_start_hook.py +205 -0
- package/scripts/_sor_gate_diff.py +365 -0
- package/scripts/_stdio_utf8.py +59 -0
- package/scripts/_triage_bootstrap_gitignore.py +904 -0
- package/scripts/_triage_classify_cli.py +122 -0
- package/scripts/_triage_queue_cli.py +625 -0
- package/scripts/_triage_scope_cli.py +343 -0
- package/scripts/_triage_scope_drift_cli.py +121 -0
- package/scripts/_triage_scope_ignores.py +286 -0
- package/scripts/_triage_scope_milestone.py +432 -0
- package/scripts/_triage_scope_mutations.py +337 -0
- package/scripts/_triage_scope_renderers.py +207 -0
- package/scripts/_triage_smoketest_stages.py +674 -0
- package/scripts/_triage_subscribe_cli.py +140 -0
- package/scripts/_triage_welcome_cli.py +421 -0
- package/scripts/_vbrief_build.py +239 -0
- package/scripts/_vbrief_fidelity.py +479 -0
- package/scripts/_vbrief_legacy.py +589 -0
- package/scripts/_vbrief_reconciliation.py +883 -0
- package/scripts/_vbrief_routing.py +277 -0
- package/scripts/_vbrief_safety.py +778 -0
- package/scripts/_vbrief_sources.py +312 -0
- package/scripts/_vbrief_speckit.py +262 -0
- package/scripts/_vbrief_story_quality.py +353 -0
- package/scripts/_vbrief_validation.py +299 -0
- package/scripts/build_dist.py +412 -0
- package/scripts/cache.py +1078 -0
- package/scripts/cache_scanner.py +745 -0
- package/scripts/candidates_log.py +432 -0
- package/scripts/capacity_backfill.py +680 -0
- package/scripts/capacity_show.py +653 -0
- package/scripts/ci_local.py +689 -0
- package/scripts/code_structure_validate.py +765 -0
- package/scripts/codebase_default_extractor.py +495 -0
- package/scripts/codebase_map.py +304 -0
- package/scripts/codebase_map_fresh.py +104 -0
- package/scripts/codebase_projection_registry.py +94 -0
- package/scripts/codebase_provider.py +582 -0
- package/scripts/doctor.py +2257 -0
- package/scripts/framework_commands.py +505 -0
- package/scripts/gh_rest.py +882 -0
- package/scripts/github_auth_modes.py +437 -0
- package/scripts/github_body.py +292 -0
- package/scripts/ip_risk.py +531 -0
- package/scripts/issue_emit.py +670 -0
- package/scripts/issue_ingest.py +1064 -0
- package/scripts/migrate_preflight.py +418 -0
- package/scripts/migrate_vbrief.py +2677 -0
- package/scripts/monitor_pr.py +401 -0
- package/scripts/pack_migrate_lessons.py +336 -0
- package/scripts/pack_migrate_patterns.py +254 -0
- package/scripts/pack_migrate_rules.py +350 -0
- package/scripts/pack_migrate_skills.py +423 -0
- package/scripts/pack_migrate_strategies.py +311 -0
- package/scripts/pack_migrate_swarm_spec.py +250 -0
- package/scripts/pack_render.py +434 -0
- package/scripts/packs_slice.py +712 -0
- package/scripts/platform_capabilities.py +336 -0
- package/scripts/policy.py +2826 -0
- package/scripts/policy_set.py +324 -0
- package/scripts/pr_check_closing_keywords.py +524 -0
- package/scripts/pr_check_protected_issues.py +267 -0
- package/scripts/pr_merge_readiness.py +1004 -0
- package/scripts/pr_wait_mergeable.py +669 -0
- package/scripts/prd_render.py +159 -0
- package/scripts/preflight_architecture_sor.py +974 -0
- package/scripts/preflight_branch.py +289 -0
- package/scripts/preflight_cache.py +974 -0
- package/scripts/preflight_gh.py +721 -0
- package/scripts/preflight_implementation.py +272 -0
- package/scripts/preflight_story_start.py +838 -0
- package/scripts/preflight_wip_cap.py +149 -0
- package/scripts/probe_session.py +545 -0
- package/scripts/project_render.py +293 -0
- package/scripts/quarantine_ext.py +237 -0
- package/scripts/reconcile_issues.py +1442 -0
- package/scripts/refresh-path.ps1 +107 -0
- package/scripts/release.py +2030 -0
- package/scripts/release_e2e.py +1011 -0
- package/scripts/release_publish.py +486 -0
- package/scripts/release_rollback.py +980 -0
- package/scripts/relocate.py +1034 -0
- package/scripts/resolve_changelog_unreleased.py +667 -0
- package/scripts/resolve_version.py +490 -0
- package/scripts/resume_conditions.py +706 -0
- package/scripts/ritual_sentinel.py +609 -0
- package/scripts/roadmap_render.py +635 -0
- package/scripts/rule_ownership_lint.py +325 -0
- package/scripts/scm.py +591 -0
- package/scripts/scope_audit_log.py +387 -0
- package/scripts/scope_decompose.py +654 -0
- package/scripts/scope_demote.py +509 -0
- package/scripts/scope_lifecycle.py +1126 -0
- package/scripts/scope_undo.py +772 -0
- package/scripts/session_start.py +406 -0
- package/scripts/setup_ghx.py +339 -0
- package/scripts/setup_windows.ps1 +220 -0
- package/scripts/slice_audit.py +585 -0
- package/scripts/slice_record.py +530 -0
- package/scripts/slice_record_existing.py +692 -0
- package/scripts/slug_normalize.py +178 -0
- package/scripts/spec_render.py +477 -0
- package/scripts/spec_validate.py +238 -0
- package/scripts/subagent_monitor.py +658 -0
- package/scripts/swarm_complete_cohort.py +644 -0
- package/scripts/swarm_launch.py +1206 -0
- package/scripts/swarm_readiness.py +554 -0
- package/scripts/swarm_verify_review_clean.py +438 -0
- package/scripts/swarm_worktrees.py +497 -0
- package/scripts/toolchain-check.py +52 -0
- package/scripts/triage_actions.py +871 -0
- package/scripts/triage_bootstrap.py +1153 -0
- package/scripts/triage_bulk.py +630 -0
- package/scripts/triage_classify.py +932 -0
- package/scripts/triage_help.py +1685 -0
- package/scripts/triage_queue.py +1944 -0
- package/scripts/triage_reconcile.py +581 -0
- package/scripts/triage_refresh.py +643 -0
- package/scripts/triage_scope.py +999 -0
- package/scripts/triage_scope_drift.py +575 -0
- package/scripts/triage_smoketest.py +396 -0
- package/scripts/triage_subscribe.py +399 -0
- package/scripts/triage_summary.py +1011 -0
- package/scripts/triage_welcome.py +1178 -0
- package/scripts/ts_check_lane.py +86 -0
- package/scripts/validate-links.py +64 -0
- package/scripts/validate_strategy_output.py +212 -0
- package/scripts/vbrief_activate.py +228 -0
- package/scripts/vbrief_migrate_conformance.py +368 -0
- package/scripts/vbrief_reconcile_graph.py +306 -0
- package/scripts/vbrief_reconcile_labels.py +460 -0
- package/scripts/vbrief_reconcile_umbrellas.py +741 -0
- package/scripts/vbrief_validate.py +1195 -0
- package/scripts/verify-stubs.py +61 -0
- package/scripts/verify_capacity.py +160 -0
- package/scripts/verify_encoding.py +699 -0
- package/scripts/verify_hooks_installed.py +206 -0
- package/scripts/verify_investigation.py +360 -0
- package/scripts/verify_judgment_gates.py +827 -0
- package/scripts/verify_no_task_runtime.py +171 -0
- package/scripts/verify_scm_boundary.py +509 -0
- package/scripts/verify_session_ritual.py +389 -0
- package/scripts/verify_tools.py +426 -0
- package/scripts/verify_vbrief_conformance.py +478 -0
- package/tasks/architecture.yml +13 -0
- package/tasks/cache.yml +69 -0
- package/tasks/capacity.yml +38 -0
- package/tasks/change.yml +46 -0
- package/tasks/changelog.yml +24 -0
- package/tasks/ci.yml +49 -0
- package/tasks/codebase.yml +47 -0
- package/tasks/commit.yml +30 -0
- package/tasks/core.yml +126 -0
- package/tasks/deployments.yml +54 -0
- package/tasks/framework.yml +74 -0
- package/tasks/install.yml +60 -0
- package/tasks/issue.yml +50 -0
- package/tasks/migrate.yml +73 -0
- package/tasks/packs.yml +92 -0
- package/tasks/policy.yml +75 -0
- package/tasks/pr.yml +89 -0
- package/tasks/prd.yml +39 -0
- package/tasks/project.yml +27 -0
- package/tasks/reconcile.yml +32 -0
- package/tasks/relocate.yml +56 -0
- package/tasks/roadmap.yml +28 -0
- package/tasks/scm.yml +126 -0
- package/tasks/scope-undo.yml +36 -0
- package/tasks/scope.yml +141 -0
- package/tasks/session.yml +19 -0
- package/tasks/setup.yml +37 -0
- package/tasks/slice.yml +69 -0
- package/tasks/spec.yml +41 -0
- package/tasks/swarm.yml +85 -0
- package/tasks/toolchain.yml +13 -0
- package/tasks/triage-actions.yml +94 -0
- package/tasks/triage-bootstrap.yml +43 -0
- package/tasks/triage-bulk.yml +75 -0
- package/tasks/triage-classify.yml +30 -0
- package/tasks/triage-queue.yml +50 -0
- package/tasks/triage-reconcile.yml +29 -0
- package/tasks/triage-scope-drift.yml +29 -0
- package/tasks/triage-scope.yml +31 -0
- package/tasks/triage-smoketest.yml +33 -0
- package/tasks/triage-subscribe.yml +36 -0
- package/tasks/triage-summary.yml +29 -0
- package/tasks/triage-welcome.yml +32 -0
- package/tasks/ts.yml +328 -0
- package/tasks/vbrief.yml +206 -0
- package/tasks/verify.yml +292 -0
- package/templates/agents-entry.md +1 -1
|
@@ -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]
|