upravy projektu a skillu
This commit is contained in:
@@ -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.
|
||||
|
||||
213
skills/project/scripts/project_cli.py
Executable file
213
skills/project/scripts/project_cli.py
Executable 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())
|
||||
184
skills/project/tests/test_project_cli.py
Normal file
184
skills/project/tests/test_project_cli.py
Normal 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"
|
||||
Reference in New Issue
Block a user