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