Files
chemenu/tools/chemenu/commands/task_cmd.py
T
torbenandClaude Opus 5.5 d66a3f65f4
CI / verify (push) Successful in 6m1s
CI / pwsh (push) Successful in 2m0s
Release / release (push) Successful in 34s
feat!: docs/ sorted into tutorials, how-to, reference, explanation plus unshipped docs/dev/; explanation pages and developer guide moved, human docs English (#187)
The seven explanation pages move to docs/explanation/ without a stub, DEVELOPMENT.md becomes docs/dev/development.md (translated to English), dist export leaves docs/dev/ out, docs verify rejects a file outside the five areas, and AGENTS.md states that the stack's human documentation is English. Required migration 9.0.0-docs-diataxis rewrites the old path in an instance's own kb/CONVENTIONS.md.

Files changed:
- .wikitool-kb.json
- AGENTS.md
- CHANGES.md
- CLAUDE.md
- DEVELOPMENT.md
- INSTALL.md
- README.md
- VERSION
- docs/dev/development.md
- docs/explanation/README.md
- docs/explanation/knowledge-and-commitment.md
- docs/explanation/language-boundaries.md
- docs/explanation/model-and-effort-selection.md
- docs/explanation/ownership-and-templates.md
- docs/explanation/pipeline-rationale.md
- docs/explanation/version-model.md
- docs/explanation/why-gates-are-code.md
- docs/how-to/README.md
- docs/knowledge-and-commitment.md
- docs/language-boundaries.md
- docs/model-and-effort-selection.md
- docs/ownership-and-templates.md
- docs/pipeline-rationale.md
- docs/reference/README.md
- docs/tutorials/README.md
- docs/version-model.md
- docs/why-gates-are-code.md
- instructions/CONTRACT.md
- instructions/dev/dev-setup.md
- instructions/dev/doc-pull-through.md
- instructions/dev/stack-close/SKILL.md
- instructions/dev/stack-mode.md
- instructions/gtd-weekly-review/SKILL.md
- instructions/ingest-queue.md
- instructions/migrations/6.0.0-type-guidance-split.md
- instructions/migrations/9.0.0-docs-diataxis.md
- instructions/upgrade-instance.md
- kb/CONVENTIONS.md
- kb/CONVENTIONS.md.template
- tools/CONTRACT.md
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/task_cmd.py
- tools/chemenu/tasks/protocol.py
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_docs_verify.py
- tools/chemenu/tests/test_git_publish.py
- tools/chemenu/tests/test_toc.py
- types/type-guidance.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
2026-10-06 20:45:09 +02:00

404 lines
17 KiB
Python

"""`wikitool task new`/`task list`/`task close` - the tracker item write and
read surface outside `new project` (Gitea #132, #138; #119 D1/D2/D4/D5/D6/D9).
`task new` is the second write path into the task tracker, alongside `new
project`'s own (`chemenu.commands.new_page._ensure_tracker_project`) - and
the last creation command that pairing needed, per
`docs/explanation/knowledge-and-commitment.md`. `task close` (#138) is the one closing
write: never a delete, only "mark done" (`TaskWriter.close_item`) - see that
protocol method's docstring and `docs/explanation/knowledge-and-commitment.md` for why
the surface stops there. `task list` (#138) is the read half a caller needs
to get an item's id before it can close it, without first running
`wikitool review`. None of the three ever touch `kb/`: an ingest that finds
both knowledge and a commitment in one source runs the tracker command for
the commitment and the normal page-creation commands (`new source`, ...) for
the knowledge, as two independent steps a skill sequences - never as one
transaction, because nothing here shares state with the page-creation path
the way `new project`'s own tracker-then-page order does within a single
command.
Neither write ever exits 42, unlike `new project`: every provider that
offers a write path at all has a real per-item call (Super Productivity's
`POST /tasks`, where `POST /projects` does not exist), so there is no
human-clearance step to wait on - a missing project or tag is an ordinary
refusal. `task close` takes the provider's own id, never a title: an item's
tracker-side identity is opaque, while a project's *name* is the one coupling
`kb/` and the tracker share. `--notes` is stored verbatim and never parsed -
the same posture a WAITING item's own title has for the person named in it.
This module owns only the CLI shape - parsing, the `--project`/`--inbox`
exclusivity (#132 D4), the `--follow-up-at` date, and `task list`/`task
close`'s rendering. The writes themselves are
`chemenu.tasks.protocol.TaskWriter.create_item`/`close_item`, dispatched
through `chemenu.tasks.build_writer` exactly like `new project` does; the
read is `TaskReader.open_items`, the same call `chemenu.review` makes.
"""
from __future__ import annotations
import datetime
from typing import Optional
import typer
from chemenu import cli_contract, config, tasks
from chemenu.commands._util import fail, success
from chemenu.errors import ValidationError
from chemenu.tasks import config as tasks_config
app = typer.Typer(
help="Create, list, and close items in the task tracker - never a kb/ page, see "
"`new project` for that pairing."
)
def _parse_follow_up_at(text: str) -> datetime.date:
try:
return datetime.date.fromisoformat(text)
except ValueError:
fail(f"--follow-up-at {text!r} must be YYYY-MM-DD.")
def _read_config():
try:
return tasks_config.read_config(config.ROOT)
except ValidationError as exc:
fail(str(exc))
@app.command("new")
@cli_contract.record(cli_contract.CommandRecord(
path="task new",
summary="Create one open item in the configured task tracker - never a kb/ page.",
synopsis=(cli_contract.Variant(
usage='task new --title "<Title>" (--project "<Name>" | --inbox) '
"[--waiting [--follow-up-at YYYY-MM-DD]] [--notes \"...\"]",
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="Yes - a single API call, made only once every precondition (the project's own "
"id, the WAITING tag's own id) is confirmed to exist, so a missing one never leaves a "
"half-written item behind",
budget=cli_contract.Budget.COUNTED,
network=cli_contract.Network.YES,
),
notes=(
"Creates one open item in the configured task tracker; never touches `kb/`.",
"Exactly one of `--project` or `--inbox` is required; an omitted `--project` refuses "
"rather than silently falling into the inbox.",
"`--project` names an existing tracker project, matched case-insensitively - never "
"created, and never searched or guessed.",
"`--inbox` files into the tracker's own inbox. An item filed there never appears in "
"`review`, since every one of its checks reaches items through a project name.",
"`--waiting` sets the WAITING status `review`'s waiting-overdue check reads. "
"`--follow-up-at` is refused without `--waiting` - it is never a due date on its own.",
"`--notes` carries a freetext backref (e.g. to the `kb/` source page the item came "
"from), stored verbatim, never parsed.",
"The tracker configuration is read from `.wikitool-tasks.json`, or from the file "
"`WIKITOOL_TASKS_CONFIG` names when that variable is set (so one checkout can be "
"run against several trackers in turn). A set variable that names no file is an "
"error, never \"no tracker configured\".",
"No `.wikitool-tasks.json` fails immediately with a \"no tracker configured\" message.",
"A provider whose configured access path has no write path (Super Productivity's "
"`access: \"snapshot\"`) refuses entirely with exit 1, naming the `access: \"api\"` "
"instance to use instead.",
"**Never exits 42.** A `--project` matching no tracker project, or `--waiting` against "
"a provider that cannot represent it right now (Super Productivity: the `waiting` tag "
"does not exist yet, and its API cannot create tags), are ordinary exit-1 refusals "
"that create nothing.",
"Not idempotent: every successful run creates another item.",
),
failures=(
cli_contract.Failure(
cause="No `.wikitool-tasks.json` - no tracker configured",
reaction="Not transient - configure a tracker first",
),
cli_contract.Failure(
cause="`WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken",
reaction="Not transient - fix the path or unset the variable",
),
cli_contract.Failure(
cause="Neither or both of `--project`/`--inbox`, or a `--follow-up-at` without "
"`--waiting` or not `YYYY-MM-DD`",
reaction="Fix the argument and retry once",
),
cli_contract.Failure(
cause="A `--project` name matching no tracker project",
reaction="Create the tracker project first, or fix the name, then retry once",
),
cli_contract.Failure(
cause="`--waiting` against a provider with no way to represent it right now (Super "
"Productivity: the `waiting` tag does not exist)",
reaction="Create the tag in the tracker first, then retry once",
),
cli_contract.Failure(
cause="A read-only access path (Super Productivity's `access: \"snapshot\"`)",
reaction="Point at the `access: \"api\"` instance the error names, then retry once",
),
),
examples=(
'tools/wikitool task new --title "Renew the TLS certificate" --project "Homelab migration"',
'tools/wikitool task new --title "Quote from the electrician" --project "Homelab migration" '
"--waiting --follow-up-at 2026-10-05",
'tools/wikitool task new --title "Read the qmd README" --inbox '
'--notes "from [[Source - qmd - GitHub Repository]]"',
),
never=(
"Never fall back to `--inbox`, or to another project, when `--project` does not match.",
),
see_also=(
"`wikitool task list` - a project's open items and their ids",
"`wikitool task close` - marks an item done",
"`wikitool new project` - a project page and its tracker project",
"`docs/explanation/knowledge-and-commitment.md` - why commitments live in the tracker, not in `kb/`",
),
))
def task_new_command(
title: str = typer.Option(..., "--title", help="The item's title. Stored verbatim, never parsed."),
project: Optional[str] = typer.Option(
None,
"--project",
help="An existing tracker project's name (matched case-insensitively). This command "
"never searches or guesses one and never creates one - use "
"`wikitool new project` first if it does not exist yet. Exactly one of --project/--inbox "
"is required.",
),
inbox: bool = typer.Option(
False,
"--inbox",
help="File into the tracker's own inbox instead of a project - "
"the deliberately chosen exit when no project fits, never a stand-in for an omitted "
"--project. An item filed here is invisible to `wikitool review`, since every check "
"there is reached through a project name and the inbox has none.",
),
waiting: bool = typer.Option(
False, "--waiting", help="Tag the item WAITING - the review's check 2 reads this."
),
follow_up_at: Optional[str] = typer.Option(
None,
"--follow-up-at",
help="YYYY-MM-DD. Only meaningful together with --waiting - it is never a due date "
"and is refused without --waiting.",
),
notes: Optional[str] = typer.Option(
None,
"--notes",
help="A freetext backref, e.g. to the kb/ source page this item came from. "
"Stored verbatim, never parsed.",
),
):
"""Create one open item in the configured task tracker - no kb/ page.
\f
Tracker-only by design (#132 D1): a source that carries both knowledge
and a commitment gets this command for the commitment and the normal
page-creation commands for the knowledge, run as two separate steps by
the calling skill - see `docs/explanation/knowledge-and-commitment.md`.
"""
if bool(project) == inbox:
fail(
"Exactly one of --project <name> or --inbox is required (Gitea #132 D4) - a missing "
"--project is a mistake, not a request for the tracker's inbox."
)
if follow_up_at is not None and not waiting:
fail(
"--follow-up-at only makes sense together with --waiting (#119 D9) - follow_up_at is "
"never a due date on its own."
)
follow_up_date = _parse_follow_up_at(follow_up_at) if follow_up_at is not None else None
cfg = _read_config()
if cfg is None:
fail(
f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is "
"nowhere to create this item. Configure one first."
)
reader = tasks.build_reader(cfg)
try:
writer = tasks.build_writer(cfg, reader)
except ValidationError as exc:
fail(str(exc))
try:
writer.create_item(
title,
project_name=(None if inbox else project),
waiting=waiting,
follow_up_at=follow_up_date,
notes=notes,
)
except ValidationError as exc:
fail(str(exc))
where = "the tracker's inbox" if inbox else f"project '{project}'"
success(f"Created '{title}' in {where}.")
@app.command("list")
@cli_contract.record(cli_contract.CommandRecord(
path="task list",
summary="List a project's open items - id, title, and whether each carries the WAITING status.",
synopsis=(cli_contract.Variant(usage='task list --project "<Name>"'),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Yes - read-only, nothing to leave half-written",
budget=cli_contract.Budget.COUNTED,
network=cli_contract.Network.YES,
),
notes=(
"Lists one tracker project's open items: id, title, and whether each carries the "
"WAITING status.",
"The id source `task close` and `review`'s `waiting_overdue`/`someday_stale` findings "
"need, without running `review` first.",
"Works on every access path a provider offers, read-only ones included.",
"A `--project` matching no tracker project prints \"No open items\": the tracker read "
"does not distinguish an empty project from an unknown one.",
"Read-only.",
),
failures=(
cli_contract.Failure(
cause="A `--project` matching no tracker project - prints \"No open items\", not an "
"error",
reaction="",
code=0,
),
cli_contract.Failure(
cause="No `.wikitool-tasks.json` - no tracker configured",
reaction="Not transient - configure a tracker first, then retry once",
),
cli_contract.Failure(
cause="`WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken",
reaction="Not transient - fix the path or unset the variable",
),
),
examples=(
'tools/wikitool task list --project "Homelab migration"',
),
see_also=(
"`wikitool task close` - closes an item by the id listed here",
"`wikitool review` - the findings that name items",
),
))
def task_list_command(
project: str = typer.Option(
..., "--project", help="An existing tracker project's name (matched case-insensitively)."
),
):
"""List a project's open items - id, title, and whether each carries the WAITING status.
\f
So a caller can get an item's id for `task close` without first running
`wikitool review` (Gitea #138). Read-only; works against either access
mode a provider offers."""
cfg = _read_config()
if cfg is None:
fail(
f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is "
"nothing to list."
)
reader = tasks.build_reader(cfg)
try:
items = reader.open_items(project).items
except ValidationError as exc:
fail(str(exc))
return
if not items:
typer.echo(f"No open items in project '{project}'.")
return
for item in items:
marker = " [WAITING]" if item.waiting else ""
typer.echo(f"{item.id}\t{item.title}{marker}")
@app.command("close")
@cli_contract.record(cli_contract.CommandRecord(
path="task close",
summary="Mark one tracker item done - never delete it.",
synopsis=(cli_contract.Variant(usage="task close --id <item-id>"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="Yes - a single API call; an unknown id is rejected by the provider itself "
"(Super Productivity: `404 TASK_NOT_FOUND`) before anything is written",
budget=cli_contract.Budget.COUNTED,
network=cli_contract.Network.YES,
),
notes=(
"Marks one tracker item done. It never deletes or moves an item - the only closing "
"write this stack makes.",
"`--id` is the provider's own item id, from `task list` or a `review` finding - never a "
"title.",
"The tracker configuration is read from `.wikitool-tasks.json`, or from the file "
"`WIKITOOL_TASKS_CONFIG` names when that variable is set (so one checkout can be "
"run against several trackers in turn). A set variable that names no file is an "
"error, never \"no tracker configured\".",
"No `.wikitool-tasks.json` fails with a \"no tracker configured\" message.",
"A provider whose configured access path has no write path (Super Productivity's "
"`access: \"snapshot\"`) refuses entirely with exit 1, naming the `access: \"api\"` "
"instance to use instead.",
"**Never exits 42.**",
),
failures=(
cli_contract.Failure(
cause="No `.wikitool-tasks.json` - no tracker configured",
reaction="Not transient - configure a tracker first",
),
cli_contract.Failure(
cause="`WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken",
reaction="Not transient - fix the path or unset the variable",
),
cli_contract.Failure(
cause="An `--id` matching no tracker item right now",
reaction="Get a current id from `task list` or `review`, then retry once",
),
cli_contract.Failure(
cause="A read-only access path (Super Productivity's `access: \"snapshot\"`)",
reaction="Point at the `access: \"api\"` instance the error names, then retry once",
),
),
examples=(
"tools/wikitool task close --id <item-id>",
),
never=(
"Never pass a title as `--id`.",
),
see_also=(
"`wikitool task list` - where the id comes from",
"`wikitool review` - findings that carry item ids",
),
))
def task_close_command(
item_id: str = typer.Option(
...,
"--id",
help="The tracker's own item id, e.g. from `task list` or `wikitool "
"review`'s waiting_overdue/someday_stale findings - never a title.",
),
):
"""Mark one tracker item done - never delete it.
\f
The only closing write this stack makes (Gitea #138); see
`chemenu.tasks.protocol.TaskWriter.close_item` and
`docs/explanation/knowledge-and-commitment.md` for why."""
cfg = _read_config()
if cfg is None:
fail(
f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is "
"nothing to close."
)
reader = tasks.build_reader(cfg)
try:
writer = tasks.build_writer(cfg, reader)
except ValidationError as exc:
fail(str(exc))
try:
writer.close_item(item_id)
except ValidationError as exc:
fail(str(exc))
success(f"Closed item {item_id!r}.")