@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,772 @@
1
+ #!/usr/bin/env python3
2
+ """scope_undo.py -- ``task scope:undo`` driver (#1134 / D15 of #1119).
3
+
4
+ Reverses a single scope-lifecycle audit entry referenced by ``decision_id``
5
+ or every entry tagged with a shared ``batch-id``. Mirrors the
6
+ ``scripts/scope_demote.py`` shape from D1 (#1121) and consumes the
7
+ ``scripts/scope_audit_log.py`` append-only audit-log surface.
8
+
9
+ Two operating modes
10
+ -------------------
11
+
12
+ 1. Single-entry undo::
13
+
14
+ scope_undo.py <decision_id> [--dry-run] [--project-root PATH]
15
+ scope_undo.py --decision-id <decision_id> [--dry-run] [--project-root PATH]
16
+
17
+ The positional form is shorthand for ``--decision-id``; both forms
18
+ are mutually exclusive with ``--batch-id``.
19
+
20
+ 2. Batch undo::
21
+
22
+ scope_undo.py --batch-id <uuid> [--dry-run] [--project-root PATH]
23
+
24
+ Reverses every audit entry tagged with the given ``batch_id``. The
25
+ undo cohort itself is tagged with a fresh ``undo_batch_id`` so a
26
+ subsequent ``scope:undo --batch-id=<undo_batch_id>`` reverses the
27
+ undo cohort (re-applying the original effect).
28
+
29
+ Action vocabulary
30
+ -----------------
31
+
32
+ * ``demote`` -> re-promote: file in ``proposed/`` moves back to
33
+ ``pending/`` with ``plan.status='pending'``.
34
+ * ``cancel`` -> restore from ``cancelled/`` to the original folder
35
+ recorded on the cancel audit entry's ``cancel_meta.cancelled_from``
36
+ field (or ``cancelled_from`` at the top level for legacy shapes).
37
+ * ``restore`` -> re-cancel: file in ``proposed/`` moves back to
38
+ ``cancelled/`` with ``plan.status='cancelled'``.
39
+ * ``undo`` -> re-apply: look up the original entry referenced by the
40
+ undo's ``undo_meta.original_decision_id`` and replay the original
41
+ action's effect (so undoing an undo lands the brief where it was
42
+ immediately after the original action).
43
+ * ``complete`` / ``fail`` -- REFUSED with a clear error (exit 1). The
44
+ operator must `git revert` or hand-edit per existing conventions.
45
+ * Any other / unknown action -- REFUSED with exit 1.
46
+
47
+ Idempotency
48
+ -----------
49
+
50
+ An audit entry is "already undone" when the log contains a later
51
+ ``undo`` entry whose ``undo_meta.original_decision_id`` references it.
52
+ Re-running undo on an already-undone entry is a no-op with exit 0 and
53
+ an informational stderr line. Batch undo skips already-undone members
54
+ and continues; the overall exit remains 0 unless EVERY member is
55
+ unprocessable (terminal / unknown action).
56
+
57
+ D18 (#1136) `scope:promote --from-issue=<N>` fallback
58
+ -----------------------------------------------------
59
+
60
+ D15 deliberately uses the existing scope-lifecycle move surfaces
61
+ (file `.replace()` + JSON write) rather than dispatching to
62
+ ``task scope:promote`` or ``task scope:restore`` -- audit-log
63
+ reversibility is a pure file-system / JSON edit and does not need
64
+ the higher-level lifecycle verbs. TODO(#1136): when the
65
+ ``scope:promote --from-issue`` form lands, consider routing the
66
+ ``demote -> re-promote`` branch through it for consistency with
67
+ the cache-side reset verb pattern (umbrella section "Layer 5 --
68
+ Reversibility everywhere", sibling to ``scripts/triage_actions.py::reset``).
69
+
70
+ Exit codes
71
+ ----------
72
+
73
+ * 0 -- undo succeeded, or no-op (idempotent re-run), or dry-run preview.
74
+ * 1 -- target entry not found / terminal action / file missing /
75
+ validation error.
76
+ * 2 -- usage error (mutex flags, missing args, undetectable project root).
77
+
78
+ Refs: #1119 (umbrella), #1121 (D1 -- audit-log surface this consumes),
79
+ #845 (cache-side reset verb pattern this mirrors).
80
+ """
81
+
82
+ from __future__ import annotations
83
+
84
+ import argparse
85
+ import json
86
+ import sys
87
+ from datetime import UTC, datetime
88
+ from pathlib import Path
89
+
90
+ # Make sibling helpers importable both when run as ``__main__`` and when
91
+ # imported by tests that preload sys.path.
92
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
93
+
94
+ from _project_context import resolve_project_root # noqa: E402
95
+ from _stdio_utf8 import reconfigure_stdio # noqa: E402
96
+ from scope_audit_log import ( # noqa: E402
97
+ append as audit_append,
98
+ canonical_log_path,
99
+ new_decision_id,
100
+ read_all,
101
+ )
102
+
103
+ reconfigure_stdio()
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Action vocabulary
108
+ # ---------------------------------------------------------------------------
109
+
110
+ # Actions whose undo is supported. Each entry maps to the inverse-target
111
+ # (folder, status) pair the brief returns to.
112
+ REVERSIBLE_ACTIONS: frozenset[str] = frozenset({"demote", "cancel", "restore", "undo"})
113
+ TERMINAL_ACTIONS: frozenset[str] = frozenset({"complete", "fail"})
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Helpers
118
+ # ---------------------------------------------------------------------------
119
+
120
+
121
+ def _utc_now_iso(now: datetime | None = None) -> str:
122
+ if now is None:
123
+ now = datetime.now(UTC)
124
+ return now.strftime("%Y-%m-%dT%H:%M:%SZ")
125
+
126
+
127
+ def _resolve_project_root_strict(
128
+ cli_project_root: str | None,
129
+ ) -> tuple[Path | None, str | None]:
130
+ project_root = resolve_project_root(cli_project_root)
131
+ if project_root is None:
132
+ return None, (
133
+ "Cannot determine project root. Pass --project-root PATH, "
134
+ "set $DEFT_PROJECT_ROOT, or run from inside a directory tree "
135
+ "that contains vbrief/ or .git/ (#535)."
136
+ )
137
+ return project_root, None
138
+
139
+
140
+ def _vbrief_root(project_root: Path) -> Path:
141
+ return project_root / "vbrief"
142
+
143
+
144
+ def _abs_for_entry_path(project_root: Path, vbrief_path: str) -> Path:
145
+ """Resolve an audit entry's project-root-relative ``vbrief_path``.
146
+
147
+ Forward-slash form is the canonical write-time shape so we just
148
+ join under ``project_root`` and let Path normalise the separator.
149
+ """
150
+ return (project_root / vbrief_path).resolve()
151
+
152
+
153
+ def _is_already_undone(decision_id: str, log_entries: list[dict]) -> bool:
154
+ """Return True if any later ``undo`` entry references *decision_id*."""
155
+ for entry in log_entries:
156
+ if entry.get("action") != "undo":
157
+ continue
158
+ meta = entry.get("undo_meta")
159
+ if isinstance(meta, dict) and meta.get("original_decision_id") == decision_id:
160
+ return True
161
+ return False
162
+
163
+
164
+ def _find_by_decision_id(decision_id: str, log_entries: list[dict]) -> dict | None:
165
+ for entry in log_entries:
166
+ if entry.get("decision_id") == decision_id:
167
+ return entry
168
+ return None
169
+
170
+
171
+ def _find_by_batch_id(batch_id: str, log_entries: list[dict]) -> list[dict]:
172
+ """Return every entry whose ``demote_meta.batch_id`` (or top-level
173
+ ``batch_id`` for forward-compat) matches *batch_id*.
174
+ """
175
+ out: list[dict] = []
176
+ for entry in log_entries:
177
+ meta = entry.get("demote_meta")
178
+ bid = None
179
+ if isinstance(meta, dict):
180
+ bid = meta.get("batch_id")
181
+ if bid is None:
182
+ bid = entry.get("batch_id")
183
+ if bid == batch_id:
184
+ out.append(entry)
185
+ return out
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Inverse transitions
190
+ # ---------------------------------------------------------------------------
191
+
192
+
193
+ def _move_and_flip(
194
+ src_file: Path,
195
+ dest_folder: Path,
196
+ new_status: str,
197
+ timestamp: str,
198
+ ) -> tuple[bool, str, Path | None]:
199
+ """Move *src_file* into *dest_folder* and flip ``plan.status`` /
200
+ ``plan.updated``. Returns ``(ok, message, dest_path)``.
201
+ """
202
+ if not src_file.exists():
203
+ return False, f"File not found: {src_file}", None
204
+ try:
205
+ data = json.loads(src_file.read_text(encoding="utf-8"))
206
+ except json.JSONDecodeError as exc:
207
+ return False, f"Invalid JSON in {src_file}: {exc}", None
208
+ plan = data.get("plan") if isinstance(data, dict) else None
209
+ if not isinstance(plan, dict):
210
+ return False, f"Missing or invalid 'plan' object in {src_file}", None
211
+ plan["status"] = new_status
212
+ plan["updated"] = timestamp
213
+ src_file.write_text(
214
+ json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
215
+ )
216
+ dest_folder.mkdir(parents=True, exist_ok=True)
217
+ dest_path = dest_folder / src_file.name
218
+ src_file.replace(dest_path)
219
+ return True, "ok", dest_path
220
+
221
+
222
+ def _inverse_plan(entry: dict, log_entries: list[dict]) -> dict | None:
223
+ """Return the planned inverse-transition for *entry*.
224
+
225
+ The returned dict carries:
226
+ * ``src_relpath`` -- project-root-relative current location.
227
+ * ``dest_folder`` -- target lifecycle folder name.
228
+ * ``new_status`` -- target ``plan.status``.
229
+ * ``from_status`` -- reported on the new audit entry.
230
+ * ``to_status`` -- reported on the new audit entry.
231
+
232
+ Returns ``None`` for un-undoable / unknown actions.
233
+ """
234
+ action = entry.get("action")
235
+ if action == "demote":
236
+ return {
237
+ "src_relpath": entry.get("vbrief_path", ""),
238
+ "dest_folder": "pending",
239
+ "new_status": "pending",
240
+ "from_status": "proposed",
241
+ "to_status": "pending",
242
+ }
243
+ if action == "cancel":
244
+ meta = entry.get("cancel_meta")
245
+ cancelled_from = None
246
+ if isinstance(meta, dict):
247
+ cancelled_from = meta.get("cancelled_from")
248
+ if not cancelled_from:
249
+ cancelled_from = entry.get("cancelled_from")
250
+ if not cancelled_from:
251
+ cancelled_from = entry.get("from_status")
252
+ if not isinstance(cancelled_from, str) or not cancelled_from:
253
+ return None
254
+ # The cancelled_from value can be either a folder name
255
+ # (``proposed`` / ``pending`` / ``active``) or a plan-status
256
+ # synonym. We map plan-status synonyms to their canonical folder.
257
+ folder_map = {
258
+ "running": "active",
259
+ "blocked": "active",
260
+ "completed": "completed",
261
+ "failed": "completed",
262
+ "cancelled": "cancelled",
263
+ "proposed": "proposed",
264
+ "pending": "pending",
265
+ "active": "active",
266
+ }
267
+ dest_folder = folder_map.get(cancelled_from, cancelled_from)
268
+ status_map = {
269
+ "proposed": "proposed",
270
+ "pending": "pending",
271
+ "active": "running",
272
+ "completed": "completed",
273
+ "cancelled": "cancelled",
274
+ }
275
+ new_status = status_map.get(dest_folder, dest_folder)
276
+ return {
277
+ "src_relpath": entry.get("vbrief_path", ""),
278
+ "dest_folder": dest_folder,
279
+ "new_status": new_status,
280
+ "from_status": "cancelled",
281
+ "to_status": new_status,
282
+ }
283
+ if action == "restore":
284
+ return {
285
+ "src_relpath": entry.get("vbrief_path", ""),
286
+ "dest_folder": "cancelled",
287
+ "new_status": "cancelled",
288
+ "from_status": "proposed",
289
+ "to_status": "cancelled",
290
+ }
291
+ if action == "undo":
292
+ # Re-apply the original action's effect.
293
+ meta = entry.get("undo_meta")
294
+ if not isinstance(meta, dict):
295
+ return None
296
+ original_id = meta.get("original_decision_id")
297
+ if not isinstance(original_id, str):
298
+ return None
299
+ original = _find_by_decision_id(original_id, log_entries)
300
+ if original is None:
301
+ return None
302
+ # The brief is currently where the undo placed it (entry.to_status
303
+ # / vbrief_path), and we want it back where the original action
304
+ # left it (original.to_status / original.vbrief_path). The undo
305
+ # also rewrote vbrief_path to point at the brief's new home, so
306
+ # the brief is currently at ``entry.vbrief_path``.
307
+ original_action = original.get("action")
308
+ if original_action == "demote":
309
+ return {
310
+ "src_relpath": entry.get("vbrief_path", ""),
311
+ "dest_folder": "proposed",
312
+ "new_status": "proposed",
313
+ "from_status": "pending",
314
+ "to_status": "proposed",
315
+ }
316
+ if original_action == "cancel":
317
+ return {
318
+ "src_relpath": entry.get("vbrief_path", ""),
319
+ "dest_folder": "cancelled",
320
+ "new_status": "cancelled",
321
+ "from_status": entry.get("to_status", "proposed"),
322
+ "to_status": "cancelled",
323
+ }
324
+ if original_action == "restore":
325
+ return {
326
+ "src_relpath": entry.get("vbrief_path", ""),
327
+ "dest_folder": "proposed",
328
+ "new_status": "proposed",
329
+ "from_status": "cancelled",
330
+ "to_status": "proposed",
331
+ }
332
+ return None
333
+ return None
334
+
335
+
336
+ # ---------------------------------------------------------------------------
337
+ # Undo engine
338
+ # ---------------------------------------------------------------------------
339
+
340
+
341
+ def undo_one(
342
+ entry: dict,
343
+ project_root: Path,
344
+ *,
345
+ actor: str = "operator",
346
+ now: datetime | None = None,
347
+ log_path: Path | None = None,
348
+ dry_run: bool = False,
349
+ undo_batch_id: str | None = None,
350
+ log_entries: list[dict] | None = None,
351
+ ) -> tuple[bool, str, dict | None]:
352
+ """Reverse a single audit *entry*.
353
+
354
+ Returns ``(ok, message, audit_entry)``. ``audit_entry`` is the new
355
+ ``undo`` entry that was appended (or that would have been appended
356
+ on ``dry_run=True``); ``None`` on failure / no-op.
357
+ """
358
+ action = entry.get("action", "")
359
+ decision_id = entry.get("decision_id", "")
360
+ if action in TERMINAL_ACTIONS:
361
+ return (
362
+ False,
363
+ (
364
+ f"Refusing to undo terminal action '{action}' "
365
+ f"(decision_id={decision_id}). Use git revert or hand-edit."
366
+ ),
367
+ None,
368
+ )
369
+ if action not in REVERSIBLE_ACTIONS:
370
+ return (
371
+ False,
372
+ (
373
+ f"Refusing to undo unknown action '{action}' "
374
+ f"(decision_id={decision_id})."
375
+ ),
376
+ None,
377
+ )
378
+
379
+ if log_path is None:
380
+ log_path = canonical_log_path(project_root)
381
+ if log_entries is None:
382
+ log_entries = read_all(log_path=log_path)
383
+
384
+ if _is_already_undone(decision_id, log_entries):
385
+ return (
386
+ True,
387
+ (
388
+ f"No-op: entry {decision_id} is already undone "
389
+ f"(idempotent re-run)."
390
+ ),
391
+ None,
392
+ )
393
+
394
+ plan = _inverse_plan(entry, log_entries)
395
+ if plan is None:
396
+ return (
397
+ False,
398
+ (
399
+ f"Cannot derive inverse transition for entry {decision_id} "
400
+ f"(action='{action}'). Missing required metadata."
401
+ ),
402
+ None,
403
+ )
404
+
405
+ src_path = _abs_for_entry_path(project_root, plan["src_relpath"])
406
+ dest_folder = _vbrief_root(project_root) / plan["dest_folder"]
407
+ new_status = plan["new_status"]
408
+ timestamp = _utc_now_iso(now)
409
+
410
+ if dry_run:
411
+ try:
412
+ src_display = src_path.relative_to(project_root).as_posix()
413
+ except ValueError:
414
+ src_display = str(src_path)
415
+ msg = (
416
+ f"DRY-RUN: would undo {action} (decision_id={decision_id}) -- "
417
+ f"{src_display} -> vbrief/{plan['dest_folder']}/ "
418
+ f"(status: {new_status})"
419
+ )
420
+ # Preview the entry we WOULD write so callers can introspect.
421
+ dest_relpath = f"vbrief/{plan['dest_folder']}/{src_path.name}"
422
+ preview = _build_undo_entry(
423
+ entry=entry,
424
+ timestamp=timestamp,
425
+ actor=actor,
426
+ from_status=plan["from_status"],
427
+ to_status=plan["to_status"],
428
+ new_relpath=dest_relpath,
429
+ undo_batch_id=undo_batch_id,
430
+ )
431
+ return True, msg, preview
432
+
433
+ ok, fs_msg, dest_path = _move_and_flip(
434
+ src_path, dest_folder, new_status, timestamp
435
+ )
436
+ if not ok or dest_path is None:
437
+ return False, fs_msg, None
438
+
439
+ try:
440
+ dest_relpath = dest_path.relative_to(project_root.resolve()).as_posix()
441
+ except ValueError:
442
+ dest_relpath = dest_path.as_posix()
443
+
444
+ undo_entry = _build_undo_entry(
445
+ entry=entry,
446
+ timestamp=timestamp,
447
+ actor=actor,
448
+ from_status=plan["from_status"],
449
+ to_status=plan["to_status"],
450
+ new_relpath=dest_relpath,
451
+ undo_batch_id=undo_batch_id,
452
+ )
453
+ audit_append(undo_entry, log_path=log_path)
454
+
455
+ msg = (
456
+ f"Undid {action} (decision_id={decision_id}): {dest_path.name} -> "
457
+ f"vbrief/{plan['dest_folder']}/ (status: {new_status})"
458
+ )
459
+ return True, msg, undo_entry
460
+
461
+
462
+ def _build_undo_entry(
463
+ *,
464
+ entry: dict,
465
+ timestamp: str,
466
+ actor: str,
467
+ from_status: str,
468
+ to_status: str,
469
+ new_relpath: str,
470
+ undo_batch_id: str | None,
471
+ ) -> dict:
472
+ """Construct the new ``undo`` audit entry."""
473
+ undo_meta: dict = {
474
+ "original_decision_id": entry["decision_id"],
475
+ "original_action": entry.get("action", ""),
476
+ }
477
+ if undo_batch_id is not None:
478
+ undo_meta["undo_batch_id"] = undo_batch_id
479
+ return {
480
+ "decision_id": new_decision_id(),
481
+ "timestamp": timestamp,
482
+ "action": "undo",
483
+ "vbrief_path": new_relpath,
484
+ "from_status": from_status,
485
+ "to_status": to_status,
486
+ "actor": actor,
487
+ "undo_meta": undo_meta,
488
+ }
489
+
490
+
491
+ def undo_batch(
492
+ batch_id: str,
493
+ project_root: Path,
494
+ *,
495
+ actor: str = "operator",
496
+ now: datetime | None = None,
497
+ log_path: Path | None = None,
498
+ dry_run: bool = False,
499
+ ) -> tuple[int, list[dict], list[str], list[str]]:
500
+ """Reverse every audit entry tagged with *batch_id*.
501
+
502
+ Returns ``(undone_count, audit_entries, skipped_messages, previews)``.
503
+ ``skipped`` carries informational messages for already-undone entries
504
+ (idempotent re-runs) plus error messages for terminal-action members
505
+ and file-level failures. ``previews`` is populated only on
506
+ ``dry_run=True`` and carries the per-entry ``would-undo`` message for
507
+ each member that would have been reversed in a real run -- emitted as
508
+ a separate list (rather than folded into ``skipped``) so callers can
509
+ surface preview-vs-error states distinctly. On ``dry_run=False`` the
510
+ list is always empty.
511
+
512
+ Greptile #1219 (D15 / #1134) P1 regression guard: prior shape
513
+ returned a 3-tuple that silently dropped per-entry dry-run preview
514
+ messages; the 4-tuple shape surfaces them so
515
+ ``task scope:undo --batch-id=<uuid> --dry-run`` produces actionable
516
+ per-entry output for an operator previewing the cohort.
517
+ """
518
+ if log_path is None:
519
+ log_path = canonical_log_path(project_root)
520
+ log_entries = read_all(log_path=log_path)
521
+ members = _find_by_batch_id(batch_id, log_entries)
522
+ if not members:
523
+ return 0, [], [f"No audit entries found for batch_id={batch_id}."], []
524
+
525
+ undo_batch_id = new_decision_id() if not dry_run else f"DRY-RUN-{new_decision_id()}"
526
+ audit_entries: list[dict] = []
527
+ skipped: list[str] = []
528
+ previews: list[str] = []
529
+ undone = 0
530
+ # Sort for deterministic test output / replay.
531
+ members.sort(key=lambda e: e.get("timestamp", ""))
532
+ for member in members:
533
+ ok, msg, entry = undo_one(
534
+ member,
535
+ project_root,
536
+ actor=actor,
537
+ now=now,
538
+ log_path=log_path,
539
+ dry_run=dry_run,
540
+ undo_batch_id=undo_batch_id,
541
+ log_entries=log_entries,
542
+ )
543
+ if ok:
544
+ if entry is not None:
545
+ audit_entries.append(entry)
546
+ if dry_run:
547
+ # Surface the per-entry preview line so the caller
548
+ # can render "would-undo X -> Y" for every member.
549
+ previews.append(msg)
550
+ else:
551
+ # Re-read log_entries so idempotency check on
552
+ # subsequent members in the same batch sees the
553
+ # newly-appended undo entry.
554
+ log_entries = read_all(log_path=log_path)
555
+ undone += 1
556
+ else:
557
+ # No-op (already-undone); record as informational skip.
558
+ skipped.append(msg)
559
+ else:
560
+ skipped.append(msg)
561
+ return undone, audit_entries, skipped, previews
562
+
563
+
564
+ # ---------------------------------------------------------------------------
565
+ # CLI
566
+ # ---------------------------------------------------------------------------
567
+
568
+
569
+ def _build_parser() -> argparse.ArgumentParser:
570
+ parser = argparse.ArgumentParser(
571
+ prog="scope_undo.py",
572
+ description=(
573
+ "Reverse a scope-lifecycle audit entry by decision_id or "
574
+ "batch_id (#1134 / D15). Mirrors scope:demote shape."
575
+ ),
576
+ )
577
+ parser.add_argument(
578
+ "decision_id_positional",
579
+ nargs="?",
580
+ metavar="<decision_id>",
581
+ help=(
582
+ "Decision id of the audit entry to undo (shorthand for "
583
+ "--decision-id). Mutually exclusive with --batch-id."
584
+ ),
585
+ )
586
+ parser.add_argument(
587
+ "--decision-id",
588
+ default=None,
589
+ help="Decision id of a single audit entry to undo.",
590
+ )
591
+ parser.add_argument(
592
+ "--batch-id",
593
+ default=None,
594
+ help=(
595
+ "Reverse every audit entry tagged with this batch_id "
596
+ "(demote_meta.batch_id from scope:demote --batch)."
597
+ ),
598
+ )
599
+ parser.add_argument(
600
+ "--latest",
601
+ action="store_true",
602
+ help=(
603
+ "Reverse the most-recent reversible audit entry (demote / "
604
+ "cancel / restore / undo) that has not already been undone. "
605
+ "Consumed by the N6 / #1146 triage:smoketest contract "
606
+ "(stage 8) so the smoketest can exercise scope:undo "
607
+ "idempotency without threading a decision_id through. "
608
+ "Mutually exclusive with --decision-id, --batch-id, and "
609
+ "the positional <decision_id>."
610
+ ),
611
+ )
612
+ parser.add_argument(
613
+ "--dry-run",
614
+ action="store_true",
615
+ help="Preview the reversals without writing.",
616
+ )
617
+ parser.add_argument(
618
+ "--actor",
619
+ default="operator",
620
+ help="Actor identity recorded on the new undo audit entry.",
621
+ )
622
+ parser.add_argument(
623
+ "--project-root",
624
+ default=None,
625
+ help="Consumer project root. Overrides $DEFT_PROJECT_ROOT.",
626
+ )
627
+ return parser
628
+
629
+
630
+ def main(argv: list[str] | None = None) -> int: # noqa: PLR0911,PLR0912
631
+ # N10 (#1150): structured --help via scripts/triage_help.REGISTRY.
632
+ from triage_help import intercept_help
633
+
634
+ rc = intercept_help("scope_undo", argv)
635
+ if rc is not None:
636
+ return rc
637
+ parser = _build_parser()
638
+ try:
639
+ args = parser.parse_args(argv)
640
+ except SystemExit as exc:
641
+ return int(exc.code) if isinstance(exc.code, int) else 2
642
+
643
+ # Coalesce positional + --decision-id; reject mutex with --batch-id / --latest.
644
+ decision_id = args.decision_id or args.decision_id_positional
645
+ if decision_id and args.batch_id:
646
+ print(
647
+ "Error: --decision-id (or positional <decision_id>) is mutually "
648
+ "exclusive with --batch-id.",
649
+ file=sys.stderr,
650
+ )
651
+ return 2
652
+ if args.decision_id_positional and args.decision_id and (
653
+ args.decision_id_positional != args.decision_id
654
+ ):
655
+ print(
656
+ "Error: positional <decision_id> conflicts with --decision-id "
657
+ f"({args.decision_id_positional!r} vs {args.decision_id!r}).",
658
+ file=sys.stderr,
659
+ )
660
+ return 2
661
+ if args.latest and (decision_id or args.batch_id):
662
+ print(
663
+ "Error: --latest is mutually exclusive with --decision-id, "
664
+ "--batch-id, and the positional <decision_id>.",
665
+ file=sys.stderr,
666
+ )
667
+ return 2
668
+ if not decision_id and not args.batch_id and not args.latest:
669
+ print(
670
+ "Error: provide a <decision_id> (positional or --decision-id), "
671
+ "--batch-id, or --latest.",
672
+ file=sys.stderr,
673
+ )
674
+ return 2
675
+
676
+ project_root, error = _resolve_project_root_strict(args.project_root)
677
+ if error is not None or project_root is None:
678
+ print(f"Error: {error}", file=sys.stderr)
679
+ return 2
680
+
681
+ log_path = canonical_log_path(project_root)
682
+ if not log_path.exists():
683
+ print(
684
+ f"Error: audit log not found at {log_path}. "
685
+ "Nothing to undo.",
686
+ file=sys.stderr,
687
+ )
688
+ return 1
689
+
690
+ if args.batch_id:
691
+ undone, _entries, skipped, previews = undo_batch(
692
+ args.batch_id,
693
+ project_root,
694
+ actor=args.actor,
695
+ log_path=log_path,
696
+ dry_run=args.dry_run,
697
+ )
698
+ if undone == 0 and skipped and skipped[0].startswith("No audit entries"):
699
+ print(skipped[0], file=sys.stderr)
700
+ return 1
701
+ prefix = "DRY-RUN: " if args.dry_run else ""
702
+ print(
703
+ f"{prefix}Batch undo: {undone} reversed, {len(skipped)} skipped "
704
+ f"(batch_id={args.batch_id})."
705
+ )
706
+ # Per-entry previews (only populated under --dry-run).
707
+ for line in previews:
708
+ print(f" preview: {line}")
709
+ for line in skipped:
710
+ print(f" skipped: {line}")
711
+ return 0
712
+
713
+ # --latest: resolve to the most-recent reversible audit entry that
714
+ # hasn't already been undone. Used by N6 / #1146 triage:smoketest.
715
+ log_entries = read_all(log_path=log_path)
716
+ if args.latest:
717
+ candidate: dict | None = None
718
+ for entry in reversed(log_entries):
719
+ action = entry.get("action")
720
+ if action not in REVERSIBLE_ACTIONS:
721
+ continue
722
+ entry_id = entry.get("decision_id")
723
+ if not isinstance(entry_id, str):
724
+ continue
725
+ if _is_already_undone(entry_id, log_entries):
726
+ continue
727
+ candidate = entry
728
+ break
729
+ if candidate is None:
730
+ print(
731
+ "Error: --latest found no reversible audit entry "
732
+ "(demote / cancel / restore / undo) that has not already "
733
+ "been undone.",
734
+ file=sys.stderr,
735
+ )
736
+ return 1
737
+ decision_id = candidate.get("decision_id")
738
+ if not isinstance(decision_id, str):
739
+ print(
740
+ "Error: --latest candidate is missing a decision_id.",
741
+ file=sys.stderr,
742
+ )
743
+ return 1
744
+
745
+ # Single-entry undo.
746
+ entry = _find_by_decision_id(decision_id, log_entries)
747
+ if entry is None:
748
+ print(
749
+ f"Error: no audit entry found with decision_id={decision_id}.",
750
+ file=sys.stderr,
751
+ )
752
+ return 1
753
+ ok, msg, _new = undo_one(
754
+ entry,
755
+ project_root,
756
+ actor=args.actor,
757
+ log_path=log_path,
758
+ dry_run=args.dry_run,
759
+ log_entries=log_entries,
760
+ )
761
+ if ok:
762
+ if args.dry_run:
763
+ print(msg)
764
+ else:
765
+ print(msg)
766
+ return 0
767
+ print(f"Error: {msg}", file=sys.stderr)
768
+ return 1
769
+
770
+
771
+ if __name__ == "__main__":
772
+ sys.exit(main())