workstate-bootstrap 0.7.1__tar.gz → 0.7.3__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 (38) hide show
  1. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/CHANGELOG.md +46 -0
  2. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/PKG-INFO +36 -3
  3. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/README.md +34 -1
  4. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/pyproject.toml +2 -2
  5. workstate_bootstrap-0.7.3/src/workstate_bootstrap/adopt.py +296 -0
  6. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/cli.py +83 -13
  7. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/install.py +393 -86
  8. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/subcommands.py +59 -0
  9. workstate_bootstrap-0.7.3/src/workstate_bootstrap/worktree.py +165 -0
  10. workstate_bootstrap-0.7.3/tests/test_adopt_worktree.py +709 -0
  11. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/tests/test_bootstrap_install_rehearsal.py +2 -2
  12. workstate_bootstrap-0.7.3/tests/test_clone_payload_resolution.py +59 -0
  13. workstate_bootstrap-0.7.3/tests/test_gitignore_block.py +259 -0
  14. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/tests/test_install.py +90 -19
  15. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/tests/test_package_source.py +12 -3
  16. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/tests/test_subcommands.py +30 -9
  17. workstate_bootstrap-0.7.3/tests/test_worktree.py +262 -0
  18. workstate_bootstrap-0.7.3/tests/test_worktree_doctor.py +181 -0
  19. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/.gitignore +0 -0
  20. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/__init__.py +0 -0
  21. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/__main__.py +0 -0
  22. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/mcp_sync.py +0 -0
  23. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/tests/__init__.py +0 -0
  24. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/tests/test_cli_profile.py +0 -0
  25. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/tests/test_doctor_repair_sync.py +0 -0
  26. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/tests/test_ensure_hooks_path_make.py +0 -0
  27. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/tests/test_install_manifest_walker.py +0 -0
  28. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/tests/test_install_profile_all_lifecycle.py +0 -0
  29. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/tests/test_install_profiles.py +0 -0
  30. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/tests/test_manifest_build.py +0 -0
  31. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/tests/test_mcp_sync_cli.py +0 -0
  32. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/tests/test_mcp_sync_e2e.py +0 -0
  33. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/tests/test_mcp_sync_malformed.py +0 -0
  34. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/tests/test_mcp_sync_prune.py +0 -0
  35. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/tests/test_mcp_sync_unit.py +0 -0
  36. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/tests/test_package_metadata.py +0 -0
  37. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/tests/test_render_seam.py +0 -0
  38. {workstate_bootstrap-0.7.1 → workstate_bootstrap-0.7.3}/tests/test_root_visible_task_plans.py +0 -0
@@ -2,6 +2,52 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## [0.7.3] — 2026-06-03
6
+
7
+ ### Changed
8
+
9
+ - TODO: summarize this release.
10
+
11
+
12
+ ## [0.7.2] — 2026-06-02
13
+
14
+ > Ships the linked-worktree overlay self-heal (implementation note) **and** its deferred
15
+ > follow-ups (implementation note), both of which landed after 0.7.1 was published.
16
+
17
+ ### Added
18
+
19
+ - **Linked-worktree overlay self-heal (implementation note):** `adopt-worktree` re-runs the
20
+ install materializer against a linked worktree with `clone=<primary>/.workstate/remote`;
21
+ worktree-aware `doctor`/`repair` short-circuit emits a single `unadopted_worktree`
22
+ finding; a managed sentinel-delimited `.gitignore` block keeps an adopted
23
+ worktree's `git status` clean.
24
+
25
+ ### Changed
26
+
27
+ - **Apply and `--check` share one surface enumeration (implementation note S1,
28
+ `revB-install-private-symbol-coupling`):** new `iter_expected_surface_targets`
29
+ is the single source of the surface/carve/exclusion rule consumed by both the
30
+ materializer and `adopt._compute_drift`, so the drift guard can no longer
31
+ desync from apply; the `_materialize_surfaces_copy` (package-install) path is
32
+ single-sourced through the same helper. `adopt` drops the `importlib` shim for
33
+ explicit imports.
34
+ - **Overlay-root resolver prefers a materialized overlay (implementation note S3,
35
+ `revA-overlay-root-unbounded-walk`):** the upward walk skips an unmaterialized
36
+ stray ancestor marker, falling back to the nearest marker so a genuinely
37
+ un-materialized primary still fails loudly.
38
+ - **Relocation repoint (implementation note S3b, `revB-relocation-dangling-symlink-no-repoint`):**
39
+ a dangling bootstrap-owned surface link (e.g. a relocated primary) is repointed
40
+ to the live clone via a shared, segment-anchored repoint predicate used by both
41
+ apply and `--check`.
42
+
43
+ ### Notes
44
+
45
+ - `.claude-plugin/marketplace.json` continues to resolve via the tracked file +
46
+ the adopted `.workstate/generated` symlink; `adopt` does not materialize it
47
+ (implementation note S2 locks this contract). Consumers that gitignore `.claude-plugin`
48
+ need a separate `install` pass.
49
+
50
+
5
51
  ## [0.7.1] — 2026-06-02
