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,1358 @@
1
+ """Post-install subcommands: status / doctor / update / repair.
2
+
3
+ Each subcommand is a small library function with a clear contract:
4
+
5
+ - ``status(target)`` returns a human-readable summary of the overlay manifest.
6
+ - ``doctor(target, mcp_servers=None)`` returns a list of drift findings.
7
+ - ``update(target, remote_ref, ...)`` (future slice) re-runs install at a new ref.
8
+ - ``repair(target, ..., force_dirty)`` (future slice) rewrites drifted overlays.
9
+
10
+ The CLI in ``workstate_bootstrap.cli`` is a thin argparse wrapper over these.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import tomllib
18
+ from pathlib import Path
19
+ from typing import Any, Mapping
20
+
21
+ import yaml
22
+
23
+ from workstate_bootstrap.install import (
24
+ BOOTSTRAP_MANIFEST_NAME,
25
+ CLAUDE_MARKETPLACE_PATH,
26
+ CLONE_SUBDIR,
27
+ CODEX_CONFIG_PATH,
28
+ CODEX_MARKETPLACE_PATH,
29
+ GENERATED_SURFACES,
30
+ GENERATOR_MANIFEST,
31
+ GENERATOR_SCRIPT,
32
+ GENERATOR_SKILLS_SOURCE,
33
+ PLUGIN_NAME,
34
+ PLUGIN_GENERATED_ROOT,
35
+ PLUGIN_MARKETPLACE_NAME,
36
+ PLUGIN_OVERRIDE_MANIFEST,
37
+ PLUGIN_OVERRIDE_ROOT,
38
+ _discover_plugin_override_root,
39
+ _relative_plugin_tree_path,
40
+ _resolve_in_clone,
41
+ SHARED_SURFACES,
42
+ _migrate_legacy_manifest,
43
+ )
44
+
45
+
46
+ def _load_manifest(target: Path) -> dict[str, object]:
47
+ _migrate_legacy_manifest(target)
48
+ manifest_path = target / BOOTSTRAP_MANIFEST_NAME
49
+ if not manifest_path.is_file():
50
+ raise FileNotFoundError(
51
+ f"{manifest_path} not found. Run `workstate-bootstrap install --target "
52
+ f"{target}` first."
53
+ )
54
+ return json.loads(manifest_path.read_text())
55
+
56
+
57
+ def _preserved_mcp_servers(
58
+ target: Path, manifest: Mapping[str, object]
59
+ ) -> Mapping[str, Mapping[str, Any]] | None:
60
+ """Return the currently registered managed MCP mapping, if any.
61
+
62
+ Updates inherit the existing managed registration by default. This keeps
63
+ `.mcp.json` / `.vscode/mcp.json` / `.codex/config.toml` listed in the
64
+ refreshed manifest and ensures init-state still runs after a managed
65
+ install when the caller omits ``mcp_servers``.
66
+ """
67
+ configs = manifest.get("configs", []) or []
68
+ registered_mcp = any(
69
+ isinstance(entry, dict) and entry.get("path") == ".mcp.json"
70
+ for entry in configs
71
+ )
72
+ if not registered_mcp:
73
+ return None
74
+
75
+ mcp_path = target / ".mcp.json"
76
+ if not mcp_path.is_file():
77
+ raise FileNotFoundError(
78
+ f"{mcp_path} missing for managed update; re-run install or pass --mcp-servers."
79
+ )
80
+
81
+ try:
82
+ doc = json.loads(mcp_path.read_text())
83
+ except json.JSONDecodeError as exc:
84
+ raise ValueError(
85
+ f"{mcp_path} is not valid JSON; repair or replace it before update."
86
+ ) from exc
87
+
88
+ servers = doc.get("mcpServers")
89
+ if not isinstance(servers, dict):
90
+ raise ValueError(
91
+ f"{mcp_path} does not contain an mcpServers mapping; repair it before update."
92
+ )
93
+
94
+ preserved: dict[str, Mapping[str, Any]] = {}
95
+ for name, spec in servers.items():
96
+ if isinstance(name, str) and isinstance(spec, dict):
97
+ preserved[name] = spec
98
+ return preserved
99
+
100
+
101
+ def status(*, target: Path) -> str:
102
+ """Return a multi-line human-readable summary of the overlay manifest at
103
+ ``<target>/.workstate-bootstrap.json``.
104
+
105
+ When the install registered MCP servers (``.mcp.json`` recorded in
106
+ ``configs``), invokes ``init-state --check`` to append the resolved
107
+ state directory, database path, exports directory, and schema
108
+ version. ``--no-mcp-servers`` installs skip this section.
109
+
110
+ Raises ``FileNotFoundError`` when the manifest is absent.
111
+ """
112
+ target = Path(target).resolve()
113
+ manifest = _load_manifest(target)
114
+
115
+ surfaces = manifest.get("surfaces", []) or []
116
+ configs = manifest.get("configs", []) or []
117
+ shared = sum(1 for s in surfaces if s.get("source") == "shared")
118
+ local = sum(1 for s in surfaces if s.get("source") == "local")
119
+ generated = sum(1 for s in surfaces if s.get("source") == "generated")
120
+
121
+ lines = [
122
+ f"workstate-bootstrap overlay at {target}",
123
+ f" remote_url: {manifest.get('remote_url')}",
124
+ f" remote_ref: {manifest.get('remote_ref')}",
125
+ f" remote_sha: {manifest.get('remote_sha')}",
126
+ f" surfaces: {len(surfaces)} ({shared} shared, {local} local, {generated} generated)",
127
+ f" configs: {len(configs)}",
128
+ ]
129
+ for entry in configs:
130
+ lines.append(f" - {entry.get('path')} ({entry.get('action')})")
131
+
132
+ state_lines = _status_handoff_state_lines(target, configs)
133
+ if state_lines:
134
+ lines.append(" handoff state:")
135
+ lines.extend(f" {line}" for line in state_lines)
136
+
137
+ return "\n".join(lines) + "\n"
138
+
139
+
140
+ def _status_handoff_state_lines(
141
+ target: Path, configs: list[dict[str, Any]]
142
+ ) -> list[str]:
143
+ """Invoke ``init-state --check`` against the workstate-handoff-mcp entry in
144
+ ``.mcp.json`` and return zero or more summary lines.
145
+
146
+ Returns an empty list when the install did not register MCP servers
147
+ (so init-state was never expected to run) — this mirrors doctor's
148
+ state-check gating.
149
+ """
150
+ import subprocess
151
+
152
+ registered_mcp = any(
153
+ isinstance(entry, dict) and entry.get("path") == ".mcp.json"
154
+ for entry in configs
155
+ )
156
+ if not registered_mcp:
157
+ return []
158
+
159
+ mcp_path = target / ".mcp.json"
160
+ if not mcp_path.is_file():
161
+ return ["error: .mcp.json missing — re-run install"]
162
+
163
+ try:
164
+ mcp_doc = json.loads(mcp_path.read_text())
165
+ except json.JSONDecodeError:
166
+ return ["error: .mcp.json is not valid JSON"]
167
+ if not isinstance(mcp_doc, dict):
168
+ return ["error: .mcp.json is not a JSON object — re-run install"]
169
+ servers = mcp_doc.get("mcpServers")
170
+ if servers is not None and not isinstance(servers, dict):
171
+ return ["error: .mcp.json mcpServers is malformed — re-run install"]
172
+ spec = None
173
+ if isinstance(servers, dict):
174
+ spec = servers.get("workstate-handoff-mcp") or servers.get("agent-handoff-mcp")
175
+ if not isinstance(spec, dict):
176
+ return []
177
+
178
+ cmd = _resolve_init_state_check_command(target, spec)
179
+ if cmd is None:
180
+ return ["error: workstate-handoff-mcp entry in .mcp.json is malformed"]
181
+
182
+ env = os.environ.copy()
183
+ raw_env = spec.get("env")
184
+ if isinstance(raw_env, dict):
185
+ for key, value in raw_env.items():
186
+ if isinstance(key, str) and isinstance(value, str):
187
+ env[key] = value
188
+
189
+ try:
190
+ proc = subprocess.run(
191
+ cmd,
192
+ check=False,
193
+ capture_output=True,
194
+ text=True,
195
+ cwd=str(target),
196
+ env=env,
197
+ timeout=120,
198
+ )
199
+ except (OSError, subprocess.TimeoutExpired) as exc:
200
+ return [f"error: init-state --check failed: {exc}"]
201
+
202
+ if proc.returncode != 0:
203
+ first = (proc.stderr or proc.stdout or "").strip().splitlines()
204
+ detail = first[-1] if first else f"exit {proc.returncode}"
205
+ return [f"error: init-state --check failed: {detail}"]
206
+
207
+ payload: dict[str, Any]
208
+ try:
209
+ payload = json.loads(proc.stdout)
210
+ except json.JSONDecodeError:
211
+ return [f"error: init-state --check returned non-JSON: {proc.stdout[:120]!r}"]
212
+
213
+ return [
214
+ f"state_dir: {payload.get('state_dir')}",
215
+ f"db_path: {payload.get('db_path')}",
216
+ f"exports_dir: {payload.get('exports_dir')}",
217
+ f"schema_version: {payload.get('schema_version')}",
218
+ f"initialized: {payload.get('initialized')}",
219
+ ]
220
+
221
+
222
+ def _resolve_init_state_check_command(
223
+ target: Path, spec: dict[str, Any]
224
+ ) -> list[str] | None:
225
+ """Map the ``.mcp.json`` workstate-handoff-mcp entry to an
226
+ ``init-state --check`` invocation, mirroring install-time resolution
227
+ in ``install._run_init_state``.
228
+ """
229
+ command = spec.get("command")
230
+ raw_args = spec.get("args", [])
231
+ if not isinstance(command, str) or not command:
232
+ return None
233
+ if not isinstance(raw_args, list) or not all(isinstance(a, str) for a in raw_args):
234
+ return None
235
+
236
+ args = list(raw_args)
237
+ if args and args[-1] in {"serve-stdio", "serve-http", "init-state"}:
238
+ args = args[:-1]
239
+ if not any(a == "--workspace-root" or a.startswith("--workspace-root=") for a in args):
240
+ args.extend(["--workspace-root", str(target)])
241
+ raw_env = spec.get("env")
242
+ env_has_state_dir = isinstance(raw_env, dict) and (
243
+ "WORKSTATE_HANDOFF_STATE_DIR" in raw_env or "AGENT_HANDOFF_STATE_DIR" in raw_env
244
+ )
245
+ if (
246
+ not any(a == "--state-dir" or a.startswith("--state-dir=") for a in args)
247
+ and not env_has_state_dir
248
+ ):
249
+ args.extend(["--state-dir", str(target / ".task-state")])
250
+ args.extend(["init-state", "--check"])
251
+ return [command, *args]
252
+
253
+
254
+ # ---------------------------------------------------------------------------
255
+ # doctor
256
+ # ---------------------------------------------------------------------------
257
+
258
+
259
+ Finding = dict[str, str]
260
+
261
+
262
+ def doctor(
263
+ *,
264
+ target: Path,
265
+ mcp_servers: Mapping[str, Mapping[str, Any]] | None = None,
266
+ plugin_overrides: Path | None = None,
267
+ ) -> list[Finding]:
268
+ """Return a list of drift findings for the overlay at ``target``.
269
+
270
+ Each finding is a dict with at least ``kind`` and ``path``. Recognized
271
+ kinds:
272
+
273
+ - ``missing_manifest`` — ``.workstate-bootstrap.json`` is gone.
274
+ - ``missing_clone`` — ``.agentic/remote/.git`` is gone.
275
+ - ``surface_drift`` — a surface recorded as ``shared`` in the manifest is
276
+ no longer a symlink resolving into the clone.
277
+ - ``generated_drift`` — a surface recorded as ``generated`` differs from
278
+ what ``scripts/generate_agent_workflows.py`` would write today. Detected
279
+ by re-running the generator in ``--check`` mode against the target.
280
+ - ``stale_override`` — a warn-mode replacement override still composes, but
281
+ its recorded upstream digest no longer matches the current base skill.
282
+ - ``config_drift`` — a managed MCP server in ``mcp_servers`` is no longer
283
+ present (or no longer matches) in ``.mcp.json`` / ``.vscode/mcp.json``.
284
+ Only checked when ``mcp_servers`` is provided.
285
+ - ``pin_target_drift`` — a plugin marketplace pin no longer points at the
286
+ expected base/effective generated tree for the current override state.
287
+ - ``plugin_source_drift`` — a plugin marketplace pin resolves to a missing
288
+ or incomplete plugin tree (missing plugin.json, .mcp.json, or skills).
289
+ - ``state_drift`` — ``.task-state/handoff.db`` is missing even though the
290
+ manifest's ``configs`` array recorded ``.mcp.json``. Suppressed when the
291
+ install was ``--no-mcp-servers`` (no managed servers registered, so no
292
+ state init was expected).
293
+ - ``hook_adapter_drift`` — a compact-session Stop adapter that bootstrap
294
+ installed (its target is in the manifest ``configs``) is missing or no
295
+ longer matches the manifest-declared managed entry. Never-installed
296
+ adapters stay optional and are not reported (WORKSTATE-REF-80 implementation note).
297
+
298
+ Returns an empty list when everything is clean.
299
+ """
300
+ target = Path(target).resolve()
301
+ findings: list[Finding] = []
302
+
303
+ _migrate_legacy_manifest(target)
304
+ manifest_path = target / BOOTSTRAP_MANIFEST_NAME
305
+ if not manifest_path.is_file():
306
+ findings.append(
307
+ {"kind": "missing_manifest", "path": BOOTSTRAP_MANIFEST_NAME}
308
+ )
309
+ return findings
310
+
311
+ manifest = json.loads(manifest_path.read_text())
312
+ clone = target.joinpath(*CLONE_SUBDIR)
313
+ clone_resolved = clone.resolve(strict=False)
314
+
315
+ if not (clone / ".git").exists():
316
+ findings.append(
317
+ {"kind": "missing_clone", "path": "/".join(CLONE_SUBDIR)}
318
+ )
319
+
320
+ surfaces = manifest.get("surfaces") or []
321
+ for entry in surfaces:
322
+ if entry.get("source") != "shared":
323
+ continue
324
+ surface = entry.get("path", "")
325
+ link = target / surface
326
+ if not link.is_symlink():
327
+ findings.append({"kind": "surface_drift", "path": surface})
328
+ continue
329
+ try:
330
+ resolved = link.resolve(strict=False)
331
+ except OSError:
332
+ findings.append({"kind": "surface_drift", "path": surface})
333
+ continue
334
+ in_clone = (
335
+ resolved == (clone / surface).resolve(strict=False)
336
+ or str(resolved).startswith(str(clone_resolved) + os.sep)
337
+ )
338
+ if not in_clone:
339
+ findings.append({"kind": "surface_drift", "path": surface})
340
+
341
+ override_root = _discover_plugin_override_root(
342
+ target,
343
+ manifest=manifest,
344
+ plugin_overrides=plugin_overrides,
345
+ )
346
+
347
+ findings.extend(_doctor_generated_surfaces(target, clone, manifest, override_root))
348
+
349
+ if mcp_servers:
350
+ findings.extend(
351
+ _doctor_mcp_config_drift(target, manifest, mcp_servers)
352
+ )
353
+
354
+ findings.extend(_doctor_plugin_pin_targets(target, override_root))
355
+ findings.extend(_doctor_codex_activation_config(target))
356
+ findings.extend(_doctor_plugin_source_integrity(target))
357
+ findings.extend(_doctor_hidden_override_collisions(target, clone, override_root))
358
+ findings.extend(_doctor_plugin_override_state(target, override_root))
359
+ findings.extend(_doctor_managed_stop_adapters(target, clone, manifest))
360
+ findings.extend(_doctor_state(target, manifest))
361
+
362
+ return findings
363
+
364
+
365
+ _MANAGED_SURFACE_BY_CONFIG_PATH: dict[str, str] = {
366
+ ".mcp.json": "claude",
367
+ ".vscode/mcp.json": "vscode",
368
+ ".codex/config.toml": "codex",
369
+ }
370
+ _CONFIG_PATH_BY_MANAGED_SURFACE: dict[str, str] = {
371
+ surface: path for path, surface in _MANAGED_SURFACE_BY_CONFIG_PATH.items()
372
+ }
373
+
374
+
375
+ def _registered_managed_surfaces(manifest: Mapping[str, object]) -> list[str]:
376
+ """Return the managed-surface names recorded in the ledger's configs.
377
+
378
+ Doctor / repair only reconcile surfaces that ``install`` actually
379
+ wrote. Legacy ledgers without ``.codex/config.toml`` therefore skip
380
+ codex even when the resolved map is supplied.
381
+ """
382
+ registered: list[str] = []
383
+ for entry in manifest.get("configs") or []:
384
+ if not isinstance(entry, dict):
385
+ continue
386
+ path = entry.get("path")
387
+ surface = _MANAGED_SURFACE_BY_CONFIG_PATH.get(str(path))
388
+ if surface is not None and surface not in registered:
389
+ registered.append(surface)
390
+ return registered
391
+
392
+
393
+ def _doctor_mcp_config_drift(
394
+ target: Path,
395
+ manifest: Mapping[str, object],
396
+ mcp_servers: Mapping[str, Mapping[str, Any]],
397
+ ) -> list[Finding]:
398
+ """Run ``sync_mcp_configs(check_only=True)`` and translate per-surface
399
+ drift into ``config_drift`` findings.
400
+
401
+ Filtered to surfaces the ledger says ``install`` wrote so doctor
402
+ does not invent drift for surfaces the consumer never opted into.
403
+ """
404
+ from workstate_bootstrap.mcp_sync import sync_mcp_configs
405
+
406
+ surfaces = _registered_managed_surfaces(manifest)
407
+ if not surfaces:
408
+ return []
409
+ report = sync_mcp_configs(
410
+ target, mcp_servers, surfaces=surfaces, check_only=True
411
+ )
412
+ return [
413
+ {"kind": "config_drift", "path": s.path}
414
+ for s in report.surfaces
415
+ if s.drift
416
+ ]
417
+
418
+
419
+ def _doctor_state(target: Path, manifest: dict[str, object]) -> list[Finding]:
420
+ # implementation note §4: the manifest's configs array records whether bootstrap
421
+ # registered MCP servers. .mcp.json is only present when an mcp_servers
422
+ # map was provided, so its presence is the gate for expecting init-state
423
+ # to have run. --no-mcp-servers installs leave .mcp.json out of configs
424
+ # and must not trigger state_drift.
425
+ registered_mcp = any(
426
+ isinstance(entry, dict) and entry.get("path") == ".mcp.json"
427
+ for entry in manifest.get("configs") or []
428
+ )
429
+ if not registered_mcp:
430
+ return []
431
+
432
+ db_path = target / ".task-state" / "handoff.db"
433
+ if db_path.is_file():
434
+ return []
435
+ return [{"kind": "state_drift", "path": ".task-state/handoff.db"}]
436
+
437
+
438
+ _COMPACT_SESSION_HOOK_ID = "compact-session"
439
+
440
+
441
+ def _compact_session_stop_adapters(clone: Path) -> dict[str, Mapping[str, Any]]:
442
+ """Return the ``compact-session`` Stop adapters keyed by target path.
443
+
444
+ Read from the portable-commands manifest in the clone — the same
445
+ source ``install`` walks. Returns ``{}`` when the manifest predates
446
+ schema v2 (no ``hooks`` array) so doctor stays a noop on legacy
447
+ overlays.
448
+ """
449
+ from workstate_bootstrap.install import _load_portable_manifest
450
+
451
+ portable = _load_portable_manifest(clone)
452
+ adapters: dict[str, Mapping[str, Any]] = {}
453
+ for hook in portable.get("hooks") or []:
454
+ if not isinstance(hook, Mapping):
455
+ continue
456
+ if hook.get("hook_id") != _COMPACT_SESSION_HOOK_ID:
457
+ continue
458
+ for adapter in hook.get("adapters") or []:
459
+ if not isinstance(adapter, Mapping):
460
+ continue
461
+ tgt = adapter.get("target")
462
+ if isinstance(tgt, str):
463
+ adapters[tgt] = adapter
464
+ return adapters
465
+
466
+
467
+ def _managed_stop_adapter_drifted(
468
+ settings_path: Path, adapter: Mapping[str, Any], *, target: Path
469
+ ) -> bool:
470
+ """True when the installed adapter file no longer carries the
471
+ manifest-declared managed Stop entry.
472
+
473
+ Drift covers: the file is gone, is unparseable, the patch container
474
+ (e.g. ``$.hooks.Stop``) is missing, the managed entry (matched by
475
+ ``match_key``) is absent, or it is present but differs from the
476
+ rendered manifest entry. A present, exact-match entry is clean.
477
+ """
478
+ from workstate_bootstrap.install import _render_template
479
+
480
+ if not settings_path.is_file():
481
+ return True
482
+ try:
483
+ doc = json.loads(settings_path.read_text())
484
+ except (OSError, ValueError):
485
+ return True
486
+
487
+ patch = adapter.get("patch") or {}
488
+ json_path = str(patch.get("json_path", ""))
489
+ match_key = patch.get("match_key")
490
+ expected = _render_template(patch.get("entry"), target=target)
491
+ if not json_path.startswith("$.") or match_key is None:
492
+ return True
493
+ if not isinstance(expected, Mapping) or match_key not in expected:
494
+ return True
495
+
496
+ node: Any = doc
497
+ for seg in json_path[2:].split("."):
498
+ if not isinstance(node, Mapping) or seg not in node:
499
+ return True
500
+ node = node[seg]
501
+ if not isinstance(node, list):
502
+ return True
503
+
504
+ match_value = expected[match_key]
505
+ for item in node:
506
+ if isinstance(item, Mapping) and item.get(match_key) == match_value:
507
+ return item != expected
508
+ return True
509
+
510
+
511
+ def _doctor_managed_stop_adapters(
512
+ target: Path, clone: Path, manifest: Mapping[str, object]
513
+ ) -> list[Finding]:
514
+ """Flag drift for compact-session Stop adapters that bootstrap installed.
515
+
516
+ An adapter is *managed* only when its target appears in the install
517
+ manifest's ``configs`` (i.e. an opt-in flag wrote it). Never-installed
518
+ adapters stay optional and are not reported here — lifecycle doctor owns
519
+ optional-not-installed visibility. See WORKSTATE-REF-80 implementation note.
520
+ """
521
+ adapters = _compact_session_stop_adapters(clone)
522
+ if not adapters:
523
+ return []
524
+ installed_paths = {
525
+ entry.get("path")
526
+ for entry in manifest.get("configs") or []
527
+ if isinstance(entry, dict)
528
+ }
529
+ findings: list[Finding] = []
530
+ for tgt, adapter in adapters.items():
531
+ if tgt not in installed_paths:
532
+ continue
533
+ if _managed_stop_adapter_drifted(target / tgt, adapter, target=target):
534
+ findings.append({"kind": "hook_adapter_drift", "path": tgt})
535
+ return findings
536
+
537
+
538
+ def _doctor_generated_surfaces(
539
+ target: Path,
540
+ clone: Path,
541
+ manifest: dict[str, object],
542
+ override_root: Path | None,
543
+ ) -> list[Finding]:
544
+ """Detect drift in per-agent generated surfaces.
545
+
546
+ Runs ``scripts/generate_agent_workflows.py --check --target <target>``
547
+ against the target. Each ``drift detected: <path>`` line in the
548
+ generator's stderr is mapped back to the manifest's ``generated``
549
+ surface that owns it; one ``generated_drift`` finding per affected
550
+ surface (deduplicated). Silent when no generated surfaces are recorded
551
+ (legacy manifests) or the generator script is missing from the clone
552
+ (older overlay refs that pre-date implementation note).
553
+ """
554
+ import subprocess
555
+ import sys
556
+
557
+ surfaces = manifest.get("surfaces") or []
558
+ generated_surfaces = [
559
+ str(entry.get("path", ""))
560
+ for entry in surfaces
561
+ if entry.get("source") == "generated" and entry.get("path")
562
+ ]
563
+ if not generated_surfaces:
564
+ return []
565
+
566
+ plugin_root = Path(*PLUGIN_GENERATED_ROOT).as_posix()
567
+ plugin_surfaces = [
568
+ surface for surface in generated_surfaces if surface.startswith(plugin_root + "/")
569
+ ]
570
+ legacy_surfaces = [
571
+ surface for surface in generated_surfaces if surface not in plugin_surfaces
572
+ ]
573
+
574
+ findings: list[Finding] = []
575
+ if legacy_surfaces:
576
+ findings.extend(
577
+ _doctor_legacy_generated_surfaces(target, clone, legacy_surfaces)
578
+ )
579
+ if plugin_surfaces:
580
+ findings.extend(
581
+ _doctor_plugin_generated_surfaces(
582
+ target,
583
+ clone,
584
+ manifest,
585
+ plugin_surfaces,
586
+ override_root,
587
+ )
588
+ )
589
+ return findings
590
+
591
+
592
+ def _doctor_legacy_generated_surfaces(
593
+ target: Path, clone: Path, generated_surfaces: list[str]
594
+ ) -> list[Finding]:
595
+ import subprocess
596
+ import sys
597
+
598
+ from workstate_bootstrap.install import _resolve_in_clone
599
+
600
+ generator_script = _resolve_in_clone(clone, GENERATOR_SCRIPT)
601
+ manifest_path = _resolve_in_clone(clone, GENERATOR_MANIFEST)
602
+ skills_source = _resolve_in_clone(clone, GENERATOR_SKILLS_SOURCE)
603
+ if not generator_script.is_file() or not manifest_path.is_file():
604
+ return []
605
+
606
+ try:
607
+ proc = subprocess.run(
608
+ [
609
+ sys.executable,
610
+ str(generator_script),
611
+ "--manifest",
612
+ str(manifest_path),
613
+ "--skills-source-root",
614
+ str(skills_source),
615
+ "--target",
616
+ str(target),
617
+ "--check",
618
+ ],
619
+ cwd=str(clone),
620
+ capture_output=True,
621
+ text=True,
622
+ timeout=120,
623
+ )
624
+ except (OSError, subprocess.TimeoutExpired):
625
+ # Generator unavailable / hung — surface as a single coarse finding
626
+ # rather than crashing doctor.
627
+ return [
628
+ {"kind": "generated_drift", "path": surface}
629
+ for surface in generated_surfaces
630
+ ]
631
+
632
+ if proc.returncode == 0:
633
+ return []
634
+
635
+ # Parse "drift detected: <abs path>" lines and map each back to the
636
+ # manifest surface whose target-relative path it sits under.
637
+ drifted: set[str] = set()
638
+ for line in (proc.stderr or "").splitlines():
639
+ line = line.strip()
640
+ if not line.startswith("drift detected:"):
641
+ continue
642
+ drifted_path = line.split(":", 1)[1].strip()
643
+ try:
644
+ rel = Path(drifted_path).resolve().relative_to(target)
645
+ except (ValueError, OSError):
646
+ continue
647
+ rel_str = str(rel)
648
+ for surface in generated_surfaces:
649
+ if rel_str == surface or rel_str.startswith(surface.rstrip("/") + "/"):
650
+ drifted.add(surface)
651
+ break
652
+
653
+ if not drifted:
654
+ # Generator reported failure but we couldn't map any path. Flag
655
+ # all generated surfaces coarsely so the operator knows there's
656
+ # work to do.
657
+ drifted.update(generated_surfaces)
658
+
659
+ return [{"kind": "generated_drift", "path": surface} for surface in sorted(drifted)]
660
+
661
+
662
+ def _doctor_plugin_generated_surfaces(
663
+ target: Path,
664
+ clone: Path,
665
+ manifest: Mapping[str, object],
666
+ generated_surfaces: list[str],
667
+ override_root: Path | None,
668
+ ) -> list[Finding]:
669
+ import subprocess
670
+ import sys
671
+
672
+ from workstate_bootstrap.install import _resolve_in_clone
673
+
674
+ generator_script = _resolve_in_clone(clone, GENERATOR_SCRIPT)
675
+ manifest_path = _resolve_in_clone(clone, GENERATOR_MANIFEST)
676
+ skills_source = _resolve_in_clone(clone, GENERATOR_SKILLS_SOURCE)
677
+ if not generator_script.is_file() or not manifest_path.is_file():
678
+ return []
679
+
680
+ remote_sha = manifest.get("remote_sha")
681
+ findings: list[Finding] = []
682
+ base_surface = Path(*PLUGIN_GENERATED_ROOT, "base").as_posix()
683
+ effective_surface = Path(*PLUGIN_GENERATED_ROOT, "effective").as_posix()
684
+
685
+ for surface in generated_surfaces:
686
+ cmd = [
687
+ sys.executable,
688
+ str(generator_script),
689
+ "--mode=plugin",
690
+ "--manifest",
691
+ str(manifest_path),
692
+ "--skills-source-root",
693
+ str(skills_source),
694
+ "--plugin-out",
695
+ str(target / surface),
696
+ "--check",
697
+ ]
698
+
699
+ if surface == effective_surface:
700
+ if override_root is None or not isinstance(remote_sha, str):
701
+ findings.append({"kind": "generated_drift", "path": surface})
702
+ continue
703
+ cmd.extend(
704
+ [
705
+ "--plugin-overrides",
706
+ str(override_root),
707
+ "--plugin-base-remote-sha",
708
+ remote_sha,
709
+ ]
710
+ )
711
+ elif surface != base_surface:
712
+ findings.append({"kind": "generated_drift", "path": surface})
713
+ continue
714
+
715
+ try:
716
+ proc = subprocess.run(
717
+ cmd,
718
+ cwd=str(clone),
719
+ capture_output=True,
720
+ text=True,
721
+ timeout=120,
722
+ )
723
+ except (OSError, subprocess.TimeoutExpired):
724
+ findings.append({"kind": "generated_drift", "path": surface})
725
+ continue
726
+
727
+ if proc.returncode != 0 and not _plugin_check_reports_only_stale_override(
728
+ target / surface, proc.stderr
729
+ ):
730
+ findings.append({"kind": "generated_drift", "path": surface})
731
+
732
+ return findings
733
+
734
+
735
+ def _plugin_check_reports_only_stale_override(plugin_root: Path, stderr: str) -> bool:
736
+ lines = [line.strip() for line in (stderr or "").splitlines() if line.strip()]
737
+ drift_markers = ("Plugin tree is out of sync", "missing plugin tree file:", "plugin tree drift:")
738
+ if any(any(marker in line for marker in drift_markers) for line in lines):
739
+ return False
740
+
741
+ lock_path = plugin_root / "plugin-lock.json"
742
+ if not lock_path.is_file():
743
+ return False
744
+
745
+ try:
746
+ payload = json.loads(lock_path.read_text())
747
+ except json.JSONDecodeError:
748
+ return False
749
+
750
+ components = payload.get("components", [])
751
+ return any(
752
+ isinstance(entry, dict) and entry.get("status") == "stale"
753
+ for entry in components
754
+ )
755
+
756
+
757
+ def _doctor_plugin_pin_targets(target: Path, override_root: Path | None) -> list[Finding]:
758
+ findings: list[Finding] = []
759
+ plugin_tree_kind = "effective" if override_root is not None else "base"
760
+
761
+ claude_path = target / CLAUDE_MARKETPLACE_PATH
762
+ if claude_path.is_file():
763
+ try:
764
+ claude_payload = json.loads(claude_path.read_text())
765
+ except json.JSONDecodeError:
766
+ claude_payload = {}
767
+ plugins = claude_payload.get("plugins")
768
+ expected = _relative_plugin_tree_path(plugin_tree_kind, "claude")
769
+ actual = None
770
+ if isinstance(plugins, list):
771
+ for plugin in plugins:
772
+ if isinstance(plugin, dict) and plugin.get("name") == PLUGIN_NAME:
773
+ actual = plugin.get("source")
774
+ break
775
+ if actual != expected:
776
+ findings.append(
777
+ {"kind": "pin_target_drift", "path": CLAUDE_MARKETPLACE_PATH.as_posix()}
778
+ )
779
+
780
+ codex_path = target / CODEX_MARKETPLACE_PATH
781
+ if codex_path.is_file():
782
+ try:
783
+ codex_payload = json.loads(codex_path.read_text())
784
+ except json.JSONDecodeError:
785
+ codex_payload = {}
786
+ plugins = codex_payload.get("plugins")
787
+ expected = {
788
+ "source": "local",
789
+ "path": _relative_plugin_tree_path(plugin_tree_kind, "codex"),
790
+ }
791
+ actual = None
792
+ if isinstance(plugins, list):
793
+ for plugin in plugins:
794
+ if isinstance(plugin, dict) and plugin.get("name") == PLUGIN_NAME:
795
+ actual = plugin.get("source")
796
+ break
797
+ if actual != expected:
798
+ findings.append(
799
+ {"kind": "pin_target_drift", "path": CODEX_MARKETPLACE_PATH.as_posix()}
800
+ )
801
+
802
+ return findings
803
+
804
+
805
+ def _doctor_codex_activation_config(target: Path) -> list[Finding]:
806
+ if not (target / CODEX_MARKETPLACE_PATH).is_file():
807
+ return []
808
+
809
+ path = target / CODEX_CONFIG_PATH
810
+ if not path.is_file():
811
+ return [{"kind": "codex_activation_drift", "path": CODEX_CONFIG_PATH.as_posix()}]
812
+
813
+ problems: list[str] = []
814
+ try:
815
+ payload = tomllib.loads(path.read_text())
816
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
817
+ return [
818
+ {
819
+ "kind": "codex_activation_drift",
820
+ "path": CODEX_CONFIG_PATH.as_posix(),
821
+ "message": f"invalid TOML: {exc}",
822
+ }
823
+ ]
824
+
825
+ marketplaces = payload.get("marketplaces")
826
+ if not isinstance(marketplaces, dict):
827
+ problems.append("marketplaces must be a table")
828
+ marketplace = None
829
+ else:
830
+ marketplace = marketplaces.get(PLUGIN_MARKETPLACE_NAME)
831
+ if not isinstance(marketplace, dict):
832
+ problems.append(f"missing marketplaces.{PLUGIN_MARKETPLACE_NAME}")
833
+ else:
834
+ if marketplace.get("source_type") != "local":
835
+ problems.append(f"marketplaces.{PLUGIN_MARKETPLACE_NAME}.source_type must be local")
836
+ if marketplace.get("source") != ".":
837
+ problems.append(f"marketplaces.{PLUGIN_MARKETPLACE_NAME}.source must be .")
838
+
839
+ selector = f"{PLUGIN_NAME}@{PLUGIN_MARKETPLACE_NAME}"
840
+ plugins = payload.get("plugins")
841
+ if not isinstance(plugins, dict):
842
+ problems.append("plugins must be a table")
843
+ plugin = None
844
+ else:
845
+ plugin = plugins.get(selector)
846
+ if not isinstance(plugin, dict) or not isinstance(plugin.get("enabled"), bool):
847
+ problems.append(f'plugins."{selector}".enabled must be a boolean')
848
+
849
+ if not problems:
850
+ return []
851
+ return [
852
+ {
853
+ "kind": "codex_activation_drift",
854
+ "path": CODEX_CONFIG_PATH.as_posix(),
855
+ "message": "; ".join(problems),
856
+ }
857
+ ]
858
+
859
+
860
+ def _resolve_plugin_source_path(
861
+ target: Path,
862
+ raw_path: object,
863
+ *,
864
+ field_name: str,
865
+ ) -> tuple[Path | None, list[str]]:
866
+ if not isinstance(raw_path, str) or not raw_path.strip():
867
+ return None, [f"{field_name} must be a non-empty string"]
868
+ candidate = Path(raw_path)
869
+ if candidate.is_absolute():
870
+ return None, [f"{field_name} must be relative"]
871
+ if ".." in candidate.parts:
872
+ return None, [f"{field_name} must not traverse outside the repo"]
873
+ resolved = (target / raw_path.removeprefix("./")).resolve(strict=False)
874
+ try:
875
+ resolved.relative_to(target.resolve())
876
+ except ValueError:
877
+ return None, [f"{field_name} resolves outside the repo"]
878
+ return resolved, []
879
+
880
+
881
+ def _plugin_tree_integrity_problems(root: Path, harness: str) -> list[str]:
882
+ manifest_dir = ".claude-plugin" if harness == "claude" else ".codex-plugin"
883
+ problems: list[str] = []
884
+ if not root.is_dir():
885
+ return [f"source path does not exist: {root}"]
886
+ if not (root / manifest_dir / "plugin.json").is_file():
887
+ problems.append(f"missing {manifest_dir}/plugin.json")
888
+ if not (root / ".mcp.json").is_file():
889
+ problems.append("missing .mcp.json")
890
+ skills_root = root / "skills"
891
+ if not skills_root.is_dir():
892
+ problems.append("missing skills/")
893
+ elif not any(path.is_file() for path in skills_root.glob("*/SKILL.md")):
894
+ problems.append("skills/ contains no SKILL.md entries")
895
+ return problems
896
+
897
+
898
+ def _doctor_plugin_source_integrity(target: Path) -> list[Finding]:
899
+ findings: list[Finding] = []
900
+
901
+ claude_path = target / CLAUDE_MARKETPLACE_PATH
902
+ if claude_path.is_file():
903
+ problems: list[str] = []
904
+ try:
905
+ payload = json.loads(claude_path.read_text())
906
+ except json.JSONDecodeError as exc:
907
+ payload = {}
908
+ problems.append(f"invalid JSON: {exc}")
909
+ plugins = payload.get("plugins") if isinstance(payload, dict) else None
910
+ if not isinstance(plugins, list):
911
+ problems.append("plugins must be a list")
912
+ else:
913
+ for plugin in plugins:
914
+ if not isinstance(plugin, dict) or plugin.get("name") != PLUGIN_NAME:
915
+ continue
916
+ root, path_problems = _resolve_plugin_source_path(
917
+ target,
918
+ plugin.get("source"),
919
+ field_name="plugins[].source",
920
+ )
921
+ problems.extend(path_problems)
922
+ if root is not None:
923
+ problems.extend(_plugin_tree_integrity_problems(root, "claude"))
924
+ break
925
+ else:
926
+ problems.append(f"missing {PLUGIN_NAME} plugin entry")
927
+ if problems:
928
+ findings.append(
929
+ {
930
+ "kind": "plugin_source_drift",
931
+ "path": CLAUDE_MARKETPLACE_PATH.as_posix(),
932
+ "message": "; ".join(problems),
933
+ }
934
+ )
935
+
936
+ codex_path = target / CODEX_MARKETPLACE_PATH
937
+ if codex_path.is_file():
938
+ problems = []
939
+ try:
940
+ payload = json.loads(codex_path.read_text())
941
+ except json.JSONDecodeError as exc:
942
+ payload = {}
943
+ problems.append(f"invalid JSON: {exc}")
944
+ plugins = payload.get("plugins") if isinstance(payload, dict) else None
945
+ if not isinstance(plugins, list):
946
+ problems.append("plugins must be a list")
947
+ else:
948
+ for plugin in plugins:
949
+ if not isinstance(plugin, dict) or plugin.get("name") != PLUGIN_NAME:
950
+ continue
951
+ source = plugin.get("source")
952
+ if not isinstance(source, dict) or source.get("source") != "local":
953
+ problems.append("plugins[].source.source must be local")
954
+ break
955
+ root, path_problems = _resolve_plugin_source_path(
956
+ target,
957
+ source.get("path"),
958
+ field_name="plugins[].source.path",
959
+ )
960
+ problems.extend(path_problems)
961
+ if root is not None:
962
+ problems.extend(_plugin_tree_integrity_problems(root, "codex"))
963
+ break
964
+ else:
965
+ problems.append(f"missing {PLUGIN_NAME} plugin entry")
966
+ if problems:
967
+ findings.append(
968
+ {
969
+ "kind": "plugin_source_drift",
970
+ "path": CODEX_MARKETPLACE_PATH.as_posix(),
971
+ "message": "; ".join(problems),
972
+ }
973
+ )
974
+
975
+ return findings
976
+
977
+
978
+ def _doctor_hidden_override_collisions(
979
+ target: Path, clone: Path, override_root: Path | None
980
+ ) -> list[Finding]:
981
+ if override_root is None:
982
+ return []
983
+
984
+ override_manifest_path = override_root / PLUGIN_OVERRIDE_MANIFEST
985
+ if not override_manifest_path.is_file():
986
+ return []
987
+
988
+ try:
989
+ override_manifest = yaml.safe_load(override_manifest_path.read_text()) or {}
990
+ except (OSError, yaml.YAMLError):
991
+ return []
992
+
993
+ declared_paths: set[str] = set()
994
+ components = override_manifest.get("components")
995
+ if isinstance(components, dict):
996
+ skills = components.get("skills")
997
+ if isinstance(skills, dict):
998
+ for spec in skills.values():
999
+ if not isinstance(spec, dict):
1000
+ continue
1001
+ path = spec.get("path")
1002
+ if isinstance(path, str) and path:
1003
+ declared_paths.add(path)
1004
+
1005
+ override_skills_root = override_root / "skills"
1006
+ if not override_skills_root.is_dir():
1007
+ return []
1008
+
1009
+ skills_source_root = _resolve_in_clone(clone, GENERATOR_SKILLS_SOURCE)
1010
+ if not skills_source_root.is_dir():
1011
+ return []
1012
+
1013
+ findings: list[Finding] = []
1014
+ for candidate in sorted(override_skills_root.glob("*/SKILL.md")):
1015
+ relative_path = candidate.relative_to(override_root).as_posix()
1016
+ if relative_path in declared_paths:
1017
+ continue
1018
+ if not (skills_source_root / candidate.parent.name).is_dir():
1019
+ continue
1020
+ findings.append(
1021
+ {
1022
+ "kind": "hidden_override_collision",
1023
+ "path": _plugin_override_display_path(
1024
+ target, override_root, relative_path
1025
+ ),
1026
+ }
1027
+ )
1028
+
1029
+ return findings
1030
+
1031
+
1032
+ def _plugin_override_display_path(
1033
+ target: Path, override_root: Path | None, relative_path: str
1034
+ ) -> str:
1035
+ if override_root is None:
1036
+ return Path(*PLUGIN_OVERRIDE_ROOT, relative_path).as_posix()
1037
+
1038
+ candidate = override_root / relative_path
1039
+ try:
1040
+ return candidate.relative_to(target).as_posix()
1041
+ except ValueError:
1042
+ return candidate.as_posix()
1043
+
1044
+
1045
+ def _doctor_plugin_override_state(
1046
+ target: Path, override_root: Path | None
1047
+ ) -> list[Finding]:
1048
+ lock_path = target.joinpath(*PLUGIN_GENERATED_ROOT, "effective", "plugin-lock.json")
1049
+
1050
+ findings: list[Finding] = []
1051
+
1052
+ if lock_path.is_file():
1053
+ try:
1054
+ payload = json.loads(lock_path.read_text())
1055
+ except json.JSONDecodeError:
1056
+ payload = {}
1057
+
1058
+ for entry in payload.get("components", []):
1059
+ if not isinstance(entry, dict) or entry.get("status") != "stale":
1060
+ continue
1061
+ override_path = entry.get("override_path")
1062
+ if not isinstance(override_path, str) or not override_path:
1063
+ continue
1064
+ findings.append(
1065
+ {
1066
+ "kind": "stale_override",
1067
+ "path": _plugin_override_display_path(
1068
+ target, override_root, override_path
1069
+ ),
1070
+ }
1071
+ )
1072
+
1073
+ override_lock_path = None if override_root is None else override_root / "overrides.lock.json"
1074
+ if not override_lock_path or not override_lock_path.is_file():
1075
+ return findings
1076
+
1077
+ try:
1078
+ override_payload = json.loads(override_lock_path.read_text())
1079
+ except json.JSONDecodeError:
1080
+ return findings
1081
+
1082
+ unsafe_op_names = {
1083
+ "replace_command",
1084
+ "replace_args",
1085
+ "append_args",
1086
+ "upsert_env",
1087
+ "remove_env",
1088
+ }
1089
+ seen_paths: set[str] = set()
1090
+ for entry in override_payload.get("components", []):
1091
+ if not isinstance(entry, dict) or entry.get("component_kind") != "mcp_server":
1092
+ continue
1093
+ patch_path = entry.get("patch_path")
1094
+ if not isinstance(patch_path, str) or not patch_path:
1095
+ continue
1096
+
1097
+ patch_file = override_root / patch_path
1098
+ display_path = _plugin_override_display_path(target, override_root, patch_path)
1099
+ try:
1100
+ patch_payload = yaml.safe_load(patch_file.read_text()) or {}
1101
+ except (OSError, yaml.YAMLError):
1102
+ if display_path not in seen_paths:
1103
+ seen_paths.add(display_path)
1104
+ findings.append({"kind": "invalid_override_schema", "path": display_path})
1105
+ continue
1106
+
1107
+ ops = patch_payload.get("ops")
1108
+ if not isinstance(ops, list):
1109
+ if display_path not in seen_paths:
1110
+ seen_paths.add(display_path)
1111
+ findings.append({"kind": "invalid_override_schema", "path": display_path})
1112
+ continue
1113
+ if not any(
1114
+ isinstance(op, dict) and op.get("op") in unsafe_op_names
1115
+ for op in ops
1116
+ ):
1117
+ continue
1118
+
1119
+ if display_path in seen_paths:
1120
+ continue
1121
+ seen_paths.add(display_path)
1122
+ findings.append({"kind": "unsafe_tool_patch", "path": display_path})
1123
+
1124
+ return findings
1125
+
1126
+
1127
+ # ---------------------------------------------------------------------------
1128
+ # update
1129
+ # ---------------------------------------------------------------------------
1130
+
1131
+
1132
+ def update(
1133
+ *,
1134
+ target: Path,
1135
+ remote_ref: str,
1136
+ remote_url: str | None = None,
1137
+ mcp_servers: Mapping[str, Mapping[str, Any]] | None = None,
1138
+ plugin_overrides: Path | None = None,
1139
+ reset_overrides: bool = False,
1140
+ backup_overrides: bool = False,
1141
+ enforce_required_surfaces: bool = True,
1142
+ ) -> dict[str, object]:
1143
+ """Re-run ``install`` against ``target`` at a new ``remote_ref``.
1144
+
1145
+ ``remote_url`` defaults to whatever the current manifest already records,
1146
+ so callers don't need to repeat it. When ``mcp_servers`` is omitted,
1147
+ managed installs preserve their existing ``.mcp.json`` registration so
1148
+ config surfaces and init-state still refresh. ``enforce_required_surfaces``
1149
+ defaults to ``True`` to match the install CLI contract.
1150
+ """
1151
+ from workstate_bootstrap.install import _discover_plugin_override_root, install
1152
+
1153
+ target = Path(target).resolve()
1154
+ manifest = _load_manifest(target)
1155
+ if remote_url is None:
1156
+ remote_url = str(manifest["remote_url"])
1157
+ if mcp_servers is None:
1158
+ mcp_servers = _preserved_mcp_servers(target, manifest)
1159
+ override_root = _discover_plugin_override_root(
1160
+ target,
1161
+ manifest=manifest,
1162
+ plugin_overrides=plugin_overrides,
1163
+ )
1164
+
1165
+ return install(
1166
+ target=target,
1167
+ remote_url=remote_url,
1168
+ remote_ref=remote_ref,
1169
+ mcp_servers=mcp_servers,
1170
+ plugin_overrides=override_root,
1171
+ reset_overrides=reset_overrides,
1172
+ backup_overrides=backup_overrides,
1173
+ enforce_required_surfaces=enforce_required_surfaces,
1174
+ )
1175
+
1176
+
1177
+ # ---------------------------------------------------------------------------
1178
+ # repair
1179
+ # ---------------------------------------------------------------------------
1180
+
1181
+
1182
+ def repair(
1183
+ *,
1184
+ target: Path,
1185
+ force_dirty: bool = False,
1186
+ mcp_servers: Mapping[str, Mapping[str, Any]] | None = None,
1187
+ plugin_overrides: Path | None = None,
1188
+ ) -> dict[str, list[Finding]]:
1189
+ """Restore drifted overlay surfaces flagged by :func:`doctor`.
1190
+
1191
+ For each surface flagged as ``surface_drift``:
1192
+
1193
+ - If the path no longer exists or is a broken/foreign symlink, the
1194
+ canonical symlink into the clone is recreated.
1195
+ - If the path has been replaced by a real directory or file, the surface
1196
+ is **skipped** unless ``force_dirty=True`` (per regression guard rg-017
1197
+ "never silently force-remove dirty content"). With ``force_dirty=True``
1198
+ the dirty content is removed and the symlink reinstated.
1199
+
1200
+ Config drift (``.mcp.json`` / ``.vscode/mcp.json``) is repaired by
1201
+ re-running the install-time writers when ``mcp_servers`` is supplied.
1202
+
1203
+ Returns a report dict ``{"repaired": [...], "skipped": [...]}`` whose
1204
+ entries reuse the :func:`doctor` finding shape.
1205
+ """
1206
+ from workstate_bootstrap.install import (
1207
+ _run_generator,
1208
+ _set_git_hooks_path,
1209
+ _write_plugin_pins,
1210
+ _discover_plugin_override_root,
1211
+ )
1212
+ from workstate_bootstrap.mcp_sync import sync_mcp_configs
1213
+ import shutil
1214
+
1215
+ target = Path(target).resolve()
1216
+ manifest = _load_manifest(target)
1217
+ override_root = _discover_plugin_override_root(
1218
+ target,
1219
+ manifest=manifest,
1220
+ plugin_overrides=plugin_overrides,
1221
+ )
1222
+ findings = doctor(
1223
+ target=target,
1224
+ mcp_servers=mcp_servers,
1225
+ plugin_overrides=override_root,
1226
+ )
1227
+ repaired: list[Finding] = []
1228
+ skipped: list[Finding] = []
1229
+
1230
+ if not findings:
1231
+ return {"repaired": repaired, "skipped": skipped}
1232
+
1233
+ clone = target.joinpath(*CLONE_SUBDIR)
1234
+
1235
+ config_drift_paths = {
1236
+ f["path"] for f in findings if f["kind"] == "config_drift"
1237
+ }
1238
+ if config_drift_paths and mcp_servers:
1239
+ drifted_surfaces = [
1240
+ _MANAGED_SURFACE_BY_CONFIG_PATH[p]
1241
+ for p in config_drift_paths
1242
+ if p in _MANAGED_SURFACE_BY_CONFIG_PATH
1243
+ ]
1244
+ if drifted_surfaces:
1245
+ sync_mcp_configs(
1246
+ target, mcp_servers, surfaces=drifted_surfaces, check_only=False
1247
+ )
1248
+
1249
+ for finding in findings:
1250
+ kind = finding["kind"]
1251
+ path = finding["path"]
1252
+
1253
+ if kind == "surface_drift":
1254
+ from workstate_bootstrap.install import _resolve_in_clone
1255
+
1256
+ link = target / path
1257
+ remote_path = _resolve_in_clone(clone, path)
1258
+ if not remote_path.exists():
1259
+ skipped.append(finding)
1260
+ continue
1261
+
1262
+ if link.is_symlink() or not link.exists():
1263
+ # Broken/foreign symlink or missing entirely: safe to replace.
1264
+ if link.is_symlink():
1265
+ link.unlink()
1266
+ elif link.is_dir() and any(link.iterdir()):
1267
+ if not force_dirty:
1268
+ skipped.append(finding)
1269
+ continue
1270
+ shutil.rmtree(link)
1271
+ elif link.is_dir():
1272
+ link.rmdir()
1273
+ else:
1274
+ if not force_dirty:
1275
+ skipped.append(finding)
1276
+ continue
1277
+ link.unlink()
1278
+
1279
+ link.parent.mkdir(parents=True, exist_ok=True)
1280
+ rel = os.path.relpath(remote_path, link.parent)
1281
+ link.symlink_to(rel, target_is_directory=True)
1282
+ repaired.append(finding)
1283
+ continue
1284
+
1285
+ if kind in {"generated_drift", "plugin_source_drift"}:
1286
+ # Re-run the generator once for the whole batch — it rewrites
1287
+ # every per-agent surface from the canonical source. Collapse
1288
+ # subsequent generated/source findings into the same repair op.
1289
+ if any(
1290
+ f["kind"] in {"generated_drift", "plugin_source_drift"}
1291
+ for f in repaired
1292
+ ):
1293
+ repaired.append(finding)
1294
+ continue
1295
+ try:
1296
+ remote_sha = manifest.get("remote_sha")
1297
+ if not isinstance(remote_sha, str):
1298
+ raise ValueError("install manifest missing remote_sha")
1299
+ _run_generator(target, clone, remote_sha, override_root)
1300
+ _write_plugin_pins(target, override_root)
1301
+ except Exception:
1302
+ skipped.append(finding)
1303
+ continue
1304
+ repaired.append(finding)
1305
+ continue
1306
+
1307
+ if kind in {"pin_target_drift", "codex_activation_drift"}:
1308
+ if any(
1309
+ f["kind"] in {"pin_target_drift", "codex_activation_drift"}
1310
+ for f in repaired
1311
+ ):
1312
+ repaired.append(finding)
1313
+ continue
1314
+ try:
1315
+ _write_plugin_pins(target, override_root)
1316
+ except Exception:
1317
+ skipped.append(finding)
1318
+ continue
1319
+ repaired.append(finding)
1320
+ continue
1321
+
1322
+ if kind == "config_drift":
1323
+ if mcp_servers and path in _MANAGED_SURFACE_BY_CONFIG_PATH:
1324
+ repaired.append(finding)
1325
+ else:
1326
+ skipped.append(finding)
1327
+ continue
1328
+
1329
+ if kind == "hook_adapter_drift":
1330
+ # Re-apply the manifest-declared managed Stop entry. The walker's
1331
+ # merge is idempotent and preserves unrelated user entries, so
1332
+ # restoring a drifted managed adapter is safe without force_dirty.
1333
+ from workstate_bootstrap.install import _apply_merge_array_entry
1334
+
1335
+ adapter = _compact_session_stop_adapters(clone).get(path)
1336
+ if adapter is None:
1337
+ skipped.append(finding)
1338
+ continue
1339
+ try:
1340
+ _apply_merge_array_entry(adapter, target=target)
1341
+ except Exception:
1342
+ skipped.append(finding)
1343
+ continue
1344
+ repaired.append(finding)
1345
+ continue
1346
+
1347
+ # missing_clone / missing_manifest are out of scope here — caller
1348
+ # should run install/update instead. Surface as skipped.
1349
+ skipped.append(finding)
1350
+
1351
+ # Refresh git hooks path defensively when we touched anything.
1352
+ if repaired and (target / ".git").exists():
1353
+ try:
1354
+ _set_git_hooks_path(target)
1355
+ except Exception:
1356
+ pass
1357
+
1358
+ return {"repaired": repaired, "skipped": skipped}