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