task new: einen zweiten Schreibweg in den Tracker (ein Posten, keine Seite, #132)
CI / verify (push) Successful in 55s
Release / release (push) Successful in 38s

Files changed:
- CHANGES.md
- VERSION
- docs/knowledge-and-commitment.md
- instructions/wiki-ingest/SKILL.md
- tools/CONTRACT.md
- tools/chemenu/cli.py
- tools/chemenu/commands/task_cmd.py
- tools/chemenu/tasks/protocol.py
- tools/chemenu/tasks/superproductivity.py
- tools/chemenu/tests/test_instructions_cmd.py
- tools/chemenu/tests/test_superproductivity.py
- tools/chemenu/tests/test_task_cmd.py
This commit is contained in:
torben committed 2026-09-22 11:55:06 +02:00
1 parent e07d1ca42a
commit cfbe3ea83e
12 files changed
+845 -45

No files matched your search

+42
View File
@@ -164,6 +164,48 @@ class TaskWriter(Protocol):
"""
...
def create_item(
self,
title: str,
*,
project_name: Optional[str],
waiting: bool = False,
follow_up_at: Optional[date] = None,
notes: Optional[str] = None,
) -> None:
"""Create one open item - a tracker `Posten`, never a kb/ page
(Gitea #132 D1). `title` is stored verbatim, exactly like
`WaitingItem.title` - never parsed.
`project_name=None` is the caller's own explicit choice of the
tracker's inbox (#132 D4 "Weg 3"), never a stand-in for "no project
was given" - the CLI's own `--inbox` flag is the only thing allowed
to produce it; an omitted `--project` is refused before this is ever
called. A `project_name` that is given must already exist
(case-normalized, #119 D8) - this never creates a project itself and
never searches or guesses one (#132 D6): `chemenu.errors.ValidationError`
if no such project exists.
`waiting`/`follow_up_at` set #119's own WAITING/`follow_up_at` pair
(D9/D30) - the same two machine-readable parts `WaitingItem` reads
back. Raises `ValidationError` if the provider can represent items at
all (it offers `TaskWriter`) but has no way to mark one WAITING right
now - e.g. Super Productivity's `waiting` tag does not exist yet and
tags cannot be created via its API (#132's own verified constraint):
an item is never created *without* the status it was asked for.
`notes` carries D5's freetext backref to a kb/ page - stored
verbatim, never parsed, exactly the posture `WaitingItem.title`
already has for the person named in it.
Unlike `create_project`, this never raises
`chemenu.errors.HumanInterventionRequired`: every provider offering
`TaskWriter` at all has been verified to have a real item-creation
call (#132 - the gap `create_project` hits, no project-creation
endpoint, does not exist on the item side).
"""
...
def find_project(reader: TaskReader, name: str) -> Optional[ProjectSummary]:
"""The project matching `name` case-normalized (#119 D8), or `None`.
+115 -17
View File
@@ -108,6 +108,19 @@ from chemenu.tasks.protocol import (
# - this instance's own convention, not something Super Productivity defines.
WAITING_TAG_TITLE = "waiting"
# Super Productivity's own inbox project id, verified against
# `project.const.ts`/`project.selectors.ts` on `master` (Gitea #132, 2026-09-20):
# a real project entity the store adds to itself if missing
# (`_addInboxProjectIfNecessary`), but `selectUnarchivedProjects` filters it out
# unconditionally by this exact id - so it never appears in `GET /projects`
# (nor in the snapshot path's own `project` entity state, which mirrors that
# filtering, module docstring). `create_item`'s `--inbox` route is the only
# place this module ever writes it; because of the same filter, an item filed
# there is invisible to every `chemenu.review` check that walks
# `TaskReader.projects()` - "Inbox" never appears as a project name to join
# against, not merely one this instance chooses to exclude.
INBOX_PROJECT_ID = "INBOX_PROJECT"
DEFAULT_API_BASE_URL = "http://127.0.0.1:3876"
ACCESS_API = "api"
@@ -391,29 +404,42 @@ def _expect_list(value: Any, what: str) -> list[dict]:
class _ApiClient:
"""The one HTTP transport `SuperProductivityApiReader` uses - a thin,
loudly-failing wrapper, not a general REST client."""
"""The one HTTP transport `SuperProductivityApiReader`/`SuperProductivityWriter`
use - a thin, loudly-failing wrapper, not a general REST client. `get` and
`post` (Gitea #132) share one request/error path, so a shape drift or a
new failure mode only needs handling once."""
def __init__(self, cfg: SuperProductivityConfig):
self._cfg = cfg
def get(self, path: str, *, timeout: float = 10.0) -> Any:
return self._request("GET", path, timeout=timeout)
def post(self, path: str, body: dict, *, timeout: float = 10.0) -> Any:
return self._request("POST", path, body=body, timeout=timeout)
def _request(
self, method: str, path: str, *, body: Optional[dict] = None, timeout: float = 10.0
) -> Any:
url = self._cfg.api_base_url.rstrip("/") + path
request = urllib.request.Request(
url, headers={"Authorization": f"Bearer {self._cfg.api_token}"}
)
headers = {"Authorization": f"Bearer {self._cfg.api_token}"}
data = None
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
request = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
body = response.read()
response_body = response.read()
except urllib.error.HTTPError as exc:
if exc.code == 503:
raise ValidationError(
"superproductivity: API answered 503 APP_NOT_READY for "
f"{path} - the app's backend is up but its renderer is not ready yet. "
"Wait a moment and retry."
f"superproductivity: API answered 503 APP_NOT_READY for "
f"{method} {path} - the app's backend is up but its renderer is not ready "
"yet. Wait a moment and retry."
) from exc
raise ValidationError(
f"superproductivity: API returned HTTP {exc.code} for {path}."
f"superproductivity: API returned HTTP {exc.code} for {method} {path}."
) from exc
except (urllib.error.URLError, OSError) as exc:
raise ValidationError(
@@ -421,10 +447,10 @@ class _ApiClient:
"Is Super Productivity running?"
) from exc
try:
return json.loads(body)
return json.loads(response_body)
except json.JSONDecodeError as exc:
raise ValidationError(
f"superproductivity: API returned unparseable JSON for {path}."
f"superproductivity: API returned unparseable JSON for {method} {path}."
) from exc
@@ -507,15 +533,17 @@ class SuperProductivityApiReader:
class SuperProductivityWriter:
"""`TaskWriter` over the local REST API - except there is no API call
this can actually make, see the module docstring. Only offered by
`chemenu.tasks.build_writer` when `access: "api"` (Gitea #133) - on
`access: "snapshot"` the tracker is read-only from here, and that refusal
happens before this class is ever constructed."""
"""`TaskWriter` over the local REST API. `create_project` never actually
creates anything - see the module docstring; `create_item` (Gitea #132)
does, since `POST /tasks` exists where `POST /projects` does not. Only
offered by `chemenu.tasks.build_writer` when `access: "api"` (Gitea #133)
- on `access: "snapshot"` the tracker is read-only from here, and that
refusal happens before this class is ever constructed."""
def __init__(self, cfg: SuperProductivityConfig, reader):
self._cfg = cfg
self._reader = reader
self._client = _ApiClient(cfg)
def create_project(self, name: str) -> None:
"""Never creates anything. Preflights the name against the read path
@@ -542,3 +570,73 @@ class SuperProductivityWriter:
" 3. Tell the agent you have done this, so it can re-check and continue.",
verify=_verify,
)
def create_item(
self,
title: str,
*,
project_name: Optional[str],
waiting: bool = False,
follow_up_at: Optional[date] = None,
notes: Optional[str] = None,
) -> None:
"""`POST /tasks` (Gitea #132) - the endpoint `create_project` cannot
reach an equivalent of. Resolves every precondition (the target
project's own id, the `waiting` tag's own id) before making the one
write, so a missing precondition never leaves behind a half-written
item - no task without the WAITING status it was asked for."""
if project_name is None:
project_id = INBOX_PROJECT_ID
else:
if find_project(self._reader, project_name) is None:
raise ValidationError(
f"No project named '{project_name}' (case-insensitively) exists in Super "
"Productivity - this command does not create one (Gitea #132 D6). Run "
"`wikitool new project` first, or pass --inbox."
)
project_id = self._project_id(project_name)
body: dict[str, Any] = {"title": title, "projectId": project_id}
if notes:
body["notes"] = notes
if waiting:
body["tagIds"] = [self._waiting_tag_id()]
if follow_up_at is not None:
body["dueDay"] = follow_up_at.isoformat()
self._client.post("/tasks", body)
def _project_id(self, project_name: str) -> str:
"""Super Productivity's own id for `project_name`, read fresh from the
API. `ProjectSummary` (the protocol-level read shape every provider
shares) deliberately carries no id - not every provider has one - so
a writer that needs one reads it itself here rather than the generic
read path growing an SP-specific field for this one caller."""
target = normalize_project_name(project_name)
for record in _expect_list(self._client.get("/projects"), "/projects"):
if normalize_project_name(str(record.get("title", ""))) == target:
project_id = record.get("id")
if isinstance(project_id, str) and project_id:
return project_id
raise ValidationError(
f"superproductivity: project '{project_name}' matched the read path moments ago but "
"its API record now has no usable id - the response shape does not match what this "
"adapter expects."
)
def _waiting_tag_id(self) -> str:
"""The `waiting` tag's own id, or a loud refusal (Gitea #132's own
acceptance criterion): tags cannot be created via this API (only
`GET /tags` exists, module docstring), so a WAITING item is never
created without its status - the precondition is checked before
`POST /tasks` is ever called, not patched up after."""
for record in _expect_list(self._client.get("/tags"), "/tags"):
if str(record.get("title", "")).strip().casefold() == WAITING_TAG_TITLE:
tag_id = record.get("id")
if isinstance(tag_id, str) and tag_id:
return tag_id
raise ValidationError(
f"superproductivity: no tag named '{WAITING_TAG_TITLE}' exists - tags cannot be "
"created via the API (only GET /tags, Gitea #132). Create it in Super Productivity "
"first, then retry."
)