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.
- workstate_bootstrap/__init__.py +6 -0
- workstate_bootstrap/__main__.py +5 -0
- workstate_bootstrap/cli.py +610 -0
- workstate_bootstrap/install.py +2057 -0
- workstate_bootstrap/mcp_sync.py +293 -0
- workstate_bootstrap/subcommands.py +1358 -0
- workstate_bootstrap-0.5.2.dist-info/METADATA +179 -0
- workstate_bootstrap-0.5.2.dist-info/RECORD +10 -0
- workstate_bootstrap-0.5.2.dist-info/WHEEL +4 -0
- workstate_bootstrap-0.5.2.dist-info/entry_points.txt +2 -0
|
@@ -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}
|