@deftai/directive-content 0.59.0 → 0.61.0

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