@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,1206 @@
1
+ #!/usr/bin/env python3
2
+ """swarm_launch.py -- deterministic headless swarm launch engine (#1387).
3
+
4
+ Turns an operator-supplied, pre-approved cohort into a ready-to-spawn
5
+ **launch manifest** so the monitor can dispatch implementation agents
6
+ without re-running Phase 0 swarm ceremony. The engine:
7
+
8
+ 1. Resolves ``--stories`` (comma-separated GitHub issue numbers, story
9
+ ids, or vBRIEF paths) and explicit ``--paths`` against ``vbrief/active``.
10
+ 2. Runs the #810 implementation-intent preflight gate and the
11
+ ``task swarm:readiness`` gate per story, exiting non-zero and naming
12
+ the FIRST failing story.
13
+ 3. Generates one per-agent dispatch envelope per story, each carrying the
14
+ #1378 allocation-context consent token (the exact five fields defined
15
+ in ``templates/agent-prompt-preamble.md`` section 2.5).
16
+ 4. Emits the launch-manifest JSON (the frozen C2 contract) to stdout and,
17
+ when ``--output`` is supplied, to a file.
18
+
19
+ Frozen contracts implemented here
20
+ ---------------------------------
21
+ - **C1** -- the ``task swarm:launch`` CLI signature
22
+ (``--stories <ids|paths> [--group <label>] [--worktree-map <path>]
23
+ [--base-branch <branch>] [--autonomous]``).
24
+ - **C2** -- the launch-manifest JSON: a JSON array of objects
25
+ ``{"story_id", "vbrief_path", "worktree_path", "branch",
26
+ "allocation_context", "runtime_mode", "github_auth_mode", ...}`` where
27
+ ``allocation_context`` is the #1378 token and ``runtime_mode`` /
28
+ ``github_auth_mode`` (#1557c) carry worker credential policy labels
29
+ (never secret token values).
30
+ - **C3** -- consumed via ``from swarm_worktrees import resolve_worktree_map``
31
+ (delivered by a sibling story; the import is guarded so this engine and
32
+ its tests build independently and the resolver is wired at integration).
33
+
34
+ Exit codes
35
+ ----------
36
+ - ``0`` -- every story resolved and passed both gates; manifest emitted.
37
+ - ``1`` -- a story could not be resolved OR a story failed a gate; the
38
+ first failing story is named on stderr. No manifest is emitted.
39
+ - ``2`` -- config / usage error (no stories supplied, malformed
40
+ ``--worktree-map`` JSON, the C3 resolver is unavailable while a
41
+ ``--worktree-map`` was supplied, or the ``--output`` write failed).
42
+
43
+ Pure stdlib. The two gate calls and the C3 resolver are exposed as
44
+ module-level seams (``run_preflight_gate``, ``run_readiness_gate``,
45
+ ``resolve_worktree_map``) so the test suite can stub them without shelling
46
+ out to ``task`` or depending on the sibling story's delivery.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import argparse
52
+ import json
53
+ import re
54
+ import sys
55
+ from collections import defaultdict
56
+ from collections.abc import Callable
57
+ from dataclasses import dataclass
58
+ from pathlib import Path
59
+ from typing import Any
60
+
61
+ # Make sibling scripts importable both when run as __main__ and when the
62
+ # module is loaded directly by the test suite.
63
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
64
+
65
+ try:
66
+ from _stdio_utf8 import reconfigure_stdio # noqa: E402
67
+
68
+ reconfigure_stdio()
69
+ except ImportError: # pragma: no cover -- optional belt-and-suspenders guard
70
+ pass
71
+
72
+ # C3 resolver (frozen signature:
73
+ # resolve_worktree_map(mapping, base_branch, create_missing=True) -> list[dict]).
74
+ # Delivered by the sibling swarm-worktree-map story and wired at integration.
75
+ # Guarded so this engine and its tests build before that story lands; tests
76
+ # inject a fake by assigning ``swarm_launch.resolve_worktree_map``.
77
+ try: # pragma: no cover -- exercised at integration, stubbed in tests
78
+ from swarm_worktrees import resolve_worktree_map # type: ignore # noqa: E402
79
+ except ImportError: # pragma: no cover
80
+ resolve_worktree_map = None # type: ignore[assignment]
81
+
82
+ # Selection ordering (#1419 Slice 2 / #987). Cohort-fill reuses the canonical
83
+ # lexicographic key from triage_queue so the queue and swarm stay in lockstep.
84
+ # Guarded so this engine + its tests build before / without that module.
85
+ try: # pragma: no cover -- core sibling in this repo
86
+ import triage_queue # type: ignore # noqa: E402
87
+ except ImportError: # pragma: no cover
88
+ triage_queue = None # type: ignore[assignment]
89
+
90
+ # Judgment-gate engine (#1419 Slice 3) for the Slice-7 clearance integration:
91
+ # a gated story rides the consent token only when its block-tier gate is
92
+ # cleared. Guarded so the engine + its tests build / run when the gate module
93
+ # is unavailable -- the gate-clearance check is then skipped (advisory anyway).
94
+ try: # pragma: no cover -- core sibling in this repo
95
+ import verify_judgment_gates as _gates # type: ignore # noqa: E402
96
+ except Exception: # noqa: BLE001
97
+ _gates = None # type: ignore[assignment]
98
+
99
+ # Durable authority-event audit helper (#1419 Slice 7), owned by
100
+ # preflight_story_start (Gate 0) so both surfaces write the same record shape.
101
+ try: # pragma: no cover -- core sibling in this repo
102
+ from preflight_story_start import append_authority_event # type: ignore # noqa: E402
103
+ except Exception: # noqa: BLE001
104
+ append_authority_event = None # type: ignore[assignment]
105
+
106
+ # Sub-agent backend policy + probe (#1531a / #1531e). Guarded so tests can
107
+ # stub the seams when the policy module is unavailable.
108
+ try: # pragma: no cover -- core sibling in this repo
109
+ from policy import ( # type: ignore # noqa: E402
110
+ SubagentBackendDescriptor,
111
+ probe_subagent_backends,
112
+ resolve_swarm_subagent_backend,
113
+ )
114
+ except Exception: # noqa: BLE001
115
+ SubagentBackendDescriptor = None # type: ignore[assignment,misc]
116
+ probe_subagent_backends = None # type: ignore[assignment]
117
+ resolve_swarm_subagent_backend = None # type: ignore[assignment]
118
+
119
+ # Runtime probe + GitHub auth-mode inference (#1557a / #1557b / #1557c).
120
+ # Guarded so tests can stub ``probe_worker_runtime_auth`` when the sibling
121
+ # modules are unavailable.
122
+ try: # pragma: no cover -- core siblings in this repo
123
+ from github_auth_modes import infer_github_auth_mode # type: ignore # noqa: E402
124
+ from platform_capabilities import get_platform_capabilities # type: ignore # noqa: E402
125
+ except Exception: # noqa: BLE001
126
+ get_platform_capabilities = None # type: ignore[assignment]
127
+ infer_github_auth_mode = None # type: ignore[assignment]
128
+
129
+ EXIT_OK = 0
130
+ EXIT_GATE_FAILED = 1
131
+ EXIT_CONFIG_ERROR = 2
132
+
133
+ DEFAULT_BASE_BRANCH = "master"
134
+
135
+ #: Gate-clearance evaluation postures (mirrors preflight_story_start /
136
+ #: verify_judgment_gates). ``advise`` (DEFAULT) surfaces an uncleared block
137
+ #: gate but still emits the manifest; ``enforce`` fails closed (exit 1).
138
+ GATE_ADVISE = "advise"
139
+ GATE_ENFORCE = "enforce"
140
+
141
+ #: Durable authority-event log file (under vbrief/.audit/) -- allocation
142
+ #: approvals + consumed gate clearances per RFC #1419 Receipts & Audit.
143
+ AUTHORITY_LOG_NAME = "authority-events.jsonl"
144
+
145
+ #: Default worker role for headless coding-worker dispatch (#1531e).
146
+ LEAF_CODING_WORKER_ROLE = "leaf-implementation"
147
+
148
+ #: Recovery command surfaced when backend policy is missing or unavailable.
149
+ SUBAGENT_BACKEND_SET_CMD = "task policy:subagent-backend -- --set {backend_id}"
150
+
151
+ #: Provider-neutral dispatch routing ids keyed by backend catalog id (#1531e).
152
+ _DISPATCH_PROVIDER_BY_BACKEND: dict[str, str] = {
153
+ "composer": "cursor",
154
+ "grok-build": "grok",
155
+ "cursor-cloud": "cursor",
156
+ }
157
+
158
+ # An x-vbrief/github-issue URI of the form
159
+ # ``https://github.com/<owner>/<repo>/issues/<N>``.
160
+ _ISSUE_URI_RE = re.compile(r"/issues/(\d+)")
161
+ # A ``Traces`` style ``#<N>`` reference.
162
+ _TRACE_HASH_RE = re.compile(r"#(\d+)")
163
+ # Characters not safe in a git branch segment.
164
+ _BRANCH_UNSAFE_RE = re.compile(r"[^A-Za-z0-9._-]+")
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Gate seams (default implementations delegate to the canonical gate scripts)
169
+ # ---------------------------------------------------------------------------
170
+
171
+
172
+ def run_preflight_gate(vbrief_path: Path) -> tuple[int, str]:
173
+ """Run the #810 implementation-intent preflight gate for one vBRIEF.
174
+
175
+ Returns ``(exit_code, message)`` where exit_code 0 means ready. The
176
+ import is lazy so the test suite can stub this seam without importing
177
+ the gate script at all.
178
+ """
179
+ from preflight_implementation import evaluate # lazy import
180
+
181
+ return evaluate(Path(vbrief_path))
182
+
183
+
184
+ def run_readiness_gate(vbrief_path: Path, project_root: Path) -> tuple[int, str]:
185
+ """Run the ``task swarm:readiness`` gate for one story vBRIEF.
186
+
187
+ Returns ``(exit_code, report)`` where exit_code 0 means ready. The
188
+ import is lazy so the test suite can stub this seam.
189
+ """
190
+ from swarm_readiness import readiness_report # lazy import
191
+
192
+ return readiness_report(Path(project_root), [Path(vbrief_path)])
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # Story resolution
197
+ # ---------------------------------------------------------------------------
198
+
199
+
200
+ @dataclass
201
+ class ResolvedStory:
202
+ """A cohort story resolved to a concrete active vBRIEF file."""
203
+
204
+ token: str
205
+ story_id: str
206
+ path: Path
207
+ relpath: str
208
+
209
+
210
+ def _load_json(path: Path) -> dict[str, Any] | None:
211
+ try:
212
+ data = json.loads(path.read_text(encoding="utf-8"))
213
+ except (OSError, json.JSONDecodeError):
214
+ return None
215
+ return data if isinstance(data, dict) else None
216
+
217
+
218
+ def _plan(data: dict[str, Any]) -> dict[str, Any]:
219
+ plan = data.get("plan")
220
+ return plan if isinstance(plan, dict) else {}
221
+
222
+
223
+ def _file_scope(plan: dict[str, Any]) -> tuple[str, ...]:
224
+ """Return ``plan.metadata.swarm.file_scope`` (the gate candidate paths).
225
+
226
+ Non-raising: any missing / wrong-shape level yields an empty tuple, which
227
+ makes the gate layer a no-op for that story.
228
+ """
229
+ metadata = plan.get("metadata")
230
+ if not isinstance(metadata, dict):
231
+ return ()
232
+ swarm = metadata.get("swarm")
233
+ if not isinstance(swarm, dict):
234
+ return ()
235
+ scope = swarm.get("file_scope")
236
+ if not isinstance(scope, list):
237
+ return ()
238
+ return tuple(p for p in scope if isinstance(p, str) and p)
239
+
240
+
241
+ def _story_id(path: Path, plan: dict[str, Any]) -> str:
242
+ value = plan.get("id")
243
+ if isinstance(value, str) and value.strip():
244
+ return value.strip()
245
+ name = path.name
246
+ return name[: -len(".vbrief.json")] if name.endswith(".vbrief.json") else path.stem
247
+
248
+
249
+ def _issue_numbers(plan: dict[str, Any]) -> set[int]:
250
+ """Collect every GitHub issue number a story references.
251
+
252
+ Scans ``plan.references[].uri`` for ``/issues/<N>`` and both the
253
+ plan-level and item-level ``narratives.Traces`` strings for ``#<N>``.
254
+ """
255
+ out: set[int] = set()
256
+ refs = plan.get("references")
257
+ if isinstance(refs, list):
258
+ for ref in refs:
259
+ if isinstance(ref, dict):
260
+ uri = ref.get("uri")
261
+ if isinstance(uri, str):
262
+ out.update(int(m) for m in _ISSUE_URI_RE.findall(uri))
263
+ narratives = plan.get("narratives")
264
+ if isinstance(narratives, dict):
265
+ traces = narratives.get("Traces")
266
+ if isinstance(traces, str):
267
+ out.update(int(m) for m in _TRACE_HASH_RE.findall(traces))
268
+ items = plan.get("items")
269
+ if isinstance(items, list):
270
+ for item in items:
271
+ if isinstance(item, dict):
272
+ narrative = item.get("narrative")
273
+ if isinstance(narrative, dict):
274
+ traces = narrative.get("Traces")
275
+ if isinstance(traces, str):
276
+ out.update(int(m) for m in _TRACE_HASH_RE.findall(traces))
277
+ return out
278
+
279
+
280
+ @dataclass
281
+ class _ActiveStory:
282
+ path: Path
283
+ story_id: str
284
+ issues: set[int]
285
+
286
+
287
+ def _index_active_stories(project_root: Path) -> list[_ActiveStory]:
288
+ """Index every ``vbrief/active/*.vbrief.json`` story for resolution."""
289
+ active_dir = project_root / "vbrief" / "active"
290
+ index: list[_ActiveStory] = []
291
+ # Guard against an absent directory: Path.glob short-circuits to empty on
292
+ # Python >= 3.12 but raises FileNotFoundError on < 3.12. main() surfaces
293
+ # the friendly EXIT_CONFIG_ERROR; this keeps the indexer non-raising.
294
+ if not active_dir.is_dir():
295
+ return index
296
+ for path in sorted(active_dir.glob("*.vbrief.json")):
297
+ data = _load_json(path)
298
+ if data is None:
299
+ continue
300
+ plan = _plan(data)
301
+ index.append(
302
+ _ActiveStory(path=path, story_id=_story_id(path, plan), issues=_issue_numbers(plan))
303
+ )
304
+ return index
305
+
306
+
307
+ def _project_rel(project_root: Path, path: Path) -> str:
308
+ try:
309
+ return path.resolve().relative_to(project_root.resolve()).as_posix()
310
+ except ValueError:
311
+ return path.as_posix()
312
+
313
+
314
+ def _looks_like_path(token: str) -> bool:
315
+ # The bare ``.exists()`` fallback is CWD-relative; restrict it to
316
+ # ``*.vbrief.json`` names so a stray file named e.g. "1234" in the
317
+ # working directory cannot shadow a numeric issue-number lookup.
318
+ return (
319
+ token.endswith(".json")
320
+ or "/" in token
321
+ or "\\" in token
322
+ or (Path(token).exists() and Path(token).name.endswith(".vbrief.json"))
323
+ )
324
+
325
+
326
+ def _resolve_one(
327
+ token: str,
328
+ project_root: Path,
329
+ id_map: dict[str, list[_ActiveStory]],
330
+ issue_map: dict[int, list[_ActiveStory]],
331
+ ) -> tuple[ResolvedStory | None, str | None]:
332
+ """Resolve a single token. Returns ``(story, None)`` or ``(None, error)``."""
333
+ if _looks_like_path(token):
334
+ candidate = Path(token)
335
+ if not candidate.is_absolute():
336
+ candidate = project_root / token
337
+ if not candidate.is_file():
338
+ return None, f"{token!r}: vBRIEF path not found ({candidate})."
339
+ data = _load_json(candidate)
340
+ if data is None:
341
+ return None, f"{token!r}: vBRIEF is unreadable or not valid JSON."
342
+ story_id = _story_id(candidate, _plan(data))
343
+ return (
344
+ ResolvedStory(
345
+ token=token,
346
+ story_id=story_id,
347
+ path=candidate,
348
+ relpath=_project_rel(project_root, candidate),
349
+ ),
350
+ None,
351
+ )
352
+
353
+ if token.isdigit():
354
+ matches = issue_map.get(int(token), [])
355
+ if len(matches) == 1:
356
+ match = matches[0]
357
+ return (
358
+ ResolvedStory(
359
+ token=token,
360
+ story_id=match.story_id,
361
+ path=match.path,
362
+ relpath=_project_rel(project_root, match.path),
363
+ ),
364
+ None,
365
+ )
366
+ if not matches:
367
+ return None, f"#{token}: no active story references this issue."
368
+ ids = ", ".join(sorted(m.story_id for m in matches))
369
+ return None, f"#{token}: ambiguous -- {len(matches)} active stories match ({ids})."
370
+
371
+ id_matches = id_map.get(token, [])
372
+ if len(id_matches) == 1:
373
+ match = id_matches[0]
374
+ return (
375
+ ResolvedStory(
376
+ token=token,
377
+ story_id=match.story_id,
378
+ path=match.path,
379
+ relpath=_project_rel(project_root, match.path),
380
+ ),
381
+ None,
382
+ )
383
+ if not id_matches:
384
+ return None, f"{token!r}: no active story with this id."
385
+ # Two+ active vBRIEFs share this plan.id. Fail loud (mirrors the
386
+ # issue-number ambiguity path) rather than silently last-wins, which
387
+ # would dispatch the wrong agent with no diagnostic.
388
+ paths = ", ".join(sorted(_project_rel(project_root, m.path) for m in id_matches))
389
+ return (
390
+ None,
391
+ f"{token!r}: ambiguous -- {len(id_matches)} active stories share this id ({paths}).",
392
+ )
393
+
394
+
395
+ def resolve_stories(project_root: Path, tokens: list[str]) -> tuple[list[ResolvedStory], list[str]]:
396
+ """Resolve cohort tokens against ``vbrief/active``.
397
+
398
+ Each token may be a GitHub issue number, a story id, or a vBRIEF path.
399
+ Returns the resolved stories (de-duplicated by path, input order
400
+ preserved) and a list of human-readable errors for unresolved tokens.
401
+ """
402
+ index = _index_active_stories(project_root)
403
+ id_map: dict[str, list[_ActiveStory]] = defaultdict(list)
404
+ issue_map: dict[int, list[_ActiveStory]] = defaultdict(list)
405
+ for story in index:
406
+ id_map[story.story_id].append(story)
407
+ for issue in story.issues:
408
+ issue_map[issue].append(story)
409
+
410
+ resolved: list[ResolvedStory] = []
411
+ errors: list[str] = []
412
+ seen_paths: set[Path] = set()
413
+ for raw in tokens:
414
+ token = raw.strip()
415
+ if not token:
416
+ continue
417
+ story, error = _resolve_one(token, project_root, id_map, issue_map)
418
+ if error is not None or story is None:
419
+ errors.append(error or f"{token!r}: could not resolve.")
420
+ continue
421
+ resolved_path = story.path.resolve()
422
+ if resolved_path in seen_paths:
423
+ continue
424
+ seen_paths.add(resolved_path)
425
+ resolved.append(story)
426
+ return resolved, errors
427
+
428
+
429
+ # ---------------------------------------------------------------------------
430
+ # Gate enforcement
431
+ # ---------------------------------------------------------------------------
432
+
433
+
434
+ def enforce_gates(
435
+ resolved: list[ResolvedStory],
436
+ project_root: Path,
437
+ ) -> tuple[ResolvedStory, str] | None:
438
+ """Run both gates per story in order; return the FIRST failure, or None.
439
+
440
+ A failure is returned as ``(story, reason)`` so the caller can name
441
+ the first failing story. Both gates run through the module-level
442
+ seams so the test suite can stub pass / fail outcomes.
443
+ """
444
+ for story in resolved:
445
+ code, message = run_preflight_gate(story.path)
446
+ if code != 0:
447
+ return story, f"preflight gate failed: {message.strip()}"
448
+ code, report = run_readiness_gate(story.path, project_root)
449
+ if code != 0:
450
+ return story, f"swarm:readiness gate failed:\n{report.strip()}"
451
+ return None
452
+
453
+
454
+ # ---------------------------------------------------------------------------
455
+ # Sub-agent backend policy (#1531e)
456
+ # ---------------------------------------------------------------------------
457
+
458
+
459
+ def _format_probed_backends(backends: list[Any]) -> str:
460
+ """Human-readable probe listing for fail-loud stderr."""
461
+ lines: list[str] = []
462
+ for entry in backends:
463
+ avail = "available" if entry.available else "unavailable"
464
+ roles = ", ".join(entry.roles)
465
+ lines.append(f" {entry.backend_id} ({avail}; roles=[{roles}])")
466
+ return "\n".join(lines)
467
+
468
+
469
+ def enforce_subagent_backend_policy(
470
+ project_root: Path,
471
+ ) -> tuple[SubagentBackendDescriptor | None, str | None]:
472
+ """Validate ``plan.policy.swarmSubagentBackend`` before headless dispatch.
473
+
474
+ Returns ``(descriptor, None)`` when the stored backend is present and
475
+ probe-available, or ``(None, reason)`` on the first failure. Never
476
+ prompts -- ``--autonomous`` and interactive launches share this path.
477
+ """
478
+ if resolve_swarm_subagent_backend is None or probe_subagent_backends is None:
479
+ return None, (
480
+ "sub-agent backend policy module is not importable "
481
+ "(scripts/policy.py)."
482
+ )
483
+
484
+ result = resolve_swarm_subagent_backend(project_root)
485
+ probed = probe_subagent_backends()
486
+
487
+ if result.backend_id is None:
488
+ detail = result.error or "plan.policy.swarmSubagentBackend is not set."
489
+ listing = _format_probed_backends(probed)
490
+ return None, (
491
+ f"{detail}\n"
492
+ "Select a coding sub-agent backend before headless dispatch:\n"
493
+ f"{listing}\n"
494
+ "Probe harness availability: task policy:subagent-backends\n"
495
+ f"Persist a choice: {SUBAGENT_BACKEND_SET_CMD.format(backend_id='<id>')}"
496
+ )
497
+
498
+ selected = next((e for e in probed if e.backend_id == result.backend_id), None)
499
+ if selected is None:
500
+ known = ", ".join(e.backend_id for e in probed)
501
+ return None, (
502
+ f"plan.policy.swarmSubagentBackend={result.backend_id!r} is not a "
503
+ f"known backend id (known: {known}).\n"
504
+ f"Persist a valid choice: {SUBAGENT_BACKEND_SET_CMD.format(backend_id='<id>')}"
505
+ )
506
+
507
+ if not selected.available:
508
+ available_ids = [e.backend_id for e in probed if e.available]
509
+ avail_text = ", ".join(available_ids) if available_ids else "(none)"
510
+ return None, (
511
+ f"plan.policy.swarmSubagentBackend={result.backend_id!r} is "
512
+ f"unavailable in the current harness.\n"
513
+ f"Available backend ids: {avail_text}\n"
514
+ f"Choose a different backend: "
515
+ f"{SUBAGENT_BACKEND_SET_CMD.format(backend_id='<id>')}"
516
+ )
517
+
518
+ if LEAF_CODING_WORKER_ROLE not in selected.roles:
519
+ roles_text = ", ".join(selected.roles) if selected.roles else "(none)"
520
+ return None, (
521
+ f"plan.policy.swarmSubagentBackend={result.backend_id!r} does not "
522
+ f"support worker role {LEAF_CODING_WORKER_ROLE!r} "
523
+ f"(roles=[{roles_text}]).\n"
524
+ f"Choose a leaf-implementation backend: "
525
+ f"{SUBAGENT_BACKEND_SET_CMD.format(backend_id='<id>')}"
526
+ )
527
+
528
+ return selected, None
529
+
530
+
531
+ def dispatch_provider_for(backend_id: str) -> str:
532
+ """Map a catalog backend id to its provider-neutral dispatch provider."""
533
+ return _DISPATCH_PROVIDER_BY_BACKEND.get(backend_id, backend_id)
534
+
535
+
536
+ # ---------------------------------------------------------------------------
537
+ # Worker runtime + GitHub auth mode labels (#1557c)
538
+ # ---------------------------------------------------------------------------
539
+
540
+
541
+ def probe_worker_runtime_auth() -> tuple[str, str]:
542
+ """Probe the launch environment and return ``(runtime_mode, github_auth_mode)``.
543
+
544
+ Labels are derived from the read-only runtime probe (#1557a) and auth-mode
545
+ inference (#1557b). Secret token values are never read or emitted -- only
546
+ mode names suitable for manifest / preamble contracts.
547
+ """
548
+ if get_platform_capabilities is None or infer_github_auth_mode is None:
549
+ msg = (
550
+ "runtime/auth mode modules are not importable "
551
+ "(scripts/platform_capabilities.py, scripts/github_auth_modes.py)."
552
+ )
553
+ raise RuntimeError(msg)
554
+ report = get_platform_capabilities()
555
+ return report.runtime_mode, infer_github_auth_mode(report)
556
+
557
+
558
+ # ---------------------------------------------------------------------------
559
+ # Gate-clearance integration (#1419 Slice 7)
560
+ # ---------------------------------------------------------------------------
561
+
562
+
563
+ @dataclass
564
+ class StoryGateStatus:
565
+ """Per-story block-tier judgment-gate status for the cohort."""
566
+
567
+ story: ResolvedStory
568
+ matched_block: tuple[str, ...] # block-tier gate ids the file_scope matched
569
+ fired_block: tuple[str, ...] # subset that fired (no recorded clearance)
570
+
571
+
572
+ def evaluate_cohort_gates(
573
+ resolved: list[ResolvedStory],
574
+ project_root: Path,
575
+ *,
576
+ posture: str,
577
+ clearances: list[dict] | None,
578
+ now: Any | None = None,
579
+ ) -> list[StoryGateStatus]:
580
+ """Evaluate each story's file_scope against the judgment gates.
581
+
582
+ Imports the Slice-3 engine (``verify_judgment_gates.build_report`` /
583
+ ``Candidate``) -- the gate logic is never re-implemented here. Returns one
584
+ :class:`StoryGateStatus` per story that matched at least one block-tier
585
+ gate (stories with no file_scope or no block-tier match are omitted). The
586
+ supplied clearances (from ``--gate-clearances``) are merged with any
587
+ recorded in the durable clearance audit log. The caller decides whether a
588
+ fired gate aborts (enforce) or is surfaced (advise).
589
+ """
590
+ statuses: list[StoryGateStatus] = []
591
+ if _gates is None:
592
+ return statuses
593
+ records = list(clearances or [])
594
+ records.extend(_gates.read_clearances(project_root))
595
+ for story in resolved:
596
+ plan = _plan(_load_json(story.path) or {})
597
+ file_scope = _file_scope(plan)
598
+ if not file_scope:
599
+ continue
600
+ report = _gates.build_report(
601
+ project_root,
602
+ _gates.Candidate(paths=file_scope),
603
+ posture=posture,
604
+ clearances=records,
605
+ now=now,
606
+ )
607
+ matched = tuple(o.gate_id for o in report.block_tier_requirements)
608
+ if not matched:
609
+ continue
610
+ fired = tuple(o.gate_id for o in report.blocking)
611
+ statuses.append(
612
+ StoryGateStatus(story=story, matched_block=matched, fired_block=fired)
613
+ )
614
+ return statuses
615
+
616
+
617
+ def enforce_cohort_gates(
618
+ statuses: list[StoryGateStatus],
619
+ *,
620
+ posture: str,
621
+ cohort_size: int,
622
+ ) -> tuple[StoryGateStatus, str] | None:
623
+ """Apply the gate-clearance + block-gated-solo rules; return first failure.
624
+
625
+ Two rules (RFC #1419):
626
+
627
+ 1. An uncleared active block-tier gate cannot launch -- a gated story rides
628
+ the consent token only when its clearance is pre-recorded.
629
+ 2. v1 ships block-gated stories SOLO -- a block-gated story may not ride a
630
+ multi-story cohort (per-commit trailer attribution is deferred to v2).
631
+
632
+ In ``enforce`` posture the first violation is returned as ``(status,
633
+ reason)`` so the caller aborts naming the story. In ``advise`` posture this
634
+ returns None (the caller surfaces the same conditions as advisory notes but
635
+ still launches) -- the framework's own ``task swarm:launch`` stays advisory.
636
+ """
637
+ if posture != GATE_ENFORCE:
638
+ return None
639
+ for status in statuses:
640
+ if status.fired_block:
641
+ return status, (
642
+ "block-gated and uncleared -- "
643
+ f"{', '.join(status.fired_block)}. Record a clearance "
644
+ "(--gate-clearances / `verify_judgment_gates.py clear`) before launch."
645
+ )
646
+ if cohort_size > 1:
647
+ for status in statuses:
648
+ if status.matched_block:
649
+ return status, (
650
+ f"block-gated ({', '.join(status.matched_block)}); v1 ships "
651
+ "block-gated stories SOLO -- launch it on its own."
652
+ )
653
+ return None
654
+
655
+
656
+ # ---------------------------------------------------------------------------
657
+ # Selection ordering -- cohort-fill (#1419 Slice 2 / #987)
658
+ # ---------------------------------------------------------------------------
659
+
660
+
661
+ def order_cohort(resolved: list[ResolvedStory], project_root: Path) -> list[ResolvedStory]:
662
+ """Order a resolved cohort by the RFC #1419 Layer-3 selection sort.
663
+
664
+ Continuation work (a story whose ``planRef`` parent epic has already
665
+ started) leads, then deficit-biased among net-new (most-under-target
666
+ capacity bucket first), then intra-bucket ``plan.metadata.rank``, then a
667
+ date-prefixed-filename proxy for creation date. Reuses
668
+ :func:`triage_queue.selection_ordering_key` (the same canonical key the
669
+ triage queue uses) so the two surfaces cannot drift.
670
+
671
+ The urgent/blocking label tier is queue-specific (it matches GitHub
672
+ issue labels against ``triageRankingLabels``); a swarm cohort is already
673
+ operator-curated, so ``label_index`` is a constant ``0`` here. The sort
674
+ is stable + best-effort: when :mod:`triage_queue` is unavailable the
675
+ input order is preserved unchanged.
676
+ """
677
+ if triage_queue is None:
678
+ return list(resolved)
679
+ continuation_map = triage_queue.continuation_by_issue_number(project_root)
680
+ deficit_map = triage_queue.bucket_deficit_by_issue_number(project_root)
681
+
682
+ def _key(story: ResolvedStory) -> tuple:
683
+ plan = _plan(_load_json(story.path) or {})
684
+ # Match the extraction the maps were built with -- both
685
+ # continuation_by_issue_number and bucket_deficit_by_issue_number key
686
+ # on triage_queue._issue_numbers_from_plan (x-vbrief/github-issue refs
687
+ # only), so the lookup must use the same narrow set rather than the
688
+ # broader resolution-time _issue_numbers (which also scans Traces).
689
+ issues = triage_queue._issue_numbers_from_plan(plan)
690
+ cont_orders = [continuation_map[n] for n in issues if n in continuation_map]
691
+ deficits = [deficit_map[n] for n in issues if n in deficit_map]
692
+ return triage_queue.selection_ordering_key(
693
+ label_index=0,
694
+ is_continuation=bool(cont_orders),
695
+ continuation_order=min(cont_orders) if cont_orders else "",
696
+ bucket_deficit=max(deficits) if deficits else None,
697
+ rank=triage_queue.scope_metadata_rank(plan),
698
+ date_key=(0, story.relpath),
699
+ )
700
+
701
+ return sorted(resolved, key=_key)
702
+
703
+
704
+ # ---------------------------------------------------------------------------
705
+ # Manifest construction (C2)
706
+ # ---------------------------------------------------------------------------
707
+
708
+
709
+ def _safe_segment(text: str) -> str:
710
+ cleaned = _BRANCH_UNSAFE_RE.sub("-", text.strip()).strip("-.")
711
+ return cleaned or "story"
712
+
713
+
714
+ def _derive_branch(group: str | None, story_id: str) -> str:
715
+ leaf = _safe_segment(story_id)
716
+ if group:
717
+ return f"swarm/{_safe_segment(group)}/{leaf}"
718
+ return f"swarm/{leaf}"
719
+
720
+
721
+ def _default_worktree(project_root: Path, story_id: str) -> str:
722
+ return (project_root / ".deft-scratch" / "worktrees" / _safe_segment(story_id)).as_posix()
723
+
724
+
725
+ def _resolve_worktree_records(
726
+ worktree_map_path: Path,
727
+ base_branch: str,
728
+ create_missing: bool,
729
+ resolver: Callable[..., list[dict]] | None,
730
+ ) -> dict[str, dict]:
731
+ """Load + resolve the C3 worktree map; return a story_id -> record map.
732
+
733
+ Raises ``ValueError`` on any config error (missing resolver, unreadable
734
+ map, non-list payload, or a resolver-raised collision / mismatch) so
735
+ the caller can map it to EXIT_CONFIG_ERROR.
736
+ """
737
+ if resolver is None:
738
+ raise ValueError(
739
+ "--worktree-map supplied but the C3 resolver (swarm_worktrees."
740
+ "resolve_worktree_map) is not importable. It is delivered by the "
741
+ "swarm-worktree-map story and wired at integration."
742
+ )
743
+ try:
744
+ payload = json.loads(worktree_map_path.read_text(encoding="utf-8"))
745
+ except (OSError, json.JSONDecodeError) as exc:
746
+ raise ValueError(f"could not read --worktree-map {worktree_map_path}: {exc}") from exc
747
+ if not isinstance(payload, list):
748
+ raise ValueError(
749
+ f"--worktree-map {worktree_map_path} must contain a JSON array of records."
750
+ )
751
+ try:
752
+ records = resolver(payload, base_branch, create_missing=create_missing)
753
+ except Exception as exc: # noqa: BLE001 -- resolver raises on collisions / mismatches
754
+ raise ValueError(f"worktree map resolution failed: {exc}") from exc
755
+ out: dict[str, dict] = {}
756
+ for record in records:
757
+ if isinstance(record, dict) and isinstance(record.get("story_id"), str):
758
+ sid = record["story_id"]
759
+ # Self-defend against a defective resolver: the C3 contract says
760
+ # it raises on collisions, but until that sibling story ships a
761
+ # duplicate here would silently record the wrong worktree path.
762
+ if sid in out:
763
+ raise ValueError(f"worktree map resolver returned duplicate story_id {sid!r}")
764
+ out[sid] = record
765
+ return out
766
+
767
+
768
+ def build_manifest(
769
+ resolved: list[ResolvedStory],
770
+ *,
771
+ project_root: Path,
772
+ group: str | None,
773
+ base_branch: str,
774
+ worktree_records: dict[str, dict],
775
+ dispatch_kind: str,
776
+ allocation_plan_id: str | None,
777
+ batching_rationale: str | None,
778
+ operator_approval_evidence: str | None,
779
+ gate_clearances: list[dict] | None = None,
780
+ subagent_backend: str | None = None,
781
+ dispatch_provider: str | None = None,
782
+ worker_role: str | None = None,
783
+ runtime_mode: str | None = None,
784
+ github_auth_mode: str | None = None,
785
+ ) -> list[dict]:
786
+ """Build the C2 launch-manifest array (one envelope per story).
787
+
788
+ When ``gate_clearances`` is non-empty each envelope's
789
+ ``allocation_context`` gains a 6th ``gate_clearances`` field (#1419 Slice
790
+ 7) so the dispatched worker's Gate 0 can recognise the pre-recorded
791
+ clearance. The field is OMITTED when there are no clearances so the
792
+ historical five-field #1378 consent token is unchanged for the common case.
793
+
794
+ Top-level ``subagent_backend``, ``dispatch_provider``, and ``worker_role``
795
+ fields (#1531e) carry audit-visible routing metadata without altering the
796
+ #1378 allocation-context recognition contract.
797
+
798
+ ``runtime_mode`` and ``github_auth_mode`` (#1557c) label the worker
799
+ credential policy for each envelope. Mode labels only -- never secret
800
+ token values.
801
+ """
802
+ cohort_vbriefs = [story.relpath for story in resolved]
803
+ manifest: list[dict] = []
804
+ for story in resolved:
805
+ record = worktree_records.get(story.story_id)
806
+ if record is not None and isinstance(record.get("worktree_path"), str):
807
+ worktree_path = record["worktree_path"]
808
+ else:
809
+ worktree_path = _default_worktree(project_root, story.story_id)
810
+ allocation_context: dict[str, Any] = {
811
+ "dispatch_kind": dispatch_kind,
812
+ "allocation_plan_id": allocation_plan_id,
813
+ "batching_rationale": batching_rationale,
814
+ "cohort_vbriefs": cohort_vbriefs,
815
+ "operator_approval_evidence": operator_approval_evidence,
816
+ }
817
+ if gate_clearances:
818
+ allocation_context["gate_clearances"] = gate_clearances
819
+ entry: dict[str, Any] = {
820
+ "story_id": story.story_id,
821
+ "vbrief_path": story.relpath,
822
+ "worktree_path": worktree_path,
823
+ "branch": _derive_branch(group, story.story_id),
824
+ "allocation_context": allocation_context,
825
+ }
826
+ if subagent_backend is not None:
827
+ entry["subagent_backend"] = subagent_backend
828
+ if dispatch_provider is not None:
829
+ entry["dispatch_provider"] = dispatch_provider
830
+ if worker_role is not None:
831
+ entry["worker_role"] = worker_role
832
+ if runtime_mode is not None:
833
+ entry["runtime_mode"] = runtime_mode
834
+ if github_auth_mode is not None:
835
+ entry["github_auth_mode"] = github_auth_mode
836
+ manifest.append(entry)
837
+ return manifest
838
+
839
+
840
+ # ---------------------------------------------------------------------------
841
+ # CLI
842
+ # ---------------------------------------------------------------------------
843
+
844
+
845
+ def _split_csv(values: list[str] | None) -> list[str]:
846
+ """Flatten repeated and comma-separated option values into a token list."""
847
+ out: list[str] = []
848
+ for value in values or []:
849
+ out.extend(piece for piece in value.split(",") if piece.strip())
850
+ return out
851
+
852
+
853
+ def _build_parser() -> argparse.ArgumentParser:
854
+ parser = argparse.ArgumentParser(
855
+ prog="swarm_launch",
856
+ description=(
857
+ "Deterministic headless swarm launch engine (#1387). Resolves a "
858
+ "pre-approved cohort, enforces the #810 preflight and "
859
+ "swarm:readiness gates per story, and emits the launch-manifest "
860
+ "JSON (the C2 contract) carrying the #1378 allocation-context "
861
+ "consent token for each agent."
862
+ ),
863
+ )
864
+ parser.add_argument(
865
+ "--stories",
866
+ action="append",
867
+ default=[],
868
+ metavar="IDS|PATHS",
869
+ help=(
870
+ "Comma-separated cohort members. Each token is a GitHub issue "
871
+ "number, a story id, or a vBRIEF path resolved against "
872
+ "vbrief/active. May be passed multiple times."
873
+ ),
874
+ )
875
+ parser.add_argument(
876
+ "--paths",
877
+ action="append",
878
+ default=[],
879
+ metavar="PATHS",
880
+ help="Comma-separated explicit vBRIEF paths (joined with --stories).",
881
+ )
882
+ parser.add_argument(
883
+ "--group",
884
+ default=None,
885
+ metavar="LABEL",
886
+ help="Cohort label; used to derive per-agent branch names.",
887
+ )
888
+ parser.add_argument(
889
+ "--worktree-map",
890
+ default=None,
891
+ metavar="PATH",
892
+ help="Path to a C3 worktree-map JSON array (resolved via swarm_worktrees).",
893
+ )
894
+ parser.add_argument(
895
+ "--base-branch",
896
+ default=DEFAULT_BASE_BRANCH,
897
+ metavar="BRANCH",
898
+ help=f"Base branch the per-agent worktrees fork from (default: {DEFAULT_BASE_BRANCH}).",
899
+ )
900
+ parser.add_argument(
901
+ "--autonomous",
902
+ action="store_true",
903
+ help=(
904
+ "Headless pre-approved mode: emit the manifest without prompting "
905
+ "and record the batching rationale in each envelope."
906
+ ),
907
+ )
908
+ parser.add_argument(
909
+ "--allocation-plan-id",
910
+ default=None,
911
+ metavar="ID",
912
+ help="Allocation-plan handle recorded in each allocation-context token.",
913
+ )
914
+ parser.add_argument(
915
+ "--batching-rationale",
916
+ default=None,
917
+ metavar="TEXT",
918
+ help="One-line batching rationale recorded in each allocation-context token.",
919
+ )
920
+ parser.add_argument(
921
+ "--operator-approval",
922
+ default=None,
923
+ metavar="EVIDENCE",
924
+ help="Operator-approval evidence recorded in each allocation-context token.",
925
+ )
926
+ parser.add_argument(
927
+ "--no-create-worktrees",
928
+ action="store_true",
929
+ help="Pass create_missing=False to the C3 worktree resolver.",
930
+ )
931
+ parser.add_argument(
932
+ "--output",
933
+ default=None,
934
+ metavar="PATH",
935
+ help="Also write the launch-manifest JSON to this file.",
936
+ )
937
+ parser.add_argument(
938
+ "--gate-clearances",
939
+ default=None,
940
+ metavar="PATH",
941
+ help=(
942
+ "Path to a JSON array of pre-recorded judgment-gate clearances "
943
+ "(#1419 Slice 7). Each entry is an object with gate_id / vbrief_path "
944
+ "/ cleared_by / rationale / cleared_at / cleared_scope. A gated story "
945
+ "rides the consent token only when its clearance is pre-recorded."
946
+ ),
947
+ )
948
+ parser.add_argument(
949
+ "--enforce-gates",
950
+ action="store_true",
951
+ help=(
952
+ "Gate-clearance ENFORCE posture (#1419 Slice 7): abort (exit 1) when "
953
+ "a story is block-gated and uncleared, or when a block-gated story "
954
+ "would ride a multi-story cohort (v1 ships block-gated stories solo). "
955
+ "DEFAULT is advisory -- such stories are surfaced but still launch."
956
+ ),
957
+ )
958
+ parser.add_argument(
959
+ "--no-audit",
960
+ action="store_true",
961
+ help=(
962
+ "Suppress the durable authority-event audit append "
963
+ "(vbrief/.audit/authority-events.jsonl). By default a successful "
964
+ "launch records the allocation approval + each consumed clearance."
965
+ ),
966
+ )
967
+ parser.add_argument(
968
+ "--project-root",
969
+ default=".",
970
+ help="Project root containing vbrief/ (default: current directory).",
971
+ )
972
+ return parser
973
+
974
+
975
+ def main(argv: list[str] | None = None) -> int:
976
+ args = _build_parser().parse_args(argv)
977
+ project_root = Path(args.project_root).resolve()
978
+
979
+ tokens = _split_csv(args.stories) + _split_csv(args.paths)
980
+ if not tokens:
981
+ print(
982
+ "Error: no stories supplied. Pass --stories <ids|paths> and/or --paths <paths>.",
983
+ file=sys.stderr,
984
+ )
985
+ return EXIT_CONFIG_ERROR
986
+
987
+ if not (project_root / "vbrief" / "active").is_dir():
988
+ print(
989
+ f"Error: no vbrief/active directory under --project-root {project_root}. "
990
+ "Point --project-root at a deft project with activated stories.",
991
+ file=sys.stderr,
992
+ )
993
+ return EXIT_CONFIG_ERROR
994
+
995
+ # Pre-recorded gate clearances (#1419 Slice 7). A supplied-but-unreadable
996
+ # / non-array file is a config error -- the operator asked us to consume a
997
+ # clearance file we cannot parse.
998
+ gate_clearances: list[dict] = []
999
+ if args.gate_clearances:
1000
+ try:
1001
+ clearance_payload = json.loads(
1002
+ Path(args.gate_clearances).read_text(encoding="utf-8")
1003
+ )
1004
+ except (OSError, json.JSONDecodeError) as exc:
1005
+ print(
1006
+ f"Error: could not read --gate-clearances {args.gate_clearances}: {exc}",
1007
+ file=sys.stderr,
1008
+ )
1009
+ return EXIT_CONFIG_ERROR
1010
+ if not isinstance(clearance_payload, list):
1011
+ print(
1012
+ f"Error: --gate-clearances {args.gate_clearances} must be a JSON "
1013
+ "array of clearance objects.",
1014
+ file=sys.stderr,
1015
+ )
1016
+ return EXIT_CONFIG_ERROR
1017
+ gate_clearances = [e for e in clearance_payload if isinstance(e, dict)]
1018
+
1019
+ resolved, errors = resolve_stories(project_root, tokens)
1020
+ if errors:
1021
+ print("Error: could not resolve every cohort member:", file=sys.stderr)
1022
+ for error in errors:
1023
+ print(f" - {error}", file=sys.stderr)
1024
+ return EXIT_GATE_FAILED
1025
+
1026
+ failure = enforce_gates(resolved, project_root)
1027
+ if failure is not None:
1028
+ story, reason = failure
1029
+ print(
1030
+ f"Error: story {story.story_id!r} ({story.relpath}) is not launch-ready -- {reason}",
1031
+ file=sys.stderr,
1032
+ )
1033
+ return EXIT_GATE_FAILED
1034
+
1035
+ backend, backend_error = enforce_subagent_backend_policy(project_root)
1036
+ if backend_error is not None:
1037
+ print(f"Error: {backend_error}", file=sys.stderr)
1038
+ return EXIT_GATE_FAILED
1039
+
1040
+ # Cohort-fill ordering (#1419 Slice 2 / #987): continuation-first,
1041
+ # deficit-biased among net-new, then rank/date. Reorders the dispatch
1042
+ # manifest (and each envelope's cohort_vbriefs list) so finishing started
1043
+ # epics and under-target buckets lead.
1044
+ resolved = order_cohort(resolved, project_root)
1045
+
1046
+ # Gate-clearance + block-gated-solo check (#1419 Slice 7). Evaluate each
1047
+ # story's file_scope against the judgment gates; ENFORCE aborts on an
1048
+ # uncleared block gate or a block-gated story riding a multi-story cohort,
1049
+ # while the advisory DEFAULT surfaces those conditions but still launches
1050
+ # (the framework's own swarm:launch stays advisory).
1051
+ gate_posture = GATE_ENFORCE if args.enforce_gates else GATE_ADVISE
1052
+ gate_statuses = evaluate_cohort_gates(
1053
+ resolved, project_root, posture=gate_posture, clearances=gate_clearances
1054
+ )
1055
+ gate_failure = enforce_cohort_gates(
1056
+ gate_statuses, posture=gate_posture, cohort_size=len(resolved)
1057
+ )
1058
+ if gate_failure is not None:
1059
+ status, reason = gate_failure
1060
+ print(
1061
+ f"Error: story {status.story.story_id!r} ({status.story.relpath}) "
1062
+ f"is not launch-ready -- {reason}",
1063
+ file=sys.stderr,
1064
+ )
1065
+ return EXIT_GATE_FAILED
1066
+ if gate_posture != GATE_ENFORCE:
1067
+ for status in gate_statuses:
1068
+ if status.fired_block:
1069
+ print(
1070
+ f"Note (advisory): story {status.story.story_id!r} is "
1071
+ f"block-gated and uncleared -- {', '.join(status.fired_block)}.",
1072
+ file=sys.stderr,
1073
+ )
1074
+ elif status.matched_block and len(resolved) > 1:
1075
+ print(
1076
+ f"Note (advisory): story {status.story.story_id!r} is "
1077
+ f"block-gated ({', '.join(status.matched_block)}); v1 ships "
1078
+ "block-gated stories solo.",
1079
+ file=sys.stderr,
1080
+ )
1081
+
1082
+ # Allocation-context token (#1378). A multi-story launch (or any
1083
+ # --group launch) is a swarm-cohort; a lone story is solo.
1084
+ dispatch_kind = "swarm-cohort" if (len(resolved) > 1 or args.group) else "solo"
1085
+ allocation_plan_id = args.allocation_plan_id or args.group
1086
+ batching_rationale = args.batching_rationale
1087
+ if batching_rationale is None and args.autonomous:
1088
+ plural = "story" if len(resolved) == 1 else "stories"
1089
+ suffix = f" (group {args.group})" if args.group else ""
1090
+ batching_rationale = (
1091
+ f"Headless launch of {len(resolved)} pre-approved cohort {plural}{suffix}."
1092
+ )
1093
+ operator_approval = args.operator_approval or (
1094
+ f"task swarm:launch ({'autonomous' if args.autonomous else 'interactive'})"
1095
+ )
1096
+
1097
+ try:
1098
+ if args.worktree_map:
1099
+ worktree_records = _resolve_worktree_records(
1100
+ Path(args.worktree_map),
1101
+ args.base_branch,
1102
+ create_missing=not args.no_create_worktrees,
1103
+ resolver=resolve_worktree_map,
1104
+ )
1105
+ else:
1106
+ worktree_records = {}
1107
+ except ValueError as exc:
1108
+ print(f"Error: {exc}", file=sys.stderr)
1109
+ return EXIT_CONFIG_ERROR
1110
+
1111
+ try:
1112
+ runtime_mode, github_auth_mode = probe_worker_runtime_auth()
1113
+ except RuntimeError as exc:
1114
+ print(f"Error: {exc}", file=sys.stderr)
1115
+ return EXIT_CONFIG_ERROR
1116
+
1117
+ manifest = build_manifest(
1118
+ resolved,
1119
+ project_root=project_root,
1120
+ group=args.group,
1121
+ base_branch=args.base_branch,
1122
+ worktree_records=worktree_records,
1123
+ dispatch_kind=dispatch_kind,
1124
+ allocation_plan_id=allocation_plan_id,
1125
+ batching_rationale=batching_rationale,
1126
+ operator_approval_evidence=operator_approval,
1127
+ gate_clearances=gate_clearances,
1128
+ subagent_backend=backend.backend_id if backend is not None else None,
1129
+ dispatch_provider=(
1130
+ dispatch_provider_for(backend.backend_id) if backend is not None else None
1131
+ ),
1132
+ worker_role=LEAF_CODING_WORKER_ROLE if backend is not None else None,
1133
+ runtime_mode=runtime_mode,
1134
+ github_auth_mode=github_auth_mode,
1135
+ )
1136
+
1137
+ rendered = json.dumps(manifest, indent=2)
1138
+
1139
+ # Write the --output file BEFORE emitting to stdout so a write failure
1140
+ # aborts cleanly instead of leaving a manifest on stdout paired with a
1141
+ # non-zero exit (Greptile review on PR #1407).
1142
+ if args.output:
1143
+ try:
1144
+ Path(args.output).write_text(rendered + "\n", encoding="utf-8")
1145
+ except OSError as exc:
1146
+ print(f"Error: could not write --output {args.output}: {exc}", file=sys.stderr)
1147
+ return EXIT_CONFIG_ERROR
1148
+
1149
+ # Authority-bearing audit (#1419 Slice 7, Receipts & Audit): a successful
1150
+ # launch IS the allocation approval, so append the approval + each consumed
1151
+ # gate clearance to the durable, committed audit log. Best-effort -- an
1152
+ # audit write failure warns but never fails an otherwise-ready launch.
1153
+ if not args.no_audit and append_authority_event is not None:
1154
+ # Only clearances actually CONSUMED this run are recorded as
1155
+ # gate:cleared -- a clearance is consumed when its gate_id matched at
1156
+ # least one story's block-tier gates AND that gate ended up cleared
1157
+ # (matched but not fired). Logging every supplied clearance would
1158
+ # over-report the durable record-of-record (Greptile review, PR #1507).
1159
+ consumed_gate_ids = {
1160
+ gate_id
1161
+ for status in gate_statuses
1162
+ for gate_id in status.matched_block
1163
+ if gate_id not in status.fired_block
1164
+ }
1165
+ try:
1166
+ append_authority_event(
1167
+ project_root,
1168
+ event_type="allocation:approved",
1169
+ payload={
1170
+ "dispatch_kind": dispatch_kind,
1171
+ "allocation_plan_id": allocation_plan_id,
1172
+ "batching_rationale": batching_rationale,
1173
+ "cohort_vbriefs": [story.relpath for story in resolved],
1174
+ "operator_approval_evidence": operator_approval,
1175
+ "group": args.group,
1176
+ },
1177
+ log_name=AUTHORITY_LOG_NAME,
1178
+ )
1179
+ for clearance in gate_clearances:
1180
+ if clearance.get("gate_id") not in consumed_gate_ids:
1181
+ continue
1182
+ append_authority_event(
1183
+ project_root,
1184
+ event_type="gate:cleared",
1185
+ payload={
1186
+ "gate_id": clearance.get("gate_id"),
1187
+ "vbrief_path": clearance.get("vbrief_path"),
1188
+ "cleared_by": clearance.get("cleared_by"),
1189
+ "cleared_scope": clearance.get("cleared_scope"),
1190
+ "rationale": clearance.get("rationale"),
1191
+ "cleared_at": clearance.get("cleared_at"),
1192
+ },
1193
+ log_name=AUTHORITY_LOG_NAME,
1194
+ )
1195
+ except OSError as exc:
1196
+ print(
1197
+ f"warning: could not append authority event(s): {exc}",
1198
+ file=sys.stderr,
1199
+ )
1200
+
1201
+ print(rendered)
1202
+ return EXIT_OK
1203
+
1204
+
1205
+ if __name__ == "__main__":
1206
+ sys.exit(main())