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
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ 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.

## Frontmatter accessors

Every ticket frontmatter field has a CLI accessor that prints its bare value for a pipe (`docket <ID> title`, `docket <ID> requires`, and so on). Any new frontmatter field gets one in the same change, never as follow-up work.

- Register the command and its help in `ACCESSORS`, and map the field to it in `FIELD_ACCESSORS`, both in `src/docket/cli/grammar.py`.
- Give it a reader in `FIELD_READERS` in `src/docket/cli/commands.py`. A list of ids prints one per line, and structured values print as JSON through `Output.json`.
- Add it to the accessor block in `README.md`.

`testEveryFrontmatterFieldHasAnAccessor` fails when `CANONICAL_FIELDS` gains a field that `FIELD_ACCESSORS` does not name.

## Commits

- Plain messages only. Do NOT add a `Co-Authored-By` or `Generated with` trailer unless explicitly asked.
Expand Down
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Docket runs on Docket.
* [Code Style](#code-style)
* [API Docs](#api-docs)
* [Naming Across Interfaces](#naming-across-interfaces)
* [Frontmatter Fields](#frontmatter-fields)
* [Versioning](#versioning)
* [Tests](#tests)
* [Opening a Pull Request](#opening-a-pull-request)
Expand Down Expand Up @@ -103,6 +104,14 @@ Two external interfaces deliberately break camelCase, and neither convention lea
snake_case is the MCP ecosystem convention and it is what the model reads, so a camelCase tool name or parameter is a bug.
The mapping lives explicitly in `server.py`.

## Frontmatter Fields

Every ticket frontmatter field is readable from the CLI as a bare value, for a pipe (`docket CORE-14 title`, `docket CORE-14 requires`, and so on).
A new field is not finished until it has one.

Add it to `ACCESSORS` and `FIELD_ACCESSORS` in `src/docket/cli/grammar.py`, and give it a reader in `FIELD_READERS` in `src/docket/cli/commands.py`.
`testEveryFrontmatterFieldHasAnAccessor` fails until all three agree with `CANONICAL_FIELDS`, and the README's accessor block lists the new command.

## Versioning

The version lives in exactly one place, `__version__` in `src/docket/__init__.py`.
Expand Down
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,27 @@ 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]
```

Every frontmatter field has an accessor that prints the value and nothing else, for a pipe:

```bash
docket CORE-14 title # the title
docket CORE-14 status # todo, wip, or done
docket CORE-14 priority # the number
docket CORE-14 requires # one id per line, nothing when empty
docket CORE-14 required-by # the reverse direction, same shape
docket CORE-14 key # CORE
docket CORE-14 ready # true or false. Every dependency done?
docket CORE-14 meta # the whole map as JSON
docket CORE-14 meta KEY # one value, bare, or JSON when it has structure
```

JSON is highlighted in a terminal and plain when piped, so `docket CORE-14 meta | jq` works as written.

Everything else works on the set:

```bash
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
id: FEAT-20
title: CLI Accessors for All Headmatter
status: todo
status: done
priority: 0
requires: []
metadata: {}
Expand Down
6 changes: 0 additions & 6 deletions roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,11 @@ graph TD
FEAT_8["FEAT-8<br/>Host Flag<br/>p4 todo"]
FEAT_12["FEAT-12<br/>Per Key Ticket Board<br/>View<br/>p2 todo"]
FEAT_16["FEAT-16<br/>Ticket Show Uses<br/>Optional Formatting<br/>p2 todo"]
FEAT_19["FEAT-19<br/>Add Google Style<br/>Documentation Comments<br/>p1 todo"]
FEAT_20["FEAT-20<br/>CLI Accessors for All<br/>Headmatter<br/>p0 todo"]
end
FEAT_6 --> FEAT_12
classDef todoP0 fill:#495057,color:#fff,stroke:#ff6b6b,stroke-width:4px
classDef todoP1 fill:#495057,color:#fff,stroke:#ff922b,stroke-width:3px
classDef todoP2 fill:#495057,color:#fff,stroke:#ffd43b,stroke-width:2px
classDef todoP3 fill:#495057,color:#fff,stroke:#adb5bd,stroke-width:2px
classDef todoP4 fill:#495057,color:#fff,stroke:#6c757d,stroke-width:1px
class FEAT_20 todoP0
class FEAT_19 todoP1
class FEAT_12,FEAT_16 todoP2
class FEAT_6 todoP3
class BUG_6,FEAT_8 todoP4
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.6.0"
__version__ = "1.7.0"
10 changes: 8 additions & 2 deletions src/docket/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
from typing import Optional

from docket.cli.commands import (
FIELD_READERS,
commandDeploy,
commandDocs,
commandField,
commandGraph,
commandKey,
commandList,
Expand All @@ -24,18 +26,19 @@
commandSet,
commandShow,
commandStatus,
commandStatusRead,
commandTicket,
commandValidate,
documentPath,
emitDocument,
requireScopeKey,
)
from docket.cli.grammar import (
ACCESSORS,
CLEAR_SENTINEL,
EXIT_INVALID,
EXIT_OK,
EXIT_USAGE,
FIELD_ACCESSORS,
OUTPUT_ARGUMENT,
PROGRAM_NAME,
TICKET_COMMAND,
Expand Down Expand Up @@ -64,10 +67,13 @@

# `docket.cli` was one module before it was a package, and it is what `pyproject.toml` names as the console script. Everything the outside world reached for then is still reachable by the same path.
__all__: list[str] = [
"ACCESSORS",
"CLEAR_SENTINEL",
"EXIT_INVALID",
"EXIT_OK",
"EXIT_USAGE",
"FIELD_ACCESSORS",
"FIELD_READERS",
"OUTPUT_ARGUMENT",
"PROGRAM_NAME",
"STATUS_STYLES",
Expand All @@ -83,6 +89,7 @@
"classifyToken",
"commandDeploy",
"commandDocs",
"commandField",
"commandGraph",
"commandKey",
"commandList",
Expand All @@ -92,7 +99,6 @@
"commandSet",
"commandShow",
"commandStatus",
"commandStatusRead",
"commandTicket",
"commandValidate",
"describeKeys",
Expand Down
82 changes: 33 additions & 49 deletions src/docket/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,37 @@

import argparse
from pathlib import Path
from typing import Optional
from typing import Callable, Optional

from rich.table import Table
from rich.text import Text

from docket.cli.grammar import EXIT_INVALID, EXIT_OK, EXIT_USAGE, OUTPUT_ARGUMENT, parseEditIdList, parseIdList, resolveGraphScope, resolveListFilters
from docket.cli.grammar import ACCESSORS, 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, discoverConfig
from docket.core.deploy import DeployReport, deploy, upgrade
from docket.core.handoff import HANDOFF_FILENAME, renderHandoff
from docket.core.graph import Readiness, ResolvedGraph, dependencyContext, readyTickets, resolveGraph, scopeGraph, ticketReadiness
from docket.core.graph import ResolvedGraph, dependencyContext, readyTickets, resolveGraph, scopeGraph, ticketReadiness
from docket.core.inputs import requireWritableFile, writeFile
from docket.core.mermaid import renderGraph
from docket.core.roadmap import ROADMAP_FILENAME, Roadmap, buildRoadmap
from docket.core.store import Store, TicketResult, TicketSet
from docket.core.ticket import STATUSES, Ticket
from docket.core.validate import SEVERITY_ERROR, ValidationReport, validate

# MARK: Constants

# How each accessor in the grammar reads its answer off a ticket. The store is passed alongside the ticket because a derived answer, such as the reverse dependencies or readiness, cannot be read from one ticket alone. A list comes back for a list of ids, which `commandField` prints one per line.
FIELD_READERS: dict[str, Callable[[Store, Ticket], object]] = {
"title": lambda store, ticket: ticket.title,
"status": lambda store, ticket: ticket.status,
"priority": lambda store, ticket: ticket.priority,
"requires": lambda store, ticket: list(ticket.requires),
"required-by": lambda store, ticket: [entry["id"] for entry in dependencyContext(store.loadAll(), ticket.id)["requiredBy"]],
"key": lambda store, ticket: ticket.key,
"ready": lambda store, ticket: ticketReadiness(store.loadAll(), ticket.id).isReady,
}

# MARK: Functions


Expand All @@ -50,11 +63,13 @@ def commandTicket(args: argparse.Namespace, store: Store, output: Output) -> int
if args.ticketCommand in STATUSES:
return commandStatus(args, store, output)

# Every read shares one handler, since what differs between them is only which value is read.
if args.ticketCommand in ACCESSORS:
return commandField(args, store, output)

handlers = {
None: commandShow,
"show": commandShow,
"status": commandStatusRead,
"ready": commandReady,
"set": commandSet,
"meta": commandMeta,
}
Expand Down Expand Up @@ -226,33 +241,11 @@ def commandStatus(args: argparse.Namespace, store: Store, output: Output) -> int
return EXIT_OK


def commandStatusRead(args: argparse.Namespace, store: Store, output: Output) -> int:
"""
Print a ticket's status and nothing else.

This goes out raw, with no styling and no surrounding words, so a shell can read the answer as easily as a person can.

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

Returns:
The process exit code.
"""

ticket: Ticket = store.load(args.id)

output.raw(f"{ticket.status}\n")

return EXIT_OK


def commandReady(args: argparse.Namespace, store: Store, output: Output) -> int:
def commandField(args: argparse.Namespace, store: Store, output: Output) -> int:
"""
Print whether a ticket's dependencies are all done, and nothing else.
Print one thing about a ticket 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.
This goes out raw, with no styling and no surrounding words, so a shell can read the answer as easily as a person can. A list of ids is written one per line and anything else through `Output.value`. What is blocking a ticket that is not ready is deliberately left to `show`, which already tables both dependency directions with their statuses.

Args:
args: The parsed arguments.
Expand All @@ -263,9 +256,12 @@ def commandReady(args: argparse.Namespace, store: Store, output: Output) -> int:
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)
value: object = FIELD_READERS[args.ticketCommand](store, store.load(args.id))

output.raw(f"{'true' if readiness.isReady else 'false'}\n")
if isinstance(value, list):
output.lines(value)
else:
output.value(value)

return EXIT_OK

Expand All @@ -291,36 +287,24 @@ def commandMeta(args: argparse.Namespace, store: Store, output: Output) -> int:
output.error("Nothing to clear. Name the metadata key to remove.")
return EXIT_USAGE

ticket: Ticket = store.load(args.id)

if not ticket.metadata:
output.print("[dim]No metadata.[/dim]")
return EXIT_OK

table: Table = Table(box=None, pad_edge=False)
table.add_column("KEY", style="bold")
table.add_column("VALUE")

for key, value in ticket.metadata.items():
table.add_row(key, str(value))

output.print(table)
# The whole map goes out as JSON, including an empty one, so a pipe into `jq` never meets a sentence where the object should be.
output.json(store.load(args.id).metadata)

return EXIT_OK

if args.clear and args.value is not None:
output.error("Cannot pass a value together with -c/--clear.")
return EXIT_USAGE

# A key with no value reads that entry, raw, for the same reason `status` does.
# A key with no value reads that entry, raw, for the same reason `status` does. A structured value goes out as JSON rather than in Python's own spelling.
if not args.clear and args.value is None:
ticket = store.load(args.id)
ticket: Ticket = store.load(args.id)

if args.key not in ticket.metadata:
output.error(f"'{args.key}' is not set on {ticket.id}.")
return EXIT_USAGE

output.raw(f"{ticket.metadata[args.key]}\n")
output.value(ticket.metadata[args.key])

return EXIT_OK

Expand Down
29 changes: 26 additions & 3 deletions src/docket/cli/grammar.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,27 @@
# 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"

# The commands that print one thing about a ticket and nothing else, for a pipe to read, each with its help text. The name is the subcommand that reaches it and the key `commands` looks its reader up by, so this one table is the whole vocabulary of reads.
ACCESSORS: dict[str, str] = {
"title": "Print the ticket's title.",
"status": "Print the ticket's status.",
"priority": "Print the ticket's priority.",
"requires": "Print the ids this ticket depends on, one per line. Prints nothing when there are none.",
"required-by": "Print the ids of the tickets depending on this one, one per line. Prints nothing when there are none.",
"key": "Print the key portion of the ticket's id and nothing else.",
"ready": "Print whether every dependency is done, as a bare true or false.",
}

# The command that reads each frontmatter field. `id` has none, since it is what was typed to reach the ticket, and `metadata` is read through `meta` because that command also writes it. A field missing from here fails the suite, which is what keeps a new field from arriving without a way to read it.
FIELD_ACCESSORS: dict[str, Optional[str]] = {
"id": None,
"title": "title",
"status": "status",
"priority": "priority",
"requires": "requires",
"metadata": "meta",
}

# 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"

Expand Down Expand Up @@ -389,8 +410,10 @@ def buildTicketParser(commands: argparse._SubParsersAction, priorityOptions: str
ticketCommands = ticketParser.add_subparsers(dest="ticketCommand", metavar="COMMAND")

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)

# Every read is registered from the one table, so a new one needs no parser code of its own.
for accessor, accessorHelp in ACCESSORS.items():
ticketCommands.add_parser(accessor, help=accessorHelp, 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 All @@ -405,7 +428,7 @@ def buildTicketParser(commands: argparse._SubParsersAction, priorityOptions: str

# How much of the call was typed is what it means, the same way a status reads with no value and writes with one.
metaParser: argparse.ArgumentParser = ticketCommands.add_parser("meta", help="Inspect and manage the ticket's metadata map.", formatter_class=RichHelpFormatter)
metaParser.add_argument("key", nargs="?", metavar="KEY", help="The metadata key. Namespace it, for example 'video', so it cannot collide with another tool's key. Omit it to show the whole map.")
metaParser.add_argument("key", nargs="?", metavar="KEY", help="The metadata key. Namespace it, for example 'video', so it cannot collide with another tool's key. Omit it to print the whole map as JSON.")
metaParser.add_argument("value", nargs="?", metavar="VALUE", help="The value to store. Omit it to print the key's value and nothing else.")
metaParser.add_argument("-c", "--clear", action="store_true", help="Remove the key instead of setting it.")

Expand Down
Loading
Loading