workstate-protocol 0.1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. workstate_protocol-0.1.5/PKG-INFO +37 -0
  2. workstate_protocol-0.1.5/README.md +22 -0
  3. workstate_protocol-0.1.5/pyproject.toml +31 -0
  4. workstate_protocol-0.1.5/setup.cfg +4 -0
  5. workstate_protocol-0.1.5/src/workstate_protocol/__init__.py +66 -0
  6. workstate_protocol-0.1.5/src/workstate_protocol/bootstrap.py +267 -0
  7. workstate_protocol-0.1.5/src/workstate_protocol/branch_naming.py +221 -0
  8. workstate_protocol-0.1.5/src/workstate_protocol/compaction.py +68 -0
  9. workstate_protocol-0.1.5/src/workstate_protocol/env_aliases.py +97 -0
  10. workstate_protocol-0.1.5/src/workstate_protocol/handoff.py +220 -0
  11. workstate_protocol-0.1.5/src/workstate_protocol/hooks.py +100 -0
  12. workstate_protocol-0.1.5/src/workstate_protocol/py.typed +0 -0
  13. workstate_protocol-0.1.5/src/workstate_protocol/skills.py +61 -0
  14. workstate_protocol-0.1.5/src/workstate_protocol.egg-info/PKG-INFO +37 -0
  15. workstate_protocol-0.1.5/src/workstate_protocol.egg-info/SOURCES.txt +24 -0
  16. workstate_protocol-0.1.5/src/workstate_protocol.egg-info/dependency_links.txt +1 -0
  17. workstate_protocol-0.1.5/src/workstate_protocol.egg-info/requires.txt +4 -0
  18. workstate_protocol-0.1.5/src/workstate_protocol.egg-info/top_level.txt +1 -0
  19. workstate_protocol-0.1.5/tests/test_bootstrap_manifest.py +68 -0
  20. workstate_protocol-0.1.5/tests/test_branch_grammar_registry.py +60 -0
  21. workstate_protocol-0.1.5/tests/test_branch_naming.py +268 -0
  22. workstate_protocol-0.1.5/tests/test_env_aliases.py +98 -0
  23. workstate_protocol-0.1.5/tests/test_handoff_schema.py +165 -0
  24. workstate_protocol-0.1.5/tests/test_package_metadata.py +56 -0
  25. workstate_protocol-0.1.5/tests/test_plugin_override_schema.py +145 -0
  26. workstate_protocol-0.1.5/tests/test_skill_manifest_real_skills.py +111 -0
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.4
2
+ Name: workstate-protocol
3
+ Version: 0.1.5
4
+ Summary: Typed cross-repo contracts for the Workstate system: handoff state, MCP I/O, hook events, skill manifests, bootstrap install manifest.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/darce/workstate
7
+ Project-URL: Source, https://github.com/darce/workstate/tree/main/packages/workstate-protocol
8
+ Project-URL: Changelog, https://github.com/darce/workstate/blob/main/packages/workstate-protocol/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/darce/workstate/issues
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: pydantic>=2.6
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=8; extra == "dev"
15
+
16
+ # workstate-protocol
17
+
18
+ Single source of truth for cross-repo contracts in the Workstate system. Pydantic v2 is canonical; JSON Schema artifacts under `schemas/` are generated from the models so non-Python consumers (hook scripts, future TS/JS tooling) can validate without importing Python.
19
+
20
+ ## Schemas (rolled out incrementally per founding implementation note)
21
+
22
+ | Status | Module | Schema |
23
+ | --- | --- | --- |
24
+ | ✅ v0.1.0 | `workstate_protocol.handoff` | `HandoffState`, `ActiveTask`, `TaskRef`, `TargetWorktree`, `TaskPlanRef` |
25
+ | ✅ v0.1.0 | `workstate_protocol.branch_naming` | `TASK_REF_RE`, `derive_task_ref_candidates`, `format_suggested_branch_name` (single source of truth for the feature-branch grammar enforced by the post-checkout / PreToolUse / pre-commit / pre-push gates; cross-package consumers like `workstate_handoff_mcp` re-export by reference, never by literal copy — identity is the contract) |
26
+ | ⏳ | `workstate_protocol.mcp` | MCP tool I/O envelopes |
27
+ | ⏳ | `workstate_protocol.hooks` | `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop` |
28
+ | ⏳ | `workstate_protocol.skills` | `SkillManifest` (with `scope: harness | project`) |
29
+ | ⏳ | `workstate_protocol.bootstrap` | Bootstrap install manifest |
30
+
31
+ ## Generated artifacts
32
+
33
+ `schemas/*.json` is regenerated by `scripts/generate_schemas.py` and committed. Consumers that don't want to import Python can read the JSON Schema directly.
34
+
35
+ ## Versioning
36
+
37
+ Hard-pin major version per consumer. Bootstrap resolves a compatible quartet of (handoff, orchestrator, bootstrap, system) against a single `workstate-protocol` major.
@@ -0,0 +1,22 @@
1
+ # workstate-protocol
2
+
3
+ Single source of truth for cross-repo contracts in the Workstate system. Pydantic v2 is canonical; JSON Schema artifacts under `schemas/` are generated from the models so non-Python consumers (hook scripts, future TS/JS tooling) can validate without importing Python.
4
+
5
+ ## Schemas (rolled out incrementally per founding implementation note)
6
+
7
+ | Status | Module | Schema |
8
+ | --- | --- | --- |
9
+ | ✅ v0.1.0 | `workstate_protocol.handoff` | `HandoffState`, `ActiveTask`, `TaskRef`, `TargetWorktree`, `TaskPlanRef` |
10
+ | ✅ v0.1.0 | `workstate_protocol.branch_naming` | `TASK_REF_RE`, `derive_task_ref_candidates`, `format_suggested_branch_name` (single source of truth for the feature-branch grammar enforced by the post-checkout / PreToolUse / pre-commit / pre-push gates; cross-package consumers like `workstate_handoff_mcp` re-export by reference, never by literal copy — identity is the contract) |
11
+ | ⏳ | `workstate_protocol.mcp` | MCP tool I/O envelopes |
12
+ | ⏳ | `workstate_protocol.hooks` | `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop` |
13
+ | ⏳ | `workstate_protocol.skills` | `SkillManifest` (with `scope: harness | project`) |
14
+ | ⏳ | `workstate_protocol.bootstrap` | Bootstrap install manifest |
15
+
16
+ ## Generated artifacts
17
+
18
+ `schemas/*.json` is regenerated by `scripts/generate_schemas.py` and committed. Consumers that don't want to import Python can read the JSON Schema directly.
19
+
20
+ ## Versioning
21
+
22
+ Hard-pin major version per consumer. Bootstrap resolves a compatible quartet of (handoff, orchestrator, bootstrap, system) against a single `workstate-protocol` major.
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "workstate-protocol"
7
+ version = "0.1.5"
8
+ description = "Typed cross-repo contracts for the Workstate system: handoff state, MCP I/O, hook events, skill manifests, bootstrap install manifest."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ dependencies = [
13
+ "pydantic>=2.6",
14
+ ]
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/darce/workstate"
18
+ Source = "https://github.com/darce/workstate/tree/main/packages/workstate-protocol"
19
+ Changelog = "https://github.com/darce/workstate/blob/main/packages/workstate-protocol/CHANGELOG.md"
20
+ Issues = "https://github.com/darce/workstate/issues"
21
+
22
+ [project.optional-dependencies]
23
+ dev = [
24
+ "pytest>=8",
25
+ ]
26
+
27
+ [tool.setuptools.packages.find]
28
+ where = ["src"]
29
+
30
+ [tool.setuptools.package-data]
31
+ workstate_protocol = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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