From ba2e897e14bd770998fea1d8e52f8993dd0fab52 Mon Sep 17 00:00:00 2001 From: Brody Childs Date: Wed, 12 Aug 2026 12:47:52 -0400 Subject: [PATCH 1/7] Added `textcase` dep --- pyproject.toml | 1 + uv.lock | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 0562ec1..e63c50f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/uv.lock b/uv.lock index 6b0643b..dcfa8e6 100644 --- a/uv.lock +++ b/uv.lock @@ -1014,6 +1014,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] +[[package]] +name = "textcase" +version = "0.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/08/991940591c7a8de25505a4e43642d1c2b54f43c3ed76f8c90f1df6ee9525/textcase-0.4.5.tar.gz", hash = "sha256:97fd08754b7dba9bfa5daf4ace474645f6b66409fb00f483c5de729e730f8e52", size = 6988, upload-time = "2025-10-10T15:11:25.476Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/48/6ea42c749608cfca883d5d8fae03edbcde636090d2bc751fd5da9157b451/textcase-0.4.5-py3-none-any.whl", hash = "sha256:bd5d6aaf653b339e3ac60ad96cfc960a94219a97da464eddeed7084f41774937", size = 6503, upload-time = "2025-10-10T15:11:24.294Z" }, +] + [[package]] name = "ticket-docket" source = { editable = "." } @@ -1023,6 +1032,7 @@ dependencies = [ { name = "pyyaml" }, { name = "rich" }, { name = "rich-argparse" }, + { name = "textcase" }, { name = "tomlkit" }, ] @@ -1039,6 +1049,7 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0.3" }, { name = "rich", specifier = ">=15.0.0" }, { name = "rich-argparse", specifier = ">=1.8.0" }, + { name = "textcase", specifier = ">=0.4.5" }, { name = "tomlkit", specifier = ">=0.15.1" }, ] From 3d870d4eeadc715adb23388171617d3f45f31369 Mon Sep 17 00:00:00 2001 From: Brody Childs Date: Wed, 12 Aug 2026 13:00:53 -0400 Subject: [PATCH 2/7] Title case for ticket titles --- src/docket/core/store.py | 14 +++-- src/docket/core/titles.py | 114 ++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 108 ++++++++++++++++++------------------ tests/test_server.py | 48 ++++++++-------- tests/test_store.py | 34 ++++++------ 5 files changed, 218 insertions(+), 100 deletions(-) create mode 100644 src/docket/core/titles.py diff --git a/src/docket/core/store.py b/src/docket/core/store.py index c2cd554..ab03531 100644 --- a/src/docket/core/store.py +++ b/src/docket/core/store.py @@ -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 @@ -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`. @@ -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) @@ -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)) @@ -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. @@ -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) diff --git a/src/docket/core/titles.py b/src/docket/core/titles.py new file mode 100644 index 0000000..2adc85f --- /dev/null +++ b/src/docket/core/titles.py @@ -0,0 +1,114 @@ +""" +Docket Titles + +Title case for the `title` field, applied on every write and reported on by `validate`. + +The rule lives here alone so the CLI, the MCP server, and the validator can never disagree about what a correct title looks like. +""" + +# MARK: Imports + +from typing import Iterable, Iterator + +import textcase + +# MARK: Constants + +# Words left lowercase when they fall between the first and the last word, following the convention that articles, coordinating conjunctions, and prepositions are minor. +# `up` and `out` are deliberately absent, because a ticket title is far more likely to want the phrasal verb in `Set Up Publishing` than the preposition. +MINOR_WORDS: frozenset[str] = frozenset( + { + "a", + "an", + "the", + "and", + "as", + "at", + "but", + "by", + "for", + "from", + "if", + "in", + "into", + "nor", + "of", + "off", + "on", + "onto", + "or", + "over", + "per", + "so", + "than", + "that", + "to", + "upon", + "via", + "when", + "with", + "yet", + } +) + +# MARK: Functions + + +def _transformWords(words: Iterable[str]) -> Iterator[str]: + """ + Case each word of a title according to its position and its existing shape. + + `textcase.title` alone is not usable here, because it capitalizes through `str.capitalize`, which lowercases the rest of the word and would turn `CLI` into `Cli`. + A word is therefore left exactly as written whenever it carries capitalization or a digit of its own, which is what lets an acronym, a ticket id, and a version number survive a title that is otherwise rewritten. + + words: The words the case split produced, in order. + + Returns each word in the case it should carry. + """ + + ordered: list[str] = list(words) + last: int = len(ordered) - 1 + + for index, word in enumerate(ordered): + # A minor word is checked first and by its lowercase form, so a wrongly capitalized `For` is corrected rather than mistaken for an acronym. + # The first and the last word are always major, no matter which word they are. + if 0 < index < last and word.lower() in MINOR_WORDS: + yield word.lower() + continue + + # A word carrying an uppercase letter past its first character, or a digit anywhere, was written that way on purpose. + if word[1:] != word[1:].lower() or any(character.isdigit() for character in word): + yield word + continue + + yield word[:1].upper() + word[1:] + + +# The boundaries are narrowed to whitespace and punctuation is kept, so `FEAT-5` and `2.x` reach the transform whole rather than being split apart and stripped. +titleCase: textcase.Case = textcase.Case(delimiter=" ", transform=_transformWords) + + +def toTitleCase(title: str) -> str: + """ + Convert a title to title case. + + Runs of whitespace collapse to a single space and surrounding whitespace is dropped, since the split that finds the words is what removes them. + + title: The title to convert. + + Returns the title in title case. + """ + + return titleCase(title, boundaries=[textcase.SPACE], strip_punctuation=False) + + +def isTitleCase(title: str) -> bool: + """ + Report whether a title is already in title case. + + title: The title to test. + + Returns `True` when converting the title would change nothing. + """ + + return toTitleCase(title) == title diff --git a/tests/test_cli.py b/tests/test_cli.py index e872304..8f7ae0b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -74,7 +74,7 @@ def testNewCreatesATicket(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> N The key and the title are positional, since both are required and a required flag is a flag that should have been an argument. """ - assert main(["new", "CORE", "Skirmish setup", "--body", "Goal: one battle."]) == EXIT_OK + assert main(["new", "CORE", "Skirmish Setup", "--body", "Goal: one battle."]) == EXIT_OK assert "CORE-1" in capsys.readouterr().out assert (inRepo / "docs" / "tickets" / "todo" / "CORE-1_skirmishSetup.md").is_file() @@ -108,14 +108,14 @@ def testABareIdShowsTheTicket(inRepo: Path, capsys: pytest.CaptureFixture[str]) Naming a ticket and nothing else means showing it, since that is what naming one almost always means. """ - main(["new", "CORE", "App shell"]) + main(["new", "CORE", "App Shell"]) capsys.readouterr() assert main(["CORE-1"]) == EXIT_OK out: str = capsys.readouterr().out - assert "App shell" in out + assert "App Shell" in out assert "Requires" in out @@ -124,7 +124,7 @@ def testShowIsTheSpelledOutFormOfABareId(inRepo: Path, capsys: pytest.CaptureFix The default action has a name, so it can be documented and typed rather than only implied. """ - main(["new", "CORE", "App shell"]) + main(["new", "CORE", "App Shell"]) capsys.readouterr() main(["CORE-1"]) @@ -139,8 +139,8 @@ def testShowResolvesBothDependencyDirections(inRepo: Path, capsys: pytest.Captur The raw file stores forward edges only, so `show` has to supply the reverse side and the titles. """ - main(["new", "CORE", "App shell"]) - main(["new", "CORE", "Skirmish setup", "--requires", "CORE-1"]) + main(["new", "CORE", "App Shell"]) + main(["new", "CORE", "Skirmish Setup", "--requires", "CORE-1"]) capsys.readouterr() assert main(["CORE-1", "show"]) == EXIT_OK @@ -148,7 +148,7 @@ def testShowResolvesBothDependencyDirections(inRepo: Path, capsys: pytest.Captur out: str = capsys.readouterr().out assert "Required by" in out - assert "Skirmish setup" in out + assert "Skirmish Setup" in out def testShowDisplaysTheBody(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: @@ -156,7 +156,7 @@ def testShowDisplaysTheBody(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> `show` prints the prose along with the context, rather than the raw file. """ - main(["new", "CORE", "App shell", "--body", "Goal: a window that opens."]) + main(["new", "CORE", "App Shell", "--body", "Goal: a window that opens."]) capsys.readouterr() main(["CORE-1", "show"]) @@ -210,15 +210,15 @@ def testListFiltersCombine(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> Each supplied filter narrows the result. """ - main(["new", "CORE", "Core work", "--priority", "0"]) - main(["new", "GEN", "Gen work", "--priority", "4"]) + main(["new", "CORE", "Core Work", "--priority", "0"]) + main(["new", "GEN", "Gen Work", "--priority", "4"]) capsys.readouterr() main(["list", "--key", "CORE"]) - assert "Gen work" not in capsys.readouterr().out + assert "Gen Work" not in capsys.readouterr().out main(["list", "--priority-max", "0"]) - assert "Gen work" not in capsys.readouterr().out + assert "Gen Work" not in capsys.readouterr().out def testListFiltersFromBareTokens(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: @@ -226,16 +226,16 @@ def testListFiltersFromBareTokens(inRepo: Path, capsys: pytest.CaptureFixture[st A token says which filter it is by its own shape, so the three can be typed in any order with no flags between them. """ - main(["new", "CORE", "Core work", "--priority", "0"]) - main(["new", "GEN", "Gen work", "--priority", "4"]) + main(["new", "CORE", "Core Work", "--priority", "0"]) + main(["new", "GEN", "Gen Work", "--priority", "4"]) capsys.readouterr() assert main(["list", "todo", "CORE", "0"]) == EXIT_OK out: str = capsys.readouterr().out - assert "Core work" in out - assert "Gen work" not in out + assert "Core Work" in out + assert "Gen Work" not in out # Order carries no meaning, since each token is classified on its own. assert main(["list", "0", "todo", "CORE"]) == EXIT_OK @@ -247,16 +247,16 @@ def testListTakesTokensAndFlagsTogether(inRepo: Path, capsys: pytest.CaptureFixt The flags remain the explicit form of the same three filters, so mixing the two spellings is fine as long as they name different filters. """ - main(["new", "CORE", "Core work", "--priority", "0"]) - main(["new", "GEN", "Gen work", "--priority", "4"]) + main(["new", "CORE", "Core Work", "--priority", "0"]) + main(["new", "GEN", "Gen Work", "--priority", "4"]) capsys.readouterr() assert main(["list", "CORE", "--status", "todo"]) == EXIT_OK out: str = capsys.readouterr().out - assert "Core work" in out - assert "Gen work" not in out + assert "Core Work" in out + assert "Gen Work" not in out def testListRefusesATokenItCannotRead(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: @@ -307,7 +307,7 @@ def testSetChangesFieldsWithoutRenamingTheFile(inRepo: Path, capsys: pytest.Capt Retitling must not rename the file, since that would break every prose cross-reference. """ - main(["new", "CORE", "Original title"]) + main(["new", "CORE", "Original Title"]) capsys.readouterr() assert main(["CORE-1", "set", "--title", "Renamed", "--priority", "0"]) == EXIT_OK @@ -469,7 +469,7 @@ def testGraphRefusesAnUnwritableOutPath(inRepo: Path, capsys: pytest.CaptureFixt An empty path used to resolve to the working directory and reach `write_text` as a directory, surfacing as a traceback rather than as a message. """ - main(["new", "CORE", "Skirmish setup"]) + main(["new", "CORE", "Skirmish Setup"]) capsys.readouterr() assert main(["graph", "--out", ""]) == EXIT_USAGE @@ -484,7 +484,7 @@ def testGraphWritesToANestedOutPath(inRepo: Path, capsys: pytest.CaptureFixture[ A destination under directories that do not exist yet is created on the way, which the check must not have broken. """ - main(["new", "CORE", "Skirmish setup"]) + main(["new", "CORE", "Skirmish Setup"]) capsys.readouterr() target: Path = inRepo / "build" / "graphs" / "docket.mmd" @@ -498,7 +498,7 @@ def testMetaSetsAKeyAndReadsTheMapBack(inRepo: Path, capsys: pytest.CaptureFixtu A set key is visible in the map, the round trip an agent or a human actually uses. """ - main(["new", "CORE", "Skirmish setup"]) + main(["new", "CORE", "Skirmish Setup"]) capsys.readouterr() assert main(["CORE-1", "meta", "video", "2026-01-devlog"]) == EXIT_OK @@ -516,7 +516,7 @@ def testMetaWithAKeyPrintsOnlyTheValue(inRepo: Path, capsys: pytest.CaptureFixtu A key on its own reads that one entry raw, for the same reason `status` does, so a shell can take the answer as readily as a person. """ - main(["new", "CORE", "Skirmish setup"]) + main(["new", "CORE", "Skirmish Setup"]) main(["CORE-1", "meta", "video", "2026-01-devlog"]) capsys.readouterr() @@ -533,7 +533,7 @@ def testMetaReadingAnUnsetKeyFails(inRepo: Path, capsys: pytest.CaptureFixture[s An absent key prints nothing on stdout, since a caller reading a value must not receive an explanation where the value would have been. """ - main(["new", "CORE", "Skirmish setup"]) + main(["new", "CORE", "Skirmish Setup"]) capsys.readouterr() assert main(["CORE-1", "meta", "video"]) == EXIT_USAGE @@ -549,7 +549,7 @@ def testMetaWithNoMetadataSaysSo(inRepo: Path, capsys: pytest.CaptureFixture[str A ticket with an empty metadata map is stated rather than printed as a bare table. """ - main(["new", "CORE", "Skirmish setup"]) + main(["new", "CORE", "Skirmish Setup"]) capsys.readouterr() assert main(["CORE-1", "meta"]) == EXIT_OK @@ -561,7 +561,7 @@ def testMetaClearsAKeyWithTheFlag(inRepo: Path, capsys: pytest.CaptureFixture[st `-c/--clear` removes the key rather than requiring a sentinel value. """ - main(["new", "CORE", "Skirmish setup"]) + main(["new", "CORE", "Skirmish Setup"]) main(["CORE-1", "meta", "video", "2026-01-devlog"]) capsys.readouterr() @@ -577,7 +577,7 @@ def testMetaRejectsAValueTogetherWithClear(inRepo: Path, capsys: pytest.CaptureF Passing both a value and --clear is contradictory, so it is refused rather than resolved silently. """ - main(["new", "CORE", "Skirmish setup"]) + main(["new", "CORE", "Skirmish Setup"]) capsys.readouterr() assert main(["CORE-1", "meta", "video", "x", "--clear"]) == EXIT_USAGE @@ -589,7 +589,7 @@ def testMetaClearWithNoKeyIsAUsageError(inRepo: Path, capsys: pytest.CaptureFixt Clearing has to know what to clear, and the whole map is not it. """ - main(["new", "CORE", "Skirmish setup"]) + main(["new", "CORE", "Skirmish Setup"]) main(["CORE-1", "meta", "video", "2026-01-devlog"]) capsys.readouterr() @@ -615,7 +615,7 @@ def testAStatusWordMovesTheFile(inRepo: Path, capsys: pytest.CaptureFixture[str] The frontmatter and the directory are written together, never one without the other. The status word is the whole command rather than a value handed to one. """ - main(["new", "CORE", "Skirmish setup"]) + main(["new", "CORE", "Skirmish Setup"]) capsys.readouterr() assert main(["CORE-1", "done"]) == EXIT_OK @@ -629,7 +629,7 @@ def testEveryStatusIsItsOwnCommand(inRepo: Path, capsys: pytest.CaptureFixture[s The vocabulary is fixed, so each word in it reaches the same write. """ - main(["new", "CORE", "Skirmish setup"]) + main(["new", "CORE", "Skirmish Setup"]) capsys.readouterr() for status in ("wip", "done", "todo"): @@ -645,7 +645,7 @@ def testStatusPrintsOnlyTheStatus(inRepo: Path, capsys: pytest.CaptureFixture[st Reading a status yields the bare word and nothing else, so a shell can read the answer as easily as a person can. """ - main(["new", "CORE", "Skirmish setup"]) + main(["new", "CORE", "Skirmish Setup"]) main(["CORE-1", "wip"]) capsys.readouterr() @@ -662,7 +662,7 @@ def testReadyPrintsOnlyTheAnswer(inRepo: Path, capsys: pytest.CaptureFixture[str Readiness reads bare for the same reason a status does, so a shell can take the answer as readily as a person. """ - main(["new", "CORE", "Skirmish setup"]) + main(["new", "CORE", "Skirmish Setup"]) capsys.readouterr() assert main(["CORE-1", "ready"]) == EXIT_OK @@ -678,7 +678,7 @@ def testReadyIsFalseWhileADependencyIsOpen(inRepo: Path, capsys: pytest.CaptureF An open prerequisite is the case the check exists for, and closing it has to flip the answer. """ - main(["new", "CORE", "Skirmish setup"]) + main(["new", "CORE", "Skirmish Setup"]) main(["new", "CORE", "Deployment", "--requires", "CORE-1"]) capsys.readouterr() @@ -697,7 +697,7 @@ def testReadyExitsZeroWhenNotReady(inRepo: Path, capsys: pytest.CaptureFixture[s The exit code reports whether the question could be answered, not what the answer was, so a false is a successful read rather than a failure. """ - main(["new", "CORE", "Skirmish setup"]) + main(["new", "CORE", "Skirmish Setup"]) main(["new", "CORE", "Deployment", "--requires", "CORE-1"]) capsys.readouterr() @@ -718,7 +718,7 @@ def testListReadyKeepsOnlyUnblockedTickets(inRepo: Path, capsys: pytest.CaptureF The filter answers "what can I pick up right now", so a blocked ticket and a finished one both fall out of it. """ - main(["new", "CORE", "Skirmish setup"]) + main(["new", "CORE", "Skirmish Setup"]) main(["new", "CORE", "Deployment", "--requires", "CORE-1"]) main(["new", "CORE", "Shipped"]) main(["CORE-3", "done"]) @@ -728,7 +728,7 @@ def testListReadyKeepsOnlyUnblockedTickets(inRepo: Path, capsys: pytest.CaptureF out: str = capsys.readouterr().out - assert "Skirmish setup" in out + assert "Skirmish Setup" in out assert "Deployment" not in out assert "Shipped" not in out @@ -738,16 +738,16 @@ def testListReadyComposesWithTheOtherFilters(inRepo: Path, capsys: pytest.Captur Readiness narrows what the other filters already selected rather than replacing them. """ - main(["new", "CORE", "Core work"]) - main(["new", "GEN", "Gen work"]) + main(["new", "CORE", "Core Work"]) + main(["new", "GEN", "Gen Work"]) capsys.readouterr() assert main(["list", "CORE", "--ready"]) == EXIT_OK out: str = capsys.readouterr().out - assert "Core work" in out - assert "Gen work" not in out + assert "Core Work" in out + assert "Gen Work" not in out def testListReadyJudgesAgainstTheWholeSet(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: @@ -756,7 +756,7 @@ def testListReadyJudgesAgainstTheWholeSet(inRepo: Path, capsys: pytest.CaptureFi """ main(["new", "GEN", "Groundwork"]) - main(["new", "CORE", "Skirmish setup", "--requires", "GEN-1"]) + main(["new", "CORE", "Skirmish Setup", "--requires", "GEN-1"]) capsys.readouterr() assert main(["list", "CORE", "--ready"]) == EXIT_OK @@ -793,7 +793,7 @@ def testGraphWritesBareMermaidToStdout(inRepo: Path, capsys: pytest.CaptureFixtu Machine-readable output bypasses `rich`, so a redirect captures exactly the source with no wrapping or escape sequences. """ - main(["new", "CORE", "App shell"]) + main(["new", "CORE", "App Shell"]) capsys.readouterr() assert main(["graph"]) == EXIT_OK @@ -823,7 +823,7 @@ def testGraphWritesToAFile(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> The file receives the same bare source that stdout would have. """ - main(["new", "CORE", "App shell"]) + main(["new", "CORE", "App Shell"]) capsys.readouterr() target: Path = inRepo / "out" / "graph.mmd" @@ -848,7 +848,7 @@ def testGraphScopesFromABareToken(inRepo: Path, capsys: pytest.CaptureFixture[st An id and a key are told apart by shape, so one positional covers both scopes the flags spell out. """ - main(["new", "CORE", "App shell"]) + main(["new", "CORE", "App Shell"]) main(["new", "GEN", "Battlescape", "--requires", "CORE-1"]) capsys.readouterr() @@ -882,7 +882,7 @@ def testGraphScopedToAKeyMarksNeighbors(inRepo: Path, capsys: pytest.CaptureFixt A key-scoped graph shows where the key ends. """ - main(["new", "CORE", "App shell"]) + main(["new", "CORE", "App Shell"]) main(["new", "GEN", "Battlescape", "--requires", "CORE-1"]) capsys.readouterr() @@ -991,7 +991,7 @@ def testCommandsWorkFromASubdirectory(inRepo: Path, capsys: pytest.CaptureFixtur Configuration is found by walking up, so a command run deep inside the repository still works. """ - main(["new", "CORE", "App shell"]) + main(["new", "CORE", "App Shell"]) capsys.readouterr() previous: str = os.getcwd() @@ -1001,7 +1001,7 @@ def testCommandsWorkFromASubdirectory(inRepo: Path, capsys: pytest.CaptureFixtur finally: os.chdir(previous) - assert "App shell" in capsys.readouterr().out + assert "App Shell" in capsys.readouterr().out def testKeyDescriptionNamesTheRegistry(config: Config) -> None: @@ -1113,7 +1113,7 @@ def testShorthandFlagsDriveNewAndSet(inRepo: Path, capsys: pytest.CaptureFixture Every short flag reaches the same handler its long form does. """ - assert main(["new", "CORE", "Skirmish setup", "-p", "1", "-b", "Goal: one battle."]) == EXIT_OK + assert main(["new", "CORE", "Skirmish Setup", "-p", "1", "-b", "Goal: one battle."]) == EXIT_OK assert main(["new", "GEN", "Battlescape", "-r", "CORE-1", "-p", "0"]) == EXIT_OK assert main(["GEN-1", "set", "-t", "Renamed", "-p", "3", "-r", "none"]) == EXIT_OK @@ -1135,7 +1135,7 @@ def testShorthandFlagsDriveListAndGraph(inRepo: Path, capsys: pytest.CaptureFixt The read commands take the same shorthands, including the file destination. """ - main(["new", "CORE", "App shell", "-p", "0"]) + main(["new", "CORE", "App Shell", "-p", "0"]) main(["new", "GEN", "Battlescape", "-p", "4"]) capsys.readouterr() @@ -1143,7 +1143,7 @@ def testShorthandFlagsDriveListAndGraph(inRepo: Path, capsys: pytest.CaptureFixt out: str = capsys.readouterr().out - assert "App shell" in out + assert "App Shell" in out assert "Battlescape" not in out target: Path = inRepo / "out" / "graph.mmd" @@ -1182,11 +1182,11 @@ def testListAcceptsAPriorityMaxAboveTheBand(inRepo: Path, capsys: pytest.Capture A ceiling above the band still describes the right set, so it is answered rather than refused. """ - main(["new", "CORE", "App shell"]) + main(["new", "CORE", "App Shell"]) capsys.readouterr() assert main(["list", "-m", "99"]) == EXIT_OK - assert "App shell" in capsys.readouterr().out + assert "App Shell" in capsys.readouterr().out def testVersionShorthand(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: diff --git a/tests/test_server.py b/tests/test_server.py index 14153a1..897d16e 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -171,7 +171,7 @@ def testCreateTicketReturnsTheNewId(inRepo: Path) -> None: Creation allocates the id and reports it, since the caller cannot know it in advance. """ - payload = callTool("create_ticket", {"key": "CORE", "title": "Skirmish setup", "body": "Goal: one battle."}) + payload = callTool("create_ticket", {"key": "CORE", "title": "Skirmish Setup", "body": "Goal: one battle."}) assert payload["id"] == "CORE-1" assert payload["warnings"] == [] @@ -231,7 +231,7 @@ def testListTicketsNeverReturnsBodies(inRepo: Path) -> None: An agent listing forty tickets must not pay for forty bodies. """ - callTool("create_ticket", {"key": "CORE", "title": "App shell", "body": "A very long body that must not appear in a listing."}) + callTool("create_ticket", {"key": "CORE", "title": "App Shell", "body": "A very long body that must not appear in a listing."}) payload = callTool("list_tickets") @@ -258,14 +258,14 @@ def testReadTicketResolvesBothDirections(inRepo: Path) -> None: The raw file carries bare ids one way, so the tool supplies the titles, the statuses, and the whole reverse side. """ - callTool("create_ticket", {"key": "CORE", "title": "App shell"}) - callTool("create_ticket", {"key": "CORE", "title": "Skirmish setup", "requires": ["CORE-1"]}) + callTool("create_ticket", {"key": "CORE", "title": "App Shell"}) + callTool("create_ticket", {"key": "CORE", "title": "Skirmish Setup", "requires": ["CORE-1"]}) payload = callTool("read_ticket", {"id": "CORE-1"}) assert payload["requires"] == [] - assert payload["requiredBy"] == [{"id": "CORE-2", "title": "Skirmish setup", "status": "todo", "priority": 2, "exists": True}] - assert "# App shell" in payload["body"] + assert payload["requiredBy"] == [{"id": "CORE-2", "title": "Skirmish Setup", "status": "todo", "priority": 2, "exists": True}] + assert "# App Shell" in payload["body"] def testReadTicketFlagsAMissingDependency(inRepo: Path) -> None: @@ -286,8 +286,8 @@ def testReadTicketCarriesReadiness(inRepo: Path) -> None: An agent already reading a ticket must not have to make a second call to find out whether it can act on it. """ - callTool("create_ticket", {"key": "CORE", "title": "App shell"}) - callTool("create_ticket", {"key": "CORE", "title": "Skirmish setup", "requires": ["CORE-1"]}) + callTool("create_ticket", {"key": "CORE", "title": "App Shell"}) + callTool("create_ticket", {"key": "CORE", "title": "Skirmish Setup", "requires": ["CORE-1"]}) assert callTool("read_ticket", {"id": "CORE-1"})["ready"] is True assert callTool("read_ticket", {"id": "CORE-2"})["ready"] is False @@ -298,15 +298,15 @@ def testCheckReadyNamesWhatIsBlocking(inRepo: Path) -> None: Refusing without naming the blocker would leave the agent to work out the reason, which is the inference this tool exists to replace. """ - callTool("create_ticket", {"key": "CORE", "title": "App shell"}) - callTool("create_ticket", {"key": "CORE", "title": "Skirmish setup", "requires": ["CORE-1"]}) + callTool("create_ticket", {"key": "CORE", "title": "App Shell"}) + callTool("create_ticket", {"key": "CORE", "title": "Skirmish Setup", "requires": ["CORE-1"]}) payload = callTool("check_ready", {"id": "CORE-2"}) assert payload == { "id": "CORE-2", "ready": False, - "blocked_by": [{"id": "CORE-1", "title": "App shell", "status": "todo", "priority": 2, "exists": True}], + "blocked_by": [{"id": "CORE-1", "title": "App Shell", "status": "todo", "priority": 2, "exists": True}], } @@ -315,8 +315,8 @@ def testCheckReadyClearsOnceTheDependencyIsDone(inRepo: Path) -> None: Readiness is derived on every read rather than stored, so closing a dependency flips the answer with nothing else written. """ - callTool("create_ticket", {"key": "CORE", "title": "App shell"}) - callTool("create_ticket", {"key": "CORE", "title": "Skirmish setup", "requires": ["CORE-1"]}) + callTool("create_ticket", {"key": "CORE", "title": "App Shell"}) + callTool("create_ticket", {"key": "CORE", "title": "Skirmish Setup", "requires": ["CORE-1"]}) callTool("set_status", {"id": "CORE-1", "status": "done"}) payload = callTool("check_ready", {"id": "CORE-2"}) @@ -372,7 +372,7 @@ def testReadTicketCarriesMetadata(inRepo: Path) -> None: Metadata is a recognized field, so it is returned in its own payload key rather than folded into `extra`. """ - callTool("create_ticket", {"key": "CORE", "title": "App shell"}) + callTool("create_ticket", {"key": "CORE", "title": "App Shell"}) callTool("set_metadata", {"id": "CORE-1", "key": "video", "value": "2026-01-devlog"}) payload = callTool("read_ticket", {"id": "CORE-1"}) @@ -386,7 +386,7 @@ def testSetMetadataOnlyTouchesTheNamedKey(inRepo: Path) -> None: Two consumers writing different keys to the same ticket do not clobber each other. """ - callTool("create_ticket", {"key": "CORE", "title": "App shell"}) + callTool("create_ticket", {"key": "CORE", "title": "App Shell"}) callTool("set_metadata", {"id": "CORE-1", "key": "video", "value": "2026-01-devlog"}) payload = callTool("set_metadata", {"id": "CORE-1", "key": "reviewed", "value": True}) @@ -399,7 +399,7 @@ def testSetMetadataWithNullValueRemovesTheKey(inRepo: Path) -> None: A null value clears the entry rather than storing it as a null. """ - callTool("create_ticket", {"key": "CORE", "title": "App shell"}) + callTool("create_ticket", {"key": "CORE", "title": "App Shell"}) callTool("set_metadata", {"id": "CORE-1", "key": "video", "value": "2026-01-devlog"}) payload = callTool("set_metadata", {"id": "CORE-1", "key": "video", "value": None}) @@ -434,7 +434,7 @@ def testUpdateTicketChangesFieldsWithoutRenaming(inRepo: Path) -> None: Retitling through the tool leaves the filename alone, so prose cross-references elsewhere survive. """ - callTool("create_ticket", {"key": "CORE", "title": "Original title"}) + callTool("create_ticket", {"key": "CORE", "title": "Original Title"}) payload = callTool("update_ticket", {"id": "CORE-1", "title": "Renamed", "priority": 0}) @@ -484,7 +484,7 @@ def testSetStatusMovesTheFile(inRepo: Path) -> None: The frontmatter and the directory are written together, which is why an agent never needs to move a file itself. """ - callTool("create_ticket", {"key": "CORE", "title": "Skirmish setup"}) + callTool("create_ticket", {"key": "CORE", "title": "Skirmish Setup"}) payload = callTool("set_status", {"id": "CORE-1", "status": "done"}) @@ -498,7 +498,7 @@ def testSetStatusRejectsAnUnknownStatus(inRepo: Path) -> None: The vocabulary is fixed, so an unrecognized status is refused rather than written. """ - callTool("create_ticket", {"key": "CORE", "title": "Skirmish setup"}) + callTool("create_ticket", {"key": "CORE", "title": "Skirmish Setup"}) with pytest.raises(Exception) as excInfo: callTool("set_status", {"id": "CORE-1", "status": "blocked"}) @@ -511,7 +511,7 @@ def testGraphReturnsMermaidAsAField(inRepo: Path) -> None: The source is a field in a JSON payload rather than the whole body, so scope and size travel with it. """ - callTool("create_ticket", {"key": "CORE", "title": "App shell"}) + callTool("create_ticket", {"key": "CORE", "title": "App Shell"}) payload = callTool("graph") @@ -526,7 +526,7 @@ def testGraphScopesToATicket(inRepo: Path) -> None: Scoping to an id narrows the graph and records what it was scoped to. """ - callTool("create_ticket", {"key": "CORE", "title": "App shell"}) + callTool("create_ticket", {"key": "CORE", "title": "App Shell"}) callTool("create_ticket", {"key": "GEN", "title": "Unrelated"}) payload = callTool("graph", {"id": "CORE-1"}) @@ -540,7 +540,7 @@ def testGraphScopesToAKeyAndMarksNeighbors(inRepo: Path) -> None: A key-scoped graph shows where the key ends. """ - callTool("create_ticket", {"key": "CORE", "title": "App shell"}) + callTool("create_ticket", {"key": "CORE", "title": "App Shell"}) callTool("create_ticket", {"key": "GEN", "title": "Battlescape", "requires": ["CORE-1"]}) payload = callTool("graph", {"key": "GEN"}) @@ -654,7 +654,7 @@ def testPayloadsAreCompactJson(inRepo: Path) -> None: An agent pays for every token, so payloads carry no indentation padding. """ - callTool("create_ticket", {"key": "CORE", "title": "App shell"}) + callTool("create_ticket", {"key": "CORE", "title": "App Shell"}) result: CallToolResult = asyncio.run(server.mcp.call_tool("list_tickets", {})) text: str = result.content[0].text @@ -668,7 +668,7 @@ def testTheServerNeverWritesToStdout(inRepo: Path, capsys: pytest.CaptureFixture The MCP stdio transport owns stdout, so a single stray byte from a handler would corrupt the protocol. """ - callTool("create_ticket", {"key": "CORE", "title": "App shell"}) + callTool("create_ticket", {"key": "CORE", "title": "App Shell"}) callTool("list_tickets") callTool("validate") diff --git a/tests/test_store.py b/tests/test_store.py index 608115d..8c0a379 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -175,7 +175,7 @@ def testCreateAllocatesTheNextIdAndWritesTheFile(store: Store, config: Config) - Creation mints an id by scanning, writes to the todo directory, and derives the filename from the title. """ - result: TicketResult = store.create(key="CORE", title="Skirmish setup", body="Goal: one battle.") + result: TicketResult = store.create(key="CORE", title="Skirmish Setup", body="Goal: one battle.") assert result.ticket.id == "CORE-1" assert result.ticket.status == "todo" @@ -183,7 +183,7 @@ def testCreateAllocatesTheNextIdAndWritesTheFile(store: Store, config: Config) - path: Path = config.todoPath / "CORE-1_skirmishSetup.md" assert path.is_file() - assert "# Skirmish setup" in path.read_text(encoding="utf-8") + assert "# Skirmish Setup" in path.read_text(encoding="utf-8") def testCreateContinuesNumberingFromExistingTickets(store: Store, config: Config) -> None: @@ -246,17 +246,17 @@ def testUpdateChangesFieldsWithoutRenamingTheFile(store: Store) -> None: Retitling must not rename the file, since that would break every prose cross-reference pointing at it. """ - created: Ticket = store.create(key="CORE", title="Original title").ticket + created: Ticket = store.create(key="CORE", title="Original Title").ticket originalPath: Path = created.path - updated: Ticket = store.update("CORE-1", title="Completely different").ticket + updated: Ticket = store.update("CORE-1", title="Completely Different").ticket - assert updated.title == "Completely different" + assert updated.title == "Completely Different" assert updated.path == originalPath assert originalPath.name == "CORE-1_originalTitle.md" # The new title is on disk even though the filename did not follow it. - assert parseTicket(originalPath.read_text(encoding="utf-8")).title == "Completely different" + assert parseTicket(originalPath.read_text(encoding="utf-8")).title == "Completely Different" def testUpdateChangesPriorityAndRequires(store: Store, config: Config) -> None: @@ -419,7 +419,7 @@ def testSetMetadataAddsAKey(store: Store) -> None: Setting a key that is not present yet adds it without disturbing anything else. """ - store.create(key="CORE", title="Skirmish setup") + store.create(key="CORE", title="Skirmish Setup") result: TicketResult = store.setMetadata("CORE-1", "video", "2026-01-devlog") @@ -431,7 +431,7 @@ def testSetMetadataOnlyTouchesTheNamedKey(store: Store) -> None: One consumer's key survives another consumer setting its own, since two skills may attach data to the same ticket. """ - store.create(key="CORE", title="Skirmish setup") + store.create(key="CORE", title="Skirmish Setup") store.setMetadata("CORE-1", "video", "2026-01-devlog") result: TicketResult = store.setMetadata("CORE-1", "reviewed", True) @@ -444,7 +444,7 @@ def testSetMetadataWithNoneValueRemovesTheKey(store: Store) -> None: A `None` value clears the entry rather than storing a null placeholder. """ - store.create(key="CORE", title="Skirmish setup") + store.create(key="CORE", title="Skirmish Setup") store.setMetadata("CORE-1", "video", "2026-01-devlog") result: TicketResult = store.setMetadata("CORE-1", "video", None) @@ -457,7 +457,7 @@ def testSetMetadataRejectsAnEmptyKey(store: Store) -> None: An empty key would be unreadable in the frontmatter, so it is refused at the boundary. """ - store.create(key="CORE", title="Skirmish setup") + store.create(key="CORE", title="Skirmish Setup") with pytest.raises(EmptyValueError): store.setMetadata("CORE-1", " ", "value") @@ -477,7 +477,7 @@ def testSetMetadataPersistsAcrossALoad(store: Store) -> None: The written value survives a fresh load, not just the in-memory ticket returned from the call. """ - store.create(key="CORE", title="Skirmish setup") + store.create(key="CORE", title="Skirmish Setup") store.setMetadata("CORE-1", "video", "2026-01-devlog") assert store.load("CORE-1").metadata == {"video": "2026-01-devlog"} @@ -488,7 +488,7 @@ def testSetStatusToDoneMovesTheFile(store: Store, config: Config) -> None: The frontmatter and the directory are written together, never one without the other. """ - created: Ticket = store.create(key="CORE", title="Skirmish setup").ticket + created: Ticket = store.create(key="CORE", title="Skirmish Setup").ticket todoPath: Path = created.path moved: Ticket = store.setStatus("CORE-1", "done") @@ -504,7 +504,7 @@ def testSetStatusToWipStaysInTodo(store: Store, config: Config) -> None: Only `done` moves a file, so `wip` lives alongside `todo` exactly as the source repository did it. """ - store.create(key="CORE", title="Skirmish setup") + store.create(key="CORE", title="Skirmish Setup") moved: Ticket = store.setStatus("CORE-1", "wip") @@ -517,7 +517,7 @@ def testSetStatusBackToTodoMovesTheFileBack(store: Store, config: Config) -> Non The move is symmetric, so reopening a finished ticket returns it to the todo directory. """ - store.create(key="CORE", title="Skirmish setup") + store.create(key="CORE", title="Skirmish Setup") store.setStatus("CORE-1", "done") reopened: Ticket = store.setStatus("CORE-1", "todo") @@ -531,7 +531,7 @@ def testSetStatusToTheSameStatusIsANoOp(store: Store) -> None: Re-setting the current status succeeds rather than failing, and leaves the file in place. """ - created: Ticket = store.create(key="CORE", title="Skirmish setup").ticket + created: Ticket = store.create(key="CORE", title="Skirmish Setup").ticket unchanged: Ticket = store.setStatus("CORE-1", "todo") @@ -544,7 +544,7 @@ def testSetStatusRejectsAnUnknownStatus(store: Store) -> None: The vocabulary is fixed, so an unrecognized status is refused rather than written. """ - store.create(key="CORE", title="Skirmish setup") + store.create(key="CORE", title="Skirmish Setup") with pytest.raises(InvalidStatusError): store.setStatus("CORE-1", "blocked") @@ -588,6 +588,6 @@ def testFilesAreWrittenWithLfNewlines(store: Store) -> None: Writing LF explicitly keeps a Windows checkout from churning every touched file. """ - created: Ticket = store.create(key="CORE", title="Skirmish setup").ticket + created: Ticket = store.create(key="CORE", title="Skirmish Setup").ticket assert b"\r\n" not in created.path.read_bytes() From 401059ec3f7fcfbb1d09736828fb9ba222b61ee5 Mon Sep 17 00:00:00 2001 From: Brody Childs Date: Wed, 12 Aug 2026 13:07:02 -0400 Subject: [PATCH 3/7] Slug casing now comes from textcase --- src/docket/core/ids.py | 21 +++++++++------------ tests/test_ids.py | 11 +++++------ 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/src/docket/core/ids.py b/src/docket/core/ids.py index 5be9ecd..230f119 100644 --- a/src/docket/core/ids.py +++ b/src/docket/core/ids.py @@ -10,6 +10,8 @@ import unicodedata from typing import Iterable, Optional +import textcase + from docket.core.errors import InvalidIdError, InvalidKeyError # MARK: Constants @@ -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. @@ -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] diff --git a/tests/test_ids.py b/tests/test_ids.py index 51544f6..513cc8d 100644 --- a/tests/test_ids.py +++ b/tests/test_ids.py @@ -147,18 +147,17 @@ def testSlugifyFallsBackWhenNothingSurvives(title: str) -> None: assert slugify(title) == SLUG_FALLBACK -def testSlugifyTruncatesOnATokenBoundary() -> None: +def testSlugifyCutsAtTheCap() -> None: """ - Truncation prefers a word boundary and never exceeds the cap. + Truncation lands wherever the cap falls, mid-token included. """ slug: str = slugify("alpha bravo charlie delta echo foxtrot golf hotel india juliet") - assert len(slug) <= SLUG_MAX_LENGTH + assert len(slug) == SLUG_MAX_LENGTH - # The cut landed on a boundary, so the slug ends with a whole token rather than a fragment. - # `india` fits at 47 characters and `juliet` is the token that would have breached the cap. - assert slug == "alphaBravoCharlieDeltaEchoFoxtrotGolfHotelIndia" + # `india` ends at 47 characters, so the cap takes the first letter of `juliet` with it. + assert slug == "alphaBravoCharlieDeltaEchoFoxtrotGolfHotelIndiaJ"[:SLUG_MAX_LENGTH] def testSlugifyHardCutsAnOversizedSingleToken() -> None: From 5e8c36620a9bc55d5c48663c61ba2cb18346aa57 Mon Sep 17 00:00:00 2001 From: Brody Childs Date: Wed, 12 Aug 2026 13:08:45 -0400 Subject: [PATCH 4/7] Validate now warns on titles that aren't title case --- src/docket/core/validate.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/docket/core/validate.py b/src/docket/core/validate.py index eb5a465..189cd03 100644 --- a/src/docket/core/validate.py +++ b/src/docket/core/validate.py @@ -16,6 +16,7 @@ from docket.core.graph import ResolvedGraph, findCycles, resolveGraph from docket.core.store import Store, TicketSet from docket.core.ticket import STATUSES, Ticket +from docket.core.titles import isTitleCase, toTitleCase # MARK: Constants @@ -33,6 +34,7 @@ RULE_STATUS_DIRECTORY: str = "statusDirectoryMismatch" RULE_PRIORITY_RANGE: str = "priorityOutOfRange" RULE_UNKNOWN_STATUS: str = "unknownStatus" +RULE_TITLE_CASE: str = "titleCase" # MARK: Classes @@ -155,6 +157,7 @@ def validate(store: Store, ticketSet: Optional[TicketSet] = None) -> ValidationR findings.extend(_checkStatusDirectory(ticket, store)) findings.extend(_checkPriority(ticket, config)) findings.extend(_checkStatus(ticket)) + findings.extend(_checkTitleCase(ticket)) findings.extend(_checkCycles(graph)) @@ -357,6 +360,33 @@ def _checkStatus(ticket: Ticket) -> list[Finding]: ] +def _checkTitleCase(ticket: Ticket) -> list[Finding]: + """ + Report a title that is not in title case. + + This is a warning rather than an error, because a title that reads badly still names the work and nothing downstream parses it. + Every write goes through the conversion, so what this catches is a file edited by hand and a ticket written before the rule existed. + The corrected title travels in the message, which is what lets a human operator or an agent fix it without working out the convention first. + + ticket: The ticket to check. + + Returns the findings. + """ + + if isTitleCase(ticket.title): + return [] + + return [ + Finding( + severity=SEVERITY_WARNING, + rule=RULE_TITLE_CASE, + message=f"Ticket '{ticket.id}' has title '{ticket.title}', which is not title case. Use '{toTitleCase(ticket.title)}'.", + ticketId=ticket.id, + path=ticket.path, + ) + ] + + def _checkCycles(graph: ResolvedGraph) -> list[Finding]: """ Report every dependency cycle. From 39181c76aa4106e365e20a160d4e784d41c89465 Mon Sep 17 00:00:00 2001 From: Brody Childs Date: Wed, 12 Aug 2026 13:15:14 -0400 Subject: [PATCH 5/7] Added tests and such --- README.md | 19 +++-- docs/tickets/CLAUDE.md | 16 +++- src/docket/__init__.py | 2 +- src/docket/templates/CLAUDE.md | 16 +++- tests/test_store.py | 24 ++++++ tests/test_titles.py | 137 +++++++++++++++++++++++++++++++++ tests/test_validate.py | 49 ++++++++++++ 7 files changed, 246 insertions(+), 17 deletions(-) create mode 100644 tests/test_titles.py diff --git a/README.md b/README.md index ece58c8..8bd1979 100644 --- a/README.md +++ b/README.md @@ -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] @@ -69,7 +69,7 @@ Goal: a screen where the player sets up one battle and plays it. | Field | Notes | |---|---| | `id` | `-`. 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. | @@ -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 diff --git a/docs/tickets/CLAUDE.md b/docs/tickets/CLAUDE.md index a27b344..ada15c2 100644 --- a/docs/tickets/CLAUDE.md +++ b/docs/tickets/CLAUDE.md @@ -9,13 +9,13 @@ 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. ``` @@ -23,7 +23,7 @@ Prose, unparsed and unconstrained. | Field | Meaning | |---|---| | `id` | `-`. 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. | @@ -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 | @@ -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. diff --git a/src/docket/__init__.py b/src/docket/__init__.py index a19a5d7..ca50a01 100644 --- a/src/docket/__init__.py +++ b/src/docket/__init__.py @@ -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" diff --git a/src/docket/templates/CLAUDE.md b/src/docket/templates/CLAUDE.md index a27b344..ada15c2 100644 --- a/src/docket/templates/CLAUDE.md +++ b/src/docket/templates/CLAUDE.md @@ -9,13 +9,13 @@ 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. ``` @@ -23,7 +23,7 @@ Prose, unparsed and unconstrained. | Field | Meaning | |---|---| | `id` | `-`. 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. | @@ -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 | @@ -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. diff --git a/tests/test_store.py b/tests/test_store.py index 8c0a379..e79511c 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -186,6 +186,30 @@ def testCreateAllocatesTheNextIdAndWritesTheFile(store: Store, config: Config) - assert "# Skirmish Setup" in path.read_text(encoding="utf-8") +def testCreateConvertsTheTitleEverywhereItLands(store: Store, config: Config) -> None: + """ + The conversion runs once, before anything derives from the title, so the stored field, the body heading, and the slug cannot disagree. + """ + + result: TicketResult = store.create(key="CORE", title="record demo gif") + + assert result.ticket.title == "Record Demo Gif" + + path: Path = config.todoPath / "CORE-1_recordDemoGif.md" + assert path.is_file() + assert "# Record Demo Gif" in path.read_text(encoding="utf-8") + + +def testUpdateConvertsTheTitle(store: Store) -> None: + """ + Retitling goes through the same conversion, so a title cannot be lowered back in after creation. + """ + + store.create(key="CORE", title="Original Title") + + assert store.update("CORE-1", title="a completely different title").ticket.title == "A Completely Different Title" + + def testCreateContinuesNumberingFromExistingTickets(store: Store, config: Config) -> None: """ The next number comes from scanning, with no counter file to desynchronize. diff --git a/tests/test_titles.py b/tests/test_titles.py new file mode 100644 index 0000000..bf63f59 --- /dev/null +++ b/tests/test_titles.py @@ -0,0 +1,137 @@ +""" +Title Case Tests + +The conversion itself, covering the two things that decide a word: its position and its existing shape. +""" + +# MARK: Imports + +import pytest + +from docket.core.titles import MINOR_WORDS, isTitleCase, toTitleCase + +# MARK: Functions + + +@pytest.mark.parametrize( + ("title", "expected"), + [ + ("record demo gif", "Record Demo Gif"), + ("Already Title Case", "Already Title Case"), + ("one", "One"), + ("HOST FLAG", "HOST FLAG"), + ], +) +def testEveryMajorWordIsCapitalized(title: str, expected: str) -> None: + """ + A word that is not minor, not positioned in the middle, and carries no case of its own is capitalized. + """ + + assert toTitleCase(title) == expected + + +@pytest.mark.parametrize( + ("title", "expected"), + [ + ("key removal checks usage outside the lock", "Key Removal Checks Usage Outside the Lock"), + ("Cannot Clear With Set Command", "Cannot Clear with Set Command"), + ("Check if Ticket is Ready For Work", "Check if Ticket Is Ready for Work"), + ], +) +def testAMinorWordInTheMiddleIsLowercased(title: str, expected: str) -> None: + """ + A minor word is matched by its lowercase form, so one that arrived capitalized is corrected rather than left alone. + """ + + assert toTitleCase(title) == expected + + +@pytest.mark.parametrize("word", ["the", "and", "of", "with"]) +def testAMinorWordIsStillCapitalizedAtEitherEnd(word: str) -> None: + """ + The first and the last word of a title are always major, whichever word they happen to be. + """ + + capitalized: str = word[:1].upper() + word[1:] + + assert toTitleCase(f"{word} middle word {word}") == f"{capitalized} Middle Word {capitalized}" + + +@pytest.mark.parametrize( + "title", + [ + "Add to Requires in CLI", + "Fix Character Encoding Issue in FEAT-5", + "Record Demo GIF with VHS", + "Set Up and Publish to PyPI", + "Migrate to 2.x MCPServer API", + ], +) +def testAWordCarryingItsOwnCaseOrADigitSurvives(title: str) -> None: + """ + An acronym, a ticket id, and a version number are left exactly as written, which is the whole reason `textcase.title` is not used bare. + """ + + assert toTitleCase(title) == title + + +def testALowercaseAcronymIsNotRecognized() -> None: + """ + An acronym typed in lowercase is indistinguishable from an ordinary word, so it is capitalized like one. + + This is a known limit of the rule rather than an oversight, and the fix is to type the acronym in caps. + """ + + assert toTitleCase("migrate to mcp") == "Migrate to Mcp" + + +def testSurroundingAndRepeatedWhitespaceCollapses() -> None: + """ + The split that finds the words is what removes the whitespace between them, so a title cannot carry padding into the file. + """ + + assert toTitleCase(" spaced out title ") == "Spaced Out Title" + + +def testConversionIsIdempotent() -> None: + """ + Converting an already converted title changes nothing, which is what lets every write run it without drift. + """ + + once: str = toTitleCase("key removal checks usage outside the lock") + + assert toTitleCase(once) == once + + +@pytest.mark.parametrize( + ("title", "expected"), + [ + ("Already Title Case", True), + ("Add to Requires in CLI", True), + ("not title case", False), + ("Trailing Space ", False), + ], +) +def testIsTitleCaseAsksWhatToTitleCaseAnswers(title: str, expected: bool) -> None: + """ + The asking half is defined by the converting half, so the two can never disagree about what is correct. + """ + + assert isTitleCase(title) is expected + + +def testMinorWordsAreAllLowercase() -> None: + """ + The list is matched against lowercased words, so an entry carrying a capital could never match. + """ + + assert all(word == word.lower() for word in MINOR_WORDS) + + +@pytest.mark.parametrize("word", ["up", "out"]) +def testAParticleIsNotTreatedAsMinor(word: str) -> None: + """ + `up` and `out` are left out of the list on purpose, since a ticket title wants the phrasal verb far more often than the preposition. + """ + + assert word not in MINOR_WORDS diff --git a/tests/test_validate.py b/tests/test_validate.py index a2297cb..93491d5 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -19,6 +19,7 @@ RULE_MISSING_DEPENDENCY, RULE_PRIORITY_RANGE, RULE_STATUS_DIRECTORY, + RULE_TITLE_CASE, RULE_UNKNOWN_KEY, RULE_UNKNOWN_STATUS, RULE_UNREADABLE, @@ -354,6 +355,54 @@ def testAnAlreadyLoadedSetIsReused(store: Store, config: Config) -> None: assert validate(store, loaded).isValid +def testATitleThatIsNotTitleCaseIsAWarning(store: Store, config: Config) -> None: + """ + A badly cased title still names the work, so it informs rather than blocks. + """ + + writeRaw(config, "todo", "CORE-1_a.md", makeText("CORE-1", title="record demo gif")) + + report: ValidationReport = validate(store) + + # Warnings alone leave the set valid, which is what keeps this out of a pre-commit hook's way. + assert report.isValid + assert rules(report) == [RULE_TITLE_CASE] + assert report.warnings[0].severity == SEVERITY_WARNING + assert report.warnings[0].ticketId == "CORE-1" + + +def testTheTitleCaseWarningNamesTheCorrectedTitle(store: Store, config: Config) -> None: + """ + The correction travels in the message, so a human or an agent can act on it without working out the convention first. + """ + + writeRaw(config, "todo", "CORE-1_a.md", makeText("CORE-1", title="key removal checks usage outside the lock")) + + report: ValidationReport = validate(store) + + assert "Key Removal Checks Usage Outside the Lock" in report.warnings[0].message + + +def testATitleCaseTitleIsNotWarnedAbout(store: Store, config: Config) -> None: + """ + An acronym must not be flagged, since the rule leaves a word carrying its own case alone. + """ + + writeRaw(config, "todo", "CORE-1_a.md", makeText("CORE-1", title="Add to Requires in CLI")) + + assert validate(store).findings == [] + + +def testAWrittenTicketNeverTripsTheTitleRule(store: Store) -> None: + """ + Every write converts the title, so the rule can only ever fire on a hand-edited file or one written before it existed. + """ + + store.create(key="CORE", title="record demo gif") + + assert validate(store).findings == [] + + def testFindingsAreOrderedByTicket(store: Store, config: Config) -> None: """ Per-ticket rules walk in sorted order, so repeated runs produce the same output for a diff or a CI log. From 1d8abcb0a342eab3326d68bf62d27a849b9ed8f3 Mon Sep 17 00:00:00 2001 From: Brody Childs Date: Wed, 12 Aug 2026 13:19:03 -0400 Subject: [PATCH 6/7] Fixed titles --- docs/tickets/done/BUG-2_cannotClearWithSetCommand.md | 3 ++- docs/tickets/done/BUG-3_migrateServerToMcp2XMcpserverApi.md | 2 +- .../tickets/done/BUG-5_serializeTicketWritesAcrossProcesses.md | 2 +- .../FEAT-11_repositoryAutomationAndContributionScaffolding.md | 2 +- docs/tickets/done/FEAT-13_checkIfTicketIsReadyForWork.md | 2 +- docs/tickets/done/FEAT-1_recordDemoGifWithVhs.md | 3 ++- docs/tickets/done/FEAT-2_setUpAndPublishToPypi.md | 2 +- docs/tickets/done/FEAT-9_arbitraryMetadata.md | 2 +- docs/tickets/todo/BUG-6_keyRemovalChecksUsageOutsideTheLock.md | 2 +- 9 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/tickets/done/BUG-2_cannotClearWithSetCommand.md b/docs/tickets/done/BUG-2_cannotClearWithSetCommand.md index 9f65262..d95256b 100644 --- a/docs/tickets/done/BUG-2_cannotClearWithSetCommand.md +++ b/docs/tickets/done/BUG-2_cannotClearWithSetCommand.md @@ -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 diff --git a/docs/tickets/done/BUG-3_migrateServerToMcp2XMcpserverApi.md b/docs/tickets/done/BUG-3_migrateServerToMcp2XMcpserverApi.md index 0fad100..c853ebd 100644 --- a/docs/tickets/done/BUG-3_migrateServerToMcp2XMcpserverApi.md +++ b/docs/tickets/done/BUG-3_migrateServerToMcp2XMcpserverApi.md @@ -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: [] diff --git a/docs/tickets/done/BUG-5_serializeTicketWritesAcrossProcesses.md b/docs/tickets/done/BUG-5_serializeTicketWritesAcrossProcesses.md index 8ec85f6..6c4bbe7 100644 --- a/docs/tickets/done/BUG-5_serializeTicketWritesAcrossProcesses.md +++ b/docs/tickets/done/BUG-5_serializeTicketWritesAcrossProcesses.md @@ -1,6 +1,6 @@ --- id: BUG-5 -title: Serialize ticket writes across processes +title: Serialize Ticket Writes Across Processes status: done priority: 3 requires: [] diff --git a/docs/tickets/done/FEAT-11_repositoryAutomationAndContributionScaffolding.md b/docs/tickets/done/FEAT-11_repositoryAutomationAndContributionScaffolding.md index a8707ec..e693cd5 100644 --- a/docs/tickets/done/FEAT-11_repositoryAutomationAndContributionScaffolding.md +++ b/docs/tickets/done/FEAT-11_repositoryAutomationAndContributionScaffolding.md @@ -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] diff --git a/docs/tickets/done/FEAT-13_checkIfTicketIsReadyForWork.md b/docs/tickets/done/FEAT-13_checkIfTicketIsReadyForWork.md index 293d9a7..8713646 100644 --- a/docs/tickets/done/FEAT-13_checkIfTicketIsReadyForWork.md +++ b/docs/tickets/done/FEAT-13_checkIfTicketIsReadyForWork.md @@ -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: [] diff --git a/docs/tickets/done/FEAT-1_recordDemoGifWithVhs.md b/docs/tickets/done/FEAT-1_recordDemoGifWithVhs.md index 991816a..9eea116 100644 --- a/docs/tickets/done/FEAT-1_recordDemoGifWithVhs.md +++ b/docs/tickets/done/FEAT-1_recordDemoGifWithVhs.md @@ -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 diff --git a/docs/tickets/done/FEAT-2_setUpAndPublishToPypi.md b/docs/tickets/done/FEAT-2_setUpAndPublishToPypi.md index 68479ae..1f6ea94 100644 --- a/docs/tickets/done/FEAT-2_setUpAndPublishToPypi.md +++ b/docs/tickets/done/FEAT-2_setUpAndPublishToPypi.md @@ -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] diff --git a/docs/tickets/done/FEAT-9_arbitraryMetadata.md b/docs/tickets/done/FEAT-9_arbitraryMetadata.md index 98d78e0..c455b89 100644 --- a/docs/tickets/done/FEAT-9_arbitraryMetadata.md +++ b/docs/tickets/done/FEAT-9_arbitraryMetadata.md @@ -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: [] diff --git a/docs/tickets/todo/BUG-6_keyRemovalChecksUsageOutsideTheLock.md b/docs/tickets/todo/BUG-6_keyRemovalChecksUsageOutsideTheLock.md index 6c95850..550507c 100644 --- a/docs/tickets/todo/BUG-6_keyRemovalChecksUsageOutsideTheLock.md +++ b/docs/tickets/todo/BUG-6_keyRemovalChecksUsageOutsideTheLock.md @@ -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] From 2d9bef57b700e4bba9ab06706b11ced13a264d74 Mon Sep 17 00:00:00 2001 From: Brody Childs Date: Wed, 12 Aug 2026 13:19:21 -0400 Subject: [PATCH 7/7] Finished FEAT-15 --- docs/tickets/{todo => done}/FEAT-15_useTitleCaseForTickets.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/tickets/{todo => done}/FEAT-15_useTitleCaseForTickets.md (98%) diff --git a/docs/tickets/todo/FEAT-15_useTitleCaseForTickets.md b/docs/tickets/done/FEAT-15_useTitleCaseForTickets.md similarity index 98% rename from docs/tickets/todo/FEAT-15_useTitleCaseForTickets.md rename to docs/tickets/done/FEAT-15_useTitleCaseForTickets.md index 591add3..87e8cfa 100644 --- a/docs/tickets/todo/FEAT-15_useTitleCaseForTickets.md +++ b/docs/tickets/done/FEAT-15_useTitleCaseForTickets.md @@ -1,7 +1,7 @@ --- id: FEAT-15 title: Use Title Case for Tickets -status: todo +status: done priority: 1 requires: [] metadata: {}