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