Add V3 dirty paths and skill pack v2.1

This commit is contained in:
Himanshu Dongre 2026-05-04 13:11:20 +05:30
parent e8ce2f9de0
commit 3cb514b279
12 changed files with 233 additions and 17 deletions

View file

@ -528,6 +528,12 @@ sees right now. The cost is bounded by the cache (60s TTL per worktree
in process memory) and the per-probe timeout (3s). Failures fail closed —
the worktree field becomes `null` rather than crashing the state endpoint.
V3 extends the same probe with `dirty_paths` because the file-level conflict
signal also lives in git, not the database. Persisting "which files are dirty"
would have the same stale-truth problem as persisting dirty counts; showing
the first three `status --porcelain` paths inline keeps the state brief honest
while staying bounded and cheap.
### Why the worktree probe cache is 60 seconds, not longer or shorter
The cache exists to keep state-brief reads cheap when called repeatedly

View file

@ -64,7 +64,7 @@ smriti/
│ │ └── providers.yaml Your keys (gitignored, not committed)
│ ├── alembic/ Database migrations (14 versions)
│ ├── tests/
│ │ ├── integration/ API integration tests (122 tests)
│ │ ├── integration/ API integration tests (124 tests)
│ │ │ ├── test_api_v4_chat.py
│ │ │ ├── test_api_v5_lineage.py
│ │ │ ├── test_multi_branch_state.py
@ -73,7 +73,7 @@ smriti/
│ │ │ ├── test_worktrees.py
│ │ │ ├── test_checkpoint_extract.py
│ │ │ └── test_delete_endpoints.py
│ │ └── unit/ Unit tests (125 tests)
│ │ └── unit/ Unit tests (129 tests)
│ │ ├── test_config_loader.py
│ │ ├── test_extractor.py
│ │ ├── test_golden_outputs.py
@ -113,10 +113,10 @@ smriti/
│ │ ├── formatters.py Continuation-oriented markdown renderers
│ │ │ (multi-branch, active claims, divergence)
│ │ └── skill_pack/ Agent skill pack source and renderer
│ │ ├── template.md Single source of truth (v2.0, 15 sections)
│ │ ├── template.md Single source of truth (v2.1, 15 sections)
│ │ ├── renderer.py Pure-function render + versioned install
│ │ └── targets.py Target configs (claude-code, codex)
│ └── tests/ CLI + MCP tests (122 tests)
│ └── tests/ CLI + MCP tests (129 tests)
│ ├── test_branch_close.py
│ ├── test_init.py
│ ├── test_mcp_server.py
@ -164,11 +164,11 @@ make migration Create a new migration (usage: make migration msg="...")
---
## Test counts (as of V2 worktree binding)
## Test counts (as of V3 dirty paths)
| Suite | Count | Location |
|---|---|---|
| Backend integration | 122 | `backend/tests/integration/` |
| Backend unit | 125 | `backend/tests/unit/` |
| CLI + MCP | 122 | `cli/tests/` |
| **Total** | **369** | |
| Backend integration | 124 | `backend/tests/integration/` |
| Backend unit | 129 | `backend/tests/unit/` |
| CLI + MCP | 129 | `cli/tests/` |
| **Total** | **382** | |

View file

@ -372,6 +372,7 @@ class ActiveWorktreeSummary(BaseModel):
path: str
branch: str
dirty_files: int
dirty_paths: list[str] = Field(default_factory=list)
ahead: int
behind: int
last_commit_sha: str

View file

@ -183,6 +183,7 @@ class WorkTreeResponse(BaseModel):
class WorkTreeProbe(BaseModel):
dirty_files: int
dirty_paths: list[str] = Field(default_factory=list)
ahead: int
behind: int
last_commit_sha: str | None = None

View file

