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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 [-o FILE]
```

`-r` replaces the dependency list. `-ra` and `-rr` edit the one already there. Both in one call is refused.
Expand All @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions docs/tickets/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
82 changes: 82 additions & 0 deletions docs/tickets/done/FEAT-18_offsiteTicketAuthoringBrief.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
id: FEAT-18
title: Offsite Ticket Authoring Brief
status: done
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 `<ID>_` 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.

## Decisions taken

- 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

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.
12 changes: 12 additions & 0 deletions docs/tickets/todo/FEAT-19_addGoogleStyleDocumentationComments.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions docs/tickets/todo/FEAT-6_templatesForTicketsPerKey.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/docket/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@

# No type check to comply with hatch's requirements.
# Do not re-add.
__version__ = "1.3.0"
__version__ = "1.4.0"
12 changes: 10 additions & 2 deletions src/docket/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from docket.cli.commands import (
commandDeploy,
commandDocs,
commandGraph,
commandKey,
commandList,
Expand All @@ -25,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,
Expand Down Expand Up @@ -62,7 +64,7 @@
"EXIT_INVALID",
"EXIT_OK",
"EXIT_USAGE",
"OUT_ARGUMENT",
"OUTPUT_ARGUMENT",
"PROGRAM_NAME",
"STATUS_STYLES",
"TICKET_COMMAND",
Expand All @@ -75,6 +77,7 @@
"buildParser",
"classifyToken",
"commandDeploy",
"commandDocs",
"commandGraph",
"commandKey",
"commandList",
Expand All @@ -89,6 +92,7 @@
"describeKeys",
"describePriorities",
"dispatch",
"emitDocument",
"main",
"parseEditIdList",
"parseIdList",
Expand Down Expand Up @@ -150,6 +154,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())

Expand Down
64 changes: 52 additions & 12 deletions src/docket/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@

import argparse
from pathlib import Path
from typing import Optional

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
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
Expand Down Expand Up @@ -342,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:
Expand Down Expand Up @@ -429,6 +421,54 @@ 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.

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

return emitDocument(renderHandoff(store), args.output, OUTPUT_ARGUMENT, output)


def commandDeploy(args: argparse.Namespace, output: Output) -> int:
"""
Install docket into a repository, or refresh what is already deployed there.
Expand Down
Loading
Loading