gemba-cli 0.1.0__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.
- gemba/__init__.py +1 -0
- gemba/assets/harness/guide.md +88 -0
- gemba/assets/init/gemba.yaml +12 -0
- gemba/assets/skills/gemba-archive/SKILL.md +78 -0
- gemba/assets/skills/gemba-config/SKILL.md +176 -0
- gemba/assets/skills/gemba-create/SKILL.md +99 -0
- gemba/assets/skills/gemba-harness/SKILL.md +194 -0
- gemba/assets/skills/gemba-init/SKILL.md +81 -0
- gemba/assets/skills/gemba-list/SKILL.md +137 -0
- gemba/assets/skills/gemba-mark/SKILL.md +88 -0
- gemba/assets/skills/gemba-record/SKILL.md +89 -0
- gemba/assets/skills/gemba-report/SKILL.md +149 -0
- gemba/assets/skills/gemba-set/SKILL.md +104 -0
- gemba/assets/skills/gemba-worktree/SKILL.md +137 -0
- gemba/cli.py +35 -0
- gemba/commands/__init__.py +1 -0
- gemba/commands/archive.py +62 -0
- gemba/commands/config.py +117 -0
- gemba/commands/create.py +104 -0
- gemba/commands/harness.py +534 -0
- gemba/commands/init.py +112 -0
- gemba/commands/list.py +51 -0
- gemba/commands/mark.py +91 -0
- gemba/commands/record.py +113 -0
- gemba/commands/report.py +80 -0
- gemba/commands/selection.py +170 -0
- gemba/commands/set.py +81 -0
- gemba/commands/skills.py +102 -0
- gemba/commands/worktree.py +292 -0
- gemba/config.py +371 -0
- gemba/harness.py +401 -0
- gemba/metadata.py +442 -0
- gemba/repo.py +76 -0
- gemba/report.py +153 -0
- gemba/skills.py +199 -0
- gemba/tasks.py +621 -0
- gemba/values.py +16 -0
- gemba/worktree.py +191 -0
- gemba_cli-0.1.0.dist-info/METADATA +321 -0
- gemba_cli-0.1.0.dist-info/RECORD +43 -0
- gemba_cli-0.1.0.dist-info/WHEEL +4 -0
- gemba_cli-0.1.0.dist-info/entry_points.txt +2 -0
- gemba_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
gemba/skills.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Built-in skills: the agent provider table, the skill registry, rendering and inspection."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from importlib import resources
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
Target = Literal["repo", "harness"]
|
|
13
|
+
Scope = Literal["repo", "harness", "both"]
|
|
14
|
+
State = Literal["missing", "unchanged", "outdated", "edited"]
|
|
15
|
+
Outcome = Literal["written", "updated", "kept", "overwritten"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Provider:
|
|
20
|
+
"""Where an agent provider reads its skills and its guide, relative to the target root."""
|
|
21
|
+
|
|
22
|
+
skills_dir: str
|
|
23
|
+
guide: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Skill:
|
|
28
|
+
"""A built-in skill and the targets it is written into: the tasks repo, a harness or both."""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
scope: Scope
|
|
32
|
+
|
|
33
|
+
def belongs_to(self, target: Target) -> bool:
|
|
34
|
+
return self.scope in (target, "both")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Every agent provider the CLI knows.
|
|
38
|
+
PROVIDERS = {
|
|
39
|
+
"claude": Provider(skills_dir=".claude/skills", guide="CLAUDE.md"),
|
|
40
|
+
"agents": Provider(skills_dir=".agents/skills", guide="AGENTS.md"),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Every built-in skill, in writing order; each one ships as assets/skills/<name>/SKILL.md.
|
|
44
|
+
SKILLS = (
|
|
45
|
+
Skill("gemba-create", "repo"),
|
|
46
|
+
Skill("gemba-list", "repo"),
|
|
47
|
+
Skill("gemba-harness", "both"),
|
|
48
|
+
Skill("gemba-record", "harness"),
|
|
49
|
+
Skill("gemba-report", "repo"),
|
|
50
|
+
Skill("gemba-mark", "both"),
|
|
51
|
+
Skill("gemba-archive", "repo"),
|
|
52
|
+
Skill("gemba-set", "both"),
|
|
53
|
+
Skill("gemba-worktree", "harness"),
|
|
54
|
+
Skill("gemba-init", "repo"),
|
|
55
|
+
Skill("gemba-config", "repo"),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
SKILL_FILE = "SKILL.md"
|
|
59
|
+
FRONTMATTER_DELIMITER = "---"
|
|
60
|
+
METADATA_KEY = "metadata"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class Action:
|
|
65
|
+
"""One registered skill under one provider: its path, the state on disk and what to write."""
|
|
66
|
+
|
|
67
|
+
path: str # relative to the target root, POSIX separators
|
|
68
|
+
state: State
|
|
69
|
+
content: str
|
|
70
|
+
|
|
71
|
+
def outcome(self, force: bool) -> Outcome:
|
|
72
|
+
"""What `update` does with the file: an edited skill is kept unless `force` is set."""
|
|
73
|
+
if self.state == "missing":
|
|
74
|
+
return "written"
|
|
75
|
+
if self.state == "outdated":
|
|
76
|
+
return "updated"
|
|
77
|
+
if self.state == "edited" and force:
|
|
78
|
+
return "overwritten"
|
|
79
|
+
return "kept"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def asset(name: str) -> str:
|
|
83
|
+
"""The text of a registered skill as the package ships it."""
|
|
84
|
+
if name not in (skill.name for skill in SKILLS):
|
|
85
|
+
raise ValueError(f"'{name}' is not a registered skill")
|
|
86
|
+
return (resources.files("gemba") / "assets" / "skills" / name / SKILL_FILE).read_text(encoding="utf-8")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def guide_asset() -> str:
|
|
90
|
+
"""The text of the harness guide as the package ships it."""
|
|
91
|
+
return (resources.files("gemba") / "assets" / "harness" / "guide.md").read_text(encoding="utf-8")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def render(name: str, version: str) -> str:
|
|
95
|
+
"""The `SKILL.md` that package `version` writes for a registered skill."""
|
|
96
|
+
return stamp(asset(name), version)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def stamp(text: str, version: str) -> str:
|
|
100
|
+
"""`text` with the provenance map appended to its frontmatter."""
|
|
101
|
+
parts = normalize(text).split("\n")
|
|
102
|
+
end = _frontmatter_end(parts)
|
|
103
|
+
if end is None:
|
|
104
|
+
raise ValueError("a skill must start with a YAML frontmatter")
|
|
105
|
+
if _metadata_span(parts, end) is not None:
|
|
106
|
+
raise ValueError("a skill asset must not carry a metadata map")
|
|
107
|
+
provenance = [f"{METADATA_KEY}:", f' generatedBy: "{version}"', f' contentHash: "{content_hash(text)}"']
|
|
108
|
+
return "\n".join(parts[:end] + provenance + parts[end:])
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def normalize(text: str) -> str:
|
|
112
|
+
"""`text` with every line ending turned into `\\n`."""
|
|
113
|
+
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def content(text: str) -> str:
|
|
117
|
+
"""What a `contentHash` covers: `text` with normalized line endings and no metadata map."""
|
|
118
|
+
stripped, _block = _split(normalize(text))
|
|
119
|
+
return stripped
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def content_hash(text: str) -> str:
|
|
123
|
+
"""SHA-256 of the content of `text`, as hexadecimal digits."""
|
|
124
|
+
return _digest(content(text))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def inspect(path: Path, rendered: str) -> State:
|
|
128
|
+
"""Classify the skill file at `path` against `rendered`, the text the package writes now."""
|
|
129
|
+
if not path.is_file():
|
|
130
|
+
return "missing"
|
|
131
|
+
text = normalize(path.read_bytes().decode("utf-8", errors="replace"))
|
|
132
|
+
stripped, block = _split(text)
|
|
133
|
+
metadata = _parse_metadata(block)
|
|
134
|
+
if metadata is None or metadata.get("contentHash") != _digest(stripped):
|
|
135
|
+
return "edited"
|
|
136
|
+
generator = metadata.get("generatedBy")
|
|
137
|
+
if isinstance(generator, str) and text == stamp(content(rendered), generator):
|
|
138
|
+
return "unchanged"
|
|
139
|
+
return "outdated"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def plan(root: Path, providers: Sequence[str], version: str, scope: Target) -> list[Action]:
|
|
143
|
+
"""One action per declared provider and skill whose scope includes the target, in declaration and registry order."""
|
|
144
|
+
names = [skill.name for skill in SKILLS if skill.belongs_to(scope)]
|
|
145
|
+
rendered = {name: render(name, version) for name in names}
|
|
146
|
+
actions = []
|
|
147
|
+
for provider in providers:
|
|
148
|
+
for name in names:
|
|
149
|
+
relative = PurePosixPath(PROVIDERS[provider].skills_dir, name, SKILL_FILE)
|
|
150
|
+
state = inspect(root / relative, rendered[name])
|
|
151
|
+
actions.append(Action(path=relative.as_posix(), state=state, content=rendered[name]))
|
|
152
|
+
return actions
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _digest(text: str) -> str:
|
|
156
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _split(text: str) -> tuple[str, str | None]:
|
|
160
|
+
"""`text` without the metadata map of its frontmatter, and the map's lines, or None without one."""
|
|
161
|
+
parts = text.split("\n")
|
|
162
|
+
end = _frontmatter_end(parts)
|
|
163
|
+
span = None if end is None else _metadata_span(parts, end)
|
|
164
|
+
if span is None:
|
|
165
|
+
return text, None
|
|
166
|
+
start, stop = span
|
|
167
|
+
return "\n".join(parts[:start] + parts[stop:]), "\n".join(parts[start:stop])
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _frontmatter_end(parts: list[str]) -> int | None:
|
|
171
|
+
"""Index of the line that closes the frontmatter, or None when the text has no frontmatter."""
|
|
172
|
+
if parts[0] != FRONTMATTER_DELIMITER:
|
|
173
|
+
return None
|
|
174
|
+
for index in range(1, len(parts)):
|
|
175
|
+
if parts[index] == FRONTMATTER_DELIMITER:
|
|
176
|
+
return index
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _metadata_span(parts: list[str], end: int) -> tuple[int, int] | None:
|
|
181
|
+
"""Line range of the top-level metadata key and its indented lines, inside the frontmatter."""
|
|
182
|
+
for start in range(1, end):
|
|
183
|
+
if parts[start].startswith(f"{METADATA_KEY}:"):
|
|
184
|
+
stop = start + 1
|
|
185
|
+
while stop < end and parts[stop][:1] in (" ", "\t"):
|
|
186
|
+
stop += 1
|
|
187
|
+
return start, stop
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _parse_metadata(block: str | None) -> dict[object, object] | None:
|
|
192
|
+
if block is None:
|
|
193
|
+
return None
|
|
194
|
+
try:
|
|
195
|
+
data = yaml.safe_load(block)
|
|
196
|
+
except yaml.YAMLError:
|
|
197
|
+
return None
|
|
198
|
+
metadata = data.get(METADATA_KEY) if isinstance(data, dict) else None
|
|
199
|
+
return metadata if isinstance(metadata, dict) else None
|