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.
- workstate_protocol/__init__.py +66 -0
- workstate_protocol/bootstrap.py +267 -0
- workstate_protocol/branch_naming.py +221 -0
- workstate_protocol/compaction.py +68 -0
- workstate_protocol/env_aliases.py +97 -0
- workstate_protocol/handoff.py +220 -0
- workstate_protocol/hooks.py +100 -0
- workstate_protocol/py.typed +0 -0
- workstate_protocol/skills.py +61 -0
- workstate_protocol-0.1.5.dist-info/METADATA +37 -0
- workstate_protocol-0.1.5.dist-info/RECORD +13 -0
- workstate_protocol-0.1.5.dist-info/WHEEL +5 -0
- workstate_protocol-0.1.5.dist-info/top_level.txt +1 -0
|
@@ -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 @@
|
|
|
1
|
+
workstate_protocol
|