Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 9 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ File: `docs/tickets/todo/CORE-14_skirmishSetup.md`
```markdown
---
id: CORE-14
title: Skirmish setup
title: Skirmish Setup
status: todo
priority: 1
requires: [CORE-9, GEN-3]
Expand All @@ -69,7 +69,7 @@ Goal: a screen where the player sets up one battle and plays it.
| Field | Notes |
|---|---|
| `id` | `<KEY>-<NUM>`. Must match the filename prefix. |
| `title` | Free text. Changing it does not rename the file. |
| `title` | Free text, converted to title case on write. Changing it does not rename the file. |
| `status` | `todo`, `wip`, or `done`. Fixed vocabulary. |
| `priority` | Integer, `0` most urgent. Ceiling configurable. |
| `requires` | Ids this depends on. Never lists what it blocks. |
Expand Down Expand Up @@ -168,15 +168,14 @@ Config value `lockTimeout` is how long a process waits before giving up. Hitting

## Validation

`docket validate` errors on:
`docket validate` presents an error or warning when:

- A `requires` entry naming an id that does not exist, or a dependency cycle
- Two tickets sharing an id, or an unregistered key
- An id disagreeing with its filename prefix, or a status disagreeing with its directory
- A priority outside the band, or a status outside the vocabulary
- A file under a status directory that cannot be read as a ticket

`validate` has no warnings of its own. That severity exists for `create_ticket`, which downgrades a dangling `requires` entry so a batch written out of order is not stranded halfway.
- A `requires` entry naming an id that does not exist, or a dependency cycle.
- Two tickets sharing an id, or an unregistered key.
- An id disagreeing with its filename prefix, or a status disagreeing with its directory.
- A priority outside the band, or a status outside the vocabulary.
- A file under a status directory that cannot be read as a ticket.
- A ticket's title is not in the valid title format.

## Docket Runs on Docket

Expand Down
16 changes: 13 additions & 3 deletions docs/tickets/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,21 @@ A markdown file with a YAML frontmatter block.
```markdown
---
id: CORE-14
title: Skirmish setup
title: Skirmish Setup
status: todo
priority: 1
requires: [CORE-9, GEN-3]
---

# Skirmish setup
# Skirmish Setup

