workstate-bootstrap 0.7.0__tar.gz → 0.7.2__tar.gz

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 (37) hide show
  1. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/CHANGELOG.md +52 -0
  2. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/PKG-INFO +34 -3
  3. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/README.md +32 -1
  4. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/pyproject.toml +2 -2
  5. workstate_bootstrap-0.7.2/src/workstate_bootstrap/adopt.py +289 -0
  6. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/src/workstate_bootstrap/cli.py +83 -13
  7. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/src/workstate_bootstrap/install.py +264 -80
  8. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/src/workstate_bootstrap/subcommands.py +59 -0
  9. workstate_bootstrap-0.7.2/src/workstate_bootstrap/worktree.py +165 -0
  10. workstate_bootstrap-0.7.2/tests/test_adopt_worktree.py +658 -0
  11. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/tests/test_bootstrap_install_rehearsal.py +1 -1
  12. workstate_bootstrap-0.7.2/tests/test_gitignore_block.py +104 -0
  13. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/tests/test_install.py +5 -1
  14. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/tests/test_package_source.py +10 -1
  15. workstate_bootstrap-0.7.2/tests/test_worktree.py +262 -0
  16. workstate_bootstrap-0.7.2/tests/test_worktree_doctor.py +181 -0
  17. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/.gitignore +0 -0
  18. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/src/workstate_bootstrap/__init__.py +0 -0
  19. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/src/workstate_bootstrap/__main__.py +0 -0
  20. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/src/workstate_bootstrap/mcp_sync.py +0 -0
  21. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/tests/__init__.py +0 -0
  22. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/tests/test_cli_profile.py +0 -0
  23. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/tests/test_doctor_repair_sync.py +0 -0
  24. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/tests/test_ensure_hooks_path_make.py +0 -0
  25. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/tests/test_install_manifest_walker.py +0 -0
  26. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/tests/test_install_profile_all_lifecycle.py +0 -0
  27. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/tests/test_install_profiles.py +0 -0
  28. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/tests/test_manifest_build.py +0 -0
  29. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/tests/test_mcp_sync_cli.py +0 -0
  30. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/tests/test_mcp_sync_e2e.py +0 -0
  31. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/tests/test_mcp_sync_malformed.py +0 -0
  32. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/tests/test_mcp_sync_prune.py +0 -0
  33. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/tests/test_mcp_sync_unit.py +0 -0
  34. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/tests/test_package_metadata.py +0 -0
  35. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/tests/test_render_seam.py +0 -0
  36. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/tests/test_root_visible_task_plans.py +0 -0
  37. {workstate_bootstrap-0.7.0 → workstate_bootstrap-0.7.2}/tests/test_subcommands.py +0 -0
@@ -2,6 +2,58 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## [0.7.2] — 2026-06-02
6
+
7
+ > Ships the linked-worktree overlay self-heal (implementation note) **and** its deferred
8
+ > follow-ups (implementation note), both of which landed after 0.7.1 was published.
9
+
10
+ ### Added
11
+
12
+ - **Linked-worktree overlay self-heal (implementation note):** `adopt-worktree` re-runs the
13
+ install materializer against a linked worktree with `clone=<primary>/.workstate/remote`;
14
+ worktree-aware `doctor`/`repair` short-circuit emits a single `unadopted_worktree`
15
+ finding; a managed sentinel-delimited `.gitignore` block keeps an adopted
16
+ worktree's `git status` clean.
17
+
18
+ ### Changed
19
+
20
+ - **Apply and `--check` share one surface enumeration (implementation note S1,
21
+ `revB-install-private-symbol-coupling`):** new `iter_expected_surface_targets`
22
+ is the single source of the surface/carve/exclusion rule consumed by both the
23
+ materializer and `adopt._compute_drift`, so the drift guard can no longer
24
+ desync from apply; the `_materialize_surfaces_copy` (package-install) path is
25
+ single-sourced through the same helper. `adopt` drops the `importlib` shim for
26
+ explicit imports.
27
+ - **Overlay-root resolver prefers a materialized overlay (implementation note S3,
28
+ `revA-overlay-root-unbounded-walk`):** the upward walk skips an unmaterialized
29
+ stray ancestor marker, falling back to the nearest marker so a genuinely
30
+ un-materialized primary still fails loudly.
31
+ - **Relocation repoint (implementation note S3b, `revB-relocation-dangling-symlink-no-repoint`):**
32
+ a dangling bootstrap-owned surface link (e.g. a relocated primary) is repointed
33
+ to the live clone via a shared, segment-anchored repoint predicate used by both
34
+ apply and `--check`.
35
+
36
+ ### Notes
37
+
38
+ - `.claude-plugin/marketplace.json` continues to resolve via the tracked file +
39
+ the adopted `.workstate/generated` symlink; `adopt` does not materialize it
40
+ (implementation note S2 locks this contract). Consumers that gitignore `.claude-plugin`
41
+ need a separate `install` pass.
42
+
43
+
44
+ ## [0.7.1] — 2026-06-02
45
+
46
+ ### Fixed
47
+
48
+ - **Default managed orchestrator pin realigned to
49
+ `mcp-workstate-orchestrator@0.5.1`.** The 0.7.0 coordinated release
50
+ published orchestrator 0.5.1 but left `DEFAULT_MCP_SERVERS` pinned at
51
+ `@0.5.0`, so package-source / default-server installs launched the
52
+ superseded 0.5.0 wheel via `uvx`. The pin (and its two drift-guard test
53
+ assertions) now track 0.5.1; `mcp-workstate-handoff` stays at `@0.12.0`.
54
+ git_overlay installs were unaffected (they run from the cloned source).
55
+
56
+
5
57
  ## [0.7.0] — 2026-06-01
