@deftai/directive-content 0.55.1 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.githooks/pre-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +13 -3
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +82 -11
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/packs/skills/skills-pack-0.1.json +22 -22
- 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/skills/deft-directive-swarm/SKILL.md +7 -26
- package/skills/deft-directive-sync/SKILL.md +1 -1
- 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 +2 -2
|
@@ -0,0 +1,1126 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
scope_lifecycle.py -- Deterministic vBRIEF scope lifecycle transitions.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
uv run python scripts/scope_lifecycle.py <action> <file> [--project-root PATH]
|
|
7
|
+
|
|
8
|
+
Actions:
|
|
9
|
+
promote -- proposed/ -> pending/ (status: pending)
|
|
10
|
+
Subject to the WIP cap (#1124 / D4 of #1119):
|
|
11
|
+
refused when ``pending/ + active/`` >= cap; pass
|
|
12
|
+
``--force`` to override (stderr warning + audit-log
|
|
13
|
+
entry tagged ``wip_cap_override``).
|
|
14
|
+
activate -- pending/ -> active/ (status: running)
|
|
15
|
+
complete -- active/ -> completed/ (status: completed)
|
|
16
|
+
fail -- active/ -> completed/ (status: failed)
|
|
17
|
+
cancel -- any folder -> cancelled/ (status: cancelled)
|
|
18
|
+
restore -- cancelled/ -> proposed/ (status: proposed)
|
|
19
|
+
block -- stays in active/ (status: blocked)
|
|
20
|
+
unblock -- stays in active/ (status: running)
|
|
21
|
+
|
|
22
|
+
Note: ``complete`` and ``fail`` share the active/ -> completed/ move;
|
|
23
|
+
they differ only in terminal status (``completed`` vs ``failed``). The
|
|
24
|
+
semantic distinction (#614) is:
|
|
25
|
+
|
|
26
|
+
* ``complete`` -- the scope succeeded.
|
|
27
|
+
* ``cancel`` -- decision: the scope is no longer wanted (superseded,
|
|
28
|
+
obsolete); moves to cancelled/.
|
|
29
|
+
* ``fail`` -- attempt: the scope was tried but could not complete
|
|
30
|
+
(external blocker, infeasibility discovered mid-flight, deadline hit,
|
|
31
|
+
agent exhausted retries). Records a failure terminal state when the
|
|
32
|
+
work should NOT be cancelled.
|
|
33
|
+
|
|
34
|
+
Collapsing ``failed`` into ``cancelled`` would lose this information
|
|
35
|
+
and leave ``active/`` as a zombie graveyard when agents hit
|
|
36
|
+
unrecoverable blockers.
|
|
37
|
+
|
|
38
|
+
Each action:
|
|
39
|
+
- Validates the transition is legal (source folder + current status)
|
|
40
|
+
- Updates plan.status and plan.updated in the vBRIEF file
|
|
41
|
+
- Moves the file to the target lifecycle folder (where applicable)
|
|
42
|
+
- Reports the transition performed
|
|
43
|
+
|
|
44
|
+
Path resolution (#535):
|
|
45
|
+
Relative ``<file>`` arguments resolve against the consumer project
|
|
46
|
+
root (highest precedence flag beats environment beats sentinel walk),
|
|
47
|
+
NEVER against ``deft/``. If no project root can be detected the script
|
|
48
|
+
fails loudly with exit 2 instead of silently falling back.
|
|
49
|
+
|
|
50
|
+
Exit codes:
|
|
51
|
+
0 -- transition successful
|
|
52
|
+
1 -- invalid transition or validation error
|
|
53
|
+
2 -- usage error (including: undetectable project root for relative path)
|
|
54
|
+
|
|
55
|
+
RFC #309 decision D16. Story #324.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
import argparse
|
|
59
|
+
import contextlib
|
|
60
|
+
import json
|
|
61
|
+
import sys
|
|
62
|
+
from dataclasses import dataclass
|
|
63
|
+
from datetime import UTC, datetime
|
|
64
|
+
from pathlib import Path
|
|
65
|
+
|
|
66
|
+
# Make sibling ``_stdio_utf8`` / ``_project_context`` importable both when
|
|
67
|
+
# run as ``__main__`` and when imported by tests that preload sys.path.
|
|
68
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
69
|
+
|
|
70
|
+
from _project_context import resolve_project_root # noqa: E402
|
|
71
|
+
from _stdio_utf8 import reconfigure_stdio # noqa: E402
|
|
72
|
+
|
|
73
|
+
reconfigure_stdio()
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Constants
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
LIFECYCLE_FOLDERS = ("proposed", "pending", "active", "completed", "cancelled")
|
|
80
|
+
|
|
81
|
+
# action -> (allowed_source_folders, target_folder, target_status)
|
|
82
|
+
# None for target_folder means file stays in place.
|
|
83
|
+
#
|
|
84
|
+
# ``fail`` parallels ``complete`` exactly on folder movement (both move
|
|
85
|
+
# active/ -> completed/); they differ only in the terminal status
|
|
86
|
+
# stamped onto ``plan.status`` (``failed`` vs ``completed``). See the
|
|
87
|
+
# module docstring for the cancel/fail semantic distinction (#614).
|
|
88
|
+
TRANSITIONS: dict[str, tuple[tuple[str, ...], str | None, str]] = {
|
|
89
|
+
"promote": (("proposed",), "pending", "pending"),
|
|
90
|
+
"activate": (("pending",), "active", "running"),
|
|
91
|
+
"complete": (("active",), "completed", "completed"),
|
|
92
|
+
"fail": (("active",), "completed", "failed"),
|
|
93
|
+
"cancel": (LIFECYCLE_FOLDERS, "cancelled", "cancelled"),
|
|
94
|
+
"restore": (("cancelled",), "proposed", "proposed"),
|
|
95
|
+
"block": (("active",), None, "blocked"),
|
|
96
|
+
"unblock": (("active",), None, "running"),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# Status preconditions for actions that stay in place.
|
|
100
|
+
# block requires status=running, unblock requires status=blocked.
|
|
101
|
+
STATUS_PRECONDITIONS: dict[str, str] = {
|
|
102
|
+
"block": "running",
|
|
103
|
+
"unblock": "blocked",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# WIP cap enforcement (#1124 / D4 of #1119)
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class WipCapCheck:
|
|
114
|
+
"""Result of the pre-promote WIP cap check.
|
|
115
|
+
|
|
116
|
+
* ``allowed`` -- True if promotion can proceed (count < cap, OR
|
|
117
|
+
``--force`` was passed).
|
|
118
|
+
* ``cap`` -- resolved cap value (default 10 per the shared
|
|
119
|
+
:data:`scripts.policy.DEFAULT_WIP_CAP`).
|
|
120
|
+
* ``count`` -- current ``pending/ + active/`` count.
|
|
121
|
+
* ``source`` -- ``scripts.policy.WipCapResult.source`` carry-through.
|
|
122
|
+
* ``force_override`` -- True when ``allowed`` was granted via
|
|
123
|
+
``--force`` (the caller MUST emit a warning + audit-log entry).
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
allowed: bool
|
|
127
|
+
cap: int
|
|
128
|
+
count: int
|
|
129
|
+
source: str
|
|
130
|
+
force_override: bool = False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def check_wip_cap(
|
|
134
|
+
project_root: Path,
|
|
135
|
+
*,
|
|
136
|
+
force: bool = False,
|
|
137
|
+
) -> WipCapCheck:
|
|
138
|
+
"""Resolve the WIP cap and current count; decide if promotion is allowed.
|
|
139
|
+
|
|
140
|
+
Pure-stdlib helper. Deferred-import of ``scripts.policy`` so a
|
|
141
|
+
consumer running this verb against a tree that pre-dates D4
|
|
142
|
+
(#1124) degrades to ``allowed=True`` (cap unknown -> do not block).
|
|
143
|
+
"""
|
|
144
|
+
try:
|
|
145
|
+
from policy import ( # noqa: I001
|
|
146
|
+
count_vbrief_wip,
|
|
147
|
+
resolve_wip_cap,
|
|
148
|
+
)
|
|
149
|
+
except ImportError: # pragma: no cover -- D4 not present on rolling-merge tolerance branch
|
|
150
|
+
return WipCapCheck(
|
|
151
|
+
allowed=True,
|
|
152
|
+
cap=10,
|
|
153
|
+
count=0,
|
|
154
|
+
source="d4-not-available",
|
|
155
|
+
force_override=force,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
cap_result = resolve_wip_cap(project_root)
|
|
159
|
+
cap = cap_result.cap
|
|
160
|
+
count = count_vbrief_wip(project_root)
|
|
161
|
+
# ``pending/ + active/`` >= cap refuses; ``--force`` overrides.
|
|
162
|
+
over_cap = count >= cap
|
|
163
|
+
if not over_cap:
|
|
164
|
+
return WipCapCheck(
|
|
165
|
+
allowed=True,
|
|
166
|
+
cap=cap,
|
|
167
|
+
count=count,
|
|
168
|
+
source=cap_result.source,
|
|
169
|
+
force_override=False,
|
|
170
|
+
)
|
|
171
|
+
if force:
|
|
172
|
+
return WipCapCheck(
|
|
173
|
+
allowed=True,
|
|
174
|
+
cap=cap,
|
|
175
|
+
count=count,
|
|
176
|
+
source=cap_result.source,
|
|
177
|
+
force_override=True,
|
|
178
|
+
)
|
|
179
|
+
return WipCapCheck(
|
|
180
|
+
allowed=False,
|
|
181
|
+
cap=cap,
|
|
182
|
+
count=count,
|
|
183
|
+
source=cap_result.source,
|
|
184
|
+
force_override=False,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def format_wip_cap_refusal(check: WipCapCheck) -> str:
|
|
189
|
+
"""Format the cap-reached error message (#1124 acceptance criterion).
|
|
190
|
+
|
|
191
|
+
Names the cap, the current count, and the canonical relief verbs
|
|
192
|
+
(single-file demote, batch demote, ``--force`` override). Mirrors
|
|
193
|
+
the issue body's demoability block verbatim so downstream operators
|
|
194
|
+
learn the same recovery surface as the spec describes.
|
|
195
|
+
"""
|
|
196
|
+
# noqa: E501 -- the alignment columns are part of the verbatim demoability
|
|
197
|
+
# block from the #1124 issue body and MUST NOT be reflowed.
|
|
198
|
+
return (
|
|
199
|
+
f"ERROR: WIP cap reached ({check.count}/{check.cap} in pending/+active/). "
|
|
200
|
+
"Either:\n"
|
|
201
|
+
" task scope:demote <existing> # return one to proposed/\n" # noqa: E501
|
|
202
|
+
" task scope:demote --batch --older-than-days 30 # bulk relief (D9 folded into D1)\n" # noqa: E501
|
|
203
|
+
" task scope:promote <file> --force # override (logged)"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _record_wip_cap_override(
|
|
208
|
+
file_path: Path,
|
|
209
|
+
project_root: Path,
|
|
210
|
+
check: WipCapCheck,
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Append a ``wip_cap_override`` audit entry to the scope-lifecycle log.
|
|
213
|
+
|
|
214
|
+
Uses :mod:`scripts.scope_audit_log` (shared with D1 / #1121) so the
|
|
215
|
+
override is on the same canonical timeline as ``demote`` entries.
|
|
216
|
+
The audit-log validator does NOT require any action-specific block
|
|
217
|
+
for ``action='promote'`` -- only ``demote`` mandates ``demote_meta``
|
|
218
|
+
-- so this entry passes validation while carrying its own forward-
|
|
219
|
+
compat ``wip_cap_override`` block. Best-effort: any audit failure
|
|
220
|
+
is swallowed (the promote itself MUST succeed when ``--force`` was
|
|
221
|
+
passed; the audit-log surface is observability).
|
|
222
|
+
"""
|
|
223
|
+
with contextlib.suppress(Exception):
|
|
224
|
+
from scope_audit_log import ( # noqa: I001
|
|
225
|
+
append as audit_append,
|
|
226
|
+
canonical_log_path,
|
|
227
|
+
new_decision_id,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
rel = file_path.resolve().relative_to(project_root.resolve())
|
|
232
|
+
canonical = rel.as_posix()
|
|
233
|
+
except ValueError:
|
|
234
|
+
canonical = file_path.resolve().as_posix()
|
|
235
|
+
entry = {
|
|
236
|
+
"decision_id": new_decision_id(),
|
|
237
|
+
"timestamp": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
238
|
+
"action": "promote",
|
|
239
|
+
"vbrief_path": canonical,
|
|
240
|
+
"from_status": "proposed",
|
|
241
|
+
"to_status": "pending",
|
|
242
|
+
"actor": "operator",
|
|
243
|
+
"wip_cap_override": {
|
|
244
|
+
"cap": check.cap,
|
|
245
|
+
"count_at_promote": check.count,
|
|
246
|
+
"source": check.source,
|
|
247
|
+
"reason": "--force",
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
audit_append(entry, log_path=canonical_log_path(project_root))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
# Core logic
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def detect_lifecycle_folder(file_path: Path) -> str | None:
|
|
259
|
+
"""Return the lifecycle folder name the file resides in, or None."""
|
|
260
|
+
parent_name = file_path.parent.name
|
|
261
|
+
if parent_name in LIFECYCLE_FOLDERS:
|
|
262
|
+
return parent_name
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
# Decomposed parent <-> child back-reference maintenance (#1485)
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
#
|
|
270
|
+
# A decomposed child vBRIEF carries a ``planRef`` (plan-level and/or item-
|
|
271
|
+
# level) pointing at its parent epic. The parent epic, in turn, lists the
|
|
272
|
+
# child via a ``plan.references[]`` entry of ``type == "x-vbrief/plan"`` whose
|
|
273
|
+
# ``uri`` points at the child's *current* lifecycle path. When a lifecycle
|
|
274
|
+
# move relocates the child between folders, that forward ``uri`` goes stale --
|
|
275
|
+
# it still names the child's old path -- which breaks the D4 bidirectional-
|
|
276
|
+
# linkage check in ``scripts/vbrief_validate.py`` (the parent references a
|
|
277
|
+
# non-existent path). The helpers below rewrite the parent's forward
|
|
278
|
+
# reference to the child's new path on every move, so ``task vbrief:validate``
|
|
279
|
+
# passes with no manual repair. The reference-resolution rules mirror
|
|
280
|
+
# ``scripts/vbrief_validate.py`` (relative-to-vbrief-dir, ``file://`` support).
|
|
281
|
+
#
|
|
282
|
+
# ``resolve_vbrief_ref``, ``collect_plan_refs``, and ``collect_child_uris``
|
|
283
|
+
# (below) are the PUBLIC decomposed-reference surface: cross-module consumers
|
|
284
|
+
# such as ``scripts/swarm_complete_cohort.py`` (#1487) call them directly, so
|
|
285
|
+
# they carry no leading underscore. The ``_rewrite_*`` helpers remain private.
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def resolve_vbrief_ref(uri: object, vbrief_dir: Path) -> Path | None:
|
|
289
|
+
"""Resolve a vBRIEF reference URI to an absolute path, or None.
|
|
290
|
+
|
|
291
|
+
Mirrors ``vbrief_validate._resolve_ref_path``: ``file://`` and bare
|
|
292
|
+
relative URIs resolve against *vbrief_dir*; ``http(s)://`` / ``#``
|
|
293
|
+
anchors are external and return None.
|
|
294
|
+
"""
|
|
295
|
+
if not isinstance(uri, str) or not uri:
|
|
296
|
+
return None
|
|
297
|
+
if uri.startswith("file://"):
|
|
298
|
+
rel = uri[len("file://") :]
|
|
299
|
+
elif uri.startswith(("http://", "https://", "#")):
|
|
300
|
+
return None
|
|
301
|
+
else:
|
|
302
|
+
rel = uri
|
|
303
|
+
return (vbrief_dir / rel).resolve()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def collect_plan_refs(plan: dict) -> list[str]:
|
|
307
|
+
"""Collect planRef values from the plan root and top-level items.
|
|
308
|
+
|
|
309
|
+
Matches ``vbrief_validate._collect_plan_refs``: ``planRef`` is valid at
|
|
310
|
+
the plan root and top-level item levels only (subItems are not scanned).
|
|
311
|
+
"""
|
|
312
|
+
refs: list[str] = []
|
|
313
|
+
root_ref = plan.get("planRef")
|
|
314
|
+
if isinstance(root_ref, str) and root_ref:
|
|
315
|
+
refs.append(root_ref)
|
|
316
|
+
items = plan.get("items")
|
|
317
|
+
if isinstance(items, list):
|
|
318
|
+
for item in items:
|
|
319
|
+
if isinstance(item, dict):
|
|
320
|
+
item_ref = item.get("planRef")
|
|
321
|
+
if isinstance(item_ref, str) and item_ref:
|
|
322
|
+
refs.append(item_ref)
|
|
323
|
+
return refs
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _rewrite_parent_child_reference(
|
|
327
|
+
parent_path: Path,
|
|
328
|
+
old_child_resolved: Path,
|
|
329
|
+
new_child_rel: str,
|
|
330
|
+
vbrief_dir: Path,
|
|
331
|
+
) -> bool:
|
|
332
|
+
"""Rewrite *parent_path*'s x-vbrief/plan ref from old to new child path.
|
|
333
|
+
|
|
334
|
+
Loads the parent, finds every ``x-vbrief/plan`` reference whose ``uri``
|
|
335
|
+
resolves to *old_child_resolved*, and rewrites it to *new_child_rel*
|
|
336
|
+
(preserving a ``file://`` prefix when the original used one). Returns
|
|
337
|
+
True when at least one reference was changed and the parent re-written.
|
|
338
|
+
"""
|
|
339
|
+
try:
|
|
340
|
+
parent_data = json.loads(parent_path.read_text(encoding="utf-8"))
|
|
341
|
+
except (OSError, json.JSONDecodeError):
|
|
342
|
+
return False
|
|
343
|
+
if not isinstance(parent_data, dict):
|
|
344
|
+
return False
|
|
345
|
+
parent_plan = parent_data.get("plan")
|
|
346
|
+
if not isinstance(parent_plan, dict):
|
|
347
|
+
return False
|
|
348
|
+
refs = parent_plan.get("references")
|
|
349
|
+
if not isinstance(refs, list):
|
|
350
|
+
return False
|
|
351
|
+
|
|
352
|
+
changed = False
|
|
353
|
+
for ref in refs:
|
|
354
|
+
if not isinstance(ref, dict):
|
|
355
|
+
continue
|
|
356
|
+
if ref.get("type") != "x-vbrief/plan":
|
|
357
|
+
continue
|
|
358
|
+
uri = ref.get("uri")
|
|
359
|
+
resolved = resolve_vbrief_ref(uri, vbrief_dir)
|
|
360
|
+
if resolved is None or resolved != old_child_resolved:
|
|
361
|
+
continue
|
|
362
|
+
new_uri = (
|
|
363
|
+
f"file://{new_child_rel}"
|
|
364
|
+
if isinstance(uri, str) and uri.startswith("file://")
|
|
365
|
+
else new_child_rel
|
|
366
|
+
)
|
|
367
|
+
if new_uri != uri:
|
|
368
|
+
ref["uri"] = new_uri
|
|
369
|
+
changed = True
|
|
370
|
+
|
|
371
|
+
if changed:
|
|
372
|
+
try:
|
|
373
|
+
parent_path.write_text(
|
|
374
|
+
json.dumps(parent_data, indent=2, ensure_ascii=False) + "\n",
|
|
375
|
+
encoding="utf-8",
|
|
376
|
+
)
|
|
377
|
+
except OSError:
|
|
378
|
+
# Best-effort: the child move has already succeeded, so a parent
|
|
379
|
+
# write failure (disk full, EROFS, PermissionError) MUST NOT
|
|
380
|
+
# escape run_transition's tuple[bool, str] "never raises"
|
|
381
|
+
# contract. Report no rewrite rather than propagating.
|
|
382
|
+
return False
|
|
383
|
+
return changed
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def update_decomposed_parent_back_references(
|
|
387
|
+
child_data: dict,
|
|
388
|
+
old_child_path: Path,
|
|
389
|
+
new_child_path: Path,
|
|
390
|
+
vbrief_dir: Path,
|
|
391
|
+
) -> list[Path]:
|
|
392
|
+
"""Sync decomposed parents' forward references after a child move (#1485).
|
|
393
|
+
|
|
394
|
+
If *child_data* is a decomposed child (carries a ``planRef`` to a parent
|
|
395
|
+
epic), rewrite each existing parent's ``x-vbrief/plan`` reference uri from
|
|
396
|
+
the child's old lifecycle path to its new path. Non-decomposed children
|
|
397
|
+
(no resolvable parent on disk) are a no-op. Best-effort: the caller has
|
|
398
|
+
already moved the file, so this never raises -- a malformed or missing
|
|
399
|
+
parent is simply skipped.
|
|
400
|
+
|
|
401
|
+
Returns the list of parent paths whose references were rewritten.
|
|
402
|
+
"""
|
|
403
|
+
plan = child_data.get("plan")
|
|
404
|
+
if not isinstance(plan, dict):
|
|
405
|
+
return []
|
|
406
|
+
old_resolved = old_child_path.resolve()
|
|
407
|
+
try:
|
|
408
|
+
new_rel = new_child_path.resolve().relative_to(vbrief_dir.resolve()).as_posix()
|
|
409
|
+
except ValueError:
|
|
410
|
+
# Child resolved outside vbrief/ -- nothing safe to rewrite.
|
|
411
|
+
return []
|
|
412
|
+
|
|
413
|
+
updated: list[Path] = []
|
|
414
|
+
seen: set[Path] = set()
|
|
415
|
+
for plan_ref in collect_plan_refs(plan):
|
|
416
|
+
parent_path = resolve_vbrief_ref(plan_ref, vbrief_dir)
|
|
417
|
+
if parent_path is None or parent_path in seen:
|
|
418
|
+
continue
|
|
419
|
+
seen.add(parent_path)
|
|
420
|
+
if not parent_path.is_file():
|
|
421
|
+
continue
|
|
422
|
+
if _rewrite_parent_child_reference(parent_path, old_resolved, new_rel, vbrief_dir):
|
|
423
|
+
updated.append(parent_path)
|
|
424
|
+
return updated
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# ---------------------------------------------------------------------------
|
|
428
|
+
# Decomposed child <- parent back-reference maintenance (symmetric to #1485)
|
|
429
|
+
# ---------------------------------------------------------------------------
|
|
430
|
+
#
|
|
431
|
+
# ``update_decomposed_parent_back_references`` (above) handles the CHILD-moved
|
|
432
|
+
# direction: a child relocating between folders leaves its parent's forward
|
|
433
|
+
# ``x-vbrief/plan`` reference stale, so we rewrite the parent. The PARENT-moved
|
|
434
|
+
# direction is the mirror image and is required by the swarm cohort-completion
|
|
435
|
+
# sweep (#1487): when a decompose-created epic parent is completed (e.g.
|
|
436
|
+
# ``pending/ -> active/ -> completed/`` once all its children are done), each
|
|
437
|
+
# child's ``planRef`` back-pointer still names the parent's OLD path. That
|
|
438
|
+
# breaks the D4 backward-linkage check in ``scripts/vbrief_validate.py`` (the
|
|
439
|
+
# child references a non-existent parent). The helpers below rewrite every
|
|
440
|
+
# child's ``planRef`` (plan-level and item-level) to the parent's new path on
|
|
441
|
+
# every move, so ``task vbrief:validate`` stays green for parent moves with no
|
|
442
|
+
# manual repair. Reference resolution mirrors ``scripts/vbrief_validate.py``.
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def collect_child_uris(plan: dict) -> list[str]:
|
|
446
|
+
"""Collect ``x-vbrief/plan`` child reference uris from a parent plan.
|
|
447
|
+
|
|
448
|
+
Matches ``vbrief_validate.validate_epic_story_links``: a forward child
|
|
449
|
+
reference is any ``plan.references[]`` entry of ``type == 'x-vbrief/plan'``.
|
|
450
|
+
"""
|
|
451
|
+
uris: list[str] = []
|
|
452
|
+
refs = plan.get("references")
|
|
453
|
+
if not isinstance(refs, list):
|
|
454
|
+
return uris
|
|
455
|
+
for ref in refs:
|
|
456
|
+
if not isinstance(ref, dict):
|
|
457
|
+
continue
|
|
458
|
+
if ref.get("type") != "x-vbrief/plan":
|
|
459
|
+
continue
|
|
460
|
+
uri = ref.get("uri")
|
|
461
|
+
if isinstance(uri, str) and uri:
|
|
462
|
+
uris.append(uri)
|
|
463
|
+
return uris
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _rewrite_one_plan_ref(
|
|
467
|
+
value: object,
|
|
468
|
+
old_parent_resolved: Path,
|
|
469
|
+
new_parent_rel: str,
|
|
470
|
+
vbrief_dir: Path,
|
|
471
|
+
) -> tuple[str, bool]:
|
|
472
|
+
"""Rewrite a single ``planRef`` value if it resolves to *old_parent_resolved*.
|
|
473
|
+
|
|
474
|
+
Returns ``(value, changed)``. Preserves a ``file://`` prefix when the
|
|
475
|
+
original used one. Non-matching / non-string values are returned
|
|
476
|
+
unchanged with ``changed=False``.
|
|
477
|
+
"""
|
|
478
|
+
if not isinstance(value, str) or not value:
|
|
479
|
+
return value, False # type: ignore[return-value]
|
|
480
|
+
resolved = resolve_vbrief_ref(value, vbrief_dir)
|
|
481
|
+
if resolved is None or resolved != old_parent_resolved:
|
|
482
|
+
return value, False
|
|
483
|
+
new_value = f"file://{new_parent_rel}" if value.startswith("file://") else new_parent_rel
|
|
484
|
+
return new_value, new_value != value
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _rewrite_child_parent_reference(
|
|
488
|
+
child_path: Path,
|
|
489
|
+
old_parent_resolved: Path,
|
|
490
|
+
new_parent_rel: str,
|
|
491
|
+
vbrief_dir: Path,
|
|
492
|
+
) -> bool:
|
|
493
|
+
"""Rewrite *child_path*'s ``planRef`` back-pointers old parent -> new parent.
|
|
494
|
+
|
|
495
|
+
Loads the child, rewrites the plan-level ``planRef`` and every top-level
|
|
496
|
+
item ``planRef`` whose uri resolves to *old_parent_resolved*, and writes the
|
|
497
|
+
child back. Returns True when at least one reference changed. Mirrors
|
|
498
|
+
``vbrief_validate._collect_plan_refs`` (plan root + top-level items only;
|
|
499
|
+
subItems are not scanned). Best-effort: a malformed child or a write
|
|
500
|
+
failure reports no rewrite rather than raising.
|
|
501
|
+
"""
|
|
502
|
+
try:
|
|
503
|
+
child_data = json.loads(child_path.read_text(encoding="utf-8"))
|
|
504
|
+
except (OSError, json.JSONDecodeError):
|
|
505
|
+
return False
|
|
506
|
+
if not isinstance(child_data, dict):
|
|
507
|
+
return False
|
|
508
|
+
child_plan = child_data.get("plan")
|
|
509
|
+
if not isinstance(child_plan, dict):
|
|
510
|
+
return False
|
|
511
|
+
|
|
512
|
+
changed = False
|
|
513
|
+
root_ref = child_plan.get("planRef")
|
|
514
|
+
new_root, root_changed = _rewrite_one_plan_ref(
|
|
515
|
+
root_ref, old_parent_resolved, new_parent_rel, vbrief_dir
|
|
516
|
+
)
|
|
517
|
+
if root_changed:
|
|
518
|
+
child_plan["planRef"] = new_root
|
|
519
|
+
changed = True
|
|
520
|
+
|
|
521
|
+
items = child_plan.get("items")
|
|
522
|
+
if isinstance(items, list):
|
|
523
|
+
for item in items:
|
|
524
|
+
if not isinstance(item, dict):
|
|
525
|
+
continue
|
|
526
|
+
item_ref = item.get("planRef")
|
|
527
|
+
new_item, item_changed = _rewrite_one_plan_ref(
|
|
528
|
+
item_ref, old_parent_resolved, new_parent_rel, vbrief_dir
|
|
529
|
+
)
|
|
530
|
+
if item_changed:
|
|
531
|
+
item["planRef"] = new_item
|
|
532
|
+
changed = True
|
|
533
|
+
|
|
534
|
+
if changed:
|
|
535
|
+
try:
|
|
536
|
+
child_path.write_text(
|
|
537
|
+
json.dumps(child_data, indent=2, ensure_ascii=False) + "\n",
|
|
538
|
+
encoding="utf-8",
|
|
539
|
+
)
|
|
540
|
+
except OSError:
|
|
541
|
+
# Best-effort: the parent move has already succeeded, so a child
|
|
542
|
+
# write failure MUST NOT escape run_transition's never-raises
|
|
543
|
+
# contract. Report no rewrite rather than propagating.
|
|
544
|
+
return False
|
|
545
|
+
return changed
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def update_decomposed_child_back_references(
|
|
549
|
+
parent_data: dict,
|
|
550
|
+
old_parent_path: Path,
|
|
551
|
+
new_parent_path: Path,
|
|
552
|
+
vbrief_dir: Path,
|
|
553
|
+
) -> list[Path]:
|
|
554
|
+
"""Sync decomposed children's planRefs after a parent move (#1487).
|
|
555
|
+
|
|
556
|
+
If *parent_data* is a decompose-created epic (carries ``x-vbrief/plan``
|
|
557
|
+
forward references to child stories), rewrite each existing child's
|
|
558
|
+
``planRef`` from the parent's old lifecycle path to its new path. A file
|
|
559
|
+
with no child references (an ordinary story) is a no-op. Best-effort: the
|
|
560
|
+
caller has already moved the file, so this never raises -- a malformed or
|
|
561
|
+
missing child is simply skipped.
|
|
562
|
+
|
|
563
|
+
Returns the list of child paths whose planRefs were rewritten.
|
|
564
|
+
"""
|
|
565
|
+
plan = parent_data.get("plan")
|
|
566
|
+
if not isinstance(plan, dict):
|
|
567
|
+
return []
|
|
568
|
+
old_resolved = old_parent_path.resolve()
|
|
569
|
+
try:
|
|
570
|
+
new_rel = new_parent_path.resolve().relative_to(vbrief_dir.resolve()).as_posix()
|
|
571
|
+
except ValueError:
|
|
572
|
+
# Parent resolved outside vbrief/ -- nothing safe to rewrite.
|
|
573
|
+
return []
|
|
574
|
+
|
|
575
|
+
updated: list[Path] = []
|
|
576
|
+
seen: set[Path] = set()
|
|
577
|
+
for child_uri in collect_child_uris(plan):
|
|
578
|
+
child_path = resolve_vbrief_ref(child_uri, vbrief_dir)
|
|
579
|
+
if child_path is None or child_path in seen:
|
|
580
|
+
continue
|
|
581
|
+
seen.add(child_path)
|
|
582
|
+
if not child_path.is_file():
|
|
583
|
+
continue
|
|
584
|
+
if _rewrite_child_parent_reference(child_path, old_resolved, new_rel, vbrief_dir):
|
|
585
|
+
updated.append(child_path)
|
|
586
|
+
return updated
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
# ---------------------------------------------------------------------------
|
|
590
|
+
# Capacity-accounting completion stamp (#1419 Delivery Slice 4)
|
|
591
|
+
# ---------------------------------------------------------------------------
|
|
592
|
+
#
|
|
593
|
+
# At completion, the capacity engine wants two facts recorded onto the
|
|
594
|
+
# completed vBRIEF so the trailing-window backward view
|
|
595
|
+
# (``scripts/capacity_show.py``) is filesystem-truth and offline:
|
|
596
|
+
#
|
|
597
|
+
# * ``plan.metadata.completedAt`` -- the completion timestamp, used to decide
|
|
598
|
+
# whether the vBRIEF falls inside the trailing accounting window.
|
|
599
|
+
# * ``plan.metadata.capacityBucket`` -- which protected bucket the work
|
|
600
|
+
# counts against. An explicit value already on the vBRIEF is preserved; an
|
|
601
|
+
# absent value is back-filled from the project's
|
|
602
|
+
# ``plan.policy.capacityAllocation.defaultBucket`` when one is configured.
|
|
603
|
+
#
|
|
604
|
+
# Stamping is best-effort: a missing / unparseable PROJECT-DEFINITION (or a
|
|
605
|
+
# tree that pre-dates the capacity schema) simply leaves ``capacityBucket``
|
|
606
|
+
# unset. The completion transition MUST NOT fail because capacity policy is
|
|
607
|
+
# absent -- this is advisory accounting, not a gate.
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _resolve_default_capacity_bucket(project_root: Path) -> str:
|
|
611
|
+
"""Return the configured ``capacityAllocation.defaultBucket`` or ``""``.
|
|
612
|
+
|
|
613
|
+
Deferred-import of ``scripts.policy`` so a tree that pre-dates the
|
|
614
|
+
#1419 capacity schema degrades cleanly (no bucket back-fill) rather
|
|
615
|
+
than raising. Any resolution failure returns the empty string.
|
|
616
|
+
"""
|
|
617
|
+
try:
|
|
618
|
+
from policy import resolve_capacity_allocation
|
|
619
|
+
except ImportError:
|
|
620
|
+
return ""
|
|
621
|
+
try:
|
|
622
|
+
allocation = resolve_capacity_allocation(project_root)
|
|
623
|
+
except Exception:
|
|
624
|
+
return ""
|
|
625
|
+
return allocation.default_bucket or ""
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _stamp_completion_metadata(plan: dict, project_root: Path, timestamp: str) -> None:
|
|
629
|
+
"""Stamp ``completedAt`` + ``capacityBucket`` onto a completing vBRIEF.
|
|
630
|
+
|
|
631
|
+
``completedAt`` is always set to *timestamp*. ``capacityBucket`` is set
|
|
632
|
+
only when the vBRIEF does not already carry a non-empty explicit value;
|
|
633
|
+
in that case it is back-filled from the project's configured
|
|
634
|
+
``defaultBucket`` (when one exists). Mutates *plan* in place. Never
|
|
635
|
+
raises -- capacity accounting is advisory.
|
|
636
|
+
"""
|
|
637
|
+
metadata = plan.get("metadata")
|
|
638
|
+
if not isinstance(metadata, dict):
|
|
639
|
+
metadata = {}
|
|
640
|
+
plan["metadata"] = metadata
|
|
641
|
+
metadata["completedAt"] = timestamp
|
|
642
|
+
existing = metadata.get("capacityBucket")
|
|
643
|
+
if not (isinstance(existing, str) and existing.strip()):
|
|
644
|
+
bucket = _resolve_default_capacity_bucket(project_root)
|
|
645
|
+
if bucket:
|
|
646
|
+
metadata["capacityBucket"] = bucket
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
# ---------------------------------------------------------------------------
|
|
650
|
+
# PROJECT-DEFINITION registry/reference synchronization (#1527)
|
|
651
|
+
# ---------------------------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _scope_ids_for_filename(filename: str) -> set[str]:
|
|
655
|
+
"""Return registry IDs that may name *filename*.
|
|
656
|
+
|
|
657
|
+
``task project:render`` uses the full date-prefixed filename stem as the
|
|
658
|
+
registry ID, while some consumer PROJECT-DEFINITION files carry the human
|
|
659
|
+
slug without the leading ``YYYY-MM-DD-``. Accept both shapes so lifecycle
|
|
660
|
+
completion can repair real-world registries without a full re-render.
|
|
661
|
+
"""
|
|
662
|
+
if filename.endswith(".vbrief.json"):
|
|
663
|
+
full_id = filename[: -len(".vbrief.json")]
|
|
664
|
+
else:
|
|
665
|
+
full_id = Path(filename).stem
|
|
666
|
+
ids = {full_id}
|
|
667
|
+
parts = full_id.split("-", 3)
|
|
668
|
+
if (
|
|
669
|
+
len(parts) == 4
|
|
670
|
+
and len(parts[0]) == 4
|
|
671
|
+
and len(parts[1]) == 2
|
|
672
|
+
and len(parts[2]) == 2
|
|
673
|
+
and all(part.isdigit() for part in parts[:3])
|
|
674
|
+
):
|
|
675
|
+
ids.add(parts[3])
|
|
676
|
+
return ids
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _relative_to_vbrief(path: Path, vbrief_root: Path) -> str | None:
|
|
680
|
+
try:
|
|
681
|
+
return path.resolve().relative_to(vbrief_root.resolve()).as_posix()
|
|
682
|
+
except ValueError:
|
|
683
|
+
return None
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _rewrite_project_definition_plan_reference(
|
|
687
|
+
ref: object,
|
|
688
|
+
old_resolved: Path,
|
|
689
|
+
new_rel: str,
|
|
690
|
+
vbrief_root: Path,
|
|
691
|
+
) -> bool:
|
|
692
|
+
"""Rewrite a PROJECT-DEFINITION x-vbrief/plan URI old path -> new path."""
|
|
693
|
+
if not isinstance(ref, dict):
|
|
694
|
+
return False
|
|
695
|
+
if ref.get("type") != "x-vbrief/plan":
|
|
696
|
+
return False
|
|
697
|
+
uri = ref.get("uri")
|
|
698
|
+
resolved = resolve_vbrief_ref(uri, vbrief_root)
|
|
699
|
+
if resolved is None or resolved != old_resolved:
|
|
700
|
+
return False
|
|
701
|
+
new_uri = f"file://{new_rel}" if isinstance(uri, str) and uri.startswith("file://") else new_rel
|
|
702
|
+
if new_uri == uri:
|
|
703
|
+
return False
|
|
704
|
+
ref["uri"] = new_uri
|
|
705
|
+
return True
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _project_item_references_scope(
|
|
709
|
+
item: dict,
|
|
710
|
+
old_resolved: Path,
|
|
711
|
+
new_resolved: Path,
|
|
712
|
+
vbrief_root: Path,
|
|
713
|
+
) -> bool:
|
|
714
|
+
"""Return True when a registry item carries a local ref/source to the scope."""
|
|
715
|
+
metadata = item.get("metadata")
|
|
716
|
+
if isinstance(metadata, dict):
|
|
717
|
+
source_path = metadata.get("source_path")
|
|
718
|
+
if isinstance(source_path, str):
|
|
719
|
+
resolved = resolve_vbrief_ref(source_path, vbrief_root)
|
|
720
|
+
if resolved in {old_resolved, new_resolved}:
|
|
721
|
+
return True
|
|
722
|
+
|
|
723
|
+
metadata_refs = metadata.get("references")
|
|
724
|
+
if isinstance(metadata_refs, list):
|
|
725
|
+
for ref in metadata_refs:
|
|
726
|
+
if not isinstance(ref, dict):
|
|
727
|
+
continue
|
|
728
|
+
if ref.get("type") != "x-vbrief/plan":
|
|
729
|
+
continue
|
|
730
|
+
resolved = resolve_vbrief_ref(ref.get("uri"), vbrief_root)
|
|
731
|
+
if resolved in {old_resolved, new_resolved}:
|
|
732
|
+
return True
|
|
733
|
+
|
|
734
|
+
refs = item.get("references")
|
|
735
|
+
if isinstance(refs, list):
|
|
736
|
+
for ref in refs:
|
|
737
|
+
if not isinstance(ref, dict):
|
|
738
|
+
continue
|
|
739
|
+
if ref.get("type") != "x-vbrief/plan":
|
|
740
|
+
continue
|
|
741
|
+
resolved = resolve_vbrief_ref(ref.get("uri"), vbrief_root)
|
|
742
|
+
if resolved in {old_resolved, new_resolved}:
|
|
743
|
+
return True
|
|
744
|
+
return False
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _project_item_matches_scope(
|
|
748
|
+
item: dict,
|
|
749
|
+
scope_data: dict,
|
|
750
|
+
old_path: Path,
|
|
751
|
+
new_path: Path,
|
|
752
|
+
vbrief_root: Path,
|
|
753
|
+
) -> bool:
|
|
754
|
+
"""Match a PROJECT-DEFINITION plan.items[] row to a moved scope."""
|
|
755
|
+
old_resolved = old_path.resolve()
|
|
756
|
+
new_resolved = new_path.resolve()
|
|
757
|
+
if _project_item_references_scope(item, old_resolved, new_resolved, vbrief_root):
|
|
758
|
+
return True
|
|
759
|
+
|
|
760
|
+
item_id = item.get("id")
|
|
761
|
+
if isinstance(item_id, str) and item_id in _scope_ids_for_filename(new_path.name):
|
|
762
|
+
return True
|
|
763
|
+
|
|
764
|
+
scope_plan = scope_data.get("plan")
|
|
765
|
+
scope_title = scope_plan.get("title") if isinstance(scope_plan, dict) else None
|
|
766
|
+
item_title = item.get("title")
|
|
767
|
+
return (
|
|
768
|
+
isinstance(scope_title, str) and isinstance(item_title, str) and item_title == scope_title
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _sync_project_definition_after_scope_move(
|
|
773
|
+
scope_data: dict,
|
|
774
|
+
old_path: Path,
|
|
775
|
+
new_path: Path,
|
|
776
|
+
vbrief_root: Path,
|
|
777
|
+
target_status: str,
|
|
778
|
+
) -> None:
|
|
779
|
+
"""Best-effort sync of PROJECT-DEFINITION after a lifecycle move.
|
|
780
|
+
|
|
781
|
+
The lifecycle transition is filesystem-first: a missing or malformed
|
|
782
|
+
PROJECT-DEFINITION must not make ``scope:complete`` fail. When the file is
|
|
783
|
+
present, keep local plan references and the matching registry row aligned
|
|
784
|
+
with the scope's new lifecycle folder/status.
|
|
785
|
+
"""
|
|
786
|
+
new_rel = _relative_to_vbrief(new_path, vbrief_root)
|
|
787
|
+
if new_rel is None:
|
|
788
|
+
return
|
|
789
|
+
try:
|
|
790
|
+
from _project_definition_io import ( # noqa: I001
|
|
791
|
+
ProjectDefinitionIOError,
|
|
792
|
+
atomic_write_project_definition,
|
|
793
|
+
load_project_definition_for_mutation,
|
|
794
|
+
project_definition_mutation_lock,
|
|
795
|
+
)
|
|
796
|
+
except ImportError:
|
|
797
|
+
return
|
|
798
|
+
|
|
799
|
+
project_root = vbrief_root.parent
|
|
800
|
+
try:
|
|
801
|
+
with project_definition_mutation_lock(project_root):
|
|
802
|
+
project_def, project_def_path = load_project_definition_for_mutation(project_root)
|
|
803
|
+
plan = project_def.get("plan")
|
|
804
|
+
if not isinstance(plan, dict):
|
|
805
|
+
return
|
|
806
|
+
|
|
807
|
+
changed = False
|
|
808
|
+
old_resolved = old_path.resolve()
|
|
809
|
+
refs = plan.get("references")
|
|
810
|
+
if isinstance(refs, list):
|
|
811
|
+
for ref in refs:
|
|
812
|
+
changed = (
|
|
813
|
+
_rewrite_project_definition_plan_reference(
|
|
814
|
+
ref, old_resolved, new_rel, vbrief_root
|
|
815
|
+
)
|
|
816
|
+
or changed
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
items = plan.get("items")
|
|
820
|
+
if isinstance(items, list):
|
|
821
|
+
for item in items:
|
|
822
|
+
if not isinstance(item, dict):
|
|
823
|
+
continue
|
|
824
|
+
if not _project_item_matches_scope(
|
|
825
|
+
item, scope_data, old_path, new_path, vbrief_root
|
|
826
|
+
):
|
|
827
|
+
continue
|
|
828
|
+
if item.get("status") != target_status:
|
|
829
|
+
item["status"] = target_status
|
|
830
|
+
changed = True
|
|
831
|
+
metadata = item.get("metadata")
|
|
832
|
+
if not isinstance(metadata, dict):
|
|
833
|
+
metadata = {}
|
|
834
|
+
item["metadata"] = metadata
|
|
835
|
+
target_folder = new_path.parent.name
|
|
836
|
+
if metadata.get("source_path") != new_rel:
|
|
837
|
+
metadata["source_path"] = new_rel
|
|
838
|
+
changed = True
|
|
839
|
+
if metadata.get("lifecycle_folder") != target_folder:
|
|
840
|
+
metadata["lifecycle_folder"] = target_folder
|
|
841
|
+
changed = True
|
|
842
|
+
|
|
843
|
+
if changed:
|
|
844
|
+
atomic_write_project_definition(project_def_path, project_def)
|
|
845
|
+
except (OSError, ProjectDefinitionIOError):
|
|
846
|
+
return
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def run_transition(action: str, file_path: Path) -> tuple[bool, str]:
|
|
850
|
+
"""Execute a lifecycle transition on a vBRIEF file.
|
|
851
|
+
|
|
852
|
+
Returns:
|
|
853
|
+
(True, success_message) on success.
|
|
854
|
+
(False, error_message) on failure.
|
|
855
|
+
"""
|
|
856
|
+
if action not in TRANSITIONS:
|
|
857
|
+
valid = ", ".join(sorted(TRANSITIONS))
|
|
858
|
+
return False, f"Unknown action '{action}'. Valid actions: {valid}"
|
|
859
|
+
|
|
860
|
+
if not file_path.exists():
|
|
861
|
+
return False, f"File not found: {file_path}"
|
|
862
|
+
|
|
863
|
+
if not file_path.name.endswith(".vbrief.json"):
|
|
864
|
+
return False, f"Not a vBRIEF file (expected .vbrief.json): {file_path.name}"
|
|
865
|
+
|
|
866
|
+
# Determine current folder
|
|
867
|
+
current_folder = detect_lifecycle_folder(file_path)
|
|
868
|
+
if current_folder is None:
|
|
869
|
+
return False, (
|
|
870
|
+
f"File is not inside a lifecycle folder ({', '.join(LIFECYCLE_FOLDERS)}): "
|
|
871
|
+
f"{file_path}"
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
allowed_sources, target_folder, target_status = TRANSITIONS[action]
|
|
875
|
+
|
|
876
|
+
# Validate source folder
|
|
877
|
+
if current_folder not in allowed_sources:
|
|
878
|
+
allowed_str = ", ".join(f"{s}/" for s in allowed_sources)
|
|
879
|
+
return False, (
|
|
880
|
+
f"Invalid transition: '{action}' requires file in "
|
|
881
|
+
f"{allowed_str}. File is in {current_folder}/."
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
# Load and validate JSON
|
|
885
|
+
try:
|
|
886
|
+
text = file_path.read_text(encoding="utf-8")
|
|
887
|
+
data = json.loads(text)
|
|
888
|
+
except json.JSONDecodeError as exc:
|
|
889
|
+
return False, f"Invalid JSON in {file_path}: {exc}"
|
|
890
|
+
|
|
891
|
+
plan = data.get("plan")
|
|
892
|
+
if not isinstance(plan, dict):
|
|
893
|
+
return False, f"Missing or invalid 'plan' object in {file_path}"
|
|
894
|
+
|
|
895
|
+
current_status = plan.get("status", "")
|
|
896
|
+
|
|
897
|
+
# Check status preconditions (block/unblock)
|
|
898
|
+
if action in STATUS_PRECONDITIONS:
|
|
899
|
+
required_status = STATUS_PRECONDITIONS[action]
|
|
900
|
+
if current_status == target_status:
|
|
901
|
+
# Idempotent: already in the target state
|
|
902
|
+
return True, (
|
|
903
|
+
f"No-op: {file_path.name} is already {target_status} " f"in {current_folder}/"
|
|
904
|
+
)
|
|
905
|
+
if current_status != required_status:
|
|
906
|
+
return False, (
|
|
907
|
+
f"Invalid transition: '{action}' requires status='{required_status}', "
|
|
908
|
+
f"but {file_path.name} has status='{current_status}'."
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
# Idempotent: same-folder move with matching status is a no-op
|
|
912
|
+
# (e.g. cancel on a file already in cancelled/)
|
|
913
|
+
if target_folder is not None and target_folder == current_folder:
|
|
914
|
+
return True, (
|
|
915
|
+
f"No-op: {file_path.name} is already in {current_folder}/ "
|
|
916
|
+
f"(status: {current_status})"
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
# Update status and timestamp
|
|
920
|
+
now_iso = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
921
|
+
plan["status"] = target_status
|
|
922
|
+
plan["updated"] = now_iso
|
|
923
|
+
|
|
924
|
+
# Capacity-accounting stamp at completion (#1419 Slice 4): record
|
|
925
|
+
# ``plan.metadata.completedAt`` + ``plan.metadata.capacityBucket`` so the
|
|
926
|
+
# trailing-window backward view in ``scripts/capacity_show.py`` is
|
|
927
|
+
# filesystem-truth. Only ``complete`` (the success terminal) is stamped --
|
|
928
|
+
# ``fail`` records an attempt that could not finish and is intentionally
|
|
929
|
+
# excluded from capacity accounting. ``project_root`` is the vbrief/
|
|
930
|
+
# parent (file is in active/ here, so parent.parent.parent is the root).
|
|
931
|
+
# Best-effort: a missing capacity policy simply leaves capacityBucket unset.
|
|
932
|
+
if action == "complete":
|
|
933
|
+
_stamp_completion_metadata(plan, file_path.parent.parent.parent, now_iso)
|
|
934
|
+
|
|
935
|
+
# Write updated JSON
|
|
936
|
+
updated_json = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
937
|
+
file_path.write_text(updated_json, encoding="utf-8")
|
|
938
|
+
|
|
939
|
+
# Move file if target folder differs from current
|
|
940
|
+
if target_folder is not None:
|
|
941
|
+
vbrief_root = file_path.parent.parent
|
|
942
|
+
dest_dir = vbrief_root / target_folder
|
|
943
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
944
|
+
dest_path = dest_dir / file_path.name
|
|
945
|
+
# Path.replace() is portable; Path.rename() raises FileExistsError on Windows
|
|
946
|
+
file_path.replace(dest_path)
|
|
947
|
+
# Keep decomposed parent <-> child linkage intact (#1485): a moved
|
|
948
|
+
# decomposed child leaves its parent epic's x-vbrief/plan reference
|
|
949
|
+
# pointing at the child's old path, which fails the D4 bidirectional-
|
|
950
|
+
# linkage check. Rewrite the parent's forward reference to the new
|
|
951
|
+
# path. Best-effort (never raises) -- the move has already succeeded.
|
|
952
|
+
update_decomposed_parent_back_references(data, file_path, dest_path, vbrief_root)
|
|
953
|
+
# Symmetric direction (#1487): a moved decompose-created epic parent
|
|
954
|
+
# leaves each child's planRef back-pointer naming the parent's old
|
|
955
|
+
# path, which fails the D4 backward-linkage check. Rewrite every
|
|
956
|
+
# child's planRef to the parent's new path. Same best-effort contract.
|
|
957
|
+
update_decomposed_child_back_references(data, file_path, dest_path, vbrief_root)
|
|
958
|
+
_sync_project_definition_after_scope_move(
|
|
959
|
+
data, file_path, dest_path, vbrief_root, target_status
|
|
960
|
+
)
|
|
961
|
+
_move_labels = {
|
|
962
|
+
"promote": "Promoted",
|
|
963
|
+
"activate": "Activated",
|
|
964
|
+
"complete": "Completed",
|
|
965
|
+
"fail": "Failed",
|
|
966
|
+
"cancel": "Cancelled",
|
|
967
|
+
"restore": "Restored",
|
|
968
|
+
}
|
|
969
|
+
action_label = _move_labels.get(action, action.capitalize())
|
|
970
|
+
return True, (
|
|
971
|
+
f"{action_label} {file_path.name}: "
|
|
972
|
+
f"{current_folder}/ -> {target_folder}/ (status: {target_status})"
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
# File stays in place (block/unblock)
|
|
976
|
+
_stay_labels = {"block": "Blocked", "unblock": "Unblocked"}
|
|
977
|
+
action_label = _stay_labels.get(action, action.capitalize())
|
|
978
|
+
return True, (
|
|
979
|
+
f"{action_label} {file_path.name}: " f"stays in {current_folder}/ (status: {target_status})"
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
# ---------------------------------------------------------------------------
|
|
984
|
+
# CLI entry point
|
|
985
|
+
# ---------------------------------------------------------------------------
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
989
|
+
parser = argparse.ArgumentParser(
|
|
990
|
+
prog="scope_lifecycle.py",
|
|
991
|
+
description=(
|
|
992
|
+
"Deterministic vBRIEF scope lifecycle transitions. "
|
|
993
|
+
"Relative <file> paths resolve against --project-root / "
|
|
994
|
+
"$DEFT_PROJECT_ROOT / the nearest vbrief|.git ancestor -- "
|
|
995
|
+
"never deft/ (#535)."
|
|
996
|
+
),
|
|
997
|
+
)
|
|
998
|
+
parser.add_argument(
|
|
999
|
+
"action",
|
|
1000
|
+
choices=sorted(TRANSITIONS),
|
|
1001
|
+
help="Lifecycle transition to perform.",
|
|
1002
|
+
)
|
|
1003
|
+
parser.add_argument(
|
|
1004
|
+
"file",
|
|
1005
|
+
help=(
|
|
1006
|
+
"Path to the vBRIEF file. Absolute paths are used as-is; "
|
|
1007
|
+
"relative paths resolve against --project-root / "
|
|
1008
|
+
"$DEFT_PROJECT_ROOT / the detected consumer project root."
|
|
1009
|
+
),
|
|
1010
|
+
)
|
|
1011
|
+
parser.add_argument(
|
|
1012
|
+
"--project-root",
|
|
1013
|
+
default=None,
|
|
1014
|
+
help=(
|
|
1015
|
+
"Consumer project root. Overrides $DEFT_PROJECT_ROOT and the "
|
|
1016
|
+
"sentinel search. Required when the invocation CWD is not "
|
|
1017
|
+
"inside a project tree (falls back to a loud error instead "
|
|
1018
|
+
"of silently using deft/)."
|
|
1019
|
+
),
|
|
1020
|
+
)
|
|
1021
|
+
parser.add_argument(
|
|
1022
|
+
"--force",
|
|
1023
|
+
action="store_true",
|
|
1024
|
+
help=(
|
|
1025
|
+
"Override the WIP cap on ``promote`` (#1124 / D4 of #1119). "
|
|
1026
|
+
"Emits a stderr warning naming the breached cap + current "
|
|
1027
|
+
"count, and records an audit-log entry tagged "
|
|
1028
|
+
"``wip_cap_override`` to vbrief/.eval/scope-lifecycle.jsonl. "
|
|
1029
|
+
"No-op on any other action."
|
|
1030
|
+
),
|
|
1031
|
+
)
|
|
1032
|
+
return parser
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _resolve_file_path(raw: str, cli_project_root: str | None) -> tuple[Path | None, str | None]:
|
|
1036
|
+
"""Resolve *raw* to an absolute Path using the project-root rules.
|
|
1037
|
+
|
|
1038
|
+
Returns ``(path, None)`` on success, ``(None, error_message)`` on
|
|
1039
|
+
failure. ``error_message`` is a single actionable line ready for
|
|
1040
|
+
stderr.
|
|
1041
|
+
"""
|
|
1042
|
+
# Some invocations (e.g. ``task scope:promote`` with no CLI_ARGS) end
|
|
1043
|
+
# up passing a trailing "/" to this script -- reject that cleanly.
|
|
1044
|
+
stripped = raw.strip().rstrip("\\/") if raw else ""
|
|
1045
|
+
if not stripped:
|
|
1046
|
+
return None, (
|
|
1047
|
+
"No vBRIEF file path provided. "
|
|
1048
|
+
"Usage: scope_lifecycle.py <action> <file> [--project-root PATH]"
|
|
1049
|
+
)
|
|
1050
|
+
candidate = Path(stripped)
|
|
1051
|
+
if candidate.is_absolute():
|
|
1052
|
+
return candidate.resolve(), None
|
|
1053
|
+
|
|
1054
|
+
project_root = resolve_project_root(cli_project_root)
|
|
1055
|
+
if project_root is None:
|
|
1056
|
+
return None, (
|
|
1057
|
+
f"Cannot resolve relative path {stripped!r}: no project root "
|
|
1058
|
+
"detected. Pass --project-root PATH, set $DEFT_PROJECT_ROOT, "
|
|
1059
|
+
"or run from inside a directory tree that contains vbrief/ or "
|
|
1060
|
+
".git/ (#535)."
|
|
1061
|
+
)
|
|
1062
|
+
return (project_root / stripped).resolve(), None
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1066
|
+
# N10 (#1150): structured --help via scripts/triage_help.REGISTRY.
|
|
1067
|
+
from triage_help import intercept_help
|
|
1068
|
+
|
|
1069
|
+
rc = intercept_help("scope_lifecycle", argv)
|
|
1070
|
+
if rc is not None:
|
|
1071
|
+
return rc
|
|
1072
|
+
parser = _build_parser()
|
|
1073
|
+
# argparse prints its own usage; convert its SystemExit(2) into our
|
|
1074
|
+
# documented usage-error exit code (2).
|
|
1075
|
+
try:
|
|
1076
|
+
args = parser.parse_args(argv)
|
|
1077
|
+
except SystemExit as exc:
|
|
1078
|
+
return int(exc.code) if isinstance(exc.code, int) else 2
|
|
1079
|
+
|
|
1080
|
+
file_path, error = _resolve_file_path(args.file, args.project_root)
|
|
1081
|
+
if error is not None:
|
|
1082
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
1083
|
+
return 2
|
|
1084
|
+
|
|
1085
|
+
# WIP cap enforcement on ``promote`` (#1124 / D4 of #1119). Other
|
|
1086
|
+
# actions are unaffected. The check is gated on a resolvable
|
|
1087
|
+
# project root -- without one we degrade safely to legacy behaviour
|
|
1088
|
+
# (no cap enforcement, mirrors the D4-absent rolling-merge
|
|
1089
|
+
# tolerance branch).
|
|
1090
|
+
cap_check: WipCapCheck | None = None
|
|
1091
|
+
if args.action == "promote":
|
|
1092
|
+
project_root_for_cap = resolve_project_root(args.project_root)
|
|
1093
|
+
if project_root_for_cap is not None:
|
|
1094
|
+
cap_check = check_wip_cap(project_root_for_cap, force=args.force)
|
|
1095
|
+
if not cap_check.allowed:
|
|
1096
|
+
print(format_wip_cap_refusal(cap_check), file=sys.stderr)
|
|
1097
|
+
return 1
|
|
1098
|
+
|
|
1099
|
+
ok, message = run_transition(args.action, file_path) # type: ignore[arg-type]
|
|
1100
|
+
if ok:
|
|
1101
|
+
# Post-promote: surface the --force override on stderr + audit-log
|
|
1102
|
+
# entry. Done after the transition succeeds so the audit entry
|
|
1103
|
+
# references the brief in its new home.
|
|
1104
|
+
if args.action == "promote" and cap_check is not None and cap_check.force_override:
|
|
1105
|
+
project_root_for_audit = resolve_project_root(args.project_root)
|
|
1106
|
+
if project_root_for_audit is not None:
|
|
1107
|
+
# File has moved to ``pending/`` -- locate the new path.
|
|
1108
|
+
new_path = project_root_for_audit / "vbrief" / "pending" / file_path.name # type: ignore[union-attr]
|
|
1109
|
+
_record_wip_cap_override(new_path, project_root_for_audit, cap_check)
|
|
1110
|
+
print(
|
|
1111
|
+
(
|
|
1112
|
+
f"\u26a0 WIP cap exceeded (count={cap_check.count}, "
|
|
1113
|
+
f"cap={cap_check.cap}); promote allowed via --force. "
|
|
1114
|
+
"audit: vbrief/.eval/scope-lifecycle.jsonl entry tagged "
|
|
1115
|
+
"wip_cap_override (#1124)."
|
|
1116
|
+
),
|
|
1117
|
+
file=sys.stderr,
|
|
1118
|
+
)
|
|
1119
|
+
print(message)
|
|
1120
|
+
return 0
|
|
1121
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
1122
|
+
return 1
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
if __name__ == "__main__":
|
|
1126
|
+
sys.exit(main())
|