@deftai/directive-content 0.58.0 → 0.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.githooks/pre-push +10 -9
- package/Taskfile.yml +57 -67
- package/UPGRADING.md +1 -1
- package/docs/assets/directive-lifecycle-diagram.png +0 -0
- package/docs/directive-lifecycle.md +73 -0
- package/docs/getting-started.md +5 -1
- package/package.json +3 -3
- package/packs/rules/rules-pack-0.1.json +3 -3
- package/packs/skills/skills-pack-0.1.json +22 -22
- package/scm/github.md +20 -2
- package/tasks/change.yml +16 -31
- package/tasks/ci.yml +8 -0
- package/tasks/commit.yml +12 -19
- package/tasks/core.yml +10 -0
- package/tasks/engine.yml +42 -0
- package/tasks/framework.yml +3 -0
- package/tasks/install.yml +20 -19
- package/tasks/migrate.yml +26 -15
- package/tasks/project.yml +16 -0
- package/tasks/relocate.yml +18 -48
- package/tasks/toolchain.yml +15 -5
- package/tasks/vbrief.yml +4 -3
- package/tasks/verify.yml +12 -14
- package/templates/agents-entry.md +1 -2
- package/scripts/_agents_md.py +0 -494
- package/scripts/_cache_fetch.py +0 -635
- package/scripts/_cache_quota.py +0 -529
- package/scripts/_cache_refresh.py +0 -163
- package/scripts/_cache_validate.py +0 -209
- package/scripts/_content_root.py +0 -42
- package/scripts/_doctor_state.py +0 -277
- package/scripts/_event_detect.py +0 -305
- package/scripts/_events.py +0 -514
- package/scripts/_lifecycle_hygiene.py +0 -568
- package/scripts/_pathspec.py +0 -91
- package/scripts/_policy_show_cli.py +0 -266
- package/scripts/_precutover.py +0 -92
- package/scripts/_project_context.py +0 -224
- package/scripts/_project_definition_io.py +0 -164
- package/scripts/_relocate_snapshot.py +0 -209
- package/scripts/_relocate_states.py +0 -343
- package/scripts/_resolve_preflight_path.py +0 -152
- package/scripts/_safe_subprocess.py +0 -167
- package/scripts/_session_start_hook.py +0 -205
- package/scripts/_sor_gate_diff.py +0 -365
- package/scripts/_stdio_utf8.py +0 -59
- package/scripts/_triage_bootstrap_gitignore.py +0 -904
- package/scripts/_triage_classify_cli.py +0 -122
- package/scripts/_triage_queue_cli.py +0 -625
- package/scripts/_triage_scope_cli.py +0 -343
- package/scripts/_triage_scope_drift_cli.py +0 -121
- package/scripts/_triage_scope_ignores.py +0 -286
- package/scripts/_triage_scope_milestone.py +0 -432
- package/scripts/_triage_scope_mutations.py +0 -337
- package/scripts/_triage_scope_renderers.py +0 -207
- package/scripts/_triage_smoketest_stages.py +0 -674
- package/scripts/_triage_subscribe_cli.py +0 -140
- package/scripts/_triage_welcome_cli.py +0 -421
- package/scripts/_vbrief_build.py +0 -239
- package/scripts/_vbrief_fidelity.py +0 -479
- package/scripts/_vbrief_legacy.py +0 -589
- package/scripts/_vbrief_reconciliation.py +0 -883
- package/scripts/_vbrief_routing.py +0 -277
- package/scripts/_vbrief_safety.py +0 -778
- package/scripts/_vbrief_sources.py +0 -312
- package/scripts/_vbrief_speckit.py +0 -262
- package/scripts/_vbrief_story_quality.py +0 -353
- package/scripts/_vbrief_validation.py +0 -299
- package/scripts/build_dist.py +0 -412
- package/scripts/cache.py +0 -1078
- package/scripts/cache_scanner.py +0 -745
- package/scripts/candidates_log.py +0 -432
- package/scripts/capacity_backfill.py +0 -680
- package/scripts/capacity_show.py +0 -653
- package/scripts/ci_local.py +0 -689
- package/scripts/code_structure_validate.py +0 -765
- package/scripts/codebase_default_extractor.py +0 -495
- package/scripts/codebase_map.py +0 -304
- package/scripts/codebase_map_fresh.py +0 -104
- package/scripts/codebase_projection_registry.py +0 -94
- package/scripts/codebase_provider.py +0 -582
- package/scripts/doctor.py +0 -2551
- package/scripts/framework_commands.py +0 -505
- package/scripts/gh_rest.py +0 -882
- package/scripts/github_auth_modes.py +0 -437
- package/scripts/github_body.py +0 -292
- package/scripts/ip_risk.py +0 -531
- package/scripts/issue_emit.py +0 -670
- package/scripts/issue_ingest.py +0 -1064
- package/scripts/migrate_preflight.py +0 -418
- package/scripts/migrate_vbrief.py +0 -2677
- package/scripts/monitor_pr.py +0 -401
- package/scripts/pack_migrate_lessons.py +0 -336
- package/scripts/pack_migrate_patterns.py +0 -254
- package/scripts/pack_migrate_rules.py +0 -350
- package/scripts/pack_migrate_skills.py +0 -423
- package/scripts/pack_migrate_strategies.py +0 -311
- package/scripts/pack_migrate_swarm_spec.py +0 -250
- package/scripts/pack_render.py +0 -434
- package/scripts/packs_slice.py +0 -712
- package/scripts/platform_capabilities.py +0 -336
- package/scripts/policy.py +0 -2826
- package/scripts/policy_set.py +0 -324
- package/scripts/pr_check_closing_keywords.py +0 -524
- package/scripts/pr_check_protected_issues.py +0 -267
- package/scripts/pr_merge_readiness.py +0 -1004
- package/scripts/pr_wait_mergeable.py +0 -669
- package/scripts/prd_render.py +0 -159
- package/scripts/preflight_architecture_sor.py +0 -974
- package/scripts/preflight_branch.py +0 -289
- package/scripts/preflight_cache.py +0 -974
- package/scripts/preflight_gh.py +0 -721
- package/scripts/preflight_implementation.py +0 -272
- package/scripts/preflight_story_start.py +0 -838
- package/scripts/preflight_wip_cap.py +0 -149
- package/scripts/probe_session.py +0 -545
- package/scripts/project_render.py +0 -293
- package/scripts/quarantine_ext.py +0 -237
- package/scripts/reconcile_issues.py +0 -1442
- package/scripts/refresh-path.ps1 +0 -107
- package/scripts/release.py +0 -2030
- package/scripts/release_e2e.py +0 -1011
- package/scripts/release_publish.py +0 -486
- package/scripts/release_rollback.py +0 -980
- package/scripts/relocate.py +0 -1034
- package/scripts/resolve_changelog_unreleased.py +0 -667
- package/scripts/resolve_version.py +0 -490
- package/scripts/resume_conditions.py +0 -706
- package/scripts/ritual_sentinel.py +0 -609
- package/scripts/roadmap_render.py +0 -635
- package/scripts/rule_ownership_lint.py +0 -325
- package/scripts/scm.py +0 -591
- package/scripts/scope_audit_log.py +0 -387
- package/scripts/scope_decompose.py +0 -654
- package/scripts/scope_demote.py +0 -509
- package/scripts/scope_lifecycle.py +0 -1126
- package/scripts/scope_undo.py +0 -772
- package/scripts/session_start.py +0 -406
- package/scripts/setup_ghx.py +0 -339
- package/scripts/setup_windows.ps1 +0 -220
- package/scripts/slice_audit.py +0 -585
- package/scripts/slice_record.py +0 -530
- package/scripts/slice_record_existing.py +0 -692
- package/scripts/slug_normalize.py +0 -178
- package/scripts/spec_render.py +0 -477
- package/scripts/spec_validate.py +0 -238
- package/scripts/subagent_monitor.py +0 -658
- package/scripts/swarm_complete_cohort.py +0 -644
- package/scripts/swarm_launch.py +0 -1206
- package/scripts/swarm_readiness.py +0 -554
- package/scripts/swarm_verify_review_clean.py +0 -438
- package/scripts/swarm_worktrees.py +0 -497
- package/scripts/toolchain-check.py +0 -52
- package/scripts/triage_actions.py +0 -871
- package/scripts/triage_bootstrap.py +0 -1153
- package/scripts/triage_bulk.py +0 -630
- package/scripts/triage_classify.py +0 -932
- package/scripts/triage_help.py +0 -1685
- package/scripts/triage_queue.py +0 -1944
- package/scripts/triage_reconcile.py +0 -581
- package/scripts/triage_refresh.py +0 -643
- package/scripts/triage_scope.py +0 -999
- package/scripts/triage_scope_drift.py +0 -575
- package/scripts/triage_smoketest.py +0 -396
- package/scripts/triage_subscribe.py +0 -399
- package/scripts/triage_summary.py +0 -1011
- package/scripts/triage_welcome.py +0 -1178
- package/scripts/ts_check_lane.py +0 -86
- package/scripts/validate-links.py +0 -64
- package/scripts/validate_strategy_output.py +0 -212
- package/scripts/vbrief_activate.py +0 -228
- package/scripts/vbrief_migrate_conformance.py +0 -368
- package/scripts/vbrief_reconcile_graph.py +0 -306
- package/scripts/vbrief_reconcile_labels.py +0 -460
- package/scripts/vbrief_reconcile_umbrellas.py +0 -741
- package/scripts/vbrief_validate.py +0 -1144
- package/scripts/verify-stubs.py +0 -61
- package/scripts/verify_capacity.py +0 -160
- package/scripts/verify_encoding.py +0 -699
- package/scripts/verify_hooks_installed.py +0 -206
- package/scripts/verify_investigation.py +0 -360
- package/scripts/verify_judgment_gates.py +0 -827
- package/scripts/verify_no_task_runtime.py +0 -171
- package/scripts/verify_scm_boundary.py +0 -509
- package/scripts/verify_session_ritual.py +0 -389
- package/scripts/verify_tools.py +0 -426
- package/scripts/verify_vbrief_conformance.py +0 -478
|
@@ -1,497 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""swarm_worktrees.py -- pre-created worktree-map resolver for swarm cohorts (#1387).
|
|
3
|
-
|
|
4
|
-
The low-ceremony / headless swarm launch (#1387, building on the #1378
|
|
5
|
-
allocation-context token) lets an operator hand the swarm a set of
|
|
6
|
-
PRE-CREATED git worktrees instead of forcing the phased skill flow to
|
|
7
|
-
recreate them on every run. This module is the reusable, independently
|
|
8
|
-
unit-testable resolver that the launch engine imports to turn a
|
|
9
|
-
story-to-worktree mapping into a normalized, git-validated worktree map.
|
|
10
|
-
|
|
11
|
-
Frozen contract (C3)
|
|
12
|
-
--------------------
|
|
13
|
-
The launch engine imports :func:`resolve_worktree_map` directly::
|
|
14
|
-
|
|
15
|
-
from swarm_worktrees import resolve_worktree_map
|
|
16
|
-
|
|
17
|
-
Input ``mapping`` is a JSON-style array of records, each a dict with:
|
|
18
|
-
|
|
19
|
-
- ``story_id`` (str, required) -- the cohort story this worktree serves.
|
|
20
|
-
- ``worktree_path`` (str, required) -- the git worktree path (absolute, or
|
|
21
|
-
relative to ``repo_root``).
|
|
22
|
-
- ``base_branch`` (str, optional) -- the branch the worktree is based on.
|
|
23
|
-
When present it MUST equal the cohort-wide ``base_branch`` argument; a
|
|
24
|
-
divergent value is a base-branch mismatch and is rejected.
|
|
25
|
-
|
|
26
|
-
:func:`resolve_worktree_map` returns a list of normalized C3 records with
|
|
27
|
-
exactly the three keys ``{"story_id", "worktree_path", "base_branch"}``;
|
|
28
|
-
``worktree_path`` is normalized to an absolute POSIX path and
|
|
29
|
-
``base_branch`` is the resolved cohort base branch.
|
|
30
|
-
|
|
31
|
-
What the resolver guarantees
|
|
32
|
-
----------------------------
|
|
33
|
-
1. **Validation against real git state.** Each ``worktree_path`` is checked
|
|
34
|
-
against ``git worktree list --porcelain``. A path that is already a
|
|
35
|
-
registered worktree is accepted idempotently.
|
|
36
|
-
2. **Base-branch validation.** A record whose ``base_branch`` differs from
|
|
37
|
-
the configured cohort ``base_branch`` raises
|
|
38
|
-
:class:`BaseBranchMismatchError` (validation failure, exit 1).
|
|
39
|
-
3. **Idempotent creation.** When ``create_missing`` is true (the default), a
|
|
40
|
-
``worktree_path`` that is not yet a registered worktree is created from
|
|
41
|
-
the base branch via ``git worktree add --detach <path> <base_branch>``.
|
|
42
|
-
Re-running is a no-op because already-registered paths are skipped. When
|
|
43
|
-
``create_missing`` is false a missing worktree raises
|
|
44
|
-
:class:`MissingWorktreeError` (validation failure, exit 1).
|
|
45
|
-
4. **Collision rejection.** Two stories mapping to the same worktree path
|
|
46
|
-
raise :class:`WorktreeCollisionError` naming both colliding stories, and a
|
|
47
|
-
``story_id`` that appears twice (even on distinct paths) raises
|
|
48
|
-
:class:`DuplicateStoryError` -- both are validation failures (exit 1) that
|
|
49
|
-
would otherwise let the launch engine dispatch a story twice.
|
|
50
|
-
|
|
51
|
-
The created worktree is checked out in DETACHED HEAD at the base-branch tip
|
|
52
|
-
on purpose: the per-story feature branch is the launch engine's concern
|
|
53
|
-
(the C2 launch-manifest carries ``branch``), so the resolver deliberately
|
|
54
|
-
does not invent or claim a branch name. This also sidesteps git's
|
|
55
|
-
one-branch-per-worktree rule when several cohort worktrees share a base.
|
|
56
|
-
|
|
57
|
-
CLI
|
|
58
|
-
---
|
|
59
|
-
The module doubles as a deterministic CLI mirroring the ``scripts/``
|
|
60
|
-
conventions (argparse ``main`` + importable functions, UTF-8 stdio,
|
|
61
|
-
three-state exit):
|
|
62
|
-
|
|
63
|
-
task ... # (Taskfile wiring is owned by the launch-CLI story)
|
|
64
|
-
python scripts/swarm_worktrees.py --map worktree-map.json --base-branch master
|
|
65
|
-
|
|
66
|
-
Exit codes (three-state, mirrors ``scripts/preflight_story_start.py``):
|
|
67
|
-
|
|
68
|
-
- ``0`` -- resolved: every record validated; missing worktrees created when
|
|
69
|
-
permitted. The normalized C3 map is printed to stdout as JSON.
|
|
70
|
-
- ``1`` -- validation failure the operator can fix in the MAP: a same-path
|
|
71
|
-
collision, a base-branch mismatch, or a missing worktree with
|
|
72
|
-
``--no-create-missing``.
|
|
73
|
-
- ``2`` -- config / environment error: malformed map JSON, a record missing a
|
|
74
|
-
required field, git not on PATH / not a work tree, or a failed
|
|
75
|
-
``git worktree add`` (e.g. the base branch does not exist).
|
|
76
|
-
|
|
77
|
-
Refs:
|
|
78
|
-
- #1387 (this resolver; headless swarm launch for pre-approved cohorts)
|
|
79
|
-
- #1378 (allocation-context token the launch engine threads alongside C3)
|
|
80
|
-
- #1366 (subprocess capture forces ``encoding="utf-8", errors="replace"``)
|
|
81
|
-
"""
|
|
82
|
-
|
|
83
|
-
from __future__ import annotations
|
|
84
|
-
|
|
85
|
-
import argparse
|
|
86
|
-
import json
|
|
87
|
-
import os
|
|
88
|
-
import sys
|
|
89
|
-
from pathlib import Path
|
|
90
|
-
from typing import Any
|
|
91
|
-
|
|
92
|
-
# Make sibling scripts importable both when run as __main__ and when imported
|
|
93
|
-
# by tests via importlib (mirrors scripts/swarm_verify_review_clean.py).
|
|
94
|
-
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
95
|
-
|
|
96
|
-
from _safe_subprocess import run_text # noqa: E402
|
|
97
|
-
|
|
98
|
-
try:
|
|
99
|
-
from _stdio_utf8 import reconfigure_stdio # noqa: E402
|
|
100
|
-
|
|
101
|
-
reconfigure_stdio()
|
|
102
|
-
except ImportError: # pragma: no cover - _stdio_utf8 is optional in some contexts
|
|
103
|
-
pass
|
|
104
|
-
|
|
105
|
-
EXIT_OK = 0
|
|
106
|
-
EXIT_VALIDATION_ERROR = 1
|
|
107
|
-
EXIT_CONFIG_ERROR = 2
|
|
108
|
-
|
|
109
|
-
#: The exact field set of a normalized C3 record (frozen contract).
|
|
110
|
-
C3_FIELDS = ("story_id", "worktree_path", "base_branch")
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
# ---------------------------------------------------------------------------
|
|
114
|
-
# Exceptions
|
|
115
|
-
# ---------------------------------------------------------------------------
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
class WorktreeMapError(Exception):
|
|
119
|
-
"""A logical validation failure the operator can fix in the map (exit 1).
|
|
120
|
-
|
|
121
|
-
Base class for same-path collisions, base-branch mismatches, and
|
|
122
|
-
missing-worktree-with-creation-disabled. Distinct from
|
|
123
|
-
:class:`WorktreeMapConfigError` so the CLI can map the two families to
|
|
124
|
-
the deterministic exit codes 1 (validation) and 2 (config).
|
|
125
|
-
"""
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
class WorktreeCollisionError(WorktreeMapError):
|
|
129
|
-
"""Two stories mapped to the same worktree path."""
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
class BaseBranchMismatchError(WorktreeMapError):
|
|
133
|
-
"""A record's base_branch disagrees with the configured cohort base."""
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
class MissingWorktreeError(WorktreeMapError):
|
|
137
|
-
"""A mapped worktree does not exist and creation is disabled."""
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
class DuplicateStoryError(WorktreeMapError):
|
|
141
|
-
"""The same ``story_id`` appears in more than one mapping record.
|
|
142
|
-
|
|
143
|
-
Distinct from :class:`WorktreeCollisionError` (two stories on the SAME
|
|
144
|
-
path): here one story maps to two records (typically distinct paths via a
|
|
145
|
-
copy-paste error). Returning both would hand the launch engine two C3
|
|
146
|
-
records for one story and dispatch it twice, so it is rejected.
|
|
147
|
-
"""
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
class WorktreeMapConfigError(Exception):
|
|
151
|
-
"""An environment / config error (exit 2).
|
|
152
|
-
|
|
153
|
-
Malformed input records, git unavailable / not a work tree, or a failed
|
|
154
|
-
``git worktree add`` (e.g. base branch does not exist).
|
|
155
|
-
"""
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
# ---------------------------------------------------------------------------
|
|
159
|
-
# Path + porcelain helpers
|
|
160
|
-
# ---------------------------------------------------------------------------
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
def _resolve_path(raw: str, repo_root: Path) -> Path:
|
|
164
|
-
"""Resolve ``raw`` to an absolute path, relative paths against repo_root."""
|
|
165
|
-
candidate = Path(raw)
|
|
166
|
-
if not candidate.is_absolute():
|
|
167
|
-
candidate = repo_root / candidate
|
|
168
|
-
return candidate.resolve()
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
def _compare_key(path: Path) -> str:
|
|
172
|
-
"""Return a case-normalized comparison key for worktree-path equality.
|
|
173
|
-
|
|
174
|
-
``os.path.normcase`` folds case + slash direction on Windows so a record
|
|
175
|
-
path and the git porcelain path compare equal regardless of how the
|
|
176
|
-
operator typed them; ``resolve`` (applied by the caller) collapses
|
|
177
|
-
symlinks / short names first.
|
|
178
|
-
"""
|
|
179
|
-
return os.path.normcase(str(path))
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def parse_worktree_porcelain(text: str) -> dict[str, str | None]:
|
|
183
|
-
"""Parse ``git worktree list --porcelain`` into ``{compare_key: branch}``.
|
|
184
|
-
|
|
185
|
-
Each porcelain stanza opens with a ``worktree <path>`` line and may carry
|
|
186
|
-
a ``branch refs/heads/<name>`` line (absent for a detached / bare entry).
|
|
187
|
-
Returns a mapping from the resolved, case-normalized worktree path to its
|
|
188
|
-
branch short-name (or ``None`` when detached / bare). Note that
|
|
189
|
-
``Path.resolve()`` is called on each path, so this issues one
|
|
190
|
-
``realpath`` / ``readlink`` syscall per worktree stanza (not pure).
|
|
191
|
-
"""
|
|
192
|
-
registered: dict[str, str | None] = {}
|
|
193
|
-
current_path: Path | None = None
|
|
194
|
-
current_branch: str | None = None
|
|
195
|
-
|
|
196
|
-
def _flush() -> None:
|
|
197
|
-
if current_path is not None:
|
|
198
|
-
registered[_compare_key(current_path)] = current_branch
|
|
199
|
-
|
|
200
|
-
for line in text.splitlines():
|
|
201
|
-
if line.startswith("worktree "):
|
|
202
|
-
# A new stanza begins; flush the previous one first.
|
|
203
|
-
_flush()
|
|
204
|
-
current_path = Path(line[len("worktree ") :].strip()).resolve()
|
|
205
|
-
current_branch = None
|
|
206
|
-
elif line.startswith("branch "):
|
|
207
|
-
ref = line[len("branch ") :].strip()
|
|
208
|
-
current_branch = ref[len("refs/heads/") :] if ref.startswith("refs/heads/") else ref
|
|
209
|
-
_flush()
|
|
210
|
-
return registered
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
# ---------------------------------------------------------------------------
|
|
214
|
-
# git wrappers
|
|
215
|
-
# ---------------------------------------------------------------------------
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
def _git_worktree_list(repo_root: Path) -> dict[str, str | None]:
|
|
219
|
-
"""Return the registered worktrees as ``{compare_key: branch}``.
|
|
220
|
-
|
|
221
|
-
Raises :class:`WorktreeMapConfigError` when git cannot be spawned or the
|
|
222
|
-
directory is not a git work tree -- the resolver fails closed rather than
|
|
223
|
-
assuming an empty worktree set.
|
|
224
|
-
"""
|
|
225
|
-
try:
|
|
226
|
-
proc = run_text(["git", "worktree", "list", "--porcelain"], cwd=str(repo_root))
|
|
227
|
-
except OSError as exc: # git not on PATH / no execute permission
|
|
228
|
-
raise WorktreeMapConfigError(
|
|
229
|
-
f"could not run `git worktree list` in {repo_root}: {exc}"
|
|
230
|
-
) from exc
|
|
231
|
-
if proc.returncode != 0:
|
|
232
|
-
raise WorktreeMapConfigError(
|
|
233
|
-
f"`git worktree list` failed in {repo_root} (rc={proc.returncode}): "
|
|
234
|
-
f"{proc.stderr.strip() or '<no stderr>'} -- is this a git work tree?"
|
|
235
|
-
)
|
|
236
|
-
return parse_worktree_porcelain(proc.stdout)
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
def _create_worktree(repo_root: Path, worktree_path: Path, base_branch: str) -> None:
|
|
240
|
-
"""Create a detached worktree at ``worktree_path`` from ``base_branch``.
|
|
241
|
-
|
|
242
|
-
The leaf directory is created by git; we pre-create any missing parent
|
|
243
|
-
directories so ``git worktree add`` does not fail on a deep target path.
|
|
244
|
-
Detached HEAD is deliberate -- the per-story branch is the launch
|
|
245
|
-
engine's concern (C2), so the resolver does not claim a branch name.
|
|
246
|
-
|
|
247
|
-
Raises :class:`WorktreeMapConfigError` on any git failure (e.g. the base
|
|
248
|
-
branch does not exist, or the target path already exists as a non-empty
|
|
249
|
-
non-worktree directory).
|
|
250
|
-
"""
|
|
251
|
-
worktree_path.parent.mkdir(parents=True, exist_ok=True)
|
|
252
|
-
try:
|
|
253
|
-
proc = run_text(
|
|
254
|
-
["git", "worktree", "add", "--detach", str(worktree_path), base_branch],
|
|
255
|
-
cwd=str(repo_root),
|
|
256
|
-
)
|
|
257
|
-
except OSError as exc:
|
|
258
|
-
raise WorktreeMapConfigError(
|
|
259
|
-
f"could not run `git worktree add` for {worktree_path}: {exc}"
|
|
260
|
-
) from exc
|
|
261
|
-
if proc.returncode != 0:
|
|
262
|
-
raise WorktreeMapConfigError(
|
|
263
|
-
f"`git worktree add --detach {worktree_path} {base_branch}` failed "
|
|
264
|
-
f"(rc={proc.returncode}): {proc.stderr.strip() or '<no stderr>'}"
|
|
265
|
-
)
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
# ---------------------------------------------------------------------------
|
|
269
|
-
# core resolver (FROZEN C3 contract)
|
|
270
|
-
# ---------------------------------------------------------------------------
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
def resolve_worktree_map(
|
|
274
|
-
mapping: list[dict],
|
|
275
|
-
base_branch: str,
|
|
276
|
-
create_missing: bool = True,
|
|
277
|
-
*,
|
|
278
|
-
repo_root: str | os.PathLike[str] | None = None,
|
|
279
|
-
) -> list[dict]:
|
|
280
|
-
"""Resolve a story-to-worktree mapping into normalized C3 records.
|
|
281
|
-
|
|
282
|
-
Args:
|
|
283
|
-
mapping: List of ``{story_id, worktree_path, base_branch?}`` records.
|
|
284
|
-
base_branch: The cohort-wide base branch every worktree is based on.
|
|
285
|
-
create_missing: When true (default) create any worktree that is not
|
|
286
|
-
yet registered, from ``base_branch``; when false a missing
|
|
287
|
-
worktree raises :class:`MissingWorktreeError`.
|
|
288
|
-
repo_root: Git repository the worktrees belong to. Defaults to the
|
|
289
|
-
current working directory. Keyword-only so the frozen positional
|
|
290
|
-
signature ``(mapping, base_branch, create_missing=True)`` is
|
|
291
|
-
preserved for the launch engine.
|
|
292
|
-
|
|
293
|
-
Returns:
|
|
294
|
-
A list of normalized C3 records, each with exactly
|
|
295
|
-
``{"story_id", "worktree_path", "base_branch"}``; ``worktree_path``
|
|
296
|
-
is an absolute POSIX path and ``base_branch`` is the cohort base.
|
|
297
|
-
Output order mirrors the input order.
|
|
298
|
-
|
|
299
|
-
Raises:
|
|
300
|
-
WorktreeMapConfigError: malformed record (missing/blank required
|
|
301
|
-
field), non-list mapping, blank ``base_branch``, git unavailable,
|
|
302
|
-
or a failed ``git worktree add``.
|
|
303
|
-
BaseBranchMismatchError: a record's ``base_branch`` differs from the
|
|
304
|
-
configured cohort ``base_branch``.
|
|
305
|
-
WorktreeCollisionError: two stories map to the same worktree path.
|
|
306
|
-
DuplicateStoryError: the same ``story_id`` appears more than once.
|
|
307
|
-
MissingWorktreeError: a mapped worktree is absent and
|
|
308
|
-
``create_missing`` is false.
|
|
309
|
-
"""
|
|
310
|
-
if not isinstance(mapping, list):
|
|
311
|
-
raise WorktreeMapConfigError(
|
|
312
|
-
f"worktree map must be a list of records, got {type(mapping).__name__}"
|
|
313
|
-
)
|
|
314
|
-
if not isinstance(base_branch, str) or not base_branch.strip():
|
|
315
|
-
raise WorktreeMapConfigError("base_branch must be a non-empty string")
|
|
316
|
-
base_branch = base_branch.strip()
|
|
317
|
-
|
|
318
|
-
root = Path(repo_root).resolve() if repo_root is not None else Path.cwd().resolve()
|
|
319
|
-
|
|
320
|
-
# First pass: validate record shape, base-branch agreement, and collisions
|
|
321
|
-
# WITHOUT touching git. This keeps the cheap, deterministic checks ahead of
|
|
322
|
-
# the (potentially mutating) git creation step so a bad map fails fast.
|
|
323
|
-
resolved: list[dict] = []
|
|
324
|
-
seen_paths: dict[str, str] = {} # compare_key -> first story_id
|
|
325
|
-
seen_story_ids: dict[str, str] = {} # story_id -> first worktree_path
|
|
326
|
-
for index, record in enumerate(mapping):
|
|
327
|
-
if not isinstance(record, dict):
|
|
328
|
-
raise WorktreeMapConfigError(
|
|
329
|
-
f"record #{index} must be an object, got {type(record).__name__}"
|
|
330
|
-
)
|
|
331
|
-
story_id = record.get("story_id")
|
|
332
|
-
if not isinstance(story_id, str) or not story_id.strip():
|
|
333
|
-
raise WorktreeMapConfigError(
|
|
334
|
-
f"record #{index} is missing a non-empty 'story_id'"
|
|
335
|
-
)
|
|
336
|
-
story_id = story_id.strip()
|
|
337
|
-
raw_path = record.get("worktree_path")
|
|
338
|
-
if not isinstance(raw_path, str) or not raw_path.strip():
|
|
339
|
-
raise WorktreeMapConfigError(
|
|
340
|
-
f"story {story_id!r} is missing a non-empty 'worktree_path'"
|
|
341
|
-
)
|
|
342
|
-
|
|
343
|
-
record_base = record.get("base_branch")
|
|
344
|
-
if record_base is not None:
|
|
345
|
-
if not isinstance(record_base, str) or not record_base.strip():
|
|
346
|
-
raise WorktreeMapConfigError(
|
|
347
|
-
f"story {story_id!r} has a non-string / blank 'base_branch'"
|
|
348
|
-
)
|
|
349
|
-
if record_base.strip() != base_branch:
|
|
350
|
-
raise BaseBranchMismatchError(
|
|
351
|
-
f"story {story_id!r} declares base_branch "
|
|
352
|
-
f"{record_base.strip()!r} but the cohort base branch is "
|
|
353
|
-
f"{base_branch!r}"
|
|
354
|
-
)
|
|
355
|
-
|
|
356
|
-
worktree_path = _resolve_path(raw_path.strip(), root)
|
|
357
|
-
key = _compare_key(worktree_path)
|
|
358
|
-
if key in seen_paths:
|
|
359
|
-
raise WorktreeCollisionError(
|
|
360
|
-
f"worktree path collision: stories {seen_paths[key]!r} and "
|
|
361
|
-
f"{story_id!r} both map to {worktree_path.as_posix()!r}"
|
|
362
|
-
)
|
|
363
|
-
if story_id in seen_story_ids:
|
|
364
|
-
raise DuplicateStoryError(
|
|
365
|
-
f"duplicate story_id {story_id!r}: mapped to both "
|
|
366
|
-
f"{seen_story_ids[story_id]!r} and {worktree_path.as_posix()!r}"
|
|
367
|
-
)
|
|
368
|
-
seen_paths[key] = story_id
|
|
369
|
-
seen_story_ids[story_id] = worktree_path.as_posix()
|
|
370
|
-
resolved.append(
|
|
371
|
-
{
|
|
372
|
-
"story_id": story_id,
|
|
373
|
-
"worktree_path": worktree_path.as_posix(),
|
|
374
|
-
"base_branch": base_branch,
|
|
375
|
-
# internal-only carry; stripped before return.
|
|
376
|
-
"_key": key,
|
|
377
|
-
"_abs": str(worktree_path),
|
|
378
|
-
}
|
|
379
|
-
)
|
|
380
|
-
|
|
381
|
-
# Second pass: reconcile against real git worktree state, creating missing
|
|
382
|
-
# worktrees idempotently when permitted.
|
|
383
|
-
registered = _git_worktree_list(root)
|
|
384
|
-
for entry in resolved:
|
|
385
|
-
key = entry.pop("_key")
|
|
386
|
-
abs_path = entry.pop("_abs")
|
|
387
|
-
if key in registered:
|
|
388
|
-
# Already a registered worktree -> accept idempotently.
|
|
389
|
-
continue
|
|
390
|
-
if not create_missing:
|
|
391
|
-
raise MissingWorktreeError(
|
|
392
|
-
f"story {entry['story_id']!r} maps to {entry['worktree_path']!r} "
|
|
393
|
-
"which is not a registered git worktree and create_missing is "
|
|
394
|
-
"disabled"
|
|
395
|
-
)
|
|
396
|
-
_create_worktree(root, Path(abs_path), base_branch)
|
|
397
|
-
|
|
398
|
-
return resolved
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
# ---------------------------------------------------------------------------
|
|
402
|
-
# CLI plumbing
|
|
403
|
-
# ---------------------------------------------------------------------------
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
def _load_map(map_path: Path) -> list[dict]:
|
|
407
|
-
"""Read + JSON-parse the worktree-map file. Raises WorktreeMapConfigError."""
|
|
408
|
-
try:
|
|
409
|
-
raw = map_path.read_text(encoding="utf-8")
|
|
410
|
-
except (OSError, UnicodeDecodeError) as exc:
|
|
411
|
-
raise WorktreeMapConfigError(f"could not read worktree map {map_path}: {exc}") from exc
|
|
412
|
-
try:
|
|
413
|
-
data: Any = json.loads(raw)
|
|
414
|
-
except json.JSONDecodeError as exc:
|
|
415
|
-
raise WorktreeMapConfigError(
|
|
416
|
-
f"worktree map {map_path} is not valid JSON: {exc.msg} (line {exc.lineno})"
|
|
417
|
-
) from exc
|
|
418
|
-
if not isinstance(data, list):
|
|
419
|
-
raise WorktreeMapConfigError(
|
|
420
|
-
f"worktree map {map_path} top-level value must be a JSON array"
|
|
421
|
-
)
|
|
422
|
-
return data
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
def _build_parser() -> argparse.ArgumentParser:
|
|
426
|
-
parser = argparse.ArgumentParser(
|
|
427
|
-
prog="swarm_worktrees.py",
|
|
428
|
-
description=(
|
|
429
|
-
"Resolve a swarm story-to-worktree mapping into a normalized, "
|
|
430
|
-
"git-validated worktree map (#1387). Validates base-branch "
|
|
431
|
-
"agreement, rejects same-path collisions, and idempotently "
|
|
432
|
-
"creates missing worktrees from the base branch. Three-state exit "
|
|
433
|
-
"(0 resolved / 1 validation error / 2 config error)."
|
|
434
|
-
),
|
|
435
|
-
)
|
|
436
|
-
parser.add_argument(
|
|
437
|
-
"--map",
|
|
438
|
-
dest="map_path",
|
|
439
|
-
required=True,
|
|
440
|
-
help=(
|
|
441
|
-
"Path to the worktree-map JSON file (array of "
|
|
442
|
-
"{story_id, worktree_path, base_branch})."
|
|
443
|
-
),
|
|
444
|
-
)
|
|
445
|
-
parser.add_argument(
|
|
446
|
-
"--base-branch",
|
|
447
|
-
required=True,
|
|
448
|
-
help="The cohort-wide base branch every worktree is based on.",
|
|
449
|
-
)
|
|
450
|
-
parser.add_argument(
|
|
451
|
-
"--repo-root",
|
|
452
|
-
default=".",
|
|
453
|
-
help="Git repository the worktrees belong to (default: cwd).",
|
|
454
|
-
)
|
|
455
|
-
parser.add_argument(
|
|
456
|
-
"--no-create-missing",
|
|
457
|
-
dest="create_missing",
|
|
458
|
-
action="store_false",
|
|
459
|
-
help=(
|
|
460
|
-
"Do NOT create missing worktrees; a mapped worktree that is not "
|
|
461
|
-
"already registered becomes a validation error (exit 1)."
|
|
462
|
-
),
|
|
463
|
-
)
|
|
464
|
-
return parser
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
def main(argv: list[str] | None = None) -> int:
|
|
468
|
-
# Force UTF-8 stdout/stderr at entry so the resolver's messages survive a
|
|
469
|
-
# Windows codepage-default stdout (mirrors scripts/preflight_story_start.py).
|
|
470
|
-
if hasattr(sys.stdout, "reconfigure"):
|
|
471
|
-
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
472
|
-
if hasattr(sys.stderr, "reconfigure"):
|
|
473
|
-
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
474
|
-
|
|
475
|
-
args = _build_parser().parse_args(argv)
|
|
476
|
-
try:
|
|
477
|
-
mapping = _load_map(Path(args.map_path))
|
|
478
|
-
resolved = resolve_worktree_map(
|
|
479
|
-
mapping,
|
|
480
|
-
args.base_branch,
|
|
481
|
-
args.create_missing,
|
|
482
|
-
repo_root=args.repo_root,
|
|
483
|
-
)
|
|
484
|
-
except WorktreeMapError as exc:
|
|
485
|
-
# Logical validation failure (collision / base mismatch / missing).
|
|
486
|
-
print(f"error: {exc}", file=sys.stderr)
|
|
487
|
-
return EXIT_VALIDATION_ERROR
|
|
488
|
-
except WorktreeMapConfigError as exc:
|
|
489
|
-
print(f"config error: {exc}", file=sys.stderr)
|
|
490
|
-
return EXIT_CONFIG_ERROR
|
|
491
|
-
|
|
492
|
-
print(json.dumps(resolved, indent=2))
|
|
493
|
-
return EXIT_OK
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
if __name__ == "__main__":
|
|
497
|
-
sys.exit(main())
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
"""Verify required source-repo toolchain is installed (go, uv, git, gh, node, pnpm)."""
|
|
2
|
-
|
|
3
|
-
import subprocess
|
|
4
|
-
import sys
|
|
5
|
-
|
|
6
|
-
TOOLS = [
|
|
7
|
-
("go", ["go", "version"]),
|
|
8
|
-
("uv", ["uv", "--version"]),
|
|
9
|
-
("git", ["git", "--version"]),
|
|
10
|
-
("gh", ["gh", "--version"]),
|
|
11
|
-
("node", ["node", "--version"]),
|
|
12
|
-
("pnpm", ["pnpm", "--version"]),
|
|
13
|
-
]
|
|
14
|
-
|
|
15
|
-
NODE_RUNTIME_TOOLS = frozenset({"node", "pnpm"})
|
|
16
|
-
NODE_RUNTIME_REMEDIATION = (
|
|
17
|
-
"Node.js and pnpm are required for TS-backed deft gates. Install Node 20+ "
|
|
18
|
-
"(see .nvmrc), then run: corepack enable && corepack prepare pnpm@latest "
|
|
19
|
-
"--activate. See UPGRADING.md § Node runtime."
|
|
20
|
-
)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def main() -> int:
|
|
24
|
-
failed = []
|
|
25
|
-
for name, cmd in TOOLS:
|
|
26
|
-
try:
|
|
27
|
-
r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
|
28
|
-
version = (r.stdout or r.stderr).strip().split("\n")[0]
|
|
29
|
-
if r.returncode == 0:
|
|
30
|
-
print(f" {name}: {version}")
|
|
31
|
-
else:
|
|
32
|
-
failed.append(name)
|
|
33
|
-
print(f" {name}: FAILED (exit {r.returncode})")
|
|
34
|
-
except FileNotFoundError:
|
|
35
|
-
failed.append(name)
|
|
36
|
-
print(f" {name}: NOT FOUND")
|
|
37
|
-
except Exception as e:
|
|
38
|
-
failed.append(name)
|
|
39
|
-
print(f" {name}: ERROR - {e}")
|
|
40
|
-
|
|
41
|
-
print()
|
|
42
|
-
if failed:
|
|
43
|
-
print(f"Missing tools: {', '.join(failed)}")
|
|
44
|
-
if any(name in NODE_RUNTIME_TOOLS for name in failed):
|
|
45
|
-
print(NODE_RUNTIME_REMEDIATION)
|
|
46
|
-
return 1
|
|
47
|
-
print("All required tools available")
|
|
48
|
-
return 0
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if __name__ == "__main__":
|
|
52
|
-
sys.exit(main())
|