mirror of
https://github.com/himanshudongre/smriti.git
synced 2026-08-28 05:14:59 +00:00
Add Smriti agent skill pack source and renderer
The skill pack is an instruction file installed into an agent host's project directory so Smriti's workflow lives in the agent's system context instead of documentation nobody reads. A single versioned template.md renders for both Claude Code (MCP-primary) and Codex (CLI-primary) via a pure-function substituter, keeping content in sync mechanically across targets. template.md contains 15 sections. The load-bearing one is Section 5, When NOT to checkpoint, with equal weight to Section 4. Agents are told explicitly not to checkpoint after every small step, not to produce end-of-session blobs, not to treat commits as a save button, not to stack commits on inconsistent state, not to restate existing state, and not to checkpoint just because the user asked when there is no real inflection point. A frequency target (2-4 checkpoints per 4-hour session) and a three-question signal test give agents concrete criteria for every call. Other sections cover the read-state-first reflex, when to fork, when to review, when to compare, when to restore, drift detection, explicit anti-patterns (HANDOFF.md, silent state reads, inconsistent author_agent, /chat/send), and the phrases the agent should say out loud so the human watching has an audit trail. Renderer API (all pure functions): load_template, get_version, render, install. install is version-aware: refuses to overwrite a destination whose installed version is >= the template version unless force=True. Dry-run mode returns the rendered content without writing. Content-integrity tests parametrized over both targets assert that every anti-pattern rule, the signal test, the frequency target, and the drift-detection guidance appear in the rendered output. If a future template edit drops any of them, tests fail loudly. 22 skill pack tests, all green.
This commit is contained in:
parent
491c7316b1
commit
ec0139f707
6 changed files with 1114 additions and 0 deletions
|
|
@ -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"
|
||||
|
|
|
|||
37
cli/smriti_cli/skill_pack/__init__.py
Normal file
37
cli/smriti_cli/skill_pack/__init__.py
Normal file
|
|
@ -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",
|
||||
]
|
||||
239
cli/smriti_cli/skill_pack/renderer.py
Normal file
239
cli/smriti_cli/skill_pack/renderer.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
76
cli/smriti_cli/skill_pack/targets.py
Normal file
76
cli/smriti_cli/skill_pack/targets.py
Normal file
|
|
@ -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/<name>/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]
|
||||
454
cli/smriti_cli/skill_pack/template.md
Normal file
454
cli/smriti_cli/skill_pack/template.md
Normal file
|
|
@ -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="<project>")}}{{cli:smriti state <project>}}
|
||||
```
|
||||
|
||||
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="<project>",
|
||||
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 <project> --extract --author-agent <your-agent-name>
|
||||
# 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="<current-head-id>", branch="alternative-X")}}{{cli:smriti fork <current-head-id> --branch alternative-X}}
|
||||
```
|
||||
|
||||
Then write a checkpoint into the forked session:
|
||||
|
||||
{{mcp:```
|
||||
smriti_create_checkpoint(
|
||||
space="<project>",
|
||||
session="<fork-session-id>",
|
||||
content="...",
|
||||
)
|
||||
```}}{{cli:```
|
||||
cat fork.md | smriti checkpoint create <project> \
|
||||
--extract --session <fork-session-id> --author-agent <your-agent-name>
|
||||
```}}
|
||||
|
||||
**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="<id>")}}{{cli:smriti checkpoint review <id>}}`
|
||||
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="<A>", checkpoint_b="<B>")}}{{cli:smriti compare <A> <B>}}`
|
||||
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="<id>")}}{{cli:smriti restore <id>}}`
|
||||
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.*
|
||||
302
cli/tests/test_skill_pack.py
Normal file
302
cli/tests/test_skill_pack.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue