@deftai/directive-content 0.59.0 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/.githooks/pre-push +10 -9
  2. package/Taskfile.yml +48 -58
  3. package/UPGRADING.md +1 -1
  4. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  5. package/docs/directive-lifecycle.md +73 -0
  6. package/docs/getting-started.md +5 -1
  7. package/package.json +3 -3
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scm/github.md +20 -2
  10. package/tasks/change.yml +16 -31
  11. package/tasks/ci.yml +8 -0
  12. package/tasks/commit.yml +12 -19
  13. package/tasks/core.yml +10 -0
  14. package/tasks/engine.yml +42 -0
  15. package/tasks/framework.yml +3 -0
  16. package/tasks/install.yml +20 -19
  17. package/tasks/migrate.yml +26 -15
  18. package/tasks/project.yml +16 -0
  19. package/tasks/toolchain.yml +15 -5
  20. package/tasks/vbrief.yml +4 -3
  21. package/tasks/verify.yml +12 -14
  22. package/scripts/_agents_md.py +0 -494
  23. package/scripts/_cache_fetch.py +0 -635
  24. package/scripts/_cache_quota.py +0 -529
  25. package/scripts/_cache_refresh.py +0 -163
  26. package/scripts/_cache_validate.py +0 -209
  27. package/scripts/_content_root.py +0 -42
  28. package/scripts/_doctor_state.py +0 -277
  29. package/scripts/_event_detect.py +0 -305
  30. package/scripts/_events.py +0 -514
  31. package/scripts/_lifecycle_hygiene.py +0 -568
  32. package/scripts/_pathspec.py +0 -91
  33. package/scripts/_policy_show_cli.py +0 -266
  34. package/scripts/_precutover.py +0 -92
  35. package/scripts/_project_context.py +0 -224
  36. package/scripts/_project_definition_io.py +0 -164
  37. package/scripts/_relocate_snapshot.py +0 -209
  38. package/scripts/_relocate_states.py +0 -343
  39. package/scripts/_resolve_preflight_path.py +0 -152
  40. package/scripts/_safe_subprocess.py +0 -167
  41. package/scripts/_session_start_hook.py +0 -205
  42. package/scripts/_sor_gate_diff.py +0 -365
  43. package/scripts/_stdio_utf8.py +0 -59
  44. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  45. package/scripts/_triage_classify_cli.py +0 -122
  46. package/scripts/_triage_queue_cli.py +0 -625
  47. package/scripts/_triage_scope_cli.py +0 -343
  48. package/scripts/_triage_scope_drift_cli.py +0 -121
  49. package/scripts/_triage_scope_ignores.py +0 -286
  50. package/scripts/_triage_scope_milestone.py +0 -432
  51. package/scripts/_triage_scope_mutations.py +0 -337
  52. package/scripts/_triage_scope_renderers.py +0 -207
  53. package/scripts/_triage_smoketest_stages.py +0 -674
  54. package/scripts/_triage_subscribe_cli.py +0 -140
  55. package/scripts/_triage_welcome_cli.py +0 -421
  56. package/scripts/_vbrief_build.py +0 -239
  57. package/scripts/_vbrief_fidelity.py +0 -479
  58. package/scripts/_vbrief_legacy.py +0 -589
  59. package/scripts/_vbrief_reconciliation.py +0 -883
  60. package/scripts/_vbrief_routing.py +0 -277
  61. package/scripts/_vbrief_safety.py +0 -778
  62. package/scripts/_vbrief_sources.py +0 -312
  63. package/scripts/_vbrief_speckit.py +0 -262
  64. package/scripts/_vbrief_story_quality.py +0 -353
  65. package/scripts/_vbrief_validation.py +0 -299
  66. package/scripts/build_dist.py +0 -412
  67. package/scripts/cache.py +0 -1078
  68. package/scripts/cache_scanner.py +0 -745
  69. package/scripts/candidates_log.py +0 -432
  70. package/scripts/capacity_backfill.py +0 -680
  71. package/scripts/capacity_show.py +0 -653
  72. package/scripts/ci_local.py +0 -689
  73. package/scripts/code_structure_validate.py +0 -765
  74. package/scripts/codebase_default_extractor.py +0 -495
  75. package/scripts/codebase_map.py +0 -304
  76. package/scripts/codebase_map_fresh.py +0 -104
  77. package/scripts/codebase_projection_registry.py +0 -94
  78. package/scripts/codebase_provider.py +0 -582
  79. package/scripts/doctor.py +0 -2552
  80. package/scripts/framework_commands.py +0 -505
  81. package/scripts/gh_rest.py +0 -882
  82. package/scripts/github_auth_modes.py +0 -437
  83. package/scripts/github_body.py +0 -292
  84. package/scripts/ip_risk.py +0 -531
  85. package/scripts/issue_emit.py +0 -670
  86. package/scripts/issue_ingest.py +0 -1064
  87. package/scripts/migrate_preflight.py +0 -418
  88. package/scripts/migrate_vbrief.py +0 -2677
  89. package/scripts/monitor_pr.py +0 -401
  90. package/scripts/pack_migrate_lessons.py +0 -336
  91. package/scripts/pack_migrate_patterns.py +0 -254
  92. package/scripts/pack_migrate_rules.py +0 -350
  93. package/scripts/pack_migrate_skills.py +0 -423
  94. package/scripts/pack_migrate_strategies.py +0 -311
  95. package/scripts/pack_migrate_swarm_spec.py +0 -250
  96. package/scripts/pack_render.py +0 -434
  97. package/scripts/packs_slice.py +0 -712
  98. package/scripts/platform_capabilities.py +0 -336
  99. package/scripts/policy.py +0 -2826
  100. package/scripts/policy_set.py +0 -324
  101. package/scripts/pr_check_closing_keywords.py +0 -524
  102. package/scripts/pr_check_protected_issues.py +0 -267
  103. package/scripts/pr_merge_readiness.py +0 -1004
  104. package/scripts/pr_wait_mergeable.py +0 -669
  105. package/scripts/prd_render.py +0 -159
  106. package/scripts/preflight_architecture_sor.py +0 -974
  107. package/scripts/preflight_branch.py +0 -289
  108. package/scripts/preflight_cache.py +0 -974
  109. package/scripts/preflight_gh.py +0 -721
  110. package/scripts/preflight_implementation.py +0 -272
  111. package/scripts/preflight_story_start.py +0 -838
  112. package/scripts/preflight_wip_cap.py +0 -149
  113. package/scripts/probe_session.py +0 -545
  114. package/scripts/project_render.py +0 -293
  115. package/scripts/quarantine_ext.py +0 -237
  116. package/scripts/reconcile_issues.py +0 -1442
  117. package/scripts/refresh-path.ps1 +0 -107
  118. package/scripts/release.py +0 -2030
  119. package/scripts/release_e2e.py +0 -1011
  120. package/scripts/release_publish.py +0 -486
  121. package/scripts/release_rollback.py +0 -980
  122. package/scripts/relocate.py +0 -1034
  123. package/scripts/resolve_changelog_unreleased.py +0 -667
  124. package/scripts/resolve_version.py +0 -490
  125. package/scripts/resume_conditions.py +0 -706
  126. package/scripts/ritual_sentinel.py +0 -609
  127. package/scripts/roadmap_render.py +0 -635
  128. package/scripts/rule_ownership_lint.py +0 -325
  129. package/scripts/scm.py +0 -591
  130. package/scripts/scope_audit_log.py +0 -387
  131. package/scripts/scope_decompose.py +0 -654
  132. package/scripts/scope_demote.py +0 -509
  133. package/scripts/scope_lifecycle.py +0 -1126
  134. package/scripts/scope_undo.py +0 -772
  135. package/scripts/session_start.py +0 -406
  136. package/scripts/setup_ghx.py +0 -339
  137. package/scripts/setup_windows.ps1 +0 -220
  138. package/scripts/slice_audit.py +0 -585
  139. package/scripts/slice_record.py +0 -530
  140. package/scripts/slice_record_existing.py +0 -692
  141. package/scripts/slug_normalize.py +0 -178
  142. package/scripts/spec_render.py +0 -477
  143. package/scripts/spec_validate.py +0 -238
  144. package/scripts/subagent_monitor.py +0 -658
  145. package/scripts/swarm_complete_cohort.py +0 -644
  146. package/scripts/swarm_launch.py +0 -1206
  147. package/scripts/swarm_readiness.py +0 -554
  148. package/scripts/swarm_verify_review_clean.py +0 -438
  149. package/scripts/swarm_worktrees.py +0 -497
  150. package/scripts/toolchain-check.py +0 -52
  151. package/scripts/triage_actions.py +0 -871
  152. package/scripts/triage_bootstrap.py +0 -1153
  153. package/scripts/triage_bulk.py +0 -630
  154. package/scripts/triage_classify.py +0 -932
  155. package/scripts/triage_help.py +0 -1685
  156. package/scripts/triage_queue.py +0 -1944
  157. package/scripts/triage_reconcile.py +0 -581
  158. package/scripts/triage_refresh.py +0 -643
  159. package/scripts/triage_scope.py +0 -999
  160. package/scripts/triage_scope_drift.py +0 -575
  161. package/scripts/triage_smoketest.py +0 -396
  162. package/scripts/triage_subscribe.py +0 -399
  163. package/scripts/triage_summary.py +0 -1011
  164. package/scripts/triage_welcome.py +0 -1178
  165. package/scripts/ts_check_lane.py +0 -86
  166. package/scripts/validate-links.py +0 -64
  167. package/scripts/validate_strategy_output.py +0 -212
  168. package/scripts/vbrief_activate.py +0 -228
  169. package/scripts/vbrief_migrate_conformance.py +0 -368
  170. package/scripts/vbrief_reconcile_graph.py +0 -306
  171. package/scripts/vbrief_reconcile_labels.py +0 -460
  172. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  173. package/scripts/vbrief_validate.py +0 -1144
  174. package/scripts/verify-stubs.py +0 -61
  175. package/scripts/verify_capacity.py +0 -160
  176. package/scripts/verify_encoding.py +0 -699
  177. package/scripts/verify_hooks_installed.py +0 -206
  178. package/scripts/verify_investigation.py +0 -360
  179. package/scripts/verify_judgment_gates.py +0 -827
  180. package/scripts/verify_no_task_runtime.py +0 -171
  181. package/scripts/verify_scm_boundary.py +0 -509
  182. package/scripts/verify_session_ritual.py +0 -389
  183. package/scripts/verify_tools.py +0 -426
  184. package/scripts/verify_vbrief_conformance.py +0 -478
