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 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
@@ -0,0 +1,2 @@
1
+ class ChittyError(Exception):
2
+ """A user-facing error: the message says what is wrong and where."""
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