Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 75 additions & 6 deletions docs/design/brain/vault-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ cheap).

**State documents** (the family's current truth; mutable through the
CLI or by hand; never carry `generated`; see §7):
- `todos` — a topic's todo list.
- `list`: a family list, such as groceries, a store or things to do (§5, §7).
- `person` (declared) — someone the family knows, one file each under
`<shared_bucket>/people/`.

Expand Down Expand Up @@ -263,6 +263,72 @@ presence.
### `index` (generated)
`type: index`, `generated: true`, `title` optional. Body is navigation.

### `list`
The family's file for one list, `<bucket>/lists/<slug>.md`. The file
name is the slug. A state document (§7), changed by hand or with
`stack memory list`, never generated.

| Field | | Notes |
|---|---|---|
| `type` | R | `list` |
| `title` | R | what the family calls the list. |
| `description` | O | what goes on it ("Was wir im Supermarkt brauchen"); the agent picks a list by it. |
| `topics` | O | list; the topics the list belongs to, by slug. |
| `sections` | O | list; the sections in the order the family walks through the store. |
| `recurring` | O | `true` for a list used again and again (a packing list, the weekly chores): its ticked items stay until it is reset. |

```
---
type: list
title: Einkaufsliste
description: Was wir im Supermarkt brauchen
topics:
- einkaeufe-besorgungen
sections:
- Gemüse & Obst
- Milchprodukte & Eier
---

- [ ] Batterien

## Gemüse & Obst
- [ ] Äpfel

## Milchprodukte & Eier
- [ ] Joghurt [Marge](../people/marge.md) 📅 2026-10-14
- [x] Milch ✅ 2026-10-10
```

The body holds one item per checkbox line. A section is a `##`
heading, kept in the order of `sections`; one the list does not name
comes after them, and an item without a section sits above the first
heading. Anything else in the body (a note under a heading, an indented
sub-item) belongs to the family and is left as it is.

An item is what the family wrote, then its fields, in this order, at
the end of the line:

- a relative link to a person in `<bucket>/people/` assigns the item to
them; several links, several people, and one tick closes it for all;
- any other relative link into the vault names the record the item came
from;
- `📅 YYYY-MM-DD` is the day it is due, `✅ YYYY-MM-DD` the day it was
ticked: the Obsidian Tasks plugin's fields, so its queries read them.

Only the trailing run of these counts. A link inside the sentence, a
web link and any other field stay part of the text. The same line is
used wherever an item appears; a record's summary writes it without the
checkbox, because the list holds whether it is done.

A ticked item stays on its list for the rest of the day. The night after,
the curator moves it to the list's archive,
`<bucket>/lists/archive/<slug>/<YYYY-MM>.md` for the month it was ticked:
a list file of its own (`title: Einkaufsliste, 2026-10`, no topics), with
the line and its section as they were. A tick without a date gets the
day's date first. A `recurring` list is left alone. The archive is where
`stack memory list add` learns where an item was last bought, its
section and its spelling.

## 6. The `generated` invariant (from B3 / ADR-011)

- A generated projection page MUST carry `generated: true`.
Expand All @@ -274,11 +340,14 @@ presence.

## 7. State documents

`todos.md` is a state document, not a generated page: it is mutable
current truth (a tick is information), lives in memory source, and never
carries `generated`. It is a markdown checklist; frontmatter is optional
and, if present, uses `type: todos`. State documents get read-your-writes
through the CLI (ADR-011); they are not regenerated.
`<bucket>/lists/<slug>.md` is a state document (§5, `list`): a tick is
information, so a list is never regenerated, and a command changes only
the lines it is about.

Before lists, each topic kept its todos in `<bucket>/<topic>/todos.md`.
`stack up memory` moves every one of them to `<bucket>/lists/<topic>.md`,
in one commit by the memory bot, keeping each line and tick and turning
the old due date into `📅`. Nothing writes `todos.md` any more.

