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

+105 -3
View File
@@ -28,18 +28,29 @@ import re
import typer
from chemenu import config
from chemenu import config, tasks
from chemenu.commands._util import (
check_collision,
check_raw_files_exist,
fail,
needs_clearance,
parse_set_fields,
rel_path,
success,
)
from chemenu.errors import HumanInterventionRequired, ValidationError
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
# 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:
"""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))
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(
type_name: str = typer.Argument(
...,
@@ -269,6 +331,13 @@ def new_page_command(
"--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",
),
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.
@@ -277,12 +346,23 @@ def new_page_command(
(schema `default:`), where the page is written (`base_dir` + `layout`),
what prefixes its title (`title_prefix`), and its body skeleton (the
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)
if not type_path:
available = sorted(fm.get("name") for _, fm in resolver.list_type_specs())
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()
try:
@@ -356,5 +436,27 @@ def new_page_command(
},
)
write_page(path, frontmatter, body)
success(f"Created {rel_path(path)}")
# Everything above only validates - nothing has touched disk or the
# 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
one write `chemenu.tasks.protocol.TaskWriter` asks of it.
The CLI adapter (a future `wikitool new project`, Gitea #126) renders this
the same way it renders the four named gates in AGENTS.md's Gates section:
The CLI adapter (`wikitool new project`, Gitea #126) renders this the same
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
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
@@ -54,7 +54,11 @@ class HumanInterventionRequired(ChemenuError):
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
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):
+3 -14
View File
@@ -25,8 +25,9 @@ from typing import Optional
from chemenu import config, kb_scan
from chemenu.errors import ValidationError
from chemenu.tasks import build_reader
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_WAITING_OVERDUE = "waiting_overdue"
@@ -91,18 +92,6 @@ def _load_kb_projects(kb_dir: Path) -> dict[str, _KbProject]:
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:
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 "
"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")
findings: list[Finding] = []
+36 -2
View File
@@ -4,9 +4,43 @@
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
Python object behind `chemenu.tasks.protocol.TaskReader`/`TaskWriter`, and
everything above this package (the future `wikitool review`/`new project`
commands, #125/#126) talks to that protocol and nothing provider-specific.
everything above this package (`wikitool review`/`new project`, #125/#126)
talks to that protocol and nothing provider-specific.
No command lives here yet - this package is a library, per #124's own scope
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.new_page import _page_subdir
from chemenu.frontmatter_io import read_page
@@ -6,6 +8,47 @@ from typer.testing import 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):
"""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"))
# --- 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):
"""Gitea #109 stops materializing an optional field's schema `default:`,
but `tags`/`related`/`sources` are optional arrays with no `default:` at