upravy projektu a skillu

This commit is contained in:
lachtan
2026-09-02 10:36:37 +02:00
parent 0fa619bbbe
commit f77cc2dcfe
19 changed files with 3875 additions and 52 deletions

View File

@@ -4,9 +4,9 @@ description: >
Switch between named ongoing projects, each with its own persistent
context, carried forward across turns until the user switches or ends it.
Triggers on: "project X", "switch to project X", "we're working on X",
"end project" / "no project". For a long-lived, named work context — not a
single task to plan and execute, and not a short or global fact to
remember.
"list projects", "end project" / "no project". For a long-lived, named
work context — not a single task to plan and execute, and not a short or
global fact to remember.
---
# Project
@@ -19,17 +19,28 @@ separate from every other project and from the agent's general memory.
`workspace/projects/<slug>/`:
- `prompt.md`project-specific context/instructions, read in full whenever
the project becomes active
- `prompt.md`**what doesn't change**: purpose, goals, scope, constraints,
how the user wants to work on this. Read in full whenever the project
becomes active.
- `memory.md` — append-only chronological log of decisions and history
- `state.md`living document: the current state/synthesis of the project,
edited in place, not appended to
- `state.md`**what changes**: the living synthesis of where the project is
now. Progress, current approach, open questions, "what's next". Edited in
place, never appended to.
- `artifacts/` — generated files (documents, code, data exports, reports)
If you're about to write anything time-varying into `prompt.md` (a "current
status" or "next steps" section), put it in `state.md` instead.
**Which project is active is tracked purely through conversation memory —
nothing is persisted to disk for that.** Once a project is chosen, keep
treating it as active for the rest of this conversation; don't re-read its
files on every subsequent turn once they're already in context.
nothing is persisted to disk for that.**
## Script
All project file operations go through one script, run from the workspace root:
`uv run skills/project/scripts/project_cli.py <subcommand>`
It owns the entry date and the append hygiene so they can't be guessed wrong.
## Activation
@@ -37,20 +48,19 @@ Triggered by "project X" / "switch to project X" / "we're working on X":
1. Turn `X` into a kebab-case directory name (like picking a slug for a plan
file — no need to spell out an algorithm, just pick something sensible).
2. **`workspace/projects/<slug>/` already exists** → read `prompt.md`,
`memory.md`, and `state.md` in full, then briefly confirm what's active
and, if `prompt.md` or `state.md` has content, what context you loaded.
3. **It doesn't exist, but the name is a close match to one or more existing
projects** (case-insensitive) → ask which one was meant. Don't guess, and
don't create a near-duplicate of an existing project.
4. **It doesn't exist and there's no close match** → this skill only works
with projects that already exist without asking. Ask the user whether to
start a new project with that name. Only on explicit yes, create
`prompt.md`, `memory.md`, `state.md` (all empty) and `artifacts/`. Never
create a project just because a trigger phrase was said.
2. Run `project_cli.py activate <slug>`. On success it prints `prompt.md`,
`memory.md` and `state.md` (creating any that are missing), so no separate
reads are needed. Then briefly confirm what's active and, if `prompt.md` or
`state.md` has content, what context you loaded.
3. **Exit 1 means no such project.** The error lists the existing ones. If one
is a close match (case-insensitive), ask which was meant — don't guess, and
don't create a near-duplicate.
4. **No close match** → ask the user whether to start a new project with that
name. Only on an explicit yes, run `project_cli.py new <slug>`. Never create
a project just because a trigger phrase was said.
**Already active:** if the same project is already active in this
conversation, don't re-create or re-read anything — just continue.
conversation, don't re-run activation — just continue.
## Staying active
@@ -58,6 +68,11 @@ Once a project is active, keep applying its `prompt.md` instructions and
`state.md` context for the rest of the conversation, until the user switches
or ends it.
**If the file contents are no longer in your context** (a long conversation
gets compacted, and the loaded files can drop out of it while the memory that
a project is active stays), run `activate` again. Never answer from a faded
recollection of `prompt.md` or `state.md`.
If a long gap or an ambiguous reference makes it unclear whether the project
is still the right context (e.g. the conversation has clearly moved to an
unrelated topic), ask rather than silently carrying it forward or silently
@@ -74,55 +89,84 @@ dropping it.
## Writing to memory.md
Append only — never rewrite or reorder existing entries. One entry per
decision, dead end, or noteworthy piece of history:
**Only ever through the script** — never `edit_file` or `write_file`. Pass the
text on stdin with a quoted heredoc so quotes and apostrophes survive verbatim:
```
- YYYY-MM-DD: <terse entry, reformulated, not verbatim>
uv run skills/project/scripts/project_cli.py log <slug> <<'NOTE'
<entry text>
NOTE
```
Write an entry when a decision is made, a dead end is found, or a fact
central to the project's ongoing context emerges — not for routine
back-and-forth. When in doubt whether something is memory-worthy, prefer not
writing it; `memory.md` is for what a future conversation needs to pick up
the thread, not a transcript.
The script prepends today's date and guarantees the entry starts on its own
line. Write the entry in the user's language, reformulated, not verbatim.
Write an entry when a decision is made, a dead end is found, or a fact central
to the project's ongoing context emerges — not for routine back-and-forth. When
in doubt, **write it**: losing something the user told you is worse than an
entry that turns out to be unremarkable. Entry length is not limited — capture
the reasoning behind a decision, not just its outcome.
**Correcting an entry:** `memory.md` is append-only, so never rewrite history.
Log a new entry starting with `correction:` that states what was wrong.
## Maintaining state.md
Unlike `memory.md`, `state.md` is edited in place: surgically update the
relevant section when the project's current state or understanding has
moved on enough that the old text would mislead a reader. It answers "where
is this now", not "what happened" — old content gets replaced, not appended
to. If it's still empty and the project has accumulated enough context to
synthesize, offer to draft it.
Edited in place with `edit_file`: surgically update the relevant section when
the project's current state or understanding has moved on enough that the old
text would mislead a reader. It answers "where is this now", not "what
happened" — old content gets replaced, not appended to.
**If `state.md` is empty and `memory.md` has content, draft it** from that
history and show the user the result. Don't just offer. Likewise, if
`prompt.md` still holds a time-varying section, move it here.
## Answering questions about a project
Answers that live in project files come **from the files**, not from memory:
read or `grep` them. If a project is active, its files are the first place to
look; with no project active, `grep` across `workspace/projects/` to find where
something was written.
## What goes where
While a project is active, a fact tied to that project goes into its
`memory.md` via the script. A durable fact about the user in general — one that
would still matter with no project active — goes to the normal memory path
instead.
## Artifacts
Files generated while working on the project (documents, code, data exports,
reports) go in `workspace/projects/<slug>/artifacts/` instead of scattered
elsewhere. Name them descriptively; no numbering or index file needed at
reports) go in `workspace/projects/<slug>/artifacts/`, created the first time
one is written. Name them descriptively; no numbering or index file needed at
this scale.
## Growth
`memory.md` grows without limit and is **never compacted, archived or
summarized**. When it gets long, `activate` simply stops printing all of it and
says where the rest is — read or `grep` the file directly for older context.
Never delete, shorten or rewrite stored content to save space.
## Listing
**"list projects" / "which projects exist":** list the directory names under
`workspace/projects/`. If none exist, say so.
**"list projects" / "which projects exist":** run `project_cli.py list`. It
prints each project with its file sizes; `(!)` marks an empty `state.md`.
## Edge cases
- "project" with no name → ask which project.
- The project directory exists but one or more of `prompt.md` / `memory.md`
/ `state.md` is missing (e.g. created by hand) → create the missing
file(s) empty, don't error.
- Missing `prompt.md` / `memory.md` / `state.md` (e.g. directory made by hand)
`activate` creates them, don't error.
- Deleting or renaming a project is out of scope for this skill — point the
user at `workspace/projects/<slug>/` to do it by hand.
## Rules
- This skill's body is English; reply to the user in their own language.
- This skill's body is English; reply to the user in their own language, and
write project files in the user's language too.
- Never fabricate project content — `prompt.md`, `memory.md`, and `state.md`
only grow from what the user actually said or what actually happened.
- Never create a new project without the user's explicit confirmation.
- Don't force a project context onto an unrelated request.
- The Dream memory processor must not touch `workspace/projects/` — it is
outside the memory and skills Dream curates.