@@ -1,644 +0,0 @@
1
- #!/usr/bin/env python3
2
- """swarm_complete_cohort.py -- Deterministic cohort completion sweep (#1487).
3
-
4
- When a ``deft-directive-swarm`` cohort finishes (all worker PRs merged), the
5
- cohort's story vBRIEFs are left stranded in ``vbrief/active/`` and their
6
- decompose-created epic parents linger in ``vbrief/pending/``. Nothing in the
7
- swarm flow swept them to ``completed/``. This helper IS that sweep: it is the
8
- durable mechanism the swarm skill's Phase 6 invokes so a finished swarm leaves
9
- no stranded vBRIEFs.
10
-
11
- What it does
12
- ------------
13
- Stage 1 -- Complete cohort stories: for each cohort story vBRIEF currently in
14
- ``vbrief/active/``, run the ``complete`` lifecycle transition (``active/`` ->
15
- ``completed/``, status ``completed``). A story already in ``completed/`` /
16
- ``cancelled/`` is a no-op.
17
-
18
- Stage 2 -- Complete epic parents: discover the decompose-created epic parents
19
- from the cohort stories' ``planRef`` back-pointers and complete each parent
20
- once ALL of its ``x-vbrief/plan`` children are settled (in ``completed/`` or
21
- ``cancelled/``). A parent in ``pending/`` is bridged ``activate`` ->
22
- ``complete``; a parent in ``active/`` is completed directly; a parent already
23
- terminal is a no-op. The sweep iterates to a fixpoint so nested decomposition
24
- (phase -> epic -> story) collapses bottom-up: completing the leaf stories makes
25
- their epics completable, which in turn makes a parent phase completable.
26
-
27
- D4 linkage stays green automatically
28
- ------------------------------------
29
- Every move routes through ``scripts/scope_lifecycle.py``. Child moves keep the
30
- parent's forward ``x-vbrief/plan`` references fresh (#1485); parent moves keep
31
- each child's ``planRef`` back-pointer fresh (#1487, the symmetric complement).
32
- So ``task vbrief:validate`` stays green after the sweep with NO manual
33
- reference repair in this script.
34
-
35
- Usage
36
- -----
37
- # Explicit cohort story paths
38
- task swarm:complete-cohort -- vbrief/active/2026-06-03-a.vbrief.json \
39
- vbrief/active/2026-06-03-b.vbrief.json
40
-
41
- # Or a glob over the cohort's active stories
42
- task swarm:complete-cohort -- --cohort 'vbrief/active/*.vbrief.json'
43
-
44
- # Preview without mutating anything
45
- task swarm:complete-cohort -- --cohort 'vbrief/active/*.vbrief.json' --dry-run
46
-
47
- # JSON output for a parent monitor agent
48
- task swarm:complete-cohort -- --cohort 'vbrief/active/*.vbrief.json' --json
49
-
50
- Exit codes
51
- ----------
52
- 0 -- sweep completed; every eligible transition succeeded (no-ops are fine)
53
- 1 -- one or more lifecycle transitions failed (per-item diagnostics printed)
54
- 2 -- config error (empty cohort, missing project root / vbrief dir)
55
-
56
- Pure stdlib. The lifecycle state machine and its reference-maintenance helpers
57
- are imported from ``scripts/scope_lifecycle.py`` so this sweep and the
58
- canonical lifecycle verbs share one source of truth.
59
- """
60
-
61
- from __future__ import annotations
62
-
63
- import argparse
64
- import glob
65
- import json
66
- import sys
67
- from dataclasses import asdict, dataclass, field
68
- from pathlib import Path
69
-
70
- # Make sibling scripts importable both when run as __main__ and when imported
71
- # by tests.
72
- sys.path.insert(0, str(Path(__file__).resolve().parent))
73
-
74
- try:
75
- from _stdio_utf8 import reconfigure_stdio # noqa: E402
76
-
77
- reconfigure_stdio()
78
- except ImportError: # pragma: no cover -- optional in some test contexts
79
- pass
80
-
81
- # Single source of truth for the lifecycle state machine + the #1485 / #1487
82
- # decomposed reference-maintenance helpers. Importing the module (rather than
83
- # duplicating the move logic) keeps the sweep in lockstep with the canonical
84
- # verbs -- a fix to reference maintenance lands in both surfaces at once.
85
- import scope_lifecycle as _sl # noqa: E402
86
-
87
- EXIT_OK = 0
88
- EXIT_FAILED = 1
89
- EXIT_CONFIG_ERROR = 2
90
-
91
- # A child is "settled" (does not block its parent's completion) when it has
92
- # reached a terminal lifecycle folder.
93
- TERMINAL_FOLDERS = ("completed", "cancelled")
94
-
95
- # Bound the parent fixpoint so a malformed planRef cycle cannot loop forever.
96
- # The bound is generous: real decomposition nests at most a handful of levels.
97
- _MAX_FIXPOINT_PASSES = 50
98
-
99
-
100
- # ---------------------------------------------------------------------------
101
- # Result model
102
- # ---------------------------------------------------------------------------
103
-
104
-
105
- @dataclass
106
- class TransitionRecord:
107
- """One vBRIEF the sweep acted on (or decided to skip)."""
108
-
109
- kind: str # "story" | "epic"
110
- path: str # original (pre-sweep) path, relative to project root when possible
111
- action: str # "complete" | "activate+complete" | "noop" | "skip" | "failed"
112
- ok: bool
113
- detail: str = ""
114
-
115
-
116
- @dataclass
117
- class SweepResult:
118
- """Aggregate sweep verdict."""
119
-
120
- project_root: str
121
- dry_run: bool
122
- stories: list[TransitionRecord] = field(default_factory=list)
123
- parents: list[TransitionRecord] = field(default_factory=list)
124
- errors: list[str] = field(default_factory=list)
125
-
126
- @property
127
- def ok(self) -> bool:
128
- return not self.errors and all(
129
- r.ok for r in (*self.stories, *self.parents)
130
- )
131
-
132
- def to_dict(self) -> dict:
133
- return {
134
- "project_root": self.project_root,
135
- "dry_run": self.dry_run,
136
- "ok": self.ok,
137
- "stories": [asdict(r) for r in self.stories],
138
- "parents": [asdict(r) for r in self.parents],
139
- "errors": list(self.errors),
140
- }
141
-
142
-
143
- # ---------------------------------------------------------------------------
144
- # Helpers
145
- # ---------------------------------------------------------------------------
146
-
147
-
148
- def resolve_cohort_paths(
149
- positional: list[str],
150
- cohort_globs: list[str],
151
- project_root: Path,
152
- ) -> tuple[list[Path], list[str]]:
153
- """Resolve cohort story paths from positional args + ``--cohort`` globs.
154
-
155
- Relative paths/globs resolve against *project_root*. Returns a
156
- de-duplicated, order-preserving list of resolved ``.vbrief.json`` paths
157
- AND a list of soft errors (a glob that matched nothing, a path that does
158
- not exist) so the caller can surface partial-resolution problems.
159
- """
160
- resolved: list[Path] = []
161
- seen: set[Path] = set()
162
- errors: list[str] = []
163
-
164
- def _add(path: Path) -> None:
165
- rp = path.resolve()
166
- if rp in seen:
167
- return
168
- seen.add(rp)
169
- resolved.append(rp)
170
-
171
- for raw in positional:
172
- candidate = Path(raw)
173
- if not candidate.is_absolute():
174
- candidate = project_root / raw
175
- if not candidate.is_file():
176
- errors.append(f"path does not exist: {raw}")
177
- continue
178
- _add(candidate)
179
-
180
- for pattern in cohort_globs:
181
- abs_pattern = pattern
182
- if not Path(pattern).is_absolute():
183
- abs_pattern = str(project_root / pattern)
184
- matched = sorted(Path(p) for p in glob.glob(abs_pattern, recursive=True))
185
- if not matched:
186
- errors.append(f"glob matched no files: {pattern!r}")
187
- continue
188
- for p in matched:
189
- if p.is_file():
190
- _add(p)
191
-
192
- return resolved, errors
193
-
194
-
195
- def _rel(path: Path, project_root: Path) -> str:
196
- """Display path relative to project root when possible."""
197
- try:
198
- return path.resolve().relative_to(project_root.resolve()).as_posix()
199
- except ValueError:
200
- return path.resolve().as_posix()
201
-
202
-
203
- def _load_plan(path: Path) -> dict | None:
204
- """Load a vBRIEF's ``plan`` object, or None if unreadable/malformed."""
205
- try:
206
- data = json.loads(path.read_text(encoding="utf-8"))
207
- except (OSError, json.JSONDecodeError):
208
- return None
209
- if not isinstance(data, dict):
210
- return None
211
- plan = data.get("plan")
212
- return plan if isinstance(plan, dict) else None
213
-
214
-
215
- def _child_is_settled(
216
- child_resolved: Path,
217
- settled: set[Path],
218
- dry_run: bool,
219
- ) -> bool:
220
- """Return whether a child vBRIEF is terminal (does not block the parent).
221
-
222
- In real mode the filesystem is ground truth: the child is settled when it
223
- lives in a terminal lifecycle folder. In dry-run nothing has moved, so we
224
- consult the virtual *settled* set the sweep accumulates (current
225
- terminal files + the stories/epics this run would complete).
226
- """
227
- if dry_run:
228
- return child_resolved in settled
229
- folder = _sl.detect_lifecycle_folder(child_resolved)
230
- return child_resolved.is_file() and folder in TERMINAL_FOLDERS
231
-
232
-
233
- def _all_children_settled(
234
- parent_plan: dict,
235
- vbrief_dir: Path,
236
- settled: set[Path],
237
- dry_run: bool,
238
- ) -> bool:
239
- """True when the parent has >=1 child ref and every child is settled."""
240
- child_uris = _sl.collect_child_uris(parent_plan)
241
- if not child_uris:
242
- return False
243
- for uri in child_uris:
244
- child_path = _sl.resolve_vbrief_ref(uri, vbrief_dir)
245
- if child_path is None:
246
- return False
247
- if not _child_is_settled(child_path, settled, dry_run):
248
- return False
249
- return True
250
-
251
-
252
- def _parent_candidates_from(
253
- plan: dict,
254
- vbrief_dir: Path,
255
- ) -> list[Path]:
256
- """Resolve a vBRIEF's planRef back-pointers to existing parent paths."""
257
- out: list[Path] = []
258
- for plan_ref in _sl.collect_plan_refs(plan):
259
- parent = _sl.resolve_vbrief_ref(plan_ref, vbrief_dir)
260
- if parent is not None and parent.is_file():
261
- out.append(parent.resolve())
262
- return out
263
-
264
-
265
- # ---------------------------------------------------------------------------
266
- # Sweep stages
267
- # ---------------------------------------------------------------------------
268
-
269
-
270
- def _complete_story(
271
- story_path: Path,
272
- vbrief_dir: Path,
273
- project_root: Path,
274
- settled: set[Path],
275
- dry_run: bool,
276
- ) -> TransitionRecord:
277
- """Stage 1: complete one cohort story (active/ -> completed/)."""
278
- folder = _sl.detect_lifecycle_folder(story_path)
279
- rel = _rel(story_path, project_root)
280
-
281
- if folder in TERMINAL_FOLDERS:
282
- settled.add(story_path.resolve())
283
- return TransitionRecord(
284
- kind="story",
285
- path=rel,
286
- action="noop",
287
- ok=True,
288
- detail=f"already in {folder}/",
289
- )
290
- if folder != "active":
291
- return TransitionRecord(
292
- kind="story",
293
- path=rel,
294
- action="skip",
295
- ok=True,
296
- detail=(
297
- f"not in active/ (in {folder}/); cohort completion only "
298
- "sweeps active stories"
299
- ),
300
- )
301
-
302
- if dry_run:
303
- settled.add(story_path.resolve())
304
- return TransitionRecord(
305
- kind="story",
306
- path=rel,
307
- action="complete",
308
- ok=True,
309
- detail="would complete active/ -> completed/",
310
- )
311
-
312
- ok, message = _sl.run_transition("complete", story_path)
313
- if ok:
314
- completed_path = (vbrief_dir / "completed" / story_path.name).resolve()
315
- settled.add(completed_path)
316
- return TransitionRecord(
317
- kind="story",
318
- path=rel,
319
- action="complete" if ok else "failed",
320
- ok=ok,
321
- detail=message,
322
- )
323
-
324
-
325
- def _complete_parent(
326
- parent_path: Path,
327
- vbrief_dir: Path,
328
- project_root: Path,
329
- settled: set[Path],
330
- dry_run: bool,
331
- ) -> TransitionRecord:
332
- """Stage 2: complete one epic parent, bridging pending/ via activate.
333
-
334
- Caller guarantees the parent's children are all settled. Returns a record
335
- describing the action taken (or skipped).
336
- """
337
- folder = _sl.detect_lifecycle_folder(parent_path)
338
- rel = _rel(parent_path, project_root)
339
-
340
- if folder in TERMINAL_FOLDERS:
341
- settled.add(parent_path.resolve())
342
- return TransitionRecord(
343
- kind="epic",
344
- path=rel,
345
- action="noop",
346
- ok=True,
347
- detail=f"already in {folder}/",
348
- )
349
- if folder == "proposed":
350
- return TransitionRecord(
351
- kind="epic",
352
- path=rel,
353
- action="skip",
354
- ok=True,
355
- detail=(
356
- "parent in proposed/; promote it before the sweep can "
357
- "complete it"
358
- ),
359
- )
360
- if folder not in ("pending", "active"):
361
- return TransitionRecord(
362
- kind="epic",
363
- path=rel,
364
- action="skip",
365
- ok=True,
366
- detail=f"unexpected folder {folder}/",
367
- )
368
-
369
- if dry_run:
370
- settled.add(parent_path.resolve())
371
- action = "activate+complete" if folder == "pending" else "complete"
372
- return TransitionRecord(
373
- kind="epic",
374
- path=rel,
375
- action=action,
376
- ok=True,
377
- detail=f"would complete {folder}/ -> completed/",
378
- )
379
-
380
- # Real mode: bridge pending/ -> active/ first, then complete.
381
- current = parent_path
382
- action = "complete"
383
- if folder == "pending":
384
- action = "activate+complete"
385
- ok, message = _sl.run_transition("activate", current)
386
- if not ok:
387
- return TransitionRecord(
388
- kind="epic",
389
- path=rel,
390
- action="failed",
391
- ok=False,
392
- detail=f"activate failed: {message}",
393
- )
394
- current = vbrief_dir / "active" / parent_path.name
395
-
396
- ok, message = _sl.run_transition("complete", current)
397
- if ok:
398
- settled.add((vbrief_dir / "completed" / parent_path.name).resolve())
399
- return TransitionRecord(
400
- kind="epic",
401
- path=rel,
402
- action=action if ok else "failed",
403
- ok=ok,
404
- detail=message,
405
- )
406
-
407
-
408
- def sweep_cohort(
409
- story_paths: list[Path],
410
- project_root: Path,
411
- dry_run: bool,
412
- ) -> SweepResult:
413
- """Run the full cohort completion sweep.
414
-
415
- Stage 1 completes the cohort stories; stage 2 completes their epic parents
416
- to a fixpoint. Returns a structured :class:`SweepResult`.
417
- """
418
- vbrief_dir = project_root / "vbrief"
419
- result = SweepResult(
420
- project_root=str(project_root.resolve()),
421
- dry_run=dry_run,
422
- )
423
-
424
- # ``settled`` tracks terminal child identities for the dry-run fixpoint
425
- # (real mode reads the filesystem directly). Seed it with everything
426
- # currently in a terminal folder so an idempotent re-run / partially-swept
427
- # cohort evaluates parents correctly.
428
- settled: set[Path] = set()
429
- for term in TERMINAL_FOLDERS:
430
- term_dir = vbrief_dir / term
431
- if term_dir.is_dir():
432
- for f in term_dir.glob("*.vbrief.json"):
433
- settled.add(f.resolve())
434
-
435
- # Stage 1 -- complete cohort stories. Collect parent candidates BEFORE the
436
- # move (the story's planRef points at the not-yet-moved parent).
437
- parent_candidates: list[Path] = []
438
- parent_seen: set[Path] = set()
439
- for story_path in story_paths:
440
- plan = _load_plan(story_path)
441
- if plan is not None:
442
- for parent in _parent_candidates_from(plan, vbrief_dir):
443
- if parent not in parent_seen:
444
- parent_seen.add(parent)
445
- parent_candidates.append(parent)
446
- result.stories.append(
447
- _complete_story(
448
- story_path, vbrief_dir, project_root, settled, dry_run
449
- )
450
- )
451
-
452
- # Stage 2 -- complete epic parents to a fixpoint. Re-evaluate every pass:
453
- # completing one epic can make its own parent (a phase) completable, and in
454
- # real mode #1487 keeps the grandparent's forward ref fresh across the move.
455
- finalized: set[Path] = set()
456
- passes = 0
457
- while passes < _MAX_FIXPOINT_PASSES:
458
- passes += 1
459
- progressed = False
460
- for candidate in list(parent_candidates):
461
- if candidate in finalized:
462
- continue
463
- parent_plan = _load_plan(candidate)
464
- if parent_plan is None:
465
- # Unreadable / moved-out parent: finalize so we don't spin.
466
- finalized.add(candidate)
467
- continue
468
- if not _all_children_settled(
469
- parent_plan, vbrief_dir, settled, dry_run
470
- ):
471
- continue
472
- record = _complete_parent(
473
- candidate, vbrief_dir, project_root, settled, dry_run
474
- )
475
- result.parents.append(record)
476
- finalized.add(candidate)
477
- progressed = True
478
- if record.ok and record.action in (
479
- "complete",
480
- "activate+complete",
481
- ):
482
- # Enqueue the grandparent (this epic's own planRef target).
483
- for grandparent in _parent_candidates_from(
484
- parent_plan, vbrief_dir
485
- ):
486
- if (
487
- grandparent not in parent_seen
488
- and grandparent not in finalized
489
- ):
490
- parent_seen.add(grandparent)
491
- parent_candidates.append(grandparent)
492
- if not progressed:
493
- break
494
-
495
- return result
496
-
497
-
498
- # ---------------------------------------------------------------------------
499
- # CLI
500
- # ---------------------------------------------------------------------------
501
-
502
-
503
- def _build_parser() -> argparse.ArgumentParser:
504
- parser = argparse.ArgumentParser(
505
- prog="swarm_complete_cohort",
506
- description=(
507
- "Deterministic swarm cohort completion sweep (#1487). Moves each "
508
- "cohort story active/ -> completed/ and completes the "
509
- "decompose-created epic parents once all their children are "
510
- "settled, keeping task vbrief:validate green via scope_lifecycle "
511
- "reference maintenance (#1485 / #1487)."
512
- ),
513
- )
514
- parser.add_argument(
515
- "stories",
516
- nargs="*",
517
- metavar="STORY",
518
- help="Cohort story vBRIEF paths (relative to --project-root or absolute).",
519
- )
520
- parser.add_argument(
521
- "--cohort",
522
- dest="cohort_globs",
523
- action="append",
524
- default=[],
525
- metavar="GLOB",
526
- help=(
527
- "Glob over cohort story vBRIEFs (e.g. 'vbrief/active/*.vbrief.json'). "
528
- "May be passed multiple times. Unioned with positional STORY args."
529
- ),
530
- )
531
- parser.add_argument(
532
- "--project-root",
533
- default=".",
534
- help="Project root containing vbrief/ (default: current directory).",
535
- )
536
- parser.add_argument(
537
- "--dry-run",
538
- action="store_true",
539
- help="Report the transitions that would run without mutating any file.",
540
- )
541
- parser.add_argument(
542
- "--json",
543
- dest="emit_json",
544
- action="store_true",
545
- help="Emit the sweep result as a single JSON object on stdout.",
546
- )
547
- return parser
548
-
549
-
550
- def _render_text(result: SweepResult) -> None:
551
- mode = "DRY-RUN" if result.dry_run else "sweep"
552
- n_story = len(result.stories)
553
- n_epic = len(result.parents)
554
- print(
555
- f"Swarm cohort completion {mode} "
556
- f"({n_story} stor{'y' if n_story == 1 else 'ies'}, "
557
- f"{n_epic} epic parent{'' if n_epic == 1 else 's'})"
558
- )
559
- print(f" Project root: {result.project_root}")
560
- if result.errors:
561
- print(" Resolution errors:")
562
- for err in result.errors:
563
- print(f" - {err}")
564
- if result.stories:
565
- print(" Stories:")
566
- for r in result.stories:
567
- flag = "ok" if r.ok else "FAILED"
568
- print(f" [{flag}] {r.action:<16} {r.path} -- {r.detail}")
569
- if result.parents:
570
- print(" Epic parents:")
571
- for r in result.parents:
572
- flag = "ok" if r.ok else "FAILED"
573
- print(f" [{flag}] {r.action:<16} {r.path} -- {r.detail}")
574
- print()
575
- if result.ok:
576
- completed = sum(
577
- 1
578
- for r in (*result.stories, *result.parents)
579
- if r.action in ("complete", "activate+complete")
580
- )
581
- verb = "would complete" if result.dry_run else "completed"
582
- print(f"Result: SWEEP CLEAN -- {verb} {completed} vBRIEF(s).")
583
- else:
584
- n_failed = sum(
585
- 1 for r in (*result.stories, *result.parents) if not r.ok
586
- )
587
- print(
588
- f"Result: SWEEP INCOMPLETE -- {n_failed} transition(s) failed "
589
- f"and/or {len(result.errors)} resolution error(s). See above."
590
- )
591
-
592
-
593
- def main(argv: list[str] | None = None) -> int:
594
- args = _build_parser().parse_args(argv)
595
-
596
- project_root = Path(args.project_root).resolve()
597
- if not project_root.is_dir():
598
- print(
599
- f"Error: project root does not exist: {project_root}",
600
- file=sys.stderr,
601
- )
602
- return EXIT_CONFIG_ERROR
603
- if not (project_root / "vbrief").is_dir():
604
- print(
605
- f"Error: no vbrief/ directory under project root: {project_root}",
606
- file=sys.stderr,
607
- )
608
- return EXIT_CONFIG_ERROR
609
-
610
- story_paths, resolution_errors = resolve_cohort_paths(
611
- args.stories, args.cohort_globs, project_root
612
- )
613
-
614
- if not story_paths:
615
- msg = (
616
- "Error: empty cohort. Pass one or more story vBRIEF paths as "
617
- "positional arguments and/or --cohort <glob>."
618
- )
619
- if args.emit_json:
620
- result = SweepResult(
621
- project_root=str(project_root),
622
- dry_run=args.dry_run,
623
- errors=resolution_errors or [msg],
624
- )
625
- print(json.dumps(result.to_dict(), indent=2))
626
- else:
627
- print(msg, file=sys.stderr)
628
- for err in resolution_errors:
629
- print(f" - {err}", file=sys.stderr)
630
- return EXIT_CONFIG_ERROR
631
-
632
- result = sweep_cohort(story_paths, project_root, args.dry_run)
633
- result.errors.extend(resolution_errors)
634
-
635
- if args.emit_json:
636
- print(json.dumps(result.to_dict(), indent=2))
637
- else:
638
- _render_text(result)
639
-
640
- return EXIT_OK if result.ok else EXIT_FAILED
641
-
642
-
643
- if __name__ == "__main__":
644
- sys.exit(main())