@deftai/directive-content 0.59.0 → 0.60.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 (184) hide show
  1. package/.githooks/pre-push +10 -9
  2. package/Taskfile.yml +48 -58
  3. package/UPGRADING.md +1 -1
  4. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  5. package/docs/directive-lifecycle.md +73 -0
  6. package/docs/getting-started.md +5 -1
  7. package/package.json +3 -3
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scm/github.md +20 -2
  10. package/tasks/change.yml +16 -31
  11. package/tasks/ci.yml +8 -0
  12. package/tasks/commit.yml +12 -19
  13. package/tasks/core.yml +10 -0
  14. package/tasks/engine.yml +42 -0
  15. package/tasks/framework.yml +3 -0
  16. package/tasks/install.yml +20 -19
  17. package/tasks/migrate.yml +26 -15
  18. package/tasks/project.yml +16 -0
  19. package/tasks/toolchain.yml +15 -5
  20. package/tasks/vbrief.yml +4 -3
  21. package/tasks/verify.yml +12 -14
  22. package/scripts/_agents_md.py +0 -494
  23. package/scripts/_cache_fetch.py +0 -635
  24. package/scripts/_cache_quota.py +0 -529
  25. package/scripts/_cache_refresh.py +0 -163
  26. package/scripts/_cache_validate.py +0 -209
  27. package/scripts/_content_root.py +0 -42
  28. package/scripts/_doctor_state.py +0 -277
  29. package/scripts/_event_detect.py +0 -305
  30. package/scripts/_events.py +0 -514
  31. package/scripts/_lifecycle_hygiene.py +0 -568
  32. package/scripts/_pathspec.py +0 -91
  33. package/scripts/_policy_show_cli.py +0 -266
  34. package/scripts/_precutover.py +0 -92
  35. package/scripts/_project_context.py +0 -224
  36. package/scripts/_project_definition_io.py +0 -164
  37. package/scripts/_relocate_snapshot.py +0 -209
  38. package/scripts/_relocate_states.py +0 -343
  39. package/scripts/_resolve_preflight_path.py +0 -152
  40. package/scripts/_safe_subprocess.py +0 -167
  41. package/scripts/_session_start_hook.py +0 -205
  42. package/scripts/_sor_gate_diff.py +0 -365
  43. package/scripts/_stdio_utf8.py +0 -59
  44. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  45. package/scripts/_triage_classify_cli.py +0 -122
  46. package/scripts/_triage_queue_cli.py +0 -625
  47. package/scripts/_triage_scope_cli.py +0 -343
  48. package/scripts/_triage_scope_drift_cli.py +0 -121
  49. package/scripts/_triage_scope_ignores.py +0 -286
  50. package/scripts/_triage_scope_milestone.py +0 -432
  51. package/scripts/_triage_scope_mutations.py +0 -337
  52. package/scripts/_triage_scope_renderers.py +0 -207
  53. package/scripts/_triage_smoketest_stages.py +0 -674
  54. package/scripts/_triage_subscribe_cli.py +0 -140
  55. package/scripts/_triage_welcome_cli.py +0 -421
  56. package/scripts/_vbrief_build.py +0 -239
  57. package/scripts/_vbrief_fidelity.py +0 -479
  58. package/scripts/_vbrief_legacy.py +0 -589
  59. package/scripts/_vbrief_reconciliation.py +0 -883
  60. package/scripts/_vbrief_routing.py +0 -277
  61. package/scripts/_vbrief_safety.py +0 -778
  62. package/scripts/_vbrief_sources.py +0 -312
  63. package/scripts/_vbrief_speckit.py +0 -262
  64. package/scripts/_vbrief_story_quality.py +0 -353
  65. package/scripts/_vbrief_validation.py +0 -299
  66. package/scripts/build_dist.py +0 -412
  67. package/scripts/cache.py +0 -1078
  68. package/scripts/cache_scanner.py +0 -745
  69. package/scripts/candidates_log.py +0 -432
  70. package/scripts/capacity_backfill.py +0 -680
  71. package/scripts/capacity_show.py +0 -653
  72. package/scripts/ci_local.py +0 -689
  73. package/scripts/code_structure_validate.py +0 -765
  74. package/scripts/codebase_default_extractor.py +0 -495
  75. package/scripts/codebase_map.py +0 -304
  76. package/scripts/codebase_map_fresh.py +0 -104
  77. package/scripts/codebase_projection_registry.py +0 -94
  78. package/scripts/codebase_provider.py +0 -582
  79. package/scripts/doctor.py +0 -2552
  80. package/scripts/framework_commands.py +0 -505
  81. package/scripts/gh_rest.py +0 -882
  82. package/scripts/github_auth_modes.py +0 -437
  83. package/scripts/github_body.py +0 -292
  84. package/scripts/ip_risk.py +0 -531
  85. package/scripts/issue_emit.py +0 -670
  86. package/scripts/issue_ingest.py +0 -1064
  87. package/scripts/migrate_preflight.py +0 -418
  88. package/scripts/migrate_vbrief.py +0 -2677
  89. package/scripts/monitor_pr.py +0 -401
  90. package/scripts/pack_migrate_lessons.py +0 -336
  91. package/scripts/pack_migrate_patterns.py +0 -254
  92. package/scripts/pack_migrate_rules.py +0 -350
  93. package/scripts/pack_migrate_skills.py +0 -423
  94. package/scripts/pack_migrate_strategies.py +0 -311
  95. package/scripts/pack_migrate_swarm_spec.py +0 -250
  96. package/scripts/pack_render.py +0 -434
  97. package/scripts/packs_slice.py +0 -712
  98. package/scripts/platform_capabilities.py +0 -336
  99. package/scripts/policy.py +0 -2826
  100. package/scripts/policy_set.py +0 -324
  101. package/scripts/pr_check_closing_keywords.py +0 -524
  102. package/scripts/pr_check_protected_issues.py +0 -267
  103. package/scripts/pr_merge_readiness.py +0 -1004
  104. package/scripts/pr_wait_mergeable.py +0 -669
  105. package/scripts/prd_render.py +0 -159
  106. package/scripts/preflight_architecture_sor.py +0 -974
  107. package/scripts/preflight_branch.py +0 -289
  108. package/scripts/preflight_cache.py +0 -974
  109. package/scripts/preflight_gh.py +0 -721
  110. package/scripts/preflight_implementation.py +0 -272
  111. package/scripts/preflight_story_start.py +0 -838
  112. package/scripts/preflight_wip_cap.py +0 -149
  113. package/scripts/probe_session.py +0 -545
  114. package/scripts/project_render.py +0 -293
  115. package/scripts/quarantine_ext.py +0 -237
  116. package/scripts/reconcile_issues.py +0 -1442
  117. package/scripts/refresh-path.ps1 +0 -107
  118. package/scripts/release.py +0 -2030
  119. package/scripts/release_e2e.py +0 -1011
  120. package/scripts/release_publish.py +0 -486
  121. package/scripts/release_rollback.py +0 -980
  122. package/scripts/relocate.py +0 -1034
  123. package/scripts/resolve_changelog_unreleased.py +0 -667
  124. package/scripts/resolve_version.py +0 -490
  125. package/scripts/resume_conditions.py +0 -706
  126. package/scripts/ritual_sentinel.py +0 -609
  127. package/scripts/roadmap_render.py +0 -635
  128. package/scripts/rule_ownership_lint.py +0 -325
  129. package/scripts/scm.py +0 -591
  130. package/scripts/scope_audit_log.py +0 -387
  131. package/scripts/scope_decompose.py +0 -654
  132. package/scripts/scope_demote.py +0 -509
  133. package/scripts/scope_lifecycle.py +0 -1126
  134. package/scripts/scope_undo.py +0 -772
  135. package/scripts/session_start.py +0 -406
  136. package/scripts/setup_ghx.py +0 -339
  137. package/scripts/setup_windows.ps1 +0 -220
  138. package/scripts/slice_audit.py +0 -585
  139. package/scripts/slice_record.py +0 -530
  140. package/scripts/slice_record_existing.py +0 -692
  141. package/scripts/slug_normalize.py +0 -178
  142. package/scripts/spec_render.py +0 -477
  143. package/scripts/spec_validate.py +0 -238
  144. package/scripts/subagent_monitor.py +0 -658
  145. package/scripts/swarm_complete_cohort.py +0 -644
  146. package/scripts/swarm_launch.py +0 -1206
  147. package/scripts/swarm_readiness.py +0 -554
  148. package/scripts/swarm_verify_review_clean.py +0 -438
  149. package/scripts/swarm_worktrees.py +0 -497
  150. package/scripts/toolchain-check.py +0 -52
  151. package/scripts/triage_actions.py +0 -871
  152. package/scripts/triage_bootstrap.py +0 -1153
  153. package/scripts/triage_bulk.py +0 -630
  154. package/scripts/triage_classify.py +0 -932
  155. package/scripts/triage_help.py +0 -1685
  156. package/scripts/triage_queue.py +0 -1944
  157. package/scripts/triage_reconcile.py +0 -581
  158. package/scripts/triage_refresh.py +0 -643
  159. package/scripts/triage_scope.py +0 -999
  160. package/scripts/triage_scope_drift.py +0 -575
  161. package/scripts/triage_smoketest.py +0 -396
  162. package/scripts/triage_subscribe.py +0 -399
  163. package/scripts/triage_summary.py +0 -1011
  164. package/scripts/triage_welcome.py +0 -1178
  165. package/scripts/ts_check_lane.py +0 -86
  166. package/scripts/validate-links.py +0 -64
  167. package/scripts/validate_strategy_output.py +0 -212
  168. package/scripts/vbrief_activate.py +0 -228
  169. package/scripts/vbrief_migrate_conformance.py +0 -368
  170. package/scripts/vbrief_reconcile_graph.py +0 -306
  171. package/scripts/vbrief_reconcile_labels.py +0 -460
  172. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  173. package/scripts/vbrief_validate.py +0 -1144
  174. package/scripts/verify-stubs.py +0 -61
  175. package/scripts/verify_capacity.py +0 -160
  176. package/scripts/verify_encoding.py +0 -699
  177. package/scripts/verify_hooks_installed.py +0 -206
  178. package/scripts/verify_investigation.py +0 -360
  179. package/scripts/verify_judgment_gates.py +0 -827
  180. package/scripts/verify_no_task_runtime.py +0 -171
  181. package/scripts/verify_scm_boundary.py +0 -509
  182. package/scripts/verify_session_ritual.py +0 -389
  183. package/scripts/verify_tools.py +0 -426
  184. package/scripts/verify_vbrief_conformance.py +0 -478
@@ -1,772 +0,0 @@
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())