Prose, unparsed and unconstrained.
```

| Field | Meaning |
|---|---|
| `id` | `<KEY>-<NUM>`. Allocated at creation. Never change it. |
| `title` | Free text. May change. The filename does not follow it. |
| `title` | Free text, converted to title case on write. May change. The filename does not follow it. |
| `status` | `todo`, `wip`, or `done`. Nothing else is valid. |
| `priority` | Integer, `0` most urgent. |
| `requires` | Ids this ticket depends on. May be empty. |
Expand Down Expand Up @@ -53,6 +53,14 @@ Never move a file between `todo/` and `done/` yourself. The `status` field is th

Filenames are frozen at creation. Retitling a ticket deliberately does not rename its file, because renaming would break every prose cross-reference pointing at it from other tickets. Do not rename one to "fix" a stale slug. It is stale on purpose.

## Titles are title case

`create_ticket` and `update_ticket` convert the `title` for you, so write one however reads naturally and let the tool case it. Do not hand-edit a title in the frontmatter to fix its casing, because that is a frontmatter field and `update_ticket` owns it.

A word carrying an uppercase letter past its first character, or a digit anywhere, is left exactly as you wrote it. That is what keeps `CLI`, `MCPServer`, `FEAT-5`, and `2.x` intact, so spell an acronym in caps when you mean one.

`validate` warns about any title that does not match, naming the corrected form. Those are worth fixing through `update_ticket` when you see them.

## The rest of the tools

| To do this | Call this |
Expand Down Expand Up @@ -109,3 +117,5 @@ Nothing was changed when that error is raised, so retry the same call once. If i
Call `validate`.

A `requires` entry naming a ticket that does not exist yet is only a warning at creation time, so that writing a batch out of order does not strand you halfway. It becomes an error in `validate`. Run it when the batch is done and resolve what it reports.

`validate` reports warnings of its own too, which do not block. Read them rather than skipping to the error count.
3 changes: 2 additions & 1 deletion docs/tickets/done/BUG-2_cannotClearWithSetCommand.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
---
id: BUG-2
title: Cannot Clear With Set Command
title: Cannot Clear with Set Command
status: done
priority: 0
requires: []
metadata: {}
---

# Cannot Clear With Set Command
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
id: BUG-3
title: Migrate server to mcp 2.x MCPServer API
title: Migrate Server to MCP 2.x MCPServer API
status: done
priority: 0
requires: []
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
id: BUG-5
title: Serialize ticket writes across processes
title: Serialize Ticket Writes Across Processes
status: done
priority: 3
requires: []
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
id: FEAT-11
title: Repository automation and contribution scaffolding
title: Repository Automation and Contribution Scaffolding
status: done
priority: 3
requires: [FEAT-2]
Expand Down
2 changes: 1 addition & 1 deletion docs/tickets/done/FEAT-13_checkIfTicketIsReadyForWork.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
id: FEAT-13
title: Check if Ticket is Ready For Work
title: Check if Ticket Is Ready for Work
status: done
priority: 1
requires: []
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
id: FEAT-15
title: Use Title Case for Tickets
status: todo
status: done
priority: 1
requires: []
metadata: {}
Expand Down
3 changes: 2 additions & 1 deletion docs/tickets/done/FEAT-1_recordDemoGifWithVhs.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
---
id: FEAT-1
title: Record demo GIF with VHS
title: Record Demo GIF with VHS
status: done
priority: 2
requires: []
metadata: {}
---

# Record demo GIF with VHS
Expand Down
2 changes: 1 addition & 1 deletion docs/tickets/done/FEAT-2_setUpAndPublishToPypi.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
id: FEAT-2
title: Set up and publish to PyPI
title: Set Up and Publish to PyPI
status: done
priority: 3
requires: [BUG-1, BUG-2, FEAT-4, FEAT-5, FEAT-7, BUG-3, BUG-4, FEAT-10, BUG-5, FEAT-1]
Expand Down
2 changes: 1 addition & 1 deletion docs/tickets/done/FEAT-9_arbitraryMetadata.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
id: FEAT-9
title: Track arbitrary additional metadata on tickets/groups
title: Track Arbitrary Additional Metadata on Tickets/Groups
status: done
priority: 1
requires: []
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
id: BUG-6
title: Key removal checks usage outside the lock
title: Key Removal Checks Usage Outside the Lock
status: todo
priority: 4
requires: [BUG-5]
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ dependencies = [
"pyyaml>=6.0.3",
"rich>=15.0.0",
"rich-argparse>=1.8.0",
"textcase>=0.4.5",
"tomlkit>=0.15.1",
]

Expand Down
2 changes: 1 addition & 1 deletion src/docket/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@

# No type check to comply with hatch's requirements.
# Do not re-add.
__version__ = "1.1.0"
__version__ = "1.2.0"
21 changes: 9 additions & 12 deletions src/docket/core/ids.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import unicodedata
from typing import Iterable, Optional

import textcase

from docket.core.errors import InvalidIdError, InvalidKeyError

# MARK: Constants
Expand Down Expand Up @@ -167,6 +169,8 @@ def slugify(title: str) -> str:
A title is untrusted input, so this works from an allowlist of characters rather than a blocklist.
Path separators, `..`, quotes, and control characters are discarded as a consequence of that rule rather than by explicit rejection.

The slug is cut at the cap wherever that lands, mid-word included, since the id prefix is what makes the filename unique.

title: The ticket title to convert.

Returns the slug, or `untitled` when nothing survives.
Expand All @@ -177,23 +181,16 @@ def slugify(title: str) -> str:
asciiOnly: str = normalized.encode("ascii", "ignore").decode("ascii")

# Split on every run of non-alphanumeric characters, which is what makes traversal impossible by construction.
# This is also what feeds the casing below a clean word list, since the runs it drops are the boundaries camel case would otherwise have to find for itself.
tokens: list[str] = [token for token in SLUG_SEPARATOR_PATTERN.split(asciiOnly) if token]
if not tokens:
return SLUG_FALLBACK

# Lowercase the first token whole, then capitalize each later token so an acronym like `HTTP` becomes `Http` rather than shouting.
parts: list[str] = [tokens[0].lower()]
parts.extend(token[0].upper() + token[1:].lower() for token in tokens[1:])

# Append tokens while they fit, so truncation lands on a word boundary wherever possible.
slug: str = ""
for part in parts:
if slug and len(slug) + len(part) > SLUG_MAX_LENGTH:
break

slug += part
# The tokens are already alphanumeric, so the split boundary is narrowed to whitespace and punctuation stripping is left off. Neither has anything left to do, and both would only risk splitting a token that survived the allowlist.
# An acronym like `HTTP` becomes `Http` rather than shouting, which is what camel case does to a token that is not the first.
slug: str = textcase.camel(" ".join(tokens), boundaries=[textcase.SPACE], strip_punctuation=False)

# A single opening token longer than the cap has no boundary to break on, so cut it hard.
# Cut at the cap wherever it lands. The id prefix is what makes the filename unique, so a slug ending mid-word costs nothing.
return slug[:SLUG_MAX_LENGTH]


Expand Down
14 changes: 9 additions & 5 deletions src/docket/core/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from docket.core.ids import buildFilename, nextId, parseId, requireValidKey
from docket.core.inputs import requireText
from docket.core.ticket import STATUS_DONE, STATUSES, Ticket, buildBody, parseTicket, serializeTicket
from docket.core.titles import toTitleCase

# MARK: Constants

Expand Down Expand Up @@ -292,7 +293,7 @@ def create(
The id is derived by scanning what already exists, so the scan and the write are held together under one lock. Without that, two processes minting under one key read the same set and allocate the same number.

key: The key to mint under, which must be registered.
title: The ticket title, which the filename slug derives from once, here.
title: The ticket title, converted to title case before anything derives from it.
body: Prose for the body, placed under a heading built from the title.
requires: Ids this ticket depends on.
priority: The priority, defaulting to the configuration's `defaultPriority`.
Expand All @@ -303,6 +304,9 @@ def create(
# The filename slug derives from the title once, here, so an empty one is frozen into the filename as well as the field.
requireText(title, "title")

# Convert before the id is allocated, so the slug and the body heading are both built from the title that actually gets stored.
casedTitle: str = toTitleCase(title)

# A key must be registered before anything is minted under it, and the error names `add_key` as the way out.
requireValidKey(key)
self.config.requireKnownKey(key)
Expand All @@ -315,11 +319,11 @@ def create(

ticket: Ticket = Ticket(
id=nextId(key, existing.ids()),
title=title,
title=casedTitle,
status=STATUSES[0],
priority=resolvedPriority,
requires=list(requires or []),
body=buildBody(title, body),
body=buildBody(casedTitle, body),
)

return TicketResult(ticket=self.write(ticket), warnings=self.__danglingWarnings(ticket, existing))
Expand All @@ -343,7 +347,7 @@ def update(
The load and the write back are held together under one lock, since a second process changing a different field in the gap would have its change reverted by this write.

ticketId: The ticket to change.
title: A new title, if any.
title: A new title, converted to title case, if any.
priority: A new priority, if any.
requires: A replacement dependency list, if any.
requiresAdd: Ids to append to the existing list, if any.
Expand All @@ -361,7 +365,7 @@ def update(
ticket: Ticket = existing.get(ticketId)

if title is not None:
ticket.title = requireText(title, "title")
ticket.title = toTitleCase(requireText(title, "title"))

if priority is not None:
self.__requireValidPriority(priority)
Expand Down
Loading