@deftai/directive-content 0.59.0 → 0.61.0

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