6
58
 
7
59
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workstate-bootstrap
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Bootstrap CLI that hoists the shared workstate-system surface into consumer repos.
5
5
  Project-URL: Homepage, https://github.com/darce/workstate
6
6
  Project-URL: Source, https://github.com/darce/workstate/tree/main/packages/workstate-bootstrap
@@ -12,7 +12,7 @@ Requires-Python: >=3.11
12
12
  Requires-Dist: pyyaml>=6
13
13
  Requires-Dist: tomlkit>=0.13
14
14
  Requires-Dist: workstate-protocol<0.2.0,>=0.1.7
15
- Requires-Dist: workstate-system<0.2.0,>=0.1.0
15
+ Requires-Dist: workstate-system<0.2.0,>=0.1.2
16
16
  Provides-Extra: dev
17
17
  Requires-Dist: mcp-workstate-handoff; (python_version >= '3.12') and extra == 'dev'
18
18
  Requires-Dist: pytest>=8; extra == 'dev'
@@ -84,6 +84,7 @@ workstate-bootstrap update --target <path> --remote-ref <tag>
84
84
  workstate-bootstrap status --target <path>
85
85
  workstate-bootstrap doctor --target <path> [--mcp-servers <default|path>]
86
86
  workstate-bootstrap repair --target <path> [--force-dirty] [--mcp-servers <default|path>]
87
+ workstate-bootstrap adopt-worktree [--target <linked-worktree>] [--primary <root>] [--check] [--json]
87
88
  ```
88
89
 
89
90
  - `install`: Clone the monorepo, materialize SHARED + GENERATED
@@ -101,7 +102,37 @@ workstate-bootstrap repair --target <path> [--force-dirty] [--mcp-servers <defa
101
102
  initialized-state surfaces. Flags missing `.task-state/handoff.db`
102
103
  as `state_drift` only when the manifest recorded `.mcp.json`. Exit
103
104
  `1` when drift exists.
104
- - `repair`: Restore drifted surfaces flagged by `doctor`.
105
+ - `repair`: Restore drifted surfaces flagged by `doctor`. For an
106
+ unadopted linked worktree this routes to `adopt-worktree` (below).
107
+ - `adopt-worktree`: Materialize the overlay into a **linked git
108
+ worktree** by redirecting its surfaces at the primary's
109
+ `.workstate/remote` clone (one hop, relative links). `--target`
110
+ defaults to the current directory; the primary is resolved by the
111
+ `.workstate-bootstrap.json` marker unless `--primary` is given.
112
+ `--check` reports drift without writing and exits `1` when the
113
+ worktree is unadopted. A no-op on the primary worktree.
114
+
115
+ ### Linked worktrees
116
+
117
+ A linked worktree (`git worktree add`, or an IDE/agent auto-worktree)
118
+ shares the primary's `.git` but **not** gitignored files, so the
119
+ overlay starts absent — the plugin is enabled (tracked
120
+ `.claude/settings.json`) but unresolvable. Self-heal works as follows:
121
+
122
+ - **`make task-start`** (the supported flow) adopts the overlay into the
123
+ new worktree automatically, so it works out of the box. Set
124
+ `WORKSTATE_ADOPT_CMD=""` to disable that auto-adopt (e.g. inside the
125
+ workstate monorepo, where worktrees resolve via the editable `.venv`).
126
+ - **Raw `git worktree add` / auto-worktrees** are healed on demand:
127
+
128
+ ```bash
129
+ uvx workstate-bootstrap adopt-worktree --target <worktree>
130
+ # or, as a steady-state guard (exit 1 on drift):
131
+ uvx workstate-bootstrap adopt-worktree --target <worktree> --check
132
+ ```
133
+
134
+ `.task-state/`, `DASHBOARD.txt`, and `CURRENT_TASK.json` are **never**
135
+ adopted — they stay per-worktree (the handoff DB is primary-rooted).
105
136
 
106
137
  See [`docs/CONSUMER.md`](https://github.com/darce/workstate/blob/main/docs/CONSUMER.md)
107
138
  for the consumer-facing walkthrough (upgrade, drift handling, skill
@@ -64,6 +64,7 @@ workstate-bootstrap update --target <path> --remote-ref <tag>
64
64
  workstate-bootstrap status --target <path>
65
65
  workstate-bootstrap doctor --target <path> [--mcp-servers <default|path>]
66
66
  workstate-bootstrap repair --target <path> [--force-dirty] [--mcp-servers <default|path>]
67
+ workstate-bootstrap adopt-worktree [--target <linked-worktree>] [--primary <root>] [--check] [--json]
67
68
  ```
