workstate-bootstrap 0.5.2__py3-none-any.whl

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.
@@ -0,0 +1,2057 @@
1
+ """Minimal install flow for the workstate-bootstrap CLI.
2
+
3
+ This slice implements four responsibilities:
4
+
5
+ 1. Clone (or fast-forward) ``<remote_url>`` at ``<remote_ref>`` into
6
+ ``<target>/.agentic/remote/``.
7
+ 2. Symlink the six known shared overlay surfaces from the clone into the
8
+ consumer repo, preserving any pre-existing real local directory at the
9
+ same path (overlay precedence: local wins per surface).
10
+ 3. When ``mcp_servers`` is provided, configure the three consumer-tool
11
+ surfaces — ``.mcp.json`` (Claude Code), ``.vscode/mcp.json`` (VS Code),
12
+ and ``.codex/config.toml`` (Codex CLI) — by deep-merging or
13
+ tomlkit-replacing only the managed entries while preserving everything
14
+ else the user had configured.
15
+ 4. When ``<target>`` is a git repo, point ``core.hooksPath`` at the
16
+ materialized ``scripts/hooks/git`` directory so git resolves shared
17
+ hooks by name (``post-checkout``, ``pre-commit``, ``pre-push`` …).
18
+ The parent ``scripts/hooks/`` symlink ships Python helpers and other
19
+ non-git-hook files; setting ``core.hooksPath`` there makes git
20
+ silently resolve nothing; the bootstrap-managed git hook directory is the
21
+ only valid hooksPath target.
22
+ 5. Write ``<target>/.workstate-bootstrap.json`` describing the resolved remote,
23
+ the materialized surfaces, and the configs that were touched. Older
24
+ installs wrote ``.workstate-overlay.json``; the legacy file is migrated
25
+ in-place on first run when present.
26
+
27
+ The ``doctor`` / ``repair`` / ``update`` / ``status`` subcommands are
28
+ implemented in adjacent modules and are deliberately out of scope here.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import os
35
+ import shutil
36
+ import subprocess
37
+ import sys
38
+ from datetime import UTC, datetime
39
+ from pathlib import Path
40
+ from collections.abc import Iterable
41
+ from typing import Any, Mapping
42
+
43
+ import tomlkit
44
+ import yaml
45
+
46
+ BOOTSTRAP_MANIFEST_NAME = ".workstate-bootstrap.json"
47
+ LEGACY_OVERLAY_MANIFEST_NAME = ".workstate-overlay.json"
48
+ # Deprecated alias kept for downstream code importing the old name. Points
49
+ # at the canonical (new) filename, NOT the legacy file. Reading legacy
50
+ # installs goes through _migrate_legacy_manifest below.
51
+ OVERLAY_MANIFEST_NAME = BOOTSTRAP_MANIFEST_NAME
52
+ SCHEMA_VERSION = 2
53
+ CLONE_SUBDIR = (".agentic", "remote")
54
+
55
+
56
+ def _build_install_manifest(
57
+ *,
58
+ remote_url: str,
59
+ remote_ref: str,
60
+ remote_sha: str,
61
+ profile: str,
62
+ surfaces: list[dict[str, str]],
63
+ configs: list[dict[str, str]],
64
+ mcp_servers: Mapping[str, Mapping[str, Any]] | None,
65
+ plugin_overrides_path: str | None,
66
+ ) -> dict[str, object]:
67
+ """Build the dict that will be written to ``.workstate-bootstrap.json``.
68
+
69
+ ``mcp_servers`` is the mapping that ``install()`` actually used to
70
+ write the three config surfaces. Persisting the sorted key list as
71
+ ``manifest["mcp_servers"]`` gives ``sync_mcp_configs(prune_removed_managed=True)``
72
+ an authoritative previously-managed provenance: any name in this
73
+ list that disappears from the new managed set is a removal that
74
+ sync may prune from the surface files; everything else is treated
75
+ as third-party and left untouched.
76
+ """
77
+ manifest = {
78
+ "schema_version": SCHEMA_VERSION,
79
+ "remote_url": remote_url,
80
+ "remote_ref": remote_ref,
81
+ "remote_sha": remote_sha,
82
+ "profile": profile,
83
+ "surfaces": surfaces,
84
+ "configs": configs,
85
+ "mcp_servers": sorted(mcp_servers) if mcp_servers else [],
86
+ }
87
+ if plugin_overrides_path is not None:
88
+ manifest["plugin_overrides_path"] = plugin_overrides_path
89
+ return manifest
90
+
91
+ # In the workstate, the shared workstate-system surfaces live
92
+ # under packages/workstate-system/ rather than at the clone root. We probe this
93
+ # subdirectory first when resolving a surface in the clone, and fall back to
94
+ # the clone root for legacy/hoisted overlay layouts (and for the
95
+ # fake_remote_with_surfaces fixture used elsewhere in the test suite).
96
+ WORKSTATE_SYSTEM_SUBDIR = "packages/workstate-system"
97
+
98
+ # Shared overlay surfaces materialized as symlinks into ``.agentic/remote``.
99
+ # Per-agent surfaces (.claude/skills, .claude/commands, .github/prompts,
100
+ # .codex/skills) are no longer canonical in the overlay clone; they are
101
+ # generated artifacts produced by generate_agent_workflows.py during install.
102
+ # Only the truly shared surfaces remain symlinked here.
103
+ SHARED_SURFACES: tuple[str, ...] = (
104
+ ".github/hooks",
105
+ "scripts/hooks",
106
+ "docs/agentic/contracts",
107
+ # Hoist canonical rule docs (development-workflow.md,
108
+ # branch-review-guide.md, planning-artifact-home.md) to consumers.
109
+ # Prior to this entry rule docs sat repo-local even though hooks and
110
+ # skills cite them by path; only contracts propagated.
111
+ "docs/agentic/rules",
112
+ # Plan-targets surface and the optional git plan-cat shell wrapper.
113
+ # Hoisted as directory symlinks so consumers
114
+ # inherit Makefile.d/plans.mk and scripts/workstate/git-plan-cat.sh
115
+ # (and any sibling files added in later slices) without re-running
116
+ # bootstrap on every file addition. The Python logic the wrappers
117
+ # invoke lives in the workstate_handoff_mcp package, fetched on demand
118
+ # by uvx — no Python module is hoisted via overlay.
119
+ "Makefile.d",
120
+ "scripts/workstate",
121
+ )
122
+
123
+ # WS-REBRAND-01 Phase A: children of a SHARED_SURFACE that must be *absent*
124
+ # from the consumer tree. A whole-directory symlink exposes every child, so a
125
+ # surface that has any excluded child is materialized as a real directory with
126
+ # an individual symlink per non-excluded child instead — the named children
127
+ # simply never appear. The evals harness (config + runner + Make fragment) is
128
+ # private operational tooling excluded from the public consumer surface; its
129
+ # config (config/evals) was never shipped, so the runner and Make fragment
130
+ # that rode the whole-directory symlinks are carved out here to match.
131
+ SURFACE_CHILD_EXCLUSIONS: dict[str, frozenset[str]] = {
132
+ "scripts/workstate": frozenset({"evals"}),
133
+ "Makefile.d": frozenset({"evals.mk"}),
134
+ }
135
+
136
+ # Per-agent surfaces written by the generator into the target as real
137
+ # directories (not symlinks). Bootstrap ensures these exist as real
138
+ # dirs before the generator runs; pre-existing symlinks pointing into
139
+ # .agentic/remote/ (left over from the legacy overlay model) are
140
+ # replaced. Recorded in the manifest with ``source: "generated"``.
141
+ #
142
+ # ``.claude/skills``, ``.codex/skills``, and ``.claude/commands`` were
143
+ # dropped when the generated plugin tree became canonical; it
144
+ # (``.agentic/generated/plugins/workstate-system/base/{claude,codex}/``)
145
+ # now owns the Claude/Codex SKILL.md surface and command discovery. The
146
+ # legacy generator path emits Copilot prompts and the codex-command-router
147
+ # from the manifest path; everything else flows through the plugin
148
+ # marketplace pin.
149
+ GENERATED_SURFACES: tuple[str, ...] = (
150
+ ".github/prompts",
151
+ )
152
+
153
+ PLUGIN_NAME = "workstate-system"
154
+ PLUGIN_MARKETPLACE_NAME = "workstate-marketplace"
155
+ PLUGIN_OWNER_NAME = "workstate maintainers"
156
+ PLUGIN_DESCRIPTION = (
157
+ "Cross-harness workstate-system plugin: portable workflow skills "
158
+ "(SKILL.md) plus uvx-stdio MCP servers (workstate-handoff-mcp, workstate-orchestrator-mcp)."
159
+ )
160
+ PLUGIN_GENERATED_ROOT: tuple[str, ...] = (
161
+ ".agentic",
162
+ "generated",
163
+ "plugins",
164
+ PLUGIN_NAME,
165
+ )
166
+ PLUGIN_OVERRIDE_ROOT: tuple[str, ...] = ("workstate-overrides", PLUGIN_NAME)
167
+ PLUGIN_OVERRIDE_MANIFEST = "overrides.yaml"
168
+ PLUGIN_OVERRIDE_LOCK = "overrides.lock.json"
169
+ CLAUDE_MARKETPLACE_PATH = Path(".claude-plugin") / "marketplace.json"
170
+ CLAUDE_SETTINGS_PATH = Path(".claude") / "settings.json"
171
+ CODEX_MARKETPLACE_PATH = Path(".agents") / "plugins" / "marketplace.json"
172
+ CODEX_CONFIG_PATH = Path(".codex") / "config.toml"
173
+ PLUGIN_SELECTOR = f"{PLUGIN_NAME}@{PLUGIN_MARKETPLACE_NAME}"
174
+
175
+ # Path to the generator script inside the cloned overlay.
176
+ GENERATOR_SCRIPT = "scripts/generate_agent_workflows.py"
177
+ GENERATOR_MANIFEST = "config/agent-workflows/portable_commands.json"
178
+ GENERATOR_SKILLS_SOURCE = "skills"
179
+
180
+ # Lifecycle profile: hoist the lifecycle Make fragment and the Python runner
181
+ # package into the consumer overlay.
182
+ # Source paths are resolved through ``_resolve_in_clone`` so they pick
183
+ # up the ``packages/workstate-system/`` prefix in the monorepo layout and
184
+ # fall back to a flat layout for hoisted fixture remotes. Destination
185
+ # paths are flat under the consumer root because the runner/Makefile
186
+ # fragment must be reachable from a vanilla consumer with no monorepo
187
+ # packaging knowledge.
188
+ LIFECYCLE_HOISTS: tuple[tuple[str, str], ...] = (
189
+ ("Makefile.d/lifecycle.mk", "Makefile.d/lifecycle.mk"),
190
+ ("scripts/workstate/lifecycle", "scripts/workstate/lifecycle"),
191
+ )
192
+
193
+ # Sentinel block managed by ``_ensure_consumer_makefile_include`` so we
194
+ # can recognize and uninstall our edit without clobbering user content.
195
+ LIFECYCLE_INCLUDE_SENTINEL_BEGIN = "# >>> WORKSTATE_BOOTSTRAP LIFECYCLE INCLUDE >>>"
196
+ LIFECYCLE_INCLUDE_SENTINEL_END = "# <<< WORKSTATE_BOOTSTRAP LIFECYCLE INCLUDE <<<"
197
+ LEGACY_LIFECYCLE_INCLUDE_SENTINEL_BEGIN = "# >>> AGENTIC_BOOTSTRAP LIFECYCLE INCLUDE >>>"
198
+ LIFECYCLE_INCLUDE_DIRECTIVE = "-include Makefile.d/*.mk"
199
+ LIFECYCLE_TARGET_NAMES = frozenset(
200
+ {
201
+ "task-start",
202
+ "task-finish",
203
+ "context",
204
+ "slice-start",
205
+ "slice-commit",
206
+ "review-ready",
207
+ "close-check",
208
+ "handoff-close-check",
209
+ "plan-review",
210
+ "plan-analyze",
211
+ "review-run",
212
+ "handoff-review-run",
213
+ "status",
214
+ "tasks",
215
+ "doctor",
216
+ "project-events-replay",
217
+ "tasks-gc",
218
+ "dashboard",
219
+ "format",
220
+ }
221
+ )
222
+
223
+ # Profile contract. ``all`` is the default for both the library
224
+ # ``install()`` API and the CLI, so a no-argument ``workstate-bootstrap
225
+ # install`` materializes the full surface set out of the box. ``minimal``
226
+ # and ``lifecycle`` remain opt-in.
227
+ PROFILE_MINIMAL = "minimal"
228
+ PROFILE_LIFECYCLE = "lifecycle"
229
+ PROFILE_ALL = "all"
230
+ SUPPORTED_PROFILES: frozenset[str] = frozenset(
231
+ {PROFILE_MINIMAL, PROFILE_LIFECYCLE, PROFILE_ALL}
232
+ )
233
+
234
+ # Built-in managed-server map. The two Workstate MCP servers ship from this
235
+ # repo and are runnable via ``uvx``. Package
236
+ # specs are pinned to the latest coordinated release so consumer repos do
237
+ # not drift when PyPI advances independently of their overlay tag. Used
238
+ # when callers pass ``mcp_servers="default"`` or, in the CLI, when
239
+ # ``--mcp-servers`` is omitted and ``--no-mcp-servers`` is not set.
240
+ # Operators wanting a custom managed map keep providing a JSON file via
241
+ # ``--mcp-servers <path>``.
242
+ DEFAULT_MCP_SERVERS: dict[str, dict[str, Any]] = {
243
+ "workstate-handoff-mcp": {
244
+ "type": "stdio",
245
+ "command": "uvx",
246
+ "args": ["mcp-workstate-handoff@0.11.5", "--workspace-root", ".", "serve-stdio"],
247
+ },
248
+ "workstate-orchestrator-mcp": {
249
+ "type": "stdio",
250
+ "command": "uvx",
251
+ "args": ["mcp-workstate-orchestrator@0.4.7", "--workspace-root", ".", "serve-stdio"],
252
+ },
253
+ }
254
+
255
+
256
+ def _local_handoff_project_candidates(package_ref: str) -> tuple[tuple[str, str], ...]:
257
+ if package_ref.startswith("mcp-agent-handoff"):
258
+ return (
259
+ ("packages/mcp-agent-handoff", "mcp-agent-handoff"),
260
+ ("packages/mcp-workstate-handoff", "mcp-workstate-handoff"),
261
+ )
262
+ return (
263
+ ("packages/mcp-workstate-handoff", "mcp-workstate-handoff"),
264
+ ("packages/mcp-agent-handoff", "mcp-agent-handoff"),
265
+ )
266
+
267
+
268
+ def _build_local_handoff_retry_cmd(target: Path, cmd: list[str]) -> list[str] | None:
269
+ if not cmd or cmd[0] != "uvx":
270
+ return None
271
+
272
+ use_from = len(cmd) >= 4 and cmd[1] == "--from"
273
+ if use_from:
274
+ package_ref = cmd[2]
275
+ tail = cmd[3:]
276
+ elif len(cmd) >= 2:
277
+ package_ref = cmd[1]
278
+ tail = cmd[2:]
279
+ else:
280
+ return None
281
+
282
+ if not package_ref.startswith(("mcp-workstate-handoff", "mcp-agent-handoff")):
283
+ return None
284
+
285
+ clone = target.joinpath(*CLONE_SUBDIR)
286
+ for relative_path, cli_name in _local_handoff_project_candidates(package_ref):
287
+ project = clone / relative_path
288
+ if not (project / "pyproject.toml").is_file():
289
+ continue
290
+ base = ["uv", "run", "--project", str(project)]
291
+ if use_from:
292
+ return [*base, *tail]
293
+ return [*base, cli_name, *tail]
294
+
295
+ return None
296
+
297
+
298
+ def _resolve_local_mcp_project(
299
+ target: Path,
300
+ candidates: tuple[tuple[str, str], ...],
301
+ ) -> tuple[str, str] | None:
302
+ clone = target.joinpath(*CLONE_SUBDIR)
303
+ for relative_path, cli_name in candidates:
304
+ project = clone / relative_path
305
+ if not (project / "pyproject.toml").is_file():
306
+ continue
307
+ return project.relative_to(target).as_posix(), cli_name
308
+ return None
309
+
310
+
311
+ def _build_local_default_mcp_servers(target: Path) -> dict[str, dict[str, Any]] | None:
312
+ handoff = _resolve_local_mcp_project(
313
+ target,
314
+ (
315
+ ("packages/mcp-workstate-handoff", "mcp-workstate-handoff"),
316
+ ("packages/mcp-agent-handoff", "mcp-agent-handoff"),
317
+ ),
318
+ )
319
+ orchestrator = _resolve_local_mcp_project(
320
+ target,
321
+ (
322
+ ("packages/mcp-workstate-orchestrator", "mcp-workstate-orchestrator"),
323
+ ("packages/mcp-agent-orchestrator", "mcp-agent-orchestrator"),
324
+ ),
325
+ )
326
+ if handoff is None or orchestrator is None:
327
+ return None
328
+
329
+ handoff_project, handoff_cli = handoff
330
+ orchestrator_project, orchestrator_cli = orchestrator
331
+ return {
332
+ "workstate-handoff-mcp": {
333
+ "type": "stdio",
334
+ "command": "uv",
335
+ "args": [
336
+ "run",
337
+ "--project",
338
+ handoff_project,
339
+ handoff_cli,
340
+ "--workspace-root",
341
+ ".",
342
+ "serve-stdio",
343
+ ],
344
+ },
345
+ "workstate-orchestrator-mcp": {
346
+ "type": "stdio",
347
+ "command": "uv",
348
+ "args": [
349
+ "run",
350
+ "--project",
351
+ orchestrator_project,
352
+ orchestrator_cli,
353
+ "--workspace-root",
354
+ ".",
355
+ "serve-stdio",
356
+ ],
357
+ },
358
+ }
359
+
360
+
361
+ def _resolve_install_mcp_servers(
362
+ target: Path,
363
+ remote_ref: str,
364
+ mcp_servers: Mapping[str, Mapping[str, Any]] | None,
365
+ ) -> Mapping[str, Mapping[str, Any]] | None:
366
+ if mcp_servers is not DEFAULT_MCP_SERVERS:
367
+ return mcp_servers
368
+ if remote_ref.startswith("v"):
369
+ return mcp_servers
370
+ return _build_local_default_mcp_servers(target) or mcp_servers
371
+
372
+
373
+ class BootstrapManifestValidationError(RuntimeError):
374
+ """Raised when the install manifest fails the cross-repo wire-shape contract."""
375
+
376
+
377
+ class RemoteUrlMismatchError(RuntimeError):
378
+ """Raised when an existing ``.agentic/remote`` clone tracks a different
379
+ ``origin`` URL than the one passed to ``install``.
380
+
381
+ Silently rewriting the manifest while leaving the on-disk clone pointed at
382
+ the old origin would make ``.workstate-bootstrap.json`` lie about provenance.
383
+ Operators get an actionable error instead.
384
+ """
385
+
386
+
387
+ class OverrideResetRequiresBackupError(RuntimeError):
388
+ """Raised when ``reset_overrides`` would delete overrides from a dirty
389
+ git worktree without an explicit backup preflight.
390
+ """
391
+
392
+
393
+ def _migrate_legacy_manifest(target: Path) -> Path | None:
394
+ """One-shot rename of legacy ``.workstate-overlay.json`` to ``.workstate-bootstrap.json``.
395
+
396
+ Renames only when the legacy file looks like a bootstrap manifest
397
+ (top-level dict with a list ``surfaces`` key) so consumer-owned files
398
+ that happen to share the legacy name are not touched. Prefers
399
+ ``git mv`` when ``target`` is a git worktree so the rename is tracked
400
+ in history; falls back to ``Path.rename`` otherwise. Returns the new
401
+ path on success, ``None`` when no migration was needed or the legacy
402
+ file did not match the bootstrap shape.
403
+ """
404
+ legacy = target / LEGACY_OVERLAY_MANIFEST_NAME
405
+ canonical = target / BOOTSTRAP_MANIFEST_NAME
406
+ if not legacy.is_file() or canonical.exists():
407
+ return None
408
+ try:
409
+ data = json.loads(legacy.read_text())
410
+ except (OSError, json.JSONDecodeError):
411
+ return None
412
+ if not isinstance(data, dict) or not isinstance(data.get("surfaces"), list):
413
+ return None
414
+ try:
415
+ _git("mv", LEGACY_OVERLAY_MANIFEST_NAME, BOOTSTRAP_MANIFEST_NAME, cwd=target)
416
+ except (subprocess.CalledProcessError, FileNotFoundError):
417
+ legacy.rename(canonical)
418
+ return canonical
419
+
420
+
421
+ def _load_existing_manifest_remote_url(target: Path) -> str | None:
422
+ for name in (BOOTSTRAP_MANIFEST_NAME, LEGACY_OVERLAY_MANIFEST_NAME):
423
+ manifest_path = target / name
424
+ if not manifest_path.is_file():
425
+ continue
426
+ try:
427
+ payload = json.loads(manifest_path.read_text())
428
+ except (OSError, json.JSONDecodeError):
429
+ continue
430
+ if not isinstance(payload, dict):
431
+ continue
432
+ remote_url = payload.get("remote_url")
433
+ if isinstance(remote_url, str) and remote_url:
434
+ return remote_url
435
+ return None
436
+
437
+
438
+ def _prepare_state_for_remote_switch(
439
+ target: Path,
440
+ remote_url: str,
441
+ ) -> tuple[str | None, str | None]:
442
+ existing_remote_url = _load_existing_manifest_remote_url(target)
443
+ if existing_remote_url is None or existing_remote_url == remote_url:
444
+ return remote_url, None
445
+
446
+ state_dir = target / ".task-state"
447
+ backup_path: str | None = None
448
+ if state_dir.exists():
449
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
450
+ backup_root = target / ".agentic" / "state-backups" / stamp
451
+ archive_target = backup_root / state_dir.name
452
+ archive_target.parent.mkdir(parents=True, exist_ok=True)
453
+ shutil.move(str(state_dir), str(archive_target))
454
+ backup_path = backup_root.relative_to(target).as_posix()
455
+
456
+ # The old adjacent bootstrap manifest still points at the prior remote
457
+ # until install writes the new one below. Skip the reuse guard for this
458
+ # init only after moving the old runtime state out of the way.
459
+ return None, backup_path
460
+
461
+
462
+ def _git_worktree_is_dirty(target: Path) -> bool:
463
+ if not (target / ".git").exists():
464
+ return False
465
+ return bool(
466
+ _git(
467
+ "status",
468
+ "--short",
469
+ "--",
470
+ ".",
471
+ ":(exclude).agentic/remote",
472
+ cwd=target,
473
+ ).strip()
474
+ )
475
+
476
+
477
+ def _prune_empty_parent_dirs(root: Path, stop: Path) -> None:
478
+ current = root.resolve()
479
+ stop = stop.resolve()
480
+ try:
481
+ current.relative_to(stop)
482
+ except ValueError:
483
+ return
484
+ while current != stop:
485
+ try:
486
+ current.rmdir()
487
+ except OSError:
488
+ return
489
+ current = current.parent
490
+
491
+
492
+ def _reset_plugin_overrides(
493
+ target: Path,
494
+ override_root: Path | None,
495
+ *,
496
+ reset_overrides: bool,
497
+ backup_overrides: bool,
498
+ ) -> tuple[Path | None, str | None]:
499
+ if not reset_overrides or override_root is None:
500
+ return override_root, None
501
+
502
+ override_root = override_root.resolve()
503
+ if _git_worktree_is_dirty(target) and not backup_overrides:
504
+ raise OverrideResetRequiresBackupError(
505
+ "refusing to reset plugin overrides from a dirty git worktree without "
506
+ "backup_overrides=True; commit/stash changes first or opt into backup preflight"
507
+ )
508
+
509
+ backup_path: str | None = None
510
+ if backup_overrides:
511
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
512
+ backup_root = target / ".agentic" / "override-backups" / stamp
513
+ archive_target = backup_root / override_root.name
514
+ archive_target.parent.mkdir(parents=True, exist_ok=True)
515
+ shutil.copytree(override_root, archive_target)
516
+ backup_path = backup_root.relative_to(target).as_posix()
517
+
518
+ shutil.rmtree(override_root)
519
+ _prune_empty_parent_dirs(override_root.parent, target)
520
+ return None, backup_path
521
+
522
+
523
+ def _git(*args: str, cwd: Path | None = None) -> str:
524
+ """Run ``git`` with the given args, returning stripped stdout."""
525
+ cmd = ["git"]
526
+ if cwd is not None:
527
+ cmd.extend(["-C", str(cwd)])
528
+ cmd.extend(args)
529
+ result = subprocess.run(
530
+ cmd,
531
+ check=True,
532
+ capture_output=True,
533
+ text=True,
534
+ timeout=120,
535
+ )
536
+ return result.stdout.strip()
537
+
538
+
539
+ def _resolve_ref_to_sha(clone: Path, remote_ref: str) -> str:
540
+ """Resolve ``remote_ref`` against the just-fetched clone, preferring the
541
+ fresh remote-tracking branch over any stale local ref.
542
+
543
+ Resolution order:
544
+
545
+ 1. ``refs/remotes/origin/<ref>`` — picks up freshly fetched branch tips
546
+ and avoids the stale local-branch trap that ``git checkout --detach
547
+ <branch>`` falls into after ``fetch``.
548
+ 2. ``refs/tags/<ref>`` — tag refs.
549
+ 3. ``<ref>`` raw — last-resort for SHAs and exotic refspecs.
550
+ """
551
+ candidates = (
552
+ f"refs/remotes/origin/{remote_ref}",
553
+ f"refs/tags/{remote_ref}",
554
+ remote_ref,
555
+ )
556
+ for candidate in candidates:
557
+ try:
558
+ return _git("rev-parse", "--verify", f"{candidate}^{{commit}}", cwd=clone)
559
+ except subprocess.CalledProcessError:
560
+ continue
561
+ raise RuntimeError(
562
+ f"could not resolve remote_ref {remote_ref!r} in {clone} "
563
+ "(tried remote-tracking branch, tag, and raw ref)"
564
+ )
565
+
566
+
567
+ def _resolve_in_clone(clone: Path, relpath: str) -> Path:
568
+ """Resolve a surface/asset path against the clone.
569
+
570
+ Probes ``<clone>/packages/workstate-system/<relpath>`` first (the
571
+ workstate layout) and falls back to ``<clone>/<relpath>``
572
+ for legacy hoisted overlays. Returns the nested path when neither exists
573
+ so callers can use ``.exists()`` as the discriminator.
574
+ """
575
+ nested = clone / WORKSTATE_SYSTEM_SUBDIR / relpath
576
+ if nested.exists():
577
+ return nested
578
+ root = clone / relpath
579
+ if root.exists():
580
+ return root
581
+ return nested
582
+
583
+
584
+ def _materialize_one_symlink(
585
+ rel: str,
586
+ remote_path: Path,
587
+ target_path: Path,
588
+ clone_resolved: Path,
589
+ remote_subtree_prefix: str,
590
+ ) -> dict[str, str]:
591
+ """Materialize one ``target_path -> remote_path`` relative symlink.
592
+
593
+ Encapsulates the idempotency / repoint / foreign-precedence rules so it
594
+ can be applied to a whole surface or to a single carved child. Returns the
595
+ manifest entry for ``rel``:
596
+
597
+ - Target absent: create parent, symlink, record ``source='shared'``.
598
+ - Target already a symlink resolving to the current source: leave it;
599
+ record ``source='shared'`` (idempotent rerun path).
600
+ - Target already a symlink lexically under our clone but not resolving to
601
+ the current source: repoint and record ``source='shared'``.
602
+ - Target a foreign symlink, or a real file/dir: leave it untouched and
603
+ record ``source='local'`` so overlay precedence is honored.
604
+ """
605
+ expected_rel = os.path.relpath(remote_path, target_path.parent)
606
+ target_is_directory = remote_path.is_dir()
607
+
608
+ if target_path.is_symlink():
609
+ raw_target = os.readlink(target_path)
610
+ if os.path.isabs(raw_target):
611
+ abs_target_str = os.path.normpath(raw_target)
612
+ else:
613
+ abs_target_str = os.path.normpath(
614
+ os.path.join(str(target_path.parent), raw_target)
615
+ )
616
+ try:
617
+ resolved = target_path.resolve(strict=False)
618
+ except OSError:
619
+ resolved = None
620
+ resolves_to_expected = (
621
+ resolved is not None and resolved == remote_path.resolve()
622
+ )
623
+ lexically_in_remote_subtree = (
624
+ abs_target_str == str(clone_resolved)
625
+ or abs_target_str.startswith(remote_subtree_prefix)
626
+ )
627
+ if resolves_to_expected:
628
+ return {"path": rel, "source": "shared"}
629
+ if lexically_in_remote_subtree:
630
+ # Stale or broken pointer into our own remote subtree —
631
+ # repoint to the current canonical location.
632
+ target_path.unlink()
633
+ target_path.parent.mkdir(parents=True, exist_ok=True)
634
+ target_path.symlink_to(expected_rel, target_is_directory=target_is_directory)
635
+ print(f"repointed: {rel}")
636
+ return {"path": rel, "source": "shared"}
637
+ # Foreign symlink — local content takes precedence.
638
+ return {"path": rel, "source": "local"}
639
+
640
+ if target_path.exists():
641
+ return {"path": rel, "source": "local"}
642
+
643
+ target_path.parent.mkdir(parents=True, exist_ok=True)
644
+ target_path.symlink_to(expected_rel, target_is_directory=target_is_directory)
645
+ return {"path": rel, "source": "shared"}
646
+
647
+
648
+ def _raw_symlink_target_path(link_path: Path) -> str:
649
+ raw_target = os.readlink(link_path)
650
+ if os.path.isabs(raw_target):
651
+ return os.path.normpath(raw_target)
652
+ return os.path.normpath(os.path.join(str(link_path.parent), raw_target))
653
+
654
+
655
+ def _points_into_remote_subtree(
656
+ abs_target_str: str,
657
+ clone_resolved: Path,
658
+ remote_subtree_prefix: str,
659
+ ) -> bool:
660
+ return abs_target_str == str(clone_resolved) or abs_target_str.startswith(
661
+ remote_subtree_prefix
662
+ )
663
+
664
+
665
+ def _remove_bootstrap_owned_excluded_child(
666
+ child_path: Path,
667
+ clone_resolved: Path,
668
+ remote_subtree_prefix: str,
669
+ ) -> None:
670
+ if not child_path.is_symlink():
671
+ return
672
+ if _points_into_remote_subtree(
673
+ _raw_symlink_target_path(child_path),
674
+ clone_resolved,
675
+ remote_subtree_prefix,
676
+ ):
677
+ child_path.unlink()
678
+
679
+
680
+ def _lifecycle_hoist_children(surface: str) -> frozenset[str]:
681
+ """Child names of ``surface`` that :data:`LIFECYCLE_HOISTS` owns.
682
+
683
+ A carved surface must not symlink these children: the lifecycle hoist
684
+ copies them as real files later in ``install()``, and recording a
685
+ ``source='shared'`` symlink entry here would collide with that pass's
686
+ ``source='lifecycle'`` entry for the same path.
687
+ """
688
+ children: set[str] = set()
689
+ for _src_rel, dest_rel in LIFECYCLE_HOISTS:
690
+ parent, _, child = dest_rel.rpartition("/")
691
+ if parent == surface and child:
692
+ children.add(child)
693
+ return frozenset(children)
694
+
695
+
696
+ def _materialize_carved_surface(
697
+ surface: str,
698
+ remote_path: Path,
699
+ target_path: Path,
700
+ clone_resolved: Path,
701
+ remote_subtree_prefix: str,
702
+ ) -> list[dict[str, str]]:
703
+ """Materialize a SHARED_SURFACE that has excluded children.
704
+
705
+ The parent becomes a real directory and each child is symlinked
706
+ individually, except: children named in ``SURFACE_CHILD_EXCLUSIONS``
707
+ (carved out — never appear in the consumer tree) and children that
708
+ :data:`LIFECYCLE_HOISTS` copies separately. A legacy whole-directory
709
+ symlink into our own clone is replaced with a real directory so the
710
+ carve can take effect on upgrade; a foreign symlink is left untouched
711
+ (local precedence). Returns one manifest entry per materialized child.
712
+ """
713
+ excluded = SURFACE_CHILD_EXCLUSIONS[surface] | _lifecycle_hoist_children(surface)
714
+
715
+ if target_path.is_symlink():
716
+ if _points_into_remote_subtree(
717
+ _raw_symlink_target_path(target_path),
718
+ clone_resolved,
719
+ remote_subtree_prefix,
720
+ ):
721
+ # Legacy whole-directory symlink into our own clone — replace
722
+ # with a real directory so the excluded children can be carved.
723
+ target_path.unlink()
724
+ print(f"carved: {surface}")
725
+ else:
726
+ # Foreign symlink: local content wins, leave untouched.
727
+ return [{"path": surface, "source": "local"}]
728
+ elif target_path.exists() and not target_path.is_dir():
729
+ # A real file where a directory surface is expected — foreign/local.
730
+ return [{"path": surface, "source": "local"}]
731
+
732
+ target_path.mkdir(parents=True, exist_ok=True)
733
+
734
+ for child_name in excluded:
735
+ _remove_bootstrap_owned_excluded_child(
736
+ target_path / child_name,
737
+ clone_resolved,
738
+ remote_subtree_prefix,
739
+ )
740
+
741
+ entries: list[dict[str, str]] = []
742
+ for child in sorted(remote_path.iterdir(), key=lambda p: p.name):
743
+ if child.name in excluded:
744
+ continue
745
+ entries.append(
746
+ _materialize_one_symlink(
747
+ f"{surface}/{child.name}",
748
+ child,
749
+ target_path / child.name,
750
+ clone_resolved,
751
+ remote_subtree_prefix,
752
+ )
753
+ )
754
+ return entries
755
+
756
+
757
+ def _materialize_surfaces(target: Path, clone: Path) -> list[dict[str, str]]:
758
+ """Symlink each known shared surface from ``clone`` into ``target``.
759
+
760
+ Surfaces absent in the clone are skipped silently (not recorded). A
761
+ surface listed in :data:`SURFACE_CHILD_EXCLUSIONS` is materialized
762
+ per-child via :func:`_materialize_carved_surface` so its excluded
763
+ children stay absent; every other surface is a single whole-directory
764
+ symlink via :func:`_materialize_one_symlink`. See those helpers for the
765
+ idempotency / repoint / foreign-precedence rules.
766
+ """
767
+
768
+ materialized: list[dict[str, str]] = []
769
+ clone_resolved = clone.resolve()
770
+ remote_subtree_prefix = str(clone_resolved) + os.sep
771
+
772
+ for surface in SHARED_SURFACES:
773
+ remote_path = _resolve_in_clone(clone, surface)
774
+ if not remote_path.exists():
775
+ continue
776
+
777
+ if surface in SURFACE_CHILD_EXCLUSIONS:
778
+ materialized.extend(
779
+ _materialize_carved_surface(
780
+ surface,
781
+ remote_path,
782
+ target / surface,
783
+ clone_resolved,
784
+ remote_subtree_prefix,
785
+ )
786
+ )
787
+ continue
788
+
789
+ materialized.append(
790
+ _materialize_one_symlink(
791
+ surface,
792
+ remote_path,
793
+ target / surface,
794
+ clone_resolved,
795
+ remote_subtree_prefix,
796
+ )
797
+ )
798
+
799
+ return materialized
800
+
801
+
802
+ def _prepare_generated_surfaces(target: Path, clone: Path) -> list[dict[str, str]]:
803
+ """Ensure each per-agent generated surface exists as a real directory.
804
+
805
+ Pre-existing symlinks pointing into the clone (left over from the
806
+ pre-Plan-0002 overlay model where these surfaces were shared
807
+ symlinks) are replaced with empty directories so the generator can
808
+ write into them. Pre-existing real local content is preserved —
809
+ the operator may have intentionally placed local overrides there;
810
+ the generator's per-file write logic will only replace the files
811
+ it owns.
812
+ """
813
+ materialized: list[dict[str, str]] = []
814
+ clone_resolved = clone.resolve()
815
+
816
+ for surface in GENERATED_SURFACES:
817
+ target_path = target / surface
818
+
819
+ if target_path.is_symlink():
820
+ try:
821
+ resolved = target_path.resolve(strict=False)
822
+ except OSError:
823
+ resolved = None
824
+ points_into_clone = (
825
+ resolved is not None
826
+ and str(resolved).startswith(str(clone_resolved) + os.sep)
827
+ )
828
+ broken = resolved is not None and not target_path.exists()
829
+ if points_into_clone or broken:
830
+ # Legacy overlay symlink, or a dangling symlink whose
831
+ # target is gone — replace with a real directory so the
832
+ # generator can write into it. (A dangling symlink also
833
+ # blocks the mkdir below, since lexists() is True.)
834
+ target_path.unlink()
835
+ target_path.mkdir(parents=True, exist_ok=True)
836
+ # Foreign live symlinks are left alone (operator chose them);
837
+ # the generator will write through them into wherever they point.
838
+
839
+ elif not target_path.exists():
840
+ target_path.mkdir(parents=True, exist_ok=True)
841
+
842
+ materialized.append({"path": surface, "source": "generated"})
843
+
844
+ return materialized
845
+
846
+
847
+ def _prepare_plugin_generated_surfaces(
848
+ target: Path, clone: Path, override_root: Path | None
849
+ ) -> list[dict[str, str]]:
850
+ """Record generated plugin trees that install materializes for this target."""
851
+ generator_script = _resolve_in_clone(clone, GENERATOR_SCRIPT)
852
+ manifest_path = _resolve_in_clone(clone, GENERATOR_MANIFEST)
853
+ if not generator_script.is_file() or not manifest_path.is_file():
854
+ return []
855
+
856
+ entries = [{"path": Path(*PLUGIN_GENERATED_ROOT, "base").as_posix(), "source": "generated"}]
857
+ if override_root is not None:
858
+ entries.append(
859
+ {
860
+ "path": Path(*PLUGIN_GENERATED_ROOT, "effective").as_posix(),
861
+ "source": "generated",
862
+ }
863
+ )
864
+ return entries
865
+
866
+
867
+ def _plugin_tree_out(target: Path, kind: str) -> Path:
868
+ return target.joinpath(*PLUGIN_GENERATED_ROOT, kind)
869
+
870
+
871
+ def _plugin_override_root_from_manifest(
872
+ target: Path, manifest: Mapping[str, object] | None
873
+ ) -> Path | None:
874
+ if manifest is None:
875
+ return None
876
+ raw_path = manifest.get("plugin_overrides_path")
877
+ if not isinstance(raw_path, str) or not raw_path:
878
+ return None
879
+ candidate = Path(raw_path)
880
+ if not candidate.is_absolute():
881
+ candidate = target / candidate
882
+ candidate = candidate.resolve()
883
+ if (candidate / PLUGIN_OVERRIDE_MANIFEST).is_file():
884
+ return candidate
885
+ return None
886
+
887
+
888
+ def _plugin_override_root_manifest_path(target: Path, override_root: Path | None) -> str | None:
889
+ if override_root is None:
890
+ return None
891
+ try:
892
+ return override_root.relative_to(target).as_posix()
893
+ except ValueError:
894
+ return override_root.as_posix()
895
+
896
+
897
+ def _discover_plugin_override_root(
898
+ target: Path,
899
+ *,
900
+ manifest: Mapping[str, object] | None = None,
901
+ plugin_overrides: Path | None = None,
902
+ ) -> Path | None:
903
+ if plugin_overrides is not None:
904
+ candidate = Path(plugin_overrides).expanduser().resolve()
905
+ manifest_path = candidate / PLUGIN_OVERRIDE_MANIFEST
906
+ if not manifest_path.is_file():
907
+ raise FileNotFoundError(
908
+ f"plugin override manifest not found: {manifest_path}"
909
+ )
910
+ return candidate
911
+
912
+ manifest_root = _plugin_override_root_from_manifest(target, manifest)
913
+ if manifest_root is not None:
914
+ return manifest_root
915
+
916
+ override_root = target.joinpath(*PLUGIN_OVERRIDE_ROOT)
917
+ if (override_root / PLUGIN_OVERRIDE_MANIFEST).is_file():
918
+ return override_root
919
+ return None
920
+
921
+
922
+ def _relative_plugin_tree_path(kind: str, harness: str) -> str:
923
+ return f"./{Path(*PLUGIN_GENERATED_ROOT, kind, harness).as_posix()}"
924
+
925
+
926
+ def _write_json_file(
927
+ path: Path, payload: dict[str, Any], *, manifest_path: str | None = None
928
+ ) -> dict[str, str]:
929
+ path.parent.mkdir(parents=True, exist_ok=True)
930
+ content = json.dumps(payload, indent=2) + "\n"
931
+ manifest_entry_path = manifest_path or path.as_posix()
932
+ if path.exists():
933
+ previous = path.read_text()
934
+ if previous == content:
935
+ return {"path": manifest_entry_path, "action": "unchanged"}
936
+ path.write_text(content)
937
+ return {"path": manifest_entry_path, "action": "updated"}
938
+ path.write_text(content)
939
+ return {"path": manifest_entry_path, "action": "created"}
940
+
941
+
942
+ def _render_plugin_override_lock(override_root: Path, remote_sha: str) -> str:
943
+ from workstate_protocol.bootstrap import PluginOverrideLock, PluginOverrideManifest
944
+
945
+ raw_payload = yaml.safe_load((override_root / PLUGIN_OVERRIDE_MANIFEST).read_text()) or {}
946
+ manifest = PluginOverrideManifest.model_validate(raw_payload)
947
+ components: list[dict[str, str]] = []
948
+
949
+ for name, override in sorted(manifest.components.skills.items()):
950
+ entry: dict[str, str] = {
951
+ "component_kind": "skill",
952
+ "name": name,
953
+ "mode": override.mode,
954
+ }
955
+ if override.path is not None:
956
+ entry["local_path"] = override.path
957
+ if override.upstream_digest is not None:
958
+ entry["upstream_digest"] = override.upstream_digest
959
+ components.append(entry)
960
+
961
+ for name, override in sorted(manifest.components.mcp_servers.items()):
962
+ entry = {
963
+ "component_kind": "mcp_server",
964
+ "name": name,
965
+ "mode": override.mode,
966
+ }
967
+ if override.patch_path is not None:
968
+ entry["patch_path"] = override.patch_path
969
+ components.append(entry)
970
+
971
+ payload = PluginOverrideLock.model_validate(
972
+ {
973
+ "schema_version": 1,
974
+ "plugin": manifest.plugin,
975
+ "base_remote_sha": remote_sha,
976
+ "components": components,
977
+ }
978
+ ).model_dump(mode="json")
979
+ return json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
980
+
981
+
982
+ def _write_plugin_override_lock(override_root: Path | None, remote_sha: str) -> None:
983
+ if override_root is None:
984
+ return
985
+ lock_path = override_root / PLUGIN_OVERRIDE_LOCK
986
+ lock_path.write_text(_render_plugin_override_lock(override_root, remote_sha))
987
+
988
+
989
+ def _write_plugin_pins(
990
+ target: Path,
991
+ override_root: Path | None = None,
992
+ *,
993
+ include_codex_activation: bool = True,
994
+ ) -> list[dict[str, str]]:
995
+ plugin_tree_kind = "effective" if override_root is not None else "base"
996
+
997
+ claude_marketplace = {
998
+ "name": PLUGIN_MARKETPLACE_NAME,
999
+ "owner": {"name": PLUGIN_OWNER_NAME},
1000
+ "plugins": [
1001
+ {
1002
+ "name": PLUGIN_NAME,
1003
+ "source": _relative_plugin_tree_path(plugin_tree_kind, "claude"),
1004
+ "description": PLUGIN_DESCRIPTION,
1005
+ }
1006
+ ],
1007
+ }
1008
+ codex_marketplace = {
1009
+ "name": PLUGIN_MARKETPLACE_NAME,
1010
+ "interface": {"displayName": "Workstate Marketplace"},
1011
+ "owner": {"name": PLUGIN_OWNER_NAME},
1012
+ "plugins": [
1013
+ {
1014
+ "name": PLUGIN_NAME,
1015
+ "source": {
1016
+ "source": "local",
1017
+ "path": _relative_plugin_tree_path(plugin_tree_kind, "codex"),
1018
+ },
1019
+ "policy": {
1020
+ "installation": "AVAILABLE",
1021
+ "authentication": "ON_INSTALL",
1022
+ },
1023
+ "category": "Productivity",
1024
+ "description": PLUGIN_DESCRIPTION,
1025
+ }
1026
+ ],
1027
+ }
1028
+
1029
+ settings_path = target / CLAUDE_SETTINGS_PATH
1030
+ if settings_path.exists():
1031
+ current_settings = json.loads(settings_path.read_text())
1032
+ if not isinstance(current_settings, dict):
1033
+ raise ValueError(f"{settings_path} must contain a JSON object")
1034
+ else:
1035
+ current_settings = {}
1036
+ _deep_merge(
1037
+ current_settings,
1038
+ {
1039
+ "extraKnownMarketplaces": {
1040
+ PLUGIN_MARKETPLACE_NAME: {
1041
+ "source": {
1042
+ "source": "directory",
1043
+ "path": ".",
1044
+ }
1045
+ }
1046
+ },
1047
+ },
1048
+ )
1049
+ enabled_plugins = current_settings.setdefault("enabledPlugins", {})
1050
+ if not isinstance(enabled_plugins, dict):
1051
+ raise ValueError(f"{settings_path} enabledPlugins must contain a JSON object")
1052
+ enabled_plugins.setdefault(PLUGIN_SELECTOR, True)
1053
+
1054
+ entries = [
1055
+ _write_json_file(
1056
+ target / CLAUDE_MARKETPLACE_PATH,
1057
+ claude_marketplace,
1058
+ manifest_path=CLAUDE_MARKETPLACE_PATH.as_posix(),
1059
+ ),
1060
+ _write_json_file(
1061
+ settings_path,
1062
+ current_settings,
1063
+ manifest_path=CLAUDE_SETTINGS_PATH.as_posix(),
1064
+ ),
1065
+ _write_json_file(
1066
+ target / CODEX_MARKETPLACE_PATH,
1067
+ codex_marketplace,
1068
+ manifest_path=CODEX_MARKETPLACE_PATH.as_posix(),
1069
+ ),
1070
+ ]
1071
+ if include_codex_activation:
1072
+ entries.append(_write_codex_plugin_activation_config(target))
1073
+ return entries
1074
+
1075
+
1076
+ def _render_codex_plugin_activation_config(target: Path) -> bytes:
1077
+ """Render repo-local Codex plugin activation without touching user state.
1078
+
1079
+ ``codex plugin add`` persists activation in ``~/.codex/config.toml`` and
1080
+ the user plugin cache. Bootstrap keeps this project-scoped instead: the
1081
+ repo config points Codex at the checked-in local marketplace and enables
1082
+ the generated ``workstate-system`` plugin when the operator has not
1083
+ explicitly disabled it.
1084
+ """
1085
+ path = target / CODEX_CONFIG_PATH
1086
+ if path.exists():
1087
+ try:
1088
+ doc = tomlkit.parse(path.read_text())
1089
+ except (tomlkit.exceptions.TOMLKitError, UnicodeDecodeError):
1090
+ doc = tomlkit.document()
1091
+ else:
1092
+ doc = tomlkit.document()
1093
+
1094
+ marketplaces = doc.get("marketplaces")
1095
+ if not isinstance(marketplaces, dict):
1096
+ marketplaces = tomlkit.table(is_super_table=True)
1097
+ doc["marketplaces"] = marketplaces
1098
+ marketplace = tomlkit.table()
1099
+ marketplace["source_type"] = "local"
1100
+ marketplace["source"] = "."
1101
+ marketplaces[PLUGIN_MARKETPLACE_NAME] = marketplace
1102
+
1103
+ plugins = doc.get("plugins")
1104
+ if not isinstance(plugins, dict):
1105
+ plugins = tomlkit.table(is_super_table=True)
1106
+ doc["plugins"] = plugins
1107
+ existing_plugin = plugins.get(PLUGIN_SELECTOR)
1108
+ if isinstance(existing_plugin, dict):
1109
+ plugin_table = existing_plugin
1110
+ else:
1111
+ plugin_table = tomlkit.table()
1112
+ plugins[PLUGIN_SELECTOR] = plugin_table
1113
+ if "enabled" not in plugin_table:
1114
+ plugin_table["enabled"] = True
1115
+
1116
+ return tomlkit.dumps(doc).encode("utf-8")
1117
+
1118
+
1119
+ def _write_codex_plugin_activation_config(target: Path) -> dict[str, str]:
1120
+ path = target / CODEX_CONFIG_PATH
1121
+ existed = path.exists()
1122
+ rendered = _render_codex_plugin_activation_config(target)
1123
+ path.parent.mkdir(parents=True, exist_ok=True)
1124
+ path.write_bytes(rendered)
1125
+ return {
1126
+ "path": CODEX_CONFIG_PATH.as_posix(),
1127
+ "action": "merged" if existed else "created",
1128
+ }
1129
+
1130
+
1131
+ def _append_config_entry(entries: list[dict[str, str]], entry: dict[str, str]) -> None:
1132
+ """Append a manifest config entry, coalescing duplicate managed paths."""
1133
+ path = entry.get("path")
1134
+ for existing in entries:
1135
+ if existing.get("path") == path:
1136
+ existing["action"] = "merged"
1137
+ return
1138
+ entries.append(entry)
1139
+
1140
+
1141
+ def _run_generator(
1142
+ target: Path, clone: Path, remote_sha: str, override_root: Path | None = None
1143
+ ) -> None:
1144
+ """Invoke the agent-workflow generator against the target.
1145
+
1146
+ Uses the generator + manifest + skills source from the overlay
1147
+ clone. Writes per-agent surfaces into the target via the
1148
+ generator's ``--target`` convenience flag.
1149
+ """
1150
+ generator_script = _resolve_in_clone(clone, GENERATOR_SCRIPT)
1151
+ manifest_path = _resolve_in_clone(clone, GENERATOR_MANIFEST)
1152
+ skills_source = _resolve_in_clone(clone, GENERATOR_SKILLS_SOURCE)
1153
+
1154
+ if not generator_script.is_file():
1155
+ # Older overlays don't ship the generator. That's acceptable when
1156
+ # bootstrapping from a legacy ref; emit nothing rather than fail.
1157
+ return
1158
+
1159
+ cmd = [
1160
+ sys.executable,
1161
+ str(generator_script),
1162
+ "--manifest",
1163
+ str(manifest_path),
1164
+ "--skills-source-root",
1165
+ str(skills_source),
1166
+ "--target",
1167
+ str(target),
1168
+ ]
1169
+ subprocess.run(cmd, check=True, cwd=str(clone), timeout=120)
1170
+
1171
+ base_plugin_cmd = [
1172
+ sys.executable,
1173
+ str(generator_script),
1174
+ "--mode=plugin",
1175
+ "--manifest",
1176
+ str(manifest_path),
1177
+ "--skills-source-root",
1178
+ str(skills_source),
1179
+ "--plugin-out",
1180
+ str(_plugin_tree_out(target, "base")),
1181
+ ]
1182
+ subprocess.run(base_plugin_cmd, check=True, cwd=str(clone), timeout=120)
1183
+
1184
+ if override_root is None:
1185
+ return
1186
+
1187
+ effective_plugin_cmd = [
1188
+ sys.executable,
1189
+ str(generator_script),
1190
+ "--mode=plugin",
1191
+ "--manifest",
1192
+ str(manifest_path),
1193
+ "--skills-source-root",
1194
+ str(skills_source),
1195
+ "--plugin-out",
1196
+ str(_plugin_tree_out(target, "effective")),
1197
+ "--plugin-overrides",
1198
+ str(override_root),
1199
+ "--plugin-base-remote-sha",
1200
+ remote_sha,
1201
+ ]
1202
+ subprocess.run(effective_plugin_cmd, check=True, cwd=str(clone), timeout=120)
1203
+
1204
+
1205
+ def _install_lifecycle_profile(
1206
+ target: Path, clone: Path
1207
+ ) -> list[dict[str, str]]:
1208
+ """Hoist the lifecycle Make fragment + runner into ``target``.
1209
+
1210
+ Each entry in :data:`LIFECYCLE_HOISTS` is resolved against the clone
1211
+ (preferring the ``packages/workstate-system/`` layout, falling back to
1212
+ a flat layout for hoisted fixture remotes), then copied to the
1213
+ consumer at the destination relpath. Files use ``shutil.copy2``;
1214
+ directories use ``shutil.copytree`` with ``dirs_exist_ok=True`` so
1215
+ re-runs are idempotent. Sources missing in the clone are skipped
1216
+ silently for older overlay refs.
1217
+ """
1218
+ entries: list[dict[str, str]] = []
1219
+ for src_rel, dest_rel in LIFECYCLE_HOISTS:
1220
+ src = _resolve_in_clone(clone, src_rel)
1221
+ if not src.exists():
1222
+ continue
1223
+ dest = target / dest_rel
1224
+ dest.parent.mkdir(parents=True, exist_ok=True)
1225
+ # Under ``--profile all``, the shared-overlay materialization may
1226
+ # already have linked ``dest`` to the same
1227
+ # file inside the clone (Makefile.d/ and scripts/workstate/ ride
1228
+ # on the overlay symlink path). When dest already resolves to
1229
+ # src, ``shutil.copy2``/``copytree`` would raise
1230
+ # ``SameFileError``. Treat the existing symlink as the canonical
1231
+ # materialization and record the surface entry without copying.
1232
+ if dest.exists() and dest.resolve() == src.resolve():
1233
+ entries.append({"path": dest_rel, "source": "lifecycle"})
1234
+ continue
1235
+ if src.is_dir():
1236
+ shutil.copytree(src, dest, dirs_exist_ok=True)
1237
+ else:
1238
+ shutil.copy2(src, dest)
1239
+ entries.append({"path": dest_rel, "source": "lifecycle"})
1240
+ return entries
1241
+
1242
+
1243
+ def _ensure_consumer_makefile_include(target: Path) -> dict[str, str] | None:
1244
+ """Idempotently inject the lifecycle ``-include`` directive into
1245
+ ``<target>/Makefile``.
1246
+
1247
+ Wraps the directive in a sentinel-bracketed block so re-runs don't
1248
+ duplicate, and a future uninstall can excise it cleanly. When the
1249
+ sentinel block is already present the file is left untouched and
1250
+ ``action='already_present'`` is returned. When the consumer already
1251
+ declares lifecycle target names, the file is also left untouched so
1252
+ bootstrap does not inject a wildcard include that overrides
1253
+ repo-owned recipes. When the consumer has no Makefile, one is
1254
+ created containing only the sentinel block.
1255
+ """
1256
+ makefile = target / "Makefile"
1257
+ block = (
1258
+ f"{LIFECYCLE_INCLUDE_SENTINEL_BEGIN}\n"
1259
+ f"{LIFECYCLE_INCLUDE_DIRECTIVE}\n"
1260
+ f"{LIFECYCLE_INCLUDE_SENTINEL_END}\n"
1261
+ )
1262
+ if not makefile.exists():
1263
+ makefile.write_text(block)
1264
+ return {"path": "Makefile", "action": "created"}
1265
+ existing = makefile.read_text()
1266
+ if LIFECYCLE_INCLUDE_SENTINEL_BEGIN in existing or LEGACY_LIFECYCLE_INCLUDE_SENTINEL_BEGIN in existing:
1267
+ return {"path": "Makefile", "action": "already_present"}
1268
+ if _makefile_declares_lifecycle_targets(existing):
1269
+ return {"path": "Makefile", "action": "skipped_existing_lifecycle_targets"}
1270
+ sep = "" if existing.endswith("\n") else "\n"
1271
+ makefile.write_text(existing + sep + block)
1272
+ return {"path": "Makefile", "action": "appended"}
1273
+
1274
+
1275
+ def _makefile_declares_lifecycle_targets(text: str) -> bool:
1276
+ """Return true when user-owned Makefile text already defines lifecycle recipes."""
1277
+ for raw_line in text.splitlines():
1278
+ if not raw_line or raw_line[0].isspace():
1279
+ continue
1280
+ line = raw_line.strip()
1281
+ if not line or line.startswith("#") or ":" not in line:
1282
+ continue
1283
+ before, after = line.split(":", 1)
1284
+ if after.lstrip().startswith("="):
1285
+ continue
1286
+ for token in before.split():
1287
+ if token in LIFECYCLE_TARGET_NAMES:
1288
+ return True
1289
+ return False
1290
+
1291
+
1292
+ def install(
1293
+ *,
1294
+ target: Path,
1295
+ remote_url: str,
1296
+ remote_ref: str,
1297
+ mcp_servers: Mapping[str, Mapping[str, Any]] | str | None = None,
1298
+ plugin_overrides: Path | None = None,
1299
+ reset_overrides: bool = False,
1300
+ backup_overrides: bool = False,
1301
+ enforce_required_surfaces: bool = False,
1302
+ profile: str = PROFILE_ALL,
1303
+ install_claude_stop_hook: bool = False,
1304
+ install_claude_stop_hook_local: bool = False,
1305
+ install_codex_stop_hook: bool = False,
1306
+ install_vscode_stop_hook: bool = False,
1307
+ ) -> dict[str, object]:
1308
+ """Clone the shared workstate-system remote, materialize overlay surfaces,
1309
+ write consumer-tool configs, and write the overlay manifest.
1310
+
1311
+ Args:
1312
+ target: Consumer repository root. Must already exist.
1313
+ remote_url: Git URL for the shared workstate-system remote.
1314
+ remote_ref: Tag, branch, or SHA to check out (e.g. ``"v0.1.0"``).
1315
+ mcp_servers: Mapping of ``<server_name> -> {command, args, env}`` to
1316
+ register in ``.mcp.json``, ``.vscode/mcp.json``, and
1317
+ ``.codex/config.toml``. Pass the sentinel string ``"default"``
1318
+ to use :data:`DEFAULT_MCP_SERVERS` (the two MCP servers shipped
1319
+ by this monorepo). When ``None``, the three file-writers are
1320
+ skipped. ``core.hooksPath`` is set independently whenever the
1321
+ target is a git repo.
1322
+ plugin_overrides: Optional explicit plugin override root. When set,
1323
+ bootstrap composes the effective plugin tree from this root and
1324
+ records the resolved path in the manifest for later doctor /
1325
+ update / repair runs.
1326
+ reset_overrides: When True, remove the resolved plugin override root
1327
+ before regeneration so marketplace pins fall back to the base
1328
+ plugin tree.
1329
+ backup_overrides: When True together with ``reset_overrides``, archive
1330
+ the override root under ``.agentic/override-backups/<timestamp>/``
1331
+ before removal.
1332
+ enforce_required_surfaces: When True, refuse the install if any
1333
+ surface declared as required by the manifest fails to
1334
+ materialize. Defaults to False (warn-only).
1335
+ profile: Install profile selecting how much overlay surface to
1336
+ materialize. One of :data:`PROFILE_MINIMAL`,
1337
+ :data:`PROFILE_LIFECYCLE`, or :data:`PROFILE_ALL` (default).
1338
+ install_claude_stop_hook: When True, write the shared, checked-in
1339
+ Claude stop-hook wiring at ``.claude/settings.json``. Off by
1340
+ default; no file is touched unless the operator opts in.
1341
+ install_claude_stop_hook_local: When True, write the user-owned,
1342
+ gitignored Claude stop-hook wiring at
1343
+ ``.claude/settings.local.json``. Off by default.
1344
+ install_codex_stop_hook: When True, write the Codex CLI harness
1345
+ stop-hook wiring at ``.codex/hooks/stop.json``. Off by default.
1346
+ install_vscode_stop_hook: When True, write the VS Code harness
1347
+ stop-hook wiring at ``.vscode/workstate-stop-hooks.json``. Off
1348
+ by default.
1349
+
1350
+ Returns:
1351
+ The manifest dict that was written to ``<target>/.workstate-bootstrap.json``.
1352
+
1353
+ Raises:
1354
+ FileNotFoundError: ``target`` does not exist.
1355
+ FileExistsError: ``<target>/.agentic/remote`` exists but is not a git clone.
1356
+ RemoteUrlMismatchError: existing clone tracks a different ``origin`` URL.
1357
+ subprocess.CalledProcessError: ``git`` command failed.
1358
+ """
1359
+ if profile not in SUPPORTED_PROFILES:
1360
+ raise ValueError(
1361
+ f"profile={profile!r} is not a recognized install profile; "
1362
+ f"expected one of {sorted(SUPPORTED_PROFILES)!r}."
1363
+ )
1364
+
1365
+ target = Path(target).resolve()
1366
+ if not target.is_dir():
1367
+ raise FileNotFoundError(f"target directory does not exist: {target}")
1368
+
1369
+ _migrate_legacy_manifest(target)
1370
+
1371
+ if isinstance(mcp_servers, str):
1372
+ if mcp_servers != "default":
1373
+ raise ValueError(
1374
+ f"mcp_servers={mcp_servers!r} is not a recognized sentinel; "
1375
+ "pass a mapping, the literal 'default', or None."
1376
+ )
1377
+ mcp_servers = DEFAULT_MCP_SERVERS
1378
+
1379
+ clone = target.joinpath(*CLONE_SUBDIR)
1380
+
1381
+ if (clone / ".git").exists():
1382
+ existing_origin = _git("remote", "get-url", "origin", cwd=clone)
1383
+ if existing_origin != remote_url:
1384
+ raise RemoteUrlMismatchError(
1385
+ f"{clone} already tracks origin {existing_origin!r}, "
1386
+ f"but install was called with remote_url={remote_url!r}. "
1387
+ "Move or remove .agentic/remote (or pass the original URL) to "
1388
+ "switch overlays."
1389
+ )
1390
+ _git("fetch", "--tags", "--prune", "--force", "origin", cwd=clone)
1391
+ else:
1392
+ clone.parent.mkdir(parents=True, exist_ok=True)
1393
+ if clone.exists():
1394
+ raise FileExistsError(
1395
+ f"{clone} exists but is not a git clone. "
1396
+ "Move or remove it before re-running install."
1397
+ )
1398
+ _git("clone", "--branch", remote_ref, remote_url, str(clone))
1399
+
1400
+ sha = _resolve_ref_to_sha(clone, remote_ref)
1401
+ if len(sha) != 40:
1402
+ raise RuntimeError(f"unexpected sha shape from git rev-parse: {sha!r}")
1403
+
1404
+ _git("checkout", "--detach", sha, cwd=clone)
1405
+ mcp_servers = _resolve_install_mcp_servers(target, remote_ref, mcp_servers)
1406
+ init_state_expected_remote_url = remote_url
1407
+ state_backup_path: str | None = None
1408
+ if mcp_servers:
1409
+ init_state_expected_remote_url, state_backup_path = _prepare_state_for_remote_switch(
1410
+ target,
1411
+ remote_url,
1412
+ )
1413
+
1414
+ override_root = _discover_plugin_override_root(
1415
+ target,
1416
+ plugin_overrides=plugin_overrides,
1417
+ )
1418
+ override_root, override_backup_path = _reset_plugin_overrides(
1419
+ target,
1420
+ override_root,
1421
+ reset_overrides=reset_overrides,
1422
+ backup_overrides=backup_overrides,
1423
+ )
1424
+
1425
+ surfaces: list[dict[str, str]] = []
1426
+ configs: list[dict[str, str]] = []
1427
+
1428
+ if profile == PROFILE_ALL:
1429
+ surfaces.extend(_materialize_surfaces(target, clone))
1430
+ surfaces.extend(_prepare_generated_surfaces(target, clone))
1431
+ plugin_surfaces = _prepare_plugin_generated_surfaces(target, clone, override_root)
1432
+ surfaces.extend(plugin_surfaces)
1433
+
1434
+ # Required-surfaces refusal keeps consumers from ending up with a
1435
+ # half-installed harness when required hooks are missing. Run BEFORE the
1436
+ # generator, config writers, and init-state so a failing install
1437
+ # cannot leave generated artifacts, .mcp.json, or .task-state/
1438
+ # behind on disk.
1439
+ materialized_paths = {entry["path"] for entry in surfaces if isinstance(entry, dict)}
1440
+ if enforce_required_surfaces and "scripts/hooks" not in materialized_paths:
1441
+ raise BootstrapManifestValidationError(
1442
+ "refusing to declare install successful: required surface 'scripts/hooks' "
1443
+ "was not materialized. Bootstrap-installed hooks are part of the harness "
1444
+ "contract; without them, target-side guardrails do not run. "
1445
+ "Set enforce_required_surfaces=False to bypass for non-standard remotes."
1446
+ )
1447
+
1448
+ _run_generator(target, clone, sha, override_root)
1449
+ if plugin_surfaces:
1450
+ _write_plugin_override_lock(override_root, sha)
1451
+ configs.extend(
1452
+ _write_plugin_pins(
1453
+ target,
1454
+ override_root,
1455
+ include_codex_activation=False,
1456
+ )
1457
+ )
1458
+ configs.extend(_write_configs(target, mcp_servers, include_hooks=False))
1459
+ if plugin_surfaces:
1460
+ _append_config_entry(configs, _write_codex_plugin_activation_config(target))
1461
+ _run_init_state(
1462
+ target,
1463
+ mcp_servers,
1464
+ expected_remote_url=init_state_expected_remote_url,
1465
+ )
1466
+
1467
+ # ``all`` also performs the lifecycle hoist so a consumer that ships the
1468
+ # lifecycle-referencing skills (branch-
1469
+ # lifecycle / tdd / incremental-implementation / branch-review /
1470
+ # handoff-lifecycle and the body-only references in auto-fix /
1471
+ # review-parallel / investigate) also receives the matching
1472
+ # ``Makefile.d/lifecycle.mk`` + ``scripts/workstate/lifecycle/``
1473
+ # runner that defines those targets. ``--profile lifecycle``
1474
+ # remains the dedicated lean profile (no skills, lifecycle only);
1475
+ # ``--profile minimal`` is unchanged. Both hoist helpers below are
1476
+ # idempotent on rerun.
1477
+ if profile in (PROFILE_ALL, PROFILE_LIFECYCLE):
1478
+ surfaces.extend(_install_lifecycle_profile(target, clone))
1479
+ include_entry = _ensure_consumer_makefile_include(target)
1480
+ if include_entry is not None:
1481
+ configs.append(include_entry)
1482
+
1483
+ hooks_entry = _set_git_hooks_path(target)
1484
+ if hooks_entry is not None:
1485
+ configs.append(hooks_entry)
1486
+
1487
+ active_flags: set[str] = set()
1488
+ if install_claude_stop_hook:
1489
+ active_flags.add("--install-claude-stop-hook")
1490
+ if install_claude_stop_hook_local:
1491
+ active_flags.add("--install-claude-stop-hook-local")
1492
+ if install_codex_stop_hook:
1493
+ active_flags.add("--install-codex-stop-hook")
1494
+ if install_vscode_stop_hook:
1495
+ active_flags.add("--install-vscode-stop-hook")
1496
+ configs.extend(
1497
+ _walk_hook_adapters(
1498
+ manifest=_load_portable_manifest(clone),
1499
+ clone=clone,
1500
+ target=target,
1501
+ profile=profile,
1502
+ active_flags=active_flags,
1503
+ )
1504
+ )
1505
+
1506
+ manifest: dict[str, object] = _build_install_manifest(
1507
+ remote_url=remote_url,
1508
+ remote_ref=remote_ref,
1509
+ remote_sha=sha,
1510
+ profile=profile,
1511
+ surfaces=surfaces,
1512
+ configs=configs,
1513
+ mcp_servers=mcp_servers,
1514
+ plugin_overrides_path=_plugin_override_root_manifest_path(target, override_root),
1515
+ )
1516
+
1517
+ # Wire-shape validation: refuse to write a manifest that does not
1518
+ # validate against workstate_protocol.BootstrapManifest. This is the
1519
+ # install-time schema check for bootstrap manifests.
1520
+ # When workstate-protocol is not installed (partial migrations), we
1521
+ # warn but still write — the manifest contract is best-effort
1522
+ # until the protocol is mandatory.
1523
+ try:
1524
+ from workstate_protocol import BootstrapManifest # type: ignore[import-not-found]
1525
+
1526
+ BootstrapManifest.model_validate(manifest)
1527
+ except ImportError:
1528
+ pass
1529
+ except Exception as exc: # noqa: BLE001
1530
+ raise BootstrapManifestValidationError(
1531
+ f"refusing to write {BOOTSTRAP_MANIFEST_NAME}: workstate_protocol.BootstrapManifest "
1532
+ f"validation failed: {exc}"
1533
+ ) from exc
1534
+
1535
+ manifest_path = target / BOOTSTRAP_MANIFEST_NAME
1536
+ manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")
1537
+
1538
+ result = dict(manifest)
1539
+ if override_backup_path is not None:
1540
+ result["override_backup_path"] = override_backup_path
1541
+ if state_backup_path is not None:
1542
+ result["state_backup_path"] = state_backup_path
1543
+ return result
1544
+
1545
+
1546
+ # ---------------------------------------------------------------------------
1547
+ # Config writers
1548
+ # ---------------------------------------------------------------------------
1549
+
1550
+
1551
+ HOOKS_PATH_VALUE = "scripts/hooks/git"
1552
+ """Workspace-relative ``core.hooksPath`` value.
1553
+
1554
+ Points at the ``git/`` subdirectory of the materialized ``scripts/hooks``
1555
+ surface. The parent directory ships Python helpers, ``.sh`` utilities,
1556
+ and tests alongside the actual hook scripts; pointing git at the parent
1557
+ makes git look for hook files by name (``post-checkout`` etc.) at a path
1558
+ where they do not exist, so it silently resolves nothing. The named
1559
+ hooks themselves live at ``scripts/hooks/git/<name>``.
1560
+
1561
+ Single-line invariant: the on-disk hook layout and this value MUST agree.
1562
+ The install rehearsal pins both halves of that contract.
1563
+ """
1564
+
1565
+
1566
+ def _deep_merge(dst: dict[str, Any], src: Mapping[str, Any]) -> dict[str, Any]:
1567
+ """Recursively merge ``src`` into ``dst`` and return ``dst``.
1568
+
1569
+ Dict-into-dict merges recurse. Any non-dict value in ``src`` (including
1570
+ lists) replaces the corresponding key in ``dst`` outright — list-concat
1571
+ semantics would silently grow user config across reruns.
1572
+ """
1573
+ for key, value in src.items():
1574
+ existing = dst.get(key)
1575
+ if isinstance(existing, dict) and isinstance(value, Mapping):
1576
+ _deep_merge(existing, value)
1577
+ elif isinstance(value, Mapping):
1578
+ new_dict: dict[str, Any] = {}
1579
+ _deep_merge(new_dict, value)
1580
+ dst[key] = new_dict
1581
+ else:
1582
+ dst[key] = value
1583
+ return dst
1584
+
1585
+
1586
+ def _write_configs(
1587
+ target: Path,
1588
+ mcp_servers: Mapping[str, Mapping[str, Any]] | None,
1589
+ *,
1590
+ include_hooks: bool = True,
1591
+ ) -> list[dict[str, str]]:
1592
+ """Run the four post-install config writers and return per-surface entries
1593
+ suitable for ``manifest['configs']``.
1594
+
1595
+ The three file-writers run only when ``mcp_servers`` is provided. The git
1596
+ ``core.hooksPath`` writer runs whenever the target looks like a git repo.
1597
+ """
1598
+ entries: list[dict[str, str]] = []
1599
+
1600
+ if mcp_servers:
1601
+ entries.append(_write_mcp_json(target, mcp_servers))
1602
+ entries.append(_write_vscode_mcp_json(target, mcp_servers))
1603
+ entries.append(_write_codex_config(target, mcp_servers))
1604
+
1605
+ if include_hooks:
1606
+ hooks_entry = _set_git_hooks_path(target)
1607
+ if hooks_entry is not None:
1608
+ entries.append(hooks_entry)
1609
+
1610
+ return entries
1611
+
1612
+
1613
+ def _run_init_state(
1614
+ target: Path,
1615
+ mcp_servers: Mapping[str, Mapping[str, Any]] | None,
1616
+ *,
1617
+ expected_remote_url: str | None = None,
1618
+ ) -> None:
1619
+ if not mcp_servers:
1620
+ return
1621
+
1622
+ spec = mcp_servers.get("workstate-handoff-mcp") or mcp_servers.get("agent-handoff-mcp")
1623
+ if spec is None:
1624
+ return
1625
+
1626
+ command = spec.get("command")
1627
+ if not isinstance(command, str) or not command:
1628
+ raise ValueError("workstate-handoff-mcp config must include a non-empty command")
1629
+
1630
+ raw_args = spec.get("args", [])
1631
+ if not isinstance(raw_args, list) or not all(isinstance(arg, str) for arg in raw_args):
1632
+ raise ValueError("workstate-handoff-mcp config args must be a list[str]")
1633
+
1634
+ args = list(raw_args)
1635
+ raw_env = spec.get("env")
1636
+ env_has_state_dir = isinstance(raw_env, Mapping) and (
1637
+ "WORKSTATE_HANDOFF_STATE_DIR" in raw_env or "AGENT_HANDOFF_STATE_DIR" in raw_env
1638
+ )
1639
+ if args and args[-1] in {"serve-stdio", "serve-http", "init-state"}:
1640
+ args = args[:-1]
1641
+ if not any(arg == "--workspace-root" or arg.startswith("--workspace-root=") for arg in args):
1642
+ args.extend(["--workspace-root", str(target)])
1643
+ if (
1644
+ not any(arg == "--state-dir" or arg.startswith("--state-dir=") for arg in args)
1645
+ and not env_has_state_dir
1646
+ ):
1647
+ args.extend(["--state-dir", str(target / ".task-state")])
1648
+ args.append("init-state")
1649
+ if expected_remote_url is not None:
1650
+ args.extend(["--expected-remote-url", expected_remote_url])
1651
+
1652
+ cmd = [command, *args]
1653
+ env = os.environ.copy()
1654
+ if raw_env is not None:
1655
+ if not isinstance(raw_env, Mapping) or not all(
1656
+ isinstance(key, str) and isinstance(value, str) for key, value in raw_env.items()
1657
+ ):
1658
+ raise ValueError("workstate-handoff-mcp config env must be a mapping[str, str]")
1659
+ env.update(raw_env)
1660
+
1661
+ try:
1662
+ subprocess.run(
1663
+ cmd,
1664
+ check=True,
1665
+ capture_output=True,
1666
+ text=True,
1667
+ cwd=str(target),
1668
+ env=env,
1669
+ timeout=120,
1670
+ )
1671
+ except subprocess.CalledProcessError as exc:
1672
+ retry_cmd = _build_local_handoff_retry_cmd(target, cmd)
1673
+ if retry_cmd is None:
1674
+ raise
1675
+ try:
1676
+ subprocess.run(
1677
+ retry_cmd,
1678
+ check=True,
1679
+ capture_output=True,
1680
+ text=True,
1681
+ cwd=str(target),
1682
+ env=env,
1683
+ timeout=120,
1684
+ )
1685
+ except subprocess.CalledProcessError as retry_exc:
1686
+ raise retry_exc from exc
1687
+
1688
+
1689
+ def _render_mcp_json(
1690
+ target: Path,
1691
+ mcp_servers: Mapping[str, Mapping[str, Any]],
1692
+ *,
1693
+ prune_names: Iterable[str] = (),
1694
+ ) -> bytes:
1695
+ """Pure render half of the .mcp.json seam: read the existing file
1696
+ (if any), deep-merge managed servers under ``mcpServers``, and return
1697
+ the bytes that ``_write_mcp_json`` would persist. No filesystem
1698
+ mutation.
1699
+
1700
+ ``prune_names`` are server names to remove from the existing
1701
+ ``mcpServers`` block before the merge — driven by
1702
+ ``sync_mcp_configs(prune_removed_managed=True)`` reading the
1703
+ ledger's previously-managed provenance. Default ``()`` keeps the
1704
+ install path's behavior byte-identical."""
1705
+ path = target / ".mcp.json"
1706
+ doc: dict[str, Any] = _load_json_or_empty(path)
1707
+ if prune_names:
1708
+ servers = doc.get("mcpServers")
1709
+ if isinstance(servers, dict):
1710
+ for name in prune_names:
1711
+ servers.pop(name, None)
1712
+ incoming = {"mcpServers": {name: dict(spec) for name, spec in mcp_servers.items()}}
1713
+ _deep_merge(doc, incoming)
1714
+ return (json.dumps(doc, indent=2) + "\n").encode("utf-8")
1715
+
1716
+
1717
+ def _load_json_or_empty(path: Path) -> dict[str, Any]:
1718
+ """Return parsed JSON or ``{}`` when the file is missing or malformed.
1719
+
1720
+ Managed surfaces are this tool's own output. If the file is invalid
1721
+ JSON (interrupted prior write, hand edit), treat it as empty so the
1722
+ next reconcile rewrites it cleanly instead of letting JSONDecodeError
1723
+ escape through doctor / mcp-sync. Third-party preservation is
1724
+ impossible in that case (the existing content is already lost).
1725
+ """
1726
+ if not path.exists():
1727
+ return {}
1728
+ try:
1729
+ loaded = json.loads(path.read_text())
1730
+ except (json.JSONDecodeError, UnicodeDecodeError):
1731
+ return {}
1732
+ return loaded if isinstance(loaded, dict) else {}
1733
+
1734
+
1735
+ def _write_mcp_json(
1736
+ target: Path,
1737
+ mcp_servers: Mapping[str, Mapping[str, Any]],
1738
+ *,
1739
+ prune_names: Iterable[str] = (),
1740
+ ) -> dict[str, str]:
1741
+ """Deep-merge managed servers into ``<target>/.mcp.json`` under
1742
+ ``mcpServers``. Preserves all other keys and other servers."""
1743
+ path = target / ".mcp.json"
1744
+ existed = path.exists()
1745
+ rendered = _render_mcp_json(target, mcp_servers, prune_names=prune_names)
1746
+ path.write_bytes(rendered)
1747
+ return {"path": ".mcp.json", "action": "merged" if existed else "created"}
1748
+
1749
+
1750
+ def _render_vscode_mcp_json(
1751
+ target: Path,
1752
+ mcp_servers: Mapping[str, Mapping[str, Any]],
1753
+ *,
1754
+ prune_names: Iterable[str] = (),
1755
+ ) -> bytes:
1756
+ """Pure render half of the .vscode/mcp.json seam: read the existing
1757
+ file (if any), deep-merge managed servers under ``servers``, and
1758
+ return the bytes that ``_write_vscode_mcp_json`` would persist. No
1759
+ filesystem mutation (the ``.vscode/`` directory is created by the
1760
+ write half).
1761
+
1762
+ ``prune_names`` removes those entries from the existing ``servers``
1763
+ block before the merge; see ``_render_mcp_json`` for the contract."""
1764
+ path = target / ".vscode" / "mcp.json"
1765
+ doc: dict[str, Any] = _load_json_or_empty(path)
1766
+ if prune_names:
1767
+ servers = doc.get("servers")
1768
+ if isinstance(servers, dict):
1769
+ for name in prune_names:
1770
+ servers.pop(name, None)
1771
+ incoming = {"servers": {name: dict(spec) for name, spec in mcp_servers.items()}}
1772
+ _deep_merge(doc, incoming)
1773
+ return (json.dumps(doc, indent=2) + "\n").encode("utf-8")
1774
+
1775
+
1776
+ def _write_vscode_mcp_json(
1777
+ target: Path,
1778
+ mcp_servers: Mapping[str, Mapping[str, Any]],
1779
+ *,
1780
+ prune_names: Iterable[str] = (),
1781
+ ) -> dict[str, str]:
1782
+ """Deep-merge managed servers into ``<target>/.vscode/mcp.json`` under
1783
+ ``servers``. Creates the ``.vscode`` directory if absent."""
1784
+ path = target / ".vscode" / "mcp.json"
1785
+ existed = path.exists()
1786
+ rendered = _render_vscode_mcp_json(target, mcp_servers, prune_names=prune_names)
1787
+ path.parent.mkdir(parents=True, exist_ok=True)
1788
+ path.write_bytes(rendered)
1789
+ return {"path": ".vscode/mcp.json", "action": "merged" if existed else "created"}
1790
+
1791
+
1792
+ def _render_codex_config(
1793
+ target: Path,
1794
+ mcp_servers: Mapping[str, Mapping[str, Any]],
1795
+ *,
1796
+ prune_names: Iterable[str] = (),
1797
+ ) -> bytes:
1798
+ """Pure render half of the .codex/config.toml seam: read the existing
1799
+ TOML (if any), replace the ``[mcp_servers.<name>]`` tables for each
1800
+ managed server while preserving every other key and comment, and
1801
+ return the bytes that ``_write_codex_config`` would persist. No
1802
+ filesystem mutation (the ``.codex/`` directory is created by the
1803
+ write half).
1804
+
1805
+ ``prune_names`` removes those tables from ``[mcp_servers]`` before
1806
+ the managed tables are added; see ``_render_mcp_json`` for the
1807
+ contract."""
1808
+ path = target / ".codex" / "config.toml"
1809
+ if path.exists():
1810
+ try:
1811
+ doc = tomlkit.parse(path.read_text())
1812
+ except (tomlkit.exceptions.TOMLKitError, UnicodeDecodeError):
1813
+ doc = tomlkit.document()
1814
+ else:
1815
+ doc = tomlkit.document()
1816
+
1817
+ if "mcp_servers" not in doc:
1818
+ doc["mcp_servers"] = tomlkit.table(is_super_table=True)
1819
+ servers_table = doc["mcp_servers"]
1820
+
1821
+ if prune_names:
1822
+ for name in prune_names:
1823
+ if name in servers_table:
1824
+ del servers_table[name]
1825
+
1826
+ for name, spec in mcp_servers.items():
1827
+ new_table = tomlkit.table()
1828
+ for spec_key, spec_value in spec.items():
1829
+ new_table[spec_key] = spec_value
1830
+ servers_table[name] = new_table
1831
+
1832
+ return tomlkit.dumps(doc).encode("utf-8")
1833
+
1834
+
1835
+ def _write_codex_config(
1836
+ target: Path,
1837
+ mcp_servers: Mapping[str, Mapping[str, Any]],
1838
+ *,
1839
+ prune_names: Iterable[str] = (),
1840
+ ) -> dict[str, str]:
1841
+ """Replace the ``[mcp_servers.<name>]`` tables in
1842
+ ``<target>/.codex/config.toml`` for each managed server, leaving every
1843
+ other root key, table, and comment untouched (tomlkit round-trip)."""
1844
+ path = target / ".codex" / "config.toml"
1845
+ existed = path.exists()
1846
+ rendered = _render_codex_config(target, mcp_servers, prune_names=prune_names)
1847
+ path.parent.mkdir(parents=True, exist_ok=True)
1848
+ path.write_bytes(rendered)
1849
+ return {"path": ".codex/config.toml", "action": "merged" if existed else "created"}
1850
+
1851
+
1852
+ def _set_git_hooks_path(target: Path) -> dict[str, str] | None:
1853
+ """If ``target`` is a git repo, set ``core.hooksPath`` to
1854
+ ``scripts/hooks/git`` (under the materialized ``scripts/hooks``
1855
+ symlink) and return a manifest entry. Otherwise return ``None``
1856
+ (silent skip).
1857
+
1858
+ See ``HOOKS_PATH_VALUE`` for why the path includes the ``git/``
1859
+ subdirectory.
1860
+ """
1861
+ if not (target / ".git").exists():
1862
+ return None
1863
+ _git("config", "core.hooksPath", HOOKS_PATH_VALUE, cwd=target)
1864
+ return {"path": "core.hooksPath", "action": "set"}
1865
+
1866
+
1867
+ # Manifest-driven hook walker.
1868
+ #
1869
+ # ``portable_commands.json`` (schema v2) is the single source of truth
1870
+ # for the per-harness adapter rows that materialize bootstrap-owned
1871
+ # hooks. The walker reads the manifest from the cloned overlay, filters
1872
+ # by install profile and the active set of opt-in flags, verifies the
1873
+ # hook's ``required_artifacts`` exist in the clone, and dispatches each
1874
+ # selected adapter on its ``patch.operation``. The previous single-harness
1875
+ # writer (``_write_claude_settings_hooks``) is replaced by this table-driven
1876
+ # walk so new harnesses (Codex, VS Code, etc.) can be
1877
+ # added by appending adapter rows to the manifest rather than by
1878
+ # growing bespoke writers in this module.
1879
+ #
1880
+ # Adapter target strings are NEVER hardcoded here — every ``.claude/...``
1881
+ # / ``.codex/...`` path comes from the manifest. The walker only knows
1882
+ # how to dispatch operations.
1883
+
1884
+ _TEMPLATE_CONSUMER_ROOT = "{{consumer_root}}"
1885
+
1886
+
1887
+ def _load_portable_manifest(clone: Path) -> dict[str, Any]:
1888
+ """Read the v2 portable-commands manifest out of the clone.
1889
+
1890
+ Returns ``{}`` when the manifest is absent (older overlays that
1891
+ predate schema v2) so the walker becomes a noop instead of raising.
1892
+ """
1893
+ manifest_path = _resolve_in_clone(clone, GENERATOR_MANIFEST)
1894
+ if not manifest_path.is_file():
1895
+ return {}
1896
+ return json.loads(manifest_path.read_text())
1897
+
1898
+
1899
+ def _render_template(value: Any, *, target: Path) -> Any:
1900
+ """Recursively substitute ``{{consumer_root}}`` with the resolved
1901
+ consumer-root path inside the adapter ``entry`` template."""
1902
+ if isinstance(value, str):
1903
+ return value.replace(_TEMPLATE_CONSUMER_ROOT, str(target))
1904
+ if isinstance(value, Mapping):
1905
+ return {k: _render_template(v, target=target) for k, v in value.items()}
1906
+ if isinstance(value, list):
1907
+ return [_render_template(v, target=target) for v in value]
1908
+ return value
1909
+
1910
+
1911
+ def _resolve_dotted_path(doc: dict[str, Any], json_path: str) -> tuple[dict[str, Any], str]:
1912
+ """Resolve a closed-set JSONPath like ``$.hooks.Stop`` to the parent
1913
+ container and the leaf key, creating intermediate dicts as needed.
1914
+
1915
+ The walker only dispatches array-merge patches today, so this parser
1916
+ is intentionally narrow: ``$.<seg>(.<seg>)*`` with object-keyed
1917
+ segments. Anything else raises ``ValueError`` rather than silently
1918
+ accepting a path the dispatcher can't honour.
1919
+ """
1920
+ if not json_path.startswith("$."):
1921
+ raise ValueError(f"unsupported json_path {json_path!r}; must start with '$.'")
1922
+ segments = json_path[2:].split(".")
1923
+ if not segments or not all(segments):
1924
+ raise ValueError(f"unsupported json_path {json_path!r}; empty segment")
1925
+ parent: dict[str, Any] = doc
1926
+ for seg in segments[:-1]:
1927
+ nxt = parent.setdefault(seg, {})
1928
+ if not isinstance(nxt, dict):
1929
+ raise ValueError(
1930
+ f"refusing to merge: {seg!r} along {json_path!r} is not an object"
1931
+ )
1932
+ parent = nxt
1933
+ return parent, segments[-1]
1934
+
1935
+
1936
+ def _apply_merge_array_entry(adapter: Mapping[str, Any], *, target: Path) -> dict[str, str]:
1937
+ """Idempotently merge a managed entry into an array container in a
1938
+ JSON settings file. ``match_key`` identifies prior managed entries
1939
+ for replacement-in-place; everything else is preserved verbatim.
1940
+ """
1941
+ patch = adapter["patch"]
1942
+ target_rel = adapter["target"]
1943
+ settings_path = target / target_rel
1944
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
1945
+
1946
+ existed = settings_path.exists()
1947
+ doc: dict[str, Any] = json.loads(settings_path.read_text()) if existed else {}
1948
+ if not isinstance(doc, dict):
1949
+ raise ValueError(
1950
+ f"refusing to merge hook into {settings_path}: "
1951
+ "existing JSON document is not an object"
1952
+ )
1953
+
1954
+ parent, leaf_key = _resolve_dotted_path(doc, patch["json_path"])
1955
+ array_raw = parent.get(leaf_key, [])
1956
+ if not isinstance(array_raw, list):
1957
+ raise ValueError(
1958
+ f"refusing to merge hook into {settings_path}: "
1959
+ f"{patch['json_path']!r} is not a list"
1960
+ )
1961
+
1962
+ managed_entry = _render_template(patch["entry"], target=target)
1963
+ match_key = patch["match_key"]
1964
+ if not isinstance(managed_entry, Mapping) or match_key not in managed_entry:
1965
+ raise ValueError(
1966
+ f"adapter entry missing match_key {match_key!r}: {managed_entry!r}"
1967
+ )
1968
+ match_value = managed_entry[match_key]
1969
+
1970
+ new_array: list[Any] = []
1971
+ replaced = False
1972
+ matched_existing = False
1973
+ for item in array_raw:
1974
+ if isinstance(item, Mapping) and item.get(match_key) == match_value:
1975
+ matched_existing = True
1976
+ if not replaced:
1977
+ new_array.append(managed_entry)
1978
+ replaced = True
1979
+ continue
1980
+ new_array.append(item)
1981
+ if not replaced:
1982
+ new_array.append(managed_entry)
1983
+
1984
+ parent[leaf_key] = new_array
1985
+ settings_path.write_text(json.dumps(doc, indent=2) + "\n")
1986
+
1987
+ if not existed:
1988
+ action = "created"
1989
+ elif matched_existing:
1990
+ action = "noop"
1991
+ else:
1992
+ action = "merged"
1993
+ return {"path": target_rel, "action": action}
1994
+
1995
+
1996
+ _ADAPTER_DISPATCH: dict[str, Any] = {
1997
+ "merge_array_entry": _apply_merge_array_entry,
1998
+ }
1999
+
2000
+
2001
+ def _walk_hook_adapters(
2002
+ *,
2003
+ manifest: Mapping[str, Any],
2004
+ clone: Path,
2005
+ target: Path,
2006
+ profile: str,
2007
+ active_flags: set[str],
2008
+ ) -> list[dict[str, str]]:
2009
+ """Walk ``manifest.hooks`` and apply each adapter whose opt_in_flag is
2010
+ in ``active_flags``. Hooks whose ``profiles`` do not include the
2011
+ active install profile are skipped. When at least one adapter is
2012
+ selected, every ``required_artifacts`` row is verified to exist in
2013
+ the clone before any file is touched — opting in to a hook whose
2014
+ artifacts are missing is a hard fail."""
2015
+ configs: list[dict[str, str]] = []
2016
+ if not isinstance(manifest, Mapping):
2017
+ return configs
2018
+ hooks = manifest.get("hooks")
2019
+ if not isinstance(hooks, list):
2020
+ return configs
2021
+
2022
+ for hook in hooks:
2023
+ if not isinstance(hook, Mapping):
2024
+ continue
2025
+ hook_profiles = hook.get("profiles") or []
2026
+ if profile not in hook_profiles and "all" not in hook_profiles:
2027
+ continue
2028
+ adapters = hook.get("adapters") or []
2029
+ selected = [
2030
+ a for a in adapters
2031
+ if isinstance(a, Mapping) and a.get("opt_in_flag") in active_flags
2032
+ ]
2033
+ if not selected:
2034
+ continue
2035
+ # Required-artifacts gate: refuse to silently skip a user-requested
2036
+ # hook just because the overlay clone is missing the script.
2037
+ for artifact in hook.get("required_artifacts") or []:
2038
+ consumer_path = artifact.get("consumer_path") if isinstance(artifact, Mapping) else None
2039
+ if not consumer_path:
2040
+ continue
2041
+ resolved = _resolve_in_clone(clone, consumer_path)
2042
+ if not resolved.is_file():
2043
+ raise RuntimeError(
2044
+ f"hook {hook.get('hook_id')!r}: required artifact "
2045
+ f"{consumer_path!r} is missing in the overlay clone "
2046
+ f"(expected at {resolved}). Cannot honour the opt-in."
2047
+ )
2048
+ for adapter in selected:
2049
+ op = adapter["patch"]["operation"]
2050
+ handler = _ADAPTER_DISPATCH.get(op)
2051
+ if handler is None:
2052
+ raise NotImplementedError(
2053
+ f"hook {hook.get('hook_id')!r}: unknown adapter "
2054
+ f"patch operation {op!r}"
2055
+ )
2056
+ configs.append(handler(adapter, target=target))
2057
+ return configs