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/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}"
|