workstate-protocol 0.1.5__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.
- workstate_protocol-0.1.5/PKG-INFO +37 -0
- workstate_protocol-0.1.5/README.md +22 -0
- workstate_protocol-0.1.5/pyproject.toml +31 -0
- workstate_protocol-0.1.5/setup.cfg +4 -0
- workstate_protocol-0.1.5/src/workstate_protocol/__init__.py +66 -0
- workstate_protocol-0.1.5/src/workstate_protocol/bootstrap.py +267 -0
- workstate_protocol-0.1.5/src/workstate_protocol/branch_naming.py +221 -0
- workstate_protocol-0.1.5/src/workstate_protocol/compaction.py +68 -0
- workstate_protocol-0.1.5/src/workstate_protocol/env_aliases.py +97 -0
- workstate_protocol-0.1.5/src/workstate_protocol/handoff.py +220 -0
- workstate_protocol-0.1.5/src/workstate_protocol/hooks.py +100 -0
- workstate_protocol-0.1.5/src/workstate_protocol/py.typed +0 -0
- workstate_protocol-0.1.5/src/workstate_protocol/skills.py +61 -0
- workstate_protocol-0.1.5/src/workstate_protocol.egg-info/PKG-INFO +37 -0
- workstate_protocol-0.1.5/src/workstate_protocol.egg-info/SOURCES.txt +24 -0
- workstate_protocol-0.1.5/src/workstate_protocol.egg-info/dependency_links.txt +1 -0
- workstate_protocol-0.1.5/src/workstate_protocol.egg-info/requires.txt +4 -0
- workstate_protocol-0.1.5/src/workstate_protocol.egg-info/top_level.txt +1 -0
- workstate_protocol-0.1.5/tests/test_bootstrap_manifest.py +68 -0
- workstate_protocol-0.1.5/tests/test_branch_grammar_registry.py +60 -0
- workstate_protocol-0.1.5/tests/test_branch_naming.py +268 -0
- workstate_protocol-0.1.5/tests/test_env_aliases.py +98 -0
- workstate_protocol-0.1.5/tests/test_handoff_schema.py +165 -0
- workstate_protocol-0.1.5/tests/test_package_metadata.py +56 -0
- workstate_protocol-0.1.5/tests/test_plugin_override_schema.py +145 -0
- workstate_protocol-0.1.5/tests/test_skill_manifest_real_skills.py +111 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: workstate-protocol
|
|
3
|
+
Version: 0.1.5
|
|
4
|
+
Summary: Typed cross-repo contracts for the Workstate system: handoff state, MCP I/O, hook events, skill manifests, bootstrap install manifest.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/darce/workstate
|
|
7
|
+
Project-URL: Source, https://github.com/darce/workstate/tree/main/packages/workstate-protocol
|
|
8
|
+
Project-URL: Changelog, https://github.com/darce/workstate/blob/main/packages/workstate-protocol/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/darce/workstate/issues
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: pydantic>=2.6
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# workstate-protocol
|
|
17
|
+
|
|
18
|
+
Single source of truth for cross-repo contracts in the Workstate system. Pydantic v2 is canonical; JSON Schema artifacts under `schemas/` are generated from the models so non-Python consumers (hook scripts, future TS/JS tooling) can validate without importing Python.
|
|
19
|
+
|
|
20
|
+
## Schemas (rolled out incrementally per founding implementation note)
|
|
21
|
+
|
|
22
|
+
| Status | Module | Schema |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| ✅ v0.1.0 | `workstate_protocol.handoff` | `HandoffState`, `ActiveTask`, `TaskRef`, `TargetWorktree`, `TaskPlanRef` |
|
|
25
|
+
| ✅ v0.1.0 | `workstate_protocol.branch_naming` | `TASK_REF_RE`, `derive_task_ref_candidates`, `format_suggested_branch_name` (single source of truth for the feature-branch grammar enforced by the post-checkout / PreToolUse / pre-commit / pre-push gates; cross-package consumers like `workstate_handoff_mcp` re-export by reference, never by literal copy — identity is the contract) |
|
|
26
|
+
| ⏳ | `workstate_protocol.mcp` | MCP tool I/O envelopes |
|
|
27
|
+
| ⏳ | `workstate_protocol.hooks` | `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop` |
|
|
28
|
+
| ⏳ | `workstate_protocol.skills` | `SkillManifest` (with `scope: harness | project`) |
|
|
29
|
+
| ⏳ | `workstate_protocol.bootstrap` | Bootstrap install manifest |
|
|
30
|
+
|
|
31
|
+
## Generated artifacts
|
|
32
|
+
|
|
33
|
+
`schemas/*.json` is regenerated by `scripts/generate_schemas.py` and committed. Consumers that don't want to import Python can read the JSON Schema directly.
|
|
34
|
+
|
|
35
|
+
## Versioning
|
|
36
|
+
|
|
37
|
+
Hard-pin major version per consumer. Bootstrap resolves a compatible quartet of (handoff, orchestrator, bootstrap, system) against a single `workstate-protocol` major.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# workstate-protocol
|
|
2
|
+
|
|
3
|
+
Single source of truth for cross-repo contracts in the Workstate system. Pydantic v2 is canonical; JSON Schema artifacts under `schemas/` are generated from the models so non-Python consumers (hook scripts, future TS/JS tooling) can validate without importing Python.
|
|
4
|
+
|
|
5
|
+
## Schemas (rolled out incrementally per founding implementation note)
|
|
6
|
+
|
|
7
|
+
| Status | Module | Schema |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| ✅ v0.1.0 | `workstate_protocol.handoff` | `HandoffState`, `ActiveTask`, `TaskRef`, `TargetWorktree`, `TaskPlanRef` |
|
|
10
|
+
| ✅ v0.1.0 | `workstate_protocol.branch_naming` | `TASK_REF_RE`, `derive_task_ref_candidates`, `format_suggested_branch_name` (single source of truth for the feature-branch grammar enforced by the post-checkout / PreToolUse / pre-commit / pre-push gates; cross-package consumers like `workstate_handoff_mcp` re-export by reference, never by literal copy — identity is the contract) |
|
|
11
|
+
| ⏳ | `workstate_protocol.mcp` | MCP tool I/O envelopes |
|
|
12
|
+
| ⏳ | `workstate_protocol.hooks` | `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop` |
|
|
13
|
+
| ⏳ | `workstate_protocol.skills` | `SkillManifest` (with `scope: harness | project`) |
|
|
14
|
+
| ⏳ | `workstate_protocol.bootstrap` | Bootstrap install manifest |
|
|
15
|
+
|
|
16
|
+
## Generated artifacts
|
|
17
|
+
|
|
18
|
+
`schemas/*.json` is regenerated by `scripts/generate_schemas.py` and committed. Consumers that don't want to import Python can read the JSON Schema directly.
|
|
19
|
+
|
|
20
|
+
## Versioning
|
|
21
|
+
|
|
22
|
+
Hard-pin major version per consumer. Bootstrap resolves a compatible quartet of (handoff, orchestrator, bootstrap, system) against a single `workstate-protocol` major.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "workstate-protocol"
|
|
7
|
+
version = "0.1.5"
|
|
8
|
+
description = "Typed cross-repo contracts for the Workstate system: handoff state, MCP I/O, hook events, skill manifests, bootstrap install manifest."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = [
|
|
13
|
+
"pydantic>=2.6",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://github.com/darce/workstate"
|
|
18
|
+
Source = "https://github.com/darce/workstate/tree/main/packages/workstate-protocol"
|
|
19
|
+
Changelog = "https://github.com/darce/workstate/blob/main/packages/workstate-protocol/CHANGELOG.md"
|
|
20
|
+
Issues = "https://github.com/darce/workstate/issues"
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
dev = [
|
|
24
|
+
"pytest>=8",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["src"]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.package-data]
|
|
31
|
+
workstate_protocol = ["py.typed"]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""workstate-protocol: typed cross-repo contracts for the Workstate system.
|
|
2
|
+
|
|
3
|
+
Pydantic v2 models that consumer packages (mcp-workstate-handoff,
|
|
4
|
+
mcp-workstate-orchestrator, workstate-bootstrap, workstate-system) import to
|
|
5
|
+
guarantee wire-level compatibility across out-of-process boundaries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from . import branch_naming as branch_naming # re-exported submodule
|
|
11
|
+
from .bootstrap import BootstrapManifest, OverlayConfigEntry, OverlaySurface
|
|
12
|
+
from .branch_naming import (
|
|
13
|
+
TASK_REF_RE,
|
|
14
|
+
derive_task_ref_candidates,
|
|
15
|
+
format_suggested_branch_name,
|
|
16
|
+
)
|
|
17
|
+
from .compaction import DecisionRef, StructuredSummary, TurnRange
|
|
18
|
+
from .env_aliases import resolve_env_alias
|
|
19
|
+
from .handoff import (
|
|
20
|
+
ActiveTask,
|
|
21
|
+
HandoffState,
|
|
22
|
+
HandoffStatus,
|
|
23
|
+
TargetWorktree,
|
|
24
|
+
TaskPlanRef,
|
|
25
|
+
TaskPlanResolution,
|
|
26
|
+
TaskRef,
|
|
27
|
+
)
|
|
28
|
+
from .hooks import (
|
|
29
|
+
PostToolUseEvent,
|
|
30
|
+
PreToolUseEvent,
|
|
31
|
+
SessionStartEvent,
|
|
32
|
+
StopEvent,
|
|
33
|
+
UserPromptSubmitEvent,
|
|
34
|
+
)
|
|
35
|
+
from .skills import SkillManifest, SkillScope
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.5"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"ActiveTask",
|
|
41
|
+
"BootstrapManifest",
|
|
42
|
+
"DecisionRef",
|
|
43
|
+
"HandoffState",
|
|
44
|
+
"HandoffStatus",
|
|
45
|
+
"OverlayConfigEntry",
|
|
46
|
+
"OverlaySurface",
|
|
47
|
+
"PostToolUseEvent",
|
|
48
|
+
"PreToolUseEvent",
|
|
49
|
+
"SessionStartEvent",
|
|
50
|
+
"SkillManifest",
|
|
51
|
+
"SkillScope",
|
|
52
|
+
"StopEvent",
|
|
53
|
+
"StructuredSummary",
|
|
54
|
+
"TASK_REF_RE",
|
|
55
|
+
"TargetWorktree",
|
|
56
|
+
"TaskPlanRef",
|
|
57
|
+
"TaskPlanResolution",
|
|
58
|
+
"TaskRef",
|
|
59
|
+
"TurnRange",
|
|
60
|
+
"UserPromptSubmitEvent",
|
|
61
|
+
"__version__",
|
|
62
|
+
"branch_naming",
|
|
63
|
+
"derive_task_ref_candidates",
|
|
64
|
+
"format_suggested_branch_name",
|
|
65
|
+
"resolve_env_alias",
|
|
66
|
+
]
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Bootstrap install manifest schema (Schema #5 from founding implementation note).
|
|
2
|
+
|
|
3
|
+
The wire shape ``workstate-bootstrap`` writes to
|
|
4
|
+
``<target>/.workstate-overlay.json``. Captures the contract between
|
|
5
|
+
bootstrap and target repos so target-side guardrails (lint hoisted
|
|
6
|
+
paths, harness sync check, drift detectors) can validate against a
|
|
7
|
+
single typed shape rather than per-key heuristics.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Annotated, Literal
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
Sha40 = Annotated[str, StringConstraints(pattern=r"^[0-9a-f]{40}$")]
|
|
18
|
+
Sha256Digest = Annotated[str, StringConstraints(pattern=r"^sha256:[0-9a-f]{64}$")]
|
|
19
|
+
PluginComponentName = Annotated[
|
|
20
|
+
str,
|
|
21
|
+
StringConstraints(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]*$"),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OverlaySurface(BaseModel):
|
|
26
|
+
"""A single overlay surface entry (e.g. skills, commands, hooks)."""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(extra="allow")
|
|
29
|
+
|
|
30
|
+
path: str = Field(description="Repo-relative surface path (e.g. '.claude/skills/handoff-lifecycle').")
|
|
31
|
+
source: Literal["shared", "local", "overlapping", "generated", "lifecycle"] = Field(
|
|
32
|
+
description=(
|
|
33
|
+
"Origin tier: 'shared' (symlinked from workstate-system), "
|
|
34
|
+
"'local' (target-owned), 'overlapping' (target overrides shared), "
|
|
35
|
+
"'generated' (per-agent surface produced by generate_agent_workflows.py "
|
|
36
|
+
"during install — implementation note step 1), "
|
|
37
|
+
"'lifecycle' (implementation note hoisted Make fragment + runner package "
|
|
38
|
+
"— copied, not symlinked, so the consumer can run `make context` "
|
|
39
|
+
"without the workstate-system packaging tree)."
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class OverlayConfigEntry(BaseModel):
|
|
45
|
+
"""A consumer-tool config that bootstrap wrote (e.g. .vscode/mcp.json).
|
|
46
|
+
|
|
47
|
+
``action`` is the string the install flow emits today
|
|
48
|
+
(``created``, ``updated``, ``set``, ``unchanged``). Modeled as a
|
|
49
|
+
free string rather than a Literal so the contract does not block
|
|
50
|
+
bootstrap from introducing new actions before the protocol bumps.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
model_config = ConfigDict(extra="allow")
|
|
54
|
+
|
|
55
|
+
path: str
|
|
56
|
+
action: str = Field(
|
|
57
|
+
min_length=1,
|
|
58
|
+
description="What bootstrap did to the config (e.g. 'created', 'updated', 'set', 'unchanged').",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class PluginSkillOverride(BaseModel):
|
|
63
|
+
"""Override contract for one plugin skill body."""
|
|
64
|
+
|
|
65
|
+
model_config = ConfigDict(extra="forbid")
|
|
66
|
+
|
|
67
|
+
mode: Literal["replace", "disable", "add"]
|
|
68
|
+
path: str | None = None
|
|
69
|
+
upstream_digest: Sha256Digest | None = None
|
|
70
|
+
on_upstream_change: Literal["warn", "error", "ignore"] | None = None
|
|
71
|
+
|
|
72
|
+
@model_validator(mode="after")
|
|
73
|
+
def validate_mode_fields(self) -> PluginSkillOverride:
|
|
74
|
+
if self.mode in {"replace", "add"} and not self.path:
|
|
75
|
+
raise ValueError("path is required when mode is replace or add")
|
|
76
|
+
if self.mode == "replace" and self.upstream_digest is None:
|
|
77
|
+
raise ValueError("upstream_digest is required when mode is replace")
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class PluginMcpServerOverride(BaseModel):
|
|
82
|
+
"""Override contract for one MCP server entry in the plugin manifest."""
|
|
83
|
+
|
|
84
|
+
model_config = ConfigDict(extra="forbid")
|
|
85
|
+
|
|
86
|
+
mode: Literal["patch", "disable", "add"]
|
|
87
|
+
patch_path: str | None = None
|
|
88
|
+
requires_trust_ack: bool = False
|
|
89
|
+
|
|
90
|
+
@model_validator(mode="after")
|
|
91
|
+
def validate_mode_fields(self) -> PluginMcpServerOverride:
|
|
92
|
+
if self.mode in {"patch", "add"} and not self.patch_path:
|
|
93
|
+
raise ValueError("patch_path is required when mode is patch or add")
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class PluginOverrideComponents(BaseModel):
|
|
98
|
+
"""Declared override components grouped by plugin surface kind."""
|
|
99
|
+
|
|
100
|
+
model_config = ConfigDict(extra="forbid")
|
|
101
|
+
|
|
102
|
+
skills: dict[PluginComponentName, PluginSkillOverride] = Field(default_factory=dict)
|
|
103
|
+
mcp_servers: dict[PluginComponentName, PluginMcpServerOverride] = Field(
|
|
104
|
+
default_factory=dict
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class PluginOverrideManifest(BaseModel):
|
|
109
|
+
"""Tracked, repo-owned plugin override manifest."""
|
|
110
|
+
|
|
111
|
+
model_config = ConfigDict(extra="forbid")
|
|
112
|
+
|
|
113
|
+
schema_version: Literal[1] = 1
|
|
114
|
+
plugin: PluginComponentName
|
|
115
|
+
components: PluginOverrideComponents = Field(default_factory=PluginOverrideComponents)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ReplaceCommandOp(BaseModel):
|
|
119
|
+
model_config = ConfigDict(extra="forbid")
|
|
120
|
+
|
|
121
|
+
op: Literal["replace_command"]
|
|
122
|
+
value: str = Field(min_length=1)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ReplaceArgsOp(BaseModel):
|
|
126
|
+
model_config = ConfigDict(extra="forbid")
|
|
127
|
+
|
|
128
|
+
op: Literal["replace_args"]
|
|
129
|
+
value: list[str] = Field(min_length=1)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class AppendArgsOp(BaseModel):
|
|
133
|
+
model_config = ConfigDict(extra="forbid")
|
|
134
|
+
|
|
135
|
+
op: Literal["append_args"]
|
|
136
|
+
value: list[str] = Field(min_length=1)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class UpsertEnvOp(BaseModel):
|
|
140
|
+
model_config = ConfigDict(extra="forbid")
|
|
141
|
+
|
|
142
|
+
op: Literal["upsert_env"]
|
|
143
|
+
name: PluginComponentName
|
|
144
|
+
value: str
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class RemoveEnvOp(BaseModel):
|
|
148
|
+
model_config = ConfigDict(extra="forbid")
|
|
149
|
+
|
|
150
|
+
op: Literal["remove_env"]
|
|
151
|
+
name: PluginComponentName
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class DisableServerOp(BaseModel):
|
|
155
|
+
model_config = ConfigDict(extra="forbid")
|
|
156
|
+
|
|
157
|
+
op: Literal["disable_server"]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
PluginMcpPatchOperation = Annotated[
|
|
161
|
+
ReplaceCommandOp
|
|
162
|
+
| ReplaceArgsOp
|
|
163
|
+
| AppendArgsOp
|
|
164
|
+
| UpsertEnvOp
|
|
165
|
+
| RemoveEnvOp
|
|
166
|
+
| DisableServerOp,
|
|
167
|
+
Field(discriminator="op"),
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class PluginMcpServerPatch(BaseModel):
|
|
172
|
+
"""Typed patch grammar for MCP server mutations in consumer overrides."""
|
|
173
|
+
|
|
174
|
+
model_config = ConfigDict(extra="forbid")
|
|
175
|
+
|
|
176
|
+
schema_version: Literal[1] = 1
|
|
177
|
+
target_server: PluginComponentName
|
|
178
|
+
ops: list[PluginMcpPatchOperation] = Field(min_length=1)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class PluginOverrideLockEntry(BaseModel):
|
|
182
|
+
"""Tracked provenance for one consumer-owned plugin override."""
|
|
183
|
+
|
|
184
|
+
model_config = ConfigDict(extra="forbid")
|
|
185
|
+
|
|
186
|
+
component_kind: Literal["skill", "command", "mcp_server"]
|
|
187
|
+
name: PluginComponentName
|
|
188
|
+
mode: Literal["replace", "disable", "add", "patch"]
|
|
189
|
+
local_path: str | None = None
|
|
190
|
+
patch_path: str | None = None
|
|
191
|
+
upstream_digest: Sha256Digest | None = None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class PluginOverrideLock(BaseModel):
|
|
195
|
+
"""Source-controlled lockfile for plugin overrides."""
|
|
196
|
+
|
|
197
|
+
model_config = ConfigDict(extra="forbid")
|
|
198
|
+
|
|
199
|
+
schema_version: Literal[1] = 1
|
|
200
|
+
plugin: PluginComponentName
|
|
201
|
+
base_remote_sha: Sha40
|
|
202
|
+
components: list[PluginOverrideLockEntry] = Field(default_factory=list)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class PluginEffectiveLockEntry(BaseModel):
|
|
206
|
+
"""Generated receipt entry for one component in the effective plugin tree."""
|
|
207
|
+
|
|
208
|
+
model_config = ConfigDict(extra="forbid")
|
|
209
|
+
|
|
210
|
+
component_kind: Literal["skill", "command", "mcp_server"]
|
|
211
|
+
name: PluginComponentName
|
|
212
|
+
mode: Literal["replace", "disable", "add", "patch"]
|
|
213
|
+
effective_digest: Sha256Digest
|
|
214
|
+
status: Literal["stale"] | None = None
|
|
215
|
+
override_path: str | None = None
|
|
216
|
+
recorded_upstream_digest: Sha256Digest | None = None
|
|
217
|
+
current_base_digest: Sha256Digest | None = None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class PluginEffectiveLock(BaseModel):
|
|
221
|
+
"""Generated receipt describing the composed effective plugin tree."""
|
|
222
|
+
|
|
223
|
+
model_config = ConfigDict(extra="forbid")
|
|
224
|
+
|
|
225
|
+
schema_version: Literal[1] = 1
|
|
226
|
+
plugin: PluginComponentName
|
|
227
|
+
base_remote_sha: Sha40
|
|
228
|
+
effective_root: str = Field(min_length=1)
|
|
229
|
+
components: list[PluginEffectiveLockEntry] = Field(default_factory=list)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class BootstrapManifest(BaseModel):
|
|
233
|
+
"""Top-level shape of ``.workstate-overlay.json``.
|
|
234
|
+
|
|
235
|
+
Validated on write by ``workstate-bootstrap.install`` so the file
|
|
236
|
+
cannot drift silently. Consumers (drift detectors, doctor commands)
|
|
237
|
+
parse the same model on read.
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
model_config = ConfigDict(extra="allow")
|
|
241
|
+
|
|
242
|
+
schema_version: int = Field(ge=1, description="Manifest schema version.")
|
|
243
|
+
remote_url: str = Field(min_length=1)
|
|
244
|
+
remote_ref: str = Field(min_length=1)
|
|
245
|
+
remote_sha: Sha40 = Field(
|
|
246
|
+
description="Resolved 40-char git SHA at install time.",
|
|
247
|
+
)
|
|
248
|
+
surfaces: list[OverlaySurface] = Field(default_factory=list)
|
|
249
|
+
configs: list[OverlayConfigEntry] = Field(default_factory=list)
|
|
250
|
+
mcp_servers: list[str] = Field(
|
|
251
|
+
default_factory=list,
|
|
252
|
+
description=(
|
|
253
|
+
"Sorted list of managed MCP server names bootstrap last wrote "
|
|
254
|
+
"into ``.mcp.json`` / ``.vscode/mcp.json`` / ``.codex/config.toml``. "
|
|
255
|
+
"Read by ``sync_mcp_configs(prune_removed_managed=True)`` as the "
|
|
256
|
+
"authoritative previously-managed provenance so third-party "
|
|
257
|
+
"launchers in those files are preserved across syncs."
|
|
258
|
+
),
|
|
259
|
+
)
|
|
260
|
+
plugin_overrides_path: str | None = Field(
|
|
261
|
+
default=None,
|
|
262
|
+
description=(
|
|
263
|
+
"Optional explicit plugin override root recorded by bootstrap so "
|
|
264
|
+
"later doctor/update/repair runs can reuse a non-default WORKSTATE-REF-03 "
|
|
265
|
+
"override location."
|
|
266
|
+
),
|
|
267
|
+
)
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Canonical feature-branch naming rule.
|
|
2
|
+
|
|
3
|
+
This module is the SOLE owner of ``TASK_REF_RE``,
|
|
4
|
+
``derive_task_ref_candidates`` and ``format_suggested_branch_name``.
|
|
5
|
+
Every gate (post-checkout warn, PreToolUse block, pre-commit hard gate,
|
|
6
|
+
pre-push mirror) imports from here. ``workstate_handoff_mcp`` re-exports
|
|
7
|
+
the same objects without redefinition. See implementation note for context.
|
|
8
|
+
|
|
9
|
+
Case convention
|
|
10
|
+
---------------
|
|
11
|
+
|
|
12
|
+
Branches are lowercase (``feature/WORKSTATE-37-foo``). Task refs in the
|
|
13
|
+
Workstate handoff task table are uppercase (``WORKSTATE-REF-37``).
|
|
14
|
+
``derive_task_ref_candidates`` returns lowercase candidates; callers
|
|
15
|
+
``.upper()`` each candidate before intersecting against the live task
|
|
16
|
+
table. ``format_suggested_branch_name`` accepts task refs in either
|
|
17
|
+
case and lowercases its output so the formatted suggestion always
|
|
18
|
+
matches ``TASK_REF_RE``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from collections.abc import Iterable
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"BRANCH_GRAMMAR_REGISTRY",
|
|
29
|
+
"BranWORKSTATElassification",
|
|
30
|
+
"BranchGrammarEntry",
|
|
31
|
+
"TASK_REF_RE",
|
|
32
|
+
"__protocol_version__",
|
|
33
|
+
"classify_branch",
|
|
34
|
+
"derive_task_ref_candidates",
|
|
35
|
+
"format_suggested_branch_name",
|
|
36
|
+
"is_allowed_branch",
|
|
37
|
+
"select_task_ref_candidate",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
__protocol_version__ = "1"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
TASK_REF_RE: re.Pattern[str] = re.compile(
|
|
44
|
+
r"^feature/"
|
|
45
|
+
# Task ref must start with a letter so leading-digit segments
|
|
46
|
+
# ("123-foo") and leading hyphens ("-x") are rejected.
|
|
47
|
+
r"(?=[a-z])"
|
|
48
|
+
# Must contain at least one digit somewhere in the matched group so
|
|
49
|
+
# purely alphabetic refs ("foo-bar", "no-digits-here") are rejected.
|
|
50
|
+
r"(?=[a-z0-9-]*\d)"
|
|
51
|
+
# >=2 hyphen-separated lowercase / digit segments; each segment is
|
|
52
|
+
# non-empty. The alternation enforces a `<prefix>-<rest>` shape so
|
|
53
|
+
# single-word branches like ``feature/foo`` are rejected and
|
|
54
|
+
# conventional task refs (``feature/WORKSTATE-37``,
|
|
55
|
+
# ``feature/maint-dirty-br-01``) still match.
|
|
56
|
+
r"(?P<task_ref>[a-z0-9]+(?:-[a-z0-9]+)+)"
|
|
57
|
+
r"$"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def derive_task_ref_candidates(branch_name: str) -> list[str]:
|
|
62
|
+
"""Return the lowercase candidate task refs implied by a branch name.
|
|
63
|
+
|
|
64
|
+
Returns an empty list for non-conforming branch names. For
|
|
65
|
+
conforming names the algorithm walks progressively shorter prefixes
|
|
66
|
+
of the matched ``task_ref`` group, dropping prefixes that no longer
|
|
67
|
+
contain a digit. Callers ``.upper()`` each candidate before
|
|
68
|
+
intersecting against the live task table.
|
|
69
|
+
|
|
70
|
+
Examples
|
|
71
|
+
--------
|
|
72
|
+
>>> derive_task_ref_candidates("feature/WORKSTATE-37-branch-naming-enforcement")
|
|
73
|
+
['WORKSTATE-37-branch-naming-enforcement', 'WORKSTATE-37']
|
|
74
|
+
>>> derive_task_ref_candidates("feature/maint-dirty-br-01")
|
|
75
|
+
['maint-dirty-br-01', 'maint-dirty-br']
|
|
76
|
+
>>> derive_task_ref_candidates("fix/foo")
|
|
77
|
+
[]
|
|
78
|
+
"""
|
|
79
|
+
match = TASK_REF_RE.match(branch_name)
|
|
80
|
+
if match is None:
|
|
81
|
+
return []
|
|
82
|
+
full = match.group("task_ref")
|
|
83
|
+
segments = full.split("-")
|
|
84
|
+
candidates: list[str] = []
|
|
85
|
+
for end in range(len(segments), 0, -1):
|
|
86
|
+
candidate = "-".join(segments[:end])
|
|
87
|
+
if not _has_digit(candidate):
|
|
88
|
+
# Once we drop below the digit-bearing prefix, every shorter
|
|
89
|
+
# prefix is also digit-less and would never intersect with
|
|
90
|
+
# the live task table — stop walking.
|
|
91
|
+
break
|
|
92
|
+
candidates.append(candidate)
|
|
93
|
+
return candidates
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def select_task_ref_candidate(
|
|
97
|
+
branch_name: str,
|
|
98
|
+
known_task_refs: Iterable[str] | None = None,
|
|
99
|
+
) -> str | None:
|
|
100
|
+
"""Return the canonical UPPERCASE task ref for a branch.
|
|
101
|
+
|
|
102
|
+
- If ``known_task_refs`` is non-empty, returns the **first** candidate
|
|
103
|
+
from :func:`derive_task_ref_candidates` (longest-to-shortest) whose
|
|
104
|
+
uppercase form appears in ``known_task_refs``. This is the
|
|
105
|
+
"most-specific registered candidate wins" rule that lets
|
|
106
|
+
``feature/<base>-<n>-fu-...`` branches resolve to the follow-up ref
|
|
107
|
+
when both the base and the follow-up are registered. When the
|
|
108
|
+
registry is non-empty but no candidate intersects, returns
|
|
109
|
+
``None`` — the strict invariant is that we never name a candidate
|
|
110
|
+
that is absent from a populated registry (WORKSTATE65-BR-02).
|
|
111
|
+
- If ``known_task_refs`` is empty or ``None`` (no registry context
|
|
112
|
+
available at all), falls back to the shortest digit-bearing
|
|
113
|
+
prefix. This is the no-context degradation path that keeps
|
|
114
|
+
environments without a configured registry resolving identically
|
|
115
|
+
to the historical lifecycle mirror.
|
|
116
|
+
- Returns ``None`` when ``branch_name`` is non-conforming (no
|
|
117
|
+
candidates derivable).
|
|
118
|
+
"""
|
|
119
|
+
candidates = derive_task_ref_candidates(branch_name)
|
|
120
|
+
if not candidates:
|
|
121
|
+
return None
|
|
122
|
+
if known_task_refs is None:
|
|
123
|
+
return candidates[-1].upper()
|
|
124
|
+
known_upper = {ref.upper() for ref in known_task_refs}
|
|
125
|
+
if not known_upper:
|
|
126
|
+
return candidates[-1].upper()
|
|
127
|
+
for candidate in candidates:
|
|
128
|
+
if candidate.upper() in known_upper:
|
|
129
|
+
return candidate.upper()
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def format_suggested_branch_name(task_ref: str | None, *, slug: str | None = None) -> str | None:
|
|
134
|
+
"""Render a "did you mean ..." branch suggestion for a task ref.
|
|
135
|
+
|
|
136
|
+
Returns ``feature/<task-ref>`` (with optional ``-<slug>``) lowercased
|
|
137
|
+
so the result is guaranteed to match ``TASK_REF_RE``. When
|
|
138
|
+
``task_ref`` is empty / ``None`` (cold-start before ``task-start``),
|
|
139
|
+
returns ``None`` so the caller can fall back to a generic message
|
|
140
|
+
instead of crashing.
|
|
141
|
+
"""
|
|
142
|
+
if not task_ref:
|
|
143
|
+
return None
|
|
144
|
+
base = f"feature/{task_ref.lower()}"
|
|
145
|
+
if slug:
|
|
146
|
+
base = f"{base}-{slug.lower()}"
|
|
147
|
+
return base
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _has_digit(value: str) -> bool:
|
|
151
|
+
return any(ch.isdigit() for ch in value)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Branch grammar registry (implementation note implementation note)
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
#
|
|
158
|
+
# Canonical pattern (``feature/<task-ref>``) plus each documented
|
|
159
|
+
# exception lives here as a single tuple of ``BranchGrammarEntry``
|
|
160
|
+
# rows. Consumers (``check_branch_naming``, lifecycle resolver mirror,
|
|
161
|
+
# bootstrap pre-commit gate) read from this registry instead of
|
|
162
|
+
# hand-rolling exception lists.
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True)
|
|
166
|
+
class BranchGrammarEntry:
|
|
167
|
+
kind: str
|
|
168
|
+
regex: re.Pattern[str]
|
|
169
|
+
allowed_in: frozenset[str] = field(default_factory=frozenset)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True)
|
|
173
|
+
class BranWORKSTATElassification:
|
|
174
|
+
kind: str
|
|
175
|
+
branch: str
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
_RELEASE_RE = re.compile(r"^release/\d+(\.\d+){1,2}(?:-[a-z0-9]+)?$")
|
|
179
|
+
_HOTFIX_RE = re.compile(r"^hotfix/[a-z][a-z0-9-]*$")
|
|
180
|
+
_MAINT_RE = re.compile(r"^maint/[a-z][a-z0-9-]*$")
|
|
181
|
+
_REVERT_RE = re.compile(r"^revert/[a-z][a-z0-9-]*$")
|
|
182
|
+
_MAIN_RE = re.compile(r"^(main|master)$")
|
|
183
|
+
|
|
184
|
+
_ALL_MODES: frozenset[str] = frozenset({"post_checkout_warn", "pretooluse", "pre_commit", "pre_push"})
|
|
185
|
+
|
|
186
|
+
BRANCH_GRAMMAR_REGISTRY: tuple[BranchGrammarEntry, ...] = (
|
|
187
|
+
BranchGrammarEntry(kind="feature", regex=TASK_REF_RE, allowed_in=_ALL_MODES),
|
|
188
|
+
BranchGrammarEntry(kind="release", regex=_RELEASE_RE, allowed_in=_ALL_MODES),
|
|
189
|
+
BranchGrammarEntry(kind="hotfix", regex=_HOTFIX_RE, allowed_in=_ALL_MODES),
|
|
190
|
+
BranchGrammarEntry(kind="maint", regex=_MAINT_RE, allowed_in=_ALL_MODES),
|
|
191
|
+
BranchGrammarEntry(kind="revert", regex=_REVERT_RE, allowed_in=_ALL_MODES),
|
|
192
|
+
BranchGrammarEntry(kind="main", regex=_MAIN_RE, allowed_in=_ALL_MODES),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def classify_branch(name: str) -> BranWORKSTATElassification | None:
|
|
197
|
+
"""Return the :class:`BranWORKSTATElassification` for ``name`` or ``None``.
|
|
198
|
+
|
|
199
|
+
Unknown patterns return ``None`` (fail-closed); each consumer
|
|
200
|
+
decides whether unknown means warn or block.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
for entry in BRANCH_GRAMMAR_REGISTRY:
|
|
204
|
+
if entry.regex.match(name):
|
|
205
|
+
return BranWORKSTATElassification(kind=entry.kind, branch=name)
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def is_allowed_branch(name: str, *, mode: str) -> bool:
|
|
210
|
+
"""Return ``True`` when ``name`` matches a registered pattern allowed in ``mode``.
|
|
211
|
+
|
|
212
|
+
``mode`` is a free-form selector — consumers pass their gate name
|
|
213
|
+
(``post_checkout_warn``, ``pretooluse``, ``pre_commit``,
|
|
214
|
+
``pre_push``) and the registry's per-entry ``allowed_in`` set
|
|
215
|
+
decides whether to admit it.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
for entry in BRANCH_GRAMMAR_REGISTRY:
|
|
219
|
+
if entry.regex.match(name) and mode in entry.allowed_in:
|
|
220
|
+
return True
|
|
221
|
+
return False
|