nanobot: 2026-09-11 09:44:11

This commit is contained in:
lachtan
2026-09-11 09:44:13 +02:00
parent 335d718cd4
commit dd3c076f2c
6 changed files with 175 additions and 77 deletions

View File

@@ -89,14 +89,20 @@ dropping it.
## Writing to memory.md
**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:
**Only ever through the script** — never `edit_file` or `write_file` on
`memory.md` itself. Two steps: write the entry text to a scratch file, then hand
the script its path.
```
uv run skills/project/scripts/project_cli.py log <slug> <<'NOTE'
<entry text>
NOTE
```
1. `write_file` the entry text to `tmp/project-entry.md`
2. `uv run skills/project/scripts/project_cli.py log <slug> --file tmp/project-entry.md`
**Never put the entry text into the command line** — not as an argument, not in a
heredoc, not through a pipe. The exec safety guard scans the raw command string
and misreads ordinary prose as a filesystem path: a colon right after a letter
that follows a diacritic parses as a Windows drive, so Czech words like `Cíl:`,
`Závěr:` or `směr:` block the whole command with *path outside working dir*. The
guard has no shell parser, so quoting and heredocs do not help. A file path in
the command is unaffected.
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.
@@ -168,5 +174,8 @@ prints each project with its file sizes; `(!)` marks an empty `state.md`.
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 write test or throwaway entries to a real project's `memory.md` — it is
append-only, so taking them back out means rewriting history. If you genuinely
need to try something, run `new <scratch-slug>` and use that.
- Never create a new project without the user's explicit confirmation.
- Don't force a project context onto an unrelated request.

View File

@@ -124,7 +124,12 @@ def cmd_activate(slug: str) -> int:
return 0
def cmd_log(slug: str, text: str | None) -> int:
def cmd_log(slug: str, entry_file: str | None) -> int:
"""Append the entry read from `entry_file` (or stdin when None) to memory.md.
The text is never passed on the command line: the exec safety guard scans the
raw command string and misreads prose as a filesystem path (see SKILL.md).
"""
directory = project_path(slug)
if not directory.is_dir():
slugs = existing_slugs()
@@ -132,7 +137,14 @@ def cmd_log(slug: str, text: str | None) -> int:
print(f"No such project: {slug}. Existing: {listing}", file=sys.stderr)
return 1
body = text if text is not None else sys.stdin.read()
if entry_file is None:
body = sys.stdin.read()
else:
source = Path(entry_file)
if not source.is_file():
print(f"No such file: {entry_file}", file=sys.stderr)
return 1
body = source.read_text(encoding="utf-8")
body = body.strip()
if not body:
print("Nothing to log (empty input).", file=sys.stderr)
@@ -191,7 +203,9 @@ def main() -> int:
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"
"--file",
default=None,
help="Path to a file holding the entry text; if omitted, read from stdin",
)
sub.add_parser("list", help="List projects with file sizes")
@@ -203,7 +217,7 @@ def main() -> int:
if args.command == "activate":
return cmd_activate(args.slug)
if args.command == "log":
return cmd_log(args.slug, args.text)
return cmd_log(args.slug, args.file)
if args.command == "list":
return cmd_list()
return cmd_new(args.slug)

View File

@@ -34,65 +34,117 @@ def today():
return datetime.now(ZoneInfo("Europe/Prague")).date().isoformat()
@pytest.fixture
def entry_file(tmp_path):
"""Write entry text to a file and return its path, the way the skill does."""
def write(text):
path = tmp_path / "entry.md"
path.write_text(text, encoding="utf-8")
return str(path)
return write
# -- log ---------------------------------------------------------------------
def test_log_appends_with_today_date(projects, capsys):
def test_log_appends_with_today_date(projects, entry_file, capsys):
directory = make_project(projects, "chata")
assert project_cli.cmd_log("chata", "Dřevo objednáno") == 0
assert project_cli.cmd_log("chata", entry_file("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):
def test_log_does_not_join_when_file_lacks_trailing_newline(projects, entry_file):
directory = make_project(projects, "chata", memory="- 2026-09-01: první")
project_cli.cmd_log("chata", "druhý")
project_cli.cmd_log("chata", entry_file("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):
def test_log_creates_missing_memory_file(projects, entry_file):
directory = projects / "chata"
directory.mkdir()
assert project_cli.cmd_log("chata", "první") == 0
assert project_cli.cmd_log("chata", entry_file("první")) == 0
assert (directory / "memory.md").read_text(encoding="utf-8").endswith("první\n")
def test_log_reads_stdin_verbatim(projects, monkeypatch):
def test_log_reads_file_verbatim(projects, entry_file):
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
assert project_cli.cmd_log("chata", entry_file(text)) == 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):
def test_log_accepts_text_the_exec_guard_would_reject_on_a_command_line(projects, entry_file):
"""Regression: `Cíl:` / `Závěr:` parse as Windows drive paths in the exec guard.
The guard scans the raw command string, so such text must reach the script
through a file, never as an argument, a heredoc or a pipe.
"""
directory = make_project(projects, "chata")
text = "Cíl: srovnat pánev. Diagnóza/směr: mobilita kyčle. Závěr: pokračovat."
assert project_cli.cmd_log("chata", entry_file(text)) == 0
assert text in (directory / "memory.md").read_text(encoding="utf-8")
def test_log_reads_stdin_when_no_file_given(projects, monkeypatch):
directory = make_project(projects, "chata")
monkeypatch.setattr(sys, "stdin", io.StringIO("ze stdinu"))
assert project_cli.cmd_log("chata", None) == 0
assert (directory / "memory.md").read_text(encoding="utf-8") == f"- {today()}: ze stdinu\n"
def test_log_rejects_missing_file_without_touching_memory(projects, tmp_path, capsys):
directory = make_project(projects, "chata", memory="- 2026-09-01: první\n")
assert project_cli.cmd_log("chata", str(tmp_path / "chybi.md")) == 1
assert "No such file" in capsys.readouterr().err
assert (directory / "memory.md").read_text(encoding="utf-8") == "- 2026-09-01: první\n"
def test_log_does_not_shorten_long_entry(projects, entry_file):
directory = make_project(projects, "chata")
text = "x" * 3000
project_cli.cmd_log("chata", text)
project_cli.cmd_log("chata", entry_file(text))
assert text in (directory / "memory.md").read_text(encoding="utf-8")
def test_log_rejects_empty_input(projects):
def test_log_rejects_empty_input(projects, entry_file):
directory = make_project(projects, "chata", memory="- 2026-09-01: první\n")
assert project_cli.cmd_log("chata", " ") == 1
assert project_cli.cmd_log("chata", entry_file(" ")) == 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
def test_log_rejects_unknown_project(projects, entry_file):
assert project_cli.cmd_log("neznamy", entry_file("text")) == 1
def test_log_no_longer_accepts_text_on_the_command_line(monkeypatch):
"""`--text` is gone for good: prose in argv is what the exec guard blocks."""
monkeypatch.setattr(sys, "argv", ["project_cli.py", "log", "chata", "--text", "něco"])
with pytest.raises(SystemExit) as excinfo:
project_cli.main()
assert excinfo.value.code == 2
# -- activate ----------------------------------------------------------------