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
2 changes: 2 additions & 0 deletions .docket/ledger.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,5 @@
{"schema":2,"kind":"decision","id":"d128","text":"Ledger records accept field-level corrections that keep the record's id.","state":"adopted","ts":"2026-09-23T08:17:31+00:00","author":"claude-code","session":"","branch":"main","scope":["docket/**","docs/ledger.md"],"rationale":"Supersession changes the id, so records that support, depend on, or cite the old id point at a retired record. d88 restating d38 left d39 on stale support. Three one-off scripts already rewrote history in place with no trail.","supports":[],"depends_on":[],"answers":[],"supersedes":["d89"],"evidence":[],"revisit":"","cost_if_wrong":"A new line kind changes the ledger format, the projection every reader uses, and the revision hash.","pinned":false,"choice":"A correction is its own ledger line, addressed as <id>.<n>, that changes wording and metadata of an earlier record: text, rationale, scope, cost, evidence, revisit. Relations, state, kind, and provenance stay fixed; changing those still goes through supersession.","alternatives":["Supersede a mis-recorded record with a restatement.","Add a restatement flag to supersession that moves inbound links to the new record."],"decided_by":""}
{"schema":2,"kind":"decision","id":"d129","text":"Corrections are ledger lines that project() folds into the corrected record.","state":"adopted","ts":"2026-09-23T08:20:41+00:00","author":"claude-code","session":"","branch":"main","scope":["docket/ledger.py","docket/rebase.py","docket/context_delta.py","docket/cli/admin.py"],"rationale":"project() already runs after any history slice, so show --at and --since see only earlier corrections without their own change. A side file doubles merge, rebase, and hashing; folding in read() hides the original lines from rebase and check.","supports":[["d128"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Every raw-line reader must accept a fourth line kind, and older docket releases refuse a ledger holding one.","pinned":false,"choice":"A correction line (kind correction, id <target>.<n>) names one earlier record and a fields object of replacement values. project() applies corrections in file order, drops the lines, and adds corrections and original to the record. choice stays fixed; alternatives, decided_by, and pinned join the correctable fields.","alternatives":["A side file, .docket/corrections.jsonl, like the feature store.","Fold corrections inside read(), so raw consumers never see a correction line."],"decided_by":""}
{"schema":2,"kind":"decision","id":"d130","text":"A correction appended with its id already set skips the write-time refusals.","state":"adopted","ts":"2026-09-23T09:15:42+00:00","author":"claude-code","session":"","branch":"feat/ledger-corrections","scope":["docket/ledger.py","docket/corrections.py","docket/rebase.py"],"rationale":"A correction valid on its own branch can be refused against the other branch's corrected state, which leaves a rebase half-applied.","supports":[["d129"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"A hand-written numbered correction line bypasses the refusals; validation still bounds its fields.","pinned":false,"choice":"append runs the no-op drop and the echo and question-text refusals only for a correction the CLI submits unnumbered. A pre-numbered correction, as rebase appends, passes on validation alone, the same as a pre-numbered record.","alternatives":["Run the refusals on every appended correction, including rebased ones."],"decided_by":""}
{"schema":2,"kind":"decision","id":"d131","text":"Ledger filtering uses one query language parsed in Python, and the viewer calls the CLI to apply it.","state":"adopted","ts":"2026-09-23T10:15:32+00:00","author":"claude-code","session":"","branch":"main","scope":["graph/**","docket/cli/graph.py","docket/cli/query.py","docket/where.py"],"rationale":"One parser keeps the CLI and viewer in agreement and keeps filtering in Python, where the viewer decision put it. A submitted filter costs one Python start, about 150 ms.","supports":[["d36"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Live filtering on field terms would need a Go parser later, and the viewer's layout change moves every key hint users know.","pinned":false,"choice":"docket/where.py parses field:value terms (kind, state, scope, is, author, branch, after, before) plus bare text. list and graph take --where. The viewer sends a submitted query to a hidden docket _filter-ids command and keeps the returned ids; bare words still filter live in Go. The viewer gains a three-line bottom pane for filter, status, and key hints, with a ? help overlay.","alternatives":["Parse the query in both Python and Go, kept in step by a shared fixture table.","Python pre-computes matchable fields and Go runs a reduced matcher.","Toggle keys or a filter form in the viewer instead of a query prompt."],"decided_by":""}
{"schema":2,"kind":"decision","id":"d132","text":"The graph viewer receives its filter callback in the DOCKET_GRAPH_FILTER_CMD environment variable.","state":"adopted","ts":"2026-09-23T14:02:55+00:00","author":"claude-code","session":"","branch":"feat/ledger-filtering","scope":["graph/main.go","docket/cli/graph.py"],"rationale":"The viewer binary ships separately from the CLI. An older binary exits 2 on an unknown flag and ignores an unknown variable, so the variable keeps docket graph working across an update skew.","supports":[["d131"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"The callback is invisible in the viewer's --help; anyone running the binary by hand must know the variable.","pinned":false,"choice":"docket graph sets DOCKET_GRAPH_FILTER_CMD to a JSON argv ([python, bin/docket, _filter-ids, --]) for the viewer process; the viewer appends the query as one argument.","alternatives":["Pass the argv as a --filter-cmd flag, as the spec first named it."],"decided_by":""}
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `docket list --where` and `docket graph --where` take a query language: plain words, `kind:`, `state:`, `scope:PATH`, `is:pinned|corrected|retired|blocked`, `author:`, `branch:`, `after:`, and `before:`, with `-` to negate a term. `docs/commands.md` lists the rules.
- The graph viewer filters with the same language. It previews text terms as you type and asks the CLI about field terms when you press `Enter`. A bottom pane shows the filter, the match count, the sort, and the key hints, and `?` opens a help overlay.

### Changed

- The graph viewer's `/` searches the id, text, choice, and rationale, and ANDs its words. Before, it matched one substring across every field. The `LEDGER GRAPH` title and the separate search line are gone.

## [0.19.0] - 2026-09-23

### Added
Expand Down
11 changes: 9 additions & 2 deletions docket/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@
from docket.cli.correct import add_correct_parser
from docket.cli.feature_parser import add_feature_parser
from docket.cli.graph import cmd_graph
from docket.cli.query import cmd_list, cmd_show, cmd_where
from docket.cli.query import cmd_filter_ids, cmd_list, cmd_show, cmd_where
from docket.cli.record import cmd_claim, cmd_decision, cmd_question
from docket.cli.selfupdate import cmd_update, cmd_update_fetch
from docket.ledger import KINDS, STATES, LedgerError
from docket.where import WhereError


class VersionAction(argparse.Action):
Expand Down Expand Up @@ -87,6 +88,7 @@ def main(argv: list[str] | None = None) -> int:
"--state", choices=tuple(sorted({state for values in STATES.values() for state in values}))
)
ls.add_argument("--find", help="match question or answer text")
ls.add_argument("--where", metavar="QUERY", help="filter with the query language")
ls.add_argument(
"--superseded",
action="store_true",
Expand All @@ -105,6 +107,7 @@ def main(argv: list[str] | None = None) -> int:
"--state", choices=tuple(sorted({state for values in STATES.values() for state in values}))
)
gr.add_argument("--find", help="match question or answer text")
gr.add_argument("--where", metavar="QUERY", help="filter with the query language")
gr.add_argument(
"--format",
choices=("mermaid", "dot", "csv"),
Expand Down Expand Up @@ -243,6 +246,10 @@ def main(argv: list[str] | None = None) -> int:

sub.add_parser("_update-fetch").set_defaults(func=cmd_update_fetch)

fi = sub.add_parser("_filter-ids")
fi.add_argument("query", nargs=argparse.REMAINDER)
fi.set_defaults(func=cmd_filter_ids)

args = p.parse_args(argv)
if args.cmd is None:
print(f"docket {version()}")
Expand All @@ -261,6 +268,6 @@ def main(argv: list[str] | None = None) -> int:

try:
return args.func(args)
except (LedgerError, features.FeatureError, OutcomeError, OSError) as exc:
except (LedgerError, features.FeatureError, OutcomeError, WhereError, OSError) as exc:
print(str(exc), file=sys.stderr)
return 1
1 change: 1 addition & 0 deletions docket/cli/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"--at",
"--all",
"--find",
"--where",
"--superseded",
"--oneline",
"--json",
Expand Down
74 changes: 54 additions & 20 deletions docket/cli/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from pathlib import Path
from typing import Any

from docket import ROOT, env
from docket import ROOT, env, where
from docket.cli.term import (
_DIM,
_GRAPH_GLYPHS,
Expand Down Expand Up @@ -124,22 +124,44 @@ def _graph_payload(entries: list[dict], retired: dict[str, str]) -> dict:
return graph_payload(entries)


def _run_graph_viewer(entries: list[dict], retired: dict[str, str], pretty: bool = False) -> int:
def _filter_command() -> list[str]:
"""The argv the viewer runs for a query with a field term.

The viewer appends the query as one final argument. sys.executable names
the interpreter, so this also runs on Windows, where bin/docket cannot be
executed by itself.
"""
return [sys.executable, str(ROOT / "bin" / "docket"), "_filter-ids", "--"]


def _run_graph_viewer(
entries: list[dict],
retired: dict[str, str],
pretty: bool = False,
where_text: str = "",
ids: list[str] | None = None,
) -> int:
"""Run the native viewer with an inherited terminal and private input."""
viewer = _graph_viewer_path()
temp_path: Path | None = None
try:
fd, raw_path = tempfile.mkstemp(prefix="docket-graph-", suffix=".json")
temp_path = Path(raw_path)
payload = _graph_payload(entries, retired)
if where_text:
payload["filter"] = {"query": where_text, "ids": ids or []}
with os.fdopen(fd, "w", encoding="utf-8") as data_file:
json.dump(_graph_payload(entries, retired), data_file)
json.dump(payload, data_file)
data_file.write("\n")

try:
command = [str(viewer), "--data", str(temp_path)]
if pretty:
command.append("--pretty")
result = subprocess.run(command)
# An older viewer binary exits on an unknown flag but ignores an
# unknown variable, so the callback travels in the environment.
child_env = {**os.environ, "DOCKET_GRAPH_FILTER_CMD": json.dumps(_filter_command())}
result = subprocess.run(command, env=child_env)
except KeyboardInterrupt:
return 130
except OSError as exc:
Expand Down Expand Up @@ -441,18 +463,23 @@ def _render_graph(
return 0


def _write_csv(entries: list[dict], args: argparse.Namespace) -> int:
def _render_static(
entries: list[dict], retired: dict[str, str], args: argparse.Namespace, style: str
) -> int:
if not entries:
print("docket: nothing recorded")
return 0
return _render_graph(entries, retired, args, style)


def _write_csv(entries: list[dict], args: argparse.Namespace, superseded: bool) -> int:
"""Write nodes.csv and edges.csv for Gephi into args.out."""

from pathlib import Path

from docket.graph_export import to_csv

nodes, edges = to_csv(
entries,
detail=args.detail,
superseded=bool(getattr(args, "superseded", False)),
)
nodes, edges = to_csv(entries, detail=args.detail, superseded=superseded)
if not nodes:
print("docket: no record in this selection carries a relation", file=sys.stderr)
return 0
Expand Down Expand Up @@ -499,6 +526,8 @@ def cmd_graph(args: argparse.Namespace) -> int:
# two documents and stdout cannot carry both.
print("docket: --format csv writes two files; name a directory with --out", file=sys.stderr)
return 2
where_text = getattr(args, "where", None) or ""
query = where.parse(where_text)
if interactive and not _graph_is_tty():
print("docket: --interactive requires terminal stdin and stdout", file=sys.stderr)
return 1
Expand All @@ -507,20 +536,17 @@ def cmd_graph(args: argparse.Namespace) -> int:
if not entries:
print("docket: nothing recorded")
return 0
shown = [e for e in entries if query.matches(e)]
superseded = bool(getattr(args, "superseded", False)) or query.wants_retired

if fmt == "csv":
return _write_csv(entries, args)
return _write_csv(shown, args, superseded)

if fmt:
from docket.graph_export import to_dot, to_mermaid

render = to_dot if args.format == "dot" else to_mermaid
text = render(
entries,
detail=args.detail,
direction=args.direction,
superseded=bool(getattr(args, "superseded", False)),
)
text = render(shown, detail=args.detail, direction=args.direction, superseded=superseded)
if not text:
print("docket: no record in this selection carries a relation", file=sys.stderr)
return 0
Expand All @@ -535,7 +561,15 @@ def cmd_graph(args: argparse.Namespace) -> int:
_graph_viewer_error(viewer)
if interactive:
return 1
return _render_graph(entries, retired, args, "compact")
return _run_graph_viewer(entries, retired, pretty=bool(getattr(args, "pretty", False)))
return _render_static(shown, retired, args, "compact")
# The viewer gets every record the other flags allow, so clearing its
# filter brings them back; --where travels as its initial filter.
return _run_graph_viewer(
entries,
retired,
pretty=bool(getattr(args, "pretty", False)),
where_text=where_text,
ids=[e["id"] for e in shown],
)

return _render_graph(entries, retired, args, style or "forest")
return _render_static(shown, retired, args, style or "forest")
25 changes: 22 additions & 3 deletions docket/cli/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import sys
import textwrap

from docket import corrections, env
from docket import corrections, env, where
from docket.cli.term import _DIM, _STATE_COLOR, _c, _match, _use_color
from docket.context_model import positions
from docket.env import LEDGER, justification_sets, read, retired_by
Expand All @@ -36,16 +36,18 @@ def _list_dim_tail(line: str, marker: str, use_color: bool) -> str:


def cmd_list(args: argparse.Namespace) -> int:
query = where.parse(getattr(args, "where", None) or "")
entries = project(read(env.ledger_path()), validated=True)
retired = retired_by(entries)
if not args.superseded:
if not args.superseded and not query.wants_retired:
entries = [e for e in entries if e.get("id") not in retired]
if args.state:
entries = [e for e in entries if e.get("state") == args.state]
if getattr(args, "kind", None):
entries = [e for e in entries if e.get("kind") == args.kind]
if args.find:
entries = [e for e in entries if _match(e, args.find)]
entries = [e for e in entries if query.matches(e)]
if not entries:
if getattr(args, "json", False):
print("[]")
Expand Down Expand Up @@ -205,4 +207,21 @@ def cmd_where(args: argparse.Namespace) -> int:
return 0


__all__ = ["cmd_list", "cmd_show", "cmd_where"]
def cmd_filter_ids(args: argparse.Namespace) -> int:
"""Print the id of every record the query matches, retired included.

The graph viewer runs this for a query with a field term and keeps the
rows whose ids it prints.
"""
parts = list(args.query)
# argparse keeps the "--" separator at the head of a REMAINDER list.
if parts[:1] == ["--"]:
parts = parts[1:]
query = where.parse(" ".join(parts))
for e in project(read(env.ledger_path()), validated=True):
if query.matches(e):
print(e["id"])
return 0


__all__ = ["cmd_filter_ids", "cmd_list", "cmd_show", "cmd_where"]
Loading
Loading