tools: command records, Pages group - one line per cause, examples, prohibitions (#142)
CI / verify (push) Successful in 1m19s
Release / release (push) Successful in 38s

Files changed:
- CHANGES.md
- VERSION
- tools/CONTRACT.md
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/page_ops.py
- tools/chemenu/commands/task_cmd.py
- tools/chemenu/commands/touch.py
This commit is contained in:
torben committed 2026-09-26 08:42:13 +02:00
1 parent 9d6ca6b193
commit d0a740acc9
7 files changed
+685 -218

No files matched your search

+105 -57
View File
@@ -331,57 +331,32 @@ def _ensure_tracker_project(page_title: str, *, resume: bool) -> Optional[str]:
synopsis=(
cli_contract.Variant(
usage='new <type-name> --name "<Name>" [--type <path>] [--set field=value ...]',
notes="Scaffold a page of any type. The type-spec drives fields, directory "
"(`base_dir`/`layout`), title prefix, and template - a schema `default:` is "
"materialized only for a field the schema also lists in `required:` (an optional "
"field's default is a reader-side assumption, not a scaffold-time value) - `--set` "
"is repeatable, and comma-separated values fill array fields. An element that itself "
"contains a comma is written `\\,`, or passed as its own repeated `--set` for that "
"field - repeating an array field appends. See `types list`/`types describe`.",
notes="Any type; its type-spec decides fields, directory, title prefix and template.",
),
cli_contract.Variant(
usage='new entity --name "<Name>" --set entity_type=<t> [--set tags=a,b] '
"[--set related=X,Y] [--set sources=\"Source - Z\"] "
"[--set provenance=sourced|general|mixed]",
notes="Scaffold `kb/entities/<subdir>/<Name>.md`",
notes="Writes `kb/entities/<subdir>/<Name>.md`",
),
cli_contract.Variant(
usage='new concept --name "<Name>" --set concept_type=<t> ...',
notes="Scaffold `kb/concepts/<Name>.md`",
notes="Writes `kb/concepts/<Name>.md`",
),
cli_contract.Variant(
usage='new source --name "<Name>" --set raw_files=raw/notes/x.md,raw/notes/y.md '
"[--set source_url=<URL>] [--set entities=A,B] [--set concepts=C,D]",
notes="Scaffold `kb/sources/Source - <Name>.md` (prefix added automatically) with a "
"`raw_files:` list (rejects paths that don't exist)",
notes="Writes `kb/sources/Source - <Name>.md` (prefix added automatically) with a "
"`raw_files:` list; rejects paths that don't exist",
),
cli_contract.Variant(
usage='new comparison --name "X vs Y" --set entities=X,Y',
notes="Scaffold `kb/comparisons/X vs Y.md`",
notes="Writes `kb/comparisons/X vs Y.md`",
),
cli_contract.Variant(
usage='new project --name "<Name>" --set responsibility=<bereich> [--resume]',
notes="Scaffold `kb/gtd/<bereich>/<Name>.md` **and**, if `.wikitool-tasks.json` "
"configures a task tracker, a same-named tracker project - one name, one identity. "
"Tracker before page: the tracker side is settled first, so a failure past that "
"point leaves a tracker project with no page - a state `review`'s check 3 already "
"reports - never a page with no tracker project. No tracker configured is a "
"legitimate, explicitly announced state (page only). A name already taken "
"(case-insensitively) in `kb/` or the tracker is refused outright, naming where it "
"was found, and creates nothing. A provider whose *configured access path* has no "
"write path (Super Productivity's `access: \"snapshot\"` - the tracker is read-only "
"from there by construction) refuses **entirely**, exit **1**, naming the "
"`access: \"api\"` instance to use instead - neither the tracker project nor the "
"page is created, and `--resume` behaves the same. A provider that could write but "
"has no project-creation endpoint of its own (Super Productivity's `access: \"api\"` "
"- `GET /projects` exists, `POST /projects` does not) raises "
"`chemenu.errors.HumanInterventionRequired`; the command shows its instructions and "
"exits **42** (`needs_clearance()`, same posture as the four named gates, without "
"being a fifth one - see that class's docstring), creating nothing. `--resume` is "
"how a later run tells the command a human has done what that message asked: it "
"re-verifies via the read path (`find_project`) before continuing to page creation, "
"rather than trusting the claim, and repeats the same 42 if the tracker still "
"doesn't have it. `--resume` on any other type is refused",
notes="Writes `kb/gtd/<bereich>/<Name>.md` and, with a task tracker configured, a "
"same-named tracker project",
),
),
properties=cli_contract.Properties(
@@ -395,36 +370,109 @@ def _ensure_tracker_project(page_title: str, *, resume: bool) -> Optional[str]:
budget=cli_contract.Budget.COUNTED,
gates=("human-intervention-required (`new project` only)",),
),
notes="`new`/`xref`/`log append` only produce structurally-correct frontmatter and body "
"skeletons/edits - the prose (Description, Summary, judgment calls about relationships) is "
"still written by the LLM afterwards.",
notes=(
"The type-spec drives everything: fields, directory (`base_dir`/`layout`), title "
"prefix, and template. `types list`/`types describe` show what a type requires.",
"A schema `default:` is materialized only for a field the schema also lists in "
"`required:`.",
"`--set` is repeatable, and comma-separated values fill array fields. An element that "
"itself contains a comma is written `\\,`, or passed as its own repeated `--set` for "
"that field - repeating an array field appends.",
"A capture field the type-spec requires (a source's `fidelity`/`authority`) must be "
"passed with `--set`; `new` never guesses it and refuses `unknown` for it.",
"Produces structurally correct frontmatter and a body skeleton only - the prose "
"(Description, Summary, judgment calls about relationships) is written afterwards.",
"`new project`: with `.wikitool-tasks.json` configuring a task tracker, also makes sure "
"a same-named tracker project exists - one name, one identity. No tracker configured is "
"a legitimate, explicitly announced state: page only.",
"`new project`: tracker before page. The tracker side is settled first, so a failure "
"past that point leaves a tracker project with no page - a state `review`'s check 3 "
"reports - never a page with no tracker project.",
"`new project`: a name already taken, case-insensitively, in `kb/` or the tracker is "
"refused outright, naming where it was found, and creates nothing. For `caldav` the "
"tracker check covers every list in the account, not only the ones counted as "
"projects.",
"`new project`: 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 - neither the tracker project nor the page "
"is created, and `--resume` behaves the same.",
"`new project`: a provider that could write but has no project-creation call of its own "
"(Super Productivity's `access: \"api\"` - `GET /projects` exists, `POST /projects` "
"does not) prints instructions for a human and exits 42, creating nothing. That is not "
"one of the named gates, but the same exit code and the same handling. `caldav` never "
"does this: `MKCALENDAR` creates the list, so a valid, non-colliding name always "
"creates it.",
"`--resume` is how a later run tells the command a human has done what that message "
"asked: it re-verifies through the tracker's read path before continuing to page "
"creation, rather than trusting the claim, and exits 42 again if the tracker still does "
"not have the project. `--resume` on any other type is refused.",
),
failures=(
cli_contract.Failure(
label="new <type>",
cause="Duplicate page title, unknown type, invalid `--set` value, or a "
"`raw_files` path that doesn't exist",
reaction="Not transient; fix the argument and retry once. Never hand-craft the page "
"instead",
cause="A page with this title already exists, the type is unknown, or a `--set` "
"value is invalid",
reaction="Not transient - fix the argument and retry once",
),
cli_contract.Failure(
cause="A `raw_files` path does not exist",
reaction="Not transient - fix the path and retry once",
),
cli_contract.Failure(
cause="A capture field the type-spec requires is missing, or set to `unknown`",
reaction="Pass it explicitly (e.g. `--set fidelity=verbatim --set "
"authority=reporting`), then retry once",
),
cli_contract.Failure(
cause="`--resume` with a type other than `project`",
reaction="Drop `--resume` and retry once",
),
cli_contract.Failure(
label="new project",
cause="Everything `new <type>` covers, **plus**: the name is already taken in the "
"tracker (case-insensitively - for `caldav` this is checked against every list in "
"the account, not only the ones counted as projects), `--resume` was passed for a "
"type other than `project`, or the configured provider's access path has no write "
"path at all (Super Productivity's `access: \"snapshot\"`)",
reaction="A collision, a bad `--set`, or a read-only access path is not transient, "
"same as `new <type>` - the last of those points at the `access: \"api\"` instance "
"instead and refuses on every `--resume` retry too, since nothing about the config "
"changes by asking again. **Exit 42** (`NEEDS USER CLEARANCE`, not exit 1) is its "
"own separate outcome from the ordinary exit-1 cases above, and is "
"`superproductivity`-only: that provider *can* write but cannot create the project "
"itself and a human must, per the printed instructions; re-run with `--resume` once "
"that is done - it re-verifies via the read path rather than trusting the claim, and "
"exits 42 again unchanged if the tracker still does not have it. `caldav` never "
"produces this outcome - `MKCALENDAR` is a real collection-creation verb, so a "
"valid, non-colliding name always creates the list itself",
cause="The name is already taken in the tracker (case-insensitively; for `caldav` "
"against every list in the account)",
reaction="Not transient - choose another name. If an earlier run of this exact "
"command asked a human to create the project and they did, re-run with `--resume`",
),
cli_contract.Failure(
label="new project",
cause="The configured access path has no write path (Super Productivity's "
"`access: \"snapshot\"`)",
reaction="Not transient - point at the `access: \"api\"` instance the error names. "
"A `--resume` retry refuses the same way, since nothing about the config changes by "
"asking again",
),
cli_contract.Failure(
label="new project",
cause="The page write failed after the tracker project was confirmed to exist",
reaction="Fix the write error, then re-run with `--resume` - a plain re-run is "
"refused as a tracker collision",
),
cli_contract.Failure(
label="new project",
cause="The provider cannot create the project itself (Super Productivity's "
"`access: \"api\"`); the output says what a human has to create",
reaction="Show the user the command's full output verbatim and stop. Once they have "
"created the project, re-run the same command with `--resume`; it re-verifies and "
"exits 42 again, unchanged, if the tracker still does not have it",
code=42,
),
),
examples=(
'tools/wikitool new entity --name "Docker" --set entity_type=tool --set tags=containers',
'tools/wikitool new source --name "Docker Cheatsheet" '
"--set raw_files=raw/2026/09/docker-cheatsheet.md --set fidelity=verbatim "
"--set authority=reporting",
'tools/wikitool new project --name "Homelab migration" --set responsibility=infrastruktur '
"--resume # re-run after exit 42, once the user created the tracker project",
),
never=(
"Never hand-craft the page, or its frontmatter, instead.",
),
see_also=(
"`wikitool types describe <type>` - what a type requires and where it lands",
"`wikitool touch` - changes a page's own frontmatter afterwards",
"`wikitool task new` - a tracker item without a page",
"`docs/knowledge-and-commitment.md` - why a project is a page and a tracker project",
),
))
def new_page_command(
+130 -44
View File
@@ -220,20 +220,51 @@ def inbound_pages(pages: dict[str, Page], title: str) -> list[str]:
atomic="No - one write per referencing page, then the file move",
budget=cli_contract.Budget.COUNTED,
),
notes="Rename a page and repoint every reference to it: body `[[wikilinks]]` (aliases and "
"anchors preserved), a `[^cite-id]` whose id was derived from the old title (refreshed to "
"match the new one, both in its Footnotes definition and every reference to it), the page's "
"own H1, and every page-ref frontmatter array declared by the type's `page_ref_fields:`. If "
"`--from` is *not* a page but is referenced, it instead repoints those references onto the "
"existing `--to` page and moves nothing - the fix for a reference spelled `act_runner` when "
"the page is `Act Runner`",
failures=(cli_contract.Failure(
label="",
cause="Neither `--from` nor `--to` is a page, target title already taken, or "
"`--from` equals `--to`",
reaction="Safe to retry once as-is; each page's rewrite is idempotent. Use `--dry-run` "
"first to see the blast radius. Never fix up references by hand instead",
),),
notes=(
"Renames a page and repoints every reference to it: body `[[wikilinks]]` (aliases and "
"anchors preserved), a `[^cite-id]` whose id was derived from the old title (refreshed "
"to match the new one, both in its Footnotes definition and every reference to it), the "
"page's own H1, and every page-ref frontmatter array declared by the type's "
"`page_ref_fields:`.",
"If `--from` is *not* a page but is referenced, it instead repoints those references "
"onto the existing `--to` page and moves nothing - the fix for a reference spelled "
"`act_runner` when the page is `Act Runner`.",
"Each page's rewrite is idempotent, so a re-run as-is is safe. If a write fails "
"midway, nothing is renamed on disk and the error lists what was updated.",
"`--dry-run` lists every page it would change; run it first to see the blast radius.",
),
failures=(
cli_contract.Failure(
cause="`--from` equals `--to`",
reaction="Fix the arguments and retry once",
),
cli_contract.Failure(
cause="Neither `--from` nor `--to` is a page",
reaction="Create the page first with `wikitool new`, or drop the reference with "
"`wikitool xref remove`",
),
cli_contract.Failure(
cause="The `--to` title is already taken",
reaction="Choose another title and retry once",
),
cli_contract.Failure(
cause="A page write failed partway; nothing was renamed on disk",
reaction="Check `git status`, resolve the write failure (permissions/disk), then "
"re-run the full command - safe, since each page's rewrite is idempotent",
),
),
examples=(
'tools/wikitool rename --from "act_runner" --to "Act Runner" --dry-run',
'tools/wikitool rename --from "Docker Engine" --to "Docker"',
),
never=(
"Never fix up references by hand instead.",
),
see_also=(
"`instructions/page-lifecycle.md` - renaming, moving and deleting a page",
"`wikitool move` - changes a page's directory, not its title",
"`wikitool rm` - deletes a page",
),
))
def rename_command(
old: str = typer.Option(..., "--from", help="Current page title, exactly as it appears"),
@@ -336,17 +367,42 @@ def rename_command(
atomic="No - one write per referencing page, then the delete",
budget=cli_contract.Budget.COUNTED,
),
notes="Refuses without `--yes` while other pages still reference it. Strips ref-array "
"entries and bare `- [[Title]]` / `- **label:** [[Title]]` bullets; leaves prose and inline "
"citations in place and reports them",
failures=(cli_contract.Failure(
label="",
cause="Page not found, **or** other pages still reference it and `--yes` was not "
"passed",
reaction="For \"still referenced\": show the user the inbound list, get approval, then "
"re-run with `--yes`. Prose references it reports afterwards are an editorial fix, not "
"a retry",
),),
notes=(
"Deletes a page and de-links it from the rest of the wiki: strips page-ref array "
"entries and bare `- [[Title]]` / `- **label:** [[Title]]` bullets pointing at it.",
"Leaves prose references and inline citations in place and reports them afterwards; "
"those are an editorial fix, not a reason to retry.",
"Refuses without `--yes` while other pages still reference it, listing them.",
"If a de-link write fails midway, the page is not deleted, so nothing is orphaned and "
"a re-run is safe.",
"`--dry-run` lists what would change, without writing.",
"Not idempotent: once the page is gone, a second run finds nothing to delete.",
),
failures=(
cli_contract.Failure(
cause="Page not found",
reaction="Fix the title and retry once",
),
cli_contract.Failure(
cause="Other pages still reference it and `--yes` was not passed",
reaction="Show the user the inbound list, get approval, then re-run with `--yes`",
),
cli_contract.Failure(
cause="A de-link write failed partway; the page was not deleted",
reaction="Check `git status`, resolve the write failure, then re-run",
),
),
examples=(
'tools/wikitool rm --page "Old Draft" --dry-run',
'tools/wikitool rm --page "Old Draft" --yes # after the user approved the inbound list',
),
never=(
"Never pass `--yes` before the user has seen the inbound list and approved it.",
),
see_also=(
"`instructions/page-lifecycle.md` - when a page is deleted rather than renamed",
"`wikitool rename` - repoints references instead of removing them",
),
))
def rm_command(
page_title: str = typer.Option(..., "--page", help="Exact title of the page to delete"),
@@ -460,25 +516,55 @@ def _rmdir_if_emptied(directory: Path) -> bool:
"each idempotent",
budget=cli_contract.Budget.COUNTED,
),
notes="Move a page to the directory its type-spec computes for its current frontmatter "
"(`base_dir` + `layout` - the same rule `new` places a page by, via "
"`TypeResolver.compute_target_dir`), never a hand-chosen destination - there is no "
"`--to <dir>`. `--reconcile` applies it corpus-wide: every misplaced page moves in one "
"call, and a second run reports nothing left to do (`lint`'s `Misplaced Pages` finding is "
"the advisory that this fixes, and its `Nested Pages` finding the hard one - see `lint`). "
"Neither mode touches a body or a frontmatter field, and the page's title (its only "
"identity in the wiki) never changes - only the file moves. A directory a move empties is "
"removed along with it, so a page that was nested below its area leaves no leftover "
"directory behind. A destination already occupied (a pre-existing duplicate-stem collision) "
"is refused rather than silently skipped",
failures=(cli_contract.Failure(
label="",
cause="Neither or both of `--page`/`--reconcile` given, the named page not found, it "
"has no `type:` to compute a placement from, or the destination already exists",
reaction="Safe to retry once as-is; a page already at its computed location is reported "
"and left alone, and `--reconcile` only re-moves what is still misplaced. Use "
"`--dry-run` first to see the blast radius. Never choose a directory by hand instead",
),),
notes=(
"Moves a page to the directory its type-spec computes for its current frontmatter "
"(`base_dir` + `layout`, the same rule `new` places a page by) - never to a hand-chosen "
"destination; there is no `--to <dir>`.",
"`--reconcile` applies it corpus-wide: every misplaced page moves in one call, and a "
"second run reports nothing left to do. It fixes `lint`'s `Misplaced Pages` (advisory) "
"and `Nested Pages` (hard) findings.",
"Neither mode touches a body or a frontmatter field, and the page's title - its only "
"identity in the wiki - never changes; only the file moves.",
"A directory a move empties is removed with it, so a page nested below its area leaves "
"no leftover directory behind.",
"A page already at its computed location is reported and left alone, and `--reconcile` "
"only re-moves what is still misplaced, so a re-run is safe.",
"`--dry-run` (with `--reconcile`) lists the moves; run it first to see the blast "
"radius.",
"Run `wikitool index rebuild` afterwards.",
),
failures=(
cli_contract.Failure(
cause="Neither or both of `--page`/`--reconcile` given",
reaction="Fix the arguments and retry once",
),
cli_contract.Failure(
cause="The named page is not found, or has no `type:` to compute a placement from",
reaction="Fix the title, or give the page its `type:`, then retry once",
),
cli_contract.Failure(
cause="The destination already exists (a pre-existing duplicate-stem collision) - "
"refused rather than silently skipped",
reaction="Resolve the collision, then retry",
),
cli_contract.Failure(
cause="`--reconcile` failed partway",
reaction="Safe to retry as-is - `--reconcile` only re-moves what is still misplaced",
),
),
examples=(
'tools/wikitool move --page "Docker"',
"tools/wikitool move --reconcile --dry-run",
"tools/wikitool move --reconcile",
),
never=(
"Never choose a directory by hand instead.",
),
see_also=(
"`wikitool lint` - reports Misplaced and Nested Pages",
"`wikitool index rebuild` - run after moving",
"`instructions/page-lifecycle.md` - moving, renaming and deleting a page",
),
))
def move_command(
page_title: Optional[str] = typer.Option(
+135 -62
View File
@@ -17,6 +17,15 @@ 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
@@ -66,38 +75,68 @@ def _parse_follow_up_at(text: str) -> datetime.date:
budget=cli_contract.Budget.COUNTED,
network=cli_contract.Network.YES,
),
notes="The second creation command alongside `new project`, and the last one their split "
"needed - see `docs/knowledge-and-commitment.md`. Exactly one of `--project` (an existing "
"tracker project, matched case-insensitively - never created and never searched or "
"guessed) or `--inbox` (the tracker's own inbox, a deliberate exit with a cost: an item "
"filed there never appears in `review`, since every one of its checks is reached through a "
"project name and the inbox has none) is required; an omitted `--project` refuses rather "
"than silently falling into the inbox. `--waiting` sets the WAITING status the review's own "
"waiting-overdue check reads; `--follow-up-at` is refused without `--waiting`, since it is "
"never a due date on its own. `--notes` carries a freetext backref (e.g. to the kb/ source "
"page this item came from), stored verbatim, never parsed - the same posture a `WAITING` "
"item's own title already has for the person named in it. No `.wikitool-tasks.json` fails "
"immediately with the same \"no tracker configured\" message as `review`. A provider whose "
"configured access path has no write path (Super Productivity's `access: \"snapshot\"`) "
"refuses **entirely**, exit **1**, naming the `access: \"api\"` instance to use instead - "
"same posture as `new project`. Unlike `new project`, **never exits 42**: every provider "
"offering a write path at all has a real item-creation call (Super Productivity's "
"`POST /tasks`, where `POST /projects` does not exist) - a named `--project` that does not "
"match any tracker project, or `--waiting` against a provider that cannot represent it "
"right now (Super Productivity: the `waiting` tag does not exist yet, and tags cannot be "
"created via its API), are ordinary exit-1 refusals instead, creating nothing",
failures=(cli_contract.Failure(
label="",
cause="No `.wikitool-tasks.json`, neither or both of `--project`/`--inbox` given, a "
"`--follow-up-at` without `--waiting` or not `YYYY-MM-DD`, a `--project` name matching "
"no tracker project, `--waiting` against a provider with no way to represent it right "
"now (Super Productivity: the `waiting` tag does not exist), or a read-only access path "
"(Super Productivity's `access: \"snapshot\"`)",
reaction="Not transient; fix the argument, create the missing tracker project or tag "
"first, or point at an `access: \"api\"` instance, then retry once. **Never exit 42** - "
"unlike `new project`, every provider offering a write path at all has a real "
"item-creation call, so there is no human-clearance step to wait on here",
),),
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.",
"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="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/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."),
@@ -192,19 +231,35 @@ def task_new_command(
budget=cli_contract.Budget.COUNTED,
network=cli_contract.Network.YES,
),
notes="Read-only; the id source `task close` and the review's own "
"`waiting_overdue`/`someday_stale` findings need, without first running `wikitool review`. "
"Works on either access mode a provider offers, unlike the write commands below. No "
"`.wikitool-tasks.json` fails with the same \"no tracker configured\" message as "
"`review`/`task new`; a `--project` matching no tracker project prints \"No open items\", "
"since `TaskReader.open_items` does not distinguish \"empty\" from \"unknown\" "
"(`chemenu.tasks.protocol.TaskReader.open_items`'s own docstring)",
failures=(cli_contract.Failure(
label="",
cause="No `.wikitool-tasks.json`",
reaction="Not transient; configure a tracker first, then retry once. A `--project` "
"matching no tracker project is not an error here - see its Commands row",
),),
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",
),
),
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(
@@ -251,23 +306,41 @@ def task_list_command(
budget=cli_contract.Budget.COUNTED,
network=cli_contract.Network.YES,
),
notes="`<item-id>` is the provider's own id, from `task list` or a `review` finding, never "
"a title - the tracker-side identity is opaque, unlike the project name that is `kb/`'s and "
"the tracker's only shared coupling. The only closing write this stack makes: no \"move a "
"reminder\", no \"remove an item\". No `.wikitool-tasks.json` fails with the same \"no "
"tracker configured\" message as `task new`. A provider whose configured access path has no "
"write path (Super Productivity's `access: \"snapshot\"`) refuses **entirely**, exit **1**, "
"naming the `access: \"api\"` instance to use instead - same posture as `task new`. Never "
"exits 42, same reasoning as `task new`: every provider offering a write path has a real "
"per-item write call",
failures=(cli_contract.Failure(
label="",
cause="No `.wikitool-tasks.json`, an `--id` matching no tracker item right now, or a "
"read-only access path (Super Productivity's `access: \"snapshot\"`)",
reaction="Not transient; fix the id (re-run `task list` or `review` to get a current one) "
"or point at an `access: \"api\"` instance, then retry once. **Never exit 42**, same "
"reasoning as `task new`",
),),
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.",
"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="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(
+55 -20
View File
@@ -197,26 +197,61 @@ def _apply_remove(frontmatter: Dict[str, Any], field: str, value: Any) -> Option
atomic="Yes - single file write, and every refusal happens before it",
budget=cli_contract.Budget.COUNTED,
),
notes="Update a page's own frontmatter: bump `modified:` and optionally rewrite any field "
"its type declares. `--summary`/`--provenance` are shorthands; `--set` reaches every other "
"field and **replaces** its value, while `--add`/`--remove` change single elements of an "
"array field (removing an absent element succeeds and says so). Repeating `--set` for one "
"array field appends *within the call*, and `\\,` is a literal comma - same rules as "
"`new --set`. Refused with the command that owns them instead: `type:` (page-lifecycle), "
"and the page-ref arrays `related:`/`sources:`/`entities:`/`concepts:` (`xref`). Everything "
"else the schema declares is settable, and an unknown field lists what the page actually "
"has. Schema-validates the fields it writes, and `raw_files:` entries must exist on disk. A "
"source declares `date:` instead of `modified:`, and that is the *publication* date of the "
"raw material - it is never bumped to today, and changes only when `--date` names a value "
"explicitly.",
failures=(cli_contract.Failure(
label="",
cause="Page not found; an invalid value for a field it writes; a field owned by "
"another command (`type:`, a page-ref array) or absent from the type's schema; "
"`--add`/`--remove` on a non-array field; a `raw_files:` path that doesn't exist",
reaction="Fix the argument and retry once. Safe to re-run as-is: `--set` and `--add` are "
"idempotent, and `--remove` of an already-absent element succeeds while reporting it",
),),
notes=(
"Bumps `modified:` to today and optionally rewrites any other field the page's type "
"declares.",
"`--summary`/`--provenance` are shorthands; `--set` reaches every other field and "
"**replaces** its value, while `--add`/`--remove` change single elements of an array "
"field (removing an absent element succeeds and says so).",
"Repeating `--set` for one array field appends *within the call*, and `\\,` is a "
"literal comma.",
"Refused, naming the command that owns them instead: `type:` (the page-lifecycle "
"procedure) and the page-ref arrays `related:`/`sources:`/`entities:`/`concepts:` "
"(`xref`). Everything else the schema declares is settable, and an unknown field is "
"refused with the list of fields the page actually has.",
"Schema-validates the fields it writes; `raw_files:` entries must exist on disk.",
"A source page declares `date:` instead of `modified:`: the *publication* date of the "
"raw material. It is never bumped to today and changes only when `--date` names a value "
"explicitly.",
"`--no-date` changes only the given fields and leaves the date alone; `--dry-run` "
"previews the new frontmatter without writing.",
"Safe to re-run as-is: `--set` and `--add` are idempotent, and `--remove` of an "
"already-absent element succeeds while reporting it.",
),
failures=(
cli_contract.Failure(
cause="Page not found",
reaction="Fix the title and retry once",
),
cli_contract.Failure(
cause="An invalid value for a field it writes, or a `raw_files:` path that doesn't "
"exist",
reaction="Fix the argument and retry once",
),
cli_contract.Failure(
cause="A field owned by another command (`type:`, a page-ref array) or absent from "
"the type's schema",
reaction="Use the command the error names, or a field from the list it prints",
),
cli_contract.Failure(
cause="`--add`/`--remove` on a non-array field",
reaction="Use `--set` for a scalar field, then retry once",
),
),
examples=(
'tools/wikitool touch --page "Docker" --summary "Container runtime and image format"',
'tools/wikitool touch --page "Docker" --add tags=containers --no-date',
'tools/wikitool touch --page "Source - Docker Cheatsheet" --date 2026-09-01 --dry-run',
),
never=(
"Never edit `type:` or a page-ref array (`related:`/`sources:`/`entities:`/"
"`concepts:`) by hand - `type:` changes go through the page-lifecycle procedure, "
"page-ref arrays through `xref`.",
),
see_also=(
"`wikitool xref add` / `wikitool xref remove` - the page-ref arrays",
"`instructions/page-lifecycle.md` - changing a page's `type:`",
),
))
def touch_command(
page_title: str = typer.Option(..., "--page", help="Exact page title, e.g. 'Docker Cheatsheet'"),