diff --git a/cron/jobs.json b/cron/jobs.json index 4075bfc..4e715f2 100644 --- a/cron/jobs.json +++ b/cron/jobs.json @@ -37,8 +37,8 @@ } }, "state": { - "nextRunAtMs": 1789106400000, - "lastRunAtMs": 1789020000002, + "nextRunAtMs": 1789192800000, + "lastRunAtMs": 1789106400001, "lastStatus": "ok", "lastError": null, "runHistory": [ @@ -47,11 +47,17 @@ "status": "ok", "durationMs": 6281, "error": null + }, + { + "runAtMs": 1789106400001, + "status": "ok", + "durationMs": 6997, + "error": null } ] }, "createdAtMs": 1788948314679, - "updatedAtMs": 1789020006283, + "updatedAtMs": 1789106406998, "deleteAfterRun": false }, { @@ -78,8 +84,8 @@ "originMetadata": {} }, "state": { - "nextRunAtMs": 1789104981395, - "lastRunAtMs": 1789097781385, + "nextRunAtMs": 1789119381693, + "lastRunAtMs": 1789112181683, "lastStatus": "ok", "lastError": null, "runHistory": [ @@ -136,11 +142,23 @@ "status": "ok", "durationMs": 10, "error": null + }, + { + "runAtMs": 1789104981671, + "status": "ok", + "durationMs": 11, + "error": null + }, + { + "runAtMs": 1789112181683, + "status": "ok", + "durationMs": 10, + "error": null } ] }, "createdAtMs": 1789032980434, - "updatedAtMs": 1789097781395, + "updatedAtMs": 1789112181693, "deleteAfterRun": false }, { @@ -167,53 +185,11 @@ "originMetadata": {} }, "state": { - "nextRunAtMs": 1789101381496, - "lastRunAtMs": 1789099581496, + "nextRunAtMs": 1789113981697, + "lastRunAtMs": 1789112181696, "lastStatus": "ok", "lastError": null, "runHistory": [ - { - "runAtMs": 1789065381122, - "status": "ok", - "durationMs": 0, - "error": null - }, - { - "runAtMs": 1789067181123, - "status": "ok", - "durationMs": 1, - "error": null - }, - { - "runAtMs": 1789068981125, - "status": "ok", - "durationMs": 1, - "error": null - }, - { - "runAtMs": 1789070781128, - "status": "ok", - "durationMs": 0, - "error": null - }, - { - "runAtMs": 1789072581129, - "status": "ok", - "durationMs": 1, - "error": null - }, - { - "runAtMs": 1789074381270, - "status": "ok", - "durationMs": 1, - "error": null - }, - { - "runAtMs": 1789076181272, - "status": "ok", - "durationMs": 0, - "error": null - }, { "runAtMs": 1789077981273, "status": "ok", @@ -291,11 +267,53 @@ "status": "ok", "durationMs": 0, "error": null + }, + { + "runAtMs": 1789101381497, + "status": "ok", + "durationMs": 1, + "error": null + }, + { + "runAtMs": 1789103181499, + "status": "ok", + "durationMs": 0, + "error": null + }, + { + "runAtMs": 1789104981682, + "status": "ok", + "durationMs": 0, + "error": null + }, + { + "runAtMs": 1789106781682, + "status": "ok", + "durationMs": 1, + "error": null + }, + { + "runAtMs": 1789108581684, + "status": "ok", + "durationMs": 0, + "error": null + }, + { + "runAtMs": 1789110381686, + "status": "ok", + "durationMs": 1, + "error": null + }, + { + "runAtMs": 1789112181696, + "status": "ok", + "durationMs": 1, + "error": null } ] }, "createdAtMs": 1789032980437, - "updatedAtMs": 1789099581496, + "updatedAtMs": 1789112181697, "deleteAfterRun": false } ] diff --git a/projects/life/memory.md b/projects/life/memory.md index 53a7915..6898ffa 100644 --- a/projects/life/memory.md +++ b/projects/life/memory.md @@ -18,3 +18,5 @@ zůstává i po jablku. - 2026-09-02: Potřebuje zajít na fyzio terapii — poradit s omezenou mobilitou pravého kyčle a zjistit, proč se pravá noha na rozdíl od levé tak snadno a často přetěžuje. Cíl: cvičit pravidelně, aspoň 2× týdně; protahování ještě častěji. - 2026-09-11: Byl na fyzio (10.9.2026). Směr: zlepšit mobilitu kyčle a hlavně srovnat pánev, která je nakřivo. Seznam cviků (rekonstrukce z paměti, nahraný si je nebyl) uložen do artifacts/fyzio-cviky-kycele.md. Otevřené otázky k dotázání fyzioterapeuta: počty opakování a délky výdrží u jednotlivých cviků. +- 2026-09-11: Cvičil dnes fyzio cviky (poprvé po návštěvě fyzioterapeuta 10.9.2026). +- 2026-09-11: Zjistěno z rešerše četnosti fyzio cviků (daily vs obden): u nízkointenzivních mobilitních/protahovacích cviků je dostačující denně i obden — intenzita určuje potřebnou frekvenci, neexistuje potřeba regenerace. Kohortní studie (Musculoskelet. Sci. Pract. 2025, 185 pacientů NLBP): vyšší předepsaná frekvence a dávka signifikantně snižují adherence → obden, který vydrží, je lepší než denní plán, který se rozpadne. Delphi konsensus expertů (2025): efekt protahování na ROM je chronický/kumulativní, vynechaný den nic nezruší. Závěr pro projekt: obden (3–4× týdně) plně dostačující a udržitelnější; denní večerní protahování (bez potu) ponechat jako levný návyk. Zdroje: cor-kinetic.com blog 6/2020, mskscienceandpractice.com S2468-7812(25)00186-9, PMC12305623. diff --git a/projects/life/state.md b/projects/life/state.md index 6afe2e4..3aa70db 100644 --- a/projects/life/state.md +++ b/projects/life/state.md @@ -52,3 +52,6 @@ Denní vážení, vnímaná kondice, jestli mě něco bolí. - Cviky uložené v `artifacts/fyzio-cviky-kycele.md` (rekonstrukce z paměti). - **Chybí**: počty opakování a délky výdrží — doladit s fyzioterapeutem při další návštěvě. +- **Frekvence (rešerše 2026-09-11)**: obden (3–4× týdně) plně dostačující — + nízkointenzivní mobilitní cviky nepotřebují den regenerace; denní plán + navíc adherence reálně nezvládne. Denní jen levné protahování bez potu. diff --git a/skills/project/SKILL.md b/skills/project/SKILL.md index 2b7a8b9..847537e 100644 --- a/skills/project/SKILL.md +++ b/skills/project/SKILL.md @@ -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 <<'NOTE' - -NOTE -``` +1. `write_file` the entry text to `tmp/project-entry.md` +2. `uv run skills/project/scripts/project_cli.py log --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 ` and use that. - Never create a new project without the user's explicit confirmation. - Don't force a project context onto an unrelated request. diff --git a/skills/project/scripts/project_cli.py b/skills/project/scripts/project_cli.py index 7259a34..77160e4 100755 --- a/skills/project/scripts/project_cli.py +++ b/skills/project/scripts/project_cli.py @@ -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) diff --git a/skills/project/tests/test_project_cli.py b/skills/project/tests/test_project_cli.py index aa96f04..e0ca834 100644 --- a/skills/project/tests/test_project_cli.py +++ b/skills/project/tests/test_project_cli.py @@ -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 ----------------------------------------------------------------