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
@@ -0,0 +1,137 @@
1
+ ---
2
+ name: gemba-worktree
3
+ description: Check out the repositories of a task as git worktrees inside its harness, list them and remove them. Use whenever, inside a harness, the user asks to check out, attach, add or open a repository of the task, to start a repository from another branch, to list or show the worktrees, their branches or what is not pushed, or to detach, drop or remove a worktree, whatever the workstream.
4
+ ---
5
+
6
+ # Work on repositories in worktrees
7
+
8
+ A worktree is a checkout of another repository under `worktrees/` of the harness, on
9
+ one local branch of the run that gemba names after the task. The `gemba worktree`
10
+ commands create, move, list and remove the worktrees and their branches; every git
11
+ command against the repository a worktree comes from is theirs. Your part is to choose
12
+ the repositories and the base branch, run the commands, relay what they print, and do
13
+ the work of the task inside the worktrees.
14
+
15
+ Every organization-specific value (the repositories folder, the workstreams and their
16
+ rules) lives in `gemba.yaml`, at the tasks repo root. Run
17
+ `gemba config show --workstream <id>`, with the `workstream` of `harness.yaml`, on every
18
+ run and rely on nothing else you know or remember about repositories, branches or
19
+ workstreams. It prints the config resolved, from anywhere inside the harness:
20
+ `repos_dir` at the top level, and in the entry of the workstream, under
21
+ `processes.worktree`, the block of every verb, such as `processes.worktree.add`, with
22
+ the block shared by every workstream already joined to the workstream's. Wherever this
23
+ skill says to read a block, read its `context` as background and follow every entry of
24
+ its `rules` in order; when two rules contradict, follow the later one.
25
+
26
+ ## Prerequisite
27
+
28
+ The `gemba` command must be on the PATH. It is installed once from PyPI with
29
+ `uv tool install gemba-cli` and checked with `gemba --version`. If `gemba` is not
30
+ found, stop and tell the user to install it with that command. Every verb runs inside a
31
+ harness, from its root or any folder below it. Never run `git worktree` or
32
+ `git branch`, and never run any git command in the repository a worktree comes from.
33
+
34
+ ## Add
35
+
36
+ 1. **Find the repositories the task needs** in `task.md` and in the notes of the run.
37
+ Name each one by its folder name under the `repos_dir` that `gemba config show`
38
+ prints, such as `nakatomi-web`. When a name is not clear from them, ask the user; never
39
+ browse `repos_dir` or any folder above the harness to guess it.
40
+ 2. **Read the `worktree.add` block**, `processes.worktree.add` of the document. Read
41
+ `context` as background and follow every entry of `rules`: they say which branch each
42
+ repository starts from.
43
+ 3. **Choose the base.** Without `--from`, the worktree starts at the default branch of
44
+ `origin`. Pass `--from <branch>` when the rules, the task or the user name another
45
+ branch of `origin`. Only a branch is a base: never pass a tag or a commit.
46
+ 4. **Run the command** once per repository:
47
+
48
+ ```bash
49
+ gemba worktree add <repo> [--from <branch>]
50
+ ```
51
+
52
+ 5. **Relay** what it printed: the worktree path, `branch:`, `from:` and `created`, or
53
+ `already attached, worktree unchanged` when the repository is already checked out in
54
+ this harness. When it refuses, relay every line, the repositories it names as found
55
+ at the root of `repos_dir` included, and ask the user how to go on; never work
56
+ around a refusal.
57
+
58
+ ## Branches
59
+
60
+ - The branch of a worktree is local: gemba names it after the run, it exists only on
61
+ this machine and it is never pushed under its own name.
62
+ - It starts with no upstream. Inside the worktree, commit, fetch and pull freely; a
63
+ pull needs the upstream that the first push sets.
64
+ - A push reaches the remote, so it needs an `external-action` note whose frontmatter
65
+ reads `approved: true` and that names the branch of the remote. The push always names
66
+ its target, `git push -u origin HEAD:<the branch the note names>`; the first one sets
67
+ the upstream, which `gemba worktree list` then shows. Never run `git push` alone or
68
+ `git push origin HEAD`.
69
+ - To start again from another base, run `gemba worktree add <repo> --from <branch>` on
70
+ the repository already attached: it moves the branch of the run to that base and
71
+ keeps its name. It is not a rebase: it refuses while anything is uncommitted or on no
72
+ remote branch, and even after a push it discards the local tree. To carry your
73
+ commits onto another base instead, rebase inside the worktree with
74
+ `git rebase --onto origin/<new base> origin/<old base>`.
75
+
76
+ ## List
77
+
78
+ Read the `worktree.list` block, `processes.worktree.list` of the document, and run:
79
+
80
+ ```bash
81
+ gemba worktree list
82
+ ```
83
+
84
+ For each worktree it prints its path, `branch:`, `upstream:` (`none` until a push sets
85
+ it), `uncommitted:`, the number of paths not committed, and `unpushed:`, the number of
86
+ commits on no remote branch. Every value is read from git when it runs. Relay them; a
87
+ folder listed as `not a worktree` was not created by gemba, so tell the user. With no
88
+ worktree it prints `no worktrees`.
89
+
90
+ ## Remove
91
+
92
+ 1. **Read the `worktree.remove` block**, `processes.worktree.remove` of the document.
93
+ Read `context` as background and follow every entry of `rules` before showing the
94
+ state.
95
+ 2. **Show the state** by running the command without `--yes`:
96
+
97
+ ```bash
98
+ gemba worktree remove <repo>
99
+ ```
100
+
101
+ It prints the block `list` prints for that worktree, then
102
+ `not removed: pass --yes to delete the worktree`, and deletes nothing.
103
+ 3. **Ask the user.** Show them the state, ask whether to remove the worktree and its
104
+ local branch, and remind them to close any editor window open on that folder first.
105
+ A yes in the conversation is enough: the worktree and its branch are local.
106
+ 4. **Remove only after a yes**, by running the same command with `--yes`:
107
+
108
+ ```bash
109
+ gemba worktree remove <repo> --yes
110
+ ```
111
+
112
+ When the user says no, stop. Relay `removed`: the worktree and its local branch are
113
+ gone, and the remote is what it was.
114
+
115
+ ## When work is pending
116
+
117
+ `gemba worktree add --from` prints `worktree not moved:` and `gemba worktree remove`
118
+ prints `worktree not removed:` while the worktree holds uncommitted paths or commits on
119
+ no remote branch. Relay every line and say how each is saved: uncommitted paths by a
120
+ commit inside the worktree; commits on no remote branch by a push through an approved
121
+ `external-action` note. Never commit, push or delete anything to make the command pass.
122
+
123
+ **Never force first.** `--force` discards work. Never pass it on the first run of
124
+ `add --from` or `remove`, even when the user asks to force it: run the command without
125
+ it, relay what it reports as pending, and pass `--force` only after the user confirms,
126
+ in the conversation, that everything in that list is to be discarded. The same holds
127
+ for `add --force` without `--from`, which deletes and recreates a branch of the run
128
+ left without a worktree, discarding its commits.
129
+
130
+ ## Rules
131
+
132
+ - The commands own the worktrees and the branches of the run. Never run `git worktree`
133
+ or `git branch`, and never run git in the repository a worktree comes from.
134
+ - Nothing reaches the remote without an `external-action` note whose frontmatter reads
135
+ `approved: true`.
136
+ - When `gemba.yaml` changes, follow the new content. This skill never needs an edit when
137
+ a workstream, a rule or the repositories folder is added, changed or removed.
gemba/cli.py ADDED
@@ -0,0 +1,35 @@
1
+ """Entry point of the `gemba` command: subcommand dispatch and `--version`."""
2
+
3
+ import argparse
4
+ import sys
5
+ from collections.abc import Sequence
6
+ from importlib.metadata import version
7
+ from typing import NoReturn
8
+
9
+ from gemba.commands import archive, config, create, harness, init, mark, record, report, skills, worktree
10
+ from gemba.commands import list as list_ # named after the process; the alias keeps list() usable
11
+ from gemba.commands import set as set_ # named after the process; the alias keeps set() usable
12
+
13
+ COMMANDS = (init, create, list_, skills, config, harness, record, mark, archive, report, set_, worktree)
14
+
15
+
16
+ class ArgumentParser(argparse.ArgumentParser):
17
+ """Argument parser whose usage errors exit with 1, like every other gemba error."""
18
+
19
+ def error(self, message: str) -> NoReturn:
20
+ self.print_usage(sys.stderr)
21
+ self.exit(1, f"{self.prog}: error: {message}\n")
22
+
23
+
24
+ def main(argv: Sequence[str] | None = None) -> int:
25
+ parser = ArgumentParser(prog="gemba", description="Task-based system for working with AI agents.")
26
+ parser.add_argument("--version", action="version", version=version("gemba-cli"))
27
+ subparsers = parser.add_subparsers(dest="command", metavar="<command>")
28
+ for command in COMMANDS:
29
+ command.register(subparsers)
30
+
31
+ args = parser.parse_args(argv)
32
+ if args.command is None:
33
+ parser.print_help(sys.stderr)
34
+ return 1
35
+ return args.handler(args)
@@ -0,0 +1 @@
1
+ """One module per process of Gemba, each exposing `register(subparsers)`."""
@@ -0,0 +1,62 @@
1
+ """`gemba archive`: move tasks that went cold into the archive folder of their workstream."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path, PurePosixPath
8
+
9
+ from gemba import config, harness, tasks
10
+
11
+
12
+ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
13
+ parser = subparsers.add_parser(
14
+ "archive",
15
+ help="move tasks into the archive folder of their workstream",
16
+ description=(
17
+ f"Move the file of each task, unchanged, into {tasks.TASKS_DIR}/<workstream>/{tasks.ARCHIVE_DIR}/, "
18
+ "whatever its status. Each ref is handled on its own and gets one line, archived or not archived; "
19
+ "the exit code is 1 when any ref was refused. A task whose harness folder exists is refused. "
20
+ "Nothing is committed."
21
+ ),
22
+ )
23
+ parser.add_argument(
24
+ "refs", nargs="+", metavar="ref", help="ref of a task, such as SUP-1; give several to archive them"
25
+ )
26
+ parser.add_argument("--root", type=Path, help="tasks repo root (default: walk up to the nearest gemba.yaml)")
27
+ parser.set_defaults(handler=run)
28
+
29
+
30
+ def run(args: argparse.Namespace) -> int:
31
+ try:
32
+ gemba_config = config.load(root=args.root)
33
+ except config.ConfigError as exc:
34
+ print(f"gemba archive: {exc}", file=sys.stderr)
35
+ return 1
36
+
37
+ root = gemba_config.root
38
+ refused = False
39
+ for ref in args.refs:
40
+ try:
41
+ task = tasks.find_by_ref(root, gemba_config.workstreams, ref)
42
+ except tasks.TaskError as exc:
43
+ problem = f"cannot be resolved: {exc}"
44
+ else:
45
+ if task is None:
46
+ problem = f"is the ref of no task under {tasks.TASKS_DIR}/, archives included"
47
+ else:
48
+ folder = harness.harness_path(root, task.workstream, PurePosixPath(task.path).stem)
49
+ destination = tasks.archive_destination(task)
50
+ problem = tasks.archive_problem(
51
+ task,
52
+ folder.relative_to(root).as_posix() if folder.exists() else None,
53
+ (root / destination).exists(),
54
+ )
55
+ if problem is None:
56
+ (root / destination).parent.mkdir(parents=True, exist_ok=True)
57
+ (root / task.path).rename(root / destination)
58
+ print(f"archived: {destination}")
59
+ continue
60
+ refused = True
61
+ print(f"not archived: {ref} {problem}")
62
+ return 1 if refused else 0
@@ -0,0 +1,117 @@
1
+ """`gemba config check` and `gemba config show`: validate gemba.yaml, then summarize or resolve it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from gemba import config
10
+ from gemba.commands import selection
11
+
12
+
13
+ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
14
+ parser = subparsers.add_parser(
15
+ "config",
16
+ help="check the gemba config or show it resolved",
17
+ description=(
18
+ f"Read {config.CONFIG_FILENAME}, the config of the tasks repo, without ever writing it: the file is "
19
+ "edited by hand."
20
+ ),
21
+ )
22
+ parser.set_defaults(handler=lambda _args: usage(parser))
23
+ verbs = parser.add_subparsers(dest="verb", metavar="<verb>")
24
+
25
+ check = verbs.add_parser(
26
+ "check",
27
+ help="validate the config and summarize it",
28
+ description=(
29
+ f"Validate {config.CONFIG_FILENAME} as every command does before acting. A valid config is summarized "
30
+ "with its workstreams and prefixes, its default workstream and its agent providers; an invalid one is "
31
+ "reported with the path of the file and the first problem, and the command exits 1."
32
+ ),
33
+ )
34
+ check.add_argument("--root", type=Path, help="tasks repo root (default: walk up to the nearest gemba.yaml)")
35
+ check.set_defaults(handler=run_check)
36
+
37
+ show = verbs.add_parser(
38
+ "show",
39
+ help="print the effective config as one JSON document",
40
+ description=(
41
+ f"Validate {config.CONFIG_FILENAME} as check does and print the effective config as one JSON object: "
42
+ "the top-level keys, the defaults applied and null where nothing is declared, and `workstreams`, in "
43
+ "catalogue order, each with its prefix, its description, its effective metadata schema and, under "
44
+ "`processes`, the effective block of every process and verb, the base block followed by the "
45
+ "workstream's."
46
+ ),
47
+ )
48
+ show.add_argument("--workstream", metavar="ID", help="workstream identifier from gemba.yaml (default: every one)")
49
+ show.add_argument("--root", type=Path, help="tasks repo root (default: walk up to the nearest gemba.yaml)")
50
+ show.set_defaults(handler=run_show)
51
+
52
+
53
+ def run_check(args: argparse.Namespace) -> int:
54
+ try:
55
+ gemba_config = config.load(root=args.root)
56
+ except config.ConfigError as exc:
57
+ return fail("check", str(exc))
58
+
59
+ workstreams = ", ".join(
60
+ f"{workstream.id} ({workstream.prefix})" for workstream in gemba_config.workstreams.values()
61
+ )
62
+ providers = ", ".join(gemba_config.agent_providers) if gemba_config.agent_providers else "none declared"
63
+ print(f"{config.CONFIG_FILENAME} is valid")
64
+ print(f"workstreams: {workstreams}")
65
+ print(f"default workstream: {gemba_config.default_workstream}")
66
+ print(f"agent providers: {providers}")
67
+ return 0
68
+
69
+
70
+ def run_show(args: argparse.Namespace) -> int:
71
+ try:
72
+ gemba_config = config.load(root=args.root)
73
+ except config.ConfigError as exc:
74
+ return fail("show", str(exc))
75
+
76
+ if args.workstream is not None and args.workstream not in gemba_config.workstreams:
77
+ return fail(
78
+ "show",
79
+ f"unknown workstream '{args.workstream}' (valid workstreams: {', '.join(gemba_config.workstreams)})",
80
+ )
81
+ shown = [args.workstream] if args.workstream is not None else list(gemba_config.workstreams)
82
+ document = {
83
+ "organization": gemba_config.organization,
84
+ "branch_prefix": gemba_config.branch_prefix,
85
+ "repos_dir": gemba_config.repos_dir,
86
+ "ide": gemba_config.ide,
87
+ "agent_providers": None if gemba_config.agent_providers is None else list(gemba_config.agent_providers),
88
+ "default_workstream": gemba_config.default_workstream,
89
+ "workstreams": [workstream(gemba_config, workstream_id) for workstream_id in shown],
90
+ }
91
+ print(selection.dump(document))
92
+ return 0
93
+
94
+
95
+ def workstream(gemba_config: config.Config, workstream_id: str) -> dict[str, object]:
96
+ """The entry of one workstream in the document of show."""
97
+ declared = gemba_config.workstreams[workstream_id]
98
+ return {
99
+ "id": declared.id,
100
+ "prefix": declared.prefix,
101
+ "description": declared.description,
102
+ "metadata": [
103
+ {"name": field.name, "type": field.type, "required": field.required, "description": field.description}
104
+ for field in gemba_config.effective_schema(workstream_id)
105
+ ],
106
+ "processes": gemba_config.effective_processes(workstream_id),
107
+ }
108
+
109
+
110
+ def usage(parser: argparse.ArgumentParser) -> int:
111
+ parser.print_help(sys.stderr)
112
+ return 1
113
+
114
+
115
+ def fail(verb: str, message: str) -> int:
116
+ print(f"gemba config {verb}: {message}", file=sys.stderr)
117
+ return 1
@@ -0,0 +1,104 @@
1
+ """`gemba create`: validate a creation request, allocate the ref and write the task file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import datetime
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from gemba import config, metadata, tasks, values
11
+
12
+ CREATION_STATUSES = ("open", "in-progress", "blocked")
13
+
14
+
15
+ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
16
+ parser = subparsers.add_parser(
17
+ "create",
18
+ help="create a task file",
19
+ description="Validate the request against gemba.yaml, allocate the ref and write the task file.",
20
+ )
21
+ parser.add_argument("--workstream", required=True, help="workstream identifier from gemba.yaml")
22
+ parser.add_argument("--slug", required=True, help="lowercase ASCII words joined by single hyphens")
23
+ parser.add_argument("--date", help="creation date as YYYY-MM-DD (default: today)")
24
+ parser.add_argument("--status", default="open", help="open (default), in-progress or blocked")
25
+ parser.add_argument("--description", help="short description written at the top of the body")
26
+ parser.add_argument(
27
+ "--set",
28
+ dest="assignments",
29
+ action="append",
30
+ default=[],
31
+ metavar="KEY=VALUE",
32
+ help="metadata value, converted by the declared type of its field (null for none); "
33
+ "repeat a key of a text field to build a list",
34
+ )
35
+ parser.add_argument("--root", type=Path, help="tasks repo root (default: walk up to the nearest gemba.yaml)")
36
+ parser.set_defaults(handler=run)
37
+
38
+
39
+ def run(args: argparse.Namespace) -> int:
40
+ try:
41
+ gemba_config = config.load(root=args.root)
42
+ except config.ConfigError as exc:
43
+ return fail(str(exc))
44
+
45
+ errors = []
46
+ workstream = gemba_config.workstreams.get(args.workstream)
47
+ if workstream is None:
48
+ errors.append(
49
+ f"unknown workstream '{args.workstream}' (valid workstreams: {', '.join(gemba_config.workstreams)})"
50
+ )
51
+ if not tasks.is_valid_slug(args.slug):
52
+ errors.append(f"invalid slug '{args.slug}': use lowercase ASCII letters and digits joined by single hyphens")
53
+ if args.status == "done":
54
+ errors.append("status 'done' is not allowed at creation (allowed: open, in-progress, blocked)")
55
+ elif args.status not in CREATION_STATUSES:
56
+ errors.append(f"invalid status '{args.status}' (allowed: open, in-progress, blocked)")
57
+ created, date_error = parse_date(args.date)
58
+ if date_error:
59
+ errors.append(date_error)
60
+ assignments, assignment_errors = metadata.parse_assignments(args.assignments, allow_add=False)
61
+ errors.extend(assignment_errors)
62
+
63
+ task_metadata: dict[str, object] = {}
64
+ if workstream is not None:
65
+ schema = gemba_config.effective_schema(workstream.id)
66
+ assigned = metadata.assign(schema, {}, assignments, f"'{workstream.id}'")
67
+ errors.extend(assigned.problems)
68
+ task_metadata = assigned.values
69
+
70
+ if errors:
71
+ return fail("task not created:\n" + "\n".join(f"- {error}" for error in errors))
72
+ assert workstream is not None and created is not None, "every failed check above appended an error"
73
+
74
+ ref = tasks.next_ref(gemba_config.root, workstream.prefix)
75
+ folder = gemba_config.root / tasks.TASKS_DIR / workstream.id
76
+ target = folder / tasks.filename(created, ref, args.slug)
77
+ relative = target.relative_to(gemba_config.root).as_posix()
78
+ if target.exists():
79
+ return fail(f"task not created: {relative} already exists")
80
+
81
+ for path in (folder, folder / tasks.ARCHIVE_DIR):
82
+ path.mkdir(parents=True, exist_ok=True)
83
+ (path / tasks.GITKEEP).touch(exist_ok=True)
84
+ content = tasks.render(args.status, ref, created, task_metadata, args.description)
85
+ with target.open("x", encoding="utf-8") as handle:
86
+ handle.write(content)
87
+
88
+ print(relative)
89
+ print(ref)
90
+ return 0
91
+
92
+
93
+ def parse_date(text: str | None) -> tuple[datetime.date | None, str | None]:
94
+ if text is None:
95
+ return datetime.date.today(), None
96
+ created = values.iso_date(text)
97
+ if created is None:
98
+ return None, f"invalid date '{text}': use YYYY-MM-DD"
99
+ return created, None
100
+
101
+
102
+ def fail(message: str) -> int:
103
+ print(f"gemba create: {message}", file=sys.stderr)
104
+ return 1