68
69
 
69
70
  - `install`: Clone the monorepo, materialize SHARED + GENERATED
@@ -81,7 +82,37 @@ workstate-bootstrap repair --target <path> [--force-dirty] [--mcp-servers <defa
81
82
  initialized-state surfaces. Flags missing `.task-state/handoff.db`
82
83
  as `state_drift` only when the manifest recorded `.mcp.json`. Exit
83
84
  `1` when drift exists.
84
- - `repair`: Restore drifted surfaces flagged by `doctor`.
85
+ - `repair`: Restore drifted surfaces flagged by `doctor`. For an
86
+ unadopted linked worktree this routes to `adopt-worktree` (below).
87
+ - `adopt-worktree`: Materialize the overlay into a **linked git
88
+ worktree** by redirecting its surfaces at the primary's
89
+ `.workstate/remote` clone (one hop, relative links). `--target`
90
+ defaults to the current directory; the primary is resolved by the
91
+ `.workstate-bootstrap.json` marker unless `--primary` is given.
92
+ `--check` reports drift without writing and exits `1` when the
93
+ worktree is unadopted. A no-op on the primary worktree.
94
+
95
+ ### Linked worktrees
96
+
97
+ A linked worktree (`git worktree add`, or an IDE/agent auto-worktree)
98
+ shares the primary's `.git` but **not** gitignored files, so the
99
+ overlay starts absent — the plugin is enabled (tracked
100
+ `.claude/settings.json`) but unresolvable. Self-heal works as follows:
101
+
102
+ - **`make task-start`** (the supported flow) adopts the overlay into the
103
+ new worktree automatically, so it works out of the box. Set
104
+ `WORKSTATE_ADOPT_CMD=""` to disable that auto-adopt (e.g. inside the
105
+ workstate monorepo, where worktrees resolve via the editable `.venv`).
106
+ - **Raw `git worktree add` / auto-worktrees** are healed on demand:
107
+
108
+ ```bash
109
+ uvx workstate-bootstrap adopt-worktree --target <worktree>
110
+ # or, as a steady-state guard (exit 1 on drift):
111
+ uvx workstate-bootstrap adopt-worktree --target <worktree> --check
112
+ ```
113
+
114
+ `.task-state/`, `DASHBOARD.txt`, and `CURRENT_TASK.json` are **never**
115
+ adopted — they stay per-worktree (the handoff DB is primary-rooted).
85
116
 
86
117
  See [`docs/CONSUMER.md`](https://github.com/darce/workstate/blob/main/docs/CONSUMER.md)
87
118
  for the consumer-facing walkthrough (upgrade, drift handling, skill
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "workstate-bootstrap"
7
- version = "0.7.0"
7
+ version = "0.7.2"
8
8
  description = "Bootstrap CLI that hoists the shared workstate-system surface into consumer repos."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -14,7 +14,7 @@ dependencies = [
14
14
  "tomlkit>=0.13",
15
15
  "pyyaml>=6",
16
16
  "workstate-protocol>=0.1.7,<0.2.0",
17
- "workstate-system>=0.1.0,<0.2.0",
17
+ "workstate-system>=0.1.2,<0.2.0",
18
18
  ]
19
19
 
20
20
  [project.scripts]
@@ -0,0 +1,289 @@
1
+ """Adopt the bootstrap overlay into a linked git worktree (implementation note, Slice S1).
2
+
3
+ A linked worktree shares the primary's ``.git`` but not gitignored files, so the
4
+ bootstrap overlay is absent until it is adopted. ``adopt_worktree`` re-runs the
5
+ *existing* install materialization passes against the worktree, but with
6
+ ``clone = <primary>/.workstate/remote`` — reusing the battle-tested carve logic,
7
+ foreign-file precedence, relative-link computation (links point one hop at the
8
+ primary's real clone), and the lifecycle real-file hoist.
9
+
10
+ Isolation is safe-by-construction rather than via a runtime allow-list (decision
11
+ ``adopt-drop-runtime-allowlist-for-materializer-scope-invariant``):
12
+
13
+ * The materializer only ever touches the recorded shared/generated/lifecycle
14
+ surfaces + the clone — the per-worktree mutable set (``.task-state``,
15
+ ``DASHBOARD.txt``, ``CURRENT_TASK.json``, ``state-backups`` …) is never a
16
+ surface, so it is never adopted.
17
+ * ``.workstate/`` is kept a real *local* directory; only its ``remote`` and
18
+ ``generated`` children are symlinked to the primary, so sibling per-worktree
19
+ state under ``.workstate/`` stays local.
20
+ * ``_run_init_state`` / MCP presync are intentionally NOT run — the worktree uses
21
+ the primary-rooted MCP server; adopt never fabricates a second ``.task-state``.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import subprocess
28
+ from pathlib import Path
29
+
30
+ # ``workstate_bootstrap.__init__`` re-exports the install *function*, which
31
+ # shadows the submodule only under *attribute* access (``import ... as`` and
32
+ # ``getattr(pkg, "install")`` both yield the function). A direct
33
+ # ``from workstate_bootstrap.install import ...`` submodule import is
34
+ # unaffected — no importlib indirection needed. adopt reuses the install
35
+ # materializer's surface enumeration + apply passes (shared so apply and
36
+ # ``--check`` cannot desync); the private names below are install internals it
37
+ # orchestrates.
38
+ from workstate_bootstrap.install import (
39
+ GITIGNORE_SENTINEL_BEGIN,
40
+ HOOKS_PATH_VALUE,
41
+ LEGACY_LIFECYCLE_INCLUDE_SENTINEL_BEGIN,
42
+ LIFECYCLE_HOISTS,
43
+ LIFECYCLE_INCLUDE_SENTINEL_BEGIN,
44
+ RUNTIME_ROOT_DIRNAME,
45
+ _ensure_consumer_gitignore_block,
46
+ _ensure_consumer_makefile_include,
47
+ _git,
48
+ _install_lifecycle_profile,
49
+ _is_repointable_bootstrap_symlink,
50
+ _materialize_surfaces,
51
+ _prepare_generated_surfaces,
52
+ _raw_symlink_target_path,
53
+ _resolve_in_clone,
54
+ _set_git_hooks_path,
55
+ iter_expected_surface_targets,
56
+ )
57
+ from workstate_bootstrap.worktree import (
58
+ WorktreeError,
59
+ is_linked_worktree,
60
+ overlay_is_materialized,
61
+ primary_overlay_root,
62
+ )
63
+
64
+ _RUNTIME = RUNTIME_ROOT_DIRNAME # ".workstate"
65
+ _WORKSTATE_CHILDREN = ("remote", "generated")
66
+
67
+
68
+ class OverlayNotMaterializedError(WorktreeError):
69
+ """The resolved primary has no materialized overlay to adopt from."""
70
+
71
+
72
+ def _materialize_workstate_child(target: Path, primary: Path, child: str) -> bool:
73
+ """Symlink ``target/.workstate/<child>`` -> ``primary/.workstate/<child>``.
74
+
75
+ ``.workstate/`` itself stays a real local directory (created if absent) so
76
+ sibling per-worktree state does not leak across worktrees. The child link is
77
+ relative for relocation safety. Foreign/real local content at the child path
78
+ is left untouched (overlay precedence). Returns True when the source exists.
79
+ """
80
+ src = primary / _RUNTIME / child
81
+ if not src.exists():
82
+ return False
83
+ ws = target / _RUNTIME
84
+ if ws.is_symlink():
85
+ # A pre-existing .workstate SYMLINK would route the remote/generated
86
+ # child links THROUGH it (into the primary or a foreign dir) — exactly
87
+ # the cross-worktree contamination the isolation invariant forbids.
88
+ # Replace it with a real local directory before materializing children.
89
+ ws.unlink()
90
+ ws.mkdir(parents=True, exist_ok=True)
91
+ dest = ws / child
92
+ rel = os.path.relpath(src, ws)
93
+ if dest.is_symlink():
94
+ if dest.resolve(strict=False) == src.resolve():
95
+ return True
96
+ dest.unlink()
97
+ elif dest.exists():
98
+ return True # foreign/real local content wins
99
+ dest.symlink_to(rel, target_is_directory=src.is_dir())
100
+ return True
101
+
102
+
103
+ def _hooks_path_value(target: Path) -> str:
104
+ try:
105
+ return _git("config", "core.hooksPath", cwd=target)
106
+ except subprocess.CalledProcessError:
107
+ return ""
108
+
109
+
110
+ def _link_drifts(
111
+ target_path: Path,
112
+ expected_src: Path,
113
+ clone_resolved: Path,
114
+ remote_subtree_prefix: str,
115
+ ) -> bool:
116
+ """True iff a bootstrap-owned surface link is missing or stale.
117
+
118
+ Shares the repoint rule with apply via
119
+ ``install._is_repointable_bootstrap_symlink`` so ``--check`` and apply cannot
120
+ disagree: a correct symlink (resolves to ``expected_src``) is clean; a
121
+ repointable bootstrap-owned link (stale in-tree pointer, or a dangling link
122
+ naming a relocated ``.workstate/remote`` clone) is drift; a foreign symlink
123
+ (resolving, or dangling but not naming our clone) and real local content keep
124
+ local precedence; an absent target is drift.
125
+ """
126
+ if target_path.is_symlink():
127
+ if target_path.resolve(strict=False) == expected_src.resolve():
128
+ return False
129
+ raw = _raw_symlink_target_path(target_path)
130
+ return _is_repointable_bootstrap_symlink(
131
+ target_path, raw, clone_resolved, remote_subtree_prefix
132
+ )
133
+ if target_path.exists():
134
+ return False # real local content — overlay precedence
135
+ return True # absent
136
+
137
+
138
+ def _compute_drift(target: Path, primary: Path, clone: Path) -> list[str]:
139
+ """Report what an adopt would materialize, without writing anything.
140
+
141
+ Shares the materializer's exclusion sets and foreign-precedence rule so the
142
+ guard cannot desync from apply: carved-surface children are checked
143
+ individually, and foreign/local content is never reported as drift.
144
+ """
145
+ drift: list[str] = []
146
+ clone_resolved = clone.resolve()
147
+ remote_subtree_prefix = str(clone_resolved) + os.sep
148
+
149
+ # Clone + generated child redirects.
150
+ for child in _WORKSTATE_CHILDREN:
151
+ src = primary / _RUNTIME / child
152
+ if not src.exists():
153
+ continue
154
+ dest = target / _RUNTIME / child
155
+ if not (dest.is_symlink() and dest.resolve(strict=False) == src.resolve()):
156
+ drift.append(f"{_RUNTIME}/{child}")
157
+
158
+ # Shared surfaces: enumerated by the SAME helper the materializer uses
159
+ # (install.iter_expected_surface_targets), so the guard cannot desync from
160
+ # apply. Each expected target — a whole-dir plain surface or a carved
161
+ # per-child link — drifts iff it is missing or a stale bootstrap-owned link
162
+ # (foreign/local content is never drift; see _link_drifts).
163
+ for expected in iter_expected_surface_targets(target, clone):
164
+ if _link_drifts(
165
+ expected.target_path,
166
+ expected.remote_path,
167
+ clone_resolved,
168
+ remote_subtree_prefix,
169
+ ):
170
+ drift.append(expected.rel)
171
+
172
+ # Lifecycle hoists: expect a real file/dir at the destination.
173
+ for src_rel, dest_rel in LIFECYCLE_HOISTS:
174
+ if not _resolve_in_clone(clone, src_rel).exists():
175
+ continue
176
+ if not (target / dest_rel).exists():
177
+ drift.append(dest_rel)
178
+
179
+ # Consumer Makefile include sentinel.
180
+ makefile = target / "Makefile"
181
+ text = makefile.read_text() if makefile.exists() else ""
182
+ if not (
183
+ LIFECYCLE_INCLUDE_SENTINEL_BEGIN in text
184
+ or LEGACY_LIFECYCLE_INCLUDE_SENTINEL_BEGIN in text
185
+ ):
186
+ drift.append("Makefile")
187
+
188
+ # Managed overlay-ignore block (keeps git status clean). apply writes it, so
189
+ # --check must verify it too, else a missing block silently regresses.
190
+ gitignore = target / ".gitignore"
191
+ gitignore_text = gitignore.read_text() if gitignore.exists() else ""
192
+ if GITIGNORE_SENTINEL_BEGIN not in gitignore_text:
193
+ drift.append(".gitignore")
194
+
195
+ # core.hooksPath (resolved against this worktree's config).
196
+ if _hooks_path_value(target) != HOOKS_PATH_VALUE:
197
+ drift.append("core.hooksPath")
198
+
199
+ return drift
200
+
201
+
202
+ def adopt_worktree(
203
+ *, target: Path, primary: Path | None = None, check: bool = False
204
+ ) -> dict[str, object]:
205
+ """Adopt (or check) the bootstrap overlay into a linked worktree.
206
+
207
+ Args:
208
+ target: The linked worktree to adopt. Adoption is a no-op when ``target``
209
+ is the primary worktree (or otherwise not a linked worktree).
210
+ primary: The primary overlay root. Resolved from ``target`` by marker when
211
+ omitted (:func:`primary_overlay_root`).
212
+ check: When True, report drift (``ok``/``drift``) without writing.
213
+
214
+ Returns:
215
+ A receipt dict with ``adopted``, ``check``, ``ok``, ``drift``, ``reason``,
216
+ ``target``, ``primary``, and (on apply) ``surfaces``.
217
+
218
+ Raises:
219
+ OverlayNotMaterializedError: the resolved primary has no overlay to adopt.
220
+ OverlayMarkerNotFoundError / NotAGitRepositoryError: from resolution.
221
+ """
222
+ target = Path(target).resolve()
223
+
224
+ if not is_linked_worktree(target):
225
+ return {
226
+ "adopted": False,
227
+ "check": check,
228
+ "ok": True,
229
+ "drift": [],
230
+ "reason": "not_a_linked_worktree",
231
+ "target": str(target),
232
+ "primary": None,
233
+ "surfaces": [],
234
+ }
235
+
236
+ primary = (
237
+ primary_overlay_root(target) if primary is None else Path(primary).resolve()
238
+ )
239
+ if not overlay_is_materialized(primary):
240
+ raise OverlayNotMaterializedError(
241
+ f"primary overlay is not materialized at {primary}; "
242
+ f"run `workstate-bootstrap install` (or repair) there first"
243
+ )
244
+ clone = primary / _RUNTIME / "remote"
245
+
246
+ if check:
247
+ drift = _compute_drift(target, primary, clone)
248
+ return {
249
+ "adopted": False,
250
+ "check": True,
251
+ "ok": not drift,
252
+ "drift": drift,
253
+ "reason": None,
254
+ "target": str(target),
255
+ "primary": str(primary),
256
+ "surfaces": [],
257
+ }
258
+
259
+ # Apply — order mirrors install() under profile=all + lifecycle.
260
+ for child in _WORKSTATE_CHILDREN:
261
+ _materialize_workstate_child(target, primary, child)
262
+
263
+ surfaces: list[dict[str, str]] = []
264
+ surfaces.extend(_materialize_surfaces(target, clone))
265
+ surfaces.extend(_prepare_generated_surfaces(target, clone))
266
+ surfaces.extend(_install_lifecycle_profile(target, clone))
267
+ makefile_include = _ensure_consumer_makefile_include(target)
268
+ # _set_git_hooks_path is worktree-aware: for a linked worktree (.git is a
269
+ # file) it writes core.hooksPath with --worktree, never touching the primary.
270
+ hooks = _set_git_hooks_path(target)
271
+ # implementation note S4: ensure the overlay-ignore block exists so the adopted
272
+ # worktree's `git status` is clean even when the primary's tracked
273
+ # .gitignore predates the managed block (idempotent / already_present).
274
+ gitignore = _ensure_consumer_gitignore_block(target)
275
+
276
+ return {
277
+ "adopted": True,
278
+ "check": False,
279
+ "ok": True,
280
+ "drift": [],
281
+ "reason": None,
282
+ "target": str(target),
283
+ "primary": str(primary),
284
+ "clone": str(clone),
285
+ "surfaces": surfaces,
286
+ "makefile_include": makefile_include,
287
+ "hooks": hooks,
288
+ "gitignore": gitignore,
289
+ }
@@ -27,7 +27,11 @@ from workstate_bootstrap.subcommands import doctor, repair, status, update
27
27
  # implementation note: CLI default flips to ``minimal``. The library
28
28
  # ``install()`` API keeps ``profile="all"`` for back-compat with
29
29
  # pre-Plan-0009 callers.
30
- INSTALL_PROFILE_CHOICES: tuple[str, ...] = (PROFILE_MINIMAL, PROFILE_LIFECYCLE, PROFILE_ALL)
30
+ INSTALL_PROFILE_CHOICES: tuple[str, ...] = (
31
+ PROFILE_MINIMAL,
32
+ PROFILE_LIFECYCLE,
33
+ PROFILE_ALL,
34
+ )
31
35
  # WORKSTATE-REF-56 implementation note: flipped from PROFILE_MINIMAL back to PROFILE_ALL so
32
36
  # a no-argument ``workstate-bootstrap install`` materializes the full
33
37
  # surface set out of the box. ``--profile minimal`` and
@@ -137,7 +141,7 @@ def _build_parser() -> argparse.ArgumentParser:
137
141
  "Either the literal 'default' (or omit the flag) to register the "
138
142
  "monorepo's two managed MCP servers (mcp-workstate-handoff, "
139
143
  "mcp-workstate-orchestrator) via uvx, or a path to a JSON file "
140
- "carrying a custom mapping. Accepts {\"mcpServers\": {...}} or a "
144
+ 'carrying a custom mapping. Accepts {"mcpServers": {...}} or a '
141
145
  "flat mapping. Writes .mcp.json / .vscode/mcp.json / "
142
146
  ".codex/config.toml. Use --no-mcp-servers to opt out entirely."
143
147
  ),
@@ -368,7 +372,7 @@ def _build_parser() -> argparse.ArgumentParser:
368
372
  required=True,
369
373
  help=(
370
374
  "Either 'default' for the monorepo's managed-server map, or a "
371
- "path to a JSON file. Accepts {\"mcpServers\": {...}} or a flat "
375
+ 'path to a JSON file. Accepts {"mcpServers": {...}} or a flat '
372
376
  "mapping."
373
377
  ),
374
378
  )
@@ -377,8 +381,7 @@ def _build_parser() -> argparse.ArgumentParser:
377
381
  "--check",
378
382
  action="store_true",
379
383
  help=(
380
- "Report drift without writing. Exit 0 if clean, 1 if any "
381
- "surface drifts."
384
+ "Report drift without writing. Exit 0 if clean, 1 if any surface drifts."
382
385
  ),
383
386
  )
384
387
  mode.add_argument(
@@ -407,9 +410,7 @@ def _build_parser() -> argparse.ArgumentParser:
407
410
  choices=sorted(SUPPORTED_SURFACES),
408
411
  default=list(DEFAULT_SURFACES),
409
412
  metavar="SURFACE",
410
- help=(
411
- "Subset of surfaces to reconcile. Default: claude vscode codex."
412
- ),
413
+ help=("Subset of surfaces to reconcile. Default: claude vscode codex."),
413
414
  )
414
415
  p_mcp_sync.add_argument(
415
416
  "--json",
@@ -459,6 +460,40 @@ def _build_parser() -> argparse.ArgumentParser:
459
460
  "workstate-overrides/workstate-system/."
460
461
  ),
461
462
  )
463
+
464
+ p_adopt = sub.add_parser(
465
+ "adopt-worktree",
466
+ help=(
467
+ "Adopt the bootstrap overlay into a linked git worktree by "
468
+ "redirecting its surfaces at the primary's clone."
469
+ ),
470
+ )
471
+ p_adopt.add_argument(
472
+ "--target",
473
+ type=Path,
474
+ default=None,
475
+ help="Linked worktree to adopt. Defaults to the current directory.",
476
+ )
477
+ p_adopt.add_argument(
478
+ "--primary",
479
+ type=Path,
480
+ default=None,
481
+ help=(
482
+ "Primary overlay root to adopt from. Defaults to resolving it by "
483
+ "the .workstate-bootstrap.json marker."
484
+ ),
485
+ )
486
+ p_adopt.add_argument(
487
+ "--check",
488
+ action="store_true",
489
+ help="Report drift without writing. Exit 1 when the worktree has drift.",
490
+ )
491
+ p_adopt.add_argument(
492
+ "--json",
493
+ dest="as_json",
494
+ action="store_true",
495
+ help="Emit the adoption receipt as JSON.",
496
+ )
462
497
  return parser
463
498
 
464
499
 
@@ -500,7 +535,9 @@ def main(argv: list[str] | None = None) -> int:
500
535
  file=sys.stdout,
501
536
  )
502
537
  if isinstance(manifest.get("override_backup_path"), str):
503
- print(f"override backup: {manifest['override_backup_path']}", file=sys.stdout)
538
+ print(
539
+ f"override backup: {manifest['override_backup_path']}", file=sys.stdout
540
+ )
504
541
  if isinstance(manifest.get("state_backup_path"), str):
505
542
  print(f"state backup: {manifest['state_backup_path']}", file=sys.stdout)
506
543
  return 0
@@ -552,7 +589,9 @@ def main(argv: list[str] | None = None) -> int:
552
589
  file=sys.stdout,
553
590
  )
554
591
  if isinstance(manifest.get("override_backup_path"), str):
555
- print(f"override backup: {manifest['override_backup_path']}", file=sys.stdout)
592
+ print(
593
+ f"override backup: {manifest['override_backup_path']}", file=sys.stdout
594
+ )
556
595
  return 0
557
596
 
558
597
  if args.command == "repair":
@@ -578,6 +617,39 @@ def main(argv: list[str] | None = None) -> int:
578
617
  print("repair: no drift detected.", file=sys.stdout)
579
618
  return 0
580
619
 
620
+ if args.command == "adopt-worktree":
621
+ from workstate_bootstrap.adopt import adopt_worktree
622
+ from workstate_bootstrap.worktree import WorktreeError
623
+
624
+ target = args.target if args.target is not None else Path.cwd()
625
+ try:
626
+ receipt: dict[str, Any] = adopt_worktree(
627
+ target=target, primary=args.primary, check=args.check
628
+ )
629
+ except WorktreeError as exc:
630
+ print(f"adopt-worktree: {exc}", file=sys.stderr)
631
+ return 1
632
+
633
+ if args.as_json:
634
+ print(json.dumps(receipt, indent=2), file=sys.stdout)
635
+ elif args.check:
636
+ if receipt["ok"]:
637
+ print("adopt-worktree: no drift detected.", file=sys.stdout)
638
+ else:
639
+ for entry in receipt["drift"]:
640
+ print(f"drift: {entry}", file=sys.stdout)
641
+ elif receipt["adopted"]:
642
+ print(
643
+ f"adopt-worktree: adopted overlay from {receipt['primary']}.",
644
+ file=sys.stdout,
645
+ )
646
+ else:
647
+ print(f"adopt-worktree: no-op ({receipt['reason']}).", file=sys.stdout)
648
+
649
+ if args.check and not receipt["ok"]:
650
+ return 1
651
+ return 0
652
+
581
653
  if args.command == "mcp-sync":
582
654
  try:
583
655
  servers = _resolve_managed_servers(args.target, args.mcp_servers)
@@ -641,9 +713,7 @@ def _print_sync_report(report: SyncReport, *, check_only: bool) -> None:
641
713
  if report.pruned_managed:
642
714
  print(f" pruned removed-managed: {', '.join(report.pruned_managed)}")
643
715
  if not check_only and report.ledger_mcp_servers:
644
- print(
645
- f" ledger mcp_servers: {', '.join(report.ledger_mcp_servers)}"
646
- )
716
+ print(f" ledger mcp_servers: {', '.join(report.ledger_mcp_servers)}")
647
717
 
648
718
 
649
719
  if __name__ == "__main__": # pragma: no cover