@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.
- 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,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())
|