@ -33,6 +33,19 @@ def _run_git(path: str, args: list[str]) -> subprocess.CompletedProcess[str]:
)
def _parse_dirty_paths(porcelain_output: str, limit: int = 3) -> list[str]:
"""Parse `git status --porcelain` output into the first dirty paths."""
paths: list[str] = []
for line in porcelain_output.splitlines():
if not line.strip():
continue
if len(line) >= 3:
paths.append(line[3:].strip())
if len(paths) >= limit:
break
return paths
def _probe_worktree(worktree_id: str, path: str, branch: str) -> dict[str, Any] | None:
"""Return git drift information for a worktree, or None on any error.
@ -61,6 +74,7 @@ def _probe_worktree_uncached(
_log_probe_failure(worktree_id, "status", status)
return None
dirty_files = len([line for line in status.stdout.splitlines() if line.strip()])
dirty_paths = _parse_dirty_paths(status.stdout)
counts = _run_git(path, ["rev-list", "--left-right", "--count", "HEAD...origin/main"])
if counts.returncode != 0:
@ -94,6 +108,7 @@ def _probe_worktree_uncached(
"path": path,
"branch": branch,
"dirty_files": dirty_files,
"dirty_paths": dirty_paths,
"ahead": ahead,
"behind": behind,
"last_commit_sha": last_parts[0],

View file

@ -57,6 +57,7 @@ def test_state_active_claims_include_bound_worktree_info(client, db_session, mon
"path": path,
"branch": branch,
"dirty_files": 3,
"dirty_paths": ["cli/main.py", "backend/app/api/routes/chat.py"],
"ahead": 1,
"behind": 0,
"last_commit_sha": "def5678",
@ -75,6 +76,7 @@ def test_state_active_claims_include_bound_worktree_info(client, db_session, mon
"path": "/tmp/state-worktree",
"branch": "smriti/codex-local/abc12345",
"dirty_files": 3,
"dirty_paths": ["cli/main.py", "backend/app/api/routes/chat.py"],
"ahead": 1,
"behind": 0,
"last_commit_sha": "def5678",

View file

@ -188,6 +188,7 @@ def test_list_includes_probe_data_for_active_worktrees(client, tmp_path, monkeyp
"path": path,
"branch": branch,
"dirty_files": 3,
"dirty_paths": ["cli/main.py", "backend/app/api/routes/worktrees.py"],
"ahead": 1,
"behind": 0,
"last_commit_sha": "abc1234",
@ -203,6 +204,7 @@ def test_list_includes_probe_data_for_active_worktrees(client, tmp_path, monkeyp
assert data[0]["id"] == created["id"]
assert data[0]["probe"] == {
"dirty_files": 3,
"dirty_paths": ["cli/main.py", "backend/app/api/routes/worktrees.py"],
"ahead": 1,
"behind": 0,
"last_commit_sha": "abc1234",

View file

@ -38,6 +38,7 @@ def test_probe_worktree_success(monkeypatch):
"path": "/tmp/wt",
"branch": "feature/wt",
"dirty_files": 2,
"dirty_paths": ["file.py", "new.py"],
"ahead": 2,
"behind": 1,
"last_commit_sha": "abc1234",
@ -108,3 +109,54 @@ def test_probe_worktree_malformed_output_returns_none(monkeypatch):
monkeypatch.setattr(worktree_probe, "_run_git", fake_run_git)
assert worktree_probe._probe_worktree("wt-bad", "/tmp/wt", "branch") is None
def test_parse_dirty_paths_basic():
output = "\n".join(
[
" M cli/main.py",
"?? backend/app/main.py",
"A docs/notes.md",
" D old.py",
"MM changed.py",
]
)
assert worktree_probe._parse_dirty_paths(output) == [
"cli/main.py",
"backend/app/main.py",
"docs/notes.md",
]
def test_parse_dirty_paths_handles_renamed():
output = "R old/path.py -> new/path.py\n"
assert worktree_probe._parse_dirty_paths(output) == [
"old/path.py -> new/path.py",
]
def test_parse_dirty_paths_empty():
assert worktree_probe._parse_dirty_paths("") == []
def test_parse_dirty_paths_respects_limit():
output = "\n".join(
[
" M one.py",
" M two.py",
" M three.py",
" M four.py",
" M five.py",
" M six.py",
]
)
assert worktree_probe._parse_dirty_paths(output, limit=5) == [
"one.py",
"two.py",
"three.py",
"four.py",
"five.py",
]

View file

@ -52,6 +52,27 @@ def format_worktree_ahead(worktree: dict) -> str:
return "0"
def _truncate_dirty_path(path: str, limit: int = 40) -> str:
"""Truncate long dirty paths so active-work lines stay readable."""
if len(path) <= limit:
return path
return path[: limit - 1] + ""
def _format_dirty_summary(dirty_files: int, dirty_paths: list[str] | None) -> str:
"""Render dirty count plus the first dirty paths for active work claims."""
if dirty_files <= 0:
return "0 dirty"
paths = dirty_paths or []
if not paths:
return f"{dirty_files} dirty"
shown = [_truncate_dirty_path(path) for path in paths[:3]]
if dirty_files > len(shown):
shown.append(f"+{dirty_files - len(shown)} more")
return f"{dirty_files} dirty ({', '.join(shown)})"
def _relative_time(iso_ts: str) -> str:
"""Format an ISO-8601 UTC timestamp as a relative-time string."""
try:
@ -148,7 +169,7 @@ def _artifact_section(
return ""
if compact:
# Labels only — no content. Explicit recovery instruction.
lines = [f"## Attached artifacts (compact — content omitted)"]
lines = ["## Attached artifacts (compact — content omitted)"]
for art in artifacts:
label = art.get("label") or "Untitled"
lines.append(f"- {label}")
@ -291,6 +312,10 @@ def _format_active_claims_section(active_claims: list[dict]) -> str:
if worktree:
path = _pretty_path(worktree.get("path")) or worktree.get("path") or "?"
dirty = worktree.get("dirty_files", 0)
dirty_summary = _format_dirty_summary(
int(dirty or 0),
worktree.get("dirty_paths") or [],
)
ahead = worktree.get("ahead", 0)
behind = worktree.get("behind", 0)
last_sha = worktree.get("last_commit_sha") or "?"
@ -298,7 +323,7 @@ def _format_active_claims_section(active_claims: list[dict]) -> str:
lines.append(f" · worktree: {path}")
lines.append(
f" · branch: {worktree.get('branch') or '?'} · "
f"{dirty} dirty · ahead {ahead} · behind {behind} · "
f"{dirty_summary} · ahead {ahead} · behind {behind} · "
f"last commit `{last_sha}` {last_rel}"
)
elif worktree_id:

View file

@ -1,5 +1,5 @@
---
smriti_skill_pack_version: 2.0
smriti_skill_pack_version: 2.1
title: Smriti — how to use it well
target: {{display_name}}
---
@ -368,6 +368,49 @@ Say out loud: **"Opening a worktree for this session."** before the
first worktree open. **"Binding my claim to worktree <id>."** when
claiming. **"Closing the worktree."** when done.
The state brief now shows you which files other agents are actively
editing, not just how many. Each active claim's worktree drift line
includes the first 3 dirty paths inline:
```
· branch: ... · 3 dirty (cli/main.py, backend/app/main.py, +1 more) · ...
```
This is your file-level coordination signal. Before editing a file in
your worktree, scan the active-claim drift lines for that path. If
another agent already has it dirty, hold off or pick a different file.
The signal is best-effort — it shows the first 3 dirty paths only and
the cache is 60 seconds — but it catches the common case where two
agents drift into the same file by accident.
If your shell tool resets the working directory between commands (some
agent harnesses do this — every Bash call starts in the original cwd
even after `cd <worktree-path>`), use absolute paths to the worktree
throughout. Example:
```
WT=/Users/.../.smriti/worktrees/<space>/<agent-slug>
cat $WT/some/file.py
edit $WT/some/file.py
```
The skill pack used to say "use the worktree path as your cwd" — that
works for persistent shells, but absolute-path discipline works
everywhere. Capture the worktree path from `smriti worktree open` once
and reuse it.
The default branch name when you `smriti worktree open` is
`smriti/<agent>/<short-uuid>`. That's fine for the system but ugly for
PR titles. Pass `--branch <name>` to use a custom branch name instead:
```
smriti worktree open <space> --agent <id> --branch v3-feature-name
```
Use this when the worktree maps cleanly to a single feature/PR. Stick
with the default when the worktree is short-lived or when several
related branches will live in it.
### 3.7 Check freshness before checkpointing
If you have been working for more than a few minutes, check whether
@ -926,7 +969,7 @@ tell you. Do not guess.
---
*Smriti skill pack version {{primary_mode}}-2.0 — this file is
*Smriti skill pack version {{primary_mode}}-2.1 — 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.*

View file

@ -21,7 +21,6 @@ from pathlib import Path
import pytest
from smriti_cli.skill_pack import (
InstallResult,
get_target,
get_version,
install,
@ -44,7 +43,7 @@ def test_load_template_nonempty():
def test_get_version_parses_frontmatter():
version = get_version()
assert version == "2.0"
assert version == "2.1"
def test_get_version_raises_when_frontmatter_missing():
@ -164,6 +163,10 @@ _REQUIRED_PHRASES = [
"worktree_id",
"cross-agent commit pollution",
"Open a worktree per agent",
# Section 3.6.1 — V3 additions
"dirty paths",
"absolute paths",
"custom branch name",
# Section 3.7 — freshness check
"check freshness",
"since",

View file

@ -17,8 +17,6 @@ from __future__ import annotations
from datetime import datetime, timezone
import pytest
from smriti_cli import mcp_server
from smriti_cli.formatters import (
_format_active_branches_section,
@ -690,6 +688,51 @@ def test_claims_section_with_worktree_info():
assert "last commit `def5678` 5 minutes ago" in out
def test_render_dirty_paths_inline():
from smriti_cli.formatters import _format_active_claims_section
claims = [_worktree_claim(dirty_files=3, dirty_paths=[
"cli/main.py",
"backend/app/api/routes/chat.py",
"backend/app/services/worktree_probe.py",
])]
out = _format_active_claims_section(claims)
assert (
"3 dirty (cli/main.py, backend/app/api/routes/chat.py, "
"backend/app/services/worktree_probe.py)"
) in out
def test_render_dirty_paths_with_overflow():
from smriti_cli.formatters import _format_active_claims_section
claims = [_worktree_claim(dirty_files=5, dirty_paths=[
"cli/main.py",
"backend/app/api/routes/chat.py",
"backend/app/services/worktree_probe.py",
])]
out = _format_active_claims_section(claims)
assert (
"5 dirty (cli/main.py, backend/app/api/routes/chat.py, "
"backend/app/services/worktree_probe.py, +2 more)"
) in out
def test_render_dirty_paths_zero():
from smriti_cli.formatters import _format_active_claims_section
claims = [_worktree_claim(dirty_files=0, dirty_paths=[])]
out = _format_active_claims_section(claims)
assert "0 dirty · ahead" in out
assert "0 dirty (" not in out
def test_claims_section_with_worktree_probe_failure():
"""A bound claim with failed probing still renders a useful hint."""
from smriti_cli.formatters import _format_active_claims_section
@ -709,3 +752,26 @@ def test_claims_section_with_worktree_probe_failure():
out = _format_active_claims_section(claims)
assert "probe failed or worktree closed" in out
def _worktree_claim(dirty_files: int, dirty_paths: list[str]) -> dict:
return {
"agent": "codex-local",
"branch_name": "v3-dirty-paths-and-skill-pack",
"scope": "Implement V3",
"worktree_id": "worktree-uuid",
"worktree": {
"id": "worktree-uuid",
"path": "/Users/example/.smriti/worktrees/smriti-dev/codex-local-abc12345",
"branch": "v3-dirty-paths-and-skill-pack",
"dirty_files": dirty_files,
"dirty_paths": dirty_paths,
"ahead": 1,
"behind": 0,
"last_commit_sha": "def5678",
"last_commit_relative": "5 minutes ago",
},
"intent_type": "implement",
"base_commit_hash": "abc1234",
"claimed_at": datetime.now(timezone.utc).isoformat(),
}