workstate-protocol 0.1.7__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/PKG-INFO +1 -1
  2. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/pyproject.toml +1 -1
  3. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/src/workstate_protocol/__init__.py +1 -1
  4. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/src/workstate_protocol/bootstrap.py +81 -10
  5. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/src/workstate_protocol.egg-info/PKG-INFO +1 -1
  6. workstate_protocol-0.2.1/tests/test_plugin_override_schema.py +330 -0
  7. workstate_protocol-0.1.7/tests/test_plugin_override_schema.py +0 -145
  8. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/README.md +0 -0
  9. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/setup.cfg +0 -0
  10. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/src/workstate_protocol/branch_naming.py +0 -0
  11. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/src/workstate_protocol/compaction.py +0 -0
  12. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/src/workstate_protocol/env_aliases.py +0 -0
  13. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/src/workstate_protocol/handoff.py +0 -0
  14. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/src/workstate_protocol/hooks.py +0 -0
  15. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/src/workstate_protocol/paths.py +0 -0
  16. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/src/workstate_protocol/py.typed +0 -0
  17. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/src/workstate_protocol/skills.py +0 -0
  18. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/src/workstate_protocol.egg-info/SOURCES.txt +0 -0
  19. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/src/workstate_protocol.egg-info/dependency_links.txt +0 -0
  20. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/src/workstate_protocol.egg-info/requires.txt +0 -0
  21. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/src/workstate_protocol.egg-info/top_level.txt +0 -0
  22. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/tests/test_bootstrap_manifest.py +0 -0
  23. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/tests/test_branch_grammar_registry.py +0 -0
  24. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/tests/test_branch_naming.py +0 -0
  25. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/tests/test_env_aliases.py +0 -0
  26. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/tests/test_handoff_schema.py +0 -0
  27. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/tests/test_package_metadata.py +0 -0
  28. {workstate_protocol-0.1.7 → workstate_protocol-0.2.1}/tests/test_skill_manifest_real_skills.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workstate-protocol
3
- Version: 0.1.7
3
+ Version: 0.2.1
4
4
  Summary: Typed cross-repo contracts for the Workstate system: handoff state, MCP I/O, hook events, skill manifests, bootstrap install manifest.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/darce/workstate
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "workstate-protocol"
7
- version = "0.1.7"
7
+ version = "0.2.1"
8
8
  description = "Typed cross-repo contracts for the Workstate system: handoff state, MCP I/O, hook events, skill manifests, bootstrap install manifest."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -44,7 +44,7 @@ from .paths import (
44
44
  )
45
45
  from .skills import SkillManifest, SkillScope
46
46
 
47
- __version__ = "0.1.6"
47
+ __version__ = "0.2.1"
48
48
 
