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