6
52
 
7
53
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workstate-bootstrap
3
- Version: 0.7.1
3
+ Version: 0.7.3
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.1
15
+ Requires-Dist: workstate-system<0.2.0,>=0.1.3
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,39 @@ 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. In source
124
+ checkouts, the lifecycle uses the freshly provisioned worktree `.venv`
125
+ `workstate-bootstrap` command when available, then falls back to `uvx`.
126
+ Set `WORKSTATE_ADOPT_CMD=""` to disable auto-adopt, or set it to a custom
127
+ command to override that default.
128
+ - **Raw `git worktree add` / auto-worktrees** are healed on demand:
129
+
130
+ ```bash
131
+ uvx workstate-bootstrap adopt-worktree --target <worktree>
132
+ # or, as a steady-state guard (exit 1 on drift):
133
+ uvx workstate-bootstrap adopt-worktree --target <worktree> --check
134
+ ```
135
+
136
+ `.task-state/`, `DASHBOARD.txt`, and `CURRENT_TASK.json` are **never**
137
+ adopted — they stay per-worktree (the handoff DB is primary-rooted).
105
138
 
106
139
  See [`docs/CONSUMER.md`](https://github.com/darce/workstate/blob/main/docs/CONSUMER.md)
107
140
  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,39 @@ 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. In source
104
+ checkouts, the lifecycle uses the freshly provisioned worktree `.venv`
105
+ `workstate-bootstrap` command when available, then falls back to `uvx`.
106
+ Set `WORKSTATE_ADOPT_CMD=""` to disable auto-adopt, or set it to a custom
107
+ command to override that default.
108
+ - **Raw `git worktree add` / auto-worktrees** are healed on demand:
109
+
110
+ ```bash
111
+ uvx workstate-bootstrap adopt-worktree --target <worktree>
112
+ # or, as a steady-state guard (exit 1 on drift):
113
+ uvx workstate-bootstrap adopt-worktree --target <worktree> --check
114
+ ```
115
+
116
+ `.task-state/`, `DASHBOARD.txt`, and `CURRENT_TASK.json` are **never**
117
+ adopted — they stay per-worktree (the handoff DB is primary-rooted).
85
118
 
86
119
  See [`docs/CONSUMER.md`](https://github.com/darce/workstate/blob/main/docs/CONSUMER.md)
87
120
  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.1"
7
+ version = "0.7.3"
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.1,<0.2.0",
17
+ "workstate-system>=0.1.3,<0.2.0",
18
18
  ]
19
19
 
20
20
  [project.scripts]
@@ -0,0 +1,296 @@
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
+ _overlay_surfaces_self_managed,
52
+ _prepare_generated_surfaces,
53
+ _raw_symlink_target_path,
54
+ _resolve_in_clone,
55
+ _set_git_hooks_path,
56
+ iter_expected_surface_targets,
57
+ )
58
+ from workstate_bootstrap.worktree import (
59
+ WorktreeError,
60
+ is_linked_worktree,
61
+ overlay_is_materialized,
62
+ primary_overlay_root,
63
+ )
64
+
65
+ _RUNTIME = RUNTIME_ROOT_DIRNAME # ".workstate"
66
+ _WORKSTATE_CHILDREN = ("remote", "generated")
67
+
68
+
69
+ class OverlayNotMaterializedError(WorktreeError):
70
+ """The resolved primary has no materialized overlay to adopt from."""
71
+
72
+
73
+ def _materialize_workstate_child(target: Path, primary: Path, child: str) -> bool:
74
+ """Symlink ``target/.workstate/<child>`` -> ``primary/.workstate/<child>``.
75
+
76
+ ``.workstate/`` itself stays a real local directory (created if absent) so
77
+ sibling per-worktree state does not leak across worktrees. The child link is
78
+ relative for relocation safety. Foreign/real local content at the child path
79
+ is left untouched (overlay precedence). Returns True when the source exists.
80
+ """
81
+ src = primary / _RUNTIME / child
82
+ if not src.exists():
83
+ return False
84
+ ws = target / _RUNTIME
85
+ if ws.is_symlink():
86
+ # A pre-existing .workstate SYMLINK would route the remote/generated
87
+ # child links THROUGH it (into the primary or a foreign dir) — exactly
88
+ # the cross-worktree contamination the isolation invariant forbids.
89
+ # Replace it with a real local directory before materializing children.
90
+ ws.unlink()
91
+ ws.mkdir(parents=True, exist_ok=True)
92
+ dest = ws / child
93
+ rel = os.path.relpath(src, ws)
94
+ if dest.is_symlink():
95
+ if dest.resolve(strict=False) == src.resolve():
96
+ return True
97
+ dest.unlink()
98
+ elif dest.exists():
99
+ return True # foreign/real local content wins
100
+ dest.symlink_to(rel, target_is_directory=src.is_dir())
101
+ return True
102
+
103
+
104
+ def _hooks_path_value(target: Path) -> str:
105
+ try:
106
+ return _git("config", "core.hooksPath", cwd=target)
107
+ except subprocess.CalledProcessError:
108
+ return ""
109
+
110
+
111
+ def _link_drifts(
112
+ target_path: Path,
113
+ expected_src: Path,
114
+ clone_resolved: Path,
115
+ remote_subtree_prefix: str,
116
+ ) -> bool:
117
+ """True iff a bootstrap-owned surface link is missing or stale.
118
+
119
+ Shares the repoint rule with apply via
120
+ ``install._is_repointable_bootstrap_symlink`` so ``--check`` and apply cannot
121
+ disagree: a correct symlink (resolves to ``expected_src``) is clean; a
122
+ repointable bootstrap-owned link (stale in-tree pointer, or a dangling link
123
+ naming a relocated ``.workstate/remote`` clone) is drift; a foreign symlink
124
+ (resolving, or dangling but not naming our clone) and real local content keep
125
+ local precedence; an absent target is drift.
126
+ """
127
+ if target_path.is_symlink():
128
+ if target_path.resolve(strict=False) == expected_src.resolve():
129
+ return False
130
+ raw = _raw_symlink_target_path(target_path)
131
+ return _is_repointable_bootstrap_symlink(
132
+ target_path, raw, clone_resolved, remote_subtree_prefix
133
+ )
134
+ if target_path.exists():
135
+ return False # real local content — overlay precedence
136
+ return True # absent
137
+
138
+
139
+ def _compute_drift(target: Path, primary: Path, clone: Path) -> list[str]:
140
+ """Report what an adopt would materialize, without writing anything.
141
+
142
+ Shares the materializer's exclusion sets and foreign-precedence rule so the
143
+ guard cannot desync from apply: carved-surface children are checked
144
+ individually, and foreign/local content is never reported as drift.
145
+ """
146
+ drift: list[str] = []
147
+ clone_resolved = clone.resolve()
148
+ remote_subtree_prefix = str(clone_resolved) + os.sep
149
+
150
+ # Clone + generated child redirects.
151
+ for child in _WORKSTATE_CHILDREN:
152
+ src = primary / _RUNTIME / child
153
+ if not src.exists():
154
+ continue
155
+ dest = target / _RUNTIME / child
156
+ if not (dest.is_symlink() and dest.resolve(strict=False) == src.resolve()):
157
+ drift.append(f"{_RUNTIME}/{child}")
158
+
159
+ # Shared surfaces: enumerated by the SAME helper the materializer uses
160
+ # (install.iter_expected_surface_targets), so the guard cannot desync from
161
+ # apply. Each expected target — a whole-dir plain surface or a carved
162
+ # per-child link — drifts iff it is missing or a stale bootstrap-owned link
163
+ # (foreign/local content is never drift; see _link_drifts).
164
+ for expected in iter_expected_surface_targets(target, clone):
165
+ if _link_drifts(
166
+ expected.target_path,
167
+ expected.remote_path,
168
+ clone_resolved,
169
+ remote_subtree_prefix,
170
+ ):
171
+ drift.append(expected.rel)
172
+
173
+ # Lifecycle hoists: expect a real file/dir at the destination.
174
+ for src_rel, dest_rel in LIFECYCLE_HOISTS:
175
+ if not _resolve_in_clone(clone, src_rel).exists():
176
+ continue
177
+ if not (target / dest_rel).exists():
178
+ drift.append(dest_rel)
179
+
180
+ # Consumer Makefile include sentinel.
181
+ makefile = target / "Makefile"
182
+ text = makefile.read_text() if makefile.exists() else ""
183
+ if not (
184
+ LIFECYCLE_INCLUDE_SENTINEL_BEGIN in text
185
+ or LEGACY_LIFECYCLE_INCLUDE_SENTINEL_BEGIN in text
186
+ ):
187
+ drift.append("Makefile")
188
+
189
+ # Managed overlay-ignore block (keeps git status clean). apply writes it, so
190
+ # --check must verify it too, else a missing block silently regresses. But a
191
+ # self-hosting source repo manages the surfaces itself (tracked or already
192
+ # ignored): apply skips the block there, so --check must too — otherwise the
193
+ # block is reported as perpetual, unsatisfiable drift.
194
+ gitignore = target / ".gitignore"
195
+ gitignore_text = gitignore.read_text() if gitignore.exists() else ""
196
+ if (
197
+ GITIGNORE_SENTINEL_BEGIN not in gitignore_text
198
+ and not _overlay_surfaces_self_managed(target)
199
+ ):
200
+ drift.append(".gitignore")
201
+
202
+ # core.hooksPath (resolved against this worktree's config).
203
+ if _hooks_path_value(target) != HOOKS_PATH_VALUE:
204
+ drift.append("core.hooksPath")
205
+
206
+ return drift
207
+
208
+
209
+ def adopt_worktree(
210
+ *, target: Path, primary: Path | None = None, check: bool = False
211
+ ) -> dict[str, object]:
212
+ """Adopt (or check) the bootstrap overlay into a linked worktree.
213
+
214
+ Args:
215
+ target: The linked worktree to adopt. Adoption is a no-op when ``target``
216
+ is the primary worktree (or otherwise not a linked worktree).
217
+ primary: The primary overlay root. Resolved from ``target`` by marker when
218
+ omitted (:func:`primary_overlay_root`).
219
+ check: When True, report drift (``ok``/``drift``) without writing.
220
+
221
+ Returns:
222
+ A receipt dict with ``adopted``, ``check``, ``ok``, ``drift``, ``reason``,
223
+ ``target``, ``primary``, and (on apply) ``surfaces``.
224
+
225
+ Raises:
226
+ OverlayNotMaterializedError: the resolved primary has no overlay to adopt.
227
+ OverlayMarkerNotFoundError / NotAGitRepositoryError: from resolution.
228
+ """
229
+ target = Path(target).resolve()
230
+
231
+ if not is_linked_worktree(target):
232
+ return {
233
+ "adopted": False,
234
+ "check": check,
235
+ "ok": True,
236
+ "drift": [],
237
+ "reason": "not_a_linked_worktree",
238
+ "target": str(target),
239
+ "primary": None,
240
+ "surfaces": [],
241
+ }
242
+
243
+ primary = (
244
+ primary_overlay_root(target) if primary is None else Path(primary).resolve()
245
+ )
246
+ if not overlay_is_materialized(primary):
247
+ raise OverlayNotMaterializedError(
248
+ f"primary overlay is not materialized at {primary}; "
249
+ f"run `workstate-bootstrap install` (or repair) there first"
250
+ )
251
+ clone = primary / _RUNTIME / "remote"
252
+
253
+ if check:
254
+ drift = _compute_drift(target, primary, clone)
255
+ return {
256
+ "adopted": False,
257
+ "check": True,
258
+ "ok": not drift,
259
+ "drift": drift,
260
+ "reason": None,
261
+ "target": str(target),
262
+ "primary": str(primary),
263
+ "surfaces": [],
264
+ }
265
+
266
+ # Apply — order mirrors install() under profile=all + lifecycle.
267
+ for child in _WORKSTATE_CHILDREN:
268
+ _materialize_workstate_child(target, primary, child)
269
+
270
+ surfaces: list[dict[str, str]] = []
271
+ surfaces.extend(_materialize_surfaces(target, clone))
272
+ surfaces.extend(_prepare_generated_surfaces(target, clone))
273
+ surfaces.extend(_install_lifecycle_profile(target, clone))
274
+ makefile_include = _ensure_consumer_makefile_include(target)
275
+ # _set_git_hooks_path is worktree-aware: for a linked worktree (.git is a
276
+ # file) it writes core.hooksPath with --worktree, never touching the primary.
277
+ hooks = _set_git_hooks_path(target)
278
+ # implementation note S4: ensure the overlay-ignore block exists so the adopted
279
+ # worktree's `git status` is clean even when the primary's tracked
280
+ # .gitignore predates the managed block (idempotent / already_present).
281
+ gitignore = _ensure_consumer_gitignore_block(target)
282
+
283
+ return {
284
+ "adopted": True,
285
+ "check": False,
286
+ "ok": True,
287
+ "drift": [],
288
+ "reason": None,
289
+ "target": str(target),
290
+ "primary": str(primary),
291
+ "clone": str(clone),
292
+ "surfaces": surfaces,
293
+ "makefile_include": makefile_include,
294
+ "hooks": hooks,
295
+ "gitignore": gitignore,
296
+ }
@@ -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