@deftai/directive-content 0.55.2 → 0.56.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.githooks/pre-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +2 -2
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +47 -1
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/scripts/_agents_md.py +494 -0
- package/scripts/_cache_fetch.py +635 -0
- package/scripts/_cache_quota.py +529 -0
- package/scripts/_cache_refresh.py +163 -0
- package/scripts/_cache_validate.py +209 -0
- package/scripts/_content_root.py +42 -0
- package/scripts/_doctor_state.py +277 -0
- package/scripts/_event_detect.py +305 -0
- package/scripts/_events.py +514 -0
- package/scripts/_lifecycle_hygiene.py +568 -0
- package/scripts/_pathspec.py +91 -0
- package/scripts/_policy_show_cli.py +266 -0
- package/scripts/_precutover.py +92 -0
- package/scripts/_project_context.py +224 -0
- package/scripts/_project_definition_io.py +164 -0
- package/scripts/_relocate_snapshot.py +209 -0
- package/scripts/_relocate_states.py +343 -0
- package/scripts/_resolve_preflight_path.py +152 -0
- package/scripts/_safe_subprocess.py +167 -0
- package/scripts/_session_start_hook.py +205 -0
- package/scripts/_sor_gate_diff.py +365 -0
- package/scripts/_stdio_utf8.py +59 -0
- package/scripts/_triage_bootstrap_gitignore.py +904 -0
- package/scripts/_triage_classify_cli.py +122 -0
- package/scripts/_triage_queue_cli.py +625 -0
- package/scripts/_triage_scope_cli.py +343 -0
- package/scripts/_triage_scope_drift_cli.py +121 -0
- package/scripts/_triage_scope_ignores.py +286 -0
- package/scripts/_triage_scope_milestone.py +432 -0
- package/scripts/_triage_scope_mutations.py +337 -0
- package/scripts/_triage_scope_renderers.py +207 -0
- package/scripts/_triage_smoketest_stages.py +674 -0
- package/scripts/_triage_subscribe_cli.py +140 -0
- package/scripts/_triage_welcome_cli.py +421 -0
- package/scripts/_vbrief_build.py +239 -0
- package/scripts/_vbrief_fidelity.py +479 -0
- package/scripts/_vbrief_legacy.py +589 -0
- package/scripts/_vbrief_reconciliation.py +883 -0
- package/scripts/_vbrief_routing.py +277 -0
- package/scripts/_vbrief_safety.py +778 -0
- package/scripts/_vbrief_sources.py +312 -0
- package/scripts/_vbrief_speckit.py +262 -0
- package/scripts/_vbrief_story_quality.py +353 -0
- package/scripts/_vbrief_validation.py +299 -0
- package/scripts/build_dist.py +412 -0
- package/scripts/cache.py +1078 -0
- package/scripts/cache_scanner.py +745 -0
- package/scripts/candidates_log.py +432 -0
- package/scripts/capacity_backfill.py +680 -0
- package/scripts/capacity_show.py +653 -0
- package/scripts/ci_local.py +689 -0
- package/scripts/code_structure_validate.py +765 -0
- package/scripts/codebase_default_extractor.py +495 -0
- package/scripts/codebase_map.py +304 -0
- package/scripts/codebase_map_fresh.py +104 -0
- package/scripts/codebase_projection_registry.py +94 -0
- package/scripts/codebase_provider.py +582 -0
- package/scripts/doctor.py +2257 -0
- package/scripts/framework_commands.py +505 -0
- package/scripts/gh_rest.py +882 -0
- package/scripts/github_auth_modes.py +437 -0
- package/scripts/github_body.py +292 -0
- package/scripts/ip_risk.py +531 -0
- package/scripts/issue_emit.py +670 -0
- package/scripts/issue_ingest.py +1064 -0
- package/scripts/migrate_preflight.py +418 -0
- package/scripts/migrate_vbrief.py +2677 -0
- package/scripts/monitor_pr.py +401 -0
- package/scripts/pack_migrate_lessons.py +336 -0
- package/scripts/pack_migrate_patterns.py +254 -0
- package/scripts/pack_migrate_rules.py +350 -0
- package/scripts/pack_migrate_skills.py +423 -0
- package/scripts/pack_migrate_strategies.py +311 -0
- package/scripts/pack_migrate_swarm_spec.py +250 -0
- package/scripts/pack_render.py +434 -0
- package/scripts/packs_slice.py +712 -0
- package/scripts/platform_capabilities.py +336 -0
- package/scripts/policy.py +2826 -0
- package/scripts/policy_set.py +324 -0
- package/scripts/pr_check_closing_keywords.py +524 -0
- package/scripts/pr_check_protected_issues.py +267 -0
- package/scripts/pr_merge_readiness.py +1004 -0
- package/scripts/pr_wait_mergeable.py +669 -0
- package/scripts/prd_render.py +159 -0
- package/scripts/preflight_architecture_sor.py +974 -0
- package/scripts/preflight_branch.py +289 -0
- package/scripts/preflight_cache.py +974 -0
- package/scripts/preflight_gh.py +721 -0
- package/scripts/preflight_implementation.py +272 -0
- package/scripts/preflight_story_start.py +838 -0
- package/scripts/preflight_wip_cap.py +149 -0
- package/scripts/probe_session.py +545 -0
- package/scripts/project_render.py +293 -0
- package/scripts/quarantine_ext.py +237 -0
- package/scripts/reconcile_issues.py +1442 -0
- package/scripts/refresh-path.ps1 +107 -0
- package/scripts/release.py +2030 -0
- package/scripts/release_e2e.py +1011 -0
- package/scripts/release_publish.py +486 -0
- package/scripts/release_rollback.py +980 -0
- package/scripts/relocate.py +1034 -0
- package/scripts/resolve_changelog_unreleased.py +667 -0
- package/scripts/resolve_version.py +490 -0
- package/scripts/resume_conditions.py +706 -0
- package/scripts/ritual_sentinel.py +609 -0
- package/scripts/roadmap_render.py +635 -0
- package/scripts/rule_ownership_lint.py +325 -0
- package/scripts/scm.py +591 -0
- package/scripts/scope_audit_log.py +387 -0
- package/scripts/scope_decompose.py +654 -0
- package/scripts/scope_demote.py +509 -0
- package/scripts/scope_lifecycle.py +1126 -0
- package/scripts/scope_undo.py +772 -0
- package/scripts/session_start.py +406 -0
- package/scripts/setup_ghx.py +339 -0
- package/scripts/setup_windows.ps1 +220 -0
- package/scripts/slice_audit.py +585 -0
- package/scripts/slice_record.py +530 -0
- package/scripts/slice_record_existing.py +692 -0
- package/scripts/slug_normalize.py +178 -0
- package/scripts/spec_render.py +477 -0
- package/scripts/spec_validate.py +238 -0
- package/scripts/subagent_monitor.py +658 -0
- package/scripts/swarm_complete_cohort.py +644 -0
- package/scripts/swarm_launch.py +1206 -0
- package/scripts/swarm_readiness.py +554 -0
- package/scripts/swarm_verify_review_clean.py +438 -0
- package/scripts/swarm_worktrees.py +497 -0
- package/scripts/toolchain-check.py +52 -0
- package/scripts/triage_actions.py +871 -0
- package/scripts/triage_bootstrap.py +1153 -0
- package/scripts/triage_bulk.py +630 -0
- package/scripts/triage_classify.py +932 -0
- package/scripts/triage_help.py +1685 -0
- package/scripts/triage_queue.py +1944 -0
- package/scripts/triage_reconcile.py +581 -0
- package/scripts/triage_refresh.py +643 -0
- package/scripts/triage_scope.py +999 -0
- package/scripts/triage_scope_drift.py +575 -0
- package/scripts/triage_smoketest.py +396 -0
- package/scripts/triage_subscribe.py +399 -0
- package/scripts/triage_summary.py +1011 -0
- package/scripts/triage_welcome.py +1178 -0
- package/scripts/ts_check_lane.py +86 -0
- package/scripts/validate-links.py +64 -0
- package/scripts/validate_strategy_output.py +212 -0
- package/scripts/vbrief_activate.py +228 -0
- package/scripts/vbrief_migrate_conformance.py +368 -0
- package/scripts/vbrief_reconcile_graph.py +306 -0
- package/scripts/vbrief_reconcile_labels.py +460 -0
- package/scripts/vbrief_reconcile_umbrellas.py +741 -0
- package/scripts/vbrief_validate.py +1195 -0
- package/scripts/verify-stubs.py +61 -0
- package/scripts/verify_capacity.py +160 -0
- package/scripts/verify_encoding.py +699 -0
- package/scripts/verify_hooks_installed.py +206 -0
- package/scripts/verify_investigation.py +360 -0
- package/scripts/verify_judgment_gates.py +827 -0
- package/scripts/verify_no_task_runtime.py +171 -0
- package/scripts/verify_scm_boundary.py +509 -0
- package/scripts/verify_session_ritual.py +389 -0
- package/scripts/verify_tools.py +426 -0
- package/scripts/verify_vbrief_conformance.py +478 -0
- package/tasks/architecture.yml +13 -0
- package/tasks/cache.yml +69 -0
- package/tasks/capacity.yml +38 -0
- package/tasks/change.yml +46 -0
- package/tasks/changelog.yml +24 -0
- package/tasks/ci.yml +49 -0
- package/tasks/codebase.yml +47 -0
- package/tasks/commit.yml +30 -0
- package/tasks/core.yml +126 -0
- package/tasks/deployments.yml +54 -0
- package/tasks/framework.yml +74 -0
- package/tasks/install.yml +60 -0
- package/tasks/issue.yml +50 -0
- package/tasks/migrate.yml +73 -0
- package/tasks/packs.yml +92 -0
- package/tasks/policy.yml +75 -0
- package/tasks/pr.yml +89 -0
- package/tasks/prd.yml +39 -0
- package/tasks/project.yml +27 -0
- package/tasks/reconcile.yml +32 -0
- package/tasks/relocate.yml +56 -0
- package/tasks/roadmap.yml +28 -0
- package/tasks/scm.yml +126 -0
- package/tasks/scope-undo.yml +36 -0
- package/tasks/scope.yml +141 -0
- package/tasks/session.yml +19 -0
- package/tasks/setup.yml +37 -0
- package/tasks/slice.yml +69 -0
- package/tasks/spec.yml +41 -0
- package/tasks/swarm.yml +85 -0
- package/tasks/toolchain.yml +13 -0
- package/tasks/triage-actions.yml +94 -0
- package/tasks/triage-bootstrap.yml +43 -0
- package/tasks/triage-bulk.yml +75 -0
- package/tasks/triage-classify.yml +30 -0
- package/tasks/triage-queue.yml +50 -0
- package/tasks/triage-reconcile.yml +29 -0
- package/tasks/triage-scope-drift.yml +29 -0
- package/tasks/triage-scope.yml +31 -0
- package/tasks/triage-smoketest.yml +33 -0
- package/tasks/triage-subscribe.yml +36 -0
- package/tasks/triage-summary.yml +29 -0
- package/tasks/triage-welcome.yml +32 -0
- package/tasks/ts.yml +328 -0
- package/tasks/vbrief.yml +206 -0
- package/tasks/verify.yml +292 -0
- package/templates/agents-entry.md +1 -1
|
@@ -0,0 +1,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())
|