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
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
"""`gemba harness`: launch the harness of a task, the folder of one run, file its notes and remove it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import dataclasses
|
|
7
|
+
import datetime
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import textwrap
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from importlib.metadata import version
|
|
15
|
+
from pathlib import Path, PurePosixPath
|
|
16
|
+
|
|
17
|
+
from gemba import config, harness, skills, tasks, worktree
|
|
18
|
+
from gemba.commands import worktree as worktree_command
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
22
|
+
parser = subparsers.add_parser(
|
|
23
|
+
"harness",
|
|
24
|
+
help="launch the harness of a task, file its notes and remove it",
|
|
25
|
+
description="Create the harness of a task, the folder of one run, work inside it, and remove it.",
|
|
26
|
+
)
|
|
27
|
+
parser.set_defaults(handler=lambda _args: usage(parser))
|
|
28
|
+
verbs = parser.add_subparsers(dest="verb", metavar="<verb>")
|
|
29
|
+
|
|
30
|
+
launch = verbs.add_parser(
|
|
31
|
+
"launch",
|
|
32
|
+
help="create the harness of a task and open it in the IDE",
|
|
33
|
+
description=(
|
|
34
|
+
f"Create {harness.HARNESSES_DIR}/<workstream>/<task file name>/ for the task with this ref: "
|
|
35
|
+
f"{harness.CONFIG_FILENAME}, {harness.TASK_LINK}, the guide and the harness skills of every agent "
|
|
36
|
+
f"provider of gemba.yaml, {harness.NOTES_DIR}/, {harness.ASSETS_DIR}/ and {harness.WORKTREES_DIR}/, "
|
|
37
|
+
"as a git repository with one commit. A harness already launched is left as it is. On every launch an "
|
|
38
|
+
"open task moves to in-progress, and the IDE command of gemba.yaml, when declared, is started with the "
|
|
39
|
+
"harness path."
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
launch.add_argument("ref", help="ref of the task, such as SUP-1")
|
|
43
|
+
launch.add_argument("--no-open", action="store_true", help="do not start the IDE command of gemba.yaml")
|
|
44
|
+
launch.add_argument("--root", type=Path, help="tasks repo root (default: walk up to the nearest gemba.yaml)")
|
|
45
|
+
launch.set_defaults(handler=run_launch)
|
|
46
|
+
|
|
47
|
+
types = "\n".join(f" {name:<16} {note_type.definition}" for name, note_type in harness.NOTE_TYPES.items())
|
|
48
|
+
note = verbs.add_parser(
|
|
49
|
+
"note",
|
|
50
|
+
help="file a note drafted under notes/",
|
|
51
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
52
|
+
description=textwrap.fill(
|
|
53
|
+
f"File the draft {harness.NOTES_DIR}/<slug>.md: rename it to "
|
|
54
|
+
f"{harness.NOTES_DIR}/YYYY-MM-DD-HHMMSS-<slug>.md after the clock, write its frontmatter above the body, "
|
|
55
|
+
f"which is kept byte for byte, and commit {harness.NOTES_DIR}/ and {harness.ASSETS_DIR}/. Paths are "
|
|
56
|
+
"relative to the harness root. An external-action note always waits for the user's approval, a "
|
|
57
|
+
"decision note only with --approval.",
|
|
58
|
+
width=79,
|
|
59
|
+
),
|
|
60
|
+
epilog=f"note types:\n{types}",
|
|
61
|
+
)
|
|
62
|
+
note.add_argument("file", help=f"the draft, {harness.NOTES_DIR}/<slug>.md, with no frontmatter")
|
|
63
|
+
note.add_argument("--type", required=True, choices=list(harness.NOTE_TYPES), help="one of the note types below")
|
|
64
|
+
note.add_argument(
|
|
65
|
+
"--approval", action="store_true", help="file a decision note with approved: false, to wait for the user"
|
|
66
|
+
)
|
|
67
|
+
note.add_argument(
|
|
68
|
+
"--asset",
|
|
69
|
+
dest="assets",
|
|
70
|
+
action="append",
|
|
71
|
+
default=[],
|
|
72
|
+
metavar="PATH",
|
|
73
|
+
help=f"an asset under {harness.ASSETS_DIR}/ the note refers to; repeat for several",
|
|
74
|
+
)
|
|
75
|
+
add_location(note)
|
|
76
|
+
note.set_defaults(handler=run_note)
|
|
77
|
+
|
|
78
|
+
resolve = verbs.add_parser(
|
|
79
|
+
"resolve",
|
|
80
|
+
help="record that an approved note was acted on",
|
|
81
|
+
description=(
|
|
82
|
+
f"Append resolved_at to the frontmatter of a filed note whose approved is true, keep its body as it "
|
|
83
|
+
f"is, and commit {harness.NOTES_DIR}/ and {harness.ASSETS_DIR}/. Write the outcome into the body "
|
|
84
|
+
"before running it. Paths are relative to the harness root."
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
resolve.add_argument("note", help=f"the filed note, {harness.NOTES_DIR}/YYYY-MM-DD-HHMMSS-<slug>.md")
|
|
88
|
+
add_location(resolve)
|
|
89
|
+
resolve.set_defaults(handler=run_resolve)
|
|
90
|
+
|
|
91
|
+
remove = verbs.add_parser(
|
|
92
|
+
"remove",
|
|
93
|
+
help="delete the harness of a task, its worktrees and their branches, after showing what it holds",
|
|
94
|
+
description=(
|
|
95
|
+
"Show the manifest of the harness of the task with this ref: its path, the task, the launch date and "
|
|
96
|
+
f"the number of notes, assets, commits and worktrees. With --yes, then remove every worktree under "
|
|
97
|
+
f"{harness.WORKTREES_DIR}/, delete every branch of the run in the repositories of repos_dir, and delete "
|
|
98
|
+
"the harness folder. Nothing is deleted while the harness repository has uncommitted changes, the "
|
|
99
|
+
"working directory is inside the harness, a worktree or a branch of the run holds uncommitted paths or "
|
|
100
|
+
f"commits on no remote branch, or a folder under {harness.WORKTREES_DIR}/ is not a worktree; --force "
|
|
101
|
+
"discards those three, never the first two. The task file and the remotes are not touched."
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
remove.add_argument("ref", help="ref of the task, such as SUP-1")
|
|
105
|
+
remove.add_argument("--yes", action="store_true", help="delete the harness after showing the manifest")
|
|
106
|
+
remove.add_argument(
|
|
107
|
+
"--force",
|
|
108
|
+
action="store_true",
|
|
109
|
+
help="delete even when the worktrees or the branches of the run hold work that is discarded",
|
|
110
|
+
)
|
|
111
|
+
remove.add_argument("--root", type=Path, help="tasks repo root (default: walk up to the nearest gemba.yaml)")
|
|
112
|
+
remove.set_defaults(handler=run_remove)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def run_launch(args: argparse.Namespace) -> int:
|
|
116
|
+
try:
|
|
117
|
+
gemba_config = config.load(root=args.root)
|
|
118
|
+
except config.ConfigError as exc:
|
|
119
|
+
return fail("launch", str(exc))
|
|
120
|
+
|
|
121
|
+
errors = []
|
|
122
|
+
if gemba_config.agent_providers is None:
|
|
123
|
+
errors.append(
|
|
124
|
+
f"no agent providers declared: add agent_providers to {config.CONFIG_FILENAME} "
|
|
125
|
+
f"(known providers: {', '.join(skills.PROVIDERS)})"
|
|
126
|
+
)
|
|
127
|
+
try:
|
|
128
|
+
task = tasks.find_by_ref(gemba_config.root, gemba_config.workstreams, args.ref)
|
|
129
|
+
except tasks.TaskError as exc:
|
|
130
|
+
task = None
|
|
131
|
+
errors.append(str(exc))
|
|
132
|
+
else:
|
|
133
|
+
if task is None:
|
|
134
|
+
errors.append(f"no task has ref '{args.ref}' under {tasks.TASKS_DIR}/, archives included")
|
|
135
|
+
elif task.archived:
|
|
136
|
+
errors.append(f"{task.ref} is archived ({task.path}): an archived task cannot be launched")
|
|
137
|
+
elif task.status == "done":
|
|
138
|
+
errors.append(
|
|
139
|
+
f"{task.ref} is done ({task.path}): a done task cannot be launched; "
|
|
140
|
+
f"move it to open or in-progress first with gemba mark {task.ref} <status>"
|
|
141
|
+
)
|
|
142
|
+
if errors:
|
|
143
|
+
return fail("launch", "harness not launched:\n" + "\n".join(f"- {error}" for error in errors))
|
|
144
|
+
assert task is not None and gemba_config.agent_providers is not None, "every failed check appended an error"
|
|
145
|
+
|
|
146
|
+
root = gemba_config.root
|
|
147
|
+
folder = harness.harness_path(root, task.workstream, PurePosixPath(task.path).stem)
|
|
148
|
+
print(folder.relative_to(root).as_posix())
|
|
149
|
+
launched = (folder / harness.CONFIG_FILENAME).is_file()
|
|
150
|
+
today = datetime.date.today()
|
|
151
|
+
if launched and has_commit(folder):
|
|
152
|
+
print("already launched, harness unchanged")
|
|
153
|
+
start_work(root, task, today)
|
|
154
|
+
return open_ide(gemba_config, folder, args.no_open)
|
|
155
|
+
|
|
156
|
+
write_layout(gemba_config, task, folder)
|
|
157
|
+
if not launched:
|
|
158
|
+
package_version = version("gemba-cli")
|
|
159
|
+
identity = harness.HarnessConfig(
|
|
160
|
+
task=task.path,
|
|
161
|
+
ref=task.ref,
|
|
162
|
+
workstream=task.workstream,
|
|
163
|
+
agent_providers=gemba_config.agent_providers,
|
|
164
|
+
launched_at=today,
|
|
165
|
+
gemba_version=package_version,
|
|
166
|
+
)
|
|
167
|
+
write_new(folder / harness.CONFIG_FILENAME, harness.render_config(identity))
|
|
168
|
+
start_work(root, task, today)
|
|
169
|
+
|
|
170
|
+
if not (folder / ".git").exists():
|
|
171
|
+
problem = git(folder, "init", "--quiet", "--initial-branch", harness.BRANCH)
|
|
172
|
+
if problem:
|
|
173
|
+
return fail("launch", git_failure(problem, task.ref))
|
|
174
|
+
if not has_commit(folder):
|
|
175
|
+
problem = git(folder, "add", "-A") or git(folder, "commit", "--quiet", "-m", harness.launch_message(task.ref))
|
|
176
|
+
if problem:
|
|
177
|
+
return fail("launch", git_failure(problem, task.ref))
|
|
178
|
+
return open_ide(gemba_config, folder, args.no_open)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def start_work(root: Path, task: tasks.Task, today: datetime.date) -> None:
|
|
182
|
+
"""Move an open task to in-progress through the status table, and say what happened to its status."""
|
|
183
|
+
task_file = root / task.path
|
|
184
|
+
text, moved = tasks.start(task_file.read_bytes().decode("utf-8"), today)
|
|
185
|
+
if moved.changed:
|
|
186
|
+
task_file.write_bytes(text.encode("utf-8"))
|
|
187
|
+
print(f"status: {moved.old_status} -> {moved.new_status}")
|
|
188
|
+
else:
|
|
189
|
+
print(f"status: {moved.old_status}, left as it was")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def run_note(args: argparse.Namespace) -> int:
|
|
193
|
+
try:
|
|
194
|
+
folder = locate(args.harness)
|
|
195
|
+
except harness.HarnessError as exc:
|
|
196
|
+
return fail("note", str(exc))
|
|
197
|
+
|
|
198
|
+
draft = folder / args.file
|
|
199
|
+
errors = [f"{args.file} {problem}" for problem in harness.check_draft(draft, folder)]
|
|
200
|
+
if problem := harness.approval_problem(args.type, args.approval):
|
|
201
|
+
errors.append(problem)
|
|
202
|
+
assets, asset_errors = harness.check_assets(args.assets, folder)
|
|
203
|
+
errors.extend(asset_errors)
|
|
204
|
+
if errors:
|
|
205
|
+
return fail("note", "note not filed:\n" + "\n".join(f"- {error}" for error in errors))
|
|
206
|
+
|
|
207
|
+
now = datetime.datetime.now()
|
|
208
|
+
target = draft.parent / harness.note_filename(now, draft.stem)
|
|
209
|
+
relative = target.resolve().relative_to(folder).as_posix()
|
|
210
|
+
if target.exists():
|
|
211
|
+
return fail("note", f"note not filed: {relative} already exists; file the note again")
|
|
212
|
+
try:
|
|
213
|
+
body = draft.read_bytes().decode("utf-8")
|
|
214
|
+
except UnicodeDecodeError:
|
|
215
|
+
return fail("note", f"note not filed: {args.file} is not UTF-8 text")
|
|
216
|
+
frontmatter = harness.NoteFrontmatter(
|
|
217
|
+
type=args.type,
|
|
218
|
+
date=now.date(),
|
|
219
|
+
time=f"{now:%H:%M:%S}",
|
|
220
|
+
approved=False if harness.is_gated(args.type, args.approval) else None,
|
|
221
|
+
assets=tuple(assets),
|
|
222
|
+
)
|
|
223
|
+
with target.open("xb") as handle:
|
|
224
|
+
handle.write(f"{harness.render_note_frontmatter(frontmatter)}\n{body}".encode())
|
|
225
|
+
draft.unlink()
|
|
226
|
+
|
|
227
|
+
if problem := commit(folder, harness.note_message(args.type, draft.stem)):
|
|
228
|
+
return fail("note", f"{relative} is filed but not committed: {problem}")
|
|
229
|
+
print(relative)
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def run_resolve(args: argparse.Namespace) -> int:
|
|
234
|
+
try:
|
|
235
|
+
folder = locate(args.harness)
|
|
236
|
+
except harness.HarnessError as exc:
|
|
237
|
+
return fail("resolve", str(exc))
|
|
238
|
+
|
|
239
|
+
path = folder / args.note
|
|
240
|
+
problems = harness.check_filed(path, folder)
|
|
241
|
+
if problems:
|
|
242
|
+
return fail("resolve", "note not resolved:\n" + "\n".join(f"- {args.note} {problem}" for problem in problems))
|
|
243
|
+
try:
|
|
244
|
+
block, rest = harness.split_note(path.read_bytes().decode("utf-8"))
|
|
245
|
+
assert block is not None, "check_filed found the frontmatter"
|
|
246
|
+
frontmatter = harness.parse_note_frontmatter(block)
|
|
247
|
+
except (harness.HarnessError, UnicodeDecodeError) as exc:
|
|
248
|
+
return fail("resolve", f"note not resolved: {args.note}: {exc}")
|
|
249
|
+
if problem := harness.check_resolvable(frontmatter):
|
|
250
|
+
return fail("resolve", f"note not resolved: {args.note} {problem}")
|
|
251
|
+
|
|
252
|
+
resolved_at = datetime.datetime.now().isoformat(timespec="seconds")
|
|
253
|
+
resolved = dataclasses.replace(frontmatter, resolved_at=resolved_at)
|
|
254
|
+
path.write_bytes(f"{harness.render_note_frontmatter(resolved)}{rest}".encode())
|
|
255
|
+
relative = path.resolve().relative_to(folder).as_posix()
|
|
256
|
+
slug = harness.filed_slug(path.name)
|
|
257
|
+
assert slug is not None, "check_filed matched the name"
|
|
258
|
+
if problem := commit(folder, harness.resolve_message(slug)):
|
|
259
|
+
return fail("resolve", f"{relative} is resolved but not committed: {problem}")
|
|
260
|
+
print(relative)
|
|
261
|
+
return 0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def run_remove(args: argparse.Namespace) -> int:
|
|
265
|
+
try:
|
|
266
|
+
gemba_config = config.load(root=args.root)
|
|
267
|
+
except config.ConfigError as exc:
|
|
268
|
+
return fail("remove", str(exc))
|
|
269
|
+
|
|
270
|
+
root = gemba_config.root
|
|
271
|
+
try:
|
|
272
|
+
task = tasks.find_by_ref(root, gemba_config.workstreams, args.ref)
|
|
273
|
+
except tasks.TaskError as exc:
|
|
274
|
+
return refuse_removal([str(exc)])
|
|
275
|
+
if task is None:
|
|
276
|
+
return refuse_removal([f"no task has ref '{args.ref}' under {tasks.TASKS_DIR}/, archives included"])
|
|
277
|
+
folder = harness.harness_path(root, task.workstream, PurePosixPath(task.path).stem)
|
|
278
|
+
relative = folder.relative_to(root).as_posix()
|
|
279
|
+
if not folder.exists():
|
|
280
|
+
return refuse_removal([f"{task.ref} has no harness: nothing exists at {relative}"])
|
|
281
|
+
if not (folder / harness.CONFIG_FILENAME).is_file():
|
|
282
|
+
not_a_harness = f"{relative} is not a harness: it holds no {harness.CONFIG_FILENAME}"
|
|
283
|
+
return refuse_removal([f"{not_a_harness}; delete it by hand if it is not needed"])
|
|
284
|
+
try:
|
|
285
|
+
identity = harness.read_config(folder, list(skills.PROVIDERS))
|
|
286
|
+
except harness.HarnessError as exc:
|
|
287
|
+
return refuse_removal([str(exc)])
|
|
288
|
+
|
|
289
|
+
status = git_lines(folder, "status", "--porcelain", "--untracked-files=all")
|
|
290
|
+
commits = git_lines(folder, "rev-list", "--count", "HEAD") if has_commit(folder) else ["0"]
|
|
291
|
+
names = worktree_command.entries(folder)
|
|
292
|
+
states = [worktree_command.read_state(folder, name) for name in names]
|
|
293
|
+
orphans = orphan_branches(gemba_config, identity.ref, folder.resolve())
|
|
294
|
+
manifest = harness.Manifest(
|
|
295
|
+
path=relative,
|
|
296
|
+
task=identity.task,
|
|
297
|
+
launched_at=identity.launched_at,
|
|
298
|
+
notes=count_files(folder / harness.NOTES_DIR),
|
|
299
|
+
assets=count_files(folder / harness.ASSETS_DIR),
|
|
300
|
+
commits=int(commits[0]) if commits else 0,
|
|
301
|
+
worktrees=sum(1 for state in states if state.kind != "not a worktree"),
|
|
302
|
+
uncommitted=None if status is None else harness.uncommitted_paths(status),
|
|
303
|
+
)
|
|
304
|
+
print(harness.render_manifest(manifest))
|
|
305
|
+
found = [(orphan.repo, orphan.branch, orphan.unpushed) for orphan in orphans]
|
|
306
|
+
problems = harness.removal_problems(Path.cwd().resolve(), folder.resolve(), status, states, found, args.force)
|
|
307
|
+
if problems:
|
|
308
|
+
return refuse_removal(problems)
|
|
309
|
+
if not args.yes:
|
|
310
|
+
print(harness.NOT_REMOVED)
|
|
311
|
+
return 0
|
|
312
|
+
|
|
313
|
+
if problem := delete_run(folder, relative, names, states, orphans, args.force):
|
|
314
|
+
again = f"run gemba harness remove {task.ref} --yes again to remove what is left"
|
|
315
|
+
return fail("remove", f"{relative} was not removed completely: {problem}; {again}")
|
|
316
|
+
try:
|
|
317
|
+
shutil.rmtree(folder)
|
|
318
|
+
except OSError as exc:
|
|
319
|
+
return fail("remove", f"{relative} was not removed completely: {exc}; delete what is left by hand")
|
|
320
|
+
print(harness.REMOVED)
|
|
321
|
+
return 0
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def delete_run(
|
|
325
|
+
folder: Path,
|
|
326
|
+
relative: str,
|
|
327
|
+
names: list[str],
|
|
328
|
+
states: list[worktree.State],
|
|
329
|
+
orphans: list[Orphan],
|
|
330
|
+
force: bool,
|
|
331
|
+
) -> str | None:
|
|
332
|
+
"""Remove every worktree of the harness, then delete every branch of the run left without one.
|
|
333
|
+
|
|
334
|
+
None when everything is deleted; otherwise what failed, followed by what was deleted before it.
|
|
335
|
+
"""
|
|
336
|
+
done = []
|
|
337
|
+
for name, state in zip(names, states, strict=True):
|
|
338
|
+
if state.kind == "not a worktree":
|
|
339
|
+
continue
|
|
340
|
+
path = f"{relative}/{state.path}"
|
|
341
|
+
if problem := worktree_command.delete(worktree.worktree_path(folder, name), path, state.branch, force):
|
|
342
|
+
return "; ".join([problem, *done])
|
|
343
|
+
done.append(f"{path} was removed")
|
|
344
|
+
for orphan in orphans:
|
|
345
|
+
# A record left by a worktree folder deleted by hand keeps its branch checked out until pruned.
|
|
346
|
+
if problem := git(orphan.repository, "worktree", "prune") or git(
|
|
347
|
+
orphan.repository, "branch", "--quiet", "-D", orphan.branch
|
|
348
|
+
):
|
|
349
|
+
return "; ".join([problem, *done])
|
|
350
|
+
done.append(f"branch {orphan.branch} of {orphan.repo} was deleted")
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@dataclass(frozen=True)
|
|
355
|
+
class Orphan:
|
|
356
|
+
"""A branch of the run left in a repository of repos_dir without a worktree."""
|
|
357
|
+
|
|
358
|
+
repo: str
|
|
359
|
+
repository: Path
|
|
360
|
+
branch: str
|
|
361
|
+
unpushed: int
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def orphan_branches(gemba_config: config.Config, ref: str, harness_root: Path) -> list[Orphan]:
|
|
365
|
+
"""The branches of the run of `ref` left without a worktree in the repositories at the root of repos_dir.
|
|
366
|
+
|
|
367
|
+
A branch is the run's when it is named after the run, or when it is recorded for a
|
|
368
|
+
worktree under `harness_root` whose folder is gone. None is found without repos_dir.
|
|
369
|
+
"""
|
|
370
|
+
if gemba_config.repos_dir is None:
|
|
371
|
+
return []
|
|
372
|
+
repos = worktree.repos_dir(gemba_config.root, gemba_config.repos_dir)
|
|
373
|
+
named = None
|
|
374
|
+
if gemba_config.organization is not None:
|
|
375
|
+
named = worktree.branch_name(gemba_config.branch_prefix, gemba_config.organization, ref)
|
|
376
|
+
orphans = []
|
|
377
|
+
for repo in worktree_command.repositories(repos) or []:
|
|
378
|
+
repository = (repos / repo).resolve()
|
|
379
|
+
records = worktree.parse_worktrees(git_lines(repository, "worktree", "list", "--porcelain") or [])
|
|
380
|
+
held = {record.branch for record in records if not record.prunable}
|
|
381
|
+
branches = {
|
|
382
|
+
record.branch
|
|
383
|
+
for record in records
|
|
384
|
+
if record.prunable and record.branch and Path(record.path).is_relative_to(harness_root)
|
|
385
|
+
}
|
|
386
|
+
if named is not None and named not in held:
|
|
387
|
+
branches.add(named)
|
|
388
|
+
for branch in sorted(branches):
|
|
389
|
+
if not worktree_command.has_branch(repository, branch):
|
|
390
|
+
continue
|
|
391
|
+
count = git_lines(repository, "rev-list", "--count", branch, "--not", "--remotes")
|
|
392
|
+
if count:
|
|
393
|
+
orphans.append(Orphan(repo, repository, branch, int(count[0])))
|
|
394
|
+
return orphans
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def refuse_removal(problems: list[str]) -> int:
|
|
398
|
+
return fail("remove", f"{harness.REMOVAL_REFUSED}\n" + "\n".join(f"- {problem}" for problem in problems))
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def count_files(folder: Path) -> int:
|
|
402
|
+
"""The number of files at any depth under `folder`; none when it does not exist."""
|
|
403
|
+
return sum(1 for path in folder.rglob("*") if path.is_file()) if folder.is_dir() else 0
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def add_location(parser: argparse.ArgumentParser) -> None:
|
|
407
|
+
"""The options every verb acting on an existing harness takes."""
|
|
408
|
+
parser.add_argument(
|
|
409
|
+
"--harness", type=Path, help=f"harness root (default: walk up to the nearest {harness.CONFIG_FILENAME})"
|
|
410
|
+
)
|
|
411
|
+
# Accepted like on every command; the harness is found from --harness or the working directory.
|
|
412
|
+
parser.add_argument("--root", type=Path, help=argparse.SUPPRESS)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def locate(given: Path | None) -> Path:
|
|
416
|
+
"""The harness root named by `--harness`, or the nearest one at or above the working directory."""
|
|
417
|
+
if given is None:
|
|
418
|
+
return harness.find_root(Path.cwd())
|
|
419
|
+
folder = given.resolve()
|
|
420
|
+
if not (folder / harness.CONFIG_FILENAME).is_file():
|
|
421
|
+
raise harness.HarnessError(f"{given} is not a harness: it holds no {harness.CONFIG_FILENAME}")
|
|
422
|
+
return folder
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def commit(folder: Path, message: str) -> str | None:
|
|
426
|
+
"""Commit the notes and assets of the harness; None when it succeeds, git's message otherwise."""
|
|
427
|
+
return git(folder, "add", "-A", "--", harness.NOTES_DIR, harness.ASSETS_DIR) or git(
|
|
428
|
+
folder, "commit", "--quiet", "-m", message
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def write_layout(gemba_config: config.Config, task: tasks.Task, folder: Path) -> None:
|
|
433
|
+
"""Every piece of the harness except its config, each written only when it is missing."""
|
|
434
|
+
assert gemba_config.agent_providers is not None
|
|
435
|
+
for name in (harness.NOTES_DIR, harness.ASSETS_DIR, harness.WORKTREES_DIR):
|
|
436
|
+
(folder / name).mkdir(parents=True, exist_ok=True)
|
|
437
|
+
write_new(folder / harness.GITIGNORE, harness.GITIGNORE_TEXT)
|
|
438
|
+
link = folder / harness.TASK_LINK
|
|
439
|
+
if not link.is_symlink() and not link.exists():
|
|
440
|
+
link.symlink_to(harness.task_link_target(task.path))
|
|
441
|
+
guide = harness.render_guide(
|
|
442
|
+
skills.guide_asset(),
|
|
443
|
+
config.guide(gemba_config.processes),
|
|
444
|
+
config.guide(gemba_config.workstreams[task.workstream].processes),
|
|
445
|
+
)
|
|
446
|
+
for provider in gemba_config.agent_providers:
|
|
447
|
+
write_new(folder / skills.PROVIDERS[provider].guide, guide)
|
|
448
|
+
for action in skills.plan(folder, gemba_config.agent_providers, version("gemba-cli"), "harness"):
|
|
449
|
+
if action.state == "missing":
|
|
450
|
+
write_new(folder / action.path, action.content)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def write_new(path: Path, content: str) -> None:
|
|
454
|
+
"""Write `content` at `path` unless a file is already there."""
|
|
455
|
+
if path.exists():
|
|
456
|
+
return
|
|
457
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
458
|
+
path.write_text(content, encoding="utf-8", newline="\n")
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def has_commit(folder: Path) -> bool:
|
|
462
|
+
"""Whether the harness has a git repository of its own holding at least one commit."""
|
|
463
|
+
if not (folder / ".git").exists():
|
|
464
|
+
return False
|
|
465
|
+
return git(folder, "rev-parse", "--verify", "--quiet", "HEAD") is None
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def git(folder: Path, *arguments: str) -> str | None:
|
|
469
|
+
"""Run git in the harness; None when it succeeds, the failed command and git's message otherwise."""
|
|
470
|
+
command = ["git", "-C", str(folder), *arguments]
|
|
471
|
+
try:
|
|
472
|
+
result = subprocess.run(command, capture_output=True, text=True)
|
|
473
|
+
except OSError as exc:
|
|
474
|
+
return f"git could not be run: {exc}"
|
|
475
|
+
if result.returncode != 0:
|
|
476
|
+
message = (result.stderr or result.stdout).strip()
|
|
477
|
+
return f"git {' '.join(arguments)} failed: {message}" if message else f"git {' '.join(arguments)} failed"
|
|
478
|
+
return None
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def git_lines(folder: Path, *arguments: str) -> list[str] | None:
|
|
482
|
+
"""The output lines of a git command that only reads the harness repository, or None when it fails.
|
|
483
|
+
|
|
484
|
+
Optional locks are off, so that reading never rewrites the index. A folder without
|
|
485
|
+
a `.git` of its own is never read, since git would read the repository around it.
|
|
486
|
+
"""
|
|
487
|
+
if not (folder / ".git").exists():
|
|
488
|
+
return None
|
|
489
|
+
command = ["git", "--no-optional-locks", "-C", str(folder), *arguments]
|
|
490
|
+
try:
|
|
491
|
+
result = subprocess.run(command, capture_output=True, text=True)
|
|
492
|
+
except OSError:
|
|
493
|
+
return None
|
|
494
|
+
return result.stdout.splitlines() if result.returncode == 0 else None
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def git_failure(problem: str, ref: str) -> str:
|
|
498
|
+
hint = ""
|
|
499
|
+
if "user.email" in problem or "identity" in problem.lower():
|
|
500
|
+
hint = "\nset your git identity with git config --global user.name and user.email"
|
|
501
|
+
return f"{problem}{hint}\nthe files written are kept; run gemba harness launch {ref} again to finish the harness"
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def open_ide(gemba_config: config.Config, folder: Path, no_open: bool) -> int:
|
|
505
|
+
"""Start the IDE command of the config on the harness without waiting for it, and say so."""
|
|
506
|
+
if no_open or gemba_config.ide is None:
|
|
507
|
+
return 0
|
|
508
|
+
try:
|
|
509
|
+
command = [*shlex.split(gemba_config.ide), str(folder)]
|
|
510
|
+
subprocess.Popen(
|
|
511
|
+
command,
|
|
512
|
+
stdin=subprocess.DEVNULL,
|
|
513
|
+
stdout=subprocess.DEVNULL,
|
|
514
|
+
stderr=subprocess.DEVNULL,
|
|
515
|
+
start_new_session=True,
|
|
516
|
+
)
|
|
517
|
+
except (OSError, ValueError) as exc:
|
|
518
|
+
return fail(
|
|
519
|
+
"launch", f"the harness is ready but the IDE command '{gemba_config.ide}' could not be started: {exc}"
|
|
520
|
+
)
|
|
521
|
+
print(f"opening: {' '.join(command)}")
|
|
522
|
+
return 0
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def usage(parser: argparse.ArgumentParser) -> int:
|
|
526
|
+
parser.print_help(sys.stderr)
|
|
527
|
+
return 1
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def fail(verb: str, message: str) -> int:
|
|
531
|
+
# What was printed before, such as the manifest of remove, comes first in a combined capture.
|
|
532
|
+
sys.stdout.flush()
|
|
533
|
+
print(f"gemba harness {verb}: {message}", file=sys.stderr)
|
|
534
|
+
return 1
|
gemba/commands/init.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""`gemba init`: make a folder a tasks repo, writing only what is missing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from importlib.metadata import version
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from gemba import config, repo, skills
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
14
|
+
parser = subparsers.add_parser(
|
|
15
|
+
"init",
|
|
16
|
+
help="make a folder a tasks repo",
|
|
17
|
+
description=(
|
|
18
|
+
f"Make the folder a tasks repo: {config.CONFIG_FILENAME} with the one workstream default, the "
|
|
19
|
+
f"{repo.GITKEEP} file, the {repo.IGNORED_LINE} line of {repo.GITIGNORE} and the tasks repo skills "
|
|
20
|
+
"under the skills folder of every agent provider. Only what is missing is written: an existing "
|
|
21
|
+
f"{config.CONFIG_FILENAME} is never rewritten and gives the providers, and a skill already on disk is "
|
|
22
|
+
"kept. Nothing is asked and no git is run."
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"path", nargs="?", default=".", help="folder to initialize, created when missing (default: the working one)"
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--providers",
|
|
30
|
+
metavar="ID[,ID...]",
|
|
31
|
+
help=f"comma-separated agent providers to declare in a new {config.CONFIG_FILENAME} "
|
|
32
|
+
f"(default: every known provider, {', '.join(skills.PROVIDERS)})",
|
|
33
|
+
)
|
|
34
|
+
parser.set_defaults(handler=run)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run(args: argparse.Namespace) -> int:
|
|
38
|
+
target = Path(args.path).resolve()
|
|
39
|
+
config_path = target / config.CONFIG_FILENAME
|
|
40
|
+
exists = config_path.is_file()
|
|
41
|
+
|
|
42
|
+
errors = []
|
|
43
|
+
try:
|
|
44
|
+
found = config.find_root(target.parent)
|
|
45
|
+
except config.ConfigError:
|
|
46
|
+
pass
|
|
47
|
+
else:
|
|
48
|
+
errors.append(repo.nested_problem(target, found))
|
|
49
|
+
if target.exists() and not target.is_dir():
|
|
50
|
+
errors.append(f"{target} is not a folder")
|
|
51
|
+
selected = list(skills.PROVIDERS)
|
|
52
|
+
if args.providers is not None and exists:
|
|
53
|
+
errors.append(repo.conflict_problem())
|
|
54
|
+
elif args.providers is not None:
|
|
55
|
+
selected = [provider.strip() for provider in args.providers.split(",")]
|
|
56
|
+
errors.extend(repo.providers_problem(selected))
|
|
57
|
+
if errors:
|
|
58
|
+
return fail("nothing written:\n" + "\n".join(f"- {error}" for error in errors))
|
|
59
|
+
|
|
60
|
+
providers: tuple[str, ...] = tuple(selected)
|
|
61
|
+
if exists:
|
|
62
|
+
try:
|
|
63
|
+
gemba_config = config.load(root=target)
|
|
64
|
+
except config.ConfigError as exc:
|
|
65
|
+
return fail(str(exc))
|
|
66
|
+
if gemba_config.agent_providers is None:
|
|
67
|
+
return fail(repo.no_providers_problem())
|
|
68
|
+
providers = gemba_config.agent_providers
|
|
69
|
+
|
|
70
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
if exists:
|
|
72
|
+
report("kept", config.CONFIG_FILENAME)
|
|
73
|
+
else:
|
|
74
|
+
config_path.write_text(repo.render_config(providers), encoding="utf-8", newline="\n")
|
|
75
|
+
report("written", config.CONFIG_FILENAME)
|
|
76
|
+
|
|
77
|
+
gitignore = target / repo.GITIGNORE
|
|
78
|
+
existing = gitignore.read_bytes().decode("utf-8", errors="surrogateescape") if gitignore.is_file() else None
|
|
79
|
+
text = repo.gitignore_text(existing)
|
|
80
|
+
if text is not None:
|
|
81
|
+
gitignore.write_bytes(text.encode("utf-8", errors="surrogateescape"))
|
|
82
|
+
report("kept" if text is None else "written" if existing is None else "updated", repo.GITIGNORE)
|
|
83
|
+
|
|
84
|
+
gitkeep = target / repo.GITKEEP
|
|
85
|
+
if gitkeep.is_file():
|
|
86
|
+
report("kept", repo.GITKEEP)
|
|
87
|
+
else:
|
|
88
|
+
gitkeep.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
gitkeep.touch()
|
|
90
|
+
report("written", repo.GITKEEP)
|
|
91
|
+
|
|
92
|
+
# A skill on disk is kept whatever its state: refreshing it is the work of gemba skills update.
|
|
93
|
+
for action in skills.plan(target, providers, version("gemba-cli"), "repo"):
|
|
94
|
+
if action.state == "missing":
|
|
95
|
+
path = target / action.path
|
|
96
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
path.write_text(action.content, encoding="utf-8", newline="\n")
|
|
98
|
+
report("written", action.path)
|
|
99
|
+
else:
|
|
100
|
+
report("kept", action.path)
|
|
101
|
+
|
|
102
|
+
print(repo.NEXT_STEP)
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def report(outcome: str, path: str) -> None:
|
|
107
|
+
print(f"{outcome} {path}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def fail(message: str) -> int:
|
|
111
|
+
print(f"gemba init: {message}", file=sys.stderr)
|
|
112
|
+
return 1
|