@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.
Files changed (217) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +2 -2
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +47 -1
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/scripts/_agents_md.py +494 -0
  9. package/scripts/_cache_fetch.py +635 -0
  10. package/scripts/_cache_quota.py +529 -0
  11. package/scripts/_cache_refresh.py +163 -0
  12. package/scripts/_cache_validate.py +209 -0
  13. package/scripts/_content_root.py +42 -0
  14. package/scripts/_doctor_state.py +277 -0
  15. package/scripts/_event_detect.py +305 -0
  16. package/scripts/_events.py +514 -0
  17. package/scripts/_lifecycle_hygiene.py +568 -0
  18. package/scripts/_pathspec.py +91 -0
  19. package/scripts/_policy_show_cli.py +266 -0
  20. package/scripts/_precutover.py +92 -0
  21. package/scripts/_project_context.py +224 -0
  22. package/scripts/_project_definition_io.py +164 -0
  23. package/scripts/_relocate_snapshot.py +209 -0
  24. package/scripts/_relocate_states.py +343 -0
  25. package/scripts/_resolve_preflight_path.py +152 -0
  26. package/scripts/_safe_subprocess.py +167 -0
  27. package/scripts/_session_start_hook.py +205 -0
  28. package/scripts/_sor_gate_diff.py +365 -0
  29. package/scripts/_stdio_utf8.py +59 -0
  30. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  31. package/scripts/_triage_classify_cli.py +122 -0
  32. package/scripts/_triage_queue_cli.py +625 -0
  33. package/scripts/_triage_scope_cli.py +343 -0
  34. package/scripts/_triage_scope_drift_cli.py +121 -0
  35. package/scripts/_triage_scope_ignores.py +286 -0
  36. package/scripts/_triage_scope_milestone.py +432 -0
  37. package/scripts/_triage_scope_mutations.py +337 -0
  38. package/scripts/_triage_scope_renderers.py +207 -0
  39. package/scripts/_triage_smoketest_stages.py +674 -0
  40. package/scripts/_triage_subscribe_cli.py +140 -0
  41. package/scripts/_triage_welcome_cli.py +421 -0
  42. package/scripts/_vbrief_build.py +239 -0
  43. package/scripts/_vbrief_fidelity.py +479 -0
  44. package/scripts/_vbrief_legacy.py +589 -0
  45. package/scripts/_vbrief_reconciliation.py +883 -0
  46. package/scripts/_vbrief_routing.py +277 -0
  47. package/scripts/_vbrief_safety.py +778 -0
  48. package/scripts/_vbrief_sources.py +312 -0
  49. package/scripts/_vbrief_speckit.py +262 -0
  50. package/scripts/_vbrief_story_quality.py +353 -0
  51. package/scripts/_vbrief_validation.py +299 -0
  52. package/scripts/build_dist.py +412 -0
  53. package/scripts/cache.py +1078 -0
  54. package/scripts/cache_scanner.py +745 -0
  55. package/scripts/candidates_log.py +432 -0
  56. package/scripts/capacity_backfill.py +680 -0
  57. package/scripts/capacity_show.py +653 -0
  58. package/scripts/ci_local.py +689 -0
  59. package/scripts/code_structure_validate.py +765 -0
  60. package/scripts/codebase_default_extractor.py +495 -0
  61. package/scripts/codebase_map.py +304 -0
  62. package/scripts/codebase_map_fresh.py +104 -0
  63. package/scripts/codebase_projection_registry.py +94 -0
  64. package/scripts/codebase_provider.py +582 -0
  65. package/scripts/doctor.py +2257 -0
  66. package/scripts/framework_commands.py +505 -0
  67. package/scripts/gh_rest.py +882 -0
  68. package/scripts/github_auth_modes.py +437 -0
  69. package/scripts/github_body.py +292 -0
  70. package/scripts/ip_risk.py +531 -0
  71. package/scripts/issue_emit.py +670 -0
  72. package/scripts/issue_ingest.py +1064 -0
  73. package/scripts/migrate_preflight.py +418 -0
  74. package/scripts/migrate_vbrief.py +2677 -0
  75. package/scripts/monitor_pr.py +401 -0
  76. package/scripts/pack_migrate_lessons.py +336 -0
  77. package/scripts/pack_migrate_patterns.py +254 -0
  78. package/scripts/pack_migrate_rules.py +350 -0
  79. package/scripts/pack_migrate_skills.py +423 -0
  80. package/scripts/pack_migrate_strategies.py +311 -0
  81. package/scripts/pack_migrate_swarm_spec.py +250 -0
  82. package/scripts/pack_render.py +434 -0
  83. package/scripts/packs_slice.py +712 -0
  84. package/scripts/platform_capabilities.py +336 -0
  85. package/scripts/policy.py +2826 -0
  86. package/scripts/policy_set.py +324 -0
  87. package/scripts/pr_check_closing_keywords.py +524 -0
  88. package/scripts/pr_check_protected_issues.py +267 -0
  89. package/scripts/pr_merge_readiness.py +1004 -0
  90. package/scripts/pr_wait_mergeable.py +669 -0
  91. package/scripts/prd_render.py +159 -0
  92. package/scripts/preflight_architecture_sor.py +974 -0
  93. package/scripts/preflight_branch.py +289 -0
  94. package/scripts/preflight_cache.py +974 -0
  95. package/scripts/preflight_gh.py +721 -0
  96. package/scripts/preflight_implementation.py +272 -0
  97. package/scripts/preflight_story_start.py +838 -0
  98. package/scripts/preflight_wip_cap.py +149 -0
  99. package/scripts/probe_session.py +545 -0
  100. package/scripts/project_render.py +293 -0
  101. package/scripts/quarantine_ext.py +237 -0
  102. package/scripts/reconcile_issues.py +1442 -0
  103. package/scripts/refresh-path.ps1 +107 -0
  104. package/scripts/release.py +2030 -0
  105. package/scripts/release_e2e.py +1011 -0
  106. package/scripts/release_publish.py +486 -0
  107. package/scripts/release_rollback.py +980 -0
  108. package/scripts/relocate.py +1034 -0
  109. package/scripts/resolve_changelog_unreleased.py +667 -0
  110. package/scripts/resolve_version.py +490 -0
  111. package/scripts/resume_conditions.py +706 -0
  112. package/scripts/ritual_sentinel.py +609 -0
  113. package/scripts/roadmap_render.py +635 -0
  114. package/scripts/rule_ownership_lint.py +325 -0
  115. package/scripts/scm.py +591 -0
  116. package/scripts/scope_audit_log.py +387 -0
  117. package/scripts/scope_decompose.py +654 -0
  118. package/scripts/scope_demote.py +509 -0
  119. package/scripts/scope_lifecycle.py +1126 -0
  120. package/scripts/scope_undo.py +772 -0
  121. package/scripts/session_start.py +406 -0
  122. package/scripts/setup_ghx.py +339 -0
  123. package/scripts/setup_windows.ps1 +220 -0
  124. package/scripts/slice_audit.py +585 -0
  125. package/scripts/slice_record.py +530 -0
  126. package/scripts/slice_record_existing.py +692 -0
  127. package/scripts/slug_normalize.py +178 -0
  128. package/scripts/spec_render.py +477 -0
  129. package/scripts/spec_validate.py +238 -0
  130. package/scripts/subagent_monitor.py +658 -0
  131. package/scripts/swarm_complete_cohort.py +644 -0
  132. package/scripts/swarm_launch.py +1206 -0
  133. package/scripts/swarm_readiness.py +554 -0
  134. package/scripts/swarm_verify_review_clean.py +438 -0
  135. package/scripts/swarm_worktrees.py +497 -0
  136. package/scripts/toolchain-check.py +52 -0
  137. package/scripts/triage_actions.py +871 -0
  138. package/scripts/triage_bootstrap.py +1153 -0
  139. package/scripts/triage_bulk.py +630 -0
  140. package/scripts/triage_classify.py +932 -0
  141. package/scripts/triage_help.py +1685 -0
  142. package/scripts/triage_queue.py +1944 -0
  143. package/scripts/triage_reconcile.py +581 -0
  144. package/scripts/triage_refresh.py +643 -0
  145. package/scripts/triage_scope.py +999 -0
  146. package/scripts/triage_scope_drift.py +575 -0
  147. package/scripts/triage_smoketest.py +396 -0
  148. package/scripts/triage_subscribe.py +399 -0
  149. package/scripts/triage_summary.py +1011 -0
  150. package/scripts/triage_welcome.py +1178 -0
  151. package/scripts/ts_check_lane.py +86 -0
  152. package/scripts/validate-links.py +64 -0
  153. package/scripts/validate_strategy_output.py +212 -0
  154. package/scripts/vbrief_activate.py +228 -0
  155. package/scripts/vbrief_migrate_conformance.py +368 -0
  156. package/scripts/vbrief_reconcile_graph.py +306 -0
  157. package/scripts/vbrief_reconcile_labels.py +460 -0
  158. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  159. package/scripts/vbrief_validate.py +1195 -0
  160. package/scripts/verify-stubs.py +61 -0
  161. package/scripts/verify_capacity.py +160 -0
  162. package/scripts/verify_encoding.py +699 -0
  163. package/scripts/verify_hooks_installed.py +206 -0
  164. package/scripts/verify_investigation.py +360 -0
  165. package/scripts/verify_judgment_gates.py +827 -0
  166. package/scripts/verify_no_task_runtime.py +171 -0
  167. package/scripts/verify_scm_boundary.py +509 -0
  168. package/scripts/verify_session_ritual.py +389 -0
  169. package/scripts/verify_tools.py +426 -0
  170. package/scripts/verify_vbrief_conformance.py +478 -0
  171. package/tasks/architecture.yml +13 -0
  172. package/tasks/cache.yml +69 -0
  173. package/tasks/capacity.yml +38 -0
  174. package/tasks/change.yml +46 -0
  175. package/tasks/changelog.yml +24 -0
  176. package/tasks/ci.yml +49 -0
  177. package/tasks/codebase.yml +47 -0
  178. package/tasks/commit.yml +30 -0
  179. package/tasks/core.yml +126 -0
  180. package/tasks/deployments.yml +54 -0
  181. package/tasks/framework.yml +74 -0
  182. package/tasks/install.yml +60 -0
  183. package/tasks/issue.yml +50 -0
  184. package/tasks/migrate.yml +73 -0
  185. package/tasks/packs.yml +92 -0
  186. package/tasks/policy.yml +75 -0
  187. package/tasks/pr.yml +89 -0
  188. package/tasks/prd.yml +39 -0
  189. package/tasks/project.yml +27 -0
  190. package/tasks/reconcile.yml +32 -0
  191. package/tasks/relocate.yml +56 -0
  192. package/tasks/roadmap.yml +28 -0
  193. package/tasks/scm.yml +126 -0
  194. package/tasks/scope-undo.yml +36 -0
  195. package/tasks/scope.yml +141 -0
  196. package/tasks/session.yml +19 -0
  197. package/tasks/setup.yml +37 -0
  198. package/tasks/slice.yml +69 -0
  199. package/tasks/spec.yml +41 -0
  200. package/tasks/swarm.yml +85 -0
  201. package/tasks/toolchain.yml +13 -0
  202. package/tasks/triage-actions.yml +94 -0
  203. package/tasks/triage-bootstrap.yml +43 -0
  204. package/tasks/triage-bulk.yml +75 -0
  205. package/tasks/triage-classify.yml +30 -0
  206. package/tasks/triage-queue.yml +50 -0
  207. package/tasks/triage-reconcile.yml +29 -0
  208. package/tasks/triage-scope-drift.yml +29 -0
  209. package/tasks/triage-scope.yml +31 -0
  210. package/tasks/triage-smoketest.yml +33 -0
  211. package/tasks/triage-subscribe.yml +36 -0
  212. package/tasks/triage-summary.yml +29 -0
  213. package/tasks/triage-welcome.yml +32 -0
  214. package/tasks/ts.yml +328 -0
  215. package/tasks/vbrief.yml +206 -0
  216. package/tasks/verify.yml +292 -0
  217. package/templates/agents-entry.md +1 -1