49
49
  __all__ = [
50
50
  "ActiveTask",
@@ -9,9 +9,18 @@ single typed shape rather than per-key heuristics.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ from pathlib import PurePosixPath
12
13
  from typing import Annotated, Literal
13
14
 
14
- from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator
15
+ from pydantic import (
16
+ BaseModel,
17
+ ConfigDict,
18
+ Field,
19
+ StringConstraints,
20
+ WithJsonSchema,
21
+ field_validator,
22
+ model_validator,
23
+ )
15
24
 
16
25
 
17
26
  Sha40 = Annotated[str, StringConstraints(pattern=r"^[0-9a-f]{40}$")]
@@ -20,6 +29,35 @@ PluginComponentName = Annotated[
20
29
  str,
21
30
  StringConstraints(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]*$"),
22
31
  ]
32
+ PluginOverrideRelativePath = Annotated[
33
+ str,
34
+ StringConstraints(min_length=1),
35
+ WithJsonSchema(
36
+ {
37
+ "type": "string",
38
+ "minLength": 1,
39
+ "not": {
40
+ "anyOf": [
41
+ {"pattern": "^/"},
42
+ {"pattern": "(^|/)\\.\\.(/|$)"},
43
+ {"pattern": "^\\.?$"},
44
+ {"pattern": "\\\\"},
45
+ ]
46
+ },
47
+ }
48
+ ),
49
+ ]
50
+
51
+
52
+ def _normalize_plugin_override_path(value: str) -> str:
53
+ if "\\" in value:
54
+ raise ValueError("override paths must use POSIX '/' separators")
55
+ path = PurePosixPath(value)
56
+ if path.is_absolute() or value == "." or ".." in path.parts:
57
+ raise ValueError(
58
+ "override paths must be relative and stay under the override root"
59
+ )
60
+ return path.as_posix()
23
61
 
24
62
 
25
63
  class OverlaySurface(BaseModel):
@@ -66,17 +104,31 @@ class PluginSkillOverride(BaseModel):
66
104
 
67
105
  model_config = ConfigDict(extra="forbid")
68
106
 
69
- mode: Literal["replace", "disable", "add"]
70
- path: str | None = None
107
+ mode: Literal["replace", "patch", "disable", "add"]
108
+ path: PluginOverrideRelativePath | None = None
109
+ base_path: PluginOverrideRelativePath | None = None
71
110
  upstream_digest: Sha256Digest | None = None
72
111
  on_upstream_change: Literal["warn", "error", "ignore"] | None = None
73
112
 
113
+ @field_validator("path", "base_path")
114
+ @classmethod
115
+ def validate_override_paths(cls, value: str | None) -> str | None:
116
+ if value is None:
117
+ return None
118
+ return _normalize_plugin_override_path(value)
119
+
74
120
  @model_validator(mode="after")
75
121
  def validate_mode_fields(self) -> PluginSkillOverride:
76
- if self.mode in {"replace", "add"} and not self.path:
77
- raise ValueError("path is required when mode is replace or add")
78
- if self.mode == "replace" and self.upstream_digest is None:
79
- raise ValueError("upstream_digest is required when mode is replace")
122
+ if self.mode in {"replace", "patch", "add"} and not self.path:
123
+ raise ValueError("path is required when mode is replace, patch, or add")
124
+ if self.mode in {"replace", "patch"} and self.upstream_digest is None:
125
+ raise ValueError(
126
+ "upstream_digest is required when mode is replace or patch"
127
+ )
128
+ if self.mode == "patch" and not self.base_path:
129
+ raise ValueError("base_path is required when mode is patch")
130
+ if self.mode != "patch" and self.base_path is not None:
131
+ raise ValueError("base_path is only valid when mode is patch")
80
132
  return self
81
133
 
82
134
 
@@ -86,9 +138,16 @@ class PluginMcpServerOverride(BaseModel):
86
138
  model_config = ConfigDict(extra="forbid")
87
139
 
88
140
  mode: Literal["patch", "disable", "add"]
89
- patch_path: str | None = None
141
+ patch_path: PluginOverrideRelativePath | None = None
90
142
  requires_trust_ack: bool = False
91
143
 
144
+ @field_validator("patch_path")
145
+ @classmethod
146
+ def validate_patch_path(cls, value: str | None) -> str | None:
147
+ if value is None:
148
+ return None
149
+ return _normalize_plugin_override_path(value)
150
+
92
151
  @model_validator(mode="after")
93
152
  def validate_mode_fields(self) -> PluginMcpServerOverride:
94
153
  if self.mode in {"patch", "add"} and not self.patch_path:
@@ -182,6 +241,16 @@ class PluginMcpServerPatch(BaseModel):
182
241
  ops: list[PluginMcpPatchOperation] = Field(min_length=1)
183
242
 
184
243
 
244
+ class PluginAcceptUpstreamProvenance(BaseModel):
245
+ """Recorded ``overrides accept-upstream`` event for one component."""
246
+
247
+ model_config = ConfigDict(extra="forbid")
248
+
249
+ previous_upstream_digest: Sha256Digest
250
+ new_upstream_digest: Sha256Digest
251
+ accepted_at: str = Field(min_length=1)
252
+
253
+
185
254
  class PluginOverrideLockEntry(BaseModel):
186
255
  """Tracked provenance for one consumer-owned plugin override."""
187
256
 
@@ -191,8 +260,10 @@ class PluginOverrideLockEntry(BaseModel):
191
260
  name: PluginComponentName
192
261
  mode: Literal["replace", "disable", "add", "patch"]
193
262
  local_path: str | None = None
263
+ base_path: str | None = None
194
264
  patch_path: str | None = None
195
265
  upstream_digest: Sha256Digest | None = None
266
+ last_accept_upstream: PluginAcceptUpstreamProvenance | None = None
196
267
 
197
268
 
198
269
  class PluginOverrideLock(BaseModel):
@@ -213,9 +284,9 @@ class PluginEffectiveLockEntry(BaseModel):
213
284
 
214
285
  component_kind: Literal["skill", "command", "mcp_server"]
215
286
  name: PluginComponentName
216
- mode: Literal["replace", "disable", "add", "patch"]
287
+ mode: Literal["replace", "disable", "add", "patch", "passthrough"]
217
288
  effective_digest: Sha256Digest
218
- status: Literal["stale"] | None = None
289
+ status: Literal["stale", "merge_conflict"] | None = None
219
290
  override_path: str | None = None
220
291
  recorded_upstream_digest: Sha256Digest | None = None
221
292
  current_base_digest: Sha256Digest | None = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workstate-protocol
3
- Version: 0.1.7
3
+ Version: 0.2.1
4
4
  Summary: Typed cross-repo contracts for the Workstate system: handoff state, MCP I/O, hook events, skill manifests, bootstrap install manifest.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/darce/workstate
@@ -0,0 +1,330 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+ from pydantic import ValidationError
8
+
9
+ from workstate_protocol.bootstrap import (
10
+ PluginEffectiveLock,
11
+ PluginMcpServerPatch,
12
+ PluginOverrideLock,
13
+ PluginOverrideManifest,
14
+ )
15
+
16
+
17
+ def test_override_manifest_accepts_skill_and_mcp_components() -> None:
18
+ manifest = PluginOverrideManifest.model_validate(
19
+ {
20
+ "schema_version": 1,
21
+ "plugin": "workstate-system",
22
+ "components": {
23
+ "skills": {
24
+ "branch-review": {
25
+ "mode": "replace",
26
+ "path": "skills/branch-review/SKILL.md",
27
+ "upstream_digest": "sha256:" + "a" * 64,
28
+ "on_upstream_change": "warn",
29
+ },
30
+ "review-parallel": {
31
+ "mode": "disable",
32
+ },
33
+ },
34
+ "mcp_servers": {
35
+ "workstate-handoff-mcp": {
36
+ "mode": "patch",
37
+ "patch_path": "tools/mcp_servers.patch.yaml",
38
+ "requires_trust_ack": True,
39
+ }
40
+ },
41
+ },
42
+ }
43
+ )
44
+
45
+ assert manifest.plugin == "workstate-system"
46
+ assert manifest.components.skills["branch-review"].mode == "replace"
47
+ assert manifest.components.mcp_servers["workstate-handoff-mcp"].mode == "patch"
48
+
49
+
50
+ def test_mcp_server_patch_accepts_typed_operations() -> None:
51
+ patch = PluginMcpServerPatch.model_validate(
52
+ {
53
+ "schema_version": 1,
54
+ "target_server": "workstate-handoff-mcp",
55
+ "ops": [
56
+ {"op": "replace_command", "value": "uvx"},
57
+ {
58
+ "op": "replace_args",
59
+ "value": ["mcp-workstate-handoff@0.11.4", "--profile", "consumer"],
60
+ },
61
+ {"op": "upsert_env", "name": "HANDOFF_PROFILE", "value": "consumer"},
62
+ {"op": "remove_env", "name": "LEGACY_FLAG"},
63
+ ],
64
+ }
65
+ )
66
+
67
+ assert [entry.op for entry in patch.ops] == [
68
+ "replace_command",
69
+ "replace_args",
70
+ "upsert_env",
71
+ "remove_env",
72
+ ]
73
+
74
+
75
+ def test_mcp_server_patch_rejects_wildcard_and_unsupported_file_replacement() -> None:
76
+ with pytest.raises(ValidationError):
77
+ PluginMcpServerPatch.model_validate(
78
+ {
79
+ "schema_version": 1,
80
+ "target_server": "*",
81
+ "ops": [
82
+ {
83
+ "op": "replace_file",
84
+ "path": "../../../tmp/override.sh",
85
+ "value": "hacked",
86
+ }
87
+ ],
88
+ }
89
+ )
90
+
91
+
92
+ def test_lock_models_roundtrip() -> None:
93
+ override_lock = PluginOverrideLock.model_validate(
94
+ {
95
+ "schema_version": 1,
96
+ "plugin": "workstate-system",
97
+ "base_remote_sha": "b" * 40,
98
+ "components": [
99
+ {
100
+ "component_kind": "skill",
101
+ "name": "branch-review",
102
+ "mode": "replace",
103
+ "local_path": "skills/branch-review/SKILL.md",
104
+ "upstream_digest": "sha256:" + "c" * 64,
105
+ }
106
+ ],
107
+ }
108
+ )
109
+ effective_lock = PluginEffectiveLock.model_validate(
110
+ {
111
+ "schema_version": 1,
112
+ "plugin": "workstate-system",
113
+ "base_remote_sha": "d" * 40,
114
+ "effective_root": ".workstate/generated/plugins/workstate-system/effective/claude",
115
+ "components": [
116
+ {
117
+ "component_kind": "skill",
118
+ "name": "branch-review",
119
+ "mode": "replace",
120
+ "effective_digest": "sha256:" + "e" * 64,
121
+ }
122
+ ],
123
+ }
124
+ )
125
+
126
+ assert (
127
+ PluginOverrideLock.model_validate_json(override_lock.model_dump_json())
128
+ == override_lock
129
+ )
130
+ assert (
131
+ PluginEffectiveLock.model_validate_json(effective_lock.model_dump_json())
132
+ == effective_lock
133
+ )
134
+
135
+
136
+ def test_skill_patch_mode_roundtrip() -> None:
137
+ manifest = PluginOverrideManifest.model_validate(
138
+ {
139
+ "schema_version": 1,
140
+ "plugin": "workstate-system",
141
+ "components": {
142
+ "skills": {
143
+ "branch-review": {
144
+ "mode": "patch",
145
+ "path": "skills/branch-review/SKILL.md",
146
+ "base_path": "skills/branch-review/SKILL.base.md",
147
+ "upstream_digest": "sha256:" + "a" * 64,
148
+ "on_upstream_change": "warn",
149
+ }
150
+ }
151
+ },
152
+ }
153
+ )
154
+
155
+ override = manifest.components.skills["branch-review"]
156
+ assert override.mode == "patch"
157
+ assert override.base_path == "skills/branch-review/SKILL.base.md"
158
+ assert (
159
+ PluginOverrideManifest.model_validate_json(manifest.model_dump_json())
160
+ == manifest
161
+ )
162
+
163
+
164
+ def test_skill_patch_mode_requires_path_base_path_and_digest() -> None:
165
+ base = {
166
+ "mode": "patch",
167
+ "path": "skills/branch-review/SKILL.md",
168
+ "base_path": "skills/branch-review/SKILL.base.md",
169
+ "upstream_digest": "sha256:" + "a" * 64,
170
+ }
171
+ for missing in ("path", "base_path", "upstream_digest"):
172
+ payload = {key: value for key, value in base.items() if key != missing}
173
+ with pytest.raises(ValidationError):
174
+ PluginOverrideManifest.model_validate(
175
+ {
176
+ "schema_version": 1,
177
+ "plugin": "workstate-system",
178
+ "components": {"skills": {"branch-review": payload}},
179
+ }
180
+ )
181
+
182
+
183
+ def test_skill_non_patch_modes_reject_base_path() -> None:
184
+ with pytest.raises(ValidationError):
185
+ PluginOverrideManifest.model_validate(
186
+ {
187
+ "schema_version": 1,
188
+ "plugin": "workstate-system",
189
+ "components": {
190
+ "skills": {
191
+ "branch-review": {
192
+ "mode": "replace",
193
+ "path": "skills/branch-review/SKILL.md",
194
+ "base_path": "skills/branch-review/SKILL.base.md",
195
+ "upstream_digest": "sha256:" + "a" * 64,
196
+ }
197
+ }
198
+ },
199
+ }
200
+ )
201
+
202
+
203
+ def test_override_manifest_rejects_paths_outside_override_root() -> None:
204
+ base_skill = {
205
+ "mode": "patch",
206
+ "path": "skills/branch-review/SKILL.md",
207
+ "base_path": "skills/branch-review/SKILL.base.md",
208
+ "upstream_digest": "sha256:" + "a" * 64,
209
+ }
210
+
211
+ for field, value in (
212
+ ("path", "../outside/SKILL.md"),
213
+ ("path", "/tmp/SKILL.md"),
214
+ ("base_path", "skills/../../outside.base.md"),
215
+ ):
216
+ payload = {**base_skill, field: value}
217
+ with pytest.raises(ValidationError):
218
+ PluginOverrideManifest.model_validate(
219
+ {
220
+ "schema_version": 1,
221
+ "plugin": "workstate-system",
222
+ "components": {"skills": {"branch-review": payload}},
223
+ }
224
+ )
225
+
226
+ with pytest.raises(ValidationError):
227
+ PluginOverrideManifest.model_validate(
228
+ {
229
+ "schema_version": 1,
230
+ "plugin": "workstate-system",
231
+ "components": {
232
+ "mcp_servers": {
233
+ "workstate-handoff-mcp": {
234
+ "mode": "patch",
235
+ "patch_path": "../server.patch.yaml",
236
+ "requires_trust_ack": True,
237
+ }
238
+ }
239
+ },
240
+ }
241
+ )
242
+
243
+
244
+ def test_effective_lock_supports_merge_conflict_and_passthrough() -> None:
245
+ effective_lock = PluginEffectiveLock.model_validate(
246
+ {
247
+ "schema_version": 1,
248
+ "plugin": "workstate-system",
249
+ "base_remote_sha": "d" * 40,
250
+ "effective_root": ".workstate/generated/plugins/workstate-system/effective/claude",
251
+ "components": [
252
+ {
253
+ "component_kind": "skill",
254
+ "name": "branch-review",
255
+ "mode": "patch",
256
+ "effective_digest": "sha256:" + "e" * 64,
257
+ "status": "merge_conflict",
258
+ "override_path": "skills/branch-review/SKILL.md",
259
+ "recorded_upstream_digest": "sha256:" + "f" * 64,
260
+ "current_base_digest": "sha256:" + "0" * 64,
261
+ },
262
+ {
263
+ "component_kind": "skill",
264
+ "name": "scope",
265
+ "mode": "passthrough",
266
+ "effective_digest": "sha256:" + "1" * 64,
267
+ },
268
+ ],
269
+ }
270
+ )
271
+
272
+ statuses = [entry.status for entry in effective_lock.components]
273
+ modes = [entry.mode for entry in effective_lock.components]
274
+ assert statuses == ["merge_conflict", None]
275
+ assert modes == ["patch", "passthrough"]
276
+ assert (
277
+ PluginEffectiveLock.model_validate_json(effective_lock.model_dump_json())
278
+ == effective_lock
279
+ )
280
+
281
+
282
+ def test_override_lock_accept_upstream_provenance_roundtrip() -> None:
283
+ override_lock = PluginOverrideLock.model_validate(
284
+ {
285
+ "schema_version": 1,
286
+ "plugin": "workstate-system",
287
+ "base_remote_sha": "b" * 40,
288
+ "components": [
289
+ {
290
+ "component_kind": "skill",
291
+ "name": "branch-review",
292
+ "mode": "patch",
293
+ "local_path": "skills/branch-review/SKILL.md",
294
+ "base_path": "skills/branch-review/SKILL.base.md",
295
+ "upstream_digest": "sha256:" + "c" * 64,
296
+ "last_accept_upstream": {
297
+ "previous_upstream_digest": "sha256:" + "9" * 64,
298
+ "new_upstream_digest": "sha256:" + "c" * 64,
299
+ "accepted_at": "2026-06-04T06:00:00Z",
300
+ },
301
+ }
302
+ ],
303
+ }
304
+ )
305
+
306
+ entry = override_lock.components[0]
307
+ assert entry.last_accept_upstream is not None
308
+ assert entry.last_accept_upstream.new_upstream_digest == "sha256:" + "c" * 64
309
+ assert (
310
+ PluginOverrideLock.model_validate_json(override_lock.model_dump_json())
311
+ == override_lock
312
+ )
313
+
314
+
315
+ def test_generated_schema_artifacts_include_plugin_override_models() -> None:
316
+ artifact_dir = Path(__file__).resolve().parent.parent / "schemas"
317
+ expected = {
318
+ "plugin-override-manifest.json": PluginOverrideManifest,
319
+ "plugin-override-lock.json": PluginOverrideLock,
320
+ "plugin-effective-lock.json": PluginEffectiveLock,
321
+ "plugin-mcp-server-patch.json": PluginMcpServerPatch,
322
+ }
323
+
324
+ for file_name, model in expected.items():
325
+ schema = model.model_json_schema()
326
+ assert "schema_version" in schema["properties"]
327
+
328
+ on_disk = artifact_dir / file_name
329
+ persisted = json.loads(on_disk.read_text())
330
+ assert "schema_version" in persisted["properties"]
@@ -1,145 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- from pathlib import Path
5
-
6
- import pytest
7
- from pydantic import ValidationError
8
-
9
- from workstate_protocol.bootstrap import (
10
- PluginEffectiveLock,
11
- PluginMcpServerPatch,
12
- PluginOverrideLock,
13
- PluginOverrideManifest,
14
- )
15
-
16
-
17
- def test_override_manifest_accepts_skill_and_mcp_components() -> None:
18
- manifest = PluginOverrideManifest.model_validate(
19
- {
20
- "schema_version": 1,
21
- "plugin": "workstate-system",
22
- "components": {
23
- "skills": {
24
- "branch-review": {
25
- "mode": "replace",
26
- "path": "skills/branch-review/SKILL.md",
27
- "upstream_digest": "sha256:" + "a" * 64,
28
- "on_upstream_change": "warn",
29
- },
30
- "review-parallel": {
31
- "mode": "disable",
32
- },
33
- },
34
- "mcp_servers": {
35
- "workstate-handoff-mcp": {
36
- "mode": "patch",
37
- "patch_path": "tools/mcp_servers.patch.yaml",
38
- "requires_trust_ack": True,
39
- }
40
- },
41
- },
42
- }
43
- )
44
-
45
- assert manifest.plugin == "workstate-system"
46
- assert manifest.components.skills["branch-review"].mode == "replace"
47
- assert manifest.components.mcp_servers["workstate-handoff-mcp"].mode == "patch"
48
-
49
-
50
- def test_mcp_server_patch_accepts_typed_operations() -> None:
51
- patch = PluginMcpServerPatch.model_validate(
52
- {
53
- "schema_version": 1,
54
- "target_server": "workstate-handoff-mcp",
55
- "ops": [
56
- {"op": "replace_command", "value": "uvx"},
57
- {
58
- "op": "replace_args",
59
- "value": ["mcp-workstate-handoff@0.11.4", "--profile", "consumer"],
60
- },
61
- {"op": "upsert_env", "name": "HANDOFF_PROFILE", "value": "consumer"},
62
- {"op": "remove_env", "name": "LEGACY_FLAG"},
63
- ],
64
- }
65
- )
66
-
67
- assert [entry.op for entry in patch.ops] == [
68
- "replace_command",
69
- "replace_args",
70
- "upsert_env",
71
- "remove_env",
72
- ]
73
-
74
-
75
- def test_mcp_server_patch_rejects_wildcard_and_unsupported_file_replacement() -> None:
76
- with pytest.raises(ValidationError):
77
- PluginMcpServerPatch.model_validate(
78
- {
79
- "schema_version": 1,
80
- "target_server": "*",
81
- "ops": [
82
- {
83
- "op": "replace_file",
84
- "path": "../../../tmp/override.sh",
85
- "value": "hacked",
86
- }
87
- ],
88
- }
89
- )
90
-
91
-
92
- def test_lock_models_roundtrip() -> None:
93
- override_lock = PluginOverrideLock.model_validate(
94
- {
95
- "schema_version": 1,
96
- "plugin": "workstate-system",
97
- "base_remote_sha": "b" * 40,
98
- "components": [
99
- {
100
- "component_kind": "skill",
101
- "name": "branch-review",
102
- "mode": "replace",
103
- "local_path": "skills/branch-review/SKILL.md",
104
- "upstream_digest": "sha256:" + "c" * 64,
105
- }
106
- ],
107
- }
108
- )
109
- effective_lock = PluginEffectiveLock.model_validate(
110
- {
111
- "schema_version": 1,
112
- "plugin": "workstate-system",
113
- "base_remote_sha": "d" * 40,
114
- "effective_root": ".workstate/generated/plugins/workstate-system/effective/claude",
115
- "components": [
116
- {
117
- "component_kind": "skill",
118
- "name": "branch-review",
119
- "mode": "replace",
120
- "effective_digest": "sha256:" + "e" * 64,
121
- }
122
- ],
123
- }
124
- )
125
-
126
- assert PluginOverrideLock.model_validate_json(override_lock.model_dump_json()) == override_lock
127
- assert PluginEffectiveLock.model_validate_json(effective_lock.model_dump_json()) == effective_lock
128
-
129
-
130
- def test_generated_schema_artifacts_include_plugin_override_models() -> None:
131
- artifact_dir = Path(__file__).resolve().parent.parent / "schemas"
132
- expected = {
133
- "plugin-override-manifest.json": PluginOverrideManifest,
134
- "plugin-override-lock.json": PluginOverrideLock,
135
- "plugin-effective-lock.json": PluginEffectiveLock,
136
- "plugin-mcp-server-patch.json": PluginMcpServerPatch,
137
- }
138
-
139
- for file_name, model in expected.items():
140
- schema = model.model_json_schema()
141
- assert "schema_version" in schema["properties"]
142
-
143
- on_disk = artifact_dir / file_name
144
- persisted = json.loads(on_disk.read_text())
145
- assert "schema_version" in persisted["properties"]