Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ Format follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/) an

## [Unreleased]

### Added
- `simplicio-mapper index <path>` idempotent orchestration command for
SendSprint. It writes the standard `.simplicio/project-map.json` and
`precedent-index.json`, short-circuits fresh indexes with exit code `2`, and
exposes a stable `--json` payload with artifact paths, counts, changed files
and skipped reason.

## [0.6.0] - 2026-05-28

### Added
Expand Down
6 changes: 6 additions & 0 deletions PYPI.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ simplicio-mapper map
# Refresh artifacts and record changed files since the last run
simplicio-mapper update

# Idempotent orchestration entry point for SendSprint and other runners
simplicio-mapper index path/to/project --json

# Map another project root, with hints when .starter-meta.json is absent
simplicio-mapper map --root path/to/project --stack python --product-name "My App"

Expand All @@ -45,6 +48,9 @@ The `llm-project-mapper` console script is provided as an alias.

| Option | Description |
|---|---|
| `index <path>` | Scriptable index command. Returns `0` when refreshed, `2` when already fresh, `1` on failure. Quiet by default. |
| `--json` | Emit stable `simplicio.mapper-index/v1` output for the `index` command. |
| `--verbose` | Show progress during `index` refreshes. |
| `--root <dir>` | Project root to map. Defaults to the current directory. |
| `--out <dir>` | Artifact directory. Defaults to `.simplicio`. |
| `--stack <name>` | Stack hint when `.starter-meta.json` is absent. |
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,20 @@ pip install simplicio-mapper

