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/harness.py ADDED
@@ -0,0 +1,401 @@
1
+ """Harness rules: the layout of a run, its harness config, the guide, the notes, the commit messages and removal."""
2
+
3
+ import datetime
4
+ import re
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+ from pathlib import Path, PurePosixPath
8
+ from typing import Literal
9
+
10
+ import yaml
11
+
12
+ from gemba import tasks, worktree
13
+
14
+ HARNESSES_DIR = "harnesses"
15
+ NOTES_DIR = "notes"
16
+ ASSETS_DIR = "assets"
17
+ WORKTREES_DIR = worktree.WORKTREES_DIR
18
+ CONFIG_FILENAME = "harness.yaml"
19
+ TASK_LINK = "task.md"
20
+ GITIGNORE = ".gitignore"
21
+ GITIGNORE_TEXT = f"{WORKTREES_DIR}/\n"
22
+ BRANCH = "main"
23
+ ORGANIZATION_HEADING = "## Your organization"
24
+ WORKSTREAM_HEADING = "## Your workstream"
25
+
26
+ # Levels between the tasks repo root and a harness root: harnesses/<workstream>/<stem>.
27
+ DEPTH = 3
28
+
29
+ # The last line `remove` prints in each of its outcomes.
30
+ NOT_REMOVED = "not removed: pass --yes to delete the harness"
31
+ REMOVED = "removed"
32
+ REMOVAL_REFUSED = "harness not removed:"
33
+
34
+ FILED_NAME_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}-\d{6}-(?P<slug>[a-z0-9]+(?:-[a-z0-9]+)*)\.md$")
35
+ TIME_PATTERN = re.compile(r"^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$")
36
+ NOTE_KEYS = ("type", "date", "time", "approved", "assets", "resolved_at")
37
+
38
+ Gate = Literal["always", "optional", "never"]
39
+
40
+
41
+ class HarnessError(Exception):
42
+ """The command cannot act on a harness; nothing may be written."""
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class NoteType:
47
+ """What a note of this type answers, and whether it waits for the user's approval."""
48
+
49
+ definition: str
50
+ gate: Gate
51
+
52
+
53
+ # The built-in note types, in the order the guide and the help list them.
54
+ NOTE_TYPES = {
55
+ "finding": NoteType("what is true and the evidence for it", "never"),
56
+ "blocker": NoteType("why the run cannot continue, what was tried, what is needed", "never"),
57
+ "decision": NoteType("the options, their trade-offs and the recommendation or the choice", "optional"),
58
+ "summary": NoteType("where the run stands at a milestone and what remains", "never"),
59
+ "external-action": NoteType("something to be done outside the harness, word for word", "always"),
60
+ }
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class NoteFrontmatter:
65
+ """The frontmatter of a filed note, as gemba writes it."""
66
+
67
+ type: str
68
+ date: datetime.date
69
+ time: str
70
+ approved: bool | None = None
71
+ assets: tuple[str, ...] = ()
72
+ resolved_at: str | None = None
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class HarnessConfig:
77
+ """The identity of a run, written once by launch."""
78
+
79
+ task: str # task file path relative to the tasks repo root, POSIX separators
80
+ ref: str
81
+ workstream: str
82
+ agent_providers: tuple[str, ...]
83
+ launched_at: datetime.date
84
+ gemba_version: str
85
+
86
+
87
+ class _Quoted(str):
88
+ """A string always written between double quotes, so that YAML never reads it as another type."""
89
+
90
+
91
+ class _Dumper(tasks.FrontmatterDumper):
92
+ """The task frontmatter style, plus double quotes for `_Quoted` strings."""
93
+
94
+
95
+ _Dumper.add_representer(
96
+ _Quoted, lambda dumper, data: dumper.represent_scalar("tag:yaml.org,2002:str", str(data), style='"')
97
+ )
98
+
99
+
100
+ def render_config(config: HarnessConfig) -> str:
101
+ """`harness.yaml`: one key per line in fixed order."""
102
+ return tasks.dump_frontmatter(
103
+ {
104
+ "task": config.task,
105
+ "ref": config.ref,
106
+ "workstream": config.workstream,
107
+ "agent_providers": list(config.agent_providers),
108
+ "launched_at": config.launched_at,
109
+ "gemba_version": _Quoted(config.gemba_version),
110
+ },
111
+ _Dumper,
112
+ )
113
+
114
+
115
+ def read_config(harness_root: Path, known_providers: Sequence[str]) -> HarnessConfig:
116
+ """The harness config at `harness_root`, refusing a file that launch would not have written."""
117
+ path = harness_root / CONFIG_FILENAME
118
+ where = f"invalid harness config {path}"
119
+ try:
120
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
121
+ except OSError as exc:
122
+ raise HarnessError(f"{where}: {exc.strerror}") from None
123
+ except (yaml.YAMLError, ValueError) as exc:
124
+ raise HarnessError(f"{where}: malformed YAML: {exc}") from None
125
+ if not isinstance(data, dict):
126
+ raise HarnessError(f"{where}: the file must be a map")
127
+ for key in ("task", "ref", "workstream", "gemba_version"):
128
+ if not isinstance(data.get(key), str) or not data[key]:
129
+ raise HarnessError(f"{where}: {key} must be a non-empty string")
130
+ providers = data.get("agent_providers")
131
+ if not isinstance(providers, list) or not providers or not all(item in known_providers for item in providers):
132
+ raise HarnessError(
133
+ f"{where}: agent_providers must be a non-empty list of known providers ({', '.join(known_providers)})"
134
+ )
135
+ launched_at = data.get("launched_at")
136
+ if not isinstance(launched_at, datetime.date) or isinstance(launched_at, datetime.datetime):
137
+ raise HarnessError(f"{where}: launched_at must be an ISO date (YYYY-MM-DD)")
138
+ return HarnessConfig(
139
+ task=data["task"],
140
+ ref=data["ref"],
141
+ workstream=data["workstream"],
142
+ agent_providers=tuple(providers),
143
+ launched_at=launched_at,
144
+ gemba_version=data["gemba_version"],
145
+ )
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class Manifest:
150
+ """What a run holds, shown before its harness is removed."""
151
+
152
+ path: str # harness root relative to the tasks repo root, POSIX separators
153
+ task: str
154
+ launched_at: datetime.date
155
+ notes: int
156
+ assets: int
157
+ commits: int
158
+ worktrees: int
159
+ uncommitted: tuple[str, ...] | None # None when the state of the harness repository could not be read
160
+
161
+
162
+ def render_manifest(manifest: Manifest) -> str:
163
+ """The lines of the manifest: the harness path, one `key: value` line per count, then each uncommitted path."""
164
+ uncommitted = manifest.uncommitted
165
+ return "\n".join(
166
+ [
167
+ manifest.path,
168
+ f"task: {manifest.task}",
169
+ f"launched_at: {manifest.launched_at.isoformat()}",
170
+ f"{NOTES_DIR}: {manifest.notes}",
171
+ f"{ASSETS_DIR}: {manifest.assets}",
172
+ f"commits: {manifest.commits}",
173
+ f"{WORKTREES_DIR}: {manifest.worktrees}",
174
+ f"uncommitted: {'unknown' if uncommitted is None else len(uncommitted)}",
175
+ *(f"- {path}" for path in uncommitted or ()),
176
+ ]
177
+ )
178
+
179
+
180
+ def uncommitted_paths(status_lines: Sequence[str]) -> tuple[str, ...]:
181
+ """The path of every line of `git status --porcelain`, in the order git reports them."""
182
+ return tuple(line[3:] for line in status_lines if line.strip())
183
+
184
+
185
+ def removal_problems(
186
+ working_directory: Path,
187
+ harness_root: Path,
188
+ status_lines: Sequence[str] | None,
189
+ worktree_states: Sequence[worktree.State] = (),
190
+ orphans: Sequence[tuple[str, str, int]] = (),
191
+ force: bool = False,
192
+ ) -> list[str]:
193
+ """Every reason the harness at `harness_root` cannot be deleted now; both paths are resolved.
194
+
195
+ `status_lines` are the lines of `git status --porcelain` in the harness, or None when
196
+ the state of its repository could not be read. An uncommitted path never refuses:
197
+ nothing in the harness repository outlives the run, and the manifest lists it.
198
+ `worktree_states` are the folders under `worktrees/` and `orphans` the branches of
199
+ the run left without a worktree, as (repository, branch, unpushed commits). With
200
+ `force` neither refuses; the working directory and an unreadable harness repository
201
+ still do.
202
+ """
203
+ problems = []
204
+ if working_directory.is_relative_to(harness_root):
205
+ problems.append(
206
+ "the working directory is inside the harness: run the command from outside it, such as the tasks repo root"
207
+ )
208
+ if status_lines is None:
209
+ problems.append("the state of the harness repository could not be read with git status")
210
+ if force:
211
+ return problems
212
+ for state in worktree_states:
213
+ problems.extend(worktree.pending_problems(state))
214
+ for repo, branch, unpushed in orphans:
215
+ if problem := worktree.orphan_problem(repo, branch, unpushed):
216
+ problems.append(problem)
217
+ return problems
218
+
219
+
220
+ def harness_path(root: Path, workstream: str, stem: str) -> Path:
221
+ """Where the harness of the task file `<stem>.md` of `workstream` lives under the tasks repo root."""
222
+ return root / HARNESSES_DIR / workstream / stem
223
+
224
+
225
+ def task_link_target(task: str) -> str:
226
+ """The relative target of `task.md` for the task file at `task`, relative to the tasks repo root."""
227
+ return PurePosixPath(*[".."] * DEPTH, task).as_posix()
228
+
229
+
230
+ def find_root(start: Path | str) -> Path:
231
+ """The nearest folder at or above `start` that holds a harness config."""
232
+ start = Path(start).resolve()
233
+ for folder in (start, *start.parents):
234
+ if (folder / CONFIG_FILENAME).is_file():
235
+ return folder
236
+ raise HarnessError(f"no harness found: no {CONFIG_FILENAME} in {start} or any parent folder")
237
+
238
+
239
+ def render_guide(asset_text: str, base_guide: str | None, workstream_guide: str | None) -> str:
240
+ """The guide of a harness: the asset as shipped, then the base text and the workstream's, each in its section."""
241
+ guide = asset_text
242
+ for heading, text in ((ORGANIZATION_HEADING, base_guide), (WORKSTREAM_HEADING, workstream_guide)):
243
+ if text is not None and text.strip():
244
+ guide = f"{guide.rstrip()}\n\n{heading}\n\n{text.strip()}\n"
245
+ return guide
246
+
247
+
248
+ def approval_problem(note_type: str, approval: bool) -> str | None:
249
+ """Why `--approval` cannot be given for this type, or None when the request is valid."""
250
+ if approval and NOTE_TYPES[note_type].gate == "never":
251
+ return f"type '{note_type}' takes no approval: --approval is for decision notes"
252
+ return None
253
+
254
+
255
+ def is_gated(note_type: str, approval: bool) -> bool:
256
+ """Whether a note of this type, filed with or without `--approval`, waits for the user's approval."""
257
+ gate = NOTE_TYPES[note_type].gate
258
+ return gate == "always" or gate == "optional" and approval
259
+
260
+
261
+ def note_filename(now: datetime.datetime, slug: str) -> str:
262
+ """`YYYY-MM-DD-HHMMSS-<slug>.md`, which sorts in filing order."""
263
+ return f"{now:%Y-%m-%d-%H%M%S}-{slug}.md"
264
+
265
+
266
+ def filed_slug(name: str) -> str | None:
267
+ """The slug of a filed note's file name, or None when the name is not one of a filed note."""
268
+ match = FILED_NAME_PATTERN.match(name)
269
+ return match["slug"] if match else None
270
+
271
+
272
+ def render_note_frontmatter(frontmatter: NoteFrontmatter) -> str:
273
+ """The frontmatter block of a note, delimiter lines included, keys in fixed order."""
274
+ data: dict[str, object] = {
275
+ "type": frontmatter.type,
276
+ "date": frontmatter.date,
277
+ "time": _Quoted(frontmatter.time),
278
+ }
279
+ if frontmatter.approved is not None:
280
+ data["approved"] = frontmatter.approved
281
+ if frontmatter.assets:
282
+ data["assets"] = list(frontmatter.assets)
283
+ if frontmatter.resolved_at is not None:
284
+ data["resolved_at"] = _Quoted(frontmatter.resolved_at)
285
+ delimiter = tasks.FRONTMATTER_DELIMITER
286
+ return f"{delimiter}\n{tasks.dump_frontmatter(data, _Dumper)}{delimiter}\n"
287
+
288
+
289
+ def split_note(text: str) -> tuple[str | None, str]:
290
+ """The frontmatter block of a note, or None without one, and the text after its closing line."""
291
+ lines = text.split("\n")
292
+ if lines[0].rstrip() != tasks.FRONTMATTER_DELIMITER:
293
+ return None, text
294
+ for index in range(1, len(lines)):
295
+ if lines[index].rstrip() == tasks.FRONTMATTER_DELIMITER:
296
+ return "\n".join(lines[1:index]), "\n".join(lines[index + 1 :])
297
+ raise HarnessError(f"the frontmatter is not closed by a {tasks.FRONTMATTER_DELIMITER} line")
298
+
299
+
300
+ def parse_note_frontmatter(block: str) -> NoteFrontmatter:
301
+ """Read a frontmatter block that gemba wrote, refusing anything gemba would not write."""
302
+ try:
303
+ data = yaml.safe_load(block)
304
+ except (yaml.YAMLError, ValueError) as exc:
305
+ raise HarnessError(f"the frontmatter is not valid YAML: {exc}") from None
306
+ if not isinstance(data, dict):
307
+ raise HarnessError("the frontmatter must be a map")
308
+ unknown = [str(key) for key in data if key not in NOTE_KEYS]
309
+ if unknown:
310
+ raise HarnessError(f"the frontmatter holds keys gemba does not write: {', '.join(unknown)}")
311
+ note_type = data.get("type")
312
+ if note_type not in NOTE_TYPES:
313
+ raise HarnessError(f"the frontmatter type {note_type!r} is not a note type ({', '.join(NOTE_TYPES)})")
314
+ date = data.get("date")
315
+ if not isinstance(date, datetime.date) or isinstance(date, datetime.datetime):
316
+ raise HarnessError("the frontmatter date must be an ISO date (YYYY-MM-DD)")
317
+ time = data.get("time")
318
+ if not isinstance(time, str) or not TIME_PATTERN.match(time):
319
+ raise HarnessError('the frontmatter time must be a quoted "HH:MM:SS"')
320
+ approved = data.get("approved")
321
+ if "approved" in data and not isinstance(approved, bool):
322
+ raise HarnessError("the frontmatter approved must be true or false")
323
+ assets = data.get("assets", [])
324
+ if not isinstance(assets, list) or not all(isinstance(asset, str) for asset in assets):
325
+ raise HarnessError("the frontmatter assets must be a list of paths")
326
+ resolved_at = data.get("resolved_at")
327
+ if "resolved_at" in data and not isinstance(resolved_at, str):
328
+ raise HarnessError("the frontmatter resolved_at must be a quoted ISO 8601 date and time")
329
+ return NoteFrontmatter(note_type, date, time, approved, tuple(assets), resolved_at)
330
+
331
+
332
+ def check_draft(path: Path, harness_root: Path) -> list[str]:
333
+ """Every rule the draft at `path` breaks, each phrased for the path as the user gave it."""
334
+ problems = []
335
+ if path.parent.resolve() != (harness_root / NOTES_DIR).resolve():
336
+ problems.append(f"is not directly under {NOTES_DIR}/ of the harness (paths are relative to the harness root)")
337
+ if path.suffix != ".md" or not tasks.is_valid_slug(path.stem):
338
+ problems.append("is not a slug followed by .md (lowercase ASCII letters and digits joined by single hyphens)")
339
+ if not path.is_file():
340
+ problems.append("does not exist")
341
+ elif _starts_with_frontmatter(path):
342
+ problems.append("is already filed: it starts with a frontmatter")
343
+ return problems
344
+
345
+
346
+ def check_assets(paths: Sequence[str], harness_root: Path) -> tuple[list[str], list[str]]:
347
+ """The assets as relative paths to write in the frontmatter, and every problem found with them."""
348
+ assets_dir = (harness_root / ASSETS_DIR).resolve()
349
+ relative = []
350
+ problems = []
351
+ for given in paths:
352
+ path = (harness_root / given).resolve()
353
+ if not path.is_relative_to(assets_dir) or path == assets_dir:
354
+ problems.append(f"asset {given} is not under {ASSETS_DIR}/ of the harness")
355
+ elif not path.is_file():
356
+ problems.append(f"asset {given} does not exist")
357
+ else:
358
+ relative.append(path.relative_to(harness_root.resolve()).as_posix())
359
+ return relative, problems
360
+
361
+
362
+ def check_filed(path: Path, harness_root: Path) -> list[str]:
363
+ """Every reason the file at `path` is not a filed note of the harness."""
364
+ problems = []
365
+ if path.parent.resolve() != (harness_root / NOTES_DIR).resolve():
366
+ problems.append(f"is not directly under {NOTES_DIR}/ of the harness (paths are relative to the harness root)")
367
+ if filed_slug(path.name) is None:
368
+ problems.append("is not named as a filed note (YYYY-MM-DD-HHMMSS-<slug>.md)")
369
+ if not path.is_file():
370
+ problems.append("does not exist")
371
+ elif not _starts_with_frontmatter(path):
372
+ problems.append("is not filed: it has no frontmatter")
373
+ return problems
374
+
375
+
376
+ def check_resolvable(frontmatter: NoteFrontmatter) -> str | None:
377
+ """Why a filed note cannot be resolved, or None when it is approved and not resolved yet."""
378
+ if frontmatter.approved is None:
379
+ return "is not gated: it has no approved key, so there is nothing to resolve"
380
+ if frontmatter.resolved_at is not None:
381
+ return f"is already resolved, at {frontmatter.resolved_at}"
382
+ if frontmatter.approved is not True:
383
+ return "is not approved: only the user sets approved: true, by editing the note"
384
+ return None
385
+
386
+
387
+ def _starts_with_frontmatter(path: Path) -> bool:
388
+ with path.open(encoding="utf-8", errors="replace") as handle:
389
+ return handle.readline().rstrip() == tasks.FRONTMATTER_DELIMITER
390
+
391
+
392
+ def launch_message(ref: str) -> str:
393
+ return f"launch {ref}"
394
+
395
+
396
+ def note_message(note_type: str, slug: str) -> str:
397
+ return f"note {note_type} {slug}"
398
+
399
+
400
+ def resolve_message(slug: str) -> str:
401
+ return f"resolve {slug}"