@@ -0,0 +1,778 @@
1
+ """
2
+ _vbrief_safety.py -- Safety helpers for `task migrate:vbrief` (#497, #506 D7).
3
+
4
+ # --- safety (Agent C, #497) ---
5
+
6
+ Implements the four safety affordances for the destructive-default migrator:
7
+
8
+ 1. Always-on `.premigrate.*` backups of every pre-cutover input before any
9
+ destructive write (#497-1).
10
+ 2. `--dry-run` preview that produces the migration plan without touching the
11
+ filesystem (#497-2). Implemented via a `dry_run` flag threaded through the
12
+ migration entry point; this module contributes the guard helpers.
13
+ 3. Dirty-tree guard: refuses to run on a non-clean `git status --porcelain`
14
+ unless the caller passes `--force` (#497-3).
15
+ 4. `--rollback` path: restores from `.premigrate.*` backups and removes the
16
+ scope vBRIEFs / migration artefacts that a prior run created (#497-4),
17
+ using a manifest written by the migrator at the end of a successful run.
18
+
19
+ Coordinates with #498 (validation failure keeps backups + writes partial
20
+ output to `vbrief.invalid/`) -- that scope is owned by Agent D; nothing in
21
+ this module should ever delete a `.premigrate.*` file outside of the
22
+ `rollback()` path.
23
+
24
+ Source of truth for the decisions above is tracking issue #506.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ import json
31
+ import shutil
32
+ import subprocess
33
+ from collections.abc import Callable, Iterable
34
+ from dataclasses import asdict, dataclass, field
35
+ from datetime import UTC, datetime
36
+ from pathlib import Path
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Filesystem constants
40
+ # ---------------------------------------------------------------------------
41
+
42
+ PREMIGRATE_SUFFIX = ".premigrate"
43
+ """Infix written between the filename stem and its extension for backups.
44
+
45
+ Example: ``SPECIFICATION.md`` -> ``SPECIFICATION.premigrate.md``;
46
+ ``specification.vbrief.json`` -> ``specification.premigrate.vbrief.json``.
47
+ """
48
+
49
+ SAFETY_MANIFEST_NAME = "safety-manifest.json"
50
+ """Manifest filename under ``vbrief/migration/`` that records per-run state."""
51
+
52
+ MIGRATION_DIR = "migration"
53
+ """Subdirectory of ``vbrief/`` where migration-report artefacts live (#506)."""
54
+
55
+ LEGACY_DIR = "legacy"
56
+ """Subdirectory of ``vbrief/`` where oversize legacy captures spill (#505)."""
57
+
58
+ # The four project-root markdown inputs and one JSON input the migrator
59
+ # consumes. PRD.md is optional -- only backed up when it exists. PRD.md is
60
+ # intentionally included so that operators who ran `task prd:render` before
61
+ # migrating can still recover its pre-cutover contents.
62
+ _ROOT_MD_INPUTS: tuple[str, ...] = (
63
+ "SPECIFICATION.md",
64
+ "PROJECT.md",
65
+ "ROADMAP.md",
66
+ "PRD.md",
67
+ )
68
+
69
+ _VBRIEF_JSON_INPUTS: tuple[str, ...] = (
70
+ "specification.vbrief.json",
71
+ # #571 / #567 Greptile P1: the migrator force-bumps
72
+ # ``plan.vbrief.json`` to v0.6 when present, so the pre-bump bytes
73
+ # MUST be backed up to its ``.premigrate.*`` sibling for rollback
74
+ # to restore them. Without this entry the bump was not reversible
75
+ # and ``migrate -> rollback`` left a non-empty ``git status
76
+ # --porcelain`` on any project that carried a v0.5 plan.vbrief.json.
77
+ "plan.vbrief.json",
78
+ )
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Data classes
83
+ # ---------------------------------------------------------------------------
84
+
85
+
86
+ @dataclass
87
+ class BackupRecord:
88
+ """Single backup entry recorded in the safety manifest."""
89
+
90
+ source: str
91
+ """Project-root-relative path of the pre-cutover input."""
92
+
93
+ backup: str
94
+ """Project-root-relative path of the ``.premigrate.*`` copy."""
95
+
96
+ source_sha256: str
97
+ """Hex digest of the pre-cutover content at backup time."""
98
+
99
+ size_bytes: int
100
+ """Byte count of the pre-cutover content at backup time."""
101
+
102
+
103
+ @dataclass
104
+ class FileModification:
105
+ """In-place file-modification recorded in the safety manifest (#567).
106
+
107
+ Tracks every non-backup forward-pass edit the migrator performs on a
108
+ pre-existing project file (currently: ``.gitignore`` append) so the
109
+ rollback path can reverse it symmetrically to
110
+ :attr:`SafetyManifest.post_migration_stub_hashes` for redirect
111
+ stubs.
112
+
113
+ Attributes
114
+ ----------
115
+ path
116
+ Project-root-relative path of the modified file.
117
+ operation
118
+ ``"append"`` when the migrator added content to an existing
119
+ file, or ``"create"`` when the migrator created the file from
120
+ scratch (pre-migration state was "absent"). Additional
121
+ operations may be introduced as the migrator grows; rollback
122
+ refuses when it sees an operation it does not recognise.
123
+ pre_hash
124
+ sha256 of the file BEFORE the modification. Empty string when
125
+ the file did not exist pre-migration (operation == "create").
126
+ post_hash
127
+ sha256 of the file AFTER the modification. Used by rollback to
128
+ detect whether the operator has edited the file since migration;
129
+ rollback refuses (same pattern as
130
+ :attr:`SafetyManifest.post_migration_stub_hashes`) when the
131
+ current on-disk hash matches neither ``pre_hash`` nor
132
+ ``post_hash``.
133
+ appended_content
134
+ Exact bytes the migrator appended (operation == "append") or
135
+ the full file content (operation == "create"). On rollback the
136
+ append case strips this suffix from the current file; the
137
+ create case deletes the file entirely.
138
+ """
139
+
140
+ path: str
141
+ operation: str
142
+ pre_hash: str
143
+ post_hash: str
144
+ appended_content: str
145
+
146
+
147
+ @dataclass
148
+ class RenameRecord:
149
+ """Single post-migration rename recorded in the safety manifest (#528).
150
+
151
+ When a ``deft-directive-*`` skill renames a file the migrator originally
152
+ created (e.g. Phase 6c of ``deft-directive-sync`` renames
153
+ ``LEGACY-REPORT.md`` -> ``LEGACY-REPORT.reviewed.md``), the skill
154
+ appends one of these records to :attr:`SafetyManifest.renames` so
155
+ rollback can resolve the current on-disk name before attempting
156
+ removal. Without this, rollback would target the original name,
157
+ silently miss the renamed file, and leave the artefact + its parent
158
+ directory orphaned on disk (issue #528).
159
+ """
160
+
161
+ original: str
162
+ """Project-root-relative path of the file when the migrator created it."""
163
+
164
+ current: str
165
+ """Project-root-relative path of the file on disk RIGHT NOW."""
166
+
167
+ renamed_by: str
168
+ """Human-readable name of the skill/phase that performed the rename."""
169
+
170
+ renamed_at: str
171
+ """UTC ISO-8601 timestamp (seconds precision) when the rename was recorded."""
172
+
173
+
174
+ @dataclass
175
+ class SafetyManifest:
176
+ """State recorded at the end of a successful migration for rollback."""
177
+
178
+ version: str = "1"
179
+ migration_timestamp: str = ""
180
+ backups: list[BackupRecord] = field(default_factory=list)
181
+ created_files: list[str] = field(default_factory=list)
182
+ """Project-root-relative paths of files the migrator wrote."""
183
+
184
+ created_dirs: list[str] = field(default_factory=list)
185
+ """Project-root-relative paths of directories the migrator created.
186
+
187
+ Only includes directories that did NOT exist before the migration so
188
+ rollback can remove them without clobbering pre-existing structure.
189
+ """
190
+
191
+ post_migration_stub_hashes: dict[str, str] = field(default_factory=dict)
192
+ """``source -> sha256`` of redirect stubs at migration time.
193
+
194
+ On rollback, any diff between this recorded hash and the on-disk content
195
+ means the operator has edited the stub since migration -- we refuse to
196
+ restore (and lose their changes) unless ``--force`` is passed.
197
+ """
198
+
199
+ renames: list[RenameRecord] = field(default_factory=list)
200
+ """Post-migration renames recorded by downstream skills (#528).
201
+
202
+ Rollback consults this to resolve the current on-disk name of any
203
+ tracked file before attempting removal. The migrator never writes
204
+ entries here itself -- entries are appended by ``deft-directive-sync``
205
+ Phase 6c and any future skill that renames migrator-created files.
206
+ """
207
+
208
+ file_modifications: list[FileModification] = field(default_factory=list)
209
+ """In-place edits the migrator performed on pre-existing files (#567).
210
+
211
+ Currently limited to the ``.gitignore`` append, but the shape is
212
+ deliberately generic so future migrator features (e.g. README
213
+ patches, Taskfile ``includes:`` injection) can record here too.
214
+ Rollback iterates this list and either strips the appended content
215
+ (``operation == "append"``) or deletes the file entirely
216
+ (``operation == "create"``) if the current on-disk hash matches the
217
+ recorded ``post_hash``. When the hash matches neither ``pre_hash``
218
+ nor ``post_hash`` the operator has edited the file since migration
219
+ and rollback refuses -- same pattern as
220
+ :attr:`post_migration_stub_hashes` for SPECIFICATION.md /
221
+ PROJECT.md redirect stubs.
222
+ """
223
+
224
+ def to_json(self) -> str:
225
+ payload = {
226
+ "version": self.version,
227
+ "migration_timestamp": self.migration_timestamp,
228
+ "backups": [asdict(b) for b in self.backups],
229
+ "created_files": list(self.created_files),
230
+ "created_dirs": list(self.created_dirs),
231
+ "post_migration_stub_hashes": dict(self.post_migration_stub_hashes),
232
+ "renames": [asdict(r) for r in self.renames],
233
+ "file_modifications": [asdict(m) for m in self.file_modifications],
234
+ }
235
+ return json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
236
+
237
+ @classmethod
238
+ def from_json(cls, raw: str) -> SafetyManifest:
239
+ data = json.loads(raw)
240
+ backups = [BackupRecord(**b) for b in data.get("backups", [])]
241
+ renames = [RenameRecord(**r) for r in data.get("renames", [])]
242
+ # Backward compatible: older manifests have no file_modifications
243
+ # key at all (pre-#567); parse to an empty list so rollback still
244
+ # runs for tree-states produced by earlier migrator versions.
245
+ file_mods = [
246
+ FileModification(**m)
247
+ for m in data.get("file_modifications", [])
248
+ ]
249
+ return cls(
250
+ version=str(data.get("version", "1")),
251
+ migration_timestamp=str(data.get("migration_timestamp", "")),
252
+ backups=backups,
253
+ created_files=list(data.get("created_files", [])),
254
+ created_dirs=list(data.get("created_dirs", [])),
255
+ post_migration_stub_hashes=dict(
256
+ data.get("post_migration_stub_hashes", {})
257
+ ),
258
+ renames=renames,
259
+ file_modifications=file_mods,
260
+ )
261
+
262
+ def current_path_for(self, original: str) -> str:
263
+ """Return the current on-disk path for a migrator-created ``original``.
264
+
265
+ Consults :attr:`renames` and follows genuine A -> B -> C chains:
266
+ each iteration looks up the *current* resolved path against the
267
+ ``original`` field of every :class:`RenameRecord`. Within a single
268
+ hop, the most recent rename (last in list) wins when multiple
269
+ records target the same original. Terminates on a fixed-point or
270
+ when the bounded iteration count is exceeded (defensive guard
271
+ against pathological loops).
272
+
273
+ Also returns ``original`` when no record matches (#528; Greptile
274
+ #561 P2 clarified the chain contract).
275
+ """
276
+ resolved = original
277
+ # A chain cannot be longer than the number of records in practice;
278
+ # bound the loop to ``len(renames) + 1`` so a hypothetical cycle
279
+ # aborts rather than spinning forever.
280
+ for _ in range(len(self.renames) + 1):
281
+ # Within one hop, scan every record that matches the current
282
+ # ``resolved`` name; the last matching record wins so two
283
+ # skills that both rename the same original land on the most
284
+ # recent destination (same-original semantics). Chain hops
285
+ # advance by looping again against the new ``resolved``.
286
+ target = resolved
287
+ for record in self.renames:
288
+ if record.original == resolved:
289
+ target = record.current
290
+ if target == resolved:
291
+ break
292
+ resolved = target
293
+ return resolved
294
+
295
+
296
+ # ---------------------------------------------------------------------------
297
+ # Backup planning / writing
298
+ # ---------------------------------------------------------------------------
299
+
300
+
301
+ def premigrate_sibling(path: Path) -> Path:
302
+ """Return the ``.premigrate.*`` sibling path for ``path``.
303
+
304
+ Preserves the full suffix chain -- `specification.vbrief.json` becomes
305
+ `specification.premigrate.vbrief.json`, and `SPECIFICATION.md` becomes
306
+ `SPECIFICATION.premigrate.md`. Files with no suffix get the sentinel
307
+ appended unchanged: `README` -> `README.premigrate`.
308
+ """
309
+ name = path.name
310
+ if "." in name:
311
+ stem, rest = name.split(".", 1)
312
+ return path.with_name(f"{stem}{PREMIGRATE_SUFFIX}.{rest}")
313
+ return path.with_name(f"{name}{PREMIGRATE_SUFFIX}")
314
+
315
+
316
+ # Default deprecation redirect sentinel (mirrors migrate_vbrief.DEPRECATION_SENTINEL).
317
+ # Kept here to avoid an import cycle with migrate_vbrief. A caller may override via
318
+ # plan_backups(..., deprecation_sentinel=...) if the project-root sentinel ever changes.
319
+ _DEPRECATION_SENTINEL_DEFAULT = "<!-- deft:deprecated-redirect -->"
320
+
321
+
322
+ def _is_deprecation_stub(path: Path, sentinel: str) -> bool:
323
+ """Return True iff ``path`` already contains the deprecation redirect sentinel.
324
+
325
+ Protects re-run recovery (Greptile #509 P1): if the operator re-invokes
326
+ ``task migrate:vbrief`` on an already-migrated project, the root-level
327
+ ``SPECIFICATION.md`` / ``PROJECT.md`` are redirect stubs rather than
328
+ originals. Backing them up would overwrite the real ``.premigrate.*``
329
+ copies from the first run with stub bytes, destroying ``--rollback``
330
+ recovery. Files we cannot read (binary, permission-denied, missing
331
+ mid-call) are treated as non-stubs so we do not silently skip backups
332
+ that should have happened.
333
+ """
334
+ try:
335
+ head = path.read_text(encoding="utf-8", errors="replace")[:4096]
336
+ except OSError:
337
+ return False
338
+ return sentinel in head
339
+
340
+
341
+ def plan_backups(
342
+ project_root: Path,
343
+ *,
344
+ deprecation_sentinel: str = _DEPRECATION_SENTINEL_DEFAULT,
345
+ ) -> list[tuple[Path, Path]]:
346
+ """Return the list of ``(source, backup)`` pairs the migrator will write.
347
+
348
+ Only includes inputs that actually exist on disk so the caller can log and
349
+ emit one BACKUP line per real file.
350
+
351
+ Sources that already carry the deprecation redirect sentinel are skipped
352
+ (re-run protection -- see ``_is_deprecation_stub`` docstring).
353
+ """
354
+ pairs: list[tuple[Path, Path]] = []
355
+ for name in _ROOT_MD_INPUTS:
356
+ src = project_root / name
357
+ if src.is_file() and not _is_deprecation_stub(src, deprecation_sentinel):
358
+ pairs.append((src, premigrate_sibling(src)))
359
+ vbrief_dir = project_root / "vbrief"
360
+ for name in _VBRIEF_JSON_INPUTS:
361
+ src = vbrief_dir / name
362
+ if src.is_file() and not _is_deprecation_stub(src, deprecation_sentinel):
363
+ pairs.append((src, premigrate_sibling(src)))
364
+ return pairs
365
+
366
+
367
+ def write_backups(
368
+ project_root: Path,
369
+ pairs: Iterable[tuple[Path, Path]],
370
+ *,
371
+ dry_run: bool,
372
+ ) -> tuple[list[BackupRecord], list[str]]:
373
+ """Copy each ``(source, backup)`` pair and return manifest records + log.
374
+
375
+ Logs one ``BACKUP <src> -> <dst> (<N> bytes)`` action per pair regardless
376
+ of ``dry_run`` so the operator can see what would happen. In dry-run
377
+ mode no bytes are written.
378
+ """
379
+ records: list[BackupRecord] = []
380
+ actions: list[str] = []
381
+ for src, dst in pairs:
382
+ raw = src.read_bytes()
383
+ digest = hashlib.sha256(raw).hexdigest()
384
+ size = len(raw)
385
+ rel_src = _rel(project_root, src)
386
+ rel_dst = _rel(project_root, dst)
387
+ if not dry_run:
388
+ dst.parent.mkdir(parents=True, exist_ok=True)
389
+ shutil.copy2(src, dst)
390
+ records.append(
391
+ BackupRecord(
392
+ source=rel_src,
393
+ backup=rel_dst,
394
+ source_sha256=digest,
395
+ size_bytes=size,
396
+ )
397
+ )
398
+ tag = "DRYRUN BACKUP" if dry_run else "BACKUP"
399
+ actions.append(f"{tag} {rel_src} -> {rel_dst} ({size} bytes)")
400
+ return records, actions
401
+
402
+
403
+ # ---------------------------------------------------------------------------
404
+ # Dirty-tree guard
405
+ # ---------------------------------------------------------------------------
406
+
407
+
408
+ def is_tree_dirty(project_root: Path) -> bool:
409
+ """Return True iff ``git status --porcelain`` reports uncommitted changes.
410
+
411
+ A project root that is NOT a git checkout -- or one where ``git`` is not
412
+ on PATH -- is treated as clean so tests using plain temp directories and
413
+ non-git consumers (e.g. tarball deployments) can migrate without a
414
+ special override. This matches #497 acceptance criteria phrasing of
415
+ "working tree is not clean".
416
+ """
417
+ try:
418
+ result = subprocess.run(
419
+ ["git", "status", "--porcelain"],
420
+ cwd=str(project_root),
421
+ capture_output=True,
422
+ text=True,
423
+ encoding="utf-8",
424
+ timeout=30,
425
+ check=False,
426
+ )
427
+ except (FileNotFoundError, subprocess.TimeoutExpired):
428
+ return False
429
+ if result.returncode != 0:
430
+ # Not a git repo, permission denied, etc. -- treat as clean rather
431
+ # than blocking migration, to avoid false-positive guard trips.
432
+ return False
433
+ return bool(result.stdout.strip())
434
+
435
+
436
+ def dirty_tree_refusal_message() -> str:
437
+ """Return the canonical refusal message used when the guard trips.
438
+
439
+ Centralised so the migrator CLI and tests agree on exact wording (Greptile
440
+ noise reduction).
441
+ """
442
+ return (
443
+ "ERROR: Working tree is not clean. Migration is destructive; commit "
444
+ "or stash your changes first, then re-run.\n"
445
+ " Bypass with: task migrate:vbrief -- --force (not recommended)"
446
+ )
447
+
448
+
449
+ # ---------------------------------------------------------------------------
450
+ # Manifest IO
451
+ # ---------------------------------------------------------------------------
452
+
453
+
454
+ def manifest_path(project_root: Path) -> Path:
455
+ return project_root / "vbrief" / MIGRATION_DIR / SAFETY_MANIFEST_NAME
456
+
457
+
458
+ def write_safety_manifest(
459
+ project_root: Path,
460
+ manifest: SafetyManifest,
461
+ *,
462
+ dry_run: bool,
463
+ ) -> str:
464
+ """Write the safety manifest under ``vbrief/migration/`` and return a log line."""
465
+ target = manifest_path(project_root)
466
+ rel = _rel(project_root, target)
467
+ if dry_run:
468
+ return f"DRYRUN WRITE {rel} (safety manifest)"
469
+ target.parent.mkdir(parents=True, exist_ok=True)
470
+ target.write_text(manifest.to_json(), encoding="utf-8")
471
+ return f"WRITE {rel} (safety manifest, {len(manifest.backups)} backup(s))"
472
+
473
+
474
+ def load_safety_manifest(project_root: Path) -> SafetyManifest | None:
475
+ path = manifest_path(project_root)
476
+ if not path.is_file():
477
+ return None
478
+ try:
479
+ return SafetyManifest.from_json(path.read_text(encoding="utf-8"))
480
+ except (json.JSONDecodeError, TypeError, ValueError):
481
+ return None
482
+
483
+
484
+ def sha256_of(path: Path) -> str:
485
+ """Return hex sha256 of ``path`` (empty string if path does not exist)."""
486
+ if not path.is_file():
487
+ return ""
488
+ return hashlib.sha256(path.read_bytes()).hexdigest()
489
+
490
+
491
+ # ---------------------------------------------------------------------------
492
+ # Rollback
493
+ # ---------------------------------------------------------------------------
494
+
495
+
496
+ def _default_confirm(prompt: str) -> bool:
497
+ """Default interactive confirmation prompt used by ``rollback``."""
498
+ try:
499
+ reply = input(f"{prompt} [yes/NO]: ")
500
+ except EOFError:
501
+ return False
502
+ return reply.strip().lower() in {"yes", "y"}
503
+
504
+
505
+ def rollback(
506
+ project_root: Path,
507
+ *,
508
+ force: bool = False,
509
+ confirm_fn: Callable[[str], bool] | None = None,
510
+ ) -> tuple[bool, list[str]]:
511
+ """Restore a project to its pre-migration state.
512
+
513
+ Returns ``(ok, messages)`` where ``messages`` is a human-readable action
514
+ log. Fails fast if:
515
+
516
+ - No safety manifest exists (migration never ran, or rollback already
517
+ happened).
518
+ - A redirect stub has been edited since migration and ``force`` is False.
519
+ - The user declines the confirmation prompt (``--force`` skips this).
520
+
521
+ On success, restores every ``.premigrate.*`` backup, removes the scope
522
+ vBRIEFs / migration / legacy artefacts the migrator created, removes
523
+ directories the migrator created (only if they are empty after cleanup),
524
+ and deletes the manifest and the backup files themselves.
525
+ """
526
+ actions: list[str] = []
527
+ manifest = load_safety_manifest(project_root)
528
+ if manifest is None:
529
+ return False, [
530
+ "ERROR: No safety manifest found. Either migration has not run, "
531
+ "or rollback has already completed. Expected "
532
+ f"{_rel(project_root, manifest_path(project_root))}."
533
+ ]
534
+
535
+ # 1. Detect edited stubs unless force.
536
+ edited_stubs: list[tuple[str, str, str]] = []
537
+ for rel, expected_hash in manifest.post_migration_stub_hashes.items():
538
+ current = sha256_of(project_root / rel)
539
+ if current and current != expected_hash:
540
+ edited_stubs.append((rel, expected_hash, current))
541
+ if edited_stubs and not force:
542
+ lines = ["ERROR: Redirect stubs have been edited since migration:"]
543
+ for rel, expected, current in edited_stubs:
544
+ lines.append(
545
+ f" - {rel} (expected sha256 {expected[:12]}..., got {current[:12]}...)"
546
+ )
547
+ lines.append(
548
+ "Rollback would overwrite your edits. Re-run with --force to "
549
+ "proceed anyway, or commit the stubs before rolling back."
550
+ )
551
+ return False, lines
552
+
553
+ # 1b. Detect edited in-place file modifications unless force (#567).
554
+ # Mirrors the stub-hash guard for the ``.gitignore`` append and any
555
+ # future non-backup in-place edit the migrator records. A current
556
+ # hash that matches neither ``pre_hash`` (already reverted -- safe to
557
+ # skip) nor ``post_hash`` (untouched since migration -- safe to
558
+ # reverse) means the operator edited the file post-migration; we
559
+ # refuse to clobber those edits without ``--force``.
560
+ edited_modifications: list[tuple[str, str, str, str]] = []
561
+ for mod in manifest.file_modifications:
562
+ current = sha256_of(project_root / mod.path)
563
+ # operation == "create" + file absent post-rollback is fine; the
564
+ # guard only fires when the file exists but the hash doesn't
565
+ # match either snapshot.
566
+ if not current:
567
+ continue
568
+ if current in {mod.pre_hash, mod.post_hash}:
569
+ continue
570
+ edited_modifications.append(
571
+ (mod.path, mod.pre_hash, mod.post_hash, current)
572
+ )
573
+ if edited_modifications and not force:
574
+ lines = [
575
+ "ERROR: Migrator-modified file(s) have been edited since migration:"
576
+ ]
577
+ for rel, pre, post, current in edited_modifications:
578
+ lines.append(
579
+ f" - {rel} (expected sha256 "
580
+ f"{post[:12]}... or {pre[:12]}..., got "
581
+ f"{current[:12]}...)"
582
+ )
583
+ lines.append(
584
+ "Rollback would overwrite your edits. Re-run with --force to "
585
+ "proceed anyway, or commit the file(s) before rolling back."
586
+ )
587
+ return False, lines
588
+
589
+ # 2. Confirmation prompt.
590
+ if not force:
591
+ prompt_fn = confirm_fn or _default_confirm
592
+ summary = (
593
+ f"Rollback will restore {len(manifest.backups)} backup(s) and "
594
+ f"remove {len(manifest.created_files)} migrator-created file(s). "
595
+ f"Proceed?"
596
+ )
597
+ if not prompt_fn(summary):
598
+ return False, ["Rollback aborted by operator."]
599
+
600
+ # 3. Pre-flight: make sure every recorded backup file is still on disk
601
+ # BEFORE we start restoring. If any is missing we refuse the rollback
602
+ # entirely rather than do a partial restore that would leave some sources
603
+ # as deprecation stubs while also deleting the manifest (which would make
604
+ # a retry impossible). Greptile P1 on #497: the prior implementation
605
+ # appended a warning and proceeded to (True, ...), printing a misleading
606
+ # "Rollback completed successfully" while half the tree was still stubs.
607
+ missing_backups = [
608
+ record.backup
609
+ for record in manifest.backups
610
+ if not (project_root / record.backup).is_file()
611
+ ]
612
+ if missing_backups:
613
+ lines = [
614
+ "ERROR: Backup file(s) missing -- cannot restore all sources:",
615
+ *[f" - {p}" for p in missing_backups],
616
+ (
617
+ "Manifest preserved for investigation. Resolve the missing "
618
+ ".premigrate.* file(s) (or restore from VCS) and retry "
619
+ "`task migrate:vbrief -- --rollback`."
620
+ ),
621
+ ]
622
+ return False, actions + lines
623
+
624
+ for record in manifest.backups:
625
+ backup_path = project_root / record.backup
626
+ source_path = project_root / record.source
627
+ source_path.parent.mkdir(parents=True, exist_ok=True)
628
+ shutil.copy2(backup_path, source_path)
629
+ actions.append(
630
+ f"RESTORE {record.source} <- {record.backup} ({record.size_bytes} bytes)"
631
+ )
632
+
633
+ # 4. Remove migrator-created files ONLY -- scoped strictly to
634
+ # manifest.created_files so rollback of this wave's run never deletes
635
+ # artefacts written by sibling waves that share `vbrief/migration/` or
636
+ # `vbrief/legacy/` (Agent D #498 writes validation-failure output there;
637
+ # Agent G #505 writes oversize legacy sections there). Greptile P2 on
638
+ # #497. Sort deepest-first so directory-removal in step 5 has a chance
639
+ # at emptying parents cleanly.
640
+ for rel in sorted(manifest.created_files, key=lambda p: -p.count("/")):
641
+ # #528: downstream skills (e.g. deft-directive-sync Phase 6c) may
642
+ # rename migrator-created files. Resolve the current on-disk name
643
+ # via manifest.renames before attempting removal so renamed files
644
+ # do not get orphaned with their parent directory.
645
+ current_rel = manifest.current_path_for(rel)
646
+ path = project_root / current_rel
647
+ if path.is_file():
648
+ path.unlink()
649
+ if current_rel != rel:
650
+ actions.append(f"REMOVE {current_rel} (renamed from {rel})")
651
+ else:
652
+ actions.append(f"REMOVE {rel}")
653
+ else:
654
+ if current_rel != rel:
655
+ actions.append(
656
+ f"SKIP {current_rel} (already absent; renamed from {rel})"
657
+ )
658
+ else:
659
+ actions.append(f"SKIP {rel} (already absent)")
660
+
661
+ # 5. Remove migrator-created directories (only if now-empty) -- also
662
+ # sorted deepest-first.
663
+ for rel in sorted(manifest.created_dirs, key=lambda p: -p.count("/")):
664
+ path = project_root / rel
665
+ if path.is_dir():
666
+ try:
667
+ path.rmdir()
668
+ actions.append(f"RMDIR {rel}")
669
+ except OSError:
670
+ actions.append(f"SKIP rmdir {rel} (not empty)")
671
+
672
+ # 5b. Reverse each recorded file_modification (#567). Runs BEFORE
673
+ # removing backup files so the log order matches the operator's
674
+ # mental model of "undo everything the forward pass did, then
675
+ # clean up the .premigrate.* siblings".
676
+ for mod in manifest.file_modifications:
677
+ target = project_root / mod.path
678
+ current = sha256_of(target)
679
+ if mod.operation == "create":
680
+ # Pre-migration state was "file absent". If the file is
681
+ # already gone, rollback is a no-op. Otherwise delete it --
682
+ # the force-path has already been gated on the hash guard
683
+ # above so we only reach here when the file is either at
684
+ # post_hash (created by us) or force is set.
685
+ if current and target.is_file():
686
+ target.unlink()
687
+ actions.append(f"REMOVE {mod.path} (created by migrator)")
688
+ else:
689
+ actions.append(
690
+ f"SKIP {mod.path} (already absent)"
691
+ )
692
+ continue
693
+ if mod.operation == "append":
694
+ if not current:
695
+ # File deleted since migration -- nothing to reverse.
696
+ actions.append(
697
+ f"SKIP {mod.path} (file no longer exists; "
698
+ f"nothing to strip)"
699
+ )
700
+ continue
701
+ if current == mod.pre_hash:
702
+ # Already reverted (operator manually reset the file).
703
+ actions.append(
704
+ f"SKIP {mod.path} (already at pre-migration hash)"
705
+ )
706
+ continue
707
+ try:
708
+ body = target.read_text(encoding="utf-8")
709
+ except OSError:
710
+ actions.append(
711
+ f"SKIP {mod.path} (unreadable; cannot strip append)"
712
+ )
713
+ continue
714
+ if body.endswith(mod.appended_content):
715
+ stripped = body[: -len(mod.appended_content)]
716
+ target.write_text(stripped, encoding="utf-8")
717
+ actions.append(
718
+ f"REVERT {mod.path} (stripped "
719
+ f"{len(mod.appended_content)} appended byte(s))"
720
+ )
721
+ else:
722
+ # Post-hash matched the snapshot but suffix no longer
723
+ # matches verbatim (rare: CRLF normalization after
724
+ # commit, etc.). Surface a clear message rather than
725
+ # silently leaving junk behind.
726
+ actions.append(
727
+ f"SKIP {mod.path} (content shape drifted; "
728
+ f"cannot strip append cleanly -- restore manually)"
729
+ )
730
+ continue
731
+ # Unknown operation -- be conservative and skip rather than
732
+ # mutating the file blindly.
733
+ actions.append(
734
+ f"SKIP {mod.path} (unknown operation {mod.operation!r})"
735
+ )
736
+
737
+ # 6. Remove the backup files themselves so the tree ends clean.
738
+ for record in manifest.backups:
739
+ backup_path = project_root / record.backup
740
+ if backup_path.is_file():
741
+ backup_path.unlink()
742
+ actions.append(f"REMOVE {record.backup}")
743
+
744
+ # 7. Finally, remove the manifest and its parent directory if empty.
745
+ m_path = manifest_path(project_root)
746
+ if m_path.is_file():
747
+ m_path.unlink()
748
+ actions.append(f"REMOVE {_rel(project_root, m_path)}")
749
+ for parent in (m_path.parent, m_path.parent.parent):
750
+ if parent.is_dir():
751
+ try:
752
+ parent.rmdir()
753
+ actions.append(f"RMDIR {_rel(project_root, parent)}")
754
+ except OSError:
755
+ # Non-empty -- leave it. Common when vbrief/ has other
756
+ # lifecycle folders the operator kept around.
757
+ pass
758
+
759
+ actions.append("Rollback completed successfully.")
760
+ return True, actions
761
+
762
+
763
+ # ---------------------------------------------------------------------------
764
+ # Helpers
765
+ # ---------------------------------------------------------------------------
766
+
767
+
768
+ def _rel(project_root: Path, target: Path) -> str:
769
+ """Project-root-relative, forward-slash path for logs and manifest entries."""
770
+ try:
771
+ return target.relative_to(project_root).as_posix()
772
+ except ValueError:
773
+ return target.as_posix()
774
+
775
+
776
+ def now_utc_iso() -> str:
777
+ """UTC timestamp in ISO-8601 seconds precision (matches vBRIEF `created`)."""
778
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")