`<shared_bucket>/people/<id>.md` is a declared person (§5): the
family's own facts about someone, starting with their names. On every
Expand Down
32 changes: 32 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Setting the server up, or keeping it running? That is the [Admin Guide](admin-gu
| Find a document again | [Ask questions](#ask-questions) |
| Save a link, note or PDF for later | [Capture rooms](#save-anything-capture-rooms) |
| Collect everything about one project | [Topic rooms](#topic-rooms) |
| Keep the shopping list, or what is still to do | [Lists](#lists) |
| Get email into the family chat | [Email](#email) |
| Capture a thought without typing | [Voice memos](#voice-memos) |
| Fix a wrong tag or title | [Correct bot mistakes](#correct-bot-mistakes) |
Expand Down Expand Up @@ -125,6 +126,37 @@ Questions asked inside a topic room automatically search just that topic. Ask "A

---

## Lists

> Needs the `memory` stacklet. Keeping lists by chat needs the `agent` stacklet (Stacky).

The shopping list, the market, the camping packing list, what still has to happen for the school trip: every list the family keeps is a checklist that Stacky, the family agent, keeps for you. Talk to it in any room it is in, starting with its name. German works as well as English.

**Start a list once, with the store in walking order.** The sections are the order you go through the shop, so the list reads top to bottom in the aisles:

> Stacky, start a list "Supermarket" with the sections Fruit & Veg, Bread, Dairy, Frozen, Drugstore.

Say what goes on a list when you start it ("the farmers' market on Saturday: vegetables, cheese, eggs"). That is how Stacky later knows where something new belongs. Changed your mind about the order? "Stacky, the drugstore comes first now."

**Then just say what you need.** "Stacky, we need milk, eggs and toothpaste." Each item lands in its section. Something you have bought before goes back to the list and the section it had, so after the first week nobody has to say where.

**Tick off as you shop.** "Stacky, I got the milk and the bread." You don't have to say which list. A ticked item stays visible for the rest of the day, so "did we get eggs?" still has an answer, and moves to the list's archive overnight. The list then shows only what is still open.

More you can say:

- "Stacky, I'll get the eggs at the market instead" moves them to the market list.
- "Stacky, Homer should book the tickets by Friday" puts a name and a day on an item.
- "Stacky, what's on the supermarket list?" shows what is still open.
- "Stacky, we're back from camping, reset the packing list." Start a list you use again and again (packing, the weekly chores) as a recurring list: its ticks stay until you reset it.

**Letters and notes put their to-dos on lists too.** When a letter in **#Documents** asks you to do something ("sign the permission slip by 23 October"), the archivist puts it on the list of the letter's topic, with the day and a link back to the letter. A short note in your own words, pinned with 📌 in a [topic room](#topic-rooms) ("we should take out the trash by Friday"), does the same. Long pasted text and links never turn into to-dos: someone else's tips are not your chores. Tick these off with Stacky like anything else.

**A list belongs to a topic.** In a topic room, Stacky uses that topic's lists. To tie an existing list to a room: "Stacky, the supermarket list belongs to this room."

Every list is a page in the [family wiki](#the-family-wiki) and a plain Markdown checklist in the vault, so you can also tick items in Obsidian or in Forgejo's editor. Stacky does not hear voice messages or see photos yet: a screenshot of a list has to be typed in for now.

---

## Email

> Needs the `core` stacklet, plus `docs` and `code` to file what arrives. Connected by the admin (see the admin guide).
Expand Down
30 changes: 11 additions & 19 deletions lib/stack/briefing.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

from __future__ import annotations

from .lists import DUE, action_item


def render_briefing(
*,
Expand Down Expand Up @@ -83,28 +85,18 @@ def action_item_lines(items: list) -> list[str]:


def format_action_item(ai) -> str | None:
"""``{action, due}`` → ``- [ ] action — YYYY-MM-DD`` or ``- [ ] action``.

Args:
ai: Action item as a dict (with "action" and optional "due")
or a plain string.
"""An action item as a line of the summary: `- action 📅 YYYY-MM-DD`.

Returns:
Formatted checkbox line, or None when the item is empty/invalid.
The same text and due date the item gets on the topic's list
(`stack.lists.action_item`), without the checkbox: the list holds
whether it is done, and a box here would never be ticked. Obsidian
reads only checkbox lines as tasks, so this copy is not counted
twice. None for an empty or invalid item.
"""
if isinstance(ai, str):
return f"- [ ] {ai.strip()}" if ai.strip() else None
if not isinstance(ai, dict):
return None
action = (ai.get("action") or "").strip()
if not action:
item = action_item(ai)
if item is None:
return None
due = ai.get("due")
if isinstance(due, str):
due_clean = due.strip()
if due_clean and due_clean.lower() not in ("null", "none", "n/a"):
return f"- [ ] {action} — {due_clean}"
return f"- [ ] {action}"
return f"- {item.text} {DUE} {item.due}" if item.due else f"- {item.text}"


def read_briefing(text: str) -> tuple[str, list[str]]:
Expand Down
11 changes: 10 additions & 1 deletion lib/stack/frontmatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,12 @@ class FrontmatterError(ValueError):
# Projections: generated wiki pages
PROJECTION_TYPES = {"person", "correspondent", "topic", "index"}

# State documents: the family's current truth, changed by hand or by a
# command, never generated (§7)
STATE_TYPES = {"list"}

# All valid types
TYPES = RECORD_TYPES | PROJECTION_TYPES
TYPES = RECORD_TYPES | PROJECTION_TYPES | STATE_TYPES


# ── Per-type field definitions (§5) ───────────────────────────────────
Expand Down Expand Up @@ -84,6 +88,11 @@ class TypeSchema:
required={"type", "generated"},
list_fields=set(),
),
"list": TypeSchema(
required={"type", "title"},
list_fields={"topics", "sections"},
scalar_fields={"title", "description", "recurring"},
),
}


Expand Down
21 changes: 15 additions & 6 deletions lib/stack/links.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
# or a person, so the resolver never has to guess. They come in two
# families, and which one a new kind belongs to is the whole design:
#
# entities, addressed by NAME — `topic`, `person`
# entities, addressed by NAME — `topic`, `person`, `list`
# A name a person could type, for something that has an identity
# beyond any one file. A trailing leaf (`todo`) selects a
# sub-page instead of the overview.
Expand Down Expand Up @@ -108,6 +108,15 @@ def go_person(slug: str, leaf: str | None = None) -> str:
return _entity_path("person", slug, leaf)


def go_list(slug: str) -> str:
"""`/list/<slug>` — one of the family's lists, by its name.

>>> go_list("einkaufsliste")
'/list/einkaufsliste'
"""
return _entity_path("list", slug, None)


def go_page(repo_path: str) -> str:
"""Logical link for a vault page path, or "" when no stable route exists.

Expand All @@ -116,8 +125,8 @@ def go_page(repo_path: str) -> str:
resolver route get a link; anything else returns "" rather than a
path-keyed link that breaks on the next rename.

>>> go_page("family/camping/todos.md")
'/topic/family/camping/todo'
>>> go_page("family/lists/einkaufsliste.md")
'/list/einkaufsliste'
>>> go_page("family/camping/about.md")
'/topic/family/camping'
>>> go_page("homer/about.md")
Expand All @@ -128,9 +137,9 @@ def go_page(repo_path: str) -> str:
parts = [p for p in repo_path.strip("/").split("/") if p]
if len(parts) < 2:
return ""
if parts[-1] == "todos.md":
leaf = "todo"
elif parts[-1] == "about.md":
if parts[:2] == ["family", "lists"] and len(parts) == 3 and parts[2].endswith(".md"):
return go_list(parts[2].removesuffix(".md"))
if parts[-1] == "about.md":
leaf = None
else:
return ""
Expand Down
12 changes: 6 additions & 6 deletions lib/stack/list_doc.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""A list page, and what changed between two versions of one.

A list lives in `todos.md`, and more than one thing writes it: the
curator merging extracted action items, a person editing it in Forgejo's
editor, and an agent asked to tidy it up. The interesting failure is not a
A list lives in `<bucket>/lists/<slug>.md`, and more than one thing writes
it: the archivist adding extracted action items, a person editing it in
Forgejo's editor, and an agent asked to tidy it up. The interesting failure is not a
malformed document. It is a quiet one: six of twenty-five items gone and a
cheerful confirmation that everything went fine.

Expand All @@ -28,7 +28,7 @@

SEE ALSO
docs/design/brain/write-layer.md why this is the first piece
stacklets/memory/bot/cli/todo_list.py the task-line grammar it shares
lib/stack/lists.py the list format; its item line is the grammar
"""

from __future__ import annotations
Expand All @@ -38,8 +38,8 @@
from difflib import SequenceMatcher

# An Obsidian task line, open or done: group 1 is the box char, group 2 the
# task text. Same grammar `todo_list.py` reads and writes, restated here
# because this module is the one both sides will eventually share.
# task text, fields included. A coarser read of the line `stack.lists`
# writes, enough to tell what an edit added, removed or ticked.
_TASK = re.compile(r"^\s*[-*]\s+\[([ xX])\]\s+(.+?)\s*$")
_SECTION = re.compile(r"^\s*##\s+(.+?)\s*$")

Expand Down
Loading
Loading