workstate-bootstrap 0.7.2__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 (39) hide show
  1. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/CHANGELOG.md +7 -0
  2. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/PKG-INFO +7 -5
  3. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/README.md +5 -3
  4. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/pyproject.toml +2 -2
  5. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/adopt.py +9 -2
  6. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/install.py +136 -13
  7. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_adopt_worktree.py +51 -0
  8. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_bootstrap_install_rehearsal.py +2 -2
  9. workstate_bootstrap-0.7.3/tests/test_clone_payload_resolution.py +59 -0
  10. workstate_bootstrap-0.7.3/tests/test_gitignore_block.py +259 -0
  11. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_install.py +86 -19
  12. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_package_source.py +2 -2
  13. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_subcommands.py +30 -9
  14. workstate_bootstrap-0.7.2/tests/test_gitignore_block.py +0 -104
  15. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/.gitignore +0 -0
  16. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/__init__.py +0 -0
  17. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/__main__.py +0 -0
  18. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/cli.py +0 -0
  19. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/mcp_sync.py +0 -0
  20. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/subcommands.py +0 -0
  21. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/worktree.py +0 -0
  22. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/__init__.py +0 -0
  23. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_cli_profile.py +0 -0
  24. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_doctor_repair_sync.py +0 -0
  25. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_ensure_hooks_path_make.py +0 -0
  26. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_install_manifest_walker.py +0 -0
  27. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_install_profile_all_lifecycle.py +0 -0
  28. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_install_profiles.py +0 -0
  29. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_manifest_build.py +0 -0
  30. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_mcp_sync_cli.py +0 -0
  31. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_mcp_sync_e2e.py +0 -0
  32. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_mcp_sync_malformed.py +0 -0
  33. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_mcp_sync_prune.py +0 -0
  34. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_mcp_sync_unit.py +0 -0
  35. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_package_metadata.py +0 -0
  36. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_render_seam.py +0 -0
  37. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_root_visible_task_plans.py +0 -0
  38. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_worktree.py +0 -0
  39. {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_worktree_doctor.py +0 -0
@@ -2,6 +2,13 @@
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
+
5
12
  ## [0.7.2] — 2026-06-02
6
13
 
7
14
  > Ships the linked-worktree overlay self-heal (implementation note) **and** its deferred
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workstate-bootstrap
3
- Version: 0.7.2
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.2
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'
@@ -120,9 +120,11 @@ overlay starts absent — the plugin is enabled (tracked
120
120
  `.claude/settings.json`) but unresolvable. Self-heal works as follows:
121
121
 
122
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`).
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.
126
128
  - **Raw `git worktree add` / auto-worktrees** are healed on demand:
127
129
 
128
130
  ```bash
@@ -100,9 +100,11 @@ overlay starts absent — the plugin is enabled (tracked
100
100
  `.claude/settings.json`) but unresolvable. Self-heal works as follows:
101
101
 
102
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`).
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.
106
108
  - **Raw `git worktree add` / auto-worktrees** are healed on demand:
107
109
 
108
110
  ```bash
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "workstate-bootstrap"
7
- version = "0.7.2"
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.2,<0.2.0",
17
+ "workstate-system>=0.1.3,<0.2.0",
18
18
  ]
19
19
 
20
20
  [project.scripts]
