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
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,11 @@ camelCase is the repo-wide Python convention above. Two external interfaces do N
TOML config keys stay camelCase, matching the repo style.

Keep the snake_case-to-camelCase mapping explicit in `server.py`. Neither convention leaks into the other.

## Commits

- Plain messages only. Do NOT add a `Co-Authored-By` or `Generated with` trailer unless explicitly asked.
- 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.
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ A ticket id is the command:
```bash
docket CORE-14 # show it, dependency context and all
docket CORE-14 status # bare word, for a pipe
docket CORE-14 ready # true or false. Every dependency done?
docket CORE-14 done # todo, wip, or done. The file follows
docket CORE-14 set [-t TEXT] [-p N] [-r A,B|none] [-ra A,B] [-rr A,B]
docket CORE-14 meta [KEY [VALUE]] [-c]
Expand All @@ -96,24 +97,27 @@ Everything else works on the set:

```bash
docket new CORE "Skirmish setup" [-r CORE-9,GEN-3] [-p 1] [-b TEXT]
docket list [-s todo] [-k CORE] [-m 2]
docket list [-s todo] [-k CORE] [-m 2] [-r]
docket graph [-i CORE-14 | -k GEN] [-o FILE]
docket key list | add KEY "desc" [-r TEXT] | remove KEY
docket validate | deploy PATH | upgrade PATH
```

`-r` replaces the dependency list. `-ra` and `-rr` edit the one already there. Both in one call is refused.

A ticket is ready when every id in its `requires` names a ticket that is `done`. A missing dependency blocks, and a `done` ticket is never ready, so `docket list -r` is the set you can pick up right now.

Every short flag has a long form (`-k/--key`, `-p/--priority`, `-m/--priority-max`, and so on). `--help` lists your actual keys and priority range.

## MCP

`docket-mcp` is a stdio server. Ten tools, each returning JSON as text.
`docket-mcp` is a stdio server. Eleven tools, each returning JSON as text.

| Tool | Purpose |
|---|---|
| `list_tickets(status?, key?, priority_max?)` | Summaries only, never bodies. |
| `read_ticket(id)` | Full body plus both dependency directions. |
| `check_ready(id)` | Whether every dependency is done, and what is blocking. |
| `create_ticket(key, title, body?, requires?, priority?)` | Allocates the id, writes the file. |
| `update_ticket(id, title?, priority?, requires?, requires_add?, requires_remove?)` | Those three fields only. |
| `set_status(id, status)` | Writes frontmatter and moves the file together. |
Expand Down
3 changes: 3 additions & 0 deletions docs/tickets/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ Filenames are frozen at creation. Retitling a ticket deliberately does not renam
|---|---|
| See what exists | `list_tickets` |
| Read one ticket in full | `read_ticket` |
| Check a ticket can be worked on | `check_ready` |
| Create a ticket | `create_ticket` |
| See the dependency graph | `graph` |
| See valid keys | `list_keys` |
Expand All @@ -71,6 +72,8 @@ A ticket declares what it `requires`. It never declares what it blocks.

The reverse direction is derived, not stored. `read_ticket` returns both, so to find out what a ticket is blocking, read it and look at `requiredBy`. Do not add a "blocks" field. Storing both directions guarantees they eventually disagree, which is exactly what this design exists to prevent.

Whether a ticket is ready to be worked on is derived the same way. Call `check_ready` rather than listing statuses and deciding for yourself, so that every caller gets the same answer from the same rule. Ready means every id in `requires` names a ticket that is `done`, a missing dependency blocks, and a ticket that is already `done` is never ready.

To change one edge, use `update_ticket` with `requires_add` or `requires_remove` rather than reading the list and passing it back with one entry different. Those edit the list in place, so nothing you did not name is at risk. Reserve `requires` for when you genuinely mean to replace the whole list, and never pass it in the same call as an edit, which is refused.

## Keys are closed
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.0.1"
__version__ = "1.1.0"
30 changes: 28 additions & 2 deletions src/docket/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,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.graph import ResolvedGraph, dependencyContext, resolveGraph, subgraphForId, subgraphForKey
from docket.core.graph import Readiness, ResolvedGraph, dependencyContext, readyTickets, resolveGraph, subgraphForId, subgraphForKey, ticketReadiness
from docket.core.inputs import requireWritableFile, writeFile
from docket.core.mermaid import renderGraph
from docket.core.store import Store, TicketResult, TicketSet
Expand Down Expand Up @@ -49,6 +49,7 @@ def commandTicket(args: argparse.Namespace, store: Store, output: Output) -> int
None: commandShow,
"show": commandShow,
"status": commandStatusRead,
"ready": commandReady,
"set": commandSet,
"meta": commandMeta,
}
Expand Down Expand Up @@ -133,7 +134,12 @@ def commandList(args: argparse.Namespace, store: Store, output: Output) -> int:
if key is not None:
store.config.requireKnownKey(key)

tickets: list[Ticket] = store.loadAll().filtered(status=status, key=key, priorityMax=priorityMax)
loaded: TicketSet = store.loadAll()
tickets: list[Ticket] = loaded.filtered(status=status, key=key, priorityMax=priorityMax)

# Readiness is judged against the whole set rather than the narrowed one, since a dependency may well have been filtered out of the listing.
if args.ready:
tickets = readyTickets(loaded, tickets)

if not tickets:
output.print("[dim]No tickets matched.[/dim]")
Expand Down Expand Up @@ -225,6 +231,26 @@ def commandStatusRead(args: argparse.Namespace, store: Store, output: Output) ->
return EXIT_OK


def commandReady(args: argparse.Namespace, store: Store, output: Output) -> int:
"""
Print whether a ticket's dependencies are all done, and nothing else.

This goes out raw for the same reason `status` does. What is blocking is deliberately left to `show`, which already tables both dependency directions with their statuses.

args: The parsed arguments.
store: The store to read from.
output: Where to write.

Returns the process exit code, which reports whether the question could be answered rather than what the answer was.
"""

readiness: Readiness = ticketReadiness(store.loadAll(), args.id)

output.raw(f"{'true' if readiness.isReady else 'false'}\n")

return EXIT_OK


def commandMeta(args: argparse.Namespace, store: Store, output: Output) -> int:
"""
Inspect and manage a ticket's metadata map.
Expand Down
2 changes: 2 additions & 0 deletions src/docket/cli/grammar.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ def buildParser(config: Optional[Config] = None) -> argparse.ArgumentParser:
listParser.add_argument("-s", "--status", choices=STATUSES, help="Keep only tickets with this status.")
listParser.add_argument("-k", "--key", help=f"Keep only tickets carrying this key. {keyOptions}")
listParser.add_argument("-m", "--priority-max", type=int, dest="priorityMax", help=f"Keep only tickets at or below this priority number. {priorityOptions}")
listParser.add_argument("-r", "--ready", action="store_true", help="Keep only tickets whose dependencies are all done. A done ticket is never ready, so this never shows one.")

graphParser: argparse.ArgumentParser = commands.add_parser("graph", help="Render the dependency graph as mermaid source.", formatter_class=RichHelpFormatter)
graphParser.add_argument("scope", nargs="?", metavar="SCOPE", help="What to scope to, read from its own shape: a ticket id, or a key. The flags below are the same two, named explicitly.")
Expand Down Expand Up @@ -309,6 +310,7 @@ def buildTicketParser(commands: argparse._SubParsersAction, priorityOptions: str

ticketCommands.add_parser("show", help="Show the ticket with its resolved dependency context. This is what a bare id does.", formatter_class=RichHelpFormatter)
ticketCommands.add_parser("status", help="Print the ticket's status and nothing else, for a pipe to read.", formatter_class=RichHelpFormatter)
ticketCommands.add_parser("ready", help="Print whether every dependency is done, as a bare true or false, for a pipe to read.", formatter_class=RichHelpFormatter)

# One parser per status is what makes 'docket CORE-14 done' work. It also puts the whole vocabulary into the error when a command is misspelled, which a single `choices` list on a value argument could not do.
for status in STATUSES:
Expand Down
99 changes: 92 additions & 7 deletions src/docket/core/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,25 @@ class Edge:
toId: str


@dataclass(frozen=True)
class Readiness:
"""
Whether one ticket can be worked on now, and what stands in the way when it cannot.

This is derived from the set on every read and never stored, for the same reason a reverse edge is. A stored answer would go stale the moment a dependency was closed.
"""

# MARK: Properties

id: str

# Whether every dependency is done and there is still work left to do.
isReady: bool

# The resolved records of everything holding this ticket back, in the same shape `dependencyContext` returns. Empty on a ticket that is itself done, since nothing is blocking it.
blockedBy: tuple[dict[str, object], ...] = ()


@dataclass
class ResolvedGraph:
"""
Expand Down Expand Up @@ -230,14 +249,47 @@ def dependencyContext(ticketSet: TicketSet, ticketId: str) -> dict[str, list[dic
# A dependency naming a missing id still has to appear, since hiding it would make a broken link invisible to the reader.
requires: list[dict[str, object]] = []
for requiredId in ticket.requires:
requires.append(_contextEntry(graph, requiredId))
requires.append(_contextEntry(ticketSet, requiredId))

# The reverse direction is the one thing here the set alone cannot answer, which is what the graph is resolved for.
node: Optional[GraphNode] = graph.nodes.get(ticketId)
requiredBy: list[dict[str, object]] = [_contextEntry(graph, dependentId) for dependentId in (node.requiredBy if node is not None else ())]
requiredBy: list[dict[str, object]] = [_contextEntry(ticketSet, dependentId) for dependentId in (node.requiredBy if node is not None else ())]

return {"requires": requires, "requiredBy": requiredBy}


def ticketReadiness(ticketSet: TicketSet, ticketId: str) -> Readiness:
"""
Decide whether one ticket can be worked on now.

Ready means every id in `requires` names a ticket that is done. Only the direct dependencies are consulted, because a done ticket is taken at its word. A done dependency with unfinished dependencies of its own is an inconsistency, and reporting that is `validate`'s job rather than this one's.

A dependency naming a ticket that does not exist blocks, since a link the reader cannot follow is not the same as clear road. A cycle blocks without a special case, because no ticket in one is ever done.

ticketSet: The loaded tickets.
ticketId: The ticket to judge.

Returns the readiness of that ticket.
"""

return _readinessOf(ticketSet, ticketSet.get(ticketId))


def readyTickets(ticketSet: TicketSet, tickets: Iterable[Ticket]) -> list[Ticket]:
"""
Select the tickets that can be worked on now, keeping the order they arrived in.

The whole set is needed to judge any one ticket, so the candidates are passed separately from the set they are judged against. That is what lets a caller filter an already narrowed listing.

ticketSet: The loaded tickets, which every dependency is looked up in.
tickets: The candidates to filter.

Returns the ready candidates.
"""

return [ticket for ticket in tickets if _readinessOf(ticketSet, ticket).isReady]


def findCycles(graph: ResolvedGraph) -> list[list[str]]:
"""
Find every dependency cycle, reporting one representative per strongly connected component.
Expand Down Expand Up @@ -312,21 +364,54 @@ def findCycles(graph: ResolvedGraph) -> list[list[str]]:
return sorted(cycles)


def _contextEntry(graph: ResolvedGraph, ticketId: str) -> dict[str, object]:
def _readinessOf(ticketSet: TicketSet, ticket: Ticket) -> Readiness:
"""
Judge one already-loaded ticket, which is what both public entry points do their work through.

ticketSet: The loaded tickets, which every dependency is looked up in.
ticket: The ticket to judge.

Returns the readiness of that ticket.
"""

# A finished ticket has no work left to be ready for, so it is not ready and nothing is holding it back.
if ticket.isDone:
return Readiness(id=ticket.id, isReady=False)

blockers: list[dict[str, object]] = [_contextEntry(ticketSet, requiredId) for requiredId in ticket.requires if not _isSatisfied(ticketSet, requiredId)]

return Readiness(id=ticket.id, isReady=not blockers, blockedBy=tuple(blockers))


def _isSatisfied(ticketSet: TicketSet, requiredId: str) -> bool:
"""
Report whether one dependency is met.

requiredId: The id the depending ticket names.

Returns `True` only when the id names a ticket that exists and is done.
"""

required: Optional[Ticket] = ticketSet.tickets.get(requiredId)

return required is not None and required.isDone


def _contextEntry(ticketSet: TicketSet, ticketId: str) -> dict[str, object]:
"""
Build one resolved dependency record.

graph: The resolved graph to look the ticket up in.
ticketSet: The loaded tickets to look the ticket up in.
ticketId: The id to describe.

Returns the record, flagged when the id names nothing that exists.
"""

node: Optional[GraphNode] = graph.nodes.get(ticketId)
if node is None:
ticket: Optional[Ticket] = ticketSet.tickets.get(ticketId)
if ticket is None:
return {"id": ticketId, "title": None, "status": None, "priority": None, "exists": False}

return {"id": node.id, "title": node.title, "status": node.status, "priority": node.priority, "exists": True}
return {"id": ticket.id, "title": ticket.title, "status": ticket.status, "priority": ticket.priority, "exists": True}


def _reachable(graph: ResolvedGraph, startId: str, forward: bool) -> set[str]:
Expand Down
23 changes: 22 additions & 1 deletion src/docket/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@

from docket import __version__
from docket.core.config import Config, discoverConfig
from docket.core.graph import ResolvedGraph, dependencyContext, resolveGraph, subgraphForId, subgraphForKey
from docket.core.graph import Readiness, ResolvedGraph, dependencyContext, resolveGraph, subgraphForId, subgraphForKey, ticketReadiness
from docket.core.mermaid import renderGraph
from docket.core.store import Store, TicketResult, TicketSet
from docket.core.ticket import Ticket
Expand All @@ -55,6 +55,8 @@

Dependencies are stored in one direction only. A ticket declares `requires`, and never declares what it blocks. `read_ticket` returns both directions, deriving the reverse side for you.

Whether a ticket can be worked on now is `check_ready`, and reading it off a status list yourself is not. The rule lives in one place so that every caller gets the same answer.

Keys are closed. Call `list_keys` before `create_ticket`. When no existing key fits, ask the user with `AskUserQuestion` whether to add one, and call `add_key` only once they agree.
"""

Expand Down Expand Up @@ -101,6 +103,7 @@ async def readTicket(id: str) -> str:

payload: dict[str, Any] = dict(ticket.summary())
payload["body"] = ticket.body
payload["ready"] = ticketReadiness(loaded, id).isReady
payload["requires"] = context["requires"]
payload["requiredBy"] = context["requiredBy"]
payload["metadata"] = ticket.metadata
Expand All @@ -109,6 +112,24 @@ async def readTicket(id: str) -> str:
return _json(payload)


@mcp.tool(name="check_ready")
async def checkReady(id: str) -> str:
"""
Report whether a ticket can be worked on now, rather than leaving you to work it out.

Ready means every id in `requires` names a ticket that is done. Only the direct dependencies are consulted, since a done ticket is taken at its word. A dependency naming a ticket that does not exist blocks, and is returned in `blocked_by` with `exists` false.

A ticket that is itself done is never ready, because there is no work left to be ready for. That case returns an empty `blocked_by`, so an empty list alongside `ready` false means finished rather than unblocked.

id: The ticket id, for example CORE-14.
"""

store: Store = _store()
readiness: Readiness = ticketReadiness(store.loadAll(), id)

return _json({"id": readiness.id, "ready": readiness.isReady, "blocked_by": list(readiness.blockedBy)})


@mcp.tool(name="create_ticket")
async def createTicket(
key: str,
Expand Down
3 changes: 3 additions & 0 deletions src/docket/templates/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ Filenames are frozen at creation. Retitling a ticket deliberately does not renam
|---|---|
| See what exists | `list_tickets` |
| Read one ticket in full | `read_ticket` |
| Check a ticket can be worked on | `check_ready` |
| Create a ticket | `create_ticket` |
| See the dependency graph | `graph` |
| See valid keys | `list_keys` |
Expand All @@ -71,6 +72,8 @@ A ticket declares what it `requires`. It never declares what it blocks.

The reverse direction is derived, not stored. `read_ticket` returns both, so to find out what a ticket is blocking, read it and look at `requiredBy`. Do not add a "blocks" field. Storing both directions guarantees they eventually disagree, which is exactly what this design exists to prevent.

Whether a ticket is ready to be worked on is derived the same way. Call `check_ready` rather than listing statuses and deciding for yourself, so that every caller gets the same answer from the same rule. Ready means every id in `requires` names a ticket that is `done`, a missing dependency blocks, and a ticket that is already `done` is never ready.

To change one edge, use `update_ticket` with `requires_add` or `requires_remove` rather than reading the list and passing it back with one entry different. Those edit the list in place, so nothing you did not name is at risk. Reserve `requires` for when you genuinely mean to replace the whole list, and never pass it in the same call as an edit, which is refused.

## Keys are closed
Expand Down
Loading