@deftai/directive-content 0.55.1 → 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 +13 -3
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +82 -11
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/packs/skills/skills-pack-0.1.json +22 -22
- 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/skills/deft-directive-swarm/SKILL.md +7 -26
- package/skills/deft-directive-sync/SKILL.md +1 -1
- 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 +2 -2
|
@@ -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())
|