@@ -48,6 +48,7 @@ from workstate_bootstrap.install import (
48
48
  _install_lifecycle_profile,
49
49
  _is_repointable_bootstrap_symlink,
50
50
  _materialize_surfaces,
51
+ _overlay_surfaces_self_managed,
51
52
  _prepare_generated_surfaces,
52
53
  _raw_symlink_target_path,
53
54
  _resolve_in_clone,
@@ -186,10 +187,16 @@ def _compute_drift(target: Path, primary: Path, clone: Path) -> list[str]:
186
187
  drift.append("Makefile")
187
188
 
188
189
  # 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
+ # --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.
190
194
  gitignore = target / ".gitignore"
191
195
  gitignore_text = gitignore.read_text() if gitignore.exists() else ""
192
- if GITIGNORE_SENTINEL_BEGIN not in gitignore_text:
196
+ if (
197
+ GITIGNORE_SENTINEL_BEGIN not in gitignore_text
198
+ and not _overlay_surfaces_self_managed(target)
199
+ ):
193
200
  drift.append(".gitignore")
194
201
 
195
202
  # core.hooksPath (resolved against this worktree's config).
@@ -148,6 +148,13 @@ def _finalize_install_manifest(
148
148
  # fake_remote_with_surfaces fixture used elsewhere in the test suite).
149
149
  WORKSTATE_SYSTEM_SUBDIR = "packages/workstate-system"
150
150
 
151
+ # implementation note S3: the shipped overlay payload moved under
152
+ # packages/workstate-system/workstate_system/payload/. Probe this co-located
153
+ # payload root FIRST when resolving a surface in the clone, keeping the pre-S3
154
+ # subdir and the clone root as fallbacks for already-installed / hoisted
155
+ # consumers (and the fake_remote_with_surfaces test fixture).
156
+ WORKSTATE_SYSTEM_PAYLOAD_SUBDIR = "packages/workstate-system/workstate_system/payload"
157
+
151
158
  # Shared overlay surfaces materialized as symlinks into ``.workstate/remote``.
152
159
  # Per-agent surfaces (.claude/skills, .claude/commands, .github/prompts,
153
160
  # .codex/skills) are no longer canonical in the overlay clone; they are
@@ -297,7 +304,7 @@ DEFAULT_MCP_SERVERS: dict[str, dict[str, Any]] = {
297
304
  "type": "stdio",
298
305
  "command": "uvx",
299
306
  "args": [
300
- "mcp-workstate-handoff@0.12.0",
307
+ "mcp-workstate-handoff@0.12.1",
301
308
  "--workspace-root",
302
309
  ".",
303
310
  "serve-stdio",
@@ -307,7 +314,7 @@ DEFAULT_MCP_SERVERS: dict[str, dict[str, Any]] = {
307
314
  "type": "stdio",
308
315
  "command": "uvx",
309
316
  "args": [
310
- "mcp-workstate-orchestrator@0.5.1",
317
+ "mcp-workstate-orchestrator@0.5.2",
311
318
  "--workspace-root",
312
319
  ".",
313
320
  "serve-stdio",
@@ -495,6 +502,13 @@ def _presync_local_mcp_envs(
495
502
  if project in seen or not (project / "pyproject.toml").is_file():
496
503
  continue
497
504
  seen.add(project)
505
+ # Base sync only — no optional extras. The orchestrator's `bridge` extra
506
+ # (workstate-codex-bridge for the `codex-subagent` backend) is host-specific
507
+ # and unpublished, so presync deliberately leaves it out to keep install
508
+ # cheap and bridge-free. When a server then launches from this venv, the
509
+ # bridge is absent and `list_available_backends(probe=true)` reports the
510
+ # backend as `declared_not_installed` rather than falsely available. Install
511
+ # the bridge on demand with `uv sync --extra bridge` against that project.
498
512
  subprocess.run(
499
513
  ["uv", "sync", "--project", str(project)],
500
514
  check=True,
@@ -737,18 +751,22 @@ def _resolve_ref_to_sha(clone: Path, remote_ref: str) -> str:
737
751
  def _resolve_in_clone(clone: Path, relpath: str) -> Path:
738
752
  """Resolve a surface/asset path against the clone.
739
753
 
740
- Probes ``<clone>/packages/workstate-system/<relpath>`` first (the
741
- workstate layout) and falls back to ``<clone>/<relpath>``
742
- for legacy hoisted overlays. Returns the nested path when neither exists
743
- so callers can use ``.exists()`` as the discriminator.
754
+ Probes ``<clone>/packages/workstate-system/workstate_system/payload/<relpath>``
755
+ first (the implementation note S3 co-located payload layout), then the pre-S3
756
+ ``<clone>/packages/workstate-system/<relpath>``, then ``<clone>/<relpath>``
757
+ for legacy hoisted overlays. Returns the co-located payload path when none
758
+ exists so callers can use ``.exists()`` as the discriminator.
744
759
  """
760
+ payload = clone / WORKSTATE_SYSTEM_PAYLOAD_SUBDIR / relpath
761
+ if payload.exists():
762
+ return payload
745
763
  nested = clone / WORKSTATE_SYSTEM_SUBDIR / relpath
746
764
  if nested.exists():
747
765
  return nested
748
766
  root = clone / relpath
749
767
  if root.exists():
750
768
  return root
751
- return nested
769
+ return payload
752
770
 
753
771
 
754
772
  def _materialize_one_symlink(
@@ -1683,6 +1701,103 @@ def _consumer_gitignore_entries() -> list[str]:
1683
1701
  return entries
1684
1702
 
1685
1703
 
1704
+ def _git_path_is_tracked(target: Path, rel: str) -> bool:
1705
+ """True when ``rel`` (relative to ``target``) has tracked content in git.
1706
+
1707
+ ``git ls-files --error-unmatch`` exits non-zero when the pathspec matches no
1708
+ tracked file; for a tracked directory it matches the tracked children and
1709
+ exits 0. Any git failure (non-zero exit, git absent, not a repo) is reported
1710
+ as "not tracked" so the conservative default keeps the managed block.
1711
+ """
1712
+ try:
1713
+ subprocess.run(
1714
+ ["git", "-C", str(target), "ls-files", "--error-unmatch", "--", rel],
1715
+ check=True,
1716
+ capture_output=True,
1717
+ text=True,
1718
+ timeout=120,
1719
+ )
1720
+ except (
1721
+ subprocess.CalledProcessError,
1722
+ subprocess.TimeoutExpired,
1723
+ FileNotFoundError,
1724
+ OSError,
1725
+ ):
1726
+ return False
1727
+ return True
1728
+
1729
+
1730
+ def _git_path_is_ignored(target: Path, rel: str) -> bool:
1731
+ """True when git already ignores ``rel`` (relative to ``target``).
1732
+
1733
+ ``git check-ignore -q`` exits 0 when the path is ignored, 1 when not, and 128
1734
+ on error. Only exit 0 counts as ignored; anything else (including git absent /
1735
+ not a repo) is "not ignored" so the conservative default keeps the block.
1736
+
1737
+ Probes a child path as a fallback: a hand-authored ``.gitignore`` commonly uses
1738
+ a directory-only pattern (``.task-state/``, ``/.github/prompts/``) which
1739
+ ``check-ignore`` will NOT match against the bare path when that directory does
1740
+ not yet exist on disk (the very M1 trailing-slash subtlety the managed block
1741
+ avoids). An ignored directory ignores its contents, so a child probe matches a
1742
+ ``dir/`` pattern regardless of whether the directory has been materialized.
1743
+ """
1744
+ for probe in (rel, f"{rel}/.workstate-overlay-probe"):
1745
+ try:
1746
+ proc = subprocess.run(
1747
+ ["git", "-C", str(target), "check-ignore", "-q", "--", probe],
1748
+ capture_output=True,
1749
+ text=True,
1750
+ timeout=120,
1751
+ )
1752
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
1753
+ return False
1754
+ if proc.returncode == 0:
1755
+ return True
1756
+ if proc.returncode != 1: # 128 / fatal — be conservative, keep the block
1757
+ return False
1758
+ return False
1759
+
1760
+
1761
+ def _leaking_overlay_entries(target: Path) -> list[str]:
1762
+ """Managed overlay entries that would surface in ``git status`` as untracked —
1763
+ i.e. neither tracked nor already ignored by the consumer's own config.
1764
+
1765
+ These are the ONLY entries the managed overlay-ignore block needs, and the
1766
+ only ones it is *safe* to ignore. An entry that is already tracked is the
1767
+ repo's own source — emitting a root-anchored ``/scripts/hooks`` ignore for it
1768
+ would silently make that tracked source un-trackable (the footgun). An entry
1769
+ already ignored is redundant. Filtering per-entry (rather than all-or-nothing)
1770
+ keeps a MIXED self-host safe: a transiently-leaking new surface still gets an
1771
+ ignore line while a sibling tracked-source surface never does.
1772
+
1773
+ Order is preserved from :func:`_consumer_gitignore_entries` for a stable block.
1774
+ The caller checks for our own sentinel block first, so this never sees a
1775
+ previously-written managed block masking the consumer's own config.
1776
+ """
1777
+ leaking: list[str] = []
1778
+ for entry in _consumer_gitignore_entries():
1779
+ rel = entry.lstrip("/")
1780
+ if not (_git_path_is_tracked(target, rel) or _git_path_is_ignored(target, rel)):
1781
+ leaking.append(entry)
1782
+ return leaking
1783
+
1784
+
1785
+ def _overlay_surfaces_self_managed(target: Path) -> bool:
1786
+ """True when ``target`` already tracks or ignores every managed overlay
1787
+ surface on its own — the self-hosting source repo case (no entry leaks).
1788
+
1789
+ The managed overlay-ignore block exists for ONE reason: keep the adopted
1790
+ overlay symlinks out of ``git status``. The workstate monorepo self-hosts the
1791
+ overlay — it ships a hand-authored ``.gitignore`` that already ignores the
1792
+ very same root surfaces (and a tracked-source layout may even version-control
1793
+ them). When nothing leaks, the block is wholly unnecessary, so adopt/install
1794
+ skip it (and ``adopt --check`` must not report it as drift). A normal external
1795
+ consumer — freshly materialized symlinks, untracked and not yet ignored — has
1796
+ leaking surfaces and still gets a block.
1797
+ """
1798
+ return not _leaking_overlay_entries(target)
1799
+
1800
+
1686
1801
  def _ensure_consumer_gitignore_block(target: Path) -> dict[str, str]:
1687
1802
  """Idempotently maintain a sentinel-delimited overlay-ignore block in
1688
1803
  ``<target>/.gitignore``.
@@ -1692,23 +1807,31 @@ def _ensure_consumer_gitignore_block(target: Path) -> dict[str, str]:
1692
1807
  uninstall can excise it cleanly, and never clobbers a user-authored
1693
1808
  ``.gitignore`` (the block is appended, preserving prior content). Keeps an
1694
1809
  installed/adopted overlay out of ``git status``. Returns the action taken
1695
- (``created`` / ``appended`` / ``already_present``).
1810
+ (``created`` / ``appended`` / ``already_present`` / ``skipped_self_managed``).
1811
+
1812
+ Only the *leaking* managed entries are emitted (see
1813
+ :func:`_leaking_overlay_entries`): a tracked surface is never ignored (closing
1814
+ the self-hosting-source footgun even in a MIXED layout), and an already-ignored
1815
+ surface is not duplicated. When nothing leaks the whole block is skipped.
1696
1816
  """
1817
+ leaking = _leaking_overlay_entries(target)
1697
1818
  gitignore = target / ".gitignore"
1819
+ existing = gitignore.read_text() if gitignore.exists() else None
1820
+ if existing is not None and GITIGNORE_SENTINEL_BEGIN in existing:
1821
+ return {"path": ".gitignore", "action": "already_present"}
1822
+ if not leaking:
1823
+ return {"path": ".gitignore", "action": "skipped_self_managed"}
1698
1824
  block = (
1699
1825
  GITIGNORE_SENTINEL_BEGIN
1700
1826
  + "\n"
1701
- + "\n".join(_consumer_gitignore_entries())
1827
+ + "\n".join(leaking)
1702
1828
  + "\n"
1703
1829
  + GITIGNORE_SENTINEL_END
1704
1830
  + "\n"
1705
1831
  )
1706
- if not gitignore.exists():
1832
+ if existing is None:
1707
1833
  gitignore.write_text(block)
1708
1834
  return {"path": ".gitignore", "action": "created"}
1709
- existing = gitignore.read_text()
1710
- if GITIGNORE_SENTINEL_BEGIN in existing:
1711
- return {"path": ".gitignore", "action": "already_present"}
1712
1835
  sep = "" if existing.endswith("\n") else "\n"
1713
1836
  gitignore.write_text(existing + sep + block)
1714
1837
  return {"path": ".gitignore", "action": "appended"}
@@ -501,6 +501,57 @@ def test_check_reports_drift_when_gitignore_block_removed(
501
501
  assert ".gitignore" in receipt["drift"]
502
502
 
503
503
 
504
+ def test_adopt_self_hosting_worktree_does_not_touch_tracked_gitignore(
505
+ tmp_path: Path,
506
+ ) -> None:
507
+ """Self-hosting source repo (the workstate monorepo): a feature worktree
508
+ inherits a TRACKED ``.gitignore`` that already ignores the overlay surfaces.
509
+ Adopt must NOT append the managed block — doing so dirties every feature
510
+ worktree's tracked ``.gitignore`` (and its root-anchored patterns would ignore
511
+ the repo's own tracked source). ``--check`` must agree it is clean, not report
512
+ perpetual, unsatisfiable ``.gitignore`` drift.
513
+ """
514
+ primary, _clone = _make_installed_primary(tmp_path / "primary")
515
+ # The self-hosting primary tracks a hand-authored .gitignore covering the
516
+ # runtime dirs + the overlay surfaces (a mix of root-anchored and
517
+ # directory-only patterns, as the live monorepo ships).
518
+ (primary / ".gitignore").write_text(
519
+ ".workstate/\n"
520
+ ".task-state/\n"
521
+ "/scripts/hooks\n"
522
+ "/.github/hooks\n"
523
+ "/docs/workstate/contracts\n"
524
+ "/docs/workstate/rules\n"
525
+ "/Makefile.d\n"
526
+ "/scripts/workstate\n"
527
+ "/.github/prompts/\n"
528
+ )
529
+ _git("add", ".gitignore", cwd=primary)
530
+ _git("commit", "-m", "hand-authored overlay ignores", cwd=primary)
531
+
532
+ wt = _add_worktree(primary, tmp_path / "wt")
533
+ tracked_before = (wt / ".gitignore").read_text()
534
+
535
+ receipt = adopt_worktree(target=wt, primary=primary)
536
+
537
+ assert receipt["adopted"] is True
538
+ assert receipt["gitignore"]["action"] == "skipped_self_managed"
539
+ # The tracked .gitignore is byte-for-byte untouched — no managed block, no
540
+ # spurious working-tree modification.
541
+ assert (wt / ".gitignore").read_text() == tracked_before
542
+ assert "WORKSTATE_BOOTSTRAP OVERLAY IGNORE" not in tracked_before
543
+ gitignore_status = [
544
+ line
545
+ for line in _git("status", "--porcelain", cwd=wt).splitlines()
546
+ if ".gitignore" in line
547
+ ]
548
+ assert gitignore_status == [], gitignore_status
549
+
550
+ # --check must not report .gitignore as drift (apply skipped it on purpose).
551
+ check = adopt_worktree(target=wt, primary=primary, check=True)
552
+ assert ".gitignore" not in check["drift"], check["drift"]
553
+
554
+
504
555
  # ---------------------------------------------------------------------------
505
556
  # implementation note S1: apply and --check share ONE surface enumeration
506
557
  #
@@ -236,8 +236,8 @@ def test_install_with_default_servers_requires_uvx_in_args() -> None:
236
236
  assert entry["command"] == "uvx", (name, entry)
237
237
  assert "--workspace-root" in entry["args"], (name, entry)
238
238
  assert entry["args"][-1] == "serve-stdio", (name, entry)
239
- assert DEFAULT_MCP_SERVERS["workstate-handoff-mcp"]["args"][0] == "mcp-workstate-handoff@0.12.0"
240
- assert DEFAULT_MCP_SERVERS["workstate-orchestrator-mcp"]["args"][0] == "mcp-workstate-orchestrator@0.5.1"
239
+ assert DEFAULT_MCP_SERVERS["workstate-handoff-mcp"]["args"][0] == "mcp-workstate-handoff@0.12.1"
240
+ assert DEFAULT_MCP_SERVERS["workstate-orchestrator-mcp"]["args"][0] == "mcp-workstate-orchestrator@0.5.2"
241
241
 
242
242
 
243
243
  def test_rehearsal_hoists_plan_targets_makefile_and_shell_wrapper(
@@ -0,0 +1,59 @@
1
+ """implementation note S3: the clone resolver must find surfaces at the co-located
2
+ ``workstate_system/payload/`` layout that a fresh monorepo clone now has.
3
+
4
+ This is the runbook's "highest-risk gap": the monorepo's own make/hooks read the
5
+ package SOURCE directly, so an in-repo regression in the CLONE resolver
6
+ (``_resolve_in_clone``) is invisible to every other test — a downstream consumer
7
+ cloning the monorepo would silently materialize an EMPTY overlay. These tests
8
+ pin the payload-first probe + the legacy fallback directly.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ from workstate_bootstrap.install import (
16
+ SHARED_SURFACES,
17
+ WORKSTATE_SYSTEM_PAYLOAD_SUBDIR,
18
+ WORKSTATE_SYSTEM_SUBDIR,
19
+ _resolve_in_clone,
20
+ )
21
+
22
+
23
+ def _seed(base: Path, surface: str) -> Path:
24
+ target = base / surface
25
+ target.mkdir(parents=True, exist_ok=True)
26
+ (target / "marker.txt").write_text("x", encoding="utf-8")
27
+ return target
28
+
29
+
30
+ def test_resolve_in_clone_finds_every_shared_surface_at_payload_layout(tmp_path: Path) -> None:
31
+ """A clone at the post-S3 layout resolves every SHARED_SURFACE non-empty."""
32
+ clone = tmp_path / "clone"
33
+ payload = clone / WORKSTATE_SYSTEM_PAYLOAD_SUBDIR
34
+ for surface in SHARED_SURFACES:
35
+ _seed(payload, surface)
36
+
37
+ for surface in SHARED_SURFACES:
38
+ resolved = _resolve_in_clone(clone, surface)
39
+ assert resolved == payload / surface, surface
40
+ assert resolved.exists() and any(resolved.iterdir()), (
41
+ f"surface {surface} resolved empty from a payload-layout clone"
42
+ )
43
+
44
+
45
+ def test_resolve_in_clone_keeps_legacy_subdir_fallback(tmp_path: Path) -> None:
46
+ """Already-installed/hoisted consumers at the pre-S3 layout still resolve."""
47
+ clone = tmp_path / "clone"
48
+ _seed(clone / WORKSTATE_SYSTEM_SUBDIR, "scripts/hooks")
49
+ resolved = _resolve_in_clone(clone, "scripts/hooks")
50
+ assert resolved == clone / WORKSTATE_SYSTEM_SUBDIR / "scripts" / "hooks"
51
+ assert resolved.exists()
52
+
53
+
54
+ def test_resolve_in_clone_payload_wins_over_legacy(tmp_path: Path) -> None:
55
+ """When both layouts exist, the co-located payload is canonical."""
56
+ clone = tmp_path / "clone"
57
+ _seed(clone / WORKSTATE_SYSTEM_PAYLOAD_SUBDIR, "skills")
58
+ _seed(clone / WORKSTATE_SYSTEM_SUBDIR, "skills")
59
+ assert _resolve_in_clone(clone, "skills") == clone / WORKSTATE_SYSTEM_PAYLOAD_SUBDIR / "skills"
@@ -0,0 +1,259 @@
1
+ """TDD gate for implementation note Slice S4: managed consumer ``.gitignore`` block.
2
+
3
+ An installed/adopted overlay materializes the runtime dir (``.workstate``) and a
4
+ set of surface SYMLINKS (``Makefile.d``, ``scripts/hooks`` …). Without ignore
5
+ rules these show as untracked, so ``git status`` is never clean. A managed,
6
+ sentinel-delimited block (mirroring the Makefile-include pattern) keeps it clean
7
+ without clobbering a user-authored ``.gitignore``.
8
+
9
+ M1: the patterns must be ROOT-ANCHORED and NON-trailing-slash — a trailing-slash
10
+ pattern matches only directories, but git treats a symlink as a non-directory,
11
+ so ``Makefile.d/`` would miss an adopted ``Makefile.d`` symlink.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+ from workstate_bootstrap.install import (
20
+ GITIGNORE_SENTINEL_BEGIN,
21
+ _consumer_gitignore_entries,
22
+ _ensure_consumer_gitignore_block,
23
+ )
24
+
25
+
26
+ def _git(*args: str, cwd: Path) -> str:
27
+ result = subprocess.run(
28
+ ["git", *args],
29
+ check=True,
30
+ capture_output=True,
31
+ text=True,
32
+ cwd=str(cwd),
33
+ timeout=30,
34
+ )
35
+ return result.stdout.strip()
36
+
37
+
38
+ def _init_repo(root: Path) -> None:
39
+ root.mkdir(parents=True, exist_ok=True)
40
+ _git("init", "--initial-branch=main", cwd=root)
41
+ _git("config", "user.email", "t@e.com", cwd=root)
42
+ _git("config", "user.name", "T", cwd=root)
43
+
44
+
45
+ def _is_ignored(repo: Path, rel: str) -> bool:
46
+ return (
47
+ subprocess.run(
48
+ ["git", "-C", str(repo), "check-ignore", "-q", "--", rel], timeout=30
49
+ ).returncode
50
+ == 0
51
+ )
52
+
53
+
54
+ def test_block_created_when_no_gitignore(tmp_path: Path) -> None:
55
+ target = tmp_path / "consumer"
56
+ target.mkdir()
57
+ result = _ensure_consumer_gitignore_block(target)
58
+ assert result == {"path": ".gitignore", "action": "created"}
59
+ text = (target / ".gitignore").read_text()
60
+ assert GITIGNORE_SENTINEL_BEGIN in text
61
+ assert "/.workstate" in text
62
+ assert "/Makefile.d" in text
63
+
64
+
65
+ def test_block_appended_preserves_user_content(tmp_path: Path) -> None:
66
+ target = tmp_path / "consumer"
67
+ target.mkdir()
68
+ (target / ".gitignore").write_text("# user\n*.log\nbuild/\n")
69
+ result = _ensure_consumer_gitignore_block(target)
70
+ assert result == {"path": ".gitignore", "action": "appended"}
71
+ text = (target / ".gitignore").read_text()
72
+ assert "*.log" in text and "build/" in text # user content preserved
73
+ assert GITIGNORE_SENTINEL_BEGIN in text
74
+
75
+
76
+ def test_block_is_idempotent(tmp_path: Path) -> None:
77
+ target = tmp_path / "consumer"
78
+ target.mkdir()
79
+ _ensure_consumer_gitignore_block(target)
80
+ result = _ensure_consumer_gitignore_block(target)
81
+ assert result == {"path": ".gitignore", "action": "already_present"}
82
+ # No duplicate block.
83
+ assert (target / ".gitignore").read_text().count(GITIGNORE_SENTINEL_BEGIN) == 1
84
+
85
+
86
+ def test_block_ignores_symlink_surfaces_git_status_clean(tmp_path: Path) -> None:
87
+ """M1 regression: the block must ignore adopted SYMLINK surfaces + .workstate
88
+ so `git status` shows nothing but the .gitignore itself."""
89
+ repo = tmp_path / "repo"
90
+ _init_repo(repo)
91
+ external = tmp_path / "external"
92
+ external.mkdir()
93
+
94
+ # Materialize the overlay shape: a real .workstate dir with a child symlink,
95
+ # plus whole-dir surface symlinks (as adoption produces).
96
+ (repo / ".workstate").mkdir()
97
+ (repo / ".workstate" / "remote").symlink_to(external)
98
+ (repo / "Makefile.d").symlink_to(external)
99
+ (repo / "scripts").mkdir()
100
+ (repo / "scripts" / "hooks").symlink_to(external)
101
+ (repo / ".github").mkdir()
102
+ (repo / ".github" / "hooks").symlink_to(external)
103
+
104
+ _ensure_consumer_gitignore_block(repo)
105
+
106
+ status_lines = [
107
+ line
108
+ for line in _git("status", "--porcelain", cwd=repo).splitlines()
109
+ if line.strip()
110
+ ]
111
+ # Only the .gitignore itself may be untracked; no overlay path leaks.
112
+ assert all(".gitignore" in line for line in status_lines), status_lines
113
+ for leaked in (".workstate", "Makefile.d", "scripts/hooks", ".github/hooks"):
114
+ assert all(leaked not in line for line in status_lines), (leaked, status_lines)
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Self-hosting source repo guard: the workstate monorepo IS the overlay source,
119
+ # so its surfaces are tracked (or already ignored by a hand-authored .gitignore).
120
+ # Appending the managed block there dirties every feature worktree's TRACKED
121
+ # .gitignore — and would ignore the repo's own tracked source. The block must be
122
+ # skipped when the consumer already manages every managed surface itself.
123
+ # ---------------------------------------------------------------------------
124
+
125
+
126
+ # Managed entries that name overlay *surfaces* (not the runtime .workstate /
127
+ # .task-state dirs). These are the paths a self-hosting repo tracks as real
128
+ # source and that the root-anchored block would dangerously ignore.
129
+ _TRACKED_SURFACE_DIRS = (
130
+ "scripts/hooks",
131
+ "Makefile.d",
132
+ "scripts/workstate",
133
+ "docs/workstate/contracts",
134
+ "docs/workstate/rules",
135
+ ".github/hooks",
136
+ ".github/prompts",
137
+ )
138
+
139
+
140
+ def test_block_skipped_when_surfaces_are_tracked_source(tmp_path: Path) -> None:
141
+ """Footgun guard: when the overlay surfaces are TRACKED source dirs (the
142
+ self-hosting monorepo), the managed block must NOT be written — its
143
+ root-anchored ``/scripts/hooks`` … patterns would start ignoring the repo's
144
+ own version-controlled source."""
145
+ repo = tmp_path / "monorepo"
146
+ _init_repo(repo)
147
+ # Track real source at every managed surface path.
148
+ for rel in _TRACKED_SURFACE_DIRS:
149
+ d = repo / rel
150
+ d.mkdir(parents=True, exist_ok=True)
151
+ (d / "source.py").write_text("# real tracked overlay source\n")
152
+ # The repo still ignores its runtime dirs itself (as the live monorepo does).
153
+ (repo / ".gitignore").write_text(".workstate/\n.task-state/\n")
154
+ _git("add", "-A", cwd=repo)
155
+ _git("commit", "-m", "tracked overlay source", cwd=repo)
156
+
157
+ result = _ensure_consumer_gitignore_block(repo)
158
+
159
+ assert result == {"path": ".gitignore", "action": "skipped_self_managed"}
160
+ assert GITIGNORE_SENTINEL_BEGIN not in (repo / ".gitignore").read_text()
161
+ # The footgun must not occur: tracked source must never become ignored.
162
+ for rel in _TRACKED_SURFACE_DIRS:
163
+ assert not _is_ignored(repo, rel), f"tracked source {rel} must not be ignored"
164
+ # Sanity: every tracked surface still appears in git's tracked set.
165
+ tracked = _git("ls-files", cwd=repo).splitlines()
166
+ assert any(line.startswith("scripts/hooks/") for line in tracked)
167
+
168
+
169
+ def test_block_skipped_when_surfaces_already_ignored(tmp_path: Path) -> None:
170
+ """The self-hosting monorepo ships a hand-authored ``.gitignore`` that already
171
+ ignores the overlay surfaces (some with directory-only ``dir/`` patterns). The
172
+ managed block must not be appended as a redundant duplicate that dirties every
173
+ feature worktree's tracked ``.gitignore``."""
174
+ repo = tmp_path / "monorepo"
175
+ _init_repo(repo)
176
+ # Mirror the live monorepo: a mix of root-anchored and directory-only
177
+ # (trailing-slash) patterns covering every managed entry.
178
+ hand_authored = (
179
+ ".workstate/\n"
180
+ ".task-state/\n"
181
+ "/scripts/hooks\n"
182
+ "/.github/hooks\n"
183
+ "/docs/workstate/contracts\n"
184
+ "/docs/workstate/rules\n"
185
+ "/Makefile.d\n"
186
+ "/scripts/workstate\n"
187
+ "/.github/prompts/\n"
188
+ )
189
+ (repo / ".gitignore").write_text(hand_authored)
190
+ _git("add", ".gitignore", cwd=repo)
191
+ _git("commit", "-m", "hand-authored overlay ignores", cwd=repo)
192
+ before = (repo / ".gitignore").read_text()
193
+
194
+ result = _ensure_consumer_gitignore_block(repo)
195
+
196
+ assert result == {"path": ".gitignore", "action": "skipped_self_managed"}
197
+ assert (repo / ".gitignore").read_text() == before # untouched, no duplicate
198
+ assert GITIGNORE_SENTINEL_BEGIN not in before
199
+ # Idempotent: a second pass is still a clean skip, never an append.
200
+ assert _ensure_consumer_gitignore_block(repo)["action"] == "skipped_self_managed"
201
+
202
+
203
+ def test_block_written_when_any_surface_leaks(tmp_path: Path) -> None:
204
+ """A real external consumer (surfaces freshly materialized, untracked, not yet
205
+ ignored) still gets the block — the self-managed guard only fires when every
206
+ managed surface is already tracked or ignored."""
207
+ repo = tmp_path / "consumer"
208
+ _init_repo(repo)
209
+ # Ignore the runtime dirs but leave the surfaces leaking (as before install).
210
+ (repo / ".gitignore").write_text(".workstate/\n.task-state/\n")
211
+ _git("add", ".gitignore", cwd=repo)
212
+ _git("commit", "-m", "runtime ignores only", cwd=repo)
213
+
214
+ result = _ensure_consumer_gitignore_block(repo)
215
+
216
+ assert result["action"] == "appended"
217
+ assert GITIGNORE_SENTINEL_BEGIN in (repo / ".gitignore").read_text()
218
+
219
+
220
+ def test_mixed_self_host_never_ignores_tracked_surface(tmp_path: Path) -> None:
221
+ """MIXED self-host footgun gate: one surface is TRACKED source, another is a
222
+ still-leaking materialized symlink. The block must be written for the leaking
223
+ surface but must NEVER ignore the tracked surface — emitting a root-anchored
224
+ ``/scripts/hooks`` ignore over tracked source silently makes it un-trackable.
225
+ Per-entry filtering (not all-or-nothing) is what closes this."""
226
+ repo = tmp_path / "monorepo"
227
+ _init_repo(repo)
228
+ # Tracked source at one surface; runtime dirs ignored by hand-authored config.
229
+ (repo / "scripts" / "hooks").mkdir(parents=True)
230
+ (repo / "scripts" / "hooks" / "src.py").write_text("# tracked overlay source\n")
231
+ (repo / ".gitignore").write_text(".workstate/\n.task-state/\n")
232
+ _git("add", "-A", cwd=repo)
233
+ _git("commit", "-m", "tracked scripts/hooks + runtime ignores", cwd=repo)
234
+ # A genuinely-leaking surface: a materialized symlink, untracked + unignored.
235
+ external = tmp_path / "external"
236
+ external.mkdir()
237
+ (repo / "Makefile.d").symlink_to(external)
238
+
239
+ result = _ensure_consumer_gitignore_block(repo)
240
+
241
+ # A block IS written (the leaking surface needs ignoring)...
242
+ assert result["action"] == "appended"
243
+ text = (repo / ".gitignore").read_text()
244
+ assert GITIGNORE_SENTINEL_BEGIN in text
245
+ # ...but the tracked surface must NOT appear in it / must stay trackable.
246
+ assert "/scripts/hooks" not in text
247
+ assert not _is_ignored(repo, "scripts/hooks")
248
+ # ...while the leaking surface is now ignored.
249
+ assert "/Makefile.d" in text
250
+ assert _is_ignored(repo, "Makefile.d")
251
+
252
+
253
+ def test_entries_helper_covers_surface_dirs() -> None:
254
+ """Pin: the surfaces the self-managed guard inspects are exactly the managed
255
+ entries, so a future surface addition is not silently excluded from the guard.
256
+ """
257
+ entries = {e.lstrip("/") for e in _consumer_gitignore_entries()}
258
+ for rel in _TRACKED_SURFACE_DIRS:
259
+ assert rel in entries, rel
@@ -517,6 +517,23 @@ def fake_remote_with_generator(tmp_path: Path) -> tuple[str, str]:
517
517
  if system_root is None:
518
518
  pytest.skip("packages/workstate-system not available in this environment")
519
519
 
520
+ # implementation note S3: shipped overlay surfaces now live under the package's
521
+ # ``workstate_system/payload/`` tree. Reads of the REAL package source
522
+ # resolve payload-first, then fall back to the legacy top-level path for
523
+ # surfaces that stayed (e.g. ``Makefile.d/evals.mk``, ``scripts/workstate/
524
+ # evals``). The fake remote still mirrors the OLD consumer layout so the
525
+ # bootstrap resolver's legacy fallback probe keeps resolving.
526
+ payload_root = system_root / "workstate_system" / "payload"
527
+
528
+ def _real_surface_dirs(rel: str) -> list[Path]:
529
+ """Real source dirs for ``rel``, payload-first then legacy top-level.
530
+ Partial-move surfaces (Makefile.d, scripts/workstate) exist in both."""
531
+ return [d for d in (payload_root / rel, system_root / rel) if d.is_dir()]
532
+
533
+ def _merge_copytree(sources: list[Path], target_dir: Path) -> None:
534
+ for source_dir in sources:
535
+ shutil.copytree(source_dir, target_dir, dirs_exist_ok=True)
536
+
520
537
  src = tmp_path / "gen-src"
521
538
  src.mkdir()
522
539
  _git("init", "--initial-branch=main", cwd=src)
@@ -524,13 +541,13 @@ def fake_remote_with_generator(tmp_path: Path) -> tuple[str, str]:
524
541
  _git("config", "user.name", "Test", cwd=src)
525
542
  system_subdir = src / "packages" / "workstate-system"
526
543
  for surface in SHARED_SURFACES_EXPECTED:
527
- source_dir = system_root / surface
544
+ source_dirs = _real_surface_dirs(surface)
528
545
  target_dir = system_subdir / surface
529
- if source_dir.is_dir() and any(source_dir.iterdir()):
546
+ if source_dirs and any(any(d.iterdir()) for d in source_dirs):
530
547
  # Mirror the real surface so rehearsal tests see actual hoisted
531
548
  # content (e.g. implementation note implementation note needs Makefile.d/plans.mk's
532
549
  # launcher token to land verbatim).
533
- shutil.copytree(source_dir, target_dir)
550
+ _merge_copytree(source_dirs, target_dir)
534
551
  else:
535
552
  target_dir.mkdir(parents=True)
536
553
  (target_dir / "MARKER.md").write_text(f"shared {surface}\n")
@@ -556,17 +573,19 @@ def fake_remote_with_generator(tmp_path: Path) -> tuple[str, str]:
556
573
  continue
557
574
  helper_path.write_text("#!/usr/bin/env python3\nimport sys; sys.exit(0)\n")
558
575
  helper_path.chmod(0o755)
559
- # Generator + manifest + neutral skill source, all under packages/workstate-system/.
576
+ # Generator + manifest + neutral skill source. These surfaces MOVED to
577
+ # the payload tree (implementation note S3); read them from payload_root but mirror
578
+ # them into the fake remote at the OLD consumer layout.
560
579
  (system_subdir / "scripts").mkdir(parents=True, exist_ok=True)
561
580
  shutil.copy2(
562
- system_root / "scripts" / "generate_agent_workflows.py",
581
+ payload_root / "scripts" / "generate_agent_workflows.py",
563
582
  system_subdir / "scripts" / "generate_agent_workflows.py",
564
583
  )
565
584
  shutil.copytree(
566
- system_root / "config" / "agent-workflows",
585
+ payload_root / "config" / "agent-workflows",
567
586
  system_subdir / "config" / "agent-workflows",
568
587
  )
569
- shutil.copytree(system_root / "skills", system_subdir / "skills")
588
+ shutil.copytree(payload_root / "skills", system_subdir / "skills")
570
589
 
571
590
  _git("add", "-A", cwd=src)
572
591
  _git("commit", "-m", "seed surfaces + generator", cwd=src)
@@ -680,7 +699,13 @@ def test_install_discovers_plugin_override_root_and_composes_effective_tree(
680
699
  "---\nname: branch-review\ndescription: local override\n---\n\nBootstrap-composed override body.\n"
681
700
  )
682
701
 
683
- system_root = Path(__file__).resolve().parents[2] / "workstate-system"
702
+ # implementation note S3: skills/ moved into the package payload tree.
703
+ system_root = (
704
+ Path(__file__).resolve().parents[2]
705
+ / "workstate-system"
706
+ / "workstate_system"
707
+ / "payload"
708
+ )
684
709
  structured = yaml.safe_load((system_root / "skills" / "branch-review" / "skill.yaml").read_text())
685
710
  structured.pop("generator", None)
686
711
  body = (system_root / "skills" / "branch-review" / "body.md").read_text()
@@ -767,7 +792,13 @@ def test_install_writes_tracked_override_lock_for_plugin_overrides(
767
792
  "---\nname: branch-review\ndescription: local override\n---\n\nBootstrap-composed override body.\n"
768
793
  )
769
794
 
770
- system_root = Path(__file__).resolve().parents[2] / "workstate-system"
795
+ # implementation note S3: skills/ moved into the package payload tree.
796
+ system_root = (
797
+ Path(__file__).resolve().parents[2]
798
+ / "workstate-system"
799
+ / "workstate_system"
800
+ / "payload"
801
+ )
771
802
  structured = yaml.safe_load((system_root / "skills" / "branch-review" / "skill.yaml").read_text())
772
803
  structured.pop("generator", None)
773
804
  body = (system_root / "skills" / "branch-review" / "body.md").read_text()
@@ -826,7 +857,13 @@ def test_install_accepts_explicit_plugin_override_root(
826
857
  "---\nname: branch-review\ndescription: local override\n---\n\nBootstrap-composed override body.\n"
827
858
  )
828
859
 
829
- system_root = Path(__file__).resolve().parents[2] / "workstate-system"
860
+ # implementation note S3: skills/ moved into the package payload tree.
861
+ system_root = (
862
+ Path(__file__).resolve().parents[2]
863
+ / "workstate-system"
864
+ / "workstate_system"
865
+ / "payload"
866
+ )
830
867
  structured = yaml.safe_load((system_root / "skills" / "branch-review" / "skill.yaml").read_text())
831
868
  structured.pop("generator", None)
832
869
  body = (system_root / "skills" / "branch-review" / "body.md").read_text()
@@ -894,7 +931,13 @@ def test_install_console_script_accepts_plugin_overrides_flag(
894
931
  "---\nname: branch-review\ndescription: local override\n---\n\nBootstrap-composed override body.\n"
895
932
  )
896
933
 
897
- system_root = Path(__file__).resolve().parents[2] / "workstate-system"
934
+ # implementation note S3: skills/ moved into the package payload tree.
935
+ system_root = (
936
+ Path(__file__).resolve().parents[2]
937
+ / "workstate-system"
938
+ / "workstate_system"
939
+ / "payload"
940
+ )
898
941
  structured = yaml.safe_load((system_root / "skills" / "branch-review" / "skill.yaml").read_text())
899
942
  structured.pop("generator", None)
900
943
  body = (system_root / "skills" / "branch-review" / "body.md").read_text()
@@ -969,7 +1012,13 @@ def test_install_points_plugin_pins_at_effective_tree_when_overrides_exist(
969
1012
  "---\nname: branch-review\ndescription: local override\n---\n\nBootstrap-composed override body.\n"
970
1013
  )
971
1014
 
972
- system_root = Path(__file__).resolve().parents[2] / "workstate-system"
1015
+ # implementation note S3: skills/ moved into the package payload tree.
1016
+ system_root = (
1017
+ Path(__file__).resolve().parents[2]
1018
+ / "workstate-system"
1019
+ / "workstate_system"
1020
+ / "payload"
1021
+ )
973
1022
  structured = yaml.safe_load((system_root / "skills" / "branch-review" / "skill.yaml").read_text())
974
1023
  structured.pop("generator", None)
975
1024
  body = (system_root / "skills" / "branch-review" / "body.md").read_text()
@@ -1042,7 +1091,13 @@ def test_install_preserves_explicit_plugin_disable_when_overrides_exist(
1042
1091
  "---\nname: branch-review\ndescription: local override\n---\n\nBootstrap-composed override body.\n"
1043
1092
  )
1044
1093
 
1045
- system_root = Path(__file__).resolve().parents[2] / "workstate-system"
1094
+ # implementation note S3: skills/ moved into the package payload tree.
1095
+ system_root = (
1096
+ Path(__file__).resolve().parents[2]
1097
+ / "workstate-system"
1098
+ / "workstate_system"
1099
+ / "payload"
1100
+ )
1046
1101
  structured = yaml.safe_load((system_root / "skills" / "branch-review" / "skill.yaml").read_text())
1047
1102
  structured.pop("generator", None)
1048
1103
  body = (system_root / "skills" / "branch-review" / "body.md").read_text()
@@ -1116,7 +1171,13 @@ def test_install_reverts_effective_tree_pins_to_base_tree_when_overrides_disappe
1116
1171
  "---\nname: branch-review\ndescription: local override\n---\n\nBootstrap-composed override body.\n"
1117
1172
  )
1118
1173
 
1119
- system_root = Path(__file__).resolve().parents[2] / "workstate-system"
1174
+ # implementation note S3: skills/ moved into the package payload tree.
1175
+ system_root = (
1176
+ Path(__file__).resolve().parents[2]
1177
+ / "workstate-system"
1178
+ / "workstate_system"
1179
+ / "payload"
1180
+ )
1120
1181
  structured = yaml.safe_load((system_root / "skills" / "branch-review" / "skill.yaml").read_text())
1121
1182
  structured.pop("generator", None)
1122
1183
  body = (system_root / "skills" / "branch-review" / "body.md").read_text()
@@ -1181,7 +1242,13 @@ def test_install_reset_overrides_removes_clean_override_root(
1181
1242
  "---\nname: branch-review\ndescription: local override\n---\n\nBootstrap-composed override body.\n"
1182
1243
  )
1183
1244
 
1184
- system_root = Path(__file__).resolve().parents[2] / "workstate-system"
1245
+ # implementation note S3: skills/ moved into the package payload tree.
1246
+ system_root = (
1247
+ Path(__file__).resolve().parents[2]
1248
+ / "workstate-system"
1249
+ / "workstate_system"
1250
+ / "payload"
1251
+ )
1185
1252
  structured = yaml.safe_load((system_root / "skills" / "branch-review" / "skill.yaml").read_text())
1186
1253
  structured.pop("generator", None)
1187
1254
  body = (system_root / "skills" / "branch-review" / "body.md").read_text()
@@ -2148,8 +2215,8 @@ def test_install_with_default_sentinel_uses_built_in_server_map(
2148
2215
  assert entry["type"] == "stdio"
2149
2216
  assert "--workspace-root" in entry["args"]
2150
2217
  assert entry["args"][-1] == "serve-stdio"
2151
- assert mcp_doc["mcpServers"]["workstate-handoff-mcp"]["args"][0] == "mcp-workstate-handoff@0.12.0"
2152
- assert mcp_doc["mcpServers"]["workstate-orchestrator-mcp"]["args"][0] == "mcp-workstate-orchestrator@0.5.1"
2218
+ assert mcp_doc["mcpServers"]["workstate-handoff-mcp"]["args"][0] == "mcp-workstate-handoff@0.12.1"
2219
+ assert mcp_doc["mcpServers"]["workstate-orchestrator-mcp"]["args"][0] == "mcp-workstate-orchestrator@0.5.2"
2153
2220
  for entry in vscode_doc["servers"].values():
2154
2221
  assert entry["type"] == "stdio"
2155
2222
  assert "--workspace-root" in entry["args"]
@@ -2355,7 +2422,7 @@ def test_run_init_state_retries_with_cloned_handoff_project_when_uvx_fails(
2355
2422
  )
2356
2423
 
2357
2424
  assert [cmd[0] for cmd, _ in calls] == ["uvx", "uv"]
2358
- assert calls[0][0][1] == "mcp-workstate-handoff@0.12.0"
2425
+ assert calls[0][0][1] == "mcp-workstate-handoff@0.12.1"
2359
2426
  assert calls[1][0][:5] == [
2360
2427
  "uv",
2361
2428
  "run",
@@ -2659,7 +2726,7 @@ def test_run_init_state_surfaces_uvx_error_when_no_cloned_handoff_project_exists
2659
2726
 
2660
2727
  assert calls == [[
2661
2728
  "uvx",
2662
- "mcp-workstate-handoff@0.12.0",
2729
+ "mcp-workstate-handoff@0.12.1",
2663
2730
  "--workspace-root",
2664
2731
  ".",
2665
2732
  "--state-dir",
@@ -51,7 +51,7 @@ def _build_and_unpack_package(tmp: Path) -> Path:
51
51
  unpacked = tmp / "site"
52
52
  with zipfile.ZipFile(wheel) as zf:
53
53
  zf.extractall(unpacked)
54
- root = unpacked / "workstate_system"
54
+ root = unpacked / "workstate_system" / "payload"
55
55
  assert (root / "scripts" / "hooks").is_dir(), (
56
56
  "unpacked payload missing scripts/hooks"
57
57
  )
@@ -156,7 +156,7 @@ def test_cli_install_from_package_resolves_installed_payload(
156
156
  from workstate_bootstrap.cli import main as cli_main
157
157
 
158
158
  package_root = _build_and_unpack_package(tmp_path)
159
- monkeypatch.syspath_prepend(str(package_root.parent))
159
+ monkeypatch.syspath_prepend(str(package_root.parents[1]))
160
160
 
161
161
  target = tmp_path / "consumer"
162
162
  target.mkdir()
@@ -383,9 +383,12 @@ def test_overlay_manifest_contract_documents_canonical_and_legacy() -> None:
383
383
  """WS-HARNESS-FAILSAFE-01 implementation note: the overlay-manifest contract doc must
384
384
  name the canonical `.workstate-bootstrap.json` ledger as well as the legacy
385
385
  `.workstate-overlay.json` read-compat manifest."""
386
+ # implementation note S3: docs/workstate/contracts moved into the package payload tree.
386
387
  contract = (
387
388
  Path(__file__).resolve().parents[2]
388
389
  / "workstate-system"
390
+ / "workstate_system"
391
+ / "payload"
389
392
  / "docs"
390
393
  / "workstate"
391
394
  / "contracts"
@@ -436,9 +439,12 @@ def _seed_branch_review_override_at(override_root: Path, system_root: Path) -> N
436
439
  "---\nname: branch-review\ndescription: local override\n---\n\nBootstrap-composed override body.\n"
437
440
  )
438
441
 
439
- structured = yaml.safe_load((system_root / "skills" / "branch-review" / "skill.yaml").read_text())
442
+ # implementation note S3: skills/ moved into the package payload tree. Read the real
443
+ # upstream skill source from payload; the override layout above stays.
444
+ payload_root = system_root / "workstate_system" / "payload"
445
+ structured = yaml.safe_load((payload_root / "skills" / "branch-review" / "skill.yaml").read_text())
440
446
  structured.pop("generator", None)
441
- body = (system_root / "skills" / "branch-review" / "body.md").read_text()
447
+ body = (payload_root / "skills" / "branch-review" / "body.md").read_text()
442
448
  fm_text = yaml.safe_dump(structured, sort_keys=False, default_flow_style=False).rstrip()
443
449
  base_skill = f"---\n{fm_text}\n---\n\n{body if body.endswith(chr(10)) else body + chr(10)}"
444
450
  upstream_digest = hashlib.sha256(base_skill.encode("utf-8")).hexdigest()
@@ -764,6 +770,20 @@ def _build_generator_two_ref_remote(
764
770
  if system_root is None:
765
771
  pytest.skip("packages/workstate-system not available in this environment")
766
772
 
773
+ # implementation note S3: shipped overlay surfaces now live under the package's
774
+ # ``workstate_system/payload/`` tree. Reads of the REAL package source
775
+ # resolve payload-first, then fall back to the legacy top-level path for
776
+ # surfaces that stayed (e.g. ``Makefile.d/evals.mk``, ``scripts/workstate/
777
+ # evals``). The fake remote still mirrors the OLD consumer layout.
778
+ payload_root = system_root / "workstate_system" / "payload"
779
+
780
+ def _real_surface_dirs(rel: str) -> list[Path]:
781
+ return [d for d in (payload_root / rel, system_root / rel) if d.is_dir()]
782
+
783
+ def _merge_copytree(sources: list[Path], target_dir: Path) -> None:
784
+ for source_dir in sources:
785
+ shutil.copytree(source_dir, target_dir, dirs_exist_ok=True)
786
+
767
787
  src = tmp_path / "gen-two-refs-src"
768
788
  src.mkdir()
769
789
  _git("init", "--initial-branch=main", cwd=src)
@@ -772,10 +792,10 @@ def _build_generator_two_ref_remote(
772
792
 
773
793
  system_subdir = src / "packages" / "workstate-system"
774
794
  for surface in SHARED_SURFACES_EXPECTED:
775
- source_dir = system_root / surface
795
+ source_dirs = _real_surface_dirs(surface)
776
796
  target_dir = system_subdir / surface
777
- if source_dir.is_dir() and any(source_dir.iterdir()):
778
- shutil.copytree(source_dir, target_dir)
797
+ if source_dirs and any(any(d.iterdir()) for d in source_dirs):
798
+ _merge_copytree(source_dirs, target_dir)
779
799
  else:
780
800
  target_dir.mkdir(parents=True)
781
801
  (target_dir / "MARKER.md").write_text(f"shared {surface}\n")
@@ -797,14 +817,14 @@ def _build_generator_two_ref_remote(
797
817
 
798
818
  (system_subdir / "scripts").mkdir(parents=True, exist_ok=True)
799
819
  shutil.copy2(
800
- system_root / "scripts" / "generate_agent_workflows.py",
820
+ payload_root / "scripts" / "generate_agent_workflows.py",
801
821
  system_subdir / "scripts" / "generate_agent_workflows.py",
802
822
  )
803
823
  shutil.copytree(
804
- system_root / "config" / "agent-workflows",
824
+ payload_root / "config" / "agent-workflows",
805
825
  system_subdir / "config" / "agent-workflows",
806
826
  )
807
- shutil.copytree(system_root / "skills", system_subdir / "skills")
827
+ shutil.copytree(payload_root / "skills", system_subdir / "skills")
808
828
 
809
829
  _git("add", "-A", cwd=src)
810
830
  _git("commit", "-m", "v1", cwd=src)
@@ -1191,9 +1211,10 @@ def test_doctor_reports_hidden_override_collision_for_undeclared_skill(
1191
1211
  _init_git_repo(target)
1192
1212
  _seed_branch_review_override(target, system_root)
1193
1213
 
1214
+ # implementation note S3: skills/ moved into the package payload tree.
1194
1215
  colliding_skill = next(
1195
1216
  path.name
1196
- for path in (system_root / "skills").iterdir()
1217
+ for path in (system_root / "workstate_system" / "payload" / "skills").iterdir()
1197
1218
  if path.is_dir() and path.name != "branch-review"
1198
1219
  )
1199
1220
  undeclared_skill = (
@@ -1,104 +0,0 @@
1
- """TDD gate for implementation note Slice S4: managed consumer ``.gitignore`` block.
2
-
3
- An installed/adopted overlay materializes the runtime dir (``.workstate``) and a
4
- set of surface SYMLINKS (``Makefile.d``, ``scripts/hooks`` …). Without ignore
5
- rules these show as untracked, so ``git status`` is never clean. A managed,
6
- sentinel-delimited block (mirroring the Makefile-include pattern) keeps it clean
7
- without clobbering a user-authored ``.gitignore``.
8
-
9
- M1: the patterns must be ROOT-ANCHORED and NON-trailing-slash — a trailing-slash
10
- pattern matches only directories, but git treats a symlink as a non-directory,
11
- so ``Makefile.d/`` would miss an adopted ``Makefile.d`` symlink.
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- import subprocess
17
- from pathlib import Path
18
-
19
- from workstate_bootstrap.install import (
20
- GITIGNORE_SENTINEL_BEGIN,
21
- _ensure_consumer_gitignore_block,
22
- )
23
-
24
-
25
- def _git(*args: str, cwd: Path) -> str:
26
- result = subprocess.run(
27
- ["git", *args],
28
- check=True,
29
- capture_output=True,
30
- text=True,
31
- cwd=str(cwd),
32
- timeout=30,
33
- )
34
- return result.stdout.strip()
35
-
36
-
37
- def _init_repo(root: Path) -> None:
38
- root.mkdir(parents=True, exist_ok=True)
39
- _git("init", "--initial-branch=main", cwd=root)
40
- _git("config", "user.email", "t@e.com", cwd=root)
41
- _git("config", "user.name", "T", cwd=root)
42
-
43
-
44
- def test_block_created_when_no_gitignore(tmp_path: Path) -> None:
45
- target = tmp_path / "consumer"
46
- target.mkdir()
47
- result = _ensure_consumer_gitignore_block(target)
48
- assert result == {"path": ".gitignore", "action": "created"}
49
- text = (target / ".gitignore").read_text()
50
- assert GITIGNORE_SENTINEL_BEGIN in text
51
- assert "/.workstate" in text
52
- assert "/Makefile.d" in text
53
-
54
-
55
- def test_block_appended_preserves_user_content(tmp_path: Path) -> None:
56
- target = tmp_path / "consumer"
57
- target.mkdir()
58
- (target / ".gitignore").write_text("# user\n*.log\nbuild/\n")
59
- result = _ensure_consumer_gitignore_block(target)
60
- assert result == {"path": ".gitignore", "action": "appended"}
61
- text = (target / ".gitignore").read_text()
62
- assert "*.log" in text and "build/" in text # user content preserved
63
- assert GITIGNORE_SENTINEL_BEGIN in text
64
-
65
-
66
- def test_block_is_idempotent(tmp_path: Path) -> None:
67
- target = tmp_path / "consumer"
68
- target.mkdir()
69
- _ensure_consumer_gitignore_block(target)
70
- result = _ensure_consumer_gitignore_block(target)
71
- assert result == {"path": ".gitignore", "action": "already_present"}
72
- # No duplicate block.
73
- assert (target / ".gitignore").read_text().count(GITIGNORE_SENTINEL_BEGIN) == 1
74
-
75
-
76
- def test_block_ignores_symlink_surfaces_git_status_clean(tmp_path: Path) -> None:
77
- """M1 regression: the block must ignore adopted SYMLINK surfaces + .workstate
78
- so `git status` shows nothing but the .gitignore itself."""
79
- repo = tmp_path / "repo"
80
- _init_repo(repo)
81
- external = tmp_path / "external"
82
- external.mkdir()
83
-
84
- # Materialize the overlay shape: a real .workstate dir with a child symlink,
85
- # plus whole-dir surface symlinks (as adoption produces).
86
- (repo / ".workstate").mkdir()
87
- (repo / ".workstate" / "remote").symlink_to(external)
88
- (repo / "Makefile.d").symlink_to(external)
89
- (repo / "scripts").mkdir()
90
- (repo / "scripts" / "hooks").symlink_to(external)
91
- (repo / ".github").mkdir()
92
- (repo / ".github" / "hooks").symlink_to(external)
93
-
94
- _ensure_consumer_gitignore_block(repo)
95
-
96
- status_lines = [
97
- line
98
- for line in _git("status", "--porcelain", cwd=repo).splitlines()
99
- if line.strip()
100
- ]
101
- # Only the .gitignore itself may be untracked; no overlay path leaks.
102
- assert all(".gitignore" in line for line in status_lines), status_lines
103
- for leaked in (".workstate", "Makefile.d", "scripts/hooks", ".github/hooks"):
104
- assert all(leaked not in line for line in status_lines), (leaked, status_lines)