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.
Files changed (43) hide show
  1. gemba/__init__.py +1 -0
  2. gemba/assets/harness/guide.md +88 -0
  3. gemba/assets/init/gemba.yaml +12 -0
  4. gemba/assets/skills/gemba-archive/SKILL.md +78 -0
  5. gemba/assets/skills/gemba-config/SKILL.md +176 -0
  6. gemba/assets/skills/gemba-create/SKILL.md +99 -0
  7. gemba/assets/skills/gemba-harness/SKILL.md +194 -0
  8. gemba/assets/skills/gemba-init/SKILL.md +81 -0
  9. gemba/assets/skills/gemba-list/SKILL.md +137 -0
  10. gemba/assets/skills/gemba-mark/SKILL.md +88 -0
  11. gemba/assets/skills/gemba-record/SKILL.md +89 -0
  12. gemba/assets/skills/gemba-report/SKILL.md +149 -0
  13. gemba/assets/skills/gemba-set/SKILL.md +104 -0
  14. gemba/assets/skills/gemba-worktree/SKILL.md +137 -0
  15. gemba/cli.py +35 -0
  16. gemba/commands/__init__.py +1 -0
  17. gemba/commands/archive.py +62 -0
  18. gemba/commands/config.py +117 -0
  19. gemba/commands/create.py +104 -0
  20. gemba/commands/harness.py +534 -0
  21. gemba/commands/init.py +112 -0
  22. gemba/commands/list.py +51 -0
  23. gemba/commands/mark.py +91 -0
  24. gemba/commands/record.py +113 -0
  25. gemba/commands/report.py +80 -0
  26. gemba/commands/selection.py +170 -0
  27. gemba/commands/set.py +81 -0
  28. gemba/commands/skills.py +102 -0
  29. gemba/commands/worktree.py +292 -0
  30. gemba/config.py +371 -0
  31. gemba/harness.py +401 -0
  32. gemba/metadata.py +442 -0
  33. gemba/repo.py +76 -0
  34. gemba/report.py +153 -0
  35. gemba/skills.py +199 -0
  36. gemba/tasks.py +621 -0
  37. gemba/values.py +16 -0
  38. gemba/worktree.py +191 -0
  39. gemba_cli-0.1.0.dist-info/METADATA +321 -0
  40. gemba_cli-0.1.0.dist-info/RECORD +43 -0
  41. gemba_cli-0.1.0.dist-info/WHEEL +4 -0
  42. gemba_cli-0.1.0.dist-info/entry_points.txt +2 -0
  43. 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