task new: einen zweiten Schreibweg in den Tracker (ein Posten, keine Seite, #132)
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:
1 parent
e07d1ca42a
commit
cfbe3ea83e
12 files changed
+845
-45
No files matched your search
+35
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}.")
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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."
|
||||||
|
)
|
||||||
@@ -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
|
"super productivity",
|
||||||
identically in every instance, whichever task-tracker provider it runs
|
"azure devops",
|
||||||
against - so its body must name none of them, and none of a provider's
|
"superproductivity",
|
||||||
file or API shape either. Reads the real repo's file, not a fixture,
|
"db.json",
|
||||||
because the claim is about what ships, not about the discovery logic."""
|
"rest api",
|
||||||
text = (config.INSTRUCTIONS_DIR / "gtd-weekly-review" / "SKILL.md").read_text(encoding="utf-8").lower()
|
".wikitool-tasks.json",
|
||||||
forbidden = [
|
]
|
||||||
"super productivity",
|
|
||||||
"azure devops",
|
|
||||||
"superproductivity",
|
@pytest.mark.parametrize("skill_name", ["gtd-weekly-review", "wiki-ingest"])
|
||||||
"db.json",
|
def test_task_tracker_skills_name_no_provider(skill_name):
|
||||||
"rest api",
|
"""#119 D25/#127 AC1, extended by #132: any skill that can reach the task
|
||||||
".wikitool-tasks.json",
|
tracker - the weekly review, and now the ingest skill's own commitment
|
||||||
]
|
step (`task new`) - is meant to read identically in every instance,
|
||||||
hits = [term for term in forbidden if term in text]
|
whichever provider it runs against, so its body must name none of them,
|
||||||
assert not hits, f"gtd-weekly-review/SKILL.md names a provider or its shape: {hits}"
|
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):
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user