build: wikitool new project - Seite und Tracker-Projekt unter einem Namen (#126)
CI / verify (push) Successful in 47s
Release / release (push) Successful in 35s

Files changed:
- CHANGES.md
- VERSION
- tools/CONTRACT.md
- tools/chemenu/commands/new_page.py
- tools/chemenu/errors.py
- tools/chemenu/review.py
- tools/chemenu/tasks/__init__.py
- tools/chemenu/tests/test_new_page.py
This commit is contained in:
torben committed 2026-09-20 07:32:03 +02:00
1 parent 80b57e0d01
commit e4260fc2de
8 files changed
+342 -24

No files matched your search

+33 -1
View File
@@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse.
--- ---
## 7.0.0-beta.3 - 2026-09-19 - wikitool review: the weekly GTD review as a read-time join ## 7.0.0-beta.4 - 2026-09-20 - wikitool new project: Seite und Tracker-Projekt unter einem Namen
**Author:** Torben Nehmer **Author:** Torben Nehmer
@@ -71,6 +71,7 @@ concern - readable here, never shipped as something to parse.
- Typ `project` und Collection `kb/gtd/`: das Vorhaben als eigene Seitenart - Typ `project` und Collection `kb/gtd/`: das Vorhaben als eigene Seitenart
- Task-tracker provider layer, with a Super Productivity adapter - Task-tracker provider layer, with a Super Productivity adapter
- wikitool review: the weekly GTD review as a read-time join - wikitool review: the weekly GTD review as a read-time join
- wikitool new project: Seite und Tracker-Projekt unter einem Namen
<!-- /wikitool:bumps --> <!-- /wikitool:bumps -->
### Typ `project` und Collection `kb/gtd/`: das Vorhaben als eigene Seitenart ### Typ `project` und Collection `kb/gtd/`: das Vorhaben als eigene Seitenart
@@ -162,6 +163,37 @@ ist ein Konfigurationsfehler, kein Erreichbarkeitsproblem, und braucht deshalb k
`kb_project_count`/`complete`); ein Test haelt beide Formen gegeneinander, wie es `kb_project_count`/`complete`); ein Test haelt beide Formen gegeneinander, wie es
`test_mcp_server.py` fuer den MCP-Lesepfad gegen die CLI tut. `test_mcp_server.py` fuer den MCP-Lesepfad gegen die CLI tut.
### wikitool new project: Seite und Tracker-Projekt unter einem Namen
Gitea #119 (Paket #126): `wikitool new project --name X --set responsibility=Y` legt jetzt, wenn
`.wikitool-tasks.json` einen Tracker konfiguriert, zusaetzlich ein gleichnamiges Tracker-Projekt
an - ein Geburtsort, ein Name (D8/D31). Tracker vor Seite: erst steht die Tracker-Seite fest,
erst danach wird die `kb/`-Seite geschrieben, damit ein Fehlschlag zwischen beiden immer im
selben, bereits bekannten Zustand landet - "Tracker-Projekt ohne Seite", das `review`s Pruefung 3
ohnehin meldet - nie im unbekannten "Seite ohne Tracker-Projekt". Ist kein Tracker konfiguriert,
bleibt es bei der reinen Seitenanlage, jetzt aber ausdruecklich als solche vermerkt statt
stillschweigend.
`chemenu.tasks.build_reader`/`build_writer` (neu in `chemenu/tasks/__init__.py`) sind die eine
Dispatch-Tabelle von `TasksConfig.provider` auf einen konkreten Adapter, jetzt von `review.py`
*und* `new_page.py` geteilt statt zweimal derselben `if cfg.provider == "superproductivity"`.
Fuer einen Provider ohne Schreibpfad (Super Productivity, #124: keine `POST /projects`) wirft
`create_project` `chemenu.errors.HumanInterventionRequired` - das Kommando zeigt die Anweisung
und beendet sich mit Exit 42, ohne irgendetwas anzulegen. Die offene Frage aus #126s eigenem
Issue-Text war, wie ein zustandsloser CLI-Prozess bei einem erneuten Aufruf eine echte
Namenskollision von "der Mensch hat gerade getan, worum genau dieses Kommando gebeten hat"
unterscheidet - beides sieht am Lesepfad identisch aus (Tracker hat den Namen, `kb/` noch keine
Seite). Entschieden (mit dem Betreiber, nicht allein): ein explizites `--resume`, das ein Treffer
im Tracker als bestaetigte Fortsetzung liest statt als Kollision - ohne `--resume` bleibt jeder
Treffer eine Ablehnung samt Fundort, auch bei einem Wiederholungsaufruf. `--resume` ohne einen
tatsaechlich fehlenden Tracker-Eintrag wirft dieselbe `HumanInterventionRequired`-Meldung erneut,
keine stille Weiterarbeit auf Zuruf. `--resume` bei jedem anderen Typ wird abgelehnt.
Ein erzwungener Fehlschlag der eigentlichen Seiten-Schreibaktion (Schritt 3) nach bereits
bestaetigtem Tracker-Projekt ist eigens getestet: die Meldung nennt, dass die Tracker-Seite schon
steht und nur die `kb/`-Seite fehlt, nie umgekehrt.
--- ---
## 6.2.0 - 2026-09-19 - Entity-Subtyp project nach codebase umbenannt ## 6.2.0 - 2026-09-19 - Entity-Subtyp project nach codebase umbenannt
+1 -1
View File
@@ -1 +1 @@
7.0.0-beta.3 7.0.0-beta.4
+2
View File
@@ -94,6 +94,7 @@ tools/wikitool <command> --help
| `new concept --name "<Name>" --set concept_type=<t> ...` | Scaffold `kb/concepts/<Name>.md` | | `new concept --name "<Name>" --set concept_type=<t> ...` | Scaffold `kb/concepts/<Name>.md` |
| `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]` | Scaffold `kb/sources/Source - <Name>.md` (prefix added automatically) with a `raw_files:` list (rejects paths that don't exist) | | `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]` | Scaffold `kb/sources/Source - <Name>.md` (prefix added automatically) with a `raw_files:` list (rejects paths that don't exist) |
| `new comparison --name "X vs Y" --set entities=X,Y` | Scaffold `kb/comparisons/X vs Y.md` | | `new comparison --name "X vs Y" --set entities=X,Y` | Scaffold `kb/comparisons/X vs Y.md` |
| `new project --name "<Name>" --set responsibility=<bereich> [--resume]` | 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 with no write path of its own (Super Productivity has no project-creation endpoint) 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 |
| `touch --page "<Title>" [--summary "..."] [--provenance <v>] [--date YYYY-MM-DD] [--set field=value ...] [--add field=value ...] [--remove field=value ...] [--no-date] [--dry-run]` | 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. | | `touch --page "<Title>" [--summary "..."] [--provenance <v>] [--date YYYY-MM-DD] [--set field=value ...] [--add field=value ...] [--remove field=value ...] [--no-date] [--dry-run]` | 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. |
| `rename --from "<Old>" --to "<New>" [--dry-run]` | 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` | | `rename --from "<Old>" --to "<New>" [--dry-run]` | 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` |
| `rm --page "<Title>" [--yes] [--dry-run]` | Delete a page and mechanically de-link it. 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 | | `rm --page "<Title>" [--yes] [--dry-run]` | Delete a page and mechanically de-link it. 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 |
@@ -309,6 +310,7 @@ is atomic, and whether a retry is safe.
| Command | Exit 1 means | Atomic? | Retry policy | | Command | Exit 1 means | Atomic? | Retry policy |
|---------|--------------|---------|--------------| |---------|--------------|---------|--------------|
| `new <type>` | Duplicate page title, unknown type, invalid `--set` value, or a `raw_files` path that doesn't exist | Yes - single file write | Not transient; fix the argument and retry once. Never hand-craft the page instead | | `new <type>` | Duplicate page title, unknown type, invalid `--set` value, or a `raw_files` path that doesn't exist | Yes - single file write | Not transient; fix the argument and retry once. Never hand-craft the page instead |
| `new project` | Everything `new <type>` covers, **plus**: the name is already taken in the tracker (case-insensitively), or `--resume` was passed for a type other than `project` | **No** for the tracker-configured case - a tracker-project write (or its human-clearance request) happens before the kb/ page write, so a failure between the two leaves a tracker project with no page (a state `review`'s check 3 already reports), never a page with no tracker project. Still a single file write when no tracker is configured | A collision or a bad `--set` is not transient, same as `new <type>`. **Exit 42** (`NEEDS USER CLEARANCE`, not exit 1) is its own third outcome: the provider 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 |
| `touch` | 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 | Yes - single file write, and every refusal happens before it | 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 | | `touch` | 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 | Yes - single file write, and every refusal happens before it | 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 |
| `rename` | Neither `--from` nor `--to` is a page, target title already taken, or `--from` equals `--to` | No - one write per referencing page, then the file move | 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 | | `rename` | Neither `--from` nor `--to` is a page, target title already taken, or `--from` equals `--to` | No - one write per referencing page, then the file move | 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 |
| `rm` | Page not found, **or** other pages still reference it and `--yes` was not passed | No - one write per referencing page, then the delete | 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 | | `rm` | Page not found, **or** other pages still reference it and `--yes` was not passed | No - one write per referencing page, then the delete | 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 |
+105 -3
View File
@@ -28,18 +28,29 @@ import re
import typer import typer
from chemenu import config from chemenu import config, tasks
from chemenu.commands._util import ( from chemenu.commands._util import (
check_collision, check_collision,
check_raw_files_exist, check_raw_files_exist,
fail, fail,
needs_clearance,
parse_set_fields, parse_set_fields,
rel_path, rel_path,
success, success,
) )
from chemenu.errors import HumanInterventionRequired, ValidationError
from chemenu.frontmatter_io import write_page from chemenu.frontmatter_io import write_page
from chemenu.tasks import config as tasks_config
from chemenu.tasks.protocol import find_project
from chemenu.type_resolver import resolver from chemenu.type_resolver import resolver
# The one type name for which `new` also touches the task tracker (Gitea
# #126, #119 D8/D16/D31) - the same literal `chemenu.review._load_kb_projects`
# already matches `page.kind` against, and the one `docs verify`'s
# `check_stack_required_types` (`kb_collections.STACK_REQUIRED_TYPES`) makes
# sure some type-spec actually declares `name: project`.
PROJECT_TYPE_NAME = "project"
def _default_summary(summary: str) -> str: def _default_summary(summary: str) -> str:
"""Scaffold-time placeholder for an unfilled --summary, so schema """Scaffold-time placeholder for an unfilled --summary, so schema
@@ -255,6 +266,57 @@ def _load_type_or_fail(type_path: str, source_dir: Path):
fail(str(exc)) fail(str(exc))
def _ensure_tracker_project(page_title: str, *, resume: bool) -> Optional[str]:
"""Step 1+2 of `new project` (Gitea #126, #119 D8/D31): make sure a
tracker project named `page_title` exists before the caller writes the
kb/ page for it, and never touch the page itself.
Returns a one-line status to fold into the success message, or `None`
when no tracker is configured at all - `page_title` is the page's actual
title (title_prefix already applied), the same value `check_collision`
checked against `kb/` moments earlier, because it is what the join in
`chemenu.review` keys on (#119 D8: the name is the sole coupling).
Leaves through `fail()`/`needs_clearance()` (never returns) for every
outcome that must not proceed to page creation - a collision, a
dependency failure, or a human still owing the manual step - so that by
the time this returns normally, either nothing was created (page-only or
a genuine refusal) or a tracker project now provably exists for this
exact name, and the caller's next step is the only one left: write the
page.
"""
cfg = tasks_config.read_config(config.ROOT)
if cfg is None:
return None
reader = tasks.build_reader(cfg)
existing = find_project(reader, page_title)
if existing is not None:
if resume:
return f"tracker project '{existing.name}' already existed (--resume)"
fail(
f"A project named '{page_title}' (case-insensitively) already exists in the "
f"tracker ('{existing.name}') - nothing was created (neither the tracker project "
"nor the kb/ page). If an earlier run of this exact command asked you to create it "
"by hand and you just did, re-run with --resume to continue to page creation "
"instead of being refused here."
)
writer = tasks.build_writer(cfg, reader)
try:
writer.create_project(page_title)
except HumanInterventionRequired as exc:
needs_clearance(
f"{exc}\n\nNothing was created yet for '{page_title}' (neither the tracker "
"project nor the kb/ page). Once you have done the above, re-run this exact "
"command with --resume to verify it and continue to page creation - do not assume "
"confirming here is enough."
)
except ValidationError as exc:
fail(str(exc))
return f"tracker project '{page_title}' created"
def new_page_command( def new_page_command(
type_name: str = typer.Argument( type_name: str = typer.Argument(
..., ...,
@@ -269,6 +331,13 @@ def new_page_command(
"--set", "--set",
help="Frontmatter field, repeatable: --set entity_type=tool --set tags=a,b. Array values split on commas (escape a literal one as \\,); repeating --set for an array field appends instead of replacing", help="Frontmatter field, repeatable: --set entity_type=tool --set tags=a,b. Array values split on commas (escape a literal one as \\,); repeating --set for an array field appends instead of replacing",
), ),
resume: bool = typer.Option(
False,
"--resume",
help="`project` only: confirm a human has completed the manual tracker step an earlier "
"HumanInterventionRequired refusal asked for, so this run continues to page creation "
"instead of refusing the now-existing tracker project as a collision (Gitea #126).",
),
): ):
"""Scaffold a new wiki page of any type. """Scaffold a new wiki page of any type.
@@ -277,12 +346,23 @@ def new_page_command(
(schema `default:`), where the page is written (`base_dir` + `layout`), (schema `default:`), where the page is written (`base_dir` + `layout`),
what prefixes its title (`title_prefix`), and its body skeleton (the what prefixes its title (`title_prefix`), and its body skeleton (the
type-spec's template). Adding a new type therefore needs no change here. type-spec's template). Adding a new type therefore needs no change here.
For `type_name == "project"` specifically, this also ensures a
same-named tracker project exists (Gitea #126, #119 D8/D31) before the
page is written - see `_ensure_tracker_project`.
""" """
type_path = type_path_override or resolver.find_type_by_name(type_name) type_path = type_path_override or resolver.find_type_by_name(type_name)
if not type_path: if not type_path:
available = sorted(fm.get("name") for _, fm in resolver.list_type_specs()) available = sorted(fm.get("name") for _, fm in resolver.list_type_specs())
fail(f"No type-spec named '{type_name}'. Available: {', '.join(available)}") fail(f"No type-spec named '{type_name}'. Available: {', '.join(available)}")
try:
is_project = resolver.get_type_name(type_path) == PROJECT_TYPE_NAME
except ValueError as exc:
fail(str(exc))
if resume and not is_project:
fail("--resume only applies to `new project` (Gitea #126) - it has no effect on any other type.")
today = datetime.date.today() today = datetime.date.today()
try: try:
@@ -356,5 +436,27 @@ def new_page_command(
}, },
) )
write_page(path, frontmatter, body) # Everything above only validates - nothing has touched disk or the
success(f"Created {rel_path(path)}") # tracker yet. Tracker before page (Gitea #126's own "Reihenfolge ist die
# Fehlerbehandlung"): a page-write failure past this point leaves a
# tracker project with no page, a state check 3 (#125) already reports;
# the reverse order would instead leave a kb/ page claiming an
# initiative nobody can act on, which is worse and unreported.
tracker_note = _ensure_tracker_project(page_title, resume=resume) if is_project else None
try:
write_page(path, frontmatter, body)
except OSError as exc:
if is_project and tracker_note is not None:
fail(
f"Could not write {rel_path(path)} ({exc}). The kb/ page was NOT created, but "
f"the tracker project was already confirmed to exist ({tracker_note}). Fix the "
"write error and re-run with --resume to finish - a plain re-run would otherwise "
"be refused as a tracker collision."
)
raise
msg = f"Created {rel_path(path)}"
if is_project:
msg += f" ({tracker_note or 'no task tracker configured - page only'})"
success(msg)
+7 -3
View File
@@ -40,8 +40,8 @@ class HumanInterventionRequired(ChemenuError):
`GET /projects`, so `SuperProductivityWriter.create_project` cannot do the `GET /projects`, so `SuperProductivityWriter.create_project` cannot do the
one write `chemenu.tasks.protocol.TaskWriter` asks of it. one write `chemenu.tasks.protocol.TaskWriter` asks of it.
The CLI adapter (a future `wikitool new project`, Gitea #126) renders this The CLI adapter (`wikitool new project`, Gitea #126) renders this the same
the same way it renders the four named gates in AGENTS.md's Gates section: way it renders the four named gates in AGENTS.md's Gates section:
`commands._util.needs_clearance(str(exc))`, exit code 42 - "a human must `commands._util.needs_clearance(str(exc))`, exit code 42 - "a human must
see the command's output before anything proceeds" applies here for the see the command's output before anything proceeds" applies here for the
same reason it applies to a mass update, just for a different cause. It is same reason it applies to a mass update, just for a different cause. It is
@@ -54,7 +54,11 @@ class HumanInterventionRequired(ChemenuError):
human's out-of-band step actually landed. A caller must invoke it after human's out-of-band step actually landed. A caller must invoke it after
the human confirms doing what `str(exc)` asked - "the user says they did the human confirms doing what `str(exc)` asked - "the user says they did
it" is never treated as "it happened" - and must refuse to proceed (and it" is never treated as "it happened" - and must refuse to proceed (and
ask again) while it still returns False. ask again) while it still returns False. `wikitool new project` is a
fresh process each time rather than a long-lived caller holding onto this
one `exc`, so it does not call `verify` itself - its `--resume` flag
re-runs the equivalent read-path check (`chemenu.tasks.protocol.find_project`)
from scratch instead, which answers the same question this closure would.
""" """
def __init__(self, message: str, *, verify): def __init__(self, message: str, *, verify):
+3 -14
View File
@@ -25,8 +25,9 @@ from typing import Optional
from chemenu import config, kb_scan from chemenu import config, kb_scan
from chemenu.errors import ValidationError from chemenu.errors import ValidationError
from chemenu.tasks import build_reader
from chemenu.tasks import config as tasks_config from chemenu.tasks import config as tasks_config
from chemenu.tasks.protocol import OpenItems, TaskReader, normalize_project_name from chemenu.tasks.protocol import OpenItems, normalize_project_name
CHECK_STALLED = "stalled" CHECK_STALLED = "stalled"
CHECK_WAITING_OVERDUE = "waiting_overdue" CHECK_WAITING_OVERDUE = "waiting_overdue"
@@ -91,18 +92,6 @@ def _load_kb_projects(kb_dir: Path) -> dict[str, _KbProject]:
return result return result
def _build_reader(cfg: tasks_config.TasksConfig) -> TaskReader:
"""Dispatch on `cfg.provider` - the same inline shape
`doctor.check_tasks_provider` uses, kept in sync with
`tasks_config.KNOWN_PROVIDERS`."""
if cfg.provider == "superproductivity":
from chemenu.tasks import superproductivity as sp
sp_cfg = sp.SuperProductivityConfig.from_dict(cfg.provider_config)
return sp.SuperProductivityReader(sp_cfg)
raise ValidationError(f"No reader is wired up for task provider {cfg.provider!r}.")
def _weeks_between(start: date, end: date) -> float: def _weeks_between(start: date, end: date) -> float:
return (end - start).days / 7 return (end - start).days / 7
@@ -136,7 +125,7 @@ def run_review(root: Path, *, today: Optional[date] = None) -> ReviewReport:
f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is " f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is "
"nothing to join the weekly review against. Configure one first." "nothing to join the weekly review against. Configure one first."
) )
reader = _build_reader(cfg) reader = build_reader(cfg)
kb_projects = _load_kb_projects(Path(root) / "kb") kb_projects = _load_kb_projects(Path(root) / "kb")
findings: list[Finding] = [] findings: list[Finding] = []
+36 -2
View File
@@ -4,9 +4,43 @@
loops (#119 D1). This package is the one place `wikitool` crosses that line - loops (#119 D1). This package is the one place `wikitool` crosses that line -
never an instruction, never a second MCP server (#119 D25): a provider is a never an instruction, never a second MCP server (#119 D25): a provider is a
Python object behind `chemenu.tasks.protocol.TaskReader`/`TaskWriter`, and Python object behind `chemenu.tasks.protocol.TaskReader`/`TaskWriter`, and
everything above this package (the future `wikitool review`/`new project` everything above this package (`wikitool review`/`new project`, #125/#126)
commands, #125/#126) talks to that protocol and nothing provider-specific. talks to that protocol and nothing provider-specific.
No command lives here yet - this package is a library, per #124's own scope No command lives here yet - this package is a library, per #124's own scope
note ("Kein Kommando. Diese Schicht ist Bibliothek"). note ("Kein Kommando. Diese Schicht ist Bibliothek").
`build_reader`/`build_writer` below are the one dispatch table from
`TasksConfig.provider` to a concrete adapter, shared by `chemenu.review`
(#125) and `chemenu.commands.new_page`'s `project` handling (#126) - kept in
one place per `AGENTS.md` invariant 8, rather than two copies of the same
`if cfg.provider == "superproductivity": ...` drifting apart.
""" """
from __future__ import annotations
from chemenu.errors import ValidationError
from chemenu.tasks.config import TasksConfig
from chemenu.tasks.protocol import TaskReader, TaskWriter
def build_reader(cfg: TasksConfig) -> TaskReader:
"""Dispatch on `cfg.provider` to a concrete `TaskReader`."""
if cfg.provider == "superproductivity":
from chemenu.tasks import superproductivity as sp
sp_cfg = sp.SuperProductivityConfig.from_dict(cfg.provider_config)
return sp.SuperProductivityReader(sp_cfg)
raise ValidationError(f"No reader is wired up for task provider {cfg.provider!r}.")
def build_writer(cfg: TasksConfig, reader: TaskReader) -> TaskWriter:
"""Dispatch on `cfg.provider` to a concrete `TaskWriter`, over an
already-built `reader` - a writer that needs to re-check the read path
(e.g. `SuperProductivityWriter`'s own collision preflight) reads through
the same object its caller does, rather than opening a second one."""
if cfg.provider == "superproductivity":
from chemenu.tasks import superproductivity as sp
sp_cfg = sp.SuperProductivityConfig.from_dict(cfg.provider_config)
return sp.SuperProductivityWriter(sp_cfg, reader)
raise ValidationError(f"No writer is wired up for task provider {cfg.provider!r}.")
+155
View File
@@ -1,3 +1,5 @@
import json
from chemenu.commands._util import coerce_set_value, parse_set_fields from chemenu.commands._util import coerce_set_value, parse_set_fields
from chemenu.commands.new_page import _page_subdir from chemenu.commands.new_page import _page_subdir
from chemenu.frontmatter_io import read_page from chemenu.frontmatter_io import read_page
@@ -6,6 +8,47 @@ from typer.testing import CliRunner
runner = CliRunner() runner = CliRunner()
_TASKS_THRESHOLDS = {
"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5,
}
def _write_tasks_config(root, db_path) -> None:
"""A minimal `.wikitool-tasks.json` pointing the superproductivity
provider at `db_path` - same shape `test_review.py`'s own helper writes,
duplicated locally rather than imported since test files in this suite
do not import each other (see `instructions/dev/testing-conventions.md`
for the isolation this mirrors one layer up)."""
(root / ".wikitool-tasks.json").write_text(
json.dumps({
"schema": 1, "provider": "superproductivity", "thresholds": _TASKS_THRESHOLDS,
"superproductivity": {"db_path": str(db_path)},
}),
encoding="utf-8",
)
def _write_sp_snapshot(root, project_titles: list[str]):
"""A Super Productivity backup snapshot whose `project` entity state
holds one project per title in `project_titles` and nothing else -
enough for `find_project`/`create_project`'s own preflight, which is all
`new project`'s tracker step reads or writes (it never calls
`open_items`/`someday_items`)."""
db_path = root / "db.json"
projects = {
f"p{i}": {"id": f"p{i}", "title": title, "created": 1700000000000}
for i, title in enumerate(project_titles)
}
db_path.write_text(
json.dumps({
"project": {"ids": list(projects), "entities": projects},
"task": {"ids": [], "entities": {}},
"tag": {"ids": [], "entities": {}},
}),
encoding="utf-8",
)
return db_path
def _invoke_new(monkeypatch, kb_dir, args): def _invoke_new(monkeypatch, kb_dir, args):
"""Invoke the CLI against a temporary fixture kb/. """Invoke the CLI against a temporary fixture kb/.
@@ -104,6 +147,118 @@ def test_new_project_refuses_a_responsibility_outside_the_enum(monkeypatch, kb_d
assert not list(kb_dir.rglob("Badvorhaben.md")) assert not list(kb_dir.rglob("Badvorhaben.md"))
# --- Gitea #126: `new project` and the task tracker --------------------------
def test_new_project_says_explicitly_when_no_tracker_is_configured(monkeypatch, kb_dir):
"""No `.wikitool-tasks.json` at all - the page-only state is legitimate
(#126's own AC) but must be said, not left implicit."""
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Ohne Tracker", "--set", "responsibility=haus",
])
assert result.exit_code == 0, result.output
assert "no task tracker configured" in result.output
assert (kb_dir / "gtd/haus/Ohne Tracker.md").exists()
def test_new_project_needs_clearance_when_the_tracker_has_no_write_path(monkeypatch, kb_dir):
"""Super Productivity can never create a project itself (Gitea #124) -
the first attempt against a free name must exit 42 and create nothing on
either side."""
root = kb_dir.parent
db_path = _write_sp_snapshot(root, [])
_write_tasks_config(root, db_path)
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
])
assert result.exit_code == 42
assert "NEEDS USER CLEARANCE" in result.output
assert "--resume" in result.output
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
def test_new_project_refuses_a_tracker_collision_without_resume(monkeypatch, kb_dir):
"""The tracker already has this name (case-insensitively) and --resume
was not passed - #126's AC: refuse, name the collision, create nothing."""
root = kb_dir.parent
db_path = _write_sp_snapshot(root, ["kueche renovieren"])
_write_tasks_config(root, db_path)
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
])
assert result.exit_code == 1
assert "already exists" in result.output
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
def test_new_project_resume_continues_past_an_existing_tracker_project(monkeypatch, kb_dir):
"""The retry half of the HumanInterventionRequired dance (#126's own
"Schritt 2"): once a human has created the tracker project by hand, a
re-run with --resume must verify it via the read path and continue to
page creation instead of treating it as a collision."""
root = kb_dir.parent
db_path = _write_sp_snapshot(root, ["Kueche renovieren"])
_write_tasks_config(root, db_path)
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
])
assert result.exit_code == 0, result.output
assert "already existed" in result.output
assert (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
def test_new_project_resume_still_refuses_when_the_human_has_not_acted_yet(monkeypatch, kb_dir):
"""--resume against a tracker that still does not have the project must
read the same as a fresh attempt - the same HumanInterventionRequired
message again, not a silent pass-through (#126's own wording: "wirft das
Kommando dieselbe HumanInterventionRequired-Meldung erneut")."""
root = kb_dir.parent
db_path = _write_sp_snapshot(root, [])
_write_tasks_config(root, db_path)
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
])
assert result.exit_code == 42
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
def test_resume_flag_is_refused_for_any_type_other_than_project(monkeypatch, kb_dir):
result = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", "Irrelevant", "--set", "entity_type=tool", "--resume",
])
assert result.exit_code == 1
assert "--resume" in result.output
assert not list(kb_dir.rglob("Irrelevant.md"))
def test_new_project_never_leaves_only_the_page_when_the_write_fails(monkeypatch, kb_dir):
"""#126's own atomicity AC, forced: step 3 (the file write) fails after
step 2 (the tracker side) already stands - here, an already-existing
tracker project confirmed via --resume. The outcome must never be "only
the page" - here it is neither, since the write itself never lands."""
import chemenu.commands.new_page as new_page
root = kb_dir.parent
db_path = _write_sp_snapshot(root, ["Kueche renovieren"])
_write_tasks_config(root, db_path)
def _boom(path, frontmatter, body):
raise OSError("disk full (fixture)")
monkeypatch.setattr(new_page, "write_page", _boom)
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
])
assert result.exit_code == 1
assert "already confirmed to exist" in " ".join(result.output.split())
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
def test_new_entity_still_materializes_empty_arrays_for_unset_optional_fields(monkeypatch, kb_dir): def test_new_entity_still_materializes_empty_arrays_for_unset_optional_fields(monkeypatch, kb_dir):
"""Gitea #109 stops materializing an optional field's schema `default:`, """Gitea #109 stops materializing an optional field's schema `default:`,
but `tags`/`related`/`sources` are optional arrays with no `default:` at but `tags`/`related`/`sources` are optional arrays with no `default:` at