From a4308543bd225b3a94de5c8aa4f076f12ee3f06b Mon Sep 17 00:00:00 2001 From: Brody Childs Date: Fri, 18 Sep 2026 11:48:12 -0400 Subject: [PATCH 1/5] Added FEAT-18 --- .../FEAT-18_offsiteTicketAuthoringBrief.md | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 docs/tickets/todo/FEAT-18_offsiteTicketAuthoringBrief.md diff --git a/docs/tickets/todo/FEAT-18_offsiteTicketAuthoringBrief.md b/docs/tickets/todo/FEAT-18_offsiteTicketAuthoringBrief.md new file mode 100644 index 0000000..e0bbe02 --- /dev/null +++ b/docs/tickets/todo/FEAT-18_offsiteTicketAuthoringBrief.md @@ -0,0 +1,81 @@ +--- +id: FEAT-18 +title: Offsite Ticket Authoring Brief +status: todo +priority: 1 +requires: [] +metadata: {} +--- + +# Offsite Ticket Authoring Brief + +## What this is + +One document, written to be pasted into a chat system that has no connection to Docket, that teaches it to write Docket tickets as markdown files by hand. + +The chat answers with a set of ticket files. The user saves them into a Docket repository's `todo` directory, registers the keys the document told it to propose, and runs `validate`. That is the whole pipeline. No importer, no carried format, no parser. + +The point is to let the thinking part of starting a backlog happen wherever the user already is, rather than requiring them to be inside a Docket instance before a single ticket can exist. + +## Why it is a document and not a command + +Two heavier designs were assessed first and both were rejected. + +A CSV or bundle format with a `docket import` command means a second format specification, a second parser, temporary reference resolution, a topological sort, a bulk write holding one lock, and a key registration path that routes around the rule saying a key needs the user's agreement. All of that to move text into files. + +Handing the file to an on-site agent with MCP and letting it transcribe removes the parser but makes the transcription probabilistic, and it still needs the same authoring document. It is a reasonable fallback for a user who prefers to work conversationally, and nothing here prevents it, but it is not worth building toward. + +Writing the files directly is the lightest path that keeps every existing guarantee, because of the next section. + +## `validate` is already the importer + +Every rule a bulk import would have needed exists in `docket.core.validate` today. + +Unreadable files, duplicate ids, missing dependencies, dependency cycles, unregistered keys, a filename disagreeing with its id, a status disagreeing with its directory, a priority outside the configured band, an unknown status, and title case as a warning. + +That is not an approximation of an import validator, it is one. Any programmatic import would have meant writing a second rule set that then has to agree with this one forever. Dropping files in and running `validate` reuses the rule set that already exists, so the acceptance test for a handoff is a command the user already runs and a command CI already runs. + +The authoring burden is correspondingly small. `requires` and `metadata` both read as empty when absent, so a valid ticket needs only `id`, `title`, `status`, and `priority`. `_checkFilename` deliberately checks only the `_` prefix and not the slug, because slugs are expected to go stale, so any readable filename with the right prefix passes. + +## The constraint that makes it safe + +Id allocation is the only real risk, and it is removed by scoping what the external chat is allowed to author. + +The chat cannot know the highest number already used under a key, so against an existing instance it will guess. Usually that fails loudly, since the same id under a different title produces two files and a `duplicateId` error naming both. The quiet case is narrow but real, because an id and a title that slugify identically produce the same filename, so saving the file overwrites the existing ticket and validation sees nothing wrong, there being only one internally consistent file left. + +So the document instructs the chat to propose new keys only. A brand new key always starts at 1, which makes collision structurally impossible rather than merely unlikely. Appending to an existing key stays possible, but only when the user pastes in the ids already in use, and the document says that plainly rather than letting the chat infer it. + +This also puts key registration in the right hands. Keys must be registered before the files validate, so the output carries a short header naming each proposed key with its description and the literal `docket key add` lines to run. The user types them, which satisfies the rule that a key is never added without the user's agreement, by construction rather than by trust. + +## It is a sibling of `docs/tickets/CLAUDE.md`, not a copy + +That file is most of the needed content already, and it is the natural starting point. It cannot be handed over unchanged, because the parts that differ are inverted rather than merely absent. + +It says not to create, move, rename, or delete ticket files. This document's central instruction is to create them by hand. It says to let the tool convert titles to title case, where the external chat has no tool and has to case them itself. Its two tables name MCP tools the reader cannot call, and its keys section tells the reader to ask the user through `AskUserQuestion`. + +So the new document states in its first line that it is for preparing tickets outside a Docket instance, for a reader with no tools. Two documents in one repository giving opposite instructions about hand-editing ticket files is a hazard worth one explicit sentence in each, especially since `docs/tickets/CLAUDE.md` is what `deploy` ships into consumer repositories. + +## Output shape + +A zip is a convenience, not the specification. Most chat systems cannot reliably emit a binary, and the ones that cannot are much of the audience this exists for. A zip is also opaque to review at the exact moment the user should be reading what they are about to commit. + +So the primary instruction is one fenced block per ticket with its filename on the line above, which works in any chat window and stays reviewable. Environments that can package the result may offer a zip as well. + +## Acceptance + +- A chat with no Docket access, given only this document and a project description, produces files that pass `validate` with no errors once the named keys are registered. +- Title case warnings are acceptable output and the document says how to clear them, through `update_ticket` or by the on-site agent. +- The document never tells the reader to call a tool, and never assumes it can see a repository. + +## Open decisions + +- Where it lives under `docs/`, and its filename. It has to read as "prepare tickets for a Docket instance that is not here", not as "start a project with Docket". +- Whether `deploy` ships it into consumer repositories alongside `docs/tickets/CLAUDE.md`, or whether it stays in this repository as something the user links to. +- Whether a CLI escape hatch prints it to stdout, so an installed user can pipe it to the clipboard without going to find the file. Cheap, and it is the difference between the feature being used and being forgotten. +- How much of `docs/tickets/CLAUDE.md` is duplicated versus restated. Duplication drifts, and the two documents disagree on purpose, so a shared source is probably not worth it. + +## Notes + +Documentation only, with no change to `docket.core`, unless the CLI escape hatch is taken. + +The residual cost against every alternative is that hand-written frontmatter skips the title casing `create_ticket` performs, so the first `validate` after a handoff usually reports title case warnings to clear. That is self-healing and cheaper than either rejected design. From c025982716ff3c8ca2db339e7eafae1cd8cde6ab Mon Sep 17 00:00:00 2001 From: Brody Childs Date: Fri, 18 Sep 2026 12:08:09 -0400 Subject: [PATCH 2/5] Ticket and instructions update --- CLAUDE.md | 15 ++++++++++++++- ...FEAT-19_addGoogleStyleDocumentationComments.md | 12 ++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 docs/tickets/todo/FEAT-19_addGoogleStyleDocumentationComments.md diff --git a/CLAUDE.md b/CLAUDE.md index f418cb1..e0ec290 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,6 +5,19 @@ The `CLAUDE.md` file in `src\docket\templates\CLAUDE.md` is *not* for you. It is a template provided when Docket is deployed. +## General + +- We use American English here. +- We *are not* lazy developers. We implement things the *right* way based on informed and reasoned hypotheses. If something is beyond the explicit scope of a ticket but it is the correct answer, then it is the proper course of action. +- We keep our code DRY and lean. If something is reused, it should be shared; not duplicated into multiple places. + +## Environment + +This project is managed by `uv`. + +- Always run Python through `uv`, as in `uv run python ...` and `uv run pytest`. Never call a bare `python`, `pip`, or the `.venv` interpreter directly. +- Add and remove dependencies with `uv add` and `uv remove`, never by editing `pyproject.toml` by hand. + ## Code Style Match existing style exactly: @@ -51,4 +64,4 @@ Keep the snake_case-to-camelCase mapping explicit in `server.py`. Neither conven - Short subject line expressing what was done as a short imperative. - Subject line only. No body, no additional text. - Never commit unprompted. Verify (compile and test), report ready for review, then wait for review. -- When presenting code for review, use the `commit-message` skill to draft the commit subject alongside it. +- When presenting code for review (ie: when you stop at Phases or Checkpoints), stage the code you believe should be merged at this review stage and use the `commit-message` skill (or fall back to repo style if the command does not exist) to draft the commit subject alongside it. diff --git a/docs/tickets/todo/FEAT-19_addGoogleStyleDocumentationComments.md b/docs/tickets/todo/FEAT-19_addGoogleStyleDocumentationComments.md new file mode 100644 index 0000000..1e24116 --- /dev/null +++ b/docs/tickets/todo/FEAT-19_addGoogleStyleDocumentationComments.md @@ -0,0 +1,12 @@ +--- +id: FEAT-19 +title: Add Google Style Documentation Comments +status: todo +priority: 1 +requires: [] +metadata: {} +--- + +# Add Google Style Documentation Comments + +Add Google style documentation comments starting with using the utility script that was devised in one of the projects. From c165c4acacf195988746c0f5161f82b8592e147d Mon Sep 17 00:00:00 2001 From: Brody Childs Date: Fri, 18 Sep 2026 12:18:45 -0400 Subject: [PATCH 3/5] Did FEAT-18 --- README.md | 13 ++ docs/tickets/CLAUDE.md | 6 + .../FEAT-18_offsiteTicketAuthoringBrief.md | 13 +- .../todo/FEAT-6_templatesForTicketsPerKey.md | 8 + pyproject.toml | 1 + src/docket/__init__.py | 2 +- src/docket/cli/__init__.py | 6 + src/docket/cli/commands.py | 26 ++++ src/docket/cli/grammar.py | 6 + src/docket/core/deploy.py | 7 +- src/docket/core/handoff.py | 134 ++++++++++++++++ src/docket/core/resources.py | 33 ++++ .../docs/writingTicketsOffsite.md.jinja | 139 +++++++++++++++++ src/docket/templates/CLAUDE.md | 6 + tests/test_cli.py | 41 +++++ tests/test_handoff.py | 145 ++++++++++++++++++ tests/test_packaging.py | 1 + uv.lock | 77 ++++++++++ 18 files changed, 655 insertions(+), 9 deletions(-) create mode 100644 src/docket/core/handoff.py create mode 100644 src/docket/core/resources.py create mode 100644 src/docket/docs/writingTicketsOffsite.md.jinja create mode 100644 tests/test_handoff.py diff --git a/README.md b/README.md index 400d48d..b9ef444 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ docket list [-s todo] [-k CORE] [-m 2] [-r] docket graph [-i CORE-14 | -k GEN | -s todo] [-o FILE] docket key list | add KEY "desc" [-r TEXT] | remove KEY docket validate | deploy PATH | upgrade PATH +docket docs handoff ``` `-r` replaces the dependency list. `-ra` and `-rr` edit the one already there. Both in one call is refused. @@ -127,6 +128,18 @@ Every short flag has a long form (`-k/--key`, `-p/--priority`, `-m/--priority-ma | `validate()` | Structured findings. | | `set_metadata(id, key, value?)` | One entry at a time, leaving every other key alone. | +## Writing Tickets Elsewhere + +```bash +docket docs handoff > brief.md +``` + +Prints a brief written for a chat system that has no access to your repository. Paste it in, describe the project, and it writes ticket files by hand. + +The brief is rendered against this repository, so it names your registered keys, the first free number under each, your priority band, and the directory the files belong in. Nothing is left for it to guess. + +Save what comes back into your todo directory and run `docket validate`. That is the whole import step, because every rule an importer would need already lives there. Tickets written somewhere else are checked by the same code as the ones written here. + ## Key Ticket Rules 1. **Dependencies point one way.** A ticket declares `requires` and nothing else. Reverse edges are derived, so a one-sided edge is impossible rather than merely detectable. diff --git a/docs/tickets/CLAUDE.md b/docs/tickets/CLAUDE.md index ada15c2..37ae41a 100644 --- a/docs/tickets/CLAUDE.md +++ b/docs/tickets/CLAUDE.md @@ -53,6 +53,12 @@ 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. +## Tickets written outside this repository + +Docket ships a second document, printed by `docket docs handoff`, written for a chat system that has no access to this repository and has to write ticket files by hand. + +Its rules are deliberately the opposite of the ones above, because its reader has no tools to call. Do not follow it here. A file produced that way is an ordinary ticket the moment it lands, so `validate` is what confirms it and the tools above are what change it afterwards. + ## 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. diff --git a/docs/tickets/todo/FEAT-18_offsiteTicketAuthoringBrief.md b/docs/tickets/todo/FEAT-18_offsiteTicketAuthoringBrief.md index e0bbe02..446f70d 100644 --- a/docs/tickets/todo/FEAT-18_offsiteTicketAuthoringBrief.md +++ b/docs/tickets/todo/FEAT-18_offsiteTicketAuthoringBrief.md @@ -67,15 +67,16 @@ So the primary instruction is one fenced block per ticket with its filename on t - Title case warnings are acceptable output and the document says how to clear them, through `update_ticket` or by the on-site agent. - The document never tells the reader to call a tool, and never assumes it can see a repository. -## Open decisions +## Decisions taken -- Where it lives under `docs/`, and its filename. It has to read as "prepare tickets for a Docket instance that is not here", not as "start a project with Docket". -- Whether `deploy` ships it into consumer repositories alongside `docs/tickets/CLAUDE.md`, or whether it stays in this repository as something the user links to. -- Whether a CLI escape hatch prints it to stdout, so an installed user can pipe it to the clipboard without going to find the file. Cheap, and it is the difference between the feature being used and being forgotten. -- How much of `docs/tickets/CLAUDE.md` is duplicated versus restated. Duplication drifts, and the two documents disagree on purpose, so a shared source is probably not worth it. +- The brief lives at `src/docket/docs/writingTicketsOffsite.md.jinja`, inside the package so the CLI can print it, and deliberately not under `templates/`, which stays reserved for `FEAT-6`. +- `deploy` does not ship it into consumer repositories. A document whose rules invert the ones beside the tickets should not sit beside the tickets. +- `docket docs handoff` prints it, through `output.raw` so a redirect receives exactly the document. +- It is a rendered template rather than a flat file, which retired the constraint this ticket originally proposed. Rather than restricting the reader to new keys, the brief now names the registered keys, what each covers, and the first free number under each. Collision stops being a rule the reader has to follow and becomes a fact it is handed, so appending to an existing key is the ordinary path and proposing a new one is the exception it argues for. +- Nothing is shared with `docs/tickets/CLAUDE.md`. The two disagree on purpose, so a shared source would have to encode the disagreement. ## Notes -Documentation only, with no change to `docket.core`, unless the CLI escape hatch is taken. +Not documentation only in the end. Rendering the brief against the repository needed `docket.core.handoff`, and sharing the package data reader with `deploy` needed `docket.core.resources`. The residual cost against every alternative is that hand-written frontmatter skips the title casing `create_ticket` performs, so the first `validate` after a handoff usually reports title case warnings to clear. That is self-healing and cheaper than either rejected design. diff --git a/docs/tickets/todo/FEAT-6_templatesForTicketsPerKey.md b/docs/tickets/todo/FEAT-6_templatesForTicketsPerKey.md index 5ba7785..49d170d 100644 --- a/docs/tickets/todo/FEAT-6_templatesForTicketsPerKey.md +++ b/docs/tickets/todo/FEAT-6_templatesForTicketsPerKey.md @@ -12,3 +12,11 @@ Using a templater like `jinja2` (decide an appropriate vector during implementat Use case is that for Features, freeform writing like in this ticket is acceptable. However, for tickets like Bugs, having specific sections pre-established for things like "What Happened", "Reproduction Steps", and "What is Expected" are standard. + +## Notes from FEAT-18 + +`jinja2` is already a dependency, added by `FEAT-18` to render the offsite authoring brief, so the choice of templater is made unless there is a reason to revisit it. + +`FEAT-18` renders one document shipped inside the package, through `_buildEnvironment` in `docket.core.handoff` and `readPackageText` in `docket.core.resources`. What this ticket needs is different enough to be worth naming: the templates are authored by the consumer repository rather than shipped, so they load from the working tree rather than from package data, and they render against one ticket rather than against the registry. The environment construction is the only piece worth sharing, and hoisting it out of `handoff.py` into something like `docket.core.templating` is the natural move once there is a second caller to shape it against. + +On the command surface, `docket docs` is taken by `FEAT-18` and `docket template` is deliberately left free for this. `docket key template BUG` is worth weighing against it, since a template is a property of a key and keys already have their own group. diff --git a/pyproject.toml b/pyproject.toml index e63c50f..ff3aa83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ classifiers = [ ] dependencies = [ "filelock>=3.32.2", + "jinja2>=3.1.6", "mcp>=2,<3", "pyyaml>=6.0.3", "rich>=15.0.0", diff --git a/src/docket/__init__.py b/src/docket/__init__.py index d9cc4bc..a47f76a 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.3.0" +__version__ = "1.4.0" diff --git a/src/docket/cli/__init__.py b/src/docket/cli/__init__.py index 3d8ef6b..6c61aa2 100644 --- a/src/docket/cli/__init__.py +++ b/src/docket/cli/__init__.py @@ -14,6 +14,7 @@ from docket.cli.commands import ( commandDeploy, + commandDocs, commandGraph, commandKey, commandList, @@ -75,6 +76,7 @@ "buildParser", "classifyToken", "commandDeploy", + "commandDocs", "commandGraph", "commandKey", "commandList", @@ -150,6 +152,10 @@ def dispatch(args: argparse.Namespace, config: Optional[Config], output: Output) if args.command in ("deploy", "upgrade"): return commandDeploy(args, output) + # A shipped document renders with or without a configuration, so it must not be gated behind discovering one either. + if args.command == "docs": + return commandDocs(args, config, output) + # Every other command works against the configuration governing the current directory. Discovery is repeated when the parser was built without one, so the reason it could not be found is reported by the code that knows it. store: Store = Store(config if config is not None else discoverConfig()) diff --git a/src/docket/cli/commands.py b/src/docket/cli/commands.py index c3aa09d..f6bbff1 100644 --- a/src/docket/cli/commands.py +++ b/src/docket/cli/commands.py @@ -10,6 +10,7 @@ import argparse from pathlib import Path +from typing import Optional from rich.table import Table from rich.text import Text @@ -18,6 +19,7 @@ from docket.cli.output import STATUS_STYLES, Output, buildContextTable, relativeToRoot from docket.core.config import Config from docket.core.deploy import DeployReport, deploy, upgrade +from docket.core.handoff import renderHandoff from docket.core.graph import Readiness, ResolvedGraph, dependencyContext, readyTickets, resolveGraph, subgraphForId, subgraphForKey, subgraphForStatus, ticketReadiness from docket.core.inputs import requireWritableFile, writeFile from docket.core.mermaid import renderGraph @@ -429,6 +431,30 @@ def commandValidate(args: argparse.Namespace, store: Store, output: Output) -> i return EXIT_INVALID if report.errors else EXIT_OK +def commandDocs(args: argparse.Namespace, config: Optional[Config], output: Output) -> int: + """ + Print a document docket ships, rendered for this repository. + + args: The parsed arguments. + config: The configuration governing the current directory, or `None` when none was found. + output: Where to write. + + Returns the process exit code. + """ + + if args.docsCommand != "handoff": + output.error("Expected one of: handoff.") + return EXIT_USAGE + + # A configuration is what lets the brief name real keys and real numbering, but its absence is a state the document handles rather than an error, since a person may be anywhere when they go to fetch it. + store: Optional[Store] = Store(config) if config is not None else None + + # Straight to stdout with no styling, so redirecting this to a file or a clipboard yields exactly the document. + output.raw(renderHandoff(store)) + + return EXIT_OK + + def commandDeploy(args: argparse.Namespace, output: Output) -> int: """ Install docket into a repository, or refresh what is already deployed there. diff --git a/src/docket/cli/grammar.py b/src/docket/cli/grammar.py index 4951408..e88d782 100644 --- a/src/docket/cli/grammar.py +++ b/src/docket/cli/grammar.py @@ -280,6 +280,12 @@ def buildParser(config: Optional[Config] = None) -> argparse.ArgumentParser: keyRemoveParser: argparse.ArgumentParser = keyCommands.add_parser("remove", help="Remove a key no ticket uses.", formatter_class=RichHelpFormatter) keyRemoveParser.add_argument("key", help=f"The key to remove. {keyOptions}") + # Documents docket ships, rendered against this repository. This is a group rather than a bare command because what it prints is read somewhere else, and more than one such document is plausible. + docsParser: argparse.ArgumentParser = commands.add_parser("docs", help="Print a document docket ships, rendered for this repository.", formatter_class=RichHelpFormatter) + docsCommands = docsParser.add_subparsers(dest="docsCommand", metavar="SUBCOMMAND") + + docsCommands.add_parser("handoff", help="Print the brief that teaches a chat system with no access to this repository how to write tickets for it by hand.", formatter_class=RichHelpFormatter) + commands.add_parser("validate", help="Run every integrity rule.", formatter_class=RichHelpFormatter) deployParser: argparse.ArgumentParser = commands.add_parser("deploy", help="Install docket into a repository.", formatter_class=RichHelpFormatter) diff --git a/src/docket/core/deploy.py b/src/docket/core/deploy.py index 5da682c..0e53a6d 100644 --- a/src/docket/core/deploy.py +++ b/src/docket/core/deploy.py @@ -10,7 +10,6 @@ import json from dataclasses import dataclass, field -from importlib.resources import files from pathlib import Path from typing import Any @@ -18,9 +17,13 @@ from docket.core.config import CONFIG_FILENAME, Config, loadConfig from docket.core.errors import DeployError from docket.core.lock import LOCK_FILENAME +from docket.core.resources import readPackageText # MARK: Constants +# The directory inside the package holding the files a repository receives. +TEMPLATES_DIRECTORY: str = "templates" + # The template files shipped inside the package. TEMPLATE_CLAUDE: str = "CLAUDE.md" TEMPLATE_CONFIG: str = "docket.toml" @@ -164,7 +167,7 @@ def readTemplate(name: str) -> str: Returns the template text. """ - return files("docket").joinpath("templates", name).read_text(encoding="utf-8") + return readPackageText(TEMPLATES_DIRECTORY, name) def _writeClaudeTemplate(config: Config, report: DeployReport) -> None: diff --git a/src/docket/core/handoff.py b/src/docket/core/handoff.py new file mode 100644 index 0000000..c50d350 --- /dev/null +++ b/src/docket/core/handoff.py @@ -0,0 +1,134 @@ +""" +Docket Handoff + +Rendering the offsite authoring brief. + +The brief is one document handed to a chat system that has no connection to this repository, so everything it could otherwise only guess at is rendered into it. +""" + +# MARK: Imports + +from dataclasses import dataclass +from typing import Any, Optional + +from jinja2 import Environment, StrictUndefined, Template + +from docket.core.config import DEFAULT_MAX_PRIORITY, DEFAULT_PRIORITY, DEFAULT_ROOT, DEFAULT_TODO_DIR, Config +from docket.core.ids import nextId +from docket.core.resources import readPackageText +from docket.core.store import Store + +# MARK: Constants + +# The directory inside the package holding documents written to be read by someone, kept apart from `templates` because those are files a repository receives rather than text a person is handed. +DOCS_DIRECTORY: str = "docs" + +# The brief itself. +HANDOFF_TEMPLATE: str = "writingTicketsOffsite.md.jinja" + +# MARK: Classes + + +@dataclass(frozen=True) +class KeyBriefing: + """ + One registered key, as the brief needs to describe it. + + The next id is carried alongside the key because it is the whole reason the brief is rendered rather than shipped flat. A reader told which number to start at cannot collide with a ticket that already exists. + """ + + # MARK: Properties + + key: str + description: str + nextId: str + + +# MARK: Functions + + +def readDocument(name: str) -> str: + """ + Read a document shipped inside the package. + + name: The document filename. + + Returns the document text. + """ + + return readPackageText(DOCS_DIRECTORY, name) + + +def buildKeyBriefings(store: Store) -> list[KeyBriefing]: + """ + Describe every registered key, with the next id free under it. + + The whole ticket set is loaded once and every key allocates against that one snapshot, so the numbers cannot disagree with each other. + + store: The store naming the configuration and the ticket root. + + Returns one briefing per registered key, ordered by key. + """ + + existingIds: list[str] = store.loadAll().ids() + + return [ + KeyBriefing(key=key, description=description, nextId=nextId(key, existingIds)) + for key, description in sorted(store.config.registeredKeys.items()) + ] + + +def buildContext(store: Optional[Store]) -> dict[str, Any]: + """ + Gather everything the brief names about the repository it is written for. + + Run outside a repository there is nothing to gather, so the defaults stand in and the brief tells its reader to propose keys instead of choosing from them. That is the same document either way rather than a second mode. + + store: The store for the repository the brief is being written for, or `None` when none was found. + + Returns the render context. + """ + + # Without a configuration every value falls back to what a freshly deployed repository would have, since that is what the reader's tickets will eventually meet. + if store is None: + return { + "keys": [], + "ticketDir": f"{DEFAULT_ROOT}/{DEFAULT_TODO_DIR}", + "defaultPriority": DEFAULT_PRIORITY, + "maxPriority": DEFAULT_MAX_PRIORITY, + } + + config: Config = store.config + + return { + "keys": buildKeyBriefings(store), + "ticketDir": f"{config.root}/{config.todoDir}", + "defaultPriority": config.defaultPriority, + "maxPriority": config.maxPriority, + } + + +def renderHandoff(store: Optional[Store] = None) -> str: + """ + Render the offsite authoring brief. + + store: The store for the repository the brief is being written for, or `None` when none was found. + + Returns the rendered document. + """ + + template: Template = _buildEnvironment().from_string(readDocument(HANDOFF_TEMPLATE)) + + return template.render(buildContext(store)) + + +def _buildEnvironment() -> Environment: + """ + Build the environment every shipped document renders through. + + Autoescaping is off because the output is markdown a person reads, and escaping it would corrupt the very syntax the brief is teaching. An undefined name raises rather than rendering as nothing, so a template naming something the context does not carry fails here instead of reaching the reader as a hole in a sentence. + + Returns the environment. + """ + + return Environment(undefined=StrictUndefined, trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True, autoescape=False) diff --git a/src/docket/core/resources.py b/src/docket/core/resources.py new file mode 100644 index 0000000..c3baf69 --- /dev/null +++ b/src/docket/core/resources.py @@ -0,0 +1,33 @@ +""" +Docket Resources + +Reading the non-Python files shipped inside the package. + +Templates a repository receives and documents a person is handed both live in the package rather than the working tree, so both are read through here. +""" + +# MARK: Imports + +from importlib.resources import files + +# MARK: Constants + +# The package the shipped files live inside. +PACKAGE_NAME: str = "docket" + +# MARK: Functions + + +def readPackageText(directory: str, name: str) -> str: + """ + Read a text file shipped inside the package. + + This goes through `importlib.resources` rather than a path derived from `__file__`, so it reads the same whether docket is installed as a wheel, run from a source checkout, or imported from a zip. + + directory: The directory inside the package holding the file. + name: The filename. + + Returns the file text. + """ + + return files(PACKAGE_NAME).joinpath(directory, name).read_text(encoding="utf-8") diff --git a/src/docket/docs/writingTicketsOffsite.md.jinja b/src/docket/docs/writingTicketsOffsite.md.jinja new file mode 100644 index 0000000..a0c3a5d --- /dev/null +++ b/src/docket/docs/writingTicketsOffsite.md.jinja @@ -0,0 +1,139 @@ +# Writing Tickets for Docket, Offsite + +You are being asked to write tickets for a Docket repository you cannot reach. + +Docket is a ticketing system where every ticket is a markdown file with a YAML frontmatter block, kept inside the repository it describes. Normally an agent writes tickets through Docket's own tools. You have no such tools and no view of the repository, so you will write the files by hand as text, and the person you are talking to will carry them to the repository themselves. + +This document is everything you need. Follow it exactly and the files you produce will be accepted without editing. + +## What a ticket file is + +One markdown file. A YAML frontmatter block, then prose. + +```markdown +--- +id: CORE-1 +title: Skirmish Setup +status: todo +priority: 2 +requires: [] +--- + +# Skirmish Setup + +Prose, unparsed and unconstrained. +``` + +That example uses an illustrative key. Use the real ones named below, and do not include the example itself in what you hand back. + +| Field | What to write | +|---|---| +| `id` | `-`. See the keys and numbering sections below. | +| `title` | Free text in title case. | +| `status` | Always `todo` for a new ticket. | +| `priority` | An integer from 0 through {{ maxPriority }}, where 0 is most urgent. Use {{ defaultPriority }} unless the work is clearly more or less urgent than the rest. | +| `requires` | A list of ids this ticket depends on. Write `[]` when it depends on nothing. | + +Everything below the closing `---` is the body, and nothing parses it. That is where the real content goes. + +## Keys + +A key is the part of an id before the hyphen, and it groups related work. Keys are closed, which means a ticket carrying a key the repository has not registered is an error rather than a new group. + +{% if keys %} +These keys are registered, and the number shown is the first one free under each. + +| Key | Covers | Start numbering at | +|---|---|---| +{% for key in keys %} +| `{{ key.key }}` | {{ key.description }} | `{{ key.nextId }}` | +{% endfor %} + +Prefer these keys. Reach for a new one only when some part of the work forms a category these plainly do not cover, and when you do, say in your output what it would group and why none of the registered keys fit. A ticket filed under a roughly right key is easy to move later, while a key is a structural decision the repository keeps. Proposing a key is not the same as having one, so a proposed key has to be registered by hand before your tickets are valid. + +Those starting numbers were read when this document was produced. If tickets have been created since, an id of yours may collide with one of them, which the repository reports as a duplicate id and a person resolves by renumbering. Nothing is lost. +{% else %} +No keys were available when this document was produced, either because the repository has none registered yet or because it was not reachable. + +So propose them. Choose a small set, three or four at most, each covering an obvious category of work in the project being described. `FEAT` for new features and `BUG` for defects are conventional starting points and are usually enough. Resist the urge to carve the project finely, because a key is a structural decision about the repository and too many of them is the common mistake. Each key is uppercase letters and digits, and must start with a letter. + +Give every key a one line description of what it covers, and number every key's tickets from 1. +{% endif %} + +## Ids and filenames + +An id is the key, a hyphen, and a number with no leading zeros. Number each key's tickets in a single unbroken run, starting from the number given above, or from 1 for a key you are proposing. An id is permanent once written, so never reuse one within your output. + +Name each file `_.md`, where the slug is the title converted to camelCase with everything outside letters and digits removed. + +``` +CORE-1_skirmishSetup.md +CORE-2_fixPathfindingOnDiagonals.md +``` + +Only the id prefix matters to the repository. A slug that reads well to a person is the whole of its job. + +## Titles are title case + +Write titles in title case, because the repository checks them and reports every title that does not match. + +A word carrying an uppercase letter past its first character, or a digit anywhere, is left exactly as written. That is what keeps `CLI`, `MCPServer`, and `2.x` intact, so spell an acronym in capitals when you mean one. + +## Dependencies point one way + +A ticket declares what it `requires`. It never declares what it blocks, because the reverse direction is derived and storing both guarantees they eventually disagree. + +Use real ids in `requires`, which you can do because you are allocating the ids yourself. Every id you name must be a ticket in your output or one that already exists in the repository. Dependencies may not form a cycle, so check that following `requires` from any ticket never arrives back at that same ticket. + +Order your output however reads best. A ticket may require one written after it. + +## Write the body for a reader who was not there + +This is the part that matters most, and the part you are actually here for. + +A ticket is read weeks later by someone, or something, with none of the context of the conversation that produced it. Put that context in the ticket. Include the architecture that was discussed, the assumptions being made, the alternatives that were considered and set aside, and the questions that were already asked and answered. State them plainly in the body where a new reader will see them. + +A ticket that only makes sense to whoever was in the conversation is a ticket that will be redone from scratch. A one line ticket is almost always a ticket that has lost something. + +Start the body with a level one heading matching the title, then write as much prose as the work deserves. Headings, lists, and code fences are all fine. + +## What to hand back + +For each ticket, write its filename on its own line, then the complete file in a fenced block: + +```` +CORE-1_skirmishSetup.md + +```markdown +--- +id: CORE-1 +title: Skirmish Setup +status: todo +priority: 2 +requires: [] +--- + +# Skirmish Setup + +The body, in full. +``` +```` + +Do not combine tickets into one file, do not summarize a ticket instead of writing it, and do not leave a body to be filled in later. + +If you are proposing keys rather than using registered ones, list them first, each with the exact command that registers it: + +``` +docket key add FEAT "Tickets that are for future features" +``` + +If your environment can package files for download, offering a zip of the same files as well is a convenience. The fenced blocks are what matters, because they can be read and corrected before anything is saved. + +## Then tell the person this + +End your output with the steps that turn it into real tickets, so nothing is left implied: + +1. Register any proposed keys by running the `docket key add` commands above, from the repository root. +2. Save each file, under its given filename, into `{{ ticketDir }}/`. +3. Run `docket validate` from the repository root. +4. Resolve anything it reports as an error. Title case warnings are expected from hand written tickets and are cleared with `docket set --title "..."`. diff --git a/src/docket/templates/CLAUDE.md b/src/docket/templates/CLAUDE.md index ada15c2..37ae41a 100644 --- a/src/docket/templates/CLAUDE.md +++ b/src/docket/templates/CLAUDE.md @@ -53,6 +53,12 @@ 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. +## Tickets written outside this repository + +Docket ships a second document, printed by `docket docs handoff`, written for a chat system that has no access to this repository and has to write ticket files by hand. + +Its rules are deliberately the opposite of the ones above, because its reader has no tools to call. Do not follow it here. A file produced that way is an ordinary ticket the moment it lands, so `validate` is what confirms it and the tools above are what change it afterwards. + ## 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. diff --git a/tests/test_cli.py b/tests/test_cli.py index a71d625..09a8969 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -788,6 +788,47 @@ def testTheOldFlatCommandsAreGone(inRepo: Path, command: list[str]) -> None: assert excInfo.value.code == EXIT_USAGE +def testDocsHandoffWritesTheBriefToStdout(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: + """ + The brief is meant to be redirected to a file or pasted into another chat, so it bypasses `rich` exactly as mermaid source does. + """ + + assert main(["docs", "handoff"]) == EXIT_OK + + out: str = capsys.readouterr().out + + assert out.startswith("# Writing Tickets for Docket, Offsite\n") + assert "\x1b" not in out + + # Rendered inside a repository, the brief names that repository's registry rather than teaching the reader to invent one. + assert "`CORE-1`" in out + assert "No keys were available" not in out + + +def testDocsHandoffRendersOutsideARepository(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """ + A person fetching the brief may be anywhere, so a missing configuration is a branch of the document rather than a failure. + """ + + previous: str = os.getcwd() + os.chdir(tmp_path) + try: + assert main(["docs", "handoff"]) == EXIT_OK + finally: + os.chdir(previous) + + assert "No keys were available" in capsys.readouterr().out + + +def testDocsRefusesAnUnknownSubcommand(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: + """ + Naming the group alone is a usage error that says what the group accepts, matching how the key group answers the same mistake. + """ + + assert main(["docs"]) == EXIT_USAGE + assert "Expected one of: handoff." in capsys.readouterr().err + + def testGraphWritesBareMermaidToStdout(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: """ Machine-readable output bypasses `rich`, so a redirect captures exactly the source with no wrapping or escape sequences. diff --git a/tests/test_handoff.py b/tests/test_handoff.py new file mode 100644 index 0000000..b3152dd --- /dev/null +++ b/tests/test_handoff.py @@ -0,0 +1,145 @@ +""" +Handoff Tests + +Render the offsite authoring brief, and hold the document to the rules it teaches. + +The brief is read by something that cannot ask a question, so a claim it makes that the code no longer honors is a silent failure. These tests are what keep the two together. +""" + +# MARK: Imports + +import re + +import pytest + +from docket.core.config import Config +from docket.core.handoff import HANDOFF_TEMPLATE, KeyBriefing, buildContext, buildKeyBriefings, readDocument, renderHandoff +from docket.core.ids import buildFilename, isValidId +from docket.core.store import Store +from docket.core.ticket import STATUSES, Ticket, parseTicket +from docket.core.titles import isTitleCase + +# MARK: Constants + +# Every fenced markdown block in the brief, which is where its worked examples live. +EXAMPLE_PATTERN: re.Pattern[str] = re.compile(r"```markdown\n(.*?)\n```", re.DOTALL) + +# The filename the brief shows beside its example, and the pieces it claims to be built from. +EXAMPLE_FILENAME: str = "CORE-1_skirmishSetup.md" +EXAMPLE_ID: str = "CORE-1" +EXAMPLE_TITLE: str = "Skirmish Setup" + +# MARK: Functions + + +def testRenderNamesEveryRegisteredKeyWithTheNextIdFreeUnderIt(store: Store) -> None: + """ + The key table is the whole reason the brief is rendered rather than shipped flat, so it has to carry the description and the number a reader starts at. + """ + + store.create("CORE", "Skirmish Setup") + store.create("CORE", "Fog of War") + + rendered: str = renderHandoff(store) + + # Two tickets exist under CORE, so the reader is sent to the third number, while an untouched key still starts at one. + assert "| `CORE` | tactical-sim core | `CORE-3` |" in rendered + assert "| `GEN` | map generation | `GEN-1` |" in rendered + + +def testKeyBriefingsAllocateAgainstOneSnapshot(store: Store) -> None: + """ + Every key allocates from the same loaded set, so two keys can never be described from two different views of the repository. + """ + + store.create("CORE", "Skirmish Setup") + + briefings: list[KeyBriefing] = buildKeyBriefings(store) + + assert [briefing.key for briefing in briefings] == ["CORE", "GEN", "HEAD", "META"] + assert [briefing.nextId for briefing in briefings] == ["CORE-2", "GEN-1", "HEAD-1", "META-1"] + + +def testRenderReadsThePriorityBandFromTheConfiguration(store: Store) -> None: + """ + A brief naming a band the repository does not use would produce tickets `validate` rejects, so the numbers come from the configuration rather than from the document. + """ + + store.config.maxPriority = 6 + store.config.defaultPriority = 5 + + rendered: str = renderHandoff(store) + + assert "0 through 6" in rendered + assert "Use 5 unless" in rendered + + +def testRenderNamesTheConfiguredTicketDirectory(store: Store, config: Config) -> None: + """ + The closing steps tell a person where to save what they were handed, which is a path only the configuration knows. + """ + + assert f"{config.root}/{config.todoDir}/" in renderHandoff(store) + + +def testRenderWithoutAConfigurationTeachesTheReaderToProposeKeys(store: Store) -> None: + """ + Run outside a repository there is no registry to choose from, so the brief switches to proposing keys rather than failing or rendering an empty table. + """ + + rendered: str = renderHandoff(None) + + assert "No keys were available" in rendered + assert "Start numbering at" not in rendered + + # The fallback still has to name somewhere to put the files, which is what a freshly deployed repository would use. + assert "docs/tickets/todo/" in rendered + + # And it must not quietly borrow the keys of whatever repository the command happened to run in. + assert "tactical-sim core" not in rendered + assert "tactical-sim core" in renderHandoff(store) + + +def testContextCarriesNoKeysWithoutAStore() -> None: + """ + The no-configuration branch is a real context rather than a second document, so it carries the same names with empty and default values. + """ + + context: dict[str, object] = buildContext(None) + + assert context["keys"] == [] + assert context["maxPriority"] == 4 + assert context["defaultPriority"] == 2 + + +@pytest.mark.parametrize("example", EXAMPLE_PATTERN.findall(readDocument(HANDOFF_TEMPLATE))) +def testEveryWorkedExampleParsesAsATicket(example: str) -> None: + """ + The brief teaches a file format by showing it, so every example it shows is run through the parser that will actually read what the reader writes. + + example: One fenced markdown block lifted from the document. + """ + + ticket: Ticket = parseTicket(example) + + assert isValidId(ticket.id) + assert ticket.status in STATUSES + assert isTitleCase(ticket.title) + assert ticket.body.strip() + + +def testTheExampleFilenameIsTheOneDocketWouldBuild() -> None: + """ + The brief spells out a filename convention and then shows one, so the shown one is derived here rather than trusted. + """ + + assert buildFilename(EXAMPLE_ID, EXAMPLE_TITLE) == EXAMPLE_FILENAME + assert EXAMPLE_FILENAME in readDocument(HANDOFF_TEMPLATE) + + +def testTheBriefCarriesNoEmDash() -> None: + """ + House style forbids them, and this document is output as much as it is source. + """ + + assert "—" not in readDocument(HANDOFF_TEMPLATE) diff --git a/tests/test_packaging.py b/tests/test_packaging.py index ae290c4..f6678f8 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -33,6 +33,7 @@ "py.typed", "templates/CLAUDE.md", "templates/docket.toml", + "docs/writingTicketsOffsite.md.jinja", ) # Directories that must never reach the source distribution, because they are development state rather than source. diff --git a/uv.lock b/uv.lock index dcfa8e6..9e3d3f2 100644 --- a/uv.lock +++ b/uv.lock @@ -393,6 +393,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -449,6 +461,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "mcp" version = "2.0.0" @@ -1028,6 +1103,7 @@ name = "ticket-docket" source = { editable = "." } dependencies = [ { name = "filelock" }, + { name = "jinja2" }, { name = "mcp" }, { name = "pyyaml" }, { name = "rich" }, @@ -1045,6 +1121,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "filelock", specifier = ">=3.32.2" }, + { name = "jinja2", specifier = ">=3.1.6" }, { name = "mcp", specifier = ">=2,<3" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "rich", specifier = ">=15.0.0" }, From 271d002c7e5910bbfb9ed05da09a6cdc6c9b379a Mon Sep 17 00:00:00 2001 From: Brody Childs Date: Fri, 18 Sep 2026 12:27:08 -0400 Subject: [PATCH 4/5] Added output files for docs commands --- README.md | 2 +- src/docket/cli/__init__.py | 6 +++-- src/docket/cli/commands.py | 46 +++++++++++++++++++++++++------------- src/docket/cli/grammar.py | 17 ++++++++++---- src/docket/core/inputs.py | 4 ++-- tests/test_cli.py | 37 ++++++++++++++++++++++++++---- tests/test_inputs.py | 10 ++++----- 7 files changed, 88 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index b9ef444..449caac 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ docket list [-s todo] [-k CORE] [-m 2] [-r] docket graph [-i CORE-14 | -k GEN | -s todo] [-o FILE] docket key list | add KEY "desc" [-r TEXT] | remove KEY docket validate | deploy PATH | upgrade PATH -docket docs handoff +docket docs handoff [-o FILE] ``` `-r` replaces the dependency list. `-ra` and `-rr` edit the one already there. Both in one call is refused. diff --git a/src/docket/cli/__init__.py b/src/docket/cli/__init__.py index 6c61aa2..62e7319 100644 --- a/src/docket/cli/__init__.py +++ b/src/docket/cli/__init__.py @@ -26,13 +26,14 @@ commandStatusRead, commandTicket, commandValidate, + emitDocument, ) from docket.cli.grammar import ( CLEAR_SENTINEL, EXIT_INVALID, EXIT_OK, EXIT_USAGE, - OUT_ARGUMENT, + OUTPUT_ARGUMENT, PROGRAM_NAME, TICKET_COMMAND, TOKEN_ID, @@ -63,7 +64,7 @@ "EXIT_INVALID", "EXIT_OK", "EXIT_USAGE", - "OUT_ARGUMENT", + "OUTPUT_ARGUMENT", "PROGRAM_NAME", "STATUS_STYLES", "TICKET_COMMAND", @@ -91,6 +92,7 @@ "describeKeys", "describePriorities", "dispatch", + "emitDocument", "main", "parseEditIdList", "parseIdList", diff --git a/src/docket/cli/commands.py b/src/docket/cli/commands.py index f6bbff1..e777443 100644 --- a/src/docket/cli/commands.py +++ b/src/docket/cli/commands.py @@ -15,7 +15,7 @@ from rich.table import Table from rich.text import Text -from docket.cli.grammar import EXIT_INVALID, EXIT_OK, EXIT_USAGE, OUT_ARGUMENT, parseEditIdList, parseIdList, resolveGraphScope, resolveListFilters +from docket.cli.grammar import EXIT_INVALID, EXIT_OK, EXIT_USAGE, OUTPUT_ARGUMENT, parseEditIdList, parseIdList, resolveGraphScope, resolveListFilters from docket.cli.output import STATUS_STYLES, Output, buildContextTable, relativeToRoot from docket.core.config import Config from docket.core.deploy import DeployReport, deploy, upgrade @@ -344,17 +344,7 @@ def commandGraph(args: argparse.Namespace, store: Store, output: Output) -> int: source: str = renderGraph(graph) - if args.out is not None: - # Check the destination before rendering work is spent on it, and translate whatever the filesystem still refuses, so no write failure reaches the user as a traceback. - outPath: Path = writeFile(requireWritableFile(args.out, OUT_ARGUMENT), source, OUT_ARGUMENT) - output.print(f"Wrote {outPath}") - - return EXIT_OK - - # Straight to stdout with no styling, so a redirect captures exactly the mermaid source. - output.raw(source) - - return EXIT_OK + return emitDocument(source, args.output, OUTPUT_ARGUMENT, output) def commandKey(args: argparse.Namespace, store: Store, output: Output) -> int: @@ -431,6 +421,33 @@ def commandValidate(args: argparse.Namespace, store: Store, output: Output) -> i return EXIT_INVALID if report.errors else EXIT_OK +def emitDocument(text: str, destination: Optional[str], name: str, output: Output) -> int: + """ + Write rendered text to a file when one was named, and to stdout when one was not. + + Both the mermaid source and the shipped documents are text a machine reads next, so both leave through here rather than each growing their own copy of the rule. + + text: The rendered text to emit. + destination: The path to write to, or `None` to write to stdout. + name: What to name the destination in an error message, for example `--output path`. + output: Where to write. + + Returns the process exit code. + """ + + if destination is not None: + # Check the destination before rendering work is spent on it, and translate whatever the filesystem still refuses, so no write failure reaches the user as a traceback. + outPath: Path = writeFile(requireWritableFile(destination, name), text, name) + output.print(f"Wrote {outPath}") + + return EXIT_OK + + # Straight to stdout with no styling, so a redirect captures exactly what was rendered and nothing else. + output.raw(text) + + return EXIT_OK + + def commandDocs(args: argparse.Namespace, config: Optional[Config], output: Output) -> int: """ Print a document docket ships, rendered for this repository. @@ -449,10 +466,7 @@ def commandDocs(args: argparse.Namespace, config: Optional[Config], output: Outp # A configuration is what lets the brief name real keys and real numbering, but its absence is a state the document handles rather than an error, since a person may be anywhere when they go to fetch it. store: Optional[Store] = Store(config) if config is not None else None - # Straight to stdout with no styling, so redirecting this to a file or a clipboard yields exactly the document. - output.raw(renderHandoff(store)) - - return EXIT_OK + return emitDocument(renderHandoff(store), args.output, OUTPUT_ARGUMENT, output) def commandDeploy(args: argparse.Namespace, output: Output) -> int: diff --git a/src/docket/cli/grammar.py b/src/docket/cli/grammar.py index e88d782..5d298a9 100644 --- a/src/docket/cli/grammar.py +++ b/src/docket/cli/grammar.py @@ -41,8 +41,8 @@ # The word that clears a comma-separated list argument. An id can never collide with it, since every id is an uppercase key followed by a hyphen and a number. CLEAR_SENTINEL: str = "none" -# How the graph destination is named when a message has to talk about it. -OUT_ARGUMENT: str = "--out path" +# How a destination is named when a message has to talk about it. The graph and the shipped documents share the flag, so they share its name too. +OUTPUT_ARGUMENT: str = "--output path" # MARK: Functions @@ -265,7 +265,7 @@ def buildParser(config: Optional[Config] = None) -> argparse.ArgumentParser: graphScope.add_argument("-i", "--id", help="Scope to one ticket's ancestors and descendants.") graphScope.add_argument("-k", "--key", help=f"Scope to one key, plus its immediate cross-key neighbors. {keyOptions}") graphScope.add_argument("-s", "--status", choices=STATUSES, help="Scope to the tickets with this status alone. Nothing outside it is borrowed, so an edge survives only when both of its ends carry the status.") - graphParser.add_argument("-o", "--out", help="Write to a file rather than to stdout.") + graphParser.add_argument("-o", "--output", help="Write to a file rather than to stdout.") keyParser: argparse.ArgumentParser = commands.add_parser("key", help="Inspect and manage the key registry.", formatter_class=RichHelpFormatter) keyCommands = keyParser.add_subparsers(dest="keyCommand", metavar="SUBCOMMAND") @@ -284,7 +284,16 @@ def buildParser(config: Optional[Config] = None) -> argparse.ArgumentParser: docsParser: argparse.ArgumentParser = commands.add_parser("docs", help="Print a document docket ships, rendered for this repository.", formatter_class=RichHelpFormatter) docsCommands = docsParser.add_subparsers(dest="docsCommand", metavar="SUBCOMMAND") - docsCommands.add_parser("handoff", help="Print the brief that teaches a chat system with no access to this repository how to write tickets for it by hand.", formatter_class=RichHelpFormatter) + # Every document is written the same two ways, so the destination is declared once here and inherited by each one rather than repeated per document. + docsOutput: argparse.ArgumentParser = argparse.ArgumentParser(add_help=False) + docsOutput.add_argument("-o", "--output", help="Write to a file rather than to stdout.") + + docsCommands.add_parser( + "handoff", + help="Print the brief that teaches a chat system with no access to this repository how to write tickets for it by hand.", + parents=[docsOutput], + formatter_class=RichHelpFormatter, + ) commands.add_parser("validate", help="Run every integrity rule.", formatter_class=RichHelpFormatter) diff --git a/src/docket/core/inputs.py b/src/docket/core/inputs.py index 2da26ff..b01e227 100644 --- a/src/docket/core/inputs.py +++ b/src/docket/core/inputs.py @@ -41,7 +41,7 @@ def requireWritableFile(path: str, name: str) -> Path: The checks that can be made without touching the disk are made here, so a caller learns the destination is unusable before any work is done for it. A filesystem may still refuse the write afterwards for a reason no check can predict, which is why `writeFile` exists to catch that too. path: The destination as the caller supplied it. - name: What to name in the error message, for example `--out path`. + name: What to name in the error message, for example `--output path`. Returns the destination as a `Path`. """ @@ -74,7 +74,7 @@ def writeFile(path: Path, text: str, name: str) -> Path: path: The destination, already checked. text: The content to write. - name: What to name in the error message, for example `--out path`. + name: What to name in the error message, for example `--output path`. Returns the path written. """ diff --git a/tests/test_cli.py b/tests/test_cli.py index 09a8969..b32fa4c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -472,10 +472,10 @@ def testGraphRefusesAnUnwritableOutPath(inRepo: Path, capsys: pytest.CaptureFixt main(["new", "CORE", "Skirmish Setup"]) capsys.readouterr() - assert main(["graph", "--out", ""]) == EXIT_USAGE + assert main(["graph", "--output", ""]) == EXIT_USAGE assert "cannot be empty" in capsys.readouterr().err - assert main(["graph", "--out", str(inRepo)]) == EXIT_USAGE + assert main(["graph", "--output", str(inRepo)]) == EXIT_USAGE assert "is a directory" in capsys.readouterr().err @@ -489,7 +489,7 @@ def testGraphWritesToANestedOutPath(inRepo: Path, capsys: pytest.CaptureFixture[ target: Path = inRepo / "build" / "graphs" / "docket.mmd" - assert main(["graph", "--out", str(target)]) == EXIT_OK + assert main(["graph", "--output", str(target)]) == EXIT_OK assert "graph TD" in target.read_text(encoding="utf-8") @@ -820,6 +820,35 @@ def testDocsHandoffRendersOutsideARepository(tmp_path: Path, capsys: pytest.Capt assert "No keys were available" in capsys.readouterr().out +def testDocsHandoffWritesToAnOutputPath(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: + """ + The brief is carried somewhere else, so writing it straight to a file saves the redirect a person would otherwise have to type. + """ + + target: Path = inRepo / "handoff" / "brief.md" + + assert main(["docs", "handoff", "--output", str(target)]) == EXIT_OK + assert "Wrote" in capsys.readouterr().out + + # The file holds the document itself, rendered for this repository rather than the fallback. + written: str = target.read_text(encoding="utf-8") + + assert written.startswith("# Writing Tickets for Docket, Offsite") + assert "`CORE-1`" in written + + +def testDocsHandoffRefusesAnUnwritableOutputPath(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: + """ + A destination is checked the same way the graph destination is, since both leave through one emitter. + """ + + assert main(["docs", "handoff", "-o", ""]) == EXIT_USAGE + assert "cannot be empty" in capsys.readouterr().err + + assert main(["docs", "handoff", "-o", str(inRepo)]) == EXIT_USAGE + assert "is a directory" in capsys.readouterr().err + + def testDocsRefusesAnUnknownSubcommand(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: """ Naming the group alone is a usage error that says what the group accepts, matching how the key group answers the same mistake. @@ -869,7 +898,7 @@ def testGraphWritesToAFile(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> target: Path = inRepo / "out" / "graph.mmd" - assert main(["graph", "--out", str(target)]) == EXIT_OK + assert main(["graph", "--output", str(target)]) == EXIT_OK assert target.read_text(encoding="utf-8").startswith("graph TD\n") diff --git a/tests/test_inputs.py b/tests/test_inputs.py index fc220e6..6446cd6 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -49,7 +49,7 @@ def testWritableFileAcceptsANewPath(tmp_path: Path) -> None: target: Path = tmp_path / "nested" / "graph.mmd" - assert requireWritableFile(str(target), "--out path") == target + assert requireWritableFile(str(target), "--output path") == target def testWritableFileRefusesAnEmptyPath() -> None: @@ -58,7 +58,7 @@ def testWritableFileRefusesAnEmptyPath() -> None: """ with pytest.raises(EmptyValueError): - requireWritableFile("", "--out path") + requireWritableFile("", "--output path") def testWritableFileRefusesADirectory(tmp_path: Path) -> None: @@ -67,7 +67,7 @@ def testWritableFileRefusesADirectory(tmp_path: Path) -> None: """ with pytest.raises(OutputPathError) as raised: - requireWritableFile(str(tmp_path), "--out path") + requireWritableFile(str(tmp_path), "--output path") assert "is a directory" in str(raised.value) @@ -79,7 +79,7 @@ def testWriteFileWritesTheTree(tmp_path: Path) -> None: target: Path = tmp_path / "nested" / "deeper" / "graph.mmd" - assert writeFile(target, "graph TD\n", "--out path") == target + assert writeFile(target, "graph TD\n", "--output path") == target assert target.read_text(encoding="utf-8") == "graph TD\n" @@ -93,4 +93,4 @@ def testWriteFileTranslatesARefusal(tmp_path: Path) -> None: blocker.write_text("not a directory", encoding="utf-8") with pytest.raises(OutputPathError): - writeFile(blocker / "graph.mmd", "graph TD\n", "--out path") + writeFile(blocker / "graph.mmd", "graph TD\n", "--output path") From 8d4c31e5983254ed4f2165a138ef56daa8d89c2f Mon Sep 17 00:00:00 2001 From: Brody Childs Date: Fri, 18 Sep 2026 12:28:53 -0400 Subject: [PATCH 5/5] Marked FEAT-18 as done --- .../{todo => done}/FEAT-18_offsiteTicketAuthoringBrief.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/tickets/{todo => done}/FEAT-18_offsiteTicketAuthoringBrief.md (99%) diff --git a/docs/tickets/todo/FEAT-18_offsiteTicketAuthoringBrief.md b/docs/tickets/done/FEAT-18_offsiteTicketAuthoringBrief.md similarity index 99% rename from docs/tickets/todo/FEAT-18_offsiteTicketAuthoringBrief.md rename to docs/tickets/done/FEAT-18_offsiteTicketAuthoringBrief.md index 446f70d..d69a4f2 100644 --- a/docs/tickets/todo/FEAT-18_offsiteTicketAuthoringBrief.md +++ b/docs/tickets/done/FEAT-18_offsiteTicketAuthoringBrief.md @@ -1,7 +1,7 @@ --- id: FEAT-18 title: Offsite Ticket Authoring Brief -status: todo +status: done priority: 1 requires: [] metadata: {}