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
+839 -39

No files matched your search

+35 -1
View File
@@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse.
--- ---
## 7.0.0-beta.11 - 2026-09-20 - SP-Zugriffsweg explizit (access: api/snapshot, #133) und follow_up_at-Korrektur (dueWithTime/dueDay, #135) ## 7.0.0-beta.12 - 2026-09-20 - task new: einen zweiten Schreibweg in den Tracker (ein Posten, keine Seite)
**Author:** Torben Nehmer **Author:** Torben Nehmer
@@ -80,6 +80,7 @@ concern - readable here, never shipped as something to parse.
- wikitool new project: Seite und Tracker-Projekt unter einem Namen - wikitool new project: Seite und Tracker-Projekt unter einem Namen
- Skill weekly-review: turning wikitool review's findings into decisions - Skill weekly-review: turning wikitool review's findings into decisions
- Doku-Nachzug zu #119: die Verpflichtungsschicht erreicht Installation, Setup und docs/ - Doku-Nachzug zu #119: die Verpflichtungsschicht erreicht Installation, Setup und docs/
- task new: einen zweiten Schreibweg in den Tracker (ein Posten, keine Seite)
**Low impact** **Low impact**
- new project: Testabdeckung fuer die required-responsibility-Ablehnung - new project: Testabdeckung fuer die required-responsibility-Ablehnung
@@ -365,6 +366,39 @@ Tickler (`dueDay` ohne `dueWithTime`, das haeufigste WAITING-Muster) hatte dadur
in der Sache unveraendert, nur die falsche Berufung auf sie ist korrigiert). Bestehende Instanzen in der Sache unveraendert, nur die falsche Berufung auf sie ist korrigiert). Bestehende Instanzen
sehen dadurch rueckblickend mehr Befunde, nicht weniger. sehen dadurch rueckblickend mehr Befunde, nicht weniger.
### task new: einen zweiten Schreibweg in den Tracker (ein Posten, keine Seite)
Gitea #132: eine Quelle kann Wissen und eine Verpflichtung zugleich tragen (eine Kundenreklamation
etwa), und bislang hatte nur die Wissenshaelfte einen Schreibweg. `chemenu.tasks.protocol.TaskWriter`
traegt jetzt eine zweite Methode, `create_item` - Projekt, Titel, optional `WAITING` mit
`follow_up_at`, optional ein Freitext-Rueckverweis in `notes` -, darueber das neue Kommando
`wikitool task new`. Anders als `create_project` schreibt sie tatsaechlich: bei Super Productivity
existiert `POST /tasks`, wo `POST /projects` fehlt, also gibt es hier keinen
`HumanInterventionRequired`-Fall. Das Kommando ordnet kein Projekt selbst zu - ein `--project`, das
zu keinem Tracker-Projekt passt, oder `--waiting` ohne vorhandenen `waiting`-Tag scheitert laut,
exit 1, statt zu raten oder einen Posten ohne seinen Status anzulegen. Die Eingangs-Ablage
(`--inbox`) ist eine eigene, ausdrueckliche Form am Kommando, nie ein Ersatz fuer ein vergessenes
`--project`.
Verifiziert gegen `super-productivity/super-productivity@master` (2026-09-20): Super Productivitys
`INBOX_PROJECT` ist zwar immer ein echtes Projekt-Entity im Store, aber
`selectUnarchivedProjects` - der Selektor hinter `GET /projects` - filtert es ueber seine feste id
unbedingt heraus. Ein per `--inbox` abgelegter Posten erscheint deshalb in keiner
`wikitool review`-Pruefung, nicht weil eine Ausnahme dafuer noetig waere, sondern weil der Eingang
in der Projektliste schlicht nie auftaucht - der Ingest-Skill nennt diese Kosten jetzt ausdruecklich,
wenn er die Route anbietet.
Der Ingest-Skill (`instructions/wiki-ingest/SKILL.md`) fragt in Schritt 5 jetzt auch nach einer
Verpflichtung, nicht nur nach dem Wissen, und legt Titel und vorgeschlagenes Projekt in einem
Bestaetigungsschritt vor (nie eine automatische Zuordnung, auch nicht bei einem eindeutigen
`search`-Treffer). Der Posten wird vor der Quellenseite angelegt - dieselbe Tracker-vor-Seite-
Reihenfolge, die `new project` schon haelt, hier mit eigenem Beleg: eine Rohdatei ohne Quellenseite
meldet `lint` als `uncovered_raw_files`, eine stillschweigend verlorene Verpflichtung meldet
nichts. Der bestehende Regressionstest, der sicherstellt, dass kein Skill den Tracker-Provider
nennt, ist entsprechend auf `wiki-ingest` erweitert. `docs/knowledge-and-commitment.md` und
`tools/CONTRACT.md` sind nachgezogen; Gitea #128 (der zweite Adapter) traegt jetzt `create_item`
in seiner eigenen Flaeche.
--- ---
## 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.11 7.0.0-beta.12
+13 -3
View File
@@ -5,7 +5,7 @@ Those look like one subject - both are "things about my projects" - and the stac
keeps them apart: `kb/gtd/` holds one page per initiative, an external task tracker holds the keeps them apart: `kb/gtd/` holds one page per initiative, an external task tracker holds the
open items, and the only thing that crosses between them is a name. This page is about why that open items, and the only thing that crosses between them is a name. This page is about why that
line was drawn there. The rules that follow from it live in [kb/CONTRACT.md](../kb/CONTRACT.md) line was drawn there. The rules that follow from it live in [kb/CONTRACT.md](../kb/CONTRACT.md)
and the `review` and `new project` rows of [tools/CONTRACT.md](../tools/CONTRACT.md). and the `review`, `new project` and `task new` rows of [tools/CONTRACT.md](../tools/CONTRACT.md).
<!-- wikitool:toc --> <!-- wikitool:toc -->
## Contents ## Contents
@@ -93,8 +93,18 @@ open right now. Anyone wanting the second reads the tracker, or runs the review.
This pays for itself somewhere unexpected: with the page carrying no task state, an agent has no This pays for itself somewhere unexpected: with the page carrying no task state, an agent has no
reason to read the task list at all outside the weekly review. That is what keeps the command reason to read the task list at all outside the weekly review. That is what keeps the command
surface as small as it is - one read command and one creation command - rather than growing a surface as small as it is - one read command and two creation commands - rather than growing a
full CRUD tree over somebody's todo list. full CRUD tree over somebody's todo list. The second creation command exists because a single
name is not always the whole story: a source can carry a piece of durable knowledge and a
commitment to follow up on it at the same time - a complaint arriving by email is both something
to file and something to chase - and the tracker-side half of that needs its own write path
alongside `new project`'s pairing of a page with a tracker project. `task new` creates only the
tracker item, never a page; a source that also carries knowledge gets that knowledge filed
through the ordinary page-creation commands, as a separate step. The two are never one
transaction the way `new project`'s tracker-then-page order is within a single command - they are
two independent writes a skill sequences, tracker first, so a failure creating the item leaves no
page and no promoted source material behind it, and a failure on the knowledge side afterwards is
exactly the ordinary "a source without a page" state `lint` already reports.
## A finished initiative is a state, not a location ## A finished initiative is a state, not a location
+59 -6
View File
@@ -1,6 +1,6 @@
--- ---
name: wiki-ingest name: wiki-ingest
description: Processes a new source file into the LLM wiki - extracts entities and concepts, creates a source summary page, cross-references, rebuilds indexes, and publishes. Use when the user drops a file into incoming/ or raw/, or says "ingest <file>", "process this source", "add this to the wiki". description: Processes a new source file into the LLM wiki - extracts entities and concepts, creates a source summary page, files a tracker item for any commitment the source also carries, cross-references, rebuilds indexes, and publishes. Use when the user drops a file into incoming/ or raw/, or says "ingest <file>", "process this source", "add this to the wiki".
--- ---
# Wiki Ingest # Wiki Ingest
@@ -27,7 +27,7 @@ validator complains - and the ticked list is the only record that they happened.
- [ ] 2. Read the source - [ ] 2. Read the source
- [ ] 3. Extract metadata - [ ] 3. Extract metadata
- [ ] 4. Check what the wiki already knows - [ ] 4. Check what the wiki already knows
- [ ] 5. Discuss with the user - [ ] 5. Discuss with the user (content and any commitment); create the commitment if confirmed
- [ ] 6. Create the source page (incl. `## Not Extracted`) - [ ] 6. Create the source page (incl. `## Not Extracted`)
- [ ] 7. Create or update entity pages - [ ] 7. Create or update entity pages
- [ ] 8. Create or update concept pages - [ ] 8. Create or update concept pages
@@ -104,7 +104,53 @@ validator complains - and the ticked list is the only record that they happened.
is exempt from the iteration budget, so ask about every subject rather than guessing. is exempt from the iteration budget, so ask about every subject rather than guessing.
5. **Discuss with the user.** Present the key takeaways and ask: which points matter most, 5. **Discuss with the user.** Present the key takeaways and ask: which points matter most,
which entities/concepts to create or update, any specific emphasis. which entities/concepts to create or update, any specific emphasis - **and whether this
source also carries a commitment**, something to follow up on rather than only record. A
customer complaint, a meeting note with an action item, an offer awaiting a reply: the
knowledge side (steps 6-9 below) and the commitment side are not exclusive, and most
external sources that are not pure reading material carry both.
Whether a source is actionable at all, and what its next step is, is the user's call - GTD's
own *Clarify* - never a guess from the source's wording alone. Do not create an item on your
own initiative; propose one and let the user confirm or correct it.
**If a commitment is confirmed, resolve its project and create the item before continuing to
step 6** - the tracker side settles first, the same order `new project` already holds between
a tracker project and its page, so a failure creating the item leaves nothing on the knowledge
side to clean up. Search for a likely project rather than asking cold:
```bash
tools/wikitool search "<likely project name>"
```
Then put title and project to the user as **one** combined question - "Create '<title>' in
project '<name>'?" - never as two separate ones and never as a foregone conclusion. The answer
is one of:
- the suggested project, confirmed as-is;
- a different existing project the user names instead;
- `wikitool new project` first, if no project fits yet - this itself needs a human's
out-of-band step on some providers, so expect to pause there before continuing;
- the tracker's own inbox, an explicit, deliberately chosen exit for when nothing above
fits - never a default for an unresolved project, and worth naming its cost when you offer
it: an item filed there will not appear in `wikitool review`, since every one of its checks
is reached through a project name and the inbox carries none.
Once resolved:
```bash
tools/wikitool task new --title "<confirmed title>" --project "<confirmed project>" \
[--waiting --follow-up-at YYYY-MM-DD] [--notes "Source - <Title>"]
# or, for the inbox route:
tools/wikitool task new --title "<confirmed title>" --inbox
```
`--notes` can point back at the source page step 6 is about to create, even though that page
does not exist yet at this moment - it is freetext, never resolved or validated against an
actual page.
No commitment in this source? Skip straight to step 6 - the knowledge side runs on its own
exactly as before.
6. **Create the source page.** Read 6. **Create the source page.** Read
`kb/sources/COLLECTION.md` first - it holds what this `kb/sources/COLLECTION.md` first - it holds what this
@@ -228,6 +274,13 @@ validator complains - and the ticked list is the only record that they happened.
- **Subject already has a page?** Update it (step 7, `touch`) instead of creating a second one. - **Subject already has a page?** Update it (step 7, `touch`) instead of creating a second one.
Two pages on one subject is the failure this step exists to prevent. Two pages on one subject is the failure this step exists to prevent.
- **Unsure whether a source is actionable at all?** Ask - never guess. A commitment nobody
actually made is worse than one that was missed: it looks like a real open item in every
later review, and nobody agreed to it. Skipping the item is always the safer default when in
doubt.
- **No project fits the commitment, and none should be created either?** File it into the
tracker's inbox rather than forcing a project choice - see step 5's own three-way choice. Name
the cost (invisible to `wikitool review`) before the user picks it.
- **One source names far more subjects than usual?** That is breadth, not volume. It is not - **One source names far more subjects than usual?** That is breadth, not volume. It is not
split into several sources - it cannot be - and it does not get a page per name either: split into several sources - it cannot be - and it does not get a page per name either:
`instructions/ingest-large-tree.md` § A broad source is not cut. `instructions/ingest-large-tree.md` § A broad source is not cut.
@@ -243,9 +296,9 @@ validator complains - and the ticked list is the only record that they happened.
## wikitool commands used ## wikitool commands used
`raw accept`, `search`, `types describe`, `new source`, `new entity`, `new concept`, `touch`, `raw accept`, `search`, `types describe`, `task new`, `new project`, `new source`, `new entity`,
`cite add`, `xref add`, `xref link-source`, `sources coverage`, `sources rebuild-index`, `new concept`, `touch`, `cite add`, `xref add`, `xref link-source`, `sources coverage`,
`index rebuild`, `log append`, `log status`, `publish` `sources rebuild-index`, `index rebuild`, `log append`, `log status`, `publish`
## Output ## Output
+2
View File
@@ -95,6 +95,7 @@ tools/wikitool <command> --help
| `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 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 | | `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 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 |
| `task new --title "<Title>" (--project "<Name>" \| --inbox) [--waiting [--follow-up-at YYYY-MM-DD]] [--notes "..."]` | Create one open item in the configured task tracker - never a kb/ page. 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 |
| `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 |
@@ -311,6 +312,7 @@ is atomic, and whether a retry is safe.
|---------|--------------|---------|--------------| |---------|--------------|---------|--------------|
| `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), `--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"`) | **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, 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: the 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 | | `new project` | Everything `new <type>` covers, **plus**: the name is already taken in the tracker (case-insensitively), `--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"`) | **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, 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: the 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 |
| `task new` | 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"`) | Yes - a single API call, made only once every precondition (the project's own id, the WAITING tag's own id) is confirmed to exist, so a missing one never leaves a half-written item behind | 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 |
| `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 |
+2
View File
@@ -32,6 +32,7 @@ try:
review_cmd, review_cmd,
run_budget, run_budget,
search as search_module, search as search_module,
task_cmd,
touch as touch_module, touch as touch_module,
types_cmd, types_cmd,
upload_cmd, upload_cmd,
@@ -152,6 +153,7 @@ app.add_typer(dist_cmd.app, name="dist")
app.add_typer(version_cmd.app, name="version") app.add_typer(version_cmd.app, name="version")
app.add_typer(migrate_cmd.app, name="migrate") app.add_typer(migrate_cmd.app, name="migrate")
app.add_typer(upstream_cmd.app, name="upstream") app.add_typer(upstream_cmd.app, name="upstream")
app.add_typer(task_cmd.app, name="task")
app.command("new")(new_page.new_page_command) app.command("new")(new_page.new_page_command)
app.command("touch")(touch_module.touch_command) app.command("touch")(touch_module.touch_command)
app.command("rename")(page_ops.rename_command) app.command("rename")(page_ops.rename_command)
+123
View File
@@ -0,0 +1,123 @@
"""`wikitool task new` - create a tracker item, no kb/ page (Gitea #132, #119
D1/D2/D4/D5/D6/D9).
The second write path into the task tracker, alongside `new project`'s own
(`chemenu.commands.new_page._ensure_tracker_project`) - and the last one that
pairing needed, per `docs/knowledge-and-commitment.md`. Unlike `new project`
this never touches `kb/`: an ingest that finds both knowledge and a
commitment in one source runs this command for the commitment and the normal
page-creation commands (`new source`, ...) for the knowledge, as two
independent steps a skill sequences - never as one transaction, because
nothing here shares state with the page-creation path the way `new project`'s
own tracker-then-page order does within a single command.
This module owns only the CLI shape - parsing, the `--project`/`--inbox`
exclusivity (#132 D4), and the `--follow-up-at` date. The one write itself is
`chemenu.tasks.protocol.TaskWriter.create_item`, dispatched through
`chemenu.tasks.build_writer` exactly like `new project` does.
"""
from __future__ import annotations
import datetime
from typing import Optional
import typer
from chemenu import config, tasks
from chemenu.commands._util import fail, success
from chemenu.errors import ValidationError
from chemenu.tasks import config as tasks_config
app = typer.Typer(
help="Create an item in the task tracker (Gitea #132) - never a kb/ page, "
"see `new project` for that pairing."
)
def _parse_follow_up_at(text: str) -> datetime.date:
try:
return datetime.date.fromisoformat(text)
except ValueError:
fail(f"--follow-up-at {text!r} must be YYYY-MM-DD.")
@app.command("new")
def task_new_command(
title: str = typer.Option(..., "--title", help="The item's title. Stored verbatim, never parsed."),
project: Optional[str] = typer.Option(
None,
"--project",
help="An existing tracker project's name (matched case-insensitively). This command "
"never searches or guesses one (Gitea #132 D6) and never creates one - use "
"`wikitool new project` first if it does not exist yet. Exactly one of --project/--inbox "
"is required.",
),
inbox: bool = typer.Option(
False,
"--inbox",
help="File into the tracker's own inbox instead of a project (Gitea #132 D4 'Weg 3') - "
"the deliberately chosen exit when no project fits, never a stand-in for an omitted "
"--project. An item filed here is invisible to `wikitool review`, since every check "
"there is reached through a project name and the inbox has none.",
),
waiting: bool = typer.Option(
False, "--waiting", help="Tag the item WAITING (#119 D9/D30) - the review's check 2 reads this."
),
follow_up_at: Optional[str] = typer.Option(
None,
"--follow-up-at",
help="YYYY-MM-DD. Only meaningful together with --waiting - it is never a due date "
"(#119 D9) and is refused without --waiting.",
),
notes: Optional[str] = typer.Option(
None,
"--notes",
help="A freetext backref, e.g. to the kb/ source page this item came from (Gitea #132 "
"D5). Stored verbatim, never parsed.",
),
):
"""Create one open item in the configured task tracker - no kb/ page.
Tracker-only by design (#132 D1): a source that carries both knowledge
and a commitment gets this command for the commitment and the normal
page-creation commands for the knowledge, run as two separate steps by
the calling skill - see `docs/knowledge-and-commitment.md`.
"""
if bool(project) == inbox:
fail(
"Exactly one of --project <name> or --inbox is required (Gitea #132 D4) - a missing "
"--project is a mistake, not a request for the tracker's inbox."
)
if follow_up_at is not None and not waiting:
fail(
"--follow-up-at only makes sense together with --waiting (#119 D9) - follow_up_at is "
"never a due date on its own."
)
follow_up_date = _parse_follow_up_at(follow_up_at) if follow_up_at is not None else None
cfg = tasks_config.read_config(config.ROOT)
if cfg is None:
fail(
f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is "
"nowhere to create this item. Configure one first."
)
reader = tasks.build_reader(cfg)
try:
writer = tasks.build_writer(cfg, reader)
except ValidationError as exc:
fail(str(exc))
try:
writer.create_item(
title,
project_name=(None if inbox else project),
waiting=waiting,
follow_up_at=follow_up_date,
notes=notes,
)
except ValidationError as exc:
fail(str(exc))
where = "the tracker's inbox" if inbox else f"project '{project}'"
success(f"Created '{title}' in {where}.")
+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]: def find_project(reader: TaskReader, name: str) -> Optional[ProjectSummary]:
"""The project matching `name` case-normalized (#119 D8), or `None`. """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. # - this instance's own convention, not something Super Productivity defines.
WAITING_TAG_TITLE = "waiting" 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" DEFAULT_API_BASE_URL = "http://127.0.0.1:3876"
ACCESS_API = "api" ACCESS_API = "api"
@@ -391,29 +404,42 @@ def _expect_list(value: Any, what: str) -> list[dict]:
class _ApiClient: class _ApiClient:
"""The one HTTP transport `SuperProductivityApiReader` uses - a thin, """The one HTTP transport `SuperProductivityApiReader`/`SuperProductivityWriter`
loudly-failing wrapper, not a general REST client.""" 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): def __init__(self, cfg: SuperProductivityConfig):
self._cfg = cfg self._cfg = cfg
def get(self, path: str, *, timeout: float = 10.0) -> Any: 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 url = self._cfg.api_base_url.rstrip("/") + path
request = urllib.request.Request( headers = {"Authorization": f"Bearer {self._cfg.api_token}"}
url, 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: try:
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
body = response.read() response_body = response.read()
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
if exc.code == 503: if exc.code == 503:
raise ValidationError( raise ValidationError(
"superproductivity: API answered 503 APP_NOT_READY for " f"superproductivity: API answered 503 APP_NOT_READY for "
f"{path} - the app's backend is up but its renderer is not ready yet. " f"{method} {path} - the app's backend is up but its renderer is not ready "
"Wait a moment and retry." "yet. Wait a moment and retry."
) from exc ) from exc
raise ValidationError( raise ValidationError(
f"superproductivity: API returned HTTP {exc.code} for {path}." f"superproductivity: API returned HTTP {exc.code} for {method} {path}."
) from exc ) from exc
except (urllib.error.URLError, OSError) as exc: except (urllib.error.URLError, OSError) as exc:
raise ValidationError( raise ValidationError(
@@ -421,10 +447,10 @@ class _ApiClient:
"Is Super Productivity running?" "Is Super Productivity running?"
) from exc ) from exc
try: try:
return json.loads(body) return json.loads(response_body)
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
raise ValidationError( raise ValidationError(
f"superproductivity: API returned unparseable JSON for {path}." f"superproductivity: API returned unparseable JSON for {method} {path}."
) from exc ) from exc
@@ -507,15 +533,17 @@ class SuperProductivityApiReader:
class SuperProductivityWriter: class SuperProductivityWriter:
"""`TaskWriter` over the local REST API - except there is no API call """`TaskWriter` over the local REST API. `create_project` never actually
this can actually make, see the module docstring. Only offered by creates anything - see the module docstring; `create_item` (Gitea #132)
`chemenu.tasks.build_writer` when `access: "api"` (Gitea #133) - on does, since `POST /tasks` exists where `POST /projects` does not. Only
`access: "snapshot"` the tracker is read-only from here, and that refusal offered by `chemenu.tasks.build_writer` when `access: "api"` (Gitea #133)
happens before this class is ever constructed.""" - 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): def __init__(self, cfg: SuperProductivityConfig, reader):
self._cfg = cfg self._cfg = cfg
self._reader = reader self._reader = reader
self._client = _ApiClient(cfg)
def create_project(self, name: str) -> None: def create_project(self, name: str) -> None:
"""Never creates anything. Preflights the name against the read path """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.", " 3. Tell the agent you have done this, so it can re-check and continue.",
verify=_verify, 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."
)
+16 -11
View File
@@ -79,23 +79,28 @@ def test_the_real_repo_publishes_every_skill():
} <= names } <= names
def test_gtd_weekly_review_skill_names_no_provider(): _NO_PROVIDER_FORBIDDEN_TERMS = [
"""#119 D25/#127 AC1: the skill is the one document meant to read
identically in every instance, whichever task-tracker provider it runs
against - so its body must name none of them, and none of a provider's
file or API shape either. Reads the real repo's file, not a fixture,
because the claim is about what ships, not about the discovery logic."""
text = (config.INSTRUCTIONS_DIR / "gtd-weekly-review" / "SKILL.md").read_text(encoding="utf-8").lower()
forbidden = [
"super productivity", "super productivity",
"azure devops", "azure devops",
"superproductivity", "superproductivity",
"db.json", "db.json",
"rest api", "rest api",
".wikitool-tasks.json", ".wikitool-tasks.json",
] ]
hits = [term for term in forbidden if term in text]
assert not hits, f"gtd-weekly-review/SKILL.md names a provider or its shape: {hits}"
@pytest.mark.parametrize("skill_name", ["gtd-weekly-review", "wiki-ingest"])
def test_task_tracker_skills_name_no_provider(skill_name):
"""#119 D25/#127 AC1, extended by #132: any skill that can reach the task
tracker - the weekly review, and now the ingest skill's own commitment
step (`task new`) - is meant to read identically in every instance,
whichever provider it runs against, so its body must name none of them,
and none of a provider's file or API shape either. Reads the real repo's
files, not a fixture, because the claim is about what ships, not about
the discovery logic."""
text = (config.INSTRUCTIONS_DIR / skill_name / "SKILL.md").read_text(encoding="utf-8").lower()
hits = [term for term in _NO_PROVIDER_FORBIDDEN_TERMS if term in text]
assert not hits, f"{skill_name}/SKILL.md names a provider or its shape: {hits}"
def test_instructions_dev_flat_file_is_discovered(layer): def test_instructions_dev_flat_file_is_discovered(layer):
@@ -563,6 +563,157 @@ def test_api_reader_source_is_live_and_needs_no_network():
assert source.kind == sp.ACCESS_API assert source.kind == sp.ACCESS_API
# --- write path: create_item (Gitea #132) --------------------------------------
def _make_write_handler(state: dict, *, token: str = "test-token"):
"""A stub API supporting `GET /projects`, `GET /tags` and `POST /tasks`
only - the three routes `create_item` ever touches. `state["posted"]`
collects every request body `POST /tasks` received, so a test can assert
on the exact fields sent (or that nothing was sent at all)."""
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802
if self.path == "/health":
self._reply(200, {"ok": True})
return
if self.headers.get("Authorization") != f"Bearer {token}":
self._reply(401, {"error": "unauthorized"})
return
if self.path == "/projects":
self._reply(200, state.get("projects", []))
return
if self.path == "/tags":
self._reply(200, state.get("tags", []))
return
if self.path == "/tasks":
# `find_project`'s preflight goes through the full
# `SuperProductivityApiReader`, which always reads all three
# routes (module docstring) - `create_item` itself never
# reads this one.
self._reply(200, state.get("tasks", []))
return
self._reply(404, {"error": "not found"})
def do_POST(self): # noqa: N802
if self.headers.get("Authorization") != f"Bearer {token}":
self._reply(401, {"error": "unauthorized"})
return
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length)) if length else {}
if self.path == "/tasks":
state.setdefault("posted", []).append(payload)
self._reply(201, {"id": "new-task", **payload})
return
self._reply(404, {"error": "not found"})
def _reply(self, code: int, payload) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
def log_message(self, *args): # silence stderr noise during the test run
pass
return Handler
@contextlib.contextmanager
def _write_api_server(state: dict, *, token: str = "test-token"):
handler_cls = _make_write_handler(state, token=token)
server = http.server.HTTPServer(("127.0.0.1", 0), handler_cls)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
thread.join(timeout=2)
def _writer_for(server: http.server.HTTPServer, *, token: str = "test-token") -> sp.SuperProductivityWriter:
cfg_ = _api_cfg(server, token=token)
reader = sp.SuperProductivityApiReader(cfg_)
return sp.SuperProductivityWriter(cfg_, reader)
def test_create_item_posts_project_id_resolved_from_the_read_path():
state = {"projects": [
{"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []},
]}
with _write_api_server(state) as server:
writer = _writer_for(server)
writer.create_item("Rueckruf beim Kunden", project_name="ship CHEMENU 7.0")
assert len(state["posted"]) == 1
assert state["posted"][0] == {"title": "Rueckruf beim Kunden", "projectId": "p1"}
def test_create_item_refuses_an_unknown_project_and_posts_nothing():
state = {"projects": []}
with _write_api_server(state) as server:
writer = _writer_for(server)
with pytest.raises(ValidationError, match="No project named"):
writer.create_item("x", project_name="No Such Project")
assert "posted" not in state
def test_create_item_inbox_route_uses_the_fixed_inbox_project_id():
state = {"projects": []}
with _write_api_server(state) as server:
writer = _writer_for(server)
writer.create_item("Beleg ablegen", project_name=None)
assert state["posted"][0]["projectId"] == sp.INBOX_PROJECT_ID
def test_create_item_sets_the_waiting_tag_and_due_day():
state = {
"projects": [{"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}],
"tags": [{"id": "tag-wait", "title": "Waiting"}],
}
with _write_api_server(state) as server:
writer = _writer_for(server)
writer.create_item(
"Nachfassen beim Elektriker", project_name="Ship Chemenu 7.0",
waiting=True, follow_up_at=date(2026, 4, 1),
)
posted = state["posted"][0]
assert posted["tagIds"] == ["tag-wait"]
assert posted["dueDay"] == "2026-04-01"
def test_create_item_carries_the_freetext_backref_in_notes():
state = {"projects": [{"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}]}
with _write_api_server(state) as server:
writer = _writer_for(server)
writer.create_item(
"Rueckruf", project_name="Ship Chemenu 7.0", notes="Source - Kundenmail 2026-09-20",
)
assert state["posted"][0]["notes"] == "Source - Kundenmail 2026-09-20"
def test_create_item_waiting_without_the_tag_refuses_and_posts_nothing():
"""Gitea #132's own acceptance criterion: a missing `waiting` tag must
fail loud, never create an item without the status it was asked for."""
state = {
"projects": [{"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}],
"tags": [],
}
with _write_api_server(state) as server:
writer = _writer_for(server)
with pytest.raises(ValidationError, match="waiting"):
writer.create_item("x", project_name="Ship Chemenu 7.0", waiting=True)
assert "posted" not in state
# --- equivalence: both access paths agree on the same fixture (Gitea #133) -------- # --- equivalence: both access paths agree on the same fixture (Gitea #133) --------
def test_snapshot_and_api_readers_agree_on_the_same_fixture(tmp_path): def test_snapshot_and_api_readers_agree_on_the_same_fixture(tmp_path):
+280
View File
@@ -0,0 +1,280 @@
"""Tests for `wikitool task new` (Gitea #132) - the CLI adapter over
`chemenu.tasks.protocol.TaskWriter.create_item`. Mirrors the fixture shape
`test_new_page.py` uses for `new project`'s own tracker calls: a stub Super
Productivity local REST API on a loopback socket, `.wikitool-tasks.json`
pointed at it, and `config.ROOT` repointed at `tmp_path` via the shared
`kb_dir` fixture. No real Super Productivity instance is ever started
(`instructions/dev/testing-conventions.md`).
"""
from __future__ import annotations
import contextlib
import http.server
import json
import threading
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any
from typer.testing import CliRunner
runner = CliRunner()
_TASKS_THRESHOLDS = {
"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5,
}
_API_TOKEN = "test-token"
def _ms(year: int, month: int, day: int) -> int:
return int(datetime(year, month, day, tzinfo=timezone.utc).timestamp() * 1000)
def _write_tasks_config(root: Path, base_url: str) -> None:
(root / ".wikitool-tasks.json").write_text(
json.dumps({
"schema": 1, "provider": "superproductivity", "thresholds": _TASKS_THRESHOLDS,
"superproductivity": {"access": "api", "api_base_url": base_url, "api_token": _API_TOKEN},
}),
encoding="utf-8",
)
def _make_handler(state: dict, *, token: str = _API_TOKEN):
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802
if self.path == "/health":
self._reply(200, {"ok": True})
return
if self.headers.get("Authorization") != f"Bearer {token}":
self._reply(401, {"error": "unauthorized"})
return
if self.path in ("/projects", "/tasks", "/tags"):
self._reply(200, state.get(self.path.lstrip("/"), []))
return
self._reply(404, {"error": "not found"})
def do_POST(self): # noqa: N802
if self.headers.get("Authorization") != f"Bearer {token}":
self._reply(401, {"error": "unauthorized"})
return
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length)) if length else {}
if self.path == "/tasks":
state.setdefault("posted", []).append(payload)
task_id = f"posted-{len(state['posted'])}"
record = {"id": task_id, "isDone": False, **payload}
state.setdefault("tasks", []).append(record)
for project in state.get("projects", []):
if project.get("id") == payload.get("projectId"):
project.setdefault("taskIds", []).append(task_id)
self._reply(201, record)
return
self._reply(404, {"error": "not found"})
def _reply(self, code: int, payload: Any) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
def log_message(self, *args): # silence stderr noise during the test run
pass
return Handler
@contextlib.contextmanager
def _api_server(state: dict):
handler_cls = _make_handler(state)
server = http.server.HTTPServer(("127.0.0.1", 0), handler_cls)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
thread.join(timeout=2)
def _base_url(server: http.server.HTTPServer) -> str:
return f"http://127.0.0.1:{server.server_address[1]}"
def _invoke(monkeypatch, kb_dir, args):
import chemenu.config as config
from chemenu.cli import app
monkeypatch.setattr(config, "KB_DIR", kb_dir)
return runner.invoke(app, args)
def _project_record(title: str = "Ship Chemenu 7.0") -> dict:
return {"id": "p1", "title": title, "created": _ms(2026, 1, 1), "taskIds": [], "backlogTaskIds": []}
# --- argument validation, no tracker call needed -----------------------------
def test_neither_project_nor_inbox_is_refused(monkeypatch, kb_dir):
result = _invoke(monkeypatch, kb_dir, ["task", "new", "--title", "x"])
assert result.exit_code == 1
assert "Exactly one of --project" in result.output
def test_both_project_and_inbox_is_refused(monkeypatch, kb_dir):
result = _invoke(monkeypatch, kb_dir, [
"task", "new", "--title", "x", "--project", "Ship Chemenu 7.0", "--inbox",
])
assert result.exit_code == 1
assert "Exactly one of --project" in result.output
def test_follow_up_at_without_waiting_is_refused(monkeypatch, kb_dir):
result = _invoke(monkeypatch, kb_dir, [
"task", "new", "--title", "x", "--project", "Ship Chemenu 7.0",
"--follow-up-at", "2026-04-01",
])
assert result.exit_code == 1
assert "--waiting" in result.output
def test_malformed_follow_up_at_is_refused(monkeypatch, kb_dir):
result = _invoke(monkeypatch, kb_dir, [
"task", "new", "--title", "x", "--project", "Ship Chemenu 7.0",
"--waiting", "--follow-up-at", "not-a-date",
])
assert result.exit_code == 1
assert "YYYY-MM-DD" in result.output
def test_no_tracker_configured_is_refused(monkeypatch, kb_dir):
result = _invoke(monkeypatch, kb_dir, [
"task", "new", "--title", "x", "--project", "Ship Chemenu 7.0",
])
assert result.exit_code == 1
assert "no task tracker is configured" in result.output
# --- against a stub Super Productivity API -----------------------------------
def test_creates_an_item_in_the_named_project(monkeypatch, kb_dir):
root = kb_dir.parent
state = {"projects": [_project_record()]}
with _api_server(state) as server:
_write_tasks_config(root, _base_url(server))
result = _invoke(monkeypatch, kb_dir, [
"task", "new", "--title", "Rueckruf beim Kunden", "--project", "ship CHEMENU 7.0",
])
assert result.exit_code == 0, result.output
assert state["posted"] == [{"title": "Rueckruf beim Kunden", "projectId": "p1"}]
def test_unknown_project_is_refused_and_creates_nothing(monkeypatch, kb_dir):
root = kb_dir.parent
state = {"projects": []}
with _api_server(state) as server:
_write_tasks_config(root, _base_url(server))
result = _invoke(monkeypatch, kb_dir, [
"task", "new", "--title", "x", "--project", "No Such Project",
])
assert result.exit_code == 1
assert "No project named" in result.output
assert "posted" not in state
def test_inbox_route_files_into_the_fixed_inbox_project(monkeypatch, kb_dir):
root = kb_dir.parent
state = {"projects": []}
with _api_server(state) as server:
_write_tasks_config(root, _base_url(server))
result = _invoke(monkeypatch, kb_dir, ["task", "new", "--title", "Beleg ablegen", "--inbox"])
assert result.exit_code == 0, result.output
assert state["posted"][0]["projectId"] == "INBOX_PROJECT"
def test_waiting_with_follow_up_at_and_notes(monkeypatch, kb_dir):
root = kb_dir.parent
state = {"projects": [_project_record()], "tags": [{"id": "tag-wait", "title": "waiting"}]}
with _api_server(state) as server:
_write_tasks_config(root, _base_url(server))
result = _invoke(monkeypatch, kb_dir, [
"task", "new", "--title", "Nachfassen", "--project", "Ship Chemenu 7.0",
"--waiting", "--follow-up-at", "2026-04-01",
"--notes", "Source - Kundenmail 2026-09-20",
])
assert result.exit_code == 0, result.output
posted = state["posted"][0]
assert posted["tagIds"] == ["tag-wait"]
assert posted["dueDay"] == "2026-04-01"
assert posted["notes"] == "Source - Kundenmail 2026-09-20"
def test_missing_waiting_tag_is_refused_and_creates_nothing(monkeypatch, kb_dir):
root = kb_dir.parent
state = {"projects": [_project_record()], "tags": []}
with _api_server(state) as server:
_write_tasks_config(root, _base_url(server))
result = _invoke(monkeypatch, kb_dir, [
"task", "new", "--title", "x", "--project", "Ship Chemenu 7.0", "--waiting",
])
assert result.exit_code == 1
assert "waiting" in result.output
assert "posted" not in state
# --- round-trip through the read path (Gitea #132's own acceptance criterion) ---
def test_created_item_appears_in_review_as_an_open_and_overdue_waiting_item(monkeypatch, kb_dir):
"""Not just "was it posted" - read it back the way `wikitool review`
actually would, over the same live API, and confirm both check 2's
finding and the plain open-item count see it."""
from chemenu.review import CHECK_WAITING_OVERDUE, run_review
root = kb_dir.parent
state = {"projects": [_project_record()], "tags": [{"id": "tag-wait", "title": "waiting"}]}
with _api_server(state) as server:
_write_tasks_config(root, _base_url(server))
result = _invoke(monkeypatch, kb_dir, [
"task", "new", "--title", "Nachfassen beim Elektriker",
"--project", "Ship Chemenu 7.0", "--waiting", "--follow-up-at", "2026-03-01",
])
assert result.exit_code == 0, result.output
report = run_review(root, today=date(2026, 4, 1))
waiting_findings = [f for f in report.findings if f.check == CHECK_WAITING_OVERDUE]
assert len(waiting_findings) == 1
assert "Nachfassen beim Elektriker" in waiting_findings[0].message
def test_snapshot_access_has_no_write_path(monkeypatch, kb_dir, tmp_path):
"""Gitea #133: `access: "snapshot"` never offers a `TaskWriter` at all -
`task new` must refuse the same way `new project` does, exit 1, naming
the `access: "api"` instance to use instead."""
root = kb_dir.parent
backups_dir = root / "backups"
backups_dir.mkdir()
(backups_dir / "2026-01-01_000000.json").write_text(
json.dumps({
"project": {"ids": [], "entities": {}},
"task": {"ids": [], "entities": {}},
"tag": {"ids": [], "entities": {}},
}),
encoding="utf-8",
)
(root / ".wikitool-tasks.json").write_text(
json.dumps({
"schema": 1, "provider": "superproductivity", "thresholds": _TASKS_THRESHOLDS,
"superproductivity": {"access": "snapshot", "backups_dir": str(backups_dir)},
}),
encoding="utf-8",
)
result = _invoke(monkeypatch, kb_dir, [
"task", "new", "--title", "x", "--project", "Ship Chemenu 7.0",
])
assert result.exit_code == 1
assert "access: 'api'" in result.output