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
404 lines
17 KiB
Python
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}.")
|