diff --git a/CHANGELOG.md b/CHANGELOG.md index c15ca98..411c8e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 diff --git a/PYPI.md b/PYPI.md index 6ee2bc4..e6fe337 100644 --- a/PYPI.md +++ b/PYPI.md @@ -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" @@ -45,6 +48,9 @@ The `llm-project-mapper` console script is provided as an alias. | Option | Description | |---|---| +| `index ` | 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 ` | Project root to map. Defaults to the current directory. | | `--out ` | Artifact directory. Defaults to `.simplicio`. | | `--stack ` | Stack hint when `.starter-meta.json` is absent. | diff --git a/README.md b/README.md index 7a754e7..77021d7 100644 --- a/README.md +++ b/README.md @@ -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 ` 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). diff --git a/package-lock.json b/package-lock.json index 755c238..3533f6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@wesleysimplicio/llm-project-mapper", - "version": "0.5.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@wesleysimplicio/llm-project-mapper", - "version": "0.5.0", + "version": "0.6.1", "license": "MIT", "bin": { "build-hamt-catalog": "bin/build-hamt-catalog", diff --git a/package.json b/package.json index 0037104..1181609 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/pyproject.toml b/pyproject.toml index 5104f13..6335d6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/simplicio_mapper/__init__.py b/simplicio_mapper/__init__.py index 2f750bb..0501eb4 100644 --- a/simplicio_mapper/__init__.py +++ b/simplicio_mapper/__init__.py @@ -2,6 +2,6 @@ from __future__ import annotations -__version__ = "0.5.0" +__version__ = "0.6.1" __all__ = ["__version__"] diff --git a/simplicio_mapper/cli.py b/simplicio_mapper/cli.py index 9019a87..4c336d0 100644 --- a/simplicio_mapper/cli.py +++ b/simplicio_mapper/cli.py @@ -8,7 +8,9 @@ from __future__ import annotations import json +import hashlib import os +import subprocess import sys import time from typing import Sequence @@ -16,15 +18,22 @@ 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 [--json] [--verbose] simplicio-mapper map [--root ] [--incremental] [--watch] simplicio-mapper update [--root ] [--watch] OPTIONS + index Idempotently create or refresh .simplicio artifacts. + --json Emit structured index output. + --verbose Show progress during index refreshes. --root Project root to map. Defaults to cwd. --stack Stack hint when .starter-meta.json is absent. --product-name Product name hint when .starter-meta.json is absent. @@ -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": @@ -79,6 +96,11 @@ 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) @@ -86,8 +108,8 @@ def _parse_args(argv: Sequence[str]) -> dict: 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 @@ -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...") @@ -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) diff --git a/tests/python/test_cli.py b/tests/python/test_cli.py index 6841a8f..cf591dd 100644 --- a/tests/python/test_cli.py +++ b/tests/python/test_cli.py @@ -10,6 +10,8 @@ import sys import tempfile import unittest +from contextlib import redirect_stdout +from io import StringIO from pathlib import Path ROOT = Path(__file__).resolve().parents[2] @@ -169,6 +171,62 @@ def test_version_matches_package(self) -> None: self.assertEqual(ctx.exception.code, 0) self.assertTrue(__version__) + def test_index_writes_json_contract(self) -> None: + _write(self.dir, "package.json", json.dumps({"name": "index-host"})) + _write(self.dir, "src/index.js", "export function run() {}\n") + + out = StringIO() + with redirect_stdout(out): + code = main(["index", str(self.dir), "--json"]) + + self.assertEqual(code, 0) + payload = json.loads(out.getvalue()) + self.assertEqual(payload["schema"], "simplicio.mapper-index/v1") + self.assertEqual(payload["status"], "updated") + self.assertEqual(payload["skipped_reason"], None) + self.assertTrue(payload["paths"]["project_map"].endswith(".simplicio/project-map.json")) + self.assertTrue(payload["paths"]["precedent_index"].endswith(".simplicio/precedent-index.json")) + self.assertGreaterEqual(payload["counts"]["files"], 2) + self.assertGreaterEqual(payload["counts"]["precedents"], 1) + + def test_index_skips_fresh_artifacts_quietly(self) -> None: + _write(self.dir, "package.json", json.dumps({"name": "fresh-host"})) + _write(self.dir, "src/index.js", "export function run() {}\n") + + self.assertEqual(main(["index", str(self.dir)]), 0) + + out = StringIO() + with redirect_stdout(out): + code = main(["index", str(self.dir), "--json"]) + + self.assertEqual(code, 2) + payload = json.loads(out.getvalue()) + self.assertEqual(payload["status"], "skipped") + self.assertEqual(payload["skipped_reason"], "already_fresh") + + quiet_out = StringIO() + with redirect_stdout(quiet_out): + quiet_code = main(["index", str(self.dir)]) + self.assertEqual(quiet_code, 2) + self.assertEqual(quiet_out.getvalue(), "") + + def test_index_refreshes_after_file_change(self) -> None: + _write(self.dir, "package.json", json.dumps({"name": "refresh-host"})) + _write(self.dir, "src/index.js", "export function run() { return 1; }\n") + + with redirect_stdout(StringIO()): + self.assertEqual(main(["index", str(self.dir), "--json"]), 0) + _write(self.dir, "src/index.js", "export function run() { return 2; }\n") + + out = StringIO() + with redirect_stdout(out): + code = main(["index", str(self.dir), "--json"]) + + self.assertEqual(code, 0) + payload = json.loads(out.getvalue()) + self.assertEqual(payload["status"], "updated") + self.assertIn("src/index.js", payload["changed_files"]) + if __name__ == "__main__": unittest.main()