View File

@@ -0,0 +1,213 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""project_cli.py — deterministic file operations for the /project skill.
The agent must never hand-edit projects/<slug>/memory.md: dates get invented and
appends get joined onto the previous line when the anchor is guessed. This script
owns both — the date comes from the system clock, the newline is guaranteed.
Nothing here ever shortens, rewrites or deletes stored content. `activate` may
omit older memory entries from its *output* when the whole project would exceed
the tool-result limit, but the files on disk are left untouched.
"""
import argparse
import json
import os
import sys
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
# workspace/skills/project/scripts/project_cli.py -> parents[3] = workspace root.
WORKSPACE = Path(__file__).resolve().parents[3]
TIMEZONE = ZoneInfo("Europe/Prague")
PROJECT_FILES = ("prompt.md", "memory.md", "state.md")
# Tool results above `maxToolResultChars` (16000, server config.json) are offloaded
# to a file the agent then has to read back in pieces. Stay under it with a margin.
MAX_OUTPUT_CHARS = 14_400
def projects_dir() -> Path:
"""Root of the project store; PROJECTS_DIR overrides it for tests."""
override = os.environ.get("PROJECTS_DIR")
return Path(override) if override else WORKSPACE / "projects"
def project_path(slug: str) -> Path:
return projects_dir() / slug
def existing_slugs() -> list[str]:
root = projects_dir()
if not root.is_dir():
return []
return sorted(entry.name for entry in root.iterdir() if entry.is_dir())
def read_file(path: Path) -> str:
try:
return path.read_text(encoding="utf-8")
except FileNotFoundError:
return ""
def ensure_project_files(directory: Path) -> None:
"""Create any missing project file as empty — a hand-made directory must work."""
directory.mkdir(parents=True, exist_ok=True)
for name in PROJECT_FILES:
path = directory / name
if not path.exists():
path.write_text("", encoding="utf-8")
def format_size(size: int) -> str:
if size < 1024:
return f"{size}B"
return f"{size / 1024:.1f}K"
def fit_memory(memory: str, budget: int, slug: str) -> str:
"""Drop the oldest entries from the *output* until it fits the budget.
The file itself is never modified — the note tells the agent where the rest is.
"""
if len(memory) <= budget:
return memory
lines = memory.splitlines(keepends=True)
kept: list[str] = []
used = 0
for line in reversed(lines):
if used + len(line) > budget:
break
kept.append(line)
used += len(line)
kept.reverse()
omitted = len(lines) - len(kept)
note = (
f"[… {omitted} older entries not shown, full log: "
f"projects/{slug}/memory.md — read it when you need older context]\n"
)
return note + "".join(kept)
def cmd_activate(slug: str) -> int:
directory = project_path(slug)
if not directory.is_dir():
slugs = existing_slugs()
listing = ", ".join(slugs) if slugs else "(none)"
print(f"No such project: {slug}. Existing: {listing}", file=sys.stderr)
return 1
ensure_project_files(directory)
prompt = read_file(directory / "prompt.md")
memory = read_file(directory / "memory.md")
state = read_file(directory / "state.md")
# prompt.md and state.md always go out whole; only memory.md gives ground.
overhead = len(prompt) + len(state) + 200
memory_out = fit_memory(memory, max(MAX_OUTPUT_CHARS - overhead, 0), slug)
sections = [
f"### prompt.md\n{prompt}",
f"### memory.md\n{memory_out}",
f"### state.md\n{state}",
]
print("\n\n".join(section.rstrip() + "\n" for section in sections), end="")
if not state.strip():
print("\n[!] state.md is empty")
return 0
def cmd_log(slug: str, text: str | None) -> int:
directory = project_path(slug)
if not directory.is_dir():
slugs = existing_slugs()
listing = ", ".join(slugs) if slugs else "(none)"
print(f"No such project: {slug}. Existing: {listing}", file=sys.stderr)
return 1
body = text if text is not None else sys.stdin.read()
body = body.strip()
if not body:
print("Nothing to log (empty input).", file=sys.stderr)
return 1
today = datetime.now(TIMEZONE).date().isoformat()
entry = f"- {today}: {body}\n"
memory_file = directory / "memory.md"
existing = read_file(memory_file)
# Guarantee the new entry starts on its own line, whatever the file ends with.
separator = "" if not existing or existing.endswith("\n") else "\n"
with memory_file.open("a", encoding="utf-8") as handle:
handle.write(separator + entry)
print(json.dumps({"appended": entry.rstrip("\n")}, ensure_ascii=False))
return 0
def cmd_list() -> int:
slugs = existing_slugs()
if not slugs:
print("(no projects yet)")
return 0
width = max(len(slug) for slug in slugs)
for slug in slugs:
directory = project_path(slug)
sizes = []
for name in PROJECT_FILES:
path = directory / name
size = path.stat().st_size if path.is_file() else 0
label = name.removesuffix(".md")
flag = " (!)" if name == "state.md" and size == 0 else ""
sizes.append(f"{label} {format_size(size)}{flag}")
print(f"{slug:<{width}} " + " ".join(sizes))
return 0
def cmd_new(slug: str) -> int:
directory = project_path(slug)
if directory.exists():
print(f"Project already exists: {slug}", file=sys.stderr)
return 1
ensure_project_files(directory)
print(json.dumps({"created": slug, "files": list(PROJECT_FILES)}, ensure_ascii=False))
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="File operations for the /project skill")
sub = parser.add_subparsers(dest="command", required=True)
activate = sub.add_parser("activate", help="Print a project's three files")
activate.add_argument("slug")
log = sub.add_parser("log", help="Append a dated entry to memory.md")
log.add_argument("slug")
log.add_argument(
"--text", default=None, help="Entry text; if omitted, read from stdin"
)
sub.add_parser("list", help="List projects with file sizes")
new = sub.add_parser("new", help="Create an empty project")
new.add_argument("slug")
args = parser.parse_args()
if args.command == "activate":
return cmd_activate(args.slug)
if args.command == "log":
return cmd_log(args.slug, args.text)
if args.command == "list":
return cmd_list()
return cmd_new(args.slug)
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,184 @@
"""Tests for project_cli.py — deterministic file operations for the /project skill."""
import io
import json
import sys
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import project_cli # noqa: E402
@pytest.fixture
def projects(tmp_path, monkeypatch):
root = tmp_path / "projects"
root.mkdir()
monkeypatch.setenv("PROJECTS_DIR", str(root))
return root
def make_project(projects, slug, prompt="", memory="", state=""):
directory = projects / slug
directory.mkdir()
(directory / "prompt.md").write_text(prompt, encoding="utf-8")
(directory / "memory.md").write_text(memory, encoding="utf-8")
(directory / "state.md").write_text(state, encoding="utf-8")
return directory
def today():
return datetime.now(ZoneInfo("Europe/Prague")).date().isoformat()
# -- log ---------------------------------------------------------------------
def test_log_appends_with_today_date(projects, capsys):
directory = make_project(projects, "chata")
assert project_cli.cmd_log("chata", "Dřevo objednáno") == 0
memory = (directory / "memory.md").read_text(encoding="utf-8")
assert memory == f"- {today()}: Dřevo objednáno\n"
assert json.loads(capsys.readouterr().out)["appended"].startswith(f"- {today()}:")
def test_log_does_not_join_when_file_lacks_trailing_newline(projects):
directory = make_project(projects, "chata", memory="- 2026-09-01: první")
project_cli.cmd_log("chata", "druhý")
lines = (directory / "memory.md").read_text(encoding="utf-8").splitlines()
assert lines == ["- 2026-09-01: první", f"- {today()}: druhý"]
def test_log_creates_missing_memory_file(projects):
directory = projects / "chata"
directory.mkdir()
assert project_cli.cmd_log("chata", "první") == 0
assert (directory / "memory.md").read_text(encoding="utf-8").endswith("první\n")
def test_log_reads_stdin_verbatim(projects, monkeypatch):
directory = make_project(projects, "chata")
text = "Uvozovky „takhle\" a apostrof ' a \"tohle\"\ndruhý řádek"
monkeypatch.setattr(sys, "stdin", io.StringIO(text))
assert project_cli.cmd_log("chata", None) == 0
memory = (directory / "memory.md").read_text(encoding="utf-8")
assert memory == f"- {today()}: {text}\n"
def test_log_does_not_shorten_long_entry(projects):
directory = make_project(projects, "chata")
text = "x" * 3000
project_cli.cmd_log("chata", text)
assert text in (directory / "memory.md").read_text(encoding="utf-8")
def test_log_rejects_empty_input(projects):
directory = make_project(projects, "chata", memory="- 2026-09-01: první\n")
assert project_cli.cmd_log("chata", " ") == 1
assert (directory / "memory.md").read_text(encoding="utf-8") == "- 2026-09-01: první\n"
def test_log_rejects_unknown_project(projects):
assert project_cli.cmd_log("neznamy", "text") == 1
# -- activate ----------------------------------------------------------------
def test_activate_prints_all_three_sections(projects, capsys):
make_project(projects, "chata", prompt="# Chata", memory="- 2026-09-01: a\n", state="stav")
assert project_cli.cmd_activate("chata") == 0
out = capsys.readouterr().out
assert "### prompt.md\n# Chata" in out
assert "### memory.md\n- 2026-09-01: a" in out
assert "### state.md\nstav" in out
assert "[!] state.md is empty" not in out
def test_activate_creates_missing_files(projects, capsys):
directory = projects / "chata"
directory.mkdir()
assert project_cli.cmd_activate("chata") == 0
assert all((directory / name).is_file() for name in project_cli.PROJECT_FILES)
def test_activate_flags_empty_state(projects, capsys):
make_project(projects, "chata", memory="- 2026-09-01: a\n")
project_cli.cmd_activate("chata")
assert "[!] state.md is empty" in capsys.readouterr().out
def test_activate_omits_oldest_entries_without_touching_disk(projects, capsys):
memory = "".join(f"- 2026-09-01: entry {i} {'x' * 200}\n" for i in range(200))
directory = make_project(projects, "big", prompt="P", memory=memory, state="S")
size_before = (directory / "memory.md").stat().st_size
assert project_cli.cmd_activate("big") == 0
out = capsys.readouterr().out
assert len(out) <= project_cli.MAX_OUTPUT_CHARS
assert "older entries not shown" in out
assert "entry 199" in out and "entry 0 " not in out
assert "### prompt.md\nP" in out and "### state.md\nS" in out
assert (directory / "memory.md").stat().st_size == size_before
def test_activate_rejects_unknown_project(projects, capsys):
make_project(projects, "chata")
assert project_cli.cmd_activate("neznamy") == 1
assert "chata" in capsys.readouterr().err
# -- list / new --------------------------------------------------------------
def test_list_reports_sizes_and_flags_empty_state(projects, capsys):
make_project(projects, "chata", prompt="x" * 595, memory="y" * 2048)
make_project(projects, "life", prompt="a", memory="b", state="c")
assert project_cli.cmd_list() == 0
lines = capsys.readouterr().out.splitlines()
assert "prompt 595B" in lines[0] and "memory 2.0K" in lines[0]
assert "state 0B (!)" in lines[0]
assert "(!)" not in lines[1]
def test_list_without_projects(projects, capsys):
assert project_cli.cmd_list() == 0
assert capsys.readouterr().out.strip() == "(no projects yet)"
def test_new_creates_empty_files_without_artifacts(projects, capsys):
assert project_cli.cmd_new("novy") == 0
directory = projects / "novy"
assert all((directory / name).read_text(encoding="utf-8") == "" for name in project_cli.PROJECT_FILES)
assert not (directory / "artifacts").exists()
def test_new_refuses_existing_project(projects):
make_project(projects, "chata", memory="- 2026-09-01: a\n")
assert project_cli.cmd_new("chata") == 1
assert (projects / "chata" / "memory.md").read_text(encoding="utf-8") == "- 2026-09-01: a\n"