chitty 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.
- chitty/__init__.py +0 -0
- chitty/check.py +67 -0
- chitty/cli.py +355 -0
- chitty/config.py +140 -0
- chitty/errors.py +2 -0
- chitty/git.py +29 -0
- chitty/guide.md +14 -0
- chitty/ids.py +73 -0
- chitty/mcp.py +178 -0
- chitty/model.py +576 -0
- chitty/store.py +449 -0
- chitty-0.1.0.dist-info/METADATA +79 -0
- chitty-0.1.0.dist-info/RECORD +16 -0
- chitty-0.1.0.dist-info/WHEEL +4 -0
- chitty-0.1.0.dist-info/entry_points.txt +2 -0
- chitty-0.1.0.dist-info/licenses/LICENSE +21 -0
chitty/__init__.py
ADDED
|
File without changes
|
chitty/check.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Cross-file validation: every file parses, IDs are unique, references resolve."""
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from chitty import ids
|
|
9
|
+
from chitty.errors import ChittyError
|
|
10
|
+
from chitty.ids import TaskId
|
|
11
|
+
from chitty.model import Task, parse
|
|
12
|
+
from chitty.store import Context, Repo, scan
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Problem:
|
|
17
|
+
path: Path
|
|
18
|
+
message: str
|
|
19
|
+
|
|
20
|
+
def __str__(self) -> str:
|
|
21
|
+
return f"{self.path}: {self.message}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def check(repo: Repo, contexts: Sequence[Context], paths: Sequence[Path]) -> list[Problem]:
|
|
25
|
+
"""Problems in the given contexts, or only in the given paths if any are given.
|
|
26
|
+
|
|
27
|
+
References are always resolved against the whole repo.
|
|
28
|
+
"""
|
|
29
|
+
problems: list[Problem] = []
|
|
30
|
+
by_id: dict[TaskId, list[Path]] = defaultdict(list)
|
|
31
|
+
tasks: dict[Path, Task] = {}
|
|
32
|
+
in_context: dict[Path, Context] = {}
|
|
33
|
+
for context in repo.contexts():
|
|
34
|
+
for archived in (False, True):
|
|
35
|
+
for path, problem in scan(context, archived):
|
|
36
|
+
in_context[path] = context
|
|
37
|
+
if problem:
|
|
38
|
+
problems.append(Problem(path, problem))
|
|
39
|
+
continue
|
|
40
|
+
by_id[ids.parse_filename(path.name)[0]].append(path)
|
|
41
|
+
try:
|
|
42
|
+
tasks[path] = parse(path.read_text())
|
|
43
|
+
except ChittyError as error:
|
|
44
|
+
problems.append(Problem(path, str(error)))
|
|
45
|
+
for task_id, task_paths in by_id.items():
|
|
46
|
+
if len(task_paths) > 1:
|
|
47
|
+
others = ", ".join(str(p) for p in task_paths)
|
|
48
|
+
problems += [
|
|
49
|
+
Problem(p, f"{task_id} is used by more than one file: {others}") for p in task_paths
|
|
50
|
+
]
|
|
51
|
+
for path, task in tasks.items():
|
|
52
|
+
own_id = ids.parse_filename(path.name)[0]
|
|
53
|
+
for reference in task.frontmatter.depends_on:
|
|
54
|
+
if reference == str(own_id):
|
|
55
|
+
problems.append(Problem(path, "depends_on lists the task itself"))
|
|
56
|
+
elif TaskId.parse(reference) not in by_id:
|
|
57
|
+
problems.append(Problem(path, f"depends_on {reference}: no such task"))
|
|
58
|
+
|
|
59
|
+
if paths:
|
|
60
|
+
wanted = {p.resolve() for p in paths}
|
|
61
|
+
problems += [
|
|
62
|
+
Problem(p, "not a task file in any chitty context")
|
|
63
|
+
for p in sorted(wanted - set(in_context))
|
|
64
|
+
]
|
|
65
|
+
return [p for p in problems if p.path in wanted]
|
|
66
|
+
names = {c.name for c in contexts}
|
|
67
|
+
return [p for p in problems if p.path not in in_context or in_context[p.path].name in names]
|
chitty/cli.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Command-line interface. Thin: parse flags, call store/model, print."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import date
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, TextIO, cast
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from chitty.check import check as check_repo
|
|
11
|
+
from chitty.config import find_root, search_root
|
|
12
|
+
from chitty.errors import ChittyError
|
|
13
|
+
from chitty.model import ACCEPTANCE_CRITERIA, FIELD_ORDER, PRIORITIES, SECTION_ORDER, STATUSES
|
|
14
|
+
from chitty.store import (
|
|
15
|
+
Change,
|
|
16
|
+
Edit,
|
|
17
|
+
Repo,
|
|
18
|
+
StoredTask,
|
|
19
|
+
Which,
|
|
20
|
+
init_repo,
|
|
21
|
+
matches,
|
|
22
|
+
resolve_add_target,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _Group(click.Group):
|
|
27
|
+
def invoke(self, ctx: click.Context) -> Any:
|
|
28
|
+
try:
|
|
29
|
+
return super().invoke(ctx)
|
|
30
|
+
except ChittyError as error:
|
|
31
|
+
raise click.ClickException(str(error)) from error
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@click.group(cls=_Group)
|
|
35
|
+
@click.version_option(package_name="chitty")
|
|
36
|
+
def cli() -> None:
|
|
37
|
+
"""Markdown-native task tracker for humans and coding agents.
|
|
38
|
+
|
|
39
|
+
Tasks are markdown files under chitty/<context>/. IDs are PREFIX-N
|
|
40
|
+
(e.g. TASK-13); a bare number means the default context.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main() -> None:
|
|
45
|
+
cli()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
json_option = click.option("--json", "as_json", is_flag=True, help="Print JSON.")
|
|
49
|
+
context_option = click.option(
|
|
50
|
+
"-c", "--context", help="Context name (default: default_context from chitty.toml)."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _repo() -> Repo:
|
|
55
|
+
return Repo.open(find_root(Path.cwd()))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _print_json(data: Any) -> None:
|
|
59
|
+
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _report(change: Change, as_json: bool, verb: str) -> None:
|
|
63
|
+
"""Print the result of a write."""
|
|
64
|
+
if as_json:
|
|
65
|
+
_print_json(change.task.as_dict())
|
|
66
|
+
elif change.paths:
|
|
67
|
+
click.echo(f"{verb} {change.task.id} {change.task.path}")
|
|
68
|
+
else:
|
|
69
|
+
click.echo(f"unchanged {change.task.id}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _priority(_ctx: click.Context, _param: click.Parameter, value: str | None) -> str | None:
|
|
73
|
+
if value is not None and value not in (*PRIORITIES, ""):
|
|
74
|
+
raise click.BadParameter(f"must be one of {', '.join(PRIORITIES)}, or '' to unset")
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _text(value: str | None, file: TextIO | None, name: str) -> str | None:
|
|
79
|
+
if value is not None and file is not None:
|
|
80
|
+
raise click.UsageError(f"give --{name} or --{name}-file, not both")
|
|
81
|
+
return file.read() if file is not None else value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@cli.command()
|
|
85
|
+
@click.option("--prefix", default="TASK", show_default=True, help="ID prefix for tasks/.")
|
|
86
|
+
def init(prefix: str) -> None:
|
|
87
|
+
"""Write chitty.toml and chitty/tasks/, and add the agent guide to AGENTS.md."""
|
|
88
|
+
for path in init_repo(Path.cwd(), prefix):
|
|
89
|
+
click.echo(f"wrote {path}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@cli.command()
|
|
93
|
+
@click.argument("title")
|
|
94
|
+
@context_option
|
|
95
|
+
@click.option("-d", "--description", required=True, help="The Description section.")
|
|
96
|
+
@click.option("--ac", "criteria", multiple=True, help="Acceptance criterion (repeatable).")
|
|
97
|
+
@click.option("-l", "--label", "labels", multiple=True, help="Label (repeatable).")
|
|
98
|
+
@click.option("-p", "--priority", type=click.Choice(PRIORITIES))
|
|
99
|
+
@click.option("--owner", help="Free text: human, a name, an agent.")
|
|
100
|
+
@click.option("--depends-on", multiple=True, help="Task ID this is blocked on (repeatable).")
|
|
101
|
+
@json_option
|
|
102
|
+
def add(
|
|
103
|
+
title: str,
|
|
104
|
+
context: str | None,
|
|
105
|
+
description: str,
|
|
106
|
+
criteria: tuple[str, ...],
|
|
107
|
+
labels: tuple[str, ...],
|
|
108
|
+
priority: str | None,
|
|
109
|
+
owner: str | None,
|
|
110
|
+
depends_on: tuple[str, ...],
|
|
111
|
+
as_json: bool,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Create a task with status todo.
|
|
114
|
+
|
|
115
|
+
-c may also name a context from ~/.config/chitty/contexts.toml: the file
|
|
116
|
+
is written into that repo's working tree and nothing else happens there.
|
|
117
|
+
"""
|
|
118
|
+
root = search_root(Path.cwd())
|
|
119
|
+
target = resolve_add_target(Repo.open(root) if root else None, context)
|
|
120
|
+
fields = {
|
|
121
|
+
"labels": list(dict.fromkeys(labels)),
|
|
122
|
+
"priority": priority,
|
|
123
|
+
"owner": owner,
|
|
124
|
+
"depends_on": list(depends_on),
|
|
125
|
+
}
|
|
126
|
+
change = target.repo.add(target.context, title, description, criteria, fields, date.today())
|
|
127
|
+
_report(change, as_json, "added")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@cli.command("list")
|
|
131
|
+
@context_option
|
|
132
|
+
@click.option("-s", "--status", "statuses", multiple=True, type=click.Choice(STATUSES))
|
|
133
|
+
@click.option("-l", "--label", "labels", multiple=True, help="Only tasks with every label.")
|
|
134
|
+
@click.option("--owner")
|
|
135
|
+
@click.option("--search", help="Case-insensitive match on title and body.")
|
|
136
|
+
@click.option("--archived", is_flag=True, help="Only archived tasks.")
|
|
137
|
+
@click.option("--all", "all_", is_flag=True, help="Archived and non-archived tasks.")
|
|
138
|
+
@json_option
|
|
139
|
+
def list_(
|
|
140
|
+
context: str | None,
|
|
141
|
+
statuses: tuple[str, ...],
|
|
142
|
+
labels: tuple[str, ...],
|
|
143
|
+
owner: str | None,
|
|
144
|
+
search: str | None,
|
|
145
|
+
archived: bool,
|
|
146
|
+
all_: bool,
|
|
147
|
+
as_json: bool,
|
|
148
|
+
) -> None:
|
|
149
|
+
"""List tasks, one per line."""
|
|
150
|
+
if archived and all_:
|
|
151
|
+
raise click.UsageError("give --archived or --all, not both")
|
|
152
|
+
which: Which = "all" if all_ else "archived" if archived else "active"
|
|
153
|
+
repo = _repo()
|
|
154
|
+
target = repo.context(context) if context else repo.default_context
|
|
155
|
+
tasks = [t for t in repo.tasks(target, which) if matches(t, statuses, labels, owner, search)]
|
|
156
|
+
if as_json:
|
|
157
|
+
_print_json([t.as_dict() for t in tasks])
|
|
158
|
+
return
|
|
159
|
+
width = max((len(str(t.id)) for t in tasks), default=0)
|
|
160
|
+
for stored in tasks:
|
|
161
|
+
fm = stored.task.frontmatter
|
|
162
|
+
line = f"{stored.id!s:<{width}} {fm.status:<6} {fm.title}"
|
|
163
|
+
if fm.labels:
|
|
164
|
+
line += f" [{','.join(sorted(fm.labels))}]"
|
|
165
|
+
if stored.archived:
|
|
166
|
+
line += " (archived)"
|
|
167
|
+
click.echo(line)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@cli.command()
|
|
171
|
+
@click.argument("task_id", metavar="ID")
|
|
172
|
+
@json_option
|
|
173
|
+
def show(task_id: str, as_json: bool) -> None:
|
|
174
|
+
"""Show a task, with acceptance criteria numbered."""
|
|
175
|
+
repo = _repo()
|
|
176
|
+
stored = repo.find(repo.resolve(task_id))
|
|
177
|
+
if as_json:
|
|
178
|
+
_print_json(stored.as_dict())
|
|
179
|
+
else:
|
|
180
|
+
click.echo(_format(stored))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _format(stored: StoredTask) -> str:
|
|
184
|
+
fm = stored.task.frontmatter.model_dump(mode="json", exclude_defaults=True)
|
|
185
|
+
lines = [f"{stored.id}{' (archived)' if stored.archived else ''}"]
|
|
186
|
+
for key in FIELD_ORDER:
|
|
187
|
+
value = fm.get(key)
|
|
188
|
+
if isinstance(value, list):
|
|
189
|
+
lines.append(f"{key}: {', '.join(cast(list[str], value))}")
|
|
190
|
+
elif value is not None:
|
|
191
|
+
lines.append(f"{key}: {value}")
|
|
192
|
+
lines.append(f"path: {stored.path}")
|
|
193
|
+
for name in SECTION_ORDER:
|
|
194
|
+
if name == ACCEPTANCE_CRITERIA:
|
|
195
|
+
criteria = stored.task.criteria
|
|
196
|
+
content = "\n".join(
|
|
197
|
+
f"{n}. [{'x' if c.checked else ' '}] {c.text}"
|
|
198
|
+
for n, c in enumerate(criteria, start=1)
|
|
199
|
+
)
|
|
200
|
+
else:
|
|
201
|
+
content = stored.task.section(name)
|
|
202
|
+
if content:
|
|
203
|
+
lines += ["", f"## {name}", "", content]
|
|
204
|
+
return "\n".join(lines)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@cli.command()
|
|
208
|
+
@click.argument("task_id", metavar="ID")
|
|
209
|
+
@click.option("--title")
|
|
210
|
+
@click.option("-s", "--status", type=click.Choice(STATUSES))
|
|
211
|
+
@click.option("--add-label", multiple=True)
|
|
212
|
+
@click.option("--rm-label", multiple=True)
|
|
213
|
+
@click.option("-p", "--priority", callback=_priority, help="low, medium, high; '' unsets.")
|
|
214
|
+
@click.option("--owner", help="'' unsets.")
|
|
215
|
+
@click.option("--depends-on", multiple=True, help="Add a dependency (repeatable).")
|
|
216
|
+
@click.option("--rm-depends-on", multiple=True, help="Remove a dependency (repeatable).")
|
|
217
|
+
@click.option("-d", "--description", help="Replace the Description section.")
|
|
218
|
+
@click.option("--description-file", type=click.File(), help="Read --description from a file.")
|
|
219
|
+
@click.option("--ac", multiple=True, help="Append an acceptance criterion (repeatable).")
|
|
220
|
+
@click.option("--rm-ac", type=int, multiple=True, help="Remove criterion N (repeatable).")
|
|
221
|
+
@click.option("--check", type=int, multiple=True, help="Tick criterion N (repeatable).")
|
|
222
|
+
@click.option("--uncheck", type=int, multiple=True, help="Untick criterion N (repeatable).")
|
|
223
|
+
@click.option("--plan", help="Replace the Plan section; '' removes it.")
|
|
224
|
+
@click.option("--plan-file", type=click.File(), help="Read --plan from a file.")
|
|
225
|
+
@click.option("--note", help="Append a paragraph to Notes.")
|
|
226
|
+
@json_option
|
|
227
|
+
def edit(
|
|
228
|
+
task_id: str,
|
|
229
|
+
title: str | None,
|
|
230
|
+
status: str | None,
|
|
231
|
+
add_label: tuple[str, ...],
|
|
232
|
+
rm_label: tuple[str, ...],
|
|
233
|
+
priority: str | None,
|
|
234
|
+
owner: str | None,
|
|
235
|
+
depends_on: tuple[str, ...],
|
|
236
|
+
rm_depends_on: tuple[str, ...],
|
|
237
|
+
description: str | None,
|
|
238
|
+
description_file: TextIO | None,
|
|
239
|
+
ac: tuple[str, ...],
|
|
240
|
+
rm_ac: tuple[int, ...],
|
|
241
|
+
check: tuple[int, ...],
|
|
242
|
+
uncheck: tuple[int, ...],
|
|
243
|
+
plan: str | None,
|
|
244
|
+
plan_file: TextIO | None,
|
|
245
|
+
note: str | None,
|
|
246
|
+
as_json: bool,
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Change a task. Criterion numbers refer to the list before this edit."""
|
|
249
|
+
repo = _repo()
|
|
250
|
+
changes = Edit(
|
|
251
|
+
title=title,
|
|
252
|
+
status=status,
|
|
253
|
+
add_labels=add_label,
|
|
254
|
+
rm_labels=rm_label,
|
|
255
|
+
priority=priority,
|
|
256
|
+
owner=owner,
|
|
257
|
+
depends_on=depends_on,
|
|
258
|
+
rm_depends_on=rm_depends_on,
|
|
259
|
+
description=_text(description, description_file, "description"),
|
|
260
|
+
add_criteria=ac,
|
|
261
|
+
rm_criteria=rm_ac,
|
|
262
|
+
check=check,
|
|
263
|
+
uncheck=uncheck,
|
|
264
|
+
plan=_text(plan, plan_file, "plan"),
|
|
265
|
+
note=note,
|
|
266
|
+
)
|
|
267
|
+
_report(repo.edit(repo.resolve(task_id), changes), as_json, "updated")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@cli.command("check")
|
|
271
|
+
@context_option
|
|
272
|
+
@click.argument("paths", nargs=-1, type=click.Path(path_type=Path))
|
|
273
|
+
@json_option
|
|
274
|
+
def check_(context: str | None, paths: tuple[Path, ...], as_json: bool) -> None:
|
|
275
|
+
"""Validate task files. Exits non-zero if there are any problems.
|
|
276
|
+
|
|
277
|
+
With PATHs, only those files are checked; otherwise every context, or
|
|
278
|
+
just -c CONTEXT.
|
|
279
|
+
"""
|
|
280
|
+
repo = _repo()
|
|
281
|
+
contexts = [repo.context(context)] if context else repo.contexts()
|
|
282
|
+
problems = check_repo(repo, contexts, paths)
|
|
283
|
+
if as_json:
|
|
284
|
+
_print_json([{"path": str(p.path), "message": p.message} for p in problems])
|
|
285
|
+
else:
|
|
286
|
+
for problem in problems:
|
|
287
|
+
click.echo(str(problem))
|
|
288
|
+
if problems:
|
|
289
|
+
raise click.ClickException(f"{len(problems)} problem(s)")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@cli.command()
|
|
293
|
+
@click.argument("task_id", metavar="ID")
|
|
294
|
+
@json_option
|
|
295
|
+
def path(task_id: str, as_json: bool) -> None:
|
|
296
|
+
"""Print the task's file path."""
|
|
297
|
+
repo = _repo()
|
|
298
|
+
stored = repo.find(repo.resolve(task_id))
|
|
299
|
+
if as_json:
|
|
300
|
+
_print_json({"id": str(stored.id), "path": str(stored.path)})
|
|
301
|
+
else:
|
|
302
|
+
click.echo(str(stored.path))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@cli.command()
|
|
306
|
+
@click.argument("task_id", metavar="ID")
|
|
307
|
+
@click.argument("context")
|
|
308
|
+
@json_option
|
|
309
|
+
def move(task_id: str, context: str, as_json: bool) -> None:
|
|
310
|
+
"""Move a task to another context in this repo.
|
|
311
|
+
|
|
312
|
+
It gets the next ID there (keeping its slug), records moved_from, and
|
|
313
|
+
every depends_on reference to the old ID is rewritten.
|
|
314
|
+
"""
|
|
315
|
+
repo = _repo()
|
|
316
|
+
change = repo.move(repo.resolve(task_id), repo.context(context))
|
|
317
|
+
_report(change, as_json, "moved")
|
|
318
|
+
if not as_json:
|
|
319
|
+
for touched in change.paths[2:]:
|
|
320
|
+
click.echo(f"rewrote reference in {touched}")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@cli.command()
|
|
324
|
+
@click.argument("task_id", metavar="ID")
|
|
325
|
+
@json_option
|
|
326
|
+
def archive(task_id: str, as_json: bool) -> None:
|
|
327
|
+
"""Move a task into its context's archive/."""
|
|
328
|
+
repo = _repo()
|
|
329
|
+
change = repo.archive(repo.resolve(task_id))
|
|
330
|
+
_report(change, as_json, "archived")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@cli.command()
|
|
334
|
+
@click.argument("task_id", metavar="ID")
|
|
335
|
+
@json_option
|
|
336
|
+
def restore(task_id: str, as_json: bool) -> None:
|
|
337
|
+
"""Move an archived task back out of archive/."""
|
|
338
|
+
repo = _repo()
|
|
339
|
+
change = repo.restore(repo.resolve(task_id))
|
|
340
|
+
_report(change, as_json, "restored")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@cli.command()
|
|
344
|
+
def mcp() -> None:
|
|
345
|
+
"""Run the stdio MCP server (needs the mcp extra)."""
|
|
346
|
+
# Imported here because the mcp package is an optional extra.
|
|
347
|
+
try:
|
|
348
|
+
from chitty.mcp import run
|
|
349
|
+
except ModuleNotFoundError as error:
|
|
350
|
+
if error.name is None or not error.name.startswith("mcp"):
|
|
351
|
+
raise
|
|
352
|
+
raise ChittyError(
|
|
353
|
+
"chitty mcp needs the mcp extra: uv tool install 'chitty[mcp]'"
|
|
354
|
+
) from error
|
|
355
|
+
run()
|
chitty/config.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Repo `chitty.toml` and user-level `contexts.toml`."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, ValidationError, field_validator, model_validator
|
|
10
|
+
|
|
11
|
+
from chitty.errors import ChittyError
|
|
12
|
+
from chitty.ids import PREFIX_RE
|
|
13
|
+
|
|
14
|
+
CONFIG_NAME = "chitty.toml"
|
|
15
|
+
BACKLOG_DIR = "chitty"
|
|
16
|
+
ROOT_ENV = "CHITTY_ROOT"
|
|
17
|
+
CONTEXT_NAME_RE = re.compile(r"[a-z0-9][a-z0-9_-]*")
|
|
18
|
+
RESERVED_CONTEXT_NAMES = ("archive",)
|
|
19
|
+
INITIAL_CONFIG = """\
|
|
20
|
+
format = 1
|
|
21
|
+
default_context = "tasks"
|
|
22
|
+
autocommit = false
|
|
23
|
+
|
|
24
|
+
[contexts.tasks]
|
|
25
|
+
prefix = "{prefix}"
|
|
26
|
+
|
|
27
|
+
[contexts.human]
|
|
28
|
+
prefix = "HUMAN"
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ContextConfig(BaseModel):
|
|
33
|
+
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
|
|
34
|
+
|
|
35
|
+
prefix: str
|
|
36
|
+
|
|
37
|
+
@field_validator("prefix")
|
|
38
|
+
@classmethod
|
|
39
|
+
def _prefix(cls, value: str) -> str:
|
|
40
|
+
if not PREFIX_RE.fullmatch(value):
|
|
41
|
+
raise ValueError(f"{value!r} must be upper-case letters only, e.g. TASK")
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RepoConfig(BaseModel):
|
|
46
|
+
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
|
|
47
|
+
|
|
48
|
+
format: Literal[1]
|
|
49
|
+
default_context: str
|
|
50
|
+
autocommit: bool
|
|
51
|
+
contexts: dict[str, ContextConfig]
|
|
52
|
+
|
|
53
|
+
@model_validator(mode="after")
|
|
54
|
+
def _consistent(self) -> "RepoConfig":
|
|
55
|
+
for name in self.contexts:
|
|
56
|
+
if not CONTEXT_NAME_RE.fullmatch(name) or name in RESERVED_CONTEXT_NAMES:
|
|
57
|
+
raise ValueError(f"context name {name!r} is not allowed")
|
|
58
|
+
if self.default_context not in self.contexts:
|
|
59
|
+
raise ValueError(f"default_context {self.default_context!r} is not in [contexts]")
|
|
60
|
+
prefixes = [c.prefix for c in self.contexts.values()]
|
|
61
|
+
duplicates = sorted({p for p in prefixes if prefixes.count(p) > 1})
|
|
62
|
+
if duplicates:
|
|
63
|
+
raise ValueError(f"prefix {duplicates[0]!r} is used by more than one context")
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class UserContext(BaseModel):
|
|
68
|
+
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
|
|
69
|
+
|
|
70
|
+
repo: str
|
|
71
|
+
context: str | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class UserContexts(BaseModel):
|
|
75
|
+
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
|
|
76
|
+
|
|
77
|
+
contexts: dict[str, UserContext] = {}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def find_root(start: Path) -> Path:
|
|
81
|
+
"""`CHITTY_ROOT` if set, else the nearest ancestor containing chitty.toml."""
|
|
82
|
+
root = search_root(start)
|
|
83
|
+
if root is None:
|
|
84
|
+
raise ChittyError(f"no {CONFIG_NAME} in {start} or any parent (run `chitty init`)")
|
|
85
|
+
return root
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def search_root(start: Path) -> Path | None:
|
|
89
|
+
"""Like find_root, but None when no chitty.toml is found by walking up."""
|
|
90
|
+
override = os.environ.get(ROOT_ENV)
|
|
91
|
+
if override:
|
|
92
|
+
root = Path(override).expanduser().resolve()
|
|
93
|
+
if not (root / CONFIG_NAME).is_file():
|
|
94
|
+
raise ChittyError(f"{ROOT_ENV}={override} has no {CONFIG_NAME}")
|
|
95
|
+
return root
|
|
96
|
+
start = start.resolve()
|
|
97
|
+
for directory in (start, *start.parents):
|
|
98
|
+
if (directory / CONFIG_NAME).is_file():
|
|
99
|
+
return directory
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load_repo_config(root: Path) -> RepoConfig:
|
|
104
|
+
return _load(root / CONFIG_NAME, RepoConfig)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def user_contexts_path() -> Path:
|
|
108
|
+
base = os.environ.get("XDG_CONFIG_HOME") or "~/.config"
|
|
109
|
+
return Path(base).expanduser() / "chitty" / "contexts.toml"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_user_contexts() -> UserContexts:
|
|
113
|
+
path = user_contexts_path()
|
|
114
|
+
if not path.exists():
|
|
115
|
+
return UserContexts()
|
|
116
|
+
return _load(path, UserContexts)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def validate_repo_config(text: str, source: Path) -> RepoConfig:
|
|
120
|
+
return _validate(text, source, RepoConfig)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _load[M: BaseModel](path: Path, model: type[M]) -> M:
|
|
124
|
+
return _validate(path.read_text(), path, model)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _validate[M: BaseModel](text: str, path: Path, model: type[M]) -> M:
|
|
128
|
+
try:
|
|
129
|
+
data = tomllib.loads(text)
|
|
130
|
+
except tomllib.TOMLDecodeError as error:
|
|
131
|
+
raise ChittyError(f"{path}: invalid TOML: {error}") from None
|
|
132
|
+
try:
|
|
133
|
+
return model.model_validate(data)
|
|
134
|
+
except ValidationError as error:
|
|
135
|
+
reasons = [
|
|
136
|
+
f"{'.'.join(str(p) for p in e['loc']) or 'config'}: "
|
|
137
|
+
+ str(e["msg"]).removeprefix("Value error, ")
|
|
138
|
+
for e in error.errors()
|
|
139
|
+
]
|
|
140
|
+
raise ChittyError(f"{path}: {'; '.join(reasons)}") from None
|
chitty/errors.py
ADDED
chitty/git.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Optional autocommit: commit exactly the paths a write touched."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from chitty.errors import ChittyError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def commit(root: Path, paths: Sequence[Path], message: str) -> None:
|
|
11
|
+
"""Commit only `paths`, so nothing the user had staged is swept in.
|
|
12
|
+
|
|
13
|
+
Deleted paths are included only if git tracks them.
|
|
14
|
+
"""
|
|
15
|
+
relative = [str(path.relative_to(root)) for path in paths]
|
|
16
|
+
present = [p for p in relative if (root / p).exists()]
|
|
17
|
+
tracked = set(_git(root, "ls-files", "--", *relative).splitlines())
|
|
18
|
+
included = [p for p in relative if p in present or p in tracked]
|
|
19
|
+
_git(root, "add", "--", *present)
|
|
20
|
+
_git(root, "commit", "--quiet", "--only", "-m", message, "--", *included)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _git(root: Path, *args: str) -> str:
|
|
24
|
+
command = ["git", *args]
|
|
25
|
+
result = subprocess.run(command, check=False, cwd=root, capture_output=True, text=True)
|
|
26
|
+
if result.returncode != 0:
|
|
27
|
+
shown = " ".join(command)
|
|
28
|
+
raise ChittyError(f"autocommit failed: `{shown}` in {root}:\n{result.stderr.strip()}")
|
|
29
|
+
return result.stdout
|
chitty/guide.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
## Chitty
|
|
2
|
+
|
|
3
|
+
Tasks live in `chitty/`. Use the `chitty` CLI (or MCP tools); do not hand-edit
|
|
4
|
+
task files. `chitty --help` is the full command reference.
|
|
5
|
+
|
|
6
|
+
- Work on one task per session. Start with `chitty show <ID>`.
|
|
7
|
+
- Before writing code, write your approach with `chitty edit <ID> --plan` and
|
|
8
|
+
set `-s doing`. Wait for approval if the human asked for it.
|
|
9
|
+
- Tick acceptance criteria as you meet them: `chitty edit <ID> --check N`
|
|
10
|
+
(numbers are shown by `chitty show`).
|
|
11
|
+
- Record anything surprising with `--note`. Set `-s done` when every
|
|
12
|
+
criterion is ticked and the work is verified.
|
|
13
|
+
- Found unrelated work? `chitty add` it (`-c human` if it needs a person).
|
|
14
|
+
Don't do it in this session.
|
chitty/ids.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Task IDs, filenames and slugs."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from chitty.errors import ChittyError
|
|
8
|
+
|
|
9
|
+
SLUG_MAX_LENGTH = 60
|
|
10
|
+
|
|
11
|
+
ID_RE = re.compile(r"(?P<prefix>[A-Z]+)-(?P<number>[1-9][0-9]*)")
|
|
12
|
+
PREFIX_RE = re.compile(r"[A-Z]+")
|
|
13
|
+
SLUG_RE = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
|
|
14
|
+
NUMBER_RE = re.compile(r"[1-9][0-9]*")
|
|
15
|
+
FILENAME_SEPARATOR = "--"
|
|
16
|
+
SUFFIX = ".md"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class IdError(ChittyError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, order=True)
|
|
24
|
+
class TaskId:
|
|
25
|
+
prefix: str
|
|
26
|
+
number: int
|
|
27
|
+
|
|
28
|
+
def __str__(self) -> str:
|
|
29
|
+
return f"{self.prefix}-{self.number}"
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def parse(cls, text: str) -> "TaskId":
|
|
33
|
+
match = ID_RE.fullmatch(text)
|
|
34
|
+
if match is None:
|
|
35
|
+
raise IdError(f"invalid task ID {text!r}: expected PREFIX-N, e.g. TASK-13")
|
|
36
|
+
return cls(match["prefix"], int(match["number"]))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve(text: str, default_prefix: str) -> TaskId:
|
|
40
|
+
"""Parse a user-supplied reference: `TASK-13`, or bare `13` for the default prefix."""
|
|
41
|
+
if NUMBER_RE.fullmatch(text):
|
|
42
|
+
return TaskId(default_prefix, int(text))
|
|
43
|
+
return TaskId.parse(text)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def slugify(title: str) -> str:
|
|
47
|
+
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
|
|
48
|
+
return slug[:SLUG_MAX_LENGTH].rstrip("-")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def filename(task_id: TaskId, slug: str) -> str:
|
|
52
|
+
if not slug:
|
|
53
|
+
return f"{task_id}{SUFFIX}"
|
|
54
|
+
return f"{task_id}{FILENAME_SEPARATOR}{slug}{SUFFIX}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_filename(name: str) -> tuple[TaskId, str]:
|
|
58
|
+
"""Split `TASK-13--add-search.md` into its ID and slug."""
|
|
59
|
+
if not name.endswith(SUFFIX):
|
|
60
|
+
raise IdError(f"task filename {name!r} must end with {SUFFIX}")
|
|
61
|
+
stem = name.removesuffix(SUFFIX)
|
|
62
|
+
id_text, _, slug = stem.partition(FILENAME_SEPARATOR)
|
|
63
|
+
task_id = TaskId.parse(id_text)
|
|
64
|
+
if FILENAME_SEPARATOR in stem and not SLUG_RE.fullmatch(slug):
|
|
65
|
+
raise IdError(
|
|
66
|
+
f"task filename {name!r} has an invalid slug {slug!r}: "
|
|
67
|
+
"expected lowercase letters, digits and single hyphens"
|
|
68
|
+
)
|
|
69
|
+
return task_id, slug
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def next_number(existing: Iterable[int]) -> int:
|
|
73
|
+
return max(existing, default=0) + 1
|