task: Weekly review proposes task new/task close; tracker gains a closing write path
Files changed: - CHANGES.md - INSTALL.md - README.md - VERSION - docs/knowledge-and-commitment.md - instructions/gtd-weekly-review/SKILL.md - instructions/ingest-large-tree.md - instructions/wiki-ingest/SKILL.md - tools/CONTRACT.md - tools/chemenu/commands/review_cmd.py - tools/chemenu/commands/task_cmd.py - tools/chemenu/review.py - tools/chemenu/tasks/protocol.py - tools/chemenu/tasks/superproductivity.py - tools/chemenu/tests/test_review.py - tools/chemenu/tests/test_superproductivity.py - tools/chemenu/tests/test_task_cmd.py
This commit is contained in:
1 parent
8b535b4016
commit
62d1c5e636
17 files changed
+563
-87
No files matched your search
+22
-1
@@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse.
|
||||
|
||||
---
|
||||
|
||||
## 7.0.0-beta.14 - 2026-09-22 - gtd-weekly-review: task new nachgezogen, veralteter Begründungszeiger korrigiert
|
||||
## 7.0.0-beta.15 - 2026-09-22 - Weekly review proposes task new/task close; tracker gains a closing write path
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
@@ -82,6 +82,7 @@ concern - readable here, never shipped as something to parse.
|
||||
- Doku-Nachzug zu #119: die Verpflichtungsschicht erreicht Installation, Setup und docs/
|
||||
- task new: einen zweiten Schreibweg in den Tracker (ein Posten, keine Seite)
|
||||
- wiki-ingest: raw accept rückt hinter die Verpflichtungsentscheidung
|
||||
- Weekly review proposes task new/task close; tracker gains a closing write path
|
||||
|
||||
**Low impact**
|
||||
- new project: Testabdeckung fuer die required-responsibility-Ablehnung
|
||||
@@ -448,6 +449,26 @@ eine Wahl statt eine Behauptung ueber eine fehlende Faehigkeit. Ob der Weekly Re
|
||||
kuenftig anbieten soll, bleibt unentschieden in Gitea #138; dieses Issue korrigiert nur, was
|
||||
nachweislich falsch dastand.
|
||||
|
||||
### Weekly review proposes task new/task close; tracker gains a closing write path
|
||||
|
||||
Gitea #138 entschied die dort offene Frage: der Weekly Review bietet `task new` jetzt bei
|
||||
`stalled`/`no_open_loop` Option (a) an, nach ausdruecklicher Bestaetigung von Titel und Projekt in
|
||||
einer Frage - dieselbe Haltung wie im Ingest. Die zweite Haelfte derselben Naht war unentschieden
|
||||
liegen geblieben: eine Quelle kann eine Verpflichtung anlegen, aber nie schliessen. Der Tracker
|
||||
bekommt dafuer einen dritten, letzten Schreibweg, `task close --id`, der einen Posten erledigt
|
||||
markiert - niemals loescht, verifiziert gegen Super Productivitys `master`-Branch, dass
|
||||
`PATCH /tasks/:id` mit `isDone: true` bit-identisch zur eigenen "erledigt"-Checkbox der App ist.
|
||||
`task list --project` liefert dazu die Item-Ids, die `task close` und die Review-Funde fuer
|
||||
`waiting_overdue`/`someday_stale` jetzt mitfuehren. `wiki-ingest` stellt die Verpflichtungsfrage
|
||||
seither in beide Richtungen (oeffnen und schliessen), und `ingest-large-tree` Schritt 5d begruendet
|
||||
jetzt in einem Satz, warum diese Frage pro Unit gestellt wird statt einmal pro Baum.
|
||||
|
||||
Die zwei Instruktionsstellen, an denen die Haltung zum Tracker-Schreibzugriff bislang doppelt
|
||||
stand, sind auf eine zusammengezogen; die zweite verweist nur noch.
|
||||
`docs/knowledge-and-commitment.md` § "Status has exactly one home" zaehlt die Kommandoflaeche
|
||||
korrekt (zwei Lese-, drei Schreibkommandos) und haelt fest, warum sie bei "anlegen" und "erledigt
|
||||
markieren" endet, nie bei "loeschen" oder "aendern".
|
||||
|
||||
---
|
||||
|
||||
## 6.2.0 - 2026-09-19 - Entity-Subtyp project nach codebase umbenannt
|
||||
|
||||
+4
-1
@@ -345,7 +345,10 @@ ignoriert. `api_token` ist bei `access: "api"` Pflicht, da jeder Endpunkt außer
|
||||
einer `access: "snapshot"`-Instanz verweigert das Kommando vollständig, exit 1: der Tracker ist
|
||||
von dort aus nur lesbar. Dasselbe gilt für `tools/wikitool task new`, den zweiten Schreibweg:
|
||||
es legt einen einzelnen Posten im Tracker an - ohne `kb/`-Seite - und existiert ebenfalls nur
|
||||
auf einer `access: "api"`-Instanz.
|
||||
auf einer `access: "api"`-Instanz. `tools/wikitool task close --id` ist der dritte und letzte
|
||||
Schreibweg - er markiert einen Posten erledigt, löscht ihn nie - und verweigert auf
|
||||
`access: "snapshot"` auf dieselbe Weise. `tools/wikitool task list --project` ist rein lesend
|
||||
und beantwortet daher auf beiden Zugriffsarten.
|
||||
|
||||
## Verifikation
|
||||
|
||||
|
||||
@@ -244,7 +244,11 @@ you and turns each one into a decision; `tools/wikitool new project` is what
|
||||
gives a new initiative its page and its tracker project under one name, and
|
||||
`tools/wikitool task new` files a single open item into the tracker - the
|
||||
commitment half of a source that carries both something to know and something
|
||||
to do, with no page of its own.
|
||||
to do, with no page of its own. `tools/wikitool task list` reads a project's
|
||||
open items back with their tracker id, and `tools/wikitool task close --id`
|
||||
marks one done - never deletes it - closing the loop the same source-driven
|
||||
way `task new` opened it, or the way the weekly review proposes it for a
|
||||
`waiting_overdue`/`someday_stale` finding once you confirm.
|
||||
|
||||
Why the split runs this way, rather than syncing the two:
|
||||
[docs/knowledge-and-commitment.md](docs/knowledge-and-commitment.md).
|
||||
|
||||
@@ -93,7 +93,7 @@ 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
|
||||
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 two creation commands - rather than growing a
|
||||
surface as small as it is - two read commands and three write commands - rather than growing a
|
||||
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
|
||||
@@ -111,6 +111,15 @@ the ordinary case avoids becomes, there, the expected condition for as long as t
|
||||
a failure on the knowledge side afterwards is exactly the ordinary "a source without a page" state
|
||||
`lint` already reports.
|
||||
|
||||
The write surface stops at *creating* an item and *marking one done* - it never moves a reminder
|
||||
and never deletes anything. `task close` sets exactly the field the tracker's own "done" checkbox
|
||||
sets, nothing more: reversible, and it leaves a record in the tracker rather than removing the
|
||||
item's trace. A command that deleted would take the same shortcut through somebody's task list
|
||||
that the whole split above exists to avoid - a write this stack cannot undo, made on behalf of a
|
||||
tracker it does not own. `task list` is the one addition on the read side, and it changes nothing
|
||||
about the join itself: it exists only because closing an item needs the tracker's own id for it,
|
||||
and that id was never worth exposing before there was a write that consumed it.
|
||||
|
||||
## A finished initiative is a state, not a location
|
||||
|
||||
Archiving moves nothing. A completed initiative's page stays where it is and changes its `state:`
|
||||
|
||||
@@ -17,14 +17,20 @@ looked at yet.
|
||||
|
||||
**Provider-neutral by design.** Nothing below names a task-tracker provider, a file format or an
|
||||
API - only the tracker's generic role. That is deliberate: this skill is the one document that
|
||||
must read identically in every instance, whichever tracker it runs against. Anything the tracker
|
||||
itself must do - add a task, reschedule a reminder, remove an item - is described as an action the
|
||||
user takes in their tracker: a deliberate choice, not a gap in `wikitool`'s GTD command surface,
|
||||
which also carries `task new` (tracker item only, no page) alongside `review` (read-only) and
|
||||
`new project` (page + tracker project creation) - this skill simply does not call it. The
|
||||
reasoning behind keeping the tracker and `kb/gtd/` on separate write paths lives in
|
||||
`docs/knowledge-and-commitment.md`, which this skill does not repeat - do not reach for a
|
||||
tracker-specific tool or API to "just do it faster".
|
||||
must read identically in every instance, whichever tracker it runs against.
|
||||
|
||||
**What this skill may write to the tracker, and what it may not.** `wikitool`'s GTD command
|
||||
surface offers exactly two tracker-side writes - `task new` (create one item) and `task close`
|
||||
(mark one item done, never delete it) - alongside `review` (read-only) and `new project` (page +
|
||||
tracker project creation). This skill proposes both writes at the specific findings below, always
|
||||
after the user confirms the exact call, never on its own initiative - the same posture
|
||||
`wiki-ingest` takes toward its own commitment question ("propose one and let the user confirm or
|
||||
correct it"). Everything else a tracker item can need - moving a reminder forward, removing an
|
||||
item outright - stays the user's own action in their tracker: that is a deliberate line, not a gap
|
||||
in the command surface waiting to be filled. Do not reach for a tracker-specific tool or API to
|
||||
"just do it faster" for either half. The reasoning behind keeping the tracker and `kb/gtd/` on
|
||||
separate write paths, and behind stopping at "create" and "mark done" rather than a fuller CRUD
|
||||
surface, lives in `docs/knowledge-and-commitment.md`, which this skill does not repeat.
|
||||
|
||||
## Steps
|
||||
|
||||
@@ -48,11 +54,11 @@ tracker-specific tool or API to "just do it faster".
|
||||
|
||||
| Check | What fired | Options | How to tell them apart |
|
||||
|---|---|---|---|
|
||||
| `stalled` | A tracker project has zero open items and its `kb/` page is `state: active` | (a) A next action is genuinely missing - add one in the tracker. (b) The initiative is deliberately paused - `tools/wikitool touch --page "<Title>" --set state=dormant`. (c) It is actually finished or given up on - `--set state=completed` or `--set state=abandoned` | Read the page's `## Ziel` and `## Status` sections and ask the user directly: is there still a next step toward that goal, or did this stop for a reason? A pause that was never decided is (a); a pause that *was* decided is (b), never left as `active` with nothing moving |
|
||||
| `waiting_overdue` | A `WAITING` item's `follow_up_at` is older than the threshold | (a) Follow up now, then move the reminder forward in the tracker. (b) The commitment is no longer needed - remove or close it in the tracker | Did the person the item names actually come through, and is the ask still relevant? If yes but late, (a); if the need has passed, (b) - never leave the same stale date standing unexamined |
|
||||
| `stalled` | A tracker project has zero open items and its `kb/` page is `state: active` | (a) A next action is genuinely missing - propose `tools/wikitool task new --title "<title>" --project "<project>"` with a title the user confirms or corrects, asked as one combined question ("Create '<title>' in project '<project>'?"), then run it once confirmed. (b) The initiative is deliberately paused - `tools/wikitool touch --page "<Title>" --set state=dormant`. (c) It is actually finished or given up on - `--set state=completed` or `--set state=abandoned` | Read the page's `## Ziel` and `## Status` sections and ask the user directly: is there still a next step toward that goal, or did this stop for a reason? A pause that was never decided is (a); a pause that *was* decided is (b), never left as `active` with nothing moving |
|
||||
| `waiting_overdue` | A `WAITING` item's `follow_up_at` is older than the threshold | (a) Follow up now, then move the reminder forward in the tracker - the user's own action, there is no `wikitool` command for it. (b) The commitment is no longer needed - propose `tools/wikitool task close --id "<id>"` (the finding's own `item_id`), asked as one combined question naming the item's title and id, then run it once confirmed | Did the person the item names actually come through, and is the ask still relevant? If yes but late, (a); if the need has passed, (b) - never leave the same stale date standing unexamined |
|
||||
| `unpaged_project` | A tracker project has no `kb/` page, past the age threshold | (a) It has grown a memory worth keeping (participants, decisions, context) - `tools/wikitool new project --name "<Name>" --set responsibility=<area>`. (b) It genuinely never needs one - confirm and leave it tracker-only | Ask: would anyone, including the operator in six months, need to know *why* this exists or who is in it? If yes, (a); a project that is fully explained by its own title and task list stays (b) |
|
||||
| `no_open_loop` | A `kb/` page is `state: active` but its tracker project is missing or empty | (a) Same three options as `stalled` above. (b) The name diverged - a rename happened on one side only | Before assuming a stall, check whether a *similarly* named tracker project exists. If it does, this is `instructions/page-lifecycle.md`'s rename case (`tools/wikitool rename` for the page, plus renaming the tracker project to match), not a state change - the review reports both directions of a rename so it never has to be inferred silently |
|
||||
| `someday_stale` | A someday/maybe item has not been touched past the threshold | (a) Activate it - give it a page with `tools/wikitool new project` if it is ready to become a committed initiative. (b) Strike it - remove it from the tracker. (c) Leave it - still genuinely "maybe" | Would the user commit to starting this today? If yes, (a). If it no longer belongs on the list at all, (b). If it is still worth keeping but not yet, (c) is a legitimate answer, not inaction - do not force a decision the user is not ready to make |
|
||||
| `someday_stale` | A someday/maybe item has not been touched past the threshold | (a) Activate it - give it a page with `tools/wikitool new project` if it is ready to become a committed initiative. (b) Strike it - propose `tools/wikitool task close --id "<id>"` (the finding's own `item_id`), asked as one combined question naming the item's title and id, then run it once confirmed. (c) Leave it - still genuinely "maybe" | Would the user commit to starting this today? If yes, (a). If it no longer belongs on the list at all, (b). If it is still worth keeping but not yet, (c) is a legitimate answer, not inaction - do not force a decision the user is not ready to make |
|
||||
|
||||
3. **Record what was decided or learned on the page - never the task list.** A decision made this
|
||||
week (a scope cut, a direction change) goes under `## Entscheidungen`; something that showed
|
||||
@@ -87,14 +93,12 @@ tracker-specific tool or API to "just do it faster".
|
||||
|
||||
## wikitool commands used
|
||||
|
||||
`review`, `touch`, `new project`, `rename` (via `instructions/page-lifecycle.md`, only for the
|
||||
rename case), `publish`
|
||||
`review`, `touch`, `new project`, `task new`, `task close`, `rename` (via
|
||||
`instructions/page-lifecycle.md`, only for the rename case), `publish`
|
||||
|
||||
**Deliberately absent:** anything that reaches into the task tracker directly. `wikitool`'s GTD
|
||||
surface also includes `task new` (tracker item only, no page -
|
||||
`docs/knowledge-and-commitment.md`), but this skill does not use it: every tracker-side action in
|
||||
the table above - adding a next action, moving a reminder, removing an item - is something the
|
||||
user does in their tracker, not something this skill automates.
|
||||
**Absent:** moving a reminder forward, and removing an item outright - both stay the user's own
|
||||
action in their tracker. See "What this skill may write to the tracker, and what it may not"
|
||||
above for why the line sits exactly there.
|
||||
|
||||
## Output
|
||||
|
||||
|
||||
@@ -153,6 +153,13 @@ session.
|
||||
already under `raw/` (§ [When to run](#when-to-run) named the volume/breadth trigger that
|
||||
put it there). Fill `## Not Extracted` from b.
|
||||
|
||||
This includes step 4's commitment question, asked once **per unit** rather than once for
|
||||
the whole tree: a unit is a subject the same way a single-file `wiki-ingest` source is one,
|
||||
and whether *this* subject opens or closes a loop is only visible while its own extract is
|
||||
in front of you - not at the end of the run, once several subjects' worth of content has
|
||||
gone by. A unit that carries no commitment simply skips the question, the same as any other
|
||||
source (`wiki-ingest` step 4's own "No commitment either way in this source?").
|
||||
|
||||
e. **Publish** this unit alone, then tick its checklist line. One unit, one commit.
|
||||
|
||||
Do not start unit *N+1* before *N* is published: later units must be able to see the pages
|
||||
|
||||
@@ -84,19 +84,21 @@ validator complains - and the ticked list is the only record that they happened.
|
||||
|
||||
4. **Discuss with the user.** Present the key takeaways and ask: which points matter most,
|
||||
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.
|
||||
source also carries a commitment**, in either direction: something to follow up on (it opens
|
||||
a loop) or evidence that an existing commitment is done (it closes one) - "das Angebot wurde
|
||||
angenommen", "der Termin hat stattgefunden". A customer complaint, a meeting note with an
|
||||
action item, an offer awaiting a reply, a confirmation email: 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 one or the other, occasionally 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.
|
||||
own *Clarify* - never a guess from the source's wording alone. Do not create or close 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 5** - 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 promoted and no
|
||||
page behind it. Search for a likely project rather than asking cold:
|
||||
**If the source opens a commitment, resolve its project and create the item before continuing
|
||||
to step 5** - 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 promoted
|
||||
and no page behind it. Search for a likely project rather than asking cold:
|
||||
|
||||
```bash
|
||||
tools/wikitool search "<likely project name>"
|
||||
@@ -128,8 +130,26 @@ validator complains - and the ticked list is the only record that they happened.
|
||||
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 5 - the knowledge side runs on its own
|
||||
exactly as before.
|
||||
**If the source instead closes a commitment**, resolve which open item it is and mark it done
|
||||
before continuing to step 5 - same order, tracker side first. Search for the likely project,
|
||||
then list its open items to find the one the source closes:
|
||||
|
||||
```bash
|
||||
tools/wikitool search "<likely project name>"
|
||||
tools/wikitool task list --project "<confirmed project>"
|
||||
```
|
||||
|
||||
Put title and id to the user as **one** combined question - "Close '<title>' (id `<id>`) as
|
||||
done?" - never a foregone conclusion, the same posture as the opening question above. If
|
||||
nothing in the list obviously matches what the source describes, say so and leave it open
|
||||
rather than guessing at an id. Once confirmed:
|
||||
|
||||
```bash
|
||||
tools/wikitool task close --id "<confirmed id>"
|
||||
```
|
||||
|
||||
No commitment either way in this source? Skip straight to step 5 - the knowledge side runs on
|
||||
its own exactly as before.
|
||||
|
||||
5. **Promote from `incoming/` if that is where the file sits.** Read
|
||||
`raw/CONTRACT.md` "Getting a file in" and "Capture fields" if you have
|
||||
@@ -304,6 +324,11 @@ validator complains - and the ticked list is the only record that they happened.
|
||||
- **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 4's own three-way choice. Name
|
||||
the cost (invisible to `wikitool review`) before the user picks it.
|
||||
- **A source seems to close a commitment, but `task list` shows nothing that obviously matches?**
|
||||
Leave it - the item may already be closed, may live under a different project name, or the
|
||||
source may be less conclusive than it first reads. A wrongly closed item is worse than one left
|
||||
open one more week: it disappears from every later review with nothing to show it was ever
|
||||
there.
|
||||
- **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:
|
||||
`instructions/ingest-large-tree.md` § A broad source is not cut.
|
||||
@@ -319,9 +344,9 @@ validator complains - and the ticked list is the only record that they happened.
|
||||
|
||||
## wikitool commands used
|
||||
|
||||
`raw accept`, `search`, `types describe`, `task new`, `new project`, `new source`, `new entity`,
|
||||
`new concept`, `touch`, `cite add`, `xref add`, `xref link-source`, `sources coverage`,
|
||||
`sources rebuild-index`, `index rebuild`, `log append`, `log status`, `publish`
|
||||
`raw accept`, `search`, `types describe`, `task new`, `task list`, `task close`, `new project`,
|
||||
`new source`, `new entity`, `new concept`, `touch`, `cite add`, `xref add`, `xref link-source`,
|
||||
`sources coverage`, `sources rebuild-index`, `index rebuild`, `log append`, `log status`, `publish`
|
||||
|
||||
## Output
|
||||
|
||||
|
||||
@@ -96,6 +96,8 @@ tools/wikitool <command> --help
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `task list --project "<Name>"` | List a project's open items - id, title, and whether each carries the `WAITING` status. Read-only; the id source `task close` and the review's own `waiting_overdue`/`someday_stale` findings need, without first running `wikitool review`. Works on either access mode a provider offers, unlike the write commands below. No `.wikitool-tasks.json` fails with the same "no tracker configured" message as `review`/`task new`; a `--project` matching no tracker project prints "No open items", since `TaskReader.open_items` does not distinguish "empty" from "unknown" (`chemenu.tasks.protocol.TaskReader.open_items`'s own docstring) |
|
||||
| `task close --id <item-id>` | Mark one tracker item done - never delete it, per `docs/knowledge-and-commitment.md`. `<item-id>` is the provider's own id, from `task list` or a `review` finding, never a title - the tracker-side identity is opaque, unlike the project name that is `kb/`'s and the tracker's only shared coupling. The only closing write this stack makes: no "move a reminder", no "remove an item". No `.wikitool-tasks.json` fails with the same "no tracker configured" message as `task new`. A provider whose configured access path has no write path (Super Productivity's `access: "snapshot"`) refuses **entirely**, exit **1**, naming the `access: "api"` instance to use instead - same posture as `task new`. Never exits 42, same reasoning as `task new`: every provider offering a write path has a real per-item write call |
|
||||
| `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` |
|
||||
| `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 |
|
||||
@@ -313,6 +315,8 @@ 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 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 |
|
||||
| `task list` | No `.wikitool-tasks.json` | Yes - read-only, nothing to leave half-written | Not transient; configure a tracker first, then retry once. A `--project` matching no tracker project is not an error here - see its Commands row |
|
||||
| `task close` | No `.wikitool-tasks.json`, an `--id` matching no tracker item right now, or a read-only access path (Super Productivity's `access: "snapshot"`) | Yes - a single API call; an unknown id is rejected by the provider itself (Super Productivity: `404 TASK_NOT_FOUND`) before anything is written | Not transient; fix the id (re-run `task list` or `review` to get a current one) or point at an `access: "api"` instance, then retry once. **Never exit 42**, same reasoning as `task new` |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
@@ -39,7 +39,8 @@ def render_report(report: ReviewReport) -> str:
|
||||
lines.append("No findings.")
|
||||
else:
|
||||
for finding in report.findings:
|
||||
lines.append(f"[{finding.check}] {finding.project}: {finding.message}")
|
||||
suffix = f" (id: {finding.item_id})" if finding.item_id is not None else ""
|
||||
lines.append(f"[{finding.check}] {finding.project}: {finding.message}{suffix}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"{len(report.findings)} finding(s), {len(report.checks_run)}/{len(ALL_CHECKS)} check(s) ran.")
|
||||
@@ -52,7 +53,12 @@ def report_to_dict(report: ReviewReport) -> dict:
|
||||
has to parse prose to tell a partial report from a complete one."""
|
||||
return {
|
||||
"findings": [
|
||||
{"check": finding.check, "project": finding.project, "message": finding.message}
|
||||
{
|
||||
"check": finding.check,
|
||||
"project": finding.project,
|
||||
"message": finding.message,
|
||||
"item_id": finding.item_id,
|
||||
}
|
||||
for finding in report.findings
|
||||
],
|
||||
"checks_run": list(report.checks_run),
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
"""`wikitool task new` - create a tracker item, no kb/ page (Gitea #132, #119
|
||||
D1/D2/D4/D5/D6/D9).
|
||||
"""`wikitool task new`/`task list`/`task close` - the tracker item write and
|
||||
read surface outside `new project` (Gitea #132, #138; #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.
|
||||
`task new` is the second write path into the task tracker, alongside `new
|
||||
project`'s own (`chemenu.commands.new_page._ensure_tracker_project`) - and
|
||||
the last creation command that pairing needed, per
|
||||
`docs/knowledge-and-commitment.md`. `task close` (#138) is the one closing
|
||||
write: never a delete, only "mark done" (`TaskWriter.close_item`) - see that
|
||||
protocol method's docstring and `docs/knowledge-and-commitment.md` for why
|
||||
the surface stops there. `task list` (#138) is the read half a caller needs
|
||||
to get an item's id before it can close it, without first running
|
||||
`wikitool review`. None of the three ever touch `kb/`: an ingest that finds
|
||||
both knowledge and a commitment in one source runs the tracker 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.
|
||||
exclusivity (#132 D4), the `--follow-up-at` date, and `task list`/`task
|
||||
close`'s rendering. The writes themselves are
|
||||
`chemenu.tasks.protocol.TaskWriter.create_item`/`close_item`, dispatched
|
||||
through `chemenu.tasks.build_writer` exactly like `new project` does; the
|
||||
read is `TaskReader.open_items`, the same call `chemenu.review` makes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -29,8 +37,8 @@ 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."
|
||||
help="Create, list, and close items in the task tracker (Gitea #132, #138) - "
|
||||
"never a kb/ page, see `new project` for that pairing."
|
||||
)
|
||||
|
||||
|
||||
@@ -121,3 +129,69 @@ def task_new_command(
|
||||
|
||||
where = "the tracker's inbox" if inbox else f"project '{project}'"
|
||||
success(f"Created '{title}' in {where}.")
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def task_list_command(
|
||||
project: str = typer.Option(
|
||||
..., "--project", help="An existing tracker project's name (matched case-insensitively)."
|
||||
),
|
||||
):
|
||||
"""List a project's open items - id, title, and WAITING status (Gitea
|
||||
#138) - so a caller can get an item's id for `task close` without first
|
||||
running `wikitool review`. Read-only; works against either access mode a
|
||||
provider offers."""
|
||||
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 "
|
||||
"nothing to list."
|
||||
)
|
||||
|
||||
reader = tasks.build_reader(cfg)
|
||||
try:
|
||||
items = reader.open_items(project).items
|
||||
except ValidationError as exc:
|
||||
fail(str(exc))
|
||||
return
|
||||
|
||||
if not items:
|
||||
typer.echo(f"No open items in project '{project}'.")
|
||||
return
|
||||
for item in items:
|
||||
marker = " [WAITING]" if item.waiting else ""
|
||||
typer.echo(f"{item.id}\t{item.title}{marker}")
|
||||
|
||||
|
||||
@app.command("close")
|
||||
def task_close_command(
|
||||
item_id: str = typer.Option(
|
||||
...,
|
||||
"--id",
|
||||
help="The tracker's own item id (Gitea #138), e.g. from `task list` or `wikitool "
|
||||
"review`'s waiting_overdue/someday_stale findings - never a title.",
|
||||
),
|
||||
):
|
||||
"""Mark one tracker item done (Gitea #138) - never delete it. The only
|
||||
closing write this stack makes; see
|
||||
`chemenu.tasks.protocol.TaskWriter.close_item` and
|
||||
`docs/knowledge-and-commitment.md` for why."""
|
||||
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 "
|
||||
"nothing to close."
|
||||
)
|
||||
|
||||
reader = tasks.build_reader(cfg)
|
||||
try:
|
||||
writer = tasks.build_writer(cfg, reader)
|
||||
except ValidationError as exc:
|
||||
fail(str(exc))
|
||||
|
||||
try:
|
||||
writer.close_item(item_id)
|
||||
except ValidationError as exc:
|
||||
fail(str(exc))
|
||||
|
||||
success(f"Closed item {item_id!r}.")
|
||||
+12
-1
@@ -48,11 +48,20 @@ class Finding:
|
||||
"""One reported mismatch. `project` is the display name - the kb/ page's
|
||||
title when a check is anchored on the kb/ side (checks 1 and 4, where the
|
||||
kb/ page is what the finding is about), the tracker's own project name
|
||||
otherwise (checks 2 and 3, where no kb/ page need exist)."""
|
||||
otherwise (checks 2 and 3, where no kb/ page need exist).
|
||||
|
||||
`item_id` (Gitea #138) is the tracker's own id for the specific item a
|
||||
finding is about - set on checks 2 (`waiting_overdue`) and 5
|
||||
(`someday_stale`), which each name one item, and `None` on checks 1, 3
|
||||
and 4, which are about a whole project rather than one item. It exists so
|
||||
`gtd-weekly-review`'s own `task close --id` proposal (option (b) on both
|
||||
checks) never has to re-look-up the item by title after the review
|
||||
already read it."""
|
||||
|
||||
check: str
|
||||
project: str
|
||||
message: str
|
||||
item_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -189,6 +198,7 @@ def run_review(root: Path, *, today: Optional[date] = None) -> ReviewReport:
|
||||
CHECK_WAITING_OVERDUE, name,
|
||||
f"'{item.title}' is {age_days} day(s) past its follow_up_at "
|
||||
f"({item.follow_up_at.isoformat()}).",
|
||||
item_id=item.id,
|
||||
))
|
||||
checks_run.append(CHECK_WAITING_OVERDUE)
|
||||
|
||||
@@ -249,6 +259,7 @@ def run_review(root: Path, *, today: Optional[date] = None) -> ReviewReport:
|
||||
CHECK_SOMEDAY_STALE, item.title,
|
||||
f"Untouched for {age_months} month(s) (last modified "
|
||||
f"{item.modified.isoformat()}).",
|
||||
item_id=item.id,
|
||||
))
|
||||
checks_run.append(CHECK_SOMEDAY_STALE)
|
||||
|
||||
|
||||
@@ -8,14 +8,22 @@ provider whose write path cannot exist (see `SuperProductivityWriter`) simply
|
||||
does not implement `TaskWriter` - nothing here forces it to.
|
||||
|
||||
The read shape is fixed by what the weekly review (#119 D26, built in #125)
|
||||
needs and nothing more: which projects exist and when they were created, how
|
||||
many open items each one has and which of those are `WAITING` with a
|
||||
`follow_up_at` (#119 D9/D30 - the *only* two machine-readable parts of a
|
||||
waiting-for item; the person stays in the title's free text), and which
|
||||
someday/maybe items exist and when they last moved. None of this is cached
|
||||
here - a `TaskReader` re-reads on every call, so a caller checking `verify()`
|
||||
after a human's out-of-band step (see `chemenu.errors.HumanInterventionRequired`)
|
||||
never sees a value this process cached from before that step.
|
||||
and `task list`/`task close` (#138) need and nothing more: which projects
|
||||
exist and when they were created, every open item each one has - id, title,
|
||||
and whether it is `WAITING` with a `follow_up_at` (#119 D9/D30 - the *only*
|
||||
two machine-readable parts of a waiting-for item; the person stays in the
|
||||
title's free text) - and which someday/maybe items exist and when they last
|
||||
moved. None of this is cached here - a `TaskReader` re-reads on every call,
|
||||
so a caller checking `verify()` after a human's out-of-band step (see
|
||||
`chemenu.errors.HumanInterventionRequired`) never sees a value this process
|
||||
cached from before that step.
|
||||
|
||||
An item's own id (#138) is read-only data, like everything else here - it is
|
||||
never stored by `wikitool`, only ever passed straight back into
|
||||
`TaskWriter.close_item` within the same invocation. That keeps the "one name
|
||||
is the only coupling" decision (`docs/knowledge-and-commitment.md` § "One
|
||||
name, carrying the duties of an identifier") intact: no id-to-anything
|
||||
mapping is ever written down, so there is nothing to keep in sync.
|
||||
|
||||
**Re-reading is not the same as reading the current state** (Gitea #134,
|
||||
resolved by #133's design rather than by a fix here): a point-in-time source
|
||||
@@ -49,12 +57,15 @@ class ProjectSummary:
|
||||
class WaitingItem:
|
||||
"""One open item carrying the `WAITING` status (#119 D9/D30).
|
||||
|
||||
`title` is shown verbatim, person and all - the review never parses it.
|
||||
`follow_up_at` is the one machine-readable date, and it is deliberately
|
||||
**not** the item's due date (#119 D9: "ausdruecklich nicht das
|
||||
Faelligkeitsdatum") - a provider that has no separate concept for this
|
||||
must not fall back to reusing the due date, it must decide it cannot
|
||||
supply the field and leave it `None` instead.
|
||||
`id` is the provider's own item id (#138) - read-only, never guessed,
|
||||
passed straight into `TaskWriter.close_item` when the review's
|
||||
`waiting_overdue` (b) is confirmed. `title` is shown verbatim, person and
|
||||
all - the review never parses it. `follow_up_at` is the one
|
||||
machine-readable date, and it is deliberately **not** the item's due date
|
||||
(#119 D9: "ausdruecklich nicht das Faelligkeitsdatum") - a provider that
|
||||
has no separate concept for this must not fall back to reusing the due
|
||||
date, it must decide it cannot supply the field and leave it `None`
|
||||
instead.
|
||||
|
||||
**This rule binds the concept, not a field's name** (Gitea #135's own
|
||||
correction, after #124's Super Productivity adapter read the wrong field
|
||||
@@ -67,6 +78,7 @@ class WaitingItem:
|
||||
counted as `follow_up_at` at all.
|
||||
"""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
follow_up_at: Optional[date]
|
||||
|
||||
@@ -85,21 +97,40 @@ class ReadSource:
|
||||
detail: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenItem:
|
||||
"""One open item as `task list` (#138) needs it - id, title, and whether
|
||||
it carries the `WAITING` status. Deliberately thinner than `WaitingItem`
|
||||
(no `follow_up_at`): a waiting item still appears here, just without the
|
||||
one field only the waiting-overdue check reads."""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
waiting: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenItems:
|
||||
"""A project's momentary open-loop count, plus the subset that is
|
||||
`WAITING`. `count` includes the waiting items - it is "how many open
|
||||
items", not "how many open items that aren't waiting"."""
|
||||
"""A project's momentary open-loop count, the subset that is `WAITING`,
|
||||
and the full list `task list` prints. `count` includes the waiting items
|
||||
- it is "how many open items", not "how many open items that aren't
|
||||
waiting". `items` and `waiting` overlap by design: a `WaitingItem` is
|
||||
also present in `items`, since `task list` shows every open item
|
||||
regardless of status."""
|
||||
|
||||
count: int
|
||||
waiting: Sequence[WaitingItem]
|
||||
items: Sequence[OpenItem]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SomedayItem:
|
||||
"""One someday/maybe item: its title and when it last changed, for
|
||||
"""One someday/maybe item: its id, title and when it last changed. `id`
|
||||
is the provider's own item id (#138), passed into `TaskWriter.close_item`
|
||||
when the review's `someday_stale` (b) is confirmed. `modified` feeds
|
||||
check 5's staleness read (#119 D26)."""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
modified: Optional[date]
|
||||
|
||||
@@ -125,9 +156,10 @@ class TaskReader(Protocol):
|
||||
def open_items(self, project_name: str) -> OpenItems:
|
||||
"""Open items for the project named `project_name` (matched
|
||||
case-normalized, #119 D8). A project the tracker does not know
|
||||
returns `OpenItems(count=0, waiting=())` - "no open items" and "no
|
||||
such project" are not distinguished here, because check 3 (#119 D26)
|
||||
is what tells those apart, over the read path's `projects()` list."""
|
||||
returns `OpenItems(count=0, waiting=(), items=())` - "no open items"
|
||||
and "no such project" are not distinguished here, because check 3
|
||||
(#119 D26) is what tells those apart, over the read path's
|
||||
`projects()` list."""
|
||||
...
|
||||
|
||||
def someday_items(self) -> list[SomedayItem]:
|
||||
@@ -206,6 +238,24 @@ class TaskWriter(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
def close_item(self, item_id: str) -> None:
|
||||
"""Mark the item `item_id` done - never delete it (Gitea #138). This
|
||||
is the only closing write this stack ever makes: no "remove", no
|
||||
"move the reminder forward". `item_id` is the provider's own id
|
||||
(`WaitingItem.id`/`SomedayItem.id`/`OpenItem.id`), read fresh
|
||||
immediately before the call and never guessed or looked up by title -
|
||||
the tracker-side identity is opaque and provider-defined, unlike the
|
||||
project name (#119 D8), which is why this takes an id rather than a
|
||||
title the way `create_item` takes a project name.
|
||||
|
||||
Raises `chemenu.errors.ValidationError` if no item with this id
|
||||
exists right now - nothing is written. Like `create_item`, never
|
||||
raises `chemenu.errors.HumanInterventionRequired`: every provider
|
||||
offering `TaskWriter` has a real per-item write call, the same gap
|
||||
`create_project` alone hits.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
def find_project(reader: TaskReader, name: str) -> Optional[ProjectSummary]:
|
||||
"""The project matching `name` case-normalized (#119 D8), or `None`.
|
||||
|
||||
@@ -82,6 +82,32 @@ regardless of `access`; see its docstring and
|
||||
when `access: "api"` - see `chemenu.tasks.build_writer` - because on
|
||||
`access: "snapshot"` the tracker is read-only from here by construction, not
|
||||
by an extra check bolted onto this module (Gitea #133).
|
||||
|
||||
## Closing an item: `PATCH /tasks/:id` with `isDone: true`, nothing else
|
||||
|
||||
Verified against `super-productivity/super-productivity`'s `master` branch
|
||||
(Gitea #138, 2026-09-22): `local-rest-api-handler.service.ts` routes
|
||||
`PATCH /tasks/:id` through `pickAllowedFields`/`validateWritableFields` and
|
||||
then a single `this._taskService.update(taskId, changes)` call - the exact
|
||||
path `TaskService.setDone(id)` itself takes
|
||||
(`update(id, { isDone: true })`), with no special-casing of `isDone` in
|
||||
either the service or the task reducer. Concretely:
|
||||
|
||||
- `isDone` is in `ALLOWED_TASK_FIELDS`, so the route accepts it.
|
||||
- Marking a task done through this API is **bit-identical** to the UI's own
|
||||
checkbox: neither sets `doneOn` or any other field - `TaskCopy.doneOn`
|
||||
exists on the model but nothing in `setDone`'s own call path writes it, so
|
||||
a task closed here looks exactly like one a human clicked done on, not a
|
||||
half-written state with a missing timestamp the UI would have set.
|
||||
- An unknown task id makes the same handler return `404 TASK_NOT_FOUND`
|
||||
before any write happens, which this module's `_ApiClient` already turns
|
||||
into an ordinary `ValidationError` - no separate existence preflight is
|
||||
needed for `close_item` to write nothing on a bad id.
|
||||
|
||||
`DELETE /tasks/:id` also exists on this API but is never called by this
|
||||
module (Gitea #138 E7): a tracker item this adapter can create, it can only
|
||||
ever mark done, never remove - the reversible half of the write surface, not
|
||||
the irreversible one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -95,6 +121,7 @@ from typing import Any, Optional
|
||||
|
||||
from chemenu.errors import HumanInterventionRequired, ValidationError
|
||||
from chemenu.tasks.protocol import (
|
||||
OpenItem,
|
||||
OpenItems,
|
||||
ProjectSummary,
|
||||
ReadSource,
|
||||
@@ -341,20 +368,27 @@ class SuperProductivityReader:
|
||||
None,
|
||||
)
|
||||
if project is None:
|
||||
return OpenItems(count=0, waiting=())
|
||||
return OpenItems(count=0, waiting=(), items=())
|
||||
|
||||
waiting: list[WaitingItem] = []
|
||||
all_items: list[OpenItem] = []
|
||||
count = 0
|
||||
for task_id in project.get("taskIds") or []:
|
||||
task = tasks.get(task_id)
|
||||
if task is None or task.get("isDone"):
|
||||
continue
|
||||
count += 1
|
||||
if _is_waiting(task, tags):
|
||||
task_id_str = str(task.get("id", task_id))
|
||||
task_title = str(task.get("title", ""))
|
||||
is_waiting = _is_waiting(task, tags)
|
||||
if is_waiting:
|
||||
waiting.append(
|
||||
WaitingItem(title=str(task.get("title", "")), follow_up_at=_follow_up_at(task))
|
||||
WaitingItem(
|
||||
id=task_id_str, title=task_title, follow_up_at=_follow_up_at(task)
|
||||
)
|
||||
)
|
||||
return OpenItems(count=count, waiting=tuple(waiting))
|
||||
all_items.append(OpenItem(id=task_id_str, title=task_title, waiting=is_waiting))
|
||||
return OpenItems(count=count, waiting=tuple(waiting), items=tuple(all_items))
|
||||
|
||||
def someday_items(self) -> list[SomedayItem]:
|
||||
_, projects, tasks, _ = self._read()
|
||||
@@ -366,6 +400,7 @@ class SuperProductivityReader:
|
||||
continue
|
||||
items.append(
|
||||
SomedayItem(
|
||||
id=str(task.get("id", task_id)),
|
||||
title=str(task.get("title", "")),
|
||||
modified=_epoch_ms_to_date(task.get("updated") or task.get("created")),
|
||||
)
|
||||
@@ -418,6 +453,9 @@ class _ApiClient:
|
||||
def post(self, path: str, body: dict, *, timeout: float = 10.0) -> Any:
|
||||
return self._request("POST", path, body=body, timeout=timeout)
|
||||
|
||||
def patch(self, path: str, body: dict, *, timeout: float = 10.0) -> Any:
|
||||
return self._request("PATCH", path, body=body, timeout=timeout)
|
||||
|
||||
def _request(
|
||||
self, method: str, path: str, *, body: Optional[dict] = None, timeout: float = 10.0
|
||||
) -> Any:
|
||||
@@ -494,20 +532,27 @@ class SuperProductivityApiReader:
|
||||
(p for p in projects if normalize_project_name(str(p.get("title", ""))) == target), None
|
||||
)
|
||||
if project is None:
|
||||
return OpenItems(count=0, waiting=())
|
||||
return OpenItems(count=0, waiting=(), items=())
|
||||
|
||||
waiting: list[WaitingItem] = []
|
||||
all_items: list[OpenItem] = []
|
||||
count = 0
|
||||
for task_id in project.get("taskIds") or []:
|
||||
task = tasks_by_id.get(task_id)
|
||||
if task is None or task.get("isDone"):
|
||||
continue
|
||||
count += 1
|
||||
if _is_waiting(task, tags_by_id):
|
||||
task_id_str = str(task.get("id", task_id))
|
||||
task_title = str(task.get("title", ""))
|
||||
is_waiting = _is_waiting(task, tags_by_id)
|
||||
if is_waiting:
|
||||
waiting.append(
|
||||
WaitingItem(title=str(task.get("title", "")), follow_up_at=_follow_up_at(task))
|
||||
WaitingItem(
|
||||
id=task_id_str, title=task_title, follow_up_at=_follow_up_at(task)
|
||||
)
|
||||
)
|
||||
return OpenItems(count=count, waiting=tuple(waiting))
|
||||
all_items.append(OpenItem(id=task_id_str, title=task_title, waiting=is_waiting))
|
||||
return OpenItems(count=count, waiting=tuple(waiting), items=tuple(all_items))
|
||||
|
||||
def someday_items(self) -> list[SomedayItem]:
|
||||
projects, tasks_by_id, _ = self._read()
|
||||
@@ -519,6 +564,7 @@ class SuperProductivityApiReader:
|
||||
continue
|
||||
items.append(
|
||||
SomedayItem(
|
||||
id=str(task.get("id", task_id)),
|
||||
title=str(task.get("title", "")),
|
||||
modified=_epoch_ms_to_date(task.get("updated") or task.get("created")),
|
||||
)
|
||||
@@ -606,6 +652,17 @@ class SuperProductivityWriter:
|
||||
|
||||
self._client.post("/tasks", body)
|
||||
|
||||
def close_item(self, item_id: str) -> None:
|
||||
"""`PATCH /tasks/:id` with `{"isDone": true}` (Gitea #138) - see the
|
||||
module docstring's "Closing an item" section for why this one field
|
||||
is bit-identical to the UI's own done checkbox and why no existence
|
||||
preflight is needed: an unknown `item_id` makes the same route
|
||||
return `404 TASK_NOT_FOUND` before writing anything, which
|
||||
`_ApiClient._request` already turns into a `ValidationError`. Never
|
||||
sends `DELETE` - see #138 E7, marking done is the only closing write
|
||||
this stack makes."""
|
||||
self._client.patch(f"/tasks/{item_id}", {"isDone": True})
|
||||
|
||||
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
|
||||
|
||||
@@ -148,6 +148,23 @@ def test_check2_waiting_overdue_threshold(tmp_path, remind_day, should_fire):
|
||||
assert fired == should_fire
|
||||
|
||||
|
||||
def test_check2_finding_carries_the_waiting_items_own_id(tmp_path):
|
||||
"""Gitea #138 - `gtd-weekly-review`'s `task close --id` proposal reads
|
||||
this off the finding rather than re-looking the item up by title."""
|
||||
projects = {"p1": {"id": "p1", "title": "Kueche renovieren", "created": _ms(2026, 1, 1),
|
||||
"taskIds": ["t1"], "backlogTaskIds": []}}
|
||||
tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot - Tobias", "isDone": False,
|
||||
"tagIds": ["tag-wait"], "dueWithTime": _ms(2026, 1, 1)}}
|
||||
tags = {"tag-wait": {"id": "tag-wait", "title": "waiting"}}
|
||||
backups_dir = _write_snapshot(tmp_path, projects, tasks, tags)
|
||||
_write_tasks_config(tmp_path, backups_dir)
|
||||
_project_page(tmp_path, "Kueche renovieren", "active")
|
||||
|
||||
report = run_review(tmp_path, today=TODAY)
|
||||
finding = next(f for f in report.findings if f.check == CHECK_WAITING_OVERDUE)
|
||||
assert finding.item_id == "t1"
|
||||
|
||||
|
||||
def test_check2_fires_for_an_all_day_waiting_item_with_no_due_with_time(tmp_path):
|
||||
"""The gap Gitea #135 closed: a `waiting` task scheduled all-day
|
||||
(`dueDay`, no `dueWithTime`, no reminder) must still surface as overdue -
|
||||
@@ -252,6 +269,34 @@ def test_check5_someday_stale_threshold(tmp_path, updated_ymd, should_fire):
|
||||
assert fired == should_fire
|
||||
|
||||
|
||||
def test_check5_finding_carries_the_someday_items_own_id(tmp_path):
|
||||
"""Gitea #138 - the same id the `task close --id` proposal needs."""
|
||||
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
|
||||
"taskIds": [], "backlogTaskIds": ["t3"]}}
|
||||
tasks = {"t3": {"id": "t3", "title": "Irgendwann Keller aufraeumen", "isDone": False,
|
||||
"tagIds": [], "updated": _ms(2025, 1, 1)}}
|
||||
backups_dir = _write_snapshot(tmp_path, projects, tasks, {})
|
||||
_write_tasks_config(tmp_path, backups_dir)
|
||||
|
||||
report = run_review(tmp_path, today=TODAY)
|
||||
finding = next(f for f in report.findings if f.check == CHECK_SOMEDAY_STALE)
|
||||
assert finding.item_id == "t3"
|
||||
|
||||
|
||||
def test_findings_with_no_specific_item_carry_no_item_id(tmp_path):
|
||||
"""Checks 1, 3 and 4 are about a whole project, not one item (Gitea
|
||||
#138) - their findings must not invent an id there is none for."""
|
||||
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
|
||||
"taskIds": [], "backlogTaskIds": []}}
|
||||
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
|
||||
_write_tasks_config(tmp_path, backups_dir)
|
||||
_project_page(tmp_path, "Ship Chemenu 7.0", "active")
|
||||
|
||||
report = run_review(tmp_path, today=TODAY)
|
||||
assert report.findings
|
||||
assert all(f.item_id is None for f in report.findings)
|
||||
|
||||
|
||||
# --- provider/configuration errors -------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -281,6 +281,7 @@ def test_open_items_reports_waiting_with_follow_up_at_from_due_with_time(cfg):
|
||||
result = reader.open_items("Ship Chemenu 7.0")
|
||||
assert len(result.waiting) == 1
|
||||
waiting = result.waiting[0]
|
||||
assert waiting.id == "t1"
|
||||
assert waiting.title == "Warte auf Angebot vom Elektriker - Tobias"
|
||||
assert waiting.follow_up_at == date(2026, 3, 1)
|
||||
|
||||
@@ -295,12 +296,26 @@ def test_open_items_unknown_project_is_empty_not_an_error(cfg):
|
||||
result = reader.open_items("No Such Project")
|
||||
assert result.count == 0
|
||||
assert result.waiting == ()
|
||||
assert result.items == ()
|
||||
|
||||
|
||||
def test_open_items_items_carries_id_title_and_waiting_for_every_open_item(cfg):
|
||||
"""Gitea #138 - `task list` reads this field, and it must agree with
|
||||
`waiting`: every waiting item also appears here, marked `waiting=True`."""
|
||||
reader = sp.SuperProductivityReader(cfg)
|
||||
result = reader.open_items("Ship Chemenu 7.0")
|
||||
by_id = {item.id: item for item in result.items}
|
||||
assert len(result.items) == result.count
|
||||
assert by_id["t1"].title == "Warte auf Angebot vom Elektriker - Tobias"
|
||||
assert by_id["t1"].waiting is True
|
||||
assert by_id["t2"].waiting is False
|
||||
|
||||
|
||||
def test_someday_items_come_from_backlog_task_ids_only(cfg):
|
||||
reader = sp.SuperProductivityReader(cfg)
|
||||
items = reader.someday_items()
|
||||
assert len(items) == 1
|
||||
assert items[0].id == "t3"
|
||||
assert items[0].title == "Irgendwann Keller aufraeumen"
|
||||
assert items[0].modified == date(2026, 1, 15)
|
||||
|
||||
@@ -510,7 +525,9 @@ def test_api_reader_open_items_counts_against_project_task_ids_not_project_id_fi
|
||||
result = reader.open_items("Ship Chemenu 7.0")
|
||||
assert result.count == 2 # t1, t2 - not the subtask t2b
|
||||
assert len(result.waiting) == 1
|
||||
assert result.waiting[0].id == "t1"
|
||||
assert result.waiting[0].follow_up_at == date(2026, 3, 15)
|
||||
assert {item.id for item in result.items} == {"t1", "t2"}
|
||||
|
||||
|
||||
def test_api_reader_someday_items():
|
||||
@@ -518,7 +535,7 @@ def test_api_reader_someday_items():
|
||||
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}) as server:
|
||||
reader = sp.SuperProductivityApiReader(_api_cfg(server))
|
||||
items = reader.someday_items()
|
||||
assert [i.title for i in items] == ["Irgendwann Keller aufraeumen"]
|
||||
assert [(i.id, i.title) for i in items] == [("t3", "Irgendwann Keller aufraeumen")]
|
||||
|
||||
|
||||
def test_api_reader_401_without_the_right_token_fails_loud():
|
||||
@@ -606,6 +623,23 @@ def _make_write_handler(state: dict, *, token: str = "test-token"):
|
||||
return
|
||||
self._reply(404, {"error": "not found"})
|
||||
|
||||
def do_PATCH(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.startswith("/tasks/"):
|
||||
task_id = self.path[len("/tasks/"):]
|
||||
known_ids = {t["id"] for t in state.get("tasks", [])}
|
||||
if task_id not in known_ids:
|
||||
self._reply(404, {"code": "TASK_NOT_FOUND", "message": "Task not found"})
|
||||
return
|
||||
state.setdefault("patched", []).append((task_id, payload))
|
||||
self._reply(200, {"id": task_id, **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)
|
||||
@@ -714,6 +748,27 @@ def test_create_item_waiting_without_the_tag_refuses_and_posts_nothing():
|
||||
assert "posted" not in state
|
||||
|
||||
|
||||
# --- write path: close_item (Gitea #138) ---------------------------------------
|
||||
|
||||
def test_close_item_patches_is_done_true_and_nothing_else():
|
||||
state = {"tasks": [{"id": "t1", "title": "x", "isDone": False}]}
|
||||
with _write_api_server(state) as server:
|
||||
writer = _writer_for(server)
|
||||
writer.close_item("t1")
|
||||
|
||||
assert state["patched"] == [("t1", {"isDone": True})]
|
||||
|
||||
|
||||
def test_close_item_unknown_id_refuses_and_writes_nothing():
|
||||
state = {"tasks": [{"id": "t1", "title": "x", "isDone": False}]}
|
||||
with _write_api_server(state) as server:
|
||||
writer = _writer_for(server)
|
||||
with pytest.raises(ValidationError):
|
||||
writer.close_item("no-such-id")
|
||||
|
||||
assert "patched" not in state
|
||||
|
||||
|
||||
# --- equivalence: both access paths agree on the same fixture (Gitea #133) --------
|
||||
|
||||
def test_snapshot_and_api_readers_agree_on_the_same_fixture(tmp_path):
|
||||
|
||||
@@ -72,6 +72,24 @@ def _make_handler(state: dict, *, token: str = _API_TOKEN):
|
||||
return
|
||||
self._reply(404, {"error": "not found"})
|
||||
|
||||
def do_PATCH(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.startswith("/tasks/"):
|
||||
task_id = self.path[len("/tasks/"):]
|
||||
record = next((t for t in state.get("tasks", []) if t.get("id") == task_id), None)
|
||||
if record is None:
|
||||
self._reply(404, {"code": "TASK_NOT_FOUND", "message": "Task not found"})
|
||||
return
|
||||
record.update(payload)
|
||||
state.setdefault("patched", []).append((task_id, payload))
|
||||
self._reply(200, 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)
|
||||
@@ -278,3 +296,86 @@ def test_snapshot_access_has_no_write_path(monkeypatch, kb_dir, tmp_path):
|
||||
])
|
||||
assert result.exit_code == 1
|
||||
assert "access: 'api'" in result.output
|
||||
|
||||
|
||||
# --- `task list` (Gitea #138) -------------------------------------------------
|
||||
|
||||
|
||||
def test_list_shows_id_title_and_waiting_marker(monkeypatch, kb_dir):
|
||||
root = kb_dir.parent
|
||||
state = {
|
||||
"projects": [{**_project_record(), "taskIds": ["t1", "t2"]}],
|
||||
"tasks": [
|
||||
{"id": "t1", "title": "Nachfassen beim Elektriker", "isDone": False,
|
||||
"tagIds": ["tag-wait"]},
|
||||
{"id": "t2", "title": "Kickoff-Meeting vorbereiten", "isDone": False, "tagIds": []},
|
||||
],
|
||||
"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", "list", "--project", "ship CHEMENU 7.0"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "t1\tNachfassen beim Elektriker [WAITING]" in result.output
|
||||
assert "t2\tKickoff-Meeting vorbereiten" in result.output
|
||||
assert "t2\tKickoff-Meeting vorbereiten [WAITING]" not in result.output
|
||||
|
||||
|
||||
def test_list_empty_project_says_so(monkeypatch, kb_dir):
|
||||
root = kb_dir.parent
|
||||
state = {"projects": [_project_record()], "tasks": []}
|
||||
with _api_server(state) as server:
|
||||
_write_tasks_config(root, _base_url(server))
|
||||
result = _invoke(monkeypatch, kb_dir, ["task", "list", "--project", "Ship Chemenu 7.0"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "No open items" in result.output
|
||||
|
||||
|
||||
# --- `task close` (Gitea #138) ------------------------------------------------
|
||||
|
||||
|
||||
def test_close_marks_the_item_done(monkeypatch, kb_dir):
|
||||
root = kb_dir.parent
|
||||
state = {"tasks": [{"id": "t1", "title": "x", "isDone": False}]}
|
||||
with _api_server(state) as server:
|
||||
_write_tasks_config(root, _base_url(server))
|
||||
result = _invoke(monkeypatch, kb_dir, ["task", "close", "--id", "t1"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert state["patched"] == [("t1", {"isDone": True})]
|
||||
|
||||
|
||||
def test_close_unknown_id_is_refused_and_writes_nothing(monkeypatch, kb_dir):
|
||||
root = kb_dir.parent
|
||||
state = {"tasks": []}
|
||||
with _api_server(state) as server:
|
||||
_write_tasks_config(root, _base_url(server))
|
||||
result = _invoke(monkeypatch, kb_dir, ["task", "close", "--id", "no-such-id"])
|
||||
assert result.exit_code == 1
|
||||
assert "patched" not in state
|
||||
|
||||
|
||||
def test_close_on_snapshot_access_is_refused_the_same_way_as_task_new(monkeypatch, kb_dir):
|
||||
"""Gitea #138 E7/#133: closing is a write like creating - `access:
|
||||
'snapshot'` never offers a `TaskWriter`, so `task close` refuses with
|
||||
the exact message `task new` already gives on the same configuration."""
|
||||
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", "close", "--id", "t1"])
|
||||
assert result.exit_code == 1
|
||||
assert "access: 'api'" in result.output
|
||||
Reference in new issue
Block a user