workstate-protocol 0.1.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
@@ -0,0 +1,68 @@
1
+ """Compaction contract schemas for cross-harness session summaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Literal
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
9
+
10
+ from .handoff import TaskRef
11
+
12
+
13
+ class DecisionRef(BaseModel):
14
+ """Stable decision pointer captured in a compaction summary."""
15
+
16
+ model_config = ConfigDict(extra="forbid")
17
+
18
+ decision_id: str = Field(description="Stable decision identifier recorded in handoff.")
19
+ slug: str = Field(description="Human-readable decision slug used for quick inspection.")
20
+
21
+
22
+ class TurnRange(BaseModel):
23
+ """Inclusive turn range covered by the compaction summary."""
24
+
25
+ model_config = ConfigDict(extra="forbid")
26
+
27
+ start_turn: int = Field(ge=1, description="Inclusive 1-indexed first turn covered by the summary.")
28
+ end_turn: int = Field(ge=1, description="Inclusive 1-indexed last turn covered by the summary.")
29
+
30
+ @model_validator(mode="after")
31
+ def _validate_bounds(self) -> "TurnRange":
32
+ if self.end_turn < self.start_turn:
33
+ raise ValueError("end_turn must be greater than or equal to start_turn")
34
+ return self
35
+
36
+
37
+ class StructuredSummary(BaseModel):
38
+ """Durable structured summary stored for cross-harness compaction."""
39
+
40
+ model_config = ConfigDict(extra="forbid")
41
+
42
+ compaction_id: str = Field(description="Server-generated stable compaction handle.")
43
+ session_id: str = Field(description="Harness session identifier for the compacted transcript span.")
44
+ harness: Literal["claude-code", "codex", "vscode", "manual"] = Field(
45
+ description="Harness that produced the compaction record."
46
+ )
47
+ task_ref: TaskRef
48
+ turn_range: TurnRange
49
+ decisions: list[DecisionRef] = Field(default_factory=list)
50
+ findings_fixed: list[str] = Field(default_factory=list)
51
+ findings_opened: list[str] = Field(default_factory=list)
52
+ tests_verified: list[str] = Field(default_factory=list)
53
+ files_touched: list[str] = Field(default_factory=list)
54
+ prose_residual: str | None = Field(
55
+ default=None,
56
+ description="Narrative spans that the extractor could not resolve to structured IDs.",
57
+ )
58
+ created_at: datetime = Field(description="Timestamp when the compaction row was created.")
59
+
60
+ @field_validator("harness", mode="before")
61
+ @classmethod
62
+ def _normalize_harness(cls, value: object) -> object:
63
+ if not isinstance(value, str):
64
+ return value
65
+ normalized = value.strip().lower()
66
+ if normalized == "cursor":
67
+ return "vscode"
68
+ return normalized
@@ -0,0 +1,97 @@
1
+ """Tier-4 env-var alias resolution for the Workstate rebrand.
2
+
3
+ implementation note Slice C / B4. During the one-release cutover from the legacy
4
+ ``AGENT_HANDOFF_*`` / ``AGENT_ORCHESTRATOR_*`` / ``AGENTIC_*`` /
5
+ ``MCP_AGENT_HANDOFF_*`` env-var names to the canonical ``WORKSTATE_*`` prefix,
6
+ :func:`resolve_env_alias` reads the new name first, then falls back to any
7
+ legacy alias, emitting exactly one :class:`DeprecationWarning` per legacy name
8
+ that is actually read. The write side (exports, generated config) always sets
9
+ the new ``WORKSTATE_*`` name only — this module is read-side compatibility so
10
+ existing shells / CI that still export the old names keep working through the
11
+ cutover release, with a nudge to migrate.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import warnings
18
+ from collections.abc import Mapping
19
+ from typing import overload
20
+
21
+ __all__ = ["resolve_env_alias", "reset_alias_warnings"]
22
+
23
+ # Legacy names already warned about, so the deprecation nudge fires once per
24
+ # process regardless of how many call sites read the same var.
25
+ _warned_legacy: set[str] = set()
26
+
27
+
28
+ def reset_alias_warnings() -> None:
29
+ """Clear the warn-once ledger. Intended for tests."""
30
+
31
+ _warned_legacy.clear()
32
+
33
+
34
+ def _non_empty(value: str | None) -> str | None:
35
+ if value is None:
36
+ return None
37
+ stripped = value.strip()
38
+ return stripped or None
39
+
40
+
41
+ @overload
42
+ def resolve_env_alias(
43
+ canonical: str,
44
+ *legacy: str,
45
+ env: Mapping[str, str] | None = ...,
46
+ default: str,
47
+ ) -> str: ...
48
+
49
+
50
+ @overload
51
+ def resolve_env_alias(
52
+ canonical: str,
53
+ *legacy: str,
54
+ env: Mapping[str, str] | None = ...,
55
+ default: None = ...,
56
+ ) -> str | None: ...
57
+
58
+
59
+ def resolve_env_alias(
60
+ canonical: str,
61
+ *legacy: str,
62
+ env: Mapping[str, str] | None = None,
63
+ default: str | None = None,
64
+ ) -> str | None:
65
+ """Resolve an env var across its canonical and legacy alias names.
66
+
67
+ Precedence: the canonical ``WORKSTATE_*`` name wins when set to a
68
+ non-blank value. Otherwise the first non-blank legacy alias (in the order
69
+ given) is returned, and a :class:`DeprecationWarning` is emitted once per
70
+ process for that legacy name, naming the ``canonical`` replacement. When
71
+ nothing is set, ``default`` is returned.
72
+
73
+ Blank/whitespace-only values are treated as unset, matching the existing
74
+ ``_first_non_empty_env`` behaviour in the handoff package.
75
+ """
76
+
77
+ source = os.environ if env is None else env
78
+
79
+ canonical_value = _non_empty(source.get(canonical))
80
+ if canonical_value is not None:
81
+ return canonical_value
82
+
83
+ for name in legacy:
84
+ legacy_value = _non_empty(source.get(name))
85
+ if legacy_value is not None:
86
+ if name not in _warned_legacy:
87
+ _warned_legacy.add(name)
88
+ warnings.warn(
89
+ f"Environment variable {name} is deprecated; "
90
+ f"set {canonical} instead. The legacy name is read for "
91
+ f"one release and will be removed.",
92
+ DeprecationWarning,
93
+ stacklevel=2,
94
+ )
95
+ return legacy_value
96
+
97
+ return default
@@ -0,0 +1,220 @@
1
+ """Handoff state schemas (Schema #1 from founding implementation note).
2
+
3
+ These are the cross-repo contract types for handoff state. They model
4
+ the wire shape that ``mcp-workstate-handoff`` exposes to MCP clients and
5
+ that ``mcp-workstate-orchestrator`` consumes — not the on-disk SQLite row
6
+ shape, which is an implementation detail.
7
+
8
+ Compatibility note: ``ActiveTask`` is permissive on extra keys
9
+ (``extra='allow'``) because the underlying handoff DB row carries
10
+ columns that this schema does not yet model (revision metadata, lane
11
+ metadata, etc.). As subsequent schema slices land, those fields
12
+ graduate from passthrough to typed.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from enum import Enum
18
+ from typing import Annotated
19
+
20
+ from pydantic import BaseModel, ConfigDict, Field, StringConstraints
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Primitive identifiers and enums
24
+ # ---------------------------------------------------------------------------
25
+
26
+ # A task_ref is a short identifier like "WORKSTATE-REF-17-14" or "WORKSTATE-REF-7" or "AAA-1".
27
+ # We require non-empty string with a permissive character class so domain
28
+ # tools can introduce new prefixes without re-releasing the protocol.
29
+ TaskRef = Annotated[
30
+ str,
31
+ StringConstraints(strip_whitespace=True, min_length=1, max_length=128),
32
+ Field(description="Short task identifier, e.g. 'WORKSTATE-REF-17-14' or 'WORKSTATE-REF-7'."),
33
+ ]
34
+
35
+
36
+ class HandoffStatus(str, Enum):
37
+ """Lifecycle status of a handoff row.
38
+
39
+ Mirrors the CHECK constraint on ``handoff_state.status`` in
40
+ ``mcp-workstate-handoff`` (``shared_schema.py``).
41
+ """
42
+
43
+ in_progress = "in_progress"
44
+ blocked = "blocked"
45
+ review = "review"
46
+ done = "done"
47
+
48
+
49
+ class TaskPlanResolution(str, Enum):
50
+ """How an absolute task-plan path was resolved at read time.
51
+
52
+ ``absolute``: the configured ``task_plan_path`` was already absolute.
53
+ ``worktree``: resolved against ``target_worktree_path``.
54
+ ``workspace``: resolved against the configured workspace root because
55
+ no ``target_worktree_path`` was set.
56
+ ``unresolved``: resolution failed (no worktree, no workspace).
57
+ """
58
+
59
+ absolute = "absolute"
60
+ worktree = "worktree"
61
+ workspace = "workspace"
62
+ unresolved = "unresolved"
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Composite models
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ class TargetWorktree(BaseModel):
71
+ """The branch + filesystem location where a task's work lives."""
72
+
73
+ model_config = ConfigDict(extra="forbid")
74
+
75
+ target_branch: str | None = Field(
76
+ default=None,
77
+ description="Git branch the task targets (e.g. 'feature/e17-14').",
78
+ )
79
+ target_worktree_path: str | None = Field(
80
+ default=None,
81
+ description="Absolute path to the worktree where the task's branch is checked out.",
82
+ )
83
+
84
+
85
+ class TaskPlanRef(BaseModel):
86
+ """Structured pointer to a task's planning artifact.
87
+
88
+ Replaces inferring plan paths from the freeform ``focus`` field.
89
+ The triple (path, abs_path, exists, resolution) is computed by the
90
+ handoff runtime against ``target_worktree_path`` and surfaced on
91
+ every active row so the root workspace can discover and open plans
92
+ living in sibling worktrees without switching the root checkout.
93
+ """
94
+
95
+ model_config = ConfigDict(extra="forbid")
96
+
97
+ task_plan_path: str = Field(
98
+ description="Repo-relative path (or absolute path) to the planning artifact."
99
+ )
100
+ task_plan_abs_path: str | None = Field(
101
+ default=None,
102
+ description="Absolute path resolved against the target worktree.",
103
+ )
104
+ task_plan_exists: bool | None = Field(
105
+ default=None,
106
+ description="Whether the resolved absolute path is a regular file at read time.",
107
+ )
108
+ task_plan_resolution: TaskPlanResolution | None = Field(
109
+ default=None,
110
+ description="How task_plan_abs_path was derived.",
111
+ )
112
+
113
+
114
+ class ActiveTask(BaseModel):
115
+ """Wire shape for a single active handoff row.
116
+
117
+ Carries identifiers, status, optional target worktree, optional
118
+ structured task-plan reference, and a revision counter used for
119
+ optimistic concurrency control. ``extra='allow'`` lets older or
120
+ newer columns (lane metadata, audit fields) round-trip without
121
+ requiring a coordinated bump of every consumer.
122
+ """
123
+
124
+ model_config = ConfigDict(extra="allow")
125
+
126
+ task_ref: TaskRef
127
+ objective: str = Field(description="Human-readable task objective.")
128
+ focus: str | None = Field(
129
+ default=None,
130
+ description="Freeform context for the current iteration. Not a plan-path source — see task_plan_path.",
131
+ )
132
+ status: HandoffStatus = HandoffStatus.in_progress
133
+ revision: int = Field(default=0, ge=0, description="Optimistic-concurrency counter.")
134
+
135
+ target_branch: str | None = None
136
+ target_worktree_path: str | None = None
137
+
138
+ # Flat task-plan fields rather than a nested object: matches the wire
139
+ # shape produced by _enrich_handoff_active in the handoff runtime,
140
+ # so existing dict consumers don't have to change keys.
141
+ task_plan_path: str | None = None
142
+ task_plan_abs_path: str | None = None
143
+ task_plan_exists: bool | None = None
144
+ task_plan_resolution: TaskPlanResolution | None = None
145
+
146
+ def target_worktree(self) -> TargetWorktree:
147
+ return TargetWorktree(
148
+ target_branch=self.target_branch,
149
+ target_worktree_path=self.target_worktree_path,
150
+ )
151
+
152
+ def task_plan(self) -> TaskPlanRef | None:
153
+ if self.task_plan_path is None:
154
+ return None
155
+ return TaskPlanRef(
156
+ task_plan_path=self.task_plan_path,
157
+ task_plan_abs_path=self.task_plan_abs_path,
158
+ task_plan_exists=self.task_plan_exists,
159
+ task_plan_resolution=self.task_plan_resolution,
160
+ )
161
+
162
+
163
+ class HandoffState(BaseModel):
164
+ """Top-level handoff state surface.
165
+
166
+ A list of active tasks plus a pointer to which one resolves to the
167
+ current workspace context (``active_task_ref``). The single-active
168
+ fallback (``active``) is kept for callers that still expect the
169
+ legacy single-task envelope; it equals the entry whose ``task_ref``
170
+ matches ``active_task_ref`` when present.
171
+ """
172
+
173
+ model_config = ConfigDict(extra="allow")
174
+
175
+ active_tasks: list[ActiveTask] = Field(default_factory=list)
176
+ active_task_ref: TaskRef | None = None
177
+ active: ActiveTask | None = None
178
+
179
+ @classmethod
180
+ def from_identity_envelope(cls, envelope: dict) -> "HandoffState":
181
+ """Adapter from the legacy ``get_handoff_state`` MCP envelope.
182
+
183
+ Accepts the shape ``{"ok": True, "tool": ..., "task_ref": ...,
184
+ "data": {"active": {...}, ...}}`` and reshapes it to a
185
+ single-active ``HandoffState``. Multi-active variants will be
186
+ added when the runtime exposes them.
187
+
188
+ Identity consistency: when both the outer ``envelope['task_ref']``
189
+ and the inner ``data.active.task_ref`` are present and differ,
190
+ this raises ``ValueError`` rather than silently picking one.
191
+ That prevents the adapter from constructing a logically
192
+ inconsistent ``HandoffState`` whose ``active_task_ref`` does
193
+ not point at the only active task.
194
+
195
+ Use this in tests and consumer-side validation where you have
196
+ an envelope and want to enforce the cross-repo top-level shape
197
+ without reshaping the runtime first.
198
+ """
199
+ if not isinstance(envelope, dict):
200
+ raise TypeError(f"envelope must be a dict, got {type(envelope).__name__}")
201
+ data = envelope.get("data") or {}
202
+ if not isinstance(data, dict):
203
+ raise TypeError("envelope['data'] must be a dict")
204
+ active_raw = data.get("active")
205
+ active = ActiveTask.model_validate(active_raw) if isinstance(active_raw, dict) else None
206
+
207
+ outer_ref = envelope.get("task_ref")
208
+ inner_ref = active.task_ref if active is not None else None
209
+ if outer_ref and inner_ref and outer_ref != inner_ref:
210
+ raise ValueError(
211
+ f"envelope identity mismatch: envelope['task_ref']={outer_ref!r} "
212
+ f"but data.active.task_ref={inner_ref!r}. The handoff envelope "
213
+ "must be self-consistent before adapting to HandoffState."
214
+ )
215
+ active_task_ref = inner_ref or outer_ref
216
+ return cls(
217
+ active=active,
218
+ active_task_ref=active_task_ref,
219
+ active_tasks=[active] if active is not None else [],
220
+ )
@@ -0,0 +1,100 @@
1
+ """Hook event payload schemas (Schema #3 from founding implementation note).
2
+
3
+ Models the JSON Claude Code delivers to hook scripts on stdin. Each
4
+ event type is modeled separately rather than as a discriminated union
5
+ so individual hook scripts can validate against the precise event they
6
+ expect without parsing every variant.
7
+
8
+ ``extra='allow'`` everywhere because Claude Code may add fields in
9
+ future releases and we don't want hooks to break on benign additions —
10
+ the contract guarantees the named fields, not exhaustive coverage.
11
+
12
+ Tag scheme: ``hook_event_name`` matches the literal string Claude Code
13
+ emits, so a hook script can do:
14
+
15
+ raw = json.loads(sys.stdin.read())
16
+ match raw["hook_event_name"]:
17
+ case "PreToolUse":
18
+ event = PreToolUseEvent.model_validate(raw)
19
+ case ...
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any, Literal
25
+
26
+ from pydantic import BaseModel, ConfigDict, Field
27
+
28
+
29
+ class _HookEventBase(BaseModel):
30
+ model_config = ConfigDict(extra="allow")
31
+
32
+ session_id: str = Field(description="Stable per-session identifier from Claude Code.")
33
+ transcript_path: str | None = Field(
34
+ default=None,
35
+ description="Filesystem path to the active session transcript, when emitted by the harness.",
36
+ )
37
+ cwd: str | None = Field(
38
+ default=None,
39
+ description="Working directory of the Claude Code process at event time.",
40
+ )
41
+
42
+
43
+ class SessionStartEvent(_HookEventBase):
44
+ hook_event_name: Literal["SessionStart"] = "SessionStart"
45
+ source: str | None = Field(
46
+ default=None,
47
+ description="Why the session started (e.g. 'startup', 'resume', 'compact').",
48
+ )
49
+
50
+
51
+ class UserPromptSubmitEvent(_HookEventBase):
52
+ hook_event_name: Literal["UserPromptSubmit"] = "UserPromptSubmit"
53
+ prompt: str = Field(description="Raw text of the user's submitted prompt.")
54
+
55
+
56
+ class PreToolUseEvent(_HookEventBase):
57
+ hook_event_name: Literal["PreToolUse"] = "PreToolUse"
58
+ tool_name: str = Field(description="Tool the model is about to invoke.")
59
+ tool_input: dict[str, Any] = Field(default_factory=dict, description="Arguments the model passed to the tool.")
60
+
61
+
62
+ class PostToolUseEvent(_HookEventBase):
63
+ hook_event_name: Literal["PostToolUse"] = "PostToolUse"
64
+ tool_name: str
65
+ tool_input: dict[str, Any] = Field(default_factory=dict)
66
+ tool_response: Any | None = Field(
67
+ default=None,
68
+ description="Tool result (shape varies by tool; passthrough so hooks can inspect freely).",
69
+ )
70
+
71
+
72
+ class StopEvent(_HookEventBase):
73
+ hook_event_name: Literal["Stop"] = "Stop"
74
+ stop_hook_active: bool | None = Field(
75
+ default=None,
76
+ description="True when a previous Stop hook already ran for this turn.",
77
+ )
78
+
79
+
80
+ _EVENT_TYPES: dict[str, type[_HookEventBase]] = {
81
+ "SessionStart": SessionStartEvent,
82
+ "UserPromptSubmit": UserPromptSubmitEvent,
83
+ "PreToolUse": PreToolUseEvent,
84
+ "PostToolUse": PostToolUseEvent,
85
+ "Stop": StopEvent,
86
+ }
87
+
88
+
89
+ def parse_hook_event(payload: dict[str, Any]) -> _HookEventBase:
90
+ """Validate ``payload`` against the matching hook-event schema.
91
+
92
+ Hook scripts call this on the dict they get from
93
+ ``json.loads(sys.stdin.read())`` to enforce the wire contract at
94
+ the entrypoint. Raises ``ValueError`` for unknown event names and
95
+ re-raises ``pydantic.ValidationError`` for shape failures.
96
+ """
97
+ name = payload.get("hook_event_name")
98
+ if name not in _EVENT_TYPES:
99
+ raise ValueError(f"unknown hook_event_name: {name!r}")
100
+ return _EVENT_TYPES[name].model_validate(payload)
File without changes
@@ -0,0 +1,61 @@
1
+ """Skill manifest schema (Schema #4 from founding implementation note).
2
+
3
+ Structured contract for the canonical neutral skill layout
4
+ ``skills/<slug>/skill.yaml`` (implementation note step 1). The pre-Plan-0002
5
+ ``.claude/skills/<slug>/SKILL.md`` frontmatter is now a *generated*
6
+ artifact in target repos and is no longer a source-of-truth surface.
7
+ Enforces the Tier 1 / Tier 2 / Tier 3 portability boundary by
8
+ requiring an explicit ``scope`` declaration on every skill —
9
+ preventing project-specific skills from being accidentally hoisted
10
+ into the harness overlay.
11
+
12
+ ``extra='allow'`` so existing harness fields (``mode``, ``context_budget``,
13
+ ``mcp_tools``, ``tdd_gate``, ``makefile_target``, etc.) round-trip
14
+ unchanged. The schema only enforces the cross-repo portability
15
+ contract; consumer-specific validators continue to enforce their own
16
+ fields locally.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from enum import Enum
22
+
23
+ from pydantic import BaseModel, ConfigDict, Field
24
+
25
+
26
+ class SkillScope(str, Enum):
27
+ """Portability scope of a skill.
28
+
29
+ ``harness``: project-agnostic. Lives in ``workstate-system`` source of
30
+ truth and is installed/symlinked into target repos by
31
+ ``workstate-bootstrap``. Must not reference project-specific
32
+ paths, package names, or domain terminology.
33
+ ``project``: project-specific. Stays in the target repo's
34
+ ``skills/`` (or its generated ``.claude/skills/``
35
+ mirror) and is never hoisted by bootstrap. May freely
36
+ reference project-local apps, packages, and
37
+ conventions.
38
+ """
39
+
40
+ harness = "harness"
41
+ project = "project"
42
+
43
+
44
+ class SkillManifest(BaseModel):
45
+ """Required cross-repo fields on a skill's frontmatter."""
46
+
47
+ model_config = ConfigDict(extra="allow")
48
+
49
+ name: str = Field(min_length=1, description="Unique skill identifier (kebab-case).")
50
+ description: str = Field(min_length=1, description="One-line description used for skill matching.")
51
+ scope: SkillScope = Field(
52
+ description="Portability scope. 'harness' = installed into target repos by bootstrap; 'project' = stays local.",
53
+ )
54
+ triggers: list[str] | None = Field(
55
+ default=None,
56
+ description="Optional list of trigger phrases or patterns the skill responds to.",
57
+ )
58
+ model: str | None = Field(
59
+ default=None,
60
+ description="Optional preferred model identifier (e.g. 'claude-opus-4-7').",
61
+ )
@@ -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,13 @@
1
+ workstate_protocol/__init__.py,sha256=Arjsuu4xlUxi2QFRhHN78k3-CmCo1eudcJJVgTiXXO8,1653
2
+ workstate_protocol/bootstrap.py,sha256=HIvNaFSbGV4fQlw8tqGWT44sD6KtmegNyBekU7Wtw-4,8857
3
+ workstate_protocol/branch_naming.py,sha256=31sa-AWtlEANq8Ay_zA4jnm9PMTHUYc9fV4ip49giYk,8479
4
+ workstate_protocol/compaction.py,sha256=4suCZW1RLCuJSZNIn08N1lhvQB81LvCMyu98oahLaPc,2623
5
+ workstate_protocol/env_aliases.py,sha256=NqjOnWS4EE_vcSiEx_wOmTrd8dhcnK3FsFMmGnb5erg,3132
6
+ workstate_protocol/handoff.py,sha256=DyOX7pmOoo3v8RFsCVLFEvkgFqz_2QLWzJad0DYO9o8,8598
7
+ workstate_protocol/hooks.py,sha256=zrs9FKT4nuMCkd8feHnI4OYYSFYuDAadMVZJNYSTmNo,3566
8
+ workstate_protocol/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ workstate_protocol/skills.py,sha256=Q8zhjdr5Bphp9H3YcmzhQ8qXUtgoiezWxLqbhoVK-6M,2471
10
+ workstate_protocol-0.1.5.dist-info/METADATA,sha256=JyOo5bgjk4T73xnz0oTYkHGbrEwGTiaJ-OShOMPZ5ZM,2318
11
+ workstate_protocol-0.1.5.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ workstate_protocol-0.1.5.dist-info/top_level.txt,sha256=0g332GBrFdObOsFWiWcgKOgdizfwrDZYzTk4FfQRwKk,19
13
+ workstate_protocol-0.1.5.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ workstate_protocol