simplicio-mapper map # write .simplicio/ artifacts
simplicio-mapper update # refresh and record changed files
simplicio-mapper index . --json # idempotent, scriptable SendSprint bootstrap
simplicio-mapper map --watch # re-map as files change locally
```

Both `simplicio-mapper` and `llm-project-mapper` console scripts are installed,
and the Python output is byte-for-byte compatible with the Node mapper's schema.

For orchestrators, `simplicio-mapper index <path>` is quiet by default. It
returns `0` when artifacts are written/refreshed, `2` when the existing index is
already fresh, and `1` on failure. Add `--json` for a stable
`simplicio.mapper-index/v1` payload containing artifact paths, item counts,
changed files and the skipped reason. Add `--verbose` only when progress logs
are useful.

Use `--watch` during long agent sessions to keep the map fresh. The schema and
Python consumption example live in [SIMPLICIO_INTEGRATION.md](SIMPLICIO_INTEGRATION.md).

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wesleysimplicio/llm-project-mapper",
"version": "0.6.0",
"version": "0.6.1",
"description": "AI-friendly project scaffold with AGENTS.md ecosystem (Claude Code, Codex, Copilot, Cursor, Aider, Hermes, OpenClaw). Specs as code, atomic tasks, automated Definition of Done, reusable skills, multi-agent ready.",
"type": "commonjs",
"bin": {
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "simplicio-mapper"
version = "0.6.0"
version = "0.6.1"
description = "Python-first project mapper that emits .simplicio/project-map.json and precedent-index.json for the Simplicio ecosystem."
readme = "PYPI.md"
requires-python = ">=3.10"
Expand Down
2 changes: 1 addition & 1 deletion simplicio_mapper/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

from __future__ import annotations

__version__ = "0.5.0"
__version__ = "0.6.1"

__all__ = ["__version__"]
219 changes: 214 additions & 5 deletions simplicio_mapper/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,32 @@
from __future__ import annotations

import json
import hashlib
import os
import subprocess
import sys
import time
from typing import Sequence

from . import __version__
from .mapper import write_mapping_artifacts

INDEX_RESULT_SCHEMA = "simplicio.mapper-index/v1"
INDEX_STATE_SCHEMA = "simplicio.mapper-index-state/v1"

HELP_TEXT = """simplicio-mapper map

Generate or update machine-readable mapper artifacts.

USAGE
simplicio-mapper index <path> [--json] [--verbose]
simplicio-mapper map [--root <dir>] [--incremental] [--watch]
simplicio-mapper update [--root <dir>] [--watch]

OPTIONS
index <path> Idempotently create or refresh .simplicio artifacts.
--json Emit structured index output.
--verbose Show progress during index refreshes.
--root <dir> Project root to map. Defaults to cwd.
--stack <name> Stack hint when .starter-meta.json is absent.
--product-name <name> Product name hint when .starter-meta.json is absent.
Expand Down Expand Up @@ -54,14 +63,22 @@ def _parse_args(argv: Sequence[str]) -> dict:
"incremental": False,
"watch": False,
"silent": False,
"json": False,
"verbose": False,
"command": "map",
}
command = "update" if argv and argv[0] == "update" else "map"
command = argv[0] if argv and argv[0] in ("index", "map", "update") else "map"
opts["command"] = command
if command == "index":
opts["silent"] = True
if command == "update":
opts["incremental"] = True
i = 1 if argv and argv[0] in ("map", "update") else 0
i = 1 if argv and argv[0] in ("index", "map", "update") else 0
while i < len(argv):
arg = argv[i]
if arg == "--root":
if command == "index" and not arg.startswith("-"):
opts["root"] = arg
elif arg == "--root":
i += 1
opts["root"] = argv[i]
elif arg == "--out":
Expand All @@ -79,15 +96,20 @@ def _parse_args(argv: Sequence[str]) -> dict:
opts["watch"] = True
elif arg == "--silent":
opts["silent"] = True
elif arg == "--json":
opts["json"] = True
elif arg == "--verbose":
opts["verbose"] = True
opts["silent"] = False
elif arg in ("-h", "--help"):
print(HELP_TEXT)
sys.exit(0)
elif arg in ("-V", "--version"):
print(__version__)
sys.exit(0)
else:
print(f"Unknown map option: {arg}", file=sys.stderr)
print("Run `simplicio-mapper map --help` for usage.", file=sys.stderr)
print(f"Unknown {command} option: {arg}", file=sys.stderr)
print("Run `simplicio-mapper --help` for usage.", file=sys.stderr)
sys.exit(2)
i += 1
return opts
Expand Down Expand Up @@ -125,6 +147,178 @@ def _signature(root: str, out: str) -> tuple:
return tuple(sorted(entries))


def _state_path(root: str, out: str) -> str:
return os.path.join(os.path.abspath(os.path.join(root, out)), "index-state.json")


def _artifact_paths(root: str, out: str) -> dict[str, str]:
abs_out = os.path.abspath(os.path.join(root, out))
return {
"project_map": os.path.join(abs_out, "project-map.json"),
"precedent_index": os.path.join(abs_out, "precedent-index.json"),
}


def _hash_text(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()


def _git_signature(root: str, out: str) -> dict | None:
ignored_out = os.path.relpath(os.path.abspath(os.path.join(root, out)), root)
ignored_out = ignored_out.replace(os.sep, "/").rstrip("/") or ".simplicio"
try:
inside = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
cwd=root,
capture_output=True,
text=True,
timeout=2,
)
if inside.returncode != 0 or inside.stdout.strip() != "true":
return None
head = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=root,
capture_output=True,
text=True,
timeout=2,
)
status = subprocess.run(
[
"git",
"status",
"--porcelain=v1",
"--untracked-files=all",
"--",
".",
f":!{ignored_out}",
],
cwd=root,
capture_output=True,
text=True,
timeout=3,
)
except (OSError, subprocess.SubprocessError):
return None
if head.returncode != 0 or status.returncode != 0:
return None
return {
"kind": "git",
"head": head.stdout.strip(),
"status_hash": _hash_text(status.stdout),
}


def _tree_signature(root: str, out: str) -> dict:
digest = hashlib.sha256()
abs_out = os.path.abspath(os.path.join(root, out))
for current, dirs, files in os.walk(root):
dirs[:] = [
d for d in dirs
if d not in (".git", "node_modules")
and os.path.abspath(os.path.join(current, d)) != abs_out
]
for name in sorted(files):
path = os.path.join(current, name)
try:
stat = os.stat(path)
except OSError:
continue
rel = os.path.relpath(path, root).replace(os.sep, "/")
digest.update(f"{rel}\0{stat.st_size}\0{stat.st_mtime_ns}\n".encode("utf-8"))
return {"kind": "tree", "hash": digest.hexdigest()}


def _freshness_signature(root: str, out: str) -> dict:
return _git_signature(root, out) or _tree_signature(root, out)


def _read_index_state(root: str, out: str) -> dict:
return _read_json_safe(_state_path(root, out))


def _write_index_state(root: str, out: str, signature: dict) -> None:
path = _state_path(root, out)
os.makedirs(os.path.dirname(path), exist_ok=True)
payload = {
"schema": INDEX_STATE_SCHEMA,
"signature": signature,
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
with open(path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2, sort_keys=True)
handle.write("\n")


def _artifacts_exist(paths: dict[str, str]) -> bool:
return all(os.path.exists(path) for path in paths.values())


def _index_result(
root: str,
out: str,
*,
status: str,
skipped_reason: str | None = None,
run_result: dict | None = None,
error: str | None = None,
) -> dict:
paths = _artifact_paths(root, out)
project_map = run_result.get("project_map", {}) if run_result else {}
precedent_index = run_result.get("precedent_index", {}) if run_result else {}
changed_files = list(project_map.get("changed_files") or [])
return {
"schema": INDEX_RESULT_SCHEMA,
"status": status,
"skipped_reason": skipped_reason,
"paths": paths,
"counts": {
"files": len(project_map.get("files", []) or []),
"precedents": len(precedent_index.get("items", []) or []),
"changed_files": len(changed_files),
},
"changed_files": changed_files,
"error": error,
}


def _emit_index_json(opts: dict, payload: dict) -> None:
if opts["json"]:
print(json.dumps(payload, sort_keys=True))


def _run_index(opts: dict) -> int:
root = os.path.abspath(opts["root"])
out = opts["out"]
paths = _artifact_paths(root, out)
state = _read_index_state(root, out)
current_signature = _freshness_signature(root, out)

if (
state.get("schema") == INDEX_STATE_SCHEMA
and state.get("signature") == current_signature
and _artifacts_exist(paths)
):
_emit_index_json(opts, _index_result(
root,
out,
status="skipped",
skipped_reason="already_fresh",
))
return 2

run_result = _run_once({
**opts,
"root": root,
"incremental": bool(state),
"silent": not opts["verbose"],
})
refreshed_signature = _freshness_signature(root, out)
_write_index_state(root, out, refreshed_signature)
_emit_index_json(opts, _index_result(root, out, status="updated", run_result=run_result))
return 0


def _watch(opts: dict) -> None:
root = os.path.abspath(opts["root"])
print(f"watching {root} for mapper updates...")
Expand All @@ -146,6 +340,21 @@ def _watch(opts: dict) -> None:
def main(argv: Sequence[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
opts = _parse_args(argv)
if opts["command"] == "index":
try:
return _run_index(opts)
except Exception as error: # noqa: BLE001 - CLI boundary must report a stable failure
payload = _index_result(
os.path.abspath(opts["root"]),
opts["out"],
status="failed",
error=str(error),
)
if opts["json"]:
_emit_index_json(opts, payload)
else:
print(f"index failed: {error}", file=sys.stderr)
return 1
_run_once(opts)
if opts["watch"]:
_watch(opts)
Expand Down
Loading
Loading