diff --git a/cli/pyproject.toml b/cli/pyproject.toml index 363a592..361045a 100644 --- a/cli/pyproject.toml +++ b/cli/pyproject.toml @@ -30,6 +30,12 @@ smriti-mcp = "smriti_cli.mcp_server:main" where = ["."] include = ["smriti_cli*"] +[tool.setuptools.package-data] +# Ship the skill pack source-of-truth template with the installed +# package so `smriti skills install` and `smriti_install_skill` can +# find it via __file__-relative path resolution. +smriti_cli = ["skill_pack/template.md"] + [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-ra --strict-markers" diff --git a/cli/smriti_cli/skill_pack/__init__.py b/cli/smriti_cli/skill_pack/__init__.py new file mode 100644 index 0000000..1eb305a --- /dev/null +++ b/cli/smriti_cli/skill_pack/__init__.py @@ -0,0 +1,37 @@ +"""Smriti agent skill pack — the agent-onboarding surface. + +The skill pack teaches coding agents (Claude Code, Codex) when and +why to use Smriti's tools — when to checkpoint, when NOT to checkpoint, +when to fork, how to detect drift, and the explicit anti-patterns to +reject. It is installed into an agent host's project directory as a +single versioned markdown file so the instructions live in the +agent's system context rather than in documentation the agent never +reads. + +Public surface: + render(target_key) -> str # render template for a target + install(target_key, ...) -> InstallResult # write rendered template to disk + get_version(content=None) -> str # parse frontmatter version + list_targets() -> list[SkillTarget] # enumerate known targets + get_target(target_key) -> SkillTarget # resolve a target config + +Layout: + skill_pack/ + __init__.py — re-exports the small public surface + template.md — single source of truth (versioned frontmatter) + renderer.py — pure-function render + install logic + targets.py — target configs (display name, destination) +""" +from .renderer import InstallResult, get_version, install, load_template, render +from .targets import SkillTarget, get_target, list_targets + +__all__ = [ + "InstallResult", + "SkillTarget", + "get_target", + "get_version", + "install", + "list_targets", + "load_template", + "render", +] diff --git a/cli/smriti_cli/skill_pack/renderer.py b/cli/smriti_cli/skill_pack/renderer.py new file mode 100644 index 0000000..30d9ac6 --- /dev/null +++ b/cli/smriti_cli/skill_pack/renderer.py @@ -0,0 +1,239 @@ +"""Skill pack renderer + installer. + +Pure functions — no LLM calls, no network, no global state. The +renderer reads the single source-of-truth `template.md`, substitutes +a small set of placeholders based on the target's primary mode, and +returns the rendered markdown as a string. The installer writes the +result to the target's default destination (or an override) with +version-aware refusal to overwrite. + +Template syntax: + + {{display_name}} + → replaced with the target's display_name ("Claude Code" or "Codex") + + {{primary_mode}} + → replaced with "mcp" or "cli" + + {{mcp:some text}}{{cli:other text}} + → paired blocks. For target primary_mode="mcp" the whole pair + becomes "some text"; for primary_mode="cli" it becomes + "other text". Either block may be empty. Both blocks must + appear together — an unmatched block raises ValueError at + render time so typos in the template fail loudly in tests. + +The version is stored in frontmatter as: + + --- + smriti_skill_pack_version: 1.0 + ... + --- + +The installer reads the existing destination's frontmatter (if any), +compares versions as strings (lexicographic — "1.0" < "1.1" < "1.10" +works for the foreseeable skill pack version series), and refuses to +overwrite a destination whose version is >= the template's version +unless `force=True` is passed. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, Optional + +from .targets import SkillTarget, get_target + + +_TEMPLATE_PATH = Path(__file__).parent / "template.md" + +_VERSION_RE = re.compile( + r"^smriti_skill_pack_version:\s*([^\n]+)$", + re.MULTILINE, +) + +# Matches a paired {{mcp:...}}{{cli:...}} block. The `?` suffix on the +# inner `.` quantifiers makes them non-greedy so multiple pairs on the +# same line (or line-wrapped pairs) do not collapse into one giant +# match. Empty bodies are allowed. +_PAIRED_BLOCK_RE = re.compile( + r"\{\{mcp:(.*?)\}\}\{\{cli:(.*?)\}\}", + re.DOTALL, +) + +# Matches any leftover unpaired block — if this matches after +# _PAIRED_BLOCK_RE has been applied, the template has a typo. +_UNPAIRED_BLOCK_RE = re.compile( + r"\{\{(?:mcp|cli):[^}]*\}\}", + re.DOTALL, +) + + +Action = Literal["created", "overwritten", "dry_run", "skipped"] + + +@dataclass +class InstallResult: + """Result of a skill pack install attempt. + + - action: what happened. "skipped" means the destination already + had a same-or-newer version and `force` was not set; nothing was + written. + - previous_version: the version read from the destination before + the install attempt, or None if the destination did not exist + or had no version frontmatter. + - content: the rendered content that was (or would have been) + written. Useful for `--dry-run` and for the MCP tool which + returns the content without writing. + """ + + target: SkillTarget + destination: Path + action: Action + version: str + previous_version: Optional[str] + content: str + + +def load_template() -> str: + """Read the raw template.md from the installed package.""" + return _TEMPLATE_PATH.read_text(encoding="utf-8") + + +def get_version(content: Optional[str] = None) -> str: + """Return the `smriti_skill_pack_version` value from the template + frontmatter. Raises ValueError if the frontmatter is missing so + missing-version bugs fail loudly during development rather than + installing an unversioned skill pack. + """ + text = content if content is not None else load_template() + m = _VERSION_RE.search(text) + if not m: + raise ValueError( + "Skill pack template is missing `smriti_skill_pack_version` " + "frontmatter. Every template.md must start with a YAML " + "frontmatter block that pins the version." + ) + return m.group(1).strip() + + +def render(target_key: str) -> str: + """Render the template for a specific target. + + The same template.md renders for every target; placeholders and + `{{mcp:...}}{{cli:...}}` blocks control the primary-mode-specific + variations. The content (workflow heuristics, anti-patterns, + when-not-to-checkpoint rules) is identical across targets — only + the tool notation and display name differ. + """ + target = get_target(target_key) + template = load_template() + return _substitute_placeholders(template, target) + + +def _substitute_placeholders(template: str, target: SkillTarget) -> str: + """Apply the template substitutions for a target. Pure function. + + Steps: + 1. Replace every {{mcp:X}}{{cli:Y}} paired block with X or Y + depending on target.primary_mode. + 2. Replace {{display_name}} → target.display_name. + 3. Replace {{primary_mode}} → target.primary_mode. + 4. Assert no unmatched paired blocks remain — if any do, the + template has a typo and the renderer fails loudly with the + location context. + """ + def pick(match: re.Match[str]) -> str: + mcp_body, cli_body = match.group(1), match.group(2) + return mcp_body if target.primary_mode == "mcp" else cli_body + + out = _PAIRED_BLOCK_RE.sub(pick, template) + out = out.replace("{{display_name}}", target.display_name) + out = out.replace("{{primary_mode}}", target.primary_mode) + + leftover = _UNPAIRED_BLOCK_RE.search(out) + if leftover: + # Provide a snippet of context so the error is actionable. + start = max(0, leftover.start() - 30) + end = min(len(out), leftover.end() + 30) + raise ValueError( + f"Skill pack template contains an unmatched " + f"{{mcp:...}}/{{cli:...}} block. Matched fragment: " + f"{leftover.group(0)!r}. Context: ...{out[start:end]!r}..." + ) + + return out + + +def install( + target_key: str, + destination: Optional[Path] = None, + *, + force: bool = False, + dry_run: bool = False, +) -> InstallResult: + """Install the rendered skill pack for a target. + + Args: + target_key: "claude-code" or "codex". + destination: Override the target's default destination path. + When None, uses `target.default_destination` relative to + the current working directory. + force: Overwrite an existing same-or-newer version. Default + False: refuse and return `action="skipped"`. + dry_run: Render the content and return it, but do not write + to disk. Default False. + + Returns: + InstallResult describing what happened. + """ + target = get_target(target_key) + dest = Path(destination) if destination is not None else target.default_destination + content = render(target_key) + current_version = get_version(content) + previous_version: Optional[str] = None + + if dest.exists(): + try: + existing = dest.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + existing = "" + m = _VERSION_RE.search(existing) + if m: + previous_version = m.group(1).strip() + + if ( + previous_version is not None + and not force + and previous_version >= current_version + ): + return InstallResult( + target=target, + destination=dest, + action="skipped", + version=current_version, + previous_version=previous_version, + content=content, + ) + + if dry_run: + return InstallResult( + target=target, + destination=dest, + action="dry_run", + version=current_version, + previous_version=previous_version, + content=content, + ) + + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(content, encoding="utf-8") + + return InstallResult( + target=target, + destination=dest, + action="overwritten" if previous_version else "created", + version=current_version, + previous_version=previous_version, + content=content, + ) diff --git a/cli/smriti_cli/skill_pack/targets.py b/cli/smriti_cli/skill_pack/targets.py new file mode 100644 index 0000000..2ae942d --- /dev/null +++ b/cli/smriti_cli/skill_pack/targets.py @@ -0,0 +1,76 @@ +"""Skill pack target configurations. + +A "target" is the agent host the skill pack is being installed into. +Two targets ship in v1.0: + +- claude-code: installs to `./.claude/skills/smriti/SKILL.md`. Claude + Code reads skills out of `.claude/skills//SKILL.md` as + project-level instructions. Primary tool notation is MCP + (`smriti_state(space="x")`) because Claude Code speaks MCP natively. +- codex: installs to `./AGENTS.md`. Codex reads `AGENTS.md` at the + project root as its primary instruction file. Primary tool notation + is CLI (`smriti state x`) because the Codex CLI runs commands in a + shell loop. + +Both targets render from the SAME template source — only the primary +tool notation and display name vary. Workflow heuristics, anti-patterns, +and when-not-to-checkpoint rules are identical for both targets by +design. +""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + + +PrimaryMode = Literal["mcp", "cli"] + + +@dataclass(frozen=True) +class SkillTarget: + """Configuration for one agent skill pack target.""" + + key: str + display_name: str + default_destination: Path + primary_mode: PrimaryMode + # One-line summary used by `smriti skills list`. + description: str + + +TARGETS: dict[str, SkillTarget] = { + "claude-code": SkillTarget( + key="claude-code", + display_name="Claude Code", + default_destination=Path(".claude/skills/smriti/SKILL.md"), + primary_mode="mcp", + description="Claude Code (MCP-native host; installs to .claude/skills/smriti/SKILL.md)", + ), + "codex": SkillTarget( + key="codex", + display_name="Codex", + default_destination=Path("AGENTS.md"), + primary_mode="cli", + description="Codex (shell-based host; installs to AGENTS.md)", + ), +} + + +def list_targets() -> list[SkillTarget]: + """Return all known skill pack targets in a deterministic order.""" + return [TARGETS[k] for k in sorted(TARGETS.keys())] + + +def get_target(target_key: str) -> SkillTarget: + """Resolve a target by key. Raises ValueError for unknown keys. + + The error message lists the known targets so CLI users and agents + get actionable feedback without having to consult the docs. + """ + if target_key not in TARGETS: + known = ", ".join(sorted(TARGETS.keys())) + raise ValueError( + f"Unknown skill pack target: '{target_key}'. Known targets: {known}" + ) + return TARGETS[target_key] diff --git a/cli/smriti_cli/skill_pack/template.md b/cli/smriti_cli/skill_pack/template.md new file mode 100644 index 0000000..d3f9d51 --- /dev/null +++ b/cli/smriti_cli/skill_pack/template.md @@ -0,0 +1,454 @@ +--- +smriti_skill_pack_version: 1.0 +title: Smriti — how to use it well +target: {{display_name}} +--- + +# Smriti: how to use it well + +You are working in a project that uses Smriti for reasoning state. +Before you do anything else on this project, read this file. It tells +you when to call Smriti, when not to, and why it matters. + +Primary tool mode for your host: `{{primary_mode}}`. + +--- + +## 1. What Smriti is, in one paragraph + +Smriti is a reasoning-state backend. It stores structured snapshots of +your thinking at meaningful inflection points — decisions, assumptions, +tasks, open questions, artifacts — and makes them available to whatever +agent picks up the work next. It is not a memory database, not a wiki, +not a notes app, not a transcript archive. It is version control for +reasoning. Spaces are repos. Checkpoints are commits. Fork, compare, +restore, and review all mean roughly what they would mean in a source +control tool, but for the shape of the work rather than the code. + +--- + +## 2. Why this file exists + +You have been trained on thousands of projects that used a markdown +handoff file — `HANDOFF.md`, `NOTES.md`, `CONTEXT.md`, whatever — to +pass context between sessions, agents, or people. You probably reach +for that pattern by default. Don't. On this project it is wrong. + +Smriti exists specifically to replace that pattern with something +structured, branchable, comparable, restorable, and resistant to +overwriting. Markdown handoff files lose on every axis except zero- +learning-curve. This file closes the learning-curve gap so your +default reflex on this project becomes Smriti, not a text file. + +If you find yourself about to write `HANDOFF.md`, stop and write a +checkpoint instead. Every time. + +--- + +## 3. The reflex: read state first + +At the start of every session where you are working on a Smriti-enabled +project, your FIRST action is: + +``` +{{mcp:smriti_state(space="")}}{{cli:smriti state }} +``` + +Before you read any file. Before you run any test. Before you plan +anything. Before you answer the user's first prompt beyond "let me +check the state first." + +The state brief is the minimum context you need to continue work +without re-discovering decisions that were already made. Calling it +unconditionally is cheap, and missing it is expensive. The brief is +multi-branch by default — if other agents are working on this project +on different branches, you will see them in the `## Active branches` +section with an author tag, and if any of them disagree with main on +decisions you will see a `## Divergence signal` section. + +Say out loud to the user: **"Reading current state from Smriti."** +before you call state. This gives the human watching a visible audit +trail. + +--- + +## 4. When to checkpoint + +Checkpoint at an **inflection point**. An inflection point is a moment +where the reasoning state has changed in a way a future agent needs to +know about. Concretely: + +- **You made a decision you would not want to re-derive.** Checkpoint. +- **You rejected a hypothesis and now know a dead end.** Checkpoint. +- **You solved a sub-problem and the next step builds on it.** Checkpoint. +- **You are about to hand off to another agent or another session.** Checkpoint. +- **You are about to explore an alternative direction and want to + preserve the current line.** Fork first, then checkpoint on the fork. + +Use the **extract path**, not hand-written JSON. Pass freeform markdown +describing the inflection point and Smriti's background LLM will pull +out the structured fields (message, objective, summary, decisions, +assumptions, tasks, open questions, entities, artifacts) for you. + +Example call: + +{{mcp:``` +smriti_create_checkpoint( + space="", + content=""" +# Decided on Pydantic for the state validation layer + +After trying dataclass-based validation and hitting the injection +surface from unbounded extra fields, going with Pydantic BaseModel +and `extra="forbid"`. Latency overhead is ~0.3 ms per call. + +## Open questions +- How do we share state across parallel agent runs? +- Cleaner schema-versioning story for migrations? +""", + author_agent="claude-code", +) +```}}{{cli:``` +cat <<'MD' | smriti checkpoint create --extract --author-agent +# Decided on Pydantic for the state validation layer + +After trying dataclass-based validation and hitting the injection +surface from unbounded extra fields, going with Pydantic BaseModel +and `extra="forbid"`. Latency overhead is ~0.3 ms per call. + +## Open questions +- How do we share state across parallel agent runs? +- Cleaner schema-versioning story for migrations? +MD +```}} + +Say out loud: **"Checkpointing now — reached an inflection point on X."** +before the call. Name the X. + +Always tag `author_agent` with a stable identifier for your agent +(e.g. `claude-code`, `codex-local`). This is how humans and other +agents know who wrote what on the shared timeline. Inconsistent or +missing `author_agent` makes divergence unattributable. + +--- + +## 5. When NOT to checkpoint + +This section is more important than the previous one. Read it twice. + +A checkpoint is not a save button. Most of your work should not +produce a checkpoint. Resist all of these: + +- **Do not checkpoint after every small step.** Finishing a helper + function, editing one file, running one test — not an inflection + point. If your checkpoint's `message` would be "Wrote a helper + function" or "Ran the tests" or "Fixed a typo," do not checkpoint. + Keep working. + +- **Do not checkpoint at end of session as a blob.** A single + "everything I did today" commit with 15 decisions crammed into one + message is less useful than zero checkpoints. It has no locality. + The next agent reading it cannot tell which decisions belong + together, what caused what, or in what order things happened. If + you reach end-of-session without having checkpointed, you missed + the inflection points earlier — and the fix is NOT to compensate + with one giant dump. Write ONE crisp checkpoint for the most recent + real decision and stop. Then reflect on what you should have + checkpointed earlier. + +- **Do not use checkpoints as a backup system.** The event stream + (turns) is already the backup. A checkpoint is a reasoning-state + snapshot, not a safety net. Checkpointing "because the session is + about to end" or "because the user is leaving" is a save-button + use and it is wrong. + +- **Do not checkpoint with nothing crisp to say.** If you cannot + write a single sentence for the `message` that names a specific + decision made, hypothesis confirmed, problem solved, or dead end + identified — you are not at an inflection point yet. Finish + thinking. Then checkpoint. + +- **Do not checkpoint on top of inconsistent state.** If + `{{mcp:smriti_state}}{{cli:smriti state}}` surfaces contradictions + between decisions, or if + `{{mcp:smriti_review_checkpoint}}{{cli:smriti checkpoint review}}` + flags issues on the current HEAD, resolve first. Either restore to + a cleaner ancestor or surface the contradictions to the human. Do + NOT stack new commits on broken state — you are compounding the + confusion, not fixing it. + +- **Do not restate existing state.** If the last checkpoint already + contains the decision you are about to record, do not re-checkpoint + it. Smriti tracks new commitments, not a running summary of + everything still true. Re-checkpointing dilutes the signal. + +- **Do not checkpoint just because the user asked you to.** The user + does not always know whether you are at an inflection point. If + they ask you to checkpoint and you are not, say so: "I don't have + a crisp inflection point to record yet — can you tell me what + decision or problem you want captured?" This is not disobedience; + it is protecting the signal quality of the project's timeline from + future noise. + +### 5.1 The signal test + +Before every checkpoint, ask yourself three questions: + +1. **Can I name the inflection point in one sentence for `message`?** + If you need a paragraph, wait. +2. **If another agent read only my `decisions`, `summary`, and + `open_questions`, could they continue the work correctly?** + If no, there is nothing worth recording yet. +3. **Is the reasoning state meaningfully different from the previous + checkpoint on this branch?** + If no, you are restating. + +If any answer is no, do not checkpoint. + +### 5.2 What good frequency looks like + +A 4-hour focused session typically produces **2 to 4 checkpoints**. +Not 20. Not 0. + +- A session producing 20 checkpoints is producing noise. You are + treating checkpoints as a save button. +- A session producing 0 checkpoints is either doing trivial work or + missing the inflection points. Reflect. +- 2–4 is the target. Aim there. + +--- + +## 6. When to fork + +Fork when you want to explore an alternative direction **without +losing the main line**. Concretely: + +``` +{{mcp:smriti_fork(checkpoint_id="", branch="alternative-X")}}{{cli:smriti fork --branch alternative-X}} +``` + +Then write a checkpoint into the forked session: + +{{mcp:``` +smriti_create_checkpoint( + space="", + session="", + content="...", +) +```}}{{cli:``` +cat fork.md | smriti checkpoint create \ + --extract --session --author-agent +```}} + +**Do not fork for small variations within the same direction.** That +is continuation, not branching. Fork when two parallel lines of +reasoning should genuinely exist for future comparison — when you +want the option to go back to the original line cleanly without +re-deriving it. + +Say out loud: **"Forking a branch to explore an alternative. Main +line is preserved."** + +--- + +## 7. When to review + +Run `{{mcp:smriti_review_checkpoint(checkpoint_id="")}}{{cli:smriti checkpoint review }}` +when: + +- The state brief you just read contains decisions or assumptions + that feel contradictory to each other. +- You are about to checkpoint on top of a state you did not write + yourself and want a sanity pass first. +- You are handing off to another agent and want to surface any + issues before the receiving agent wastes cycles on broken state. + +Do **not** run review on every checkpoint. It is a self-audit tool, +not an audit trail. Running it reflexively adds noise. + +--- + +## 8. When to compare + +Run `{{mcp:smriti_compare(checkpoint_a="", checkpoint_b="")}}{{cli:smriti compare }}` +when: + +- The state brief shows a `## Divergence signal` on an active branch + and you want the full diff (the signal only shows the top 3 + conflicting decisions per side). +- You see two checkpoints in the lineage that should agree on + something but seem to differ. +- You are resolving a fork back to a single line and need to decide + which decisions to keep. + +--- + +## 9. When to restore + +Run `{{mcp:smriti_restore(checkpoint_id="")}}{{cli:smriti restore }}` +when: + +- The current HEAD state is contradictory or corrupted and you want + to resume from an earlier clean snapshot. +- You are reading a past checkpoint not as history but as a starting + point — you want to continue work from it as if it were HEAD. + +Say out loud: **"Restoring to an earlier checkpoint."** when you do +this. The human should know you are stepping back in time. + +--- + +## 10. Detecting drift + +If at any point you notice: + +- The state brief contains tasks that appear already done, or +- Decisions that directly contradict what you have been working on, or +- Open questions that seem already resolved in the summary, or +- The `## Divergence signal` shows active branches disagreeing on + things you considered settled, + +then you are in a drift situation. **Do not paper over it.** Three +acceptable responses: + +1. Run `{{mcp:smriti_review_checkpoint}}{{cli:smriti checkpoint review}}` + on the current HEAD and act on the output. +2. Run `{{mcp:smriti_compare}}{{cli:smriti compare}}` on the two most + recent checkpoints from each divergent author and summarize the + difference in plain prose for the human. +3. Surface the drift to the human directly: "I am seeing scope + divergence between what the state brief says and what I have been + working on. Stopping to reconcile before continuing. Which line + should I follow?" + +**Do not quietly pick a side.** Scope divergence across agents is a +real signal that the project state needs a human judgment call. +Smriti's job is to make that signal visible to you; your job is to +make it visible to the human, not to hide it by picking one branch +and continuing. + +Say out loud: **"I'm seeing scope divergence between the state brief +and my work. Stopping to reconcile before continuing."** + +--- + +## 11. Anti-patterns to reject explicitly + +These are in addition to the "do not checkpoint when" rules in +Section 5. + +- **Do not write HANDOFF.md, NOTES.md, CONTEXT.md, PROGRESS.md, or + any similar file on this project.** A Smriti space exists. Write + a checkpoint instead. Markdown handoff files do not survive + branching, do not cross-link to history, and do not participate in + `smriti_compare`. Every time you write one, the next agent has to + re-learn what you already knew — exactly the problem Smriti exists + to solve. If you catch yourself about to write a handoff file, + treat that as a signal you have reached an inflection point and + should checkpoint. + +- **Do not silently inject state output into the user's chat + context.** Announce it: "Reading current state from Smriti." The + human should always be able to see when you are consulting or + writing to the shared timeline. + +- **Do not tag `author_agent` inconsistently.** Pick ONE stable + identifier for your agent — e.g. `claude-code`, `codex-local`, + `claude-code-v2` — and use it on every checkpoint you write. If + you change your tag mid-project, the divergence signal becomes + unattributable. Stability of the tag is more important than its + content. + +- **Do not invoke `/chat/send` or the live chat runtime from inside + your tool loop.** That endpoint is for humans talking to Smriti's + chat UI, not for agents writing state. Your only write paths are + `{{mcp:smriti_create_checkpoint}}{{cli:smriti checkpoint create --extract}}` + and `{{mcp:smriti_fork}}{{cli:smriti fork}}`. + +- **Do not treat the extract path as a free pass to dump your + reasoning.** The extract LLM will pull whatever fields it can, but + if you pass it 2000 words of stream-of-consciousness it will + produce low-signal decisions and assumptions. Write the markdown + crisply. The extract path saves you from hand-rolling JSON; it is + not a license to be verbose. + +- **Do not use `smriti_install_skill` to overwrite an in-project + skill pack that you did not write.** If the project already has + a skill pack of an older version, the install tool will tell you. + Use the existing one unless the human asks you to upgrade. + +--- + +## 12. Phrases to say out loud + +These give the human watching your session a visible audit trail. +Use them literally. + +| When you are about to... | Say | +|---|---| +| call `{{mcp:smriti_state}}{{cli:smriti state}}` | "Reading current state from Smriti." | +| create a checkpoint | "Checkpointing now — reached an inflection point on X." | +| fork a branch | "Forking a branch to explore an alternative. Main line is preserved." | +| restore an earlier checkpoint | "Restoring to an earlier checkpoint." | +| run review after a drift signal | "Running a consistency review on the current state before I continue." | +| run compare on a divergence signal | "Comparing main against the divergent branch to see the full diff." | +| surface drift to the human | "I'm seeing scope divergence between the state brief and my work. Stopping to reconcile before continuing." | + +These are not cosmetic. They are what makes your session legible to +the human who will eventually read the thread and decide whether +your reasoning is sound. + +--- + +## 13. The project root + +Every checkpoint you write should have a `project_root` field that +points at the absolute path where this project actually lives on +disk. {{mcp:The MCP server runs in the host's arbitrary working +directory, so it does NOT auto-capture `project_root`. Pass the +path explicitly: `smriti_create_checkpoint(..., project_root="/abs/path")`.}}{{cli:The CLI auto-captures `$(pwd)` as `project_root` +when you run from the project directory. If you are running from +somewhere else, pass `--project-root /abs/path` explicitly.}} + +This is how cross-agent handoffs know where the project lives. +Missing or wrong `project_root` makes the next agent spend 30 +seconds hunting for the codebase. + +--- + +## 14. Two-sentence summary + +Call {{mcp:`smriti_state`}}{{cli:`smriti state`}} at session start, +unconditionally, before anything else. Checkpoint at inflection +points — not after every small step, not at end of session as a +dump, never as a save button. + +Everything else in this file is implementation detail for those two +rules. + +--- + +## 15. If you are confused about Smriti + +The order of operations is always: + +1. **Read state first.** You are missing context until you do. +2. **Work.** Smriti has no opinion about what happens between + checkpoints. +3. **Checkpoint at inflection points.** Not before, not more often. +4. **Hand off.** The next agent reads the new state. No markdown + handoff file. + +If you are unsure whether an action is "Smriti-shaped," ask yourself: +"Is this a reasoning-state change, or is it just work-in-progress?" +Reasoning-state changes get checkpointed. Work-in-progress does not. + +If you are still unsure, surface the question to the human. They will +tell you. Do not guess. + +--- + +*Smriti skill pack version {{primary_mode}}-1.0 — this file is +authoritative for agent behaviour on this project. If you catch it +contradicting itself or your observed behaviour of the tools, tell +the human; the skill pack is versioned and meant to be updated.* diff --git a/cli/tests/test_skill_pack.py b/cli/tests/test_skill_pack.py new file mode 100644 index 0000000..725e5b2 --- /dev/null +++ b/cli/tests/test_skill_pack.py @@ -0,0 +1,302 @@ +"""Tests for the skill pack renderer + installer. + +Covers: +- Template loads and has a parseable version frontmatter +- render() produces distinct output per target (MCP vs CLI notation) +- render() substitutes {{display_name}} and {{primary_mode}} +- render() raises on unknown targets +- render() raises on unmatched paired blocks (template typo guard) +- install() creates the destination file with the rendered content +- install() creates parent directories that don't exist +- install() --dry-run does NOT write to disk +- install() refuses to overwrite a same-version destination without --force +- install() --force overwrites a same-version destination +- install() writes to an explicit --destination override +- list_targets() returns the known targets deterministically +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from smriti_cli.skill_pack import ( + InstallResult, + get_target, + get_version, + install, + list_targets, + load_template, + render, +) +from smriti_cli.skill_pack.renderer import _substitute_placeholders +from smriti_cli.skill_pack.targets import SkillTarget + + +# ── Template loading + version ────────────────────────────────────────────── + + +def test_load_template_nonempty(): + text = load_template() + assert text.strip() != "" + assert "smriti_skill_pack_version" in text + + +def test_get_version_parses_frontmatter(): + version = get_version() + assert version == "1.0" + + +def test_get_version_raises_when_frontmatter_missing(): + with pytest.raises(ValueError, match="smriti_skill_pack_version"): + get_version(content="no frontmatter here\njust body text") + + +# ── Render ─────────────────────────────────────────────────────────────────── + + +def test_render_claude_code_uses_mcp_notation(): + out = render("claude-code") + assert "Claude Code" in out + # MCP tool-call form should be present. + assert "smriti_state(" in out + assert "smriti_create_checkpoint(" in out + # CLI shell form should NOT appear — the claude-code variant elides it. + assert "smriti checkpoint create" not in out + assert "smriti checkpoint review" not in out + + +def test_render_codex_uses_cli_notation(): + out = render("codex") + assert "Codex" in out + # CLI shell form should be present. + assert "smriti state " in out + assert "smriti checkpoint create" in out + assert "smriti checkpoint review" in out + # MCP tool-call form should NOT appear — the codex variant elides it. + assert "smriti_state(space" not in out + assert "smriti_create_checkpoint(" not in out + + +def test_render_substitutes_display_name(): + assert "Claude Code" in render("claude-code") + assert "Codex" in render("codex") + + +def test_render_substitutes_primary_mode(): + claude_out = render("claude-code") + codex_out = render("codex") + assert "mcp" in claude_out + assert "cli" in codex_out + + +def test_render_unknown_target_raises(): + with pytest.raises(ValueError, match="Unknown skill pack target"): + render("not-a-real-target") + + +# ── Content integrity (shared between both targets) ───────────────────────── + + +# These phrases define the load-bearing parts of the skill pack's +# anti-pattern teaching. If a future template edit drops any of them, +# these tests fail and we catch the regression before shipping. The +# product contract is: both targets must always teach when NOT to +# checkpoint, with all the rules intact. +_REQUIRED_PHRASES = [ + # Section 5.x — When NOT to checkpoint + "after every small step", + "end of session as a blob", + "backup system", + "nothing crisp to say", + "inconsistent state", + "restate", + # Section 5.1 — the signal test (3 questions) + "The signal test", + "name the inflection point", + # Section 5.2 — frequency target + "2 to 4 checkpoints", + "20 checkpoints is producing noise", + # Section 3 — the reflex + "Reading current state from Smriti", + # Section 11 — anti-patterns + "HANDOFF.md", + "author_agent", + # Section 10 — drift detection + "divergence", + "scope divergence", + # Section 14 — two-sentence summary + "session start", +] + + +@pytest.mark.parametrize("target_key", ["claude-code", "codex"]) +def test_render_contains_all_critical_content(target_key: str): + """Every target must teach all anti-patterns and heuristics. + + Divergent targets with missing sections would erode the skill + pack's value — the rules are identical across hosts and this + test enforces that invariant. + """ + out = render(target_key) + missing = [p for p in _REQUIRED_PHRASES if p.lower() not in out.lower()] + assert not missing, ( + f"Rendered skill pack for {target_key!r} is missing required " + f"phrases: {missing}. This usually means a template edit " + f"dropped a critical section." + ) + + +def test_render_targets_share_anti_pattern_section(): + """Both targets should include the same When-NOT-to-checkpoint + bullet structure. String-match the most distinctive phrases that + should never go away.""" + claude = render("claude-code").lower() + codex = render("codex").lower() + for phrase in [ + "when not to checkpoint", + "checkpoint is not a save button", + "do not checkpoint after every small step", + "do not checkpoint at end of session as a blob", + ]: + assert phrase in claude, f"missing from claude-code: {phrase}" + assert phrase in codex, f"missing from codex: {phrase}" + + +def test_substitute_placeholders_unmatched_block_raises(): + """An unpaired {{mcp:...}} or {{cli:...}} block in the template + must fail loudly so typos in template.md are caught in tests, not + silently shipped.""" + target = get_target("claude-code") + bad_template = "Some text {{mcp:foo}} with an unmatched block." + with pytest.raises(ValueError, match="unmatched"): + _substitute_placeholders(bad_template, target) + + +def test_substitute_placeholders_empty_blocks_allowed(): + """A paired block where one side is empty is valid and useful for + sections that should appear in one target but not the other.""" + target_claude = get_target("claude-code") + target_codex = get_target("codex") + tpl = "prefix {{mcp:only-in-mcp}}{{cli:}} suffix" + claude_out = _substitute_placeholders(tpl, target_claude) + codex_out = _substitute_placeholders(tpl, target_codex) + assert "only-in-mcp" in claude_out + assert "only-in-mcp" not in codex_out + assert "prefix suffix" in codex_out + + +# ── Install ────────────────────────────────────────────────────────────────── + + +def test_install_creates_file_with_rendered_content(tmp_path: Path): + dest = tmp_path / "SKILL.md" + result = install("claude-code", destination=dest) + + assert result.action == "created" + assert result.previous_version is None + assert result.destination == dest + assert dest.exists() + content = dest.read_text(encoding="utf-8") + assert "Claude Code" in content + assert "smriti_state(" in content + assert "smriti_skill_pack_version" in content + + +def test_install_creates_parent_directories(tmp_path: Path): + dest = tmp_path / "deep" / "nested" / "path" / "SKILL.md" + assert not dest.parent.exists() + + result = install("claude-code", destination=dest) + + assert result.action == "created" + assert dest.exists() + assert dest.parent.is_dir() + + +def test_install_dry_run_does_not_write(tmp_path: Path): + dest = tmp_path / "SKILL.md" + result = install("claude-code", destination=dest, dry_run=True) + + assert result.action == "dry_run" + assert not dest.exists() + # But the rendered content is returned on the result. + assert "Claude Code" in result.content + + +def test_install_refuses_same_version_without_force(tmp_path: Path): + dest = tmp_path / "SKILL.md" + install("claude-code", destination=dest) # first install + + result = install("claude-code", destination=dest) # second attempt + assert result.action == "skipped" + assert result.previous_version == "1.0" + # File on disk is unchanged. + assert "Claude Code" in dest.read_text(encoding="utf-8") + + +def test_install_force_overwrites_same_version(tmp_path: Path): + dest = tmp_path / "SKILL.md" + install("claude-code", destination=dest) # first install + + # Tamper with the file — a downstream edit the agent made by hand. + dest.write_text( + "---\nsmriti_skill_pack_version: 1.0\n---\n\nTAMPERED\n", + encoding="utf-8", + ) + + result = install("claude-code", destination=dest, force=True) + assert result.action == "overwritten" + assert result.previous_version == "1.0" + # TAMPERED content is gone; fresh rendered content is back. + assert "TAMPERED" not in dest.read_text(encoding="utf-8") + assert "Claude Code" in dest.read_text(encoding="utf-8") + + +def test_install_overwrites_older_version_without_force(tmp_path: Path): + """An older version should be overwritten automatically — only + same or newer versions are protected.""" + dest = tmp_path / "SKILL.md" + dest.write_text( + "---\nsmriti_skill_pack_version: 0.9\n---\n\nOLD CONTENT\n", + encoding="utf-8", + ) + + result = install("claude-code", destination=dest) + assert result.action == "overwritten" + assert result.previous_version == "0.9" + assert "OLD CONTENT" not in dest.read_text(encoding="utf-8") + + +def test_install_dry_run_still_reports_previous_version(tmp_path: Path): + dest = tmp_path / "SKILL.md" + dest.write_text( + "---\nsmriti_skill_pack_version: 0.5\n---\n\n...\n", + encoding="utf-8", + ) + + result = install("claude-code", destination=dest, dry_run=True) + assert result.action == "dry_run" + assert result.previous_version == "0.5" + # Dry run must not touch disk. + assert dest.read_text(encoding="utf-8").startswith("---") + assert "0.5" in dest.read_text(encoding="utf-8") + + +# ── list_targets ───────────────────────────────────────────────────────────── + + +def test_list_targets_returns_known_targets(): + targets = list_targets() + keys = [t.key for t in targets] + assert "claude-code" in keys + assert "codex" in keys + assert all(isinstance(t, SkillTarget) for t in targets) + + +def test_list_targets_is_deterministic(): + """Two calls return the same ordering — important for `smriti skills + list` output stability across invocations.""" + a = [t.key for t in list_targets()] + b = [t.key for t in list_targets()] + assert a == b