diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f1ce8b..4ee7527 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,9 @@ jobs: python-version: ${{ matrix.python-version }} - name: Run installer smoke tests run: python tests/test_installer.py - - name: Verify installer dry-run - run: python scripts/install_cortex.py --dry-run - - name: Verify --list-files - run: python scripts/install_cortex.py --list-files + - name: Run CLI tests + run: python tests/test_cli.py + - name: Verify cortex --version + run: python -m cli --version + - name: Verify cortex validate + run: python -m cli validate diff --git a/MANIFEST.yaml b/MANIFEST.yaml index 14e2c05..2eba02a 100644 --- a/MANIFEST.yaml +++ b/MANIFEST.yaml @@ -1,7 +1,7 @@ name: cortex display_name: CORTEX category: project-evolution-operating-system -version: 3.1.0 +version: 4.0.0 license: Apache-2.0 entrypoint: START.md default_mode: advisory diff --git a/README.md b/README.md index 1fa7d2c..2a3f153 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,11 @@ It installs a persistent operating layer *around* the project so any capable age CORTEX is: - a project continuity system for agent-driven development; - a governance layer for AI-built repositories; +- a CLI that verifies, scores, and protects your project; - a safety model for branch isolation and change sequencing; - a persistent memory layer stored in repository files; -- a structured audit and restructuring workflow. +- a structured audit and restructuring workflow; +- git hooks that enforce protocol compliance automatically. CORTEX is not: - an app framework; @@ -108,10 +110,12 @@ When CORTEX is applied to a target repository, the agent should: current branch, repo status, recent branches, relevant commits, active risks. 4. Identify the last confirmed stable branch. 5. Create a new isolated branch from that stable branch. -6. Install `.cortex/` using `scripts/install_cortex.py` when possible. -7. Fill the installed files with repository evidence. -8. Produce the first audit and restructuring outputs. -9. Finish by documenting the next safe step in `.cortex/CURRENT_STATUS.md`. +6. Install `.cortex/` using `cortex init` (or `python -m cli init`). +7. Install git hooks using `cortex hooks install`. +8. Fill the installed files with repository evidence. +9. Produce the first audit and restructuring outputs. +10. Run `cortex score` to compute the initial CSEI. +11. Finish by documenting the next safe step in `.cortex/CURRENT_STATUS.md`. ## First use @@ -155,6 +159,55 @@ You can upgrade from Lite to Full at any time by running the installer again wit For an example of filled `.cortex/` files, see [`examples/filled-cortex/`](examples/filled-cortex/). +## CLI + +CORTEX includes a command-line tool that turns governance documentation into operational mechanisms. + +### Installation + +```bash +pip install -e . +# or run directly: +python -m cli +``` + +### Commands + +| Command | Purpose | +|---|---| +| `cortex init ` | Install `.cortex/` into a target repository | +| `cortex init --lite` | Install only the 3 essential files | +| `cortex doctor ` | Check health of a `.cortex/` installation | +| `cortex status ` | Quick overview of project state and CSEI | +| `cortex validate` | Verify consistency of CORTEX package | +| `cortex score ` | Compute CSEI score from repository evidence | +| `cortex score --update` | Compute and write CSEI into CURRENT_STATUS.md | +| `cortex hooks install ` | Install git hooks that enforce branch policy | +| `cortex hooks remove ` | Remove CORTEX git hooks | +| `cortex hooks status ` | Check which hooks are active | + +### Git hooks + +CORTEX provides git hooks that enforce the protocol automatically: +- **pre-commit**: blocks direct commits to main/master (Law 3) +- **pre-push**: blocks direct pushes to main/master +- **post-checkout**: reminds the operator of project state on branch switch + +Install with `cortex hooks install` — remove with `cortex hooks remove`. + +### CSEI — automated scoring + +The CORTEX Structural Evolution Index is computed automatically by inspecting repository evidence: +- **Structure**: .cortex/ presence and file completeness +- **Clarity**: project context and next safe step documented +- **Safety**: branch discipline, clean tree, hooks installed +- **Verification**: tests exist, CI configured +- **Continuity**: handoff and learning artifacts filled +- **Recoverability**: remote configured, stable branch documented +- **Regression Pressure**: unguarded regressions (lower is better) + +Run `cortex score` to see the full breakdown. + ## Using CORTEX with coding agents ### Codex @@ -215,10 +268,14 @@ Apply the attached CORTEX package to this target project autonomously. Read STAR - `docs/` — overview, first-use, boundaries, templates, and agent usage docs - `adapters/` — agent-specific operating notes - `templates/` — standard files installed into `.cortex/` -- `scripts/` — installation tooling +- `cli/` — CORTEX CLI (init, doctor, status, validate, score, hooks) +- `cli/core/` — core modules (manifest, repo, templates, scoring) +- `cli/hooks/` — git hook scripts +- `scripts/` — standalone installation script (legacy) - `examples/` — first-use prompt and filled `.cortex/` example -- `tests/` — installer smoke tests +- `tests/` — installer and CLI tests (34 tests) - `.github/workflows/` — CI pipeline +- `pyproject.toml` — Python package definition ## License diff --git a/START.md b/START.md index 2dd1519..63b5c81 100644 --- a/START.md +++ b/START.md @@ -39,10 +39,12 @@ Then inspect the target repository. 2. Determine whether `.cortex/` already exists. 3. Locate the latest confirmed stable branch. 4. Create a new isolated branch from that stable branch. -5. Install `.cortex/` using `scripts/install_cortex.py` when possible. -6. Populate or update the initial CORTEX files. -7. Produce the first audit and restructuring outputs. -8. Record the next safe step in `.cortex/CURRENT_STATUS.md`. +5. Install `.cortex/` using `cortex init` (or `python -m cli init`, or `scripts/install_cortex.py`). +6. Install git hooks using `cortex hooks install` for branch protection. +7. Populate or update the initial CORTEX files. +8. Produce the first audit and restructuring outputs. +9. Run `cortex score` to compute the initial CSEI. +10. Record the next safe step in `.cortex/CURRENT_STATUS.md`. ## Branch law diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 0000000..c0f9178 --- /dev/null +++ b/cli/__init__.py @@ -0,0 +1,3 @@ +"""CORTEX CLI — operational layer for AI-built projects.""" + +__version__ = "4.0.0" diff --git a/cli/__main__.py b/cli/__main__.py new file mode 100644 index 0000000..8390116 --- /dev/null +++ b/cli/__main__.py @@ -0,0 +1,6 @@ +"""Allow running as: python -m cli""" + +from .main import main +import sys + +sys.exit(main() or 0) diff --git a/cli/commands/__init__.py b/cli/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/commands/doctor.py b/cli/commands/doctor.py new file mode 100644 index 0000000..d8728af --- /dev/null +++ b/cli/commands/doctor.py @@ -0,0 +1,142 @@ +"""cortex doctor — check health of a .cortex/ installation.""" + +from pathlib import Path + +from ..core import manifest, repo, templates + + +def add_arguments(parser): + parser.add_argument( + "target", + nargs="?", + default=".", + help="Target repository root (default: current directory).", + ) + + +def run(args): + target = Path(args.target).resolve() + issues = [] + warnings = [] + ok = [] + + # 1. Target exists + if not target.is_dir(): + print(f"ERROR: {target} is not a directory.") + return 1 + + # 2. Git repo + if repo.is_git_repo(target): + ok.append("Git repository detected") + # Branch check + if repo.is_on_main(target): + warnings.append("Currently on main/master — CORTEX protocol requires isolated branches for work") + else: + branch = repo.current_branch(target) + ok.append(f"On branch: {branch}") + # Clean tree + if repo.is_clean(target): + ok.append("Working tree is clean") + else: + warnings.append("Working tree has uncommitted changes") + # Remote + if repo.has_remote(target): + ok.append("Remote repository configured") + else: + warnings.append("No remote configured — recoverability reduced") + else: + issues.append("Not a git repository — CORTEX requires git") + + # 3. .cortex/ exists + if templates.exists(target): + ok.append(".cortex/ directory exists") + else: + issues.append(".cortex/ not found — run 'cortex init' first") + _print_report(ok, warnings, issues) + return 1 if issues else 0 + + # 4. File completeness + expected = manifest.full_files() + lite = manifest.lite_files() + installed = templates.installed_files(target) + missing = templates.missing_files(target, expected) + missing_lite = templates.missing_files(target, lite) + + if not missing: + ok.append(f"All {len(expected)} template files present (full mode)") + elif not missing_lite: + ok.append(f"Lite mode files present ({len(lite)} essential files)") + warnings.append(f"Full mode files missing: {', '.join(missing)}") + else: + issues.append(f"Essential files missing: {', '.join(missing_lite)}") + + # 5. File fill status + status = templates.file_status(target, installed) + filled = [f for f, s in status.items() if s == "filled"] + empty = [f for f, s in status.items() if s == "empty"] + + if filled: + ok.append(f"{len(filled)} file(s) filled with content") + if empty: + warnings.append(f"{len(empty)} file(s) still empty (template skeleton only): {', '.join(empty)}") + + # 6. Critical fields + d = templates.cortex_dir(target) + phase = templates.read_field(d / "CURRENT_STATUS.md", "Name") + if phase: + ok.append(f"Current phase documented: {phase}") + else: + warnings.append("CURRENT_STATUS.md has no current phase defined") + + next_step = templates.read_field(d / "CURRENT_STATUS.md", "Action") + if next_step: + ok.append(f"Next safe step documented") + else: + warnings.append("CURRENT_STATUS.md has no next safe step defined") + + stable_branch = templates.read_field(d / "CURRENT_STATUS.md", "Name") + # Check under "Last stable branch" section + stable_lines = templates.read_section(d / "CURRENT_STATUS.md", "Last stable branch") + if stable_lines: + ok.append("Last stable branch documented") + else: + warnings.append("No last stable branch documented in CURRENT_STATUS.md") + + # 7. Git hooks + hooks_path = repo.hooks_dir(target) + has_precommit = (hooks_path / "pre-commit").exists() + has_prepush = (hooks_path / "pre-push").exists() + if has_precommit and has_prepush: + ok.append("CORTEX git hooks installed") + elif has_precommit or has_prepush: + warnings.append("Only partial git hooks installed — run 'cortex hooks install'") + else: + warnings.append("No CORTEX git hooks — run 'cortex hooks install' for branch protection") + + _print_report(ok, warnings, issues) + return 1 if issues else 0 + + +def _print_report(ok: list, warnings: list, issues: list): + print("CORTEX Doctor Report") + print("=" * 40) + if ok: + print(f"\n OK ({len(ok)}):") + for item in ok: + print(f" [+] {item}") + if warnings: + print(f"\n WARNINGS ({len(warnings)}):") + for item in warnings: + print(f" [!] {item}") + if issues: + print(f"\n ISSUES ({len(issues)}):") + for item in issues: + print(f" [X] {item}") + print() + total_checks = len(ok) + len(warnings) + len(issues) + if issues: + print(f" Health: UNHEALTHY — {len(issues)} issue(s) need attention") + elif warnings: + print(f" Health: FAIR — {len(warnings)} warning(s) to address") + else: + print(f" Health: GOOD — {total_checks} checks passed") diff --git a/cli/commands/hooks.py b/cli/commands/hooks.py new file mode 100644 index 0000000..d06f9cd --- /dev/null +++ b/cli/commands/hooks.py @@ -0,0 +1,141 @@ +"""cortex hooks — install or remove git hooks that enforce CORTEX protocol.""" + +import shutil +import stat +from pathlib import Path + +from ..core import repo + +HOOK_NAMES = ["pre-commit", "pre-push", "post-checkout"] +HOOK_SOURCES = Path(__file__).resolve().parents[1] / "hooks" +CORTEX_MARKER = "# CORTEX" + + +def add_arguments(parser): + sub = parser.add_subparsers(dest="hooks_action") + install_parser = sub.add_parser("install", help="Install CORTEX git hooks.") + install_parser.add_argument( + "target", + nargs="?", + default=".", + help="Target repository root (default: current directory).", + ) + remove_parser = sub.add_parser("remove", help="Remove CORTEX git hooks.") + remove_parser.add_argument( + "target", + nargs="?", + default=".", + help="Target repository root (default: current directory).", + ) + status_parser = sub.add_parser("status", help="Check hook installation status.") + status_parser.add_argument( + "target", + nargs="?", + default=".", + help="Target repository root (default: current directory).", + ) + + +def run(args): + action = getattr(args, "hooks_action", None) + if action is None: + print("Usage: cortex hooks {install|remove|status}") + return 1 + + target = Path(args.target).resolve() + + if not repo.is_git_repo(target): + print("ERROR: Not a git repository.") + return 1 + + hooks_dir = repo.hooks_dir(target) + + if action == "install": + return _install(hooks_dir) + elif action == "remove": + return _remove(hooks_dir) + elif action == "status": + return _status(hooks_dir) + return 1 + + +def _install(hooks_dir: Path) -> int: + hooks_dir.mkdir(parents=True, exist_ok=True) + installed = [] + skipped = [] + + for name in HOOK_NAMES: + source = HOOK_SOURCES / name + dest = hooks_dir / name + + if not source.exists(): + continue + + if dest.exists(): + content = dest.read_text(encoding="utf-8", errors="ignore") + if CORTEX_MARKER in content: + skipped.append(name) + continue + # Non-CORTEX hook exists — back it up + backup = dest.with_suffix(".pre-cortex") + shutil.copy2(dest, backup) + print(f" Backed up existing {name} → {name}.pre-cortex") + + shutil.copy2(source, dest) + dest.chmod(dest.stat().st_mode | stat.S_IEXEC) + installed.append(name) + + if installed: + print(f" Installed hooks: {', '.join(installed)}") + if skipped: + print(f" Already installed: {', '.join(skipped)}") + if not installed and not skipped: + print(" No hooks to install.") + + print("\n CORTEX protocol enforcement is now active.") + print(" Law 3 (branch isolation) is enforced on commit and push.") + return 0 + + +def _remove(hooks_dir: Path) -> int: + removed = [] + + for name in HOOK_NAMES: + dest = hooks_dir / name + if not dest.exists(): + continue + + content = dest.read_text(encoding="utf-8", errors="ignore") + if CORTEX_MARKER not in content: + continue + + dest.unlink() + removed.append(name) + + # Restore backup if it exists + backup = dest.with_suffix(".pre-cortex") + if backup.exists(): + shutil.move(str(backup), str(dest)) + print(f" Restored original {name} from backup") + + if removed: + print(f" Removed CORTEX hooks: {', '.join(removed)}") + else: + print(" No CORTEX hooks found to remove.") + return 0 + + +def _status(hooks_dir: Path) -> int: + print("CORTEX Hook Status") + print("=" * 30) + for name in HOOK_NAMES: + dest = hooks_dir / name + if not dest.exists(): + print(f" {name}: not installed") + else: + content = dest.read_text(encoding="utf-8", errors="ignore") + if CORTEX_MARKER in content: + print(f" {name}: ACTIVE (CORTEX)") + else: + print(f" {name}: exists (not CORTEX)") + return 0 diff --git a/cli/commands/init.py b/cli/commands/init.py new file mode 100644 index 0000000..ca89bdd --- /dev/null +++ b/cli/commands/init.py @@ -0,0 +1,102 @@ +"""cortex init — install .cortex/ into a target repository.""" + +import shutil +from pathlib import Path + +from ..core import manifest, templates + + +def add_arguments(parser): + parser.add_argument( + "target", + nargs="?", + default=".", + help="Target repository root (default: current directory).", + ) + parser.add_argument( + "--lite", + action="store_true", + help="Install only essential files (PROJECT_CONTEXT, CURRENT_STATUS, AGENT_HANDOFF).", + ) + parser.add_argument( + "--force", + action="store_true", + help="Overwrite existing .cortex/ files.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would change without writing.", + ) + + +def run(args): + m = manifest.load() + files = manifest.lite_files(m) if args.lite else manifest.full_files(m) + + if not files: + key = "lite_files" if args.lite else "required_project_files" + print(f"ERROR: MANIFEST.yaml has no '{key}' defined.") + return 1 + + # Validate that all template sources exist + source_dir = manifest._ROOT / "templates" + missing_sources = [f for f in files if not (source_dir / f).exists()] + if missing_sources: + print(f"ERROR: Template sources missing: {', '.join(missing_sources)}") + return 1 + + target = Path(args.target).resolve() + if not target.is_dir(): + print(f"ERROR: Target directory does not exist: {target}") + return 1 + + cortex_dir = target / ".cortex" + create = [] + overwrite = [] + skip = [] + + for name in files: + dst = cortex_dir / name + if dst.exists(): + if args.force: + overwrite.append(name) + else: + skip.append(name) + else: + create.append(name) + + if not args.dry_run: + cortex_dir.mkdir(parents=True, exist_ok=True) + for name in create + overwrite: + shutil.copy2(source_dir / name, cortex_dir / name) + + mode_label = " (lite)" if args.lite else "" + action = "Would install" if args.dry_run else "Installed" + print(f"{action} CORTEX{mode_label} into: {cortex_dir}") + + if create: + label = "Would create:" if args.dry_run else "Created:" + print(f"\n {label}") + for name in create: + print(f" {name}") + if overwrite: + label = "Would overwrite:" if args.dry_run else "Overwrote:" + print(f"\n {label}") + for name in overwrite: + print(f" {name}") + if skip: + print(f"\n Skipped (already exist):") + for name in skip: + print(f" {name}") + + if not create and not overwrite and not skip: + print(" No actions required — .cortex/ is already complete.") + + if args.lite and not args.dry_run and create: + print( + f"\n Lite mode installed ({len(files)} files)." + f"\n To upgrade to full mode: cortex init {args.target}" + ) + + return 0 diff --git a/cli/commands/score.py b/cli/commands/score.py new file mode 100644 index 0000000..cb33bdf --- /dev/null +++ b/cli/commands/score.py @@ -0,0 +1,118 @@ +"""cortex score — compute CSEI automatically from repository evidence.""" + +from pathlib import Path + +from ..core import scoring, templates + + +def add_arguments(parser): + parser.add_argument( + "target", + nargs="?", + default=".", + help="Target repository root (default: current directory).", + ) + parser.add_argument( + "--update", + action="store_true", + help="Write the computed CSEI back into .cortex/CURRENT_STATUS.md.", + ) + + +def run(args): + target = Path(args.target).resolve() + + if not templates.exists(target): + print("No .cortex/ found. Run 'cortex init' first.") + return 1 + + scores = scoring.compute_csei(target) + + print("CORTEX Structural Evolution Index (CSEI)") + print("=" * 45) + print() + _bar(scores, "structure", "Structure") + _bar(scores, "clarity", "Clarity") + _bar(scores, "safety", "Safety") + _bar(scores, "verification", "Verification") + _bar(scores, "continuity", "Continuity") + _bar(scores, "recoverability", "Recoverability") + print() + _bar(scores, "regression_pressure", "Regression Pressure", invert=True) + print() + print(f" Base Score: {scores['base_score']}/5.0") + print(f" Final CSEI: {scores['csei']}/100") + print() + _interpret(scores["csei"]) + + if args.update: + _write_to_status(target, scores) + print("\n CSEI written to .cortex/CURRENT_STATUS.md") + + return 0 + + +def _bar(scores: dict, key: str, label: str, invert: bool = False): + val = scores[key] + filled = val + empty = 5 - val + bar = "[" + "#" * filled + "." * empty + "]" + note = "" + if invert: + if val <= 1: + note = " (low — good)" + elif val >= 4: + note = " (high — needs attention)" + print(f" {label:<22} {bar} {val}/5{note}") + + +def _interpret(csei: int): + if csei >= 80: + print(" Interpretation: STRONG — project is well-governed and recoverable.") + elif csei >= 60: + print(" Interpretation: SOLID — good foundation, some areas to improve.") + elif csei >= 40: + print(" Interpretation: DEVELOPING — governance exists but gaps remain.") + elif csei >= 20: + print(" Interpretation: FRAGILE — significant structural risks present.") + else: + print(" Interpretation: CRITICAL — project needs immediate stabilization.") + + +def _write_to_status(target: Path, scores: dict): + """Update the CSEI snapshot in CURRENT_STATUS.md.""" + status_file = templates.cortex_dir(target) / "CURRENT_STATUS.md" + if not status_file.exists(): + return + + content = status_file.read_text(encoding="utf-8") + lines = content.splitlines() + new_lines = [] + in_csei = False + csei_written = False + + for line in lines: + if line.strip() == "## CSEI snapshot": + in_csei = True + new_lines.append(line) + new_lines.append(f"- Structure: {scores['structure']}") + new_lines.append(f"- Clarity: {scores['clarity']}") + new_lines.append(f"- Safety: {scores['safety']}") + new_lines.append(f"- Verification: {scores['verification']}") + new_lines.append(f"- Continuity: {scores['continuity']}") + new_lines.append(f"- Recoverability: {scores['recoverability']}") + new_lines.append(f"- Regression Pressure: {scores['regression_pressure']}") + new_lines.append(f"- Final CSEI: {scores['csei']}") + csei_written = True + continue + + if in_csei: + if line.strip().startswith("## "): + in_csei = False + new_lines.append(line) + # Skip old CSEI lines + continue + + new_lines.append(line) + + status_file.write_text("\n".join(new_lines) + "\n", encoding="utf-8") diff --git a/cli/commands/status.py b/cli/commands/status.py new file mode 100644 index 0000000..ab1c214 --- /dev/null +++ b/cli/commands/status.py @@ -0,0 +1,89 @@ +"""cortex status — quick overview of project state.""" + +from pathlib import Path + +from ..core import manifest, repo, templates, scoring + + +def add_arguments(parser): + parser.add_argument( + "target", + nargs="?", + default=".", + help="Target repository root (default: current directory).", + ) + + +def run(args): + target = Path(args.target).resolve() + + if not templates.exists(target): + print("No .cortex/ found. Run 'cortex init' first.") + return 1 + + d = templates.cortex_dir(target) + + # Header + project_name = templates.read_field(d / "PROJECT_CONTEXT.md", "Project name") + project_goal = templates.read_field(d / "PROJECT_CONTEXT.md", "Project goal") + print(f" Project: {project_name or '(not set)'}") + if project_goal: + print(f" Goal: {project_goal}") + print() + + # Phase + phase = templates.read_field(d / "CURRENT_STATUS.md", "Name") + confidence = templates.read_field(d / "CURRENT_STATUS.md", "Confidence") + print(f" Phase: {phase or '(not set)'}") + if confidence: + print(f" Confidence: {confidence}") + + # Branch info + if repo.is_git_repo(target): + branch = repo.current_branch(target) + print(f" Branch: {branch}") + if repo.is_on_main(target): + print(f" WARNING: on main — create an isolated branch before working") + + # Last stable + stable_lines = templates.read_section(d / "CURRENT_STATUS.md", "Last stable branch") + for line in stable_lines[:2]: + print(f" Stable: {line}") + + # Last update + date = templates.read_field(d / "CURRENT_STATUS.md", "Date") + agent = templates.read_field(d / "CURRENT_STATUS.md", "Agent") + if date or agent: + print(f" Last update: {date or '?'} by {agent or '?'}") + + # Next safe step + next_step = templates.read_field(d / "CURRENT_STATUS.md", "Action") + why_safe = templates.read_field(d / "CURRENT_STATUS.md", "Why this is safe") + if next_step: + print(f"\n Next step: {next_step}") + if why_safe: + print(f" Why safe: {why_safe}") + + # Known issues + issues = templates.read_section(d / "CURRENT_STATUS.md", "Known issues") + if issues: + print(f"\n Known issues:") + for issue in issues[:5]: + print(f" {issue}") + + # CSEI + scores = scoring.compute_csei(target) + print(f"\n CSEI Score: {scores['csei']}/100") + print(f" Structure: {scores['structure']}/5 Clarity: {scores['clarity']}/5 Safety: {scores['safety']}/5") + print(f" Verification: {scores['verification']}/5 Continuity: {scores['continuity']}/5 Recoverability: {scores['recoverability']}/5") + print(f" Regression Pressure: {scores['regression_pressure']}/5 (lower is better)") + + # File status summary + expected = manifest.full_files() + status = templates.file_status(target, expected) + filled = sum(1 for s in status.values() if s == "filled") + empty = sum(1 for s in status.values() if s == "empty") + missing = sum(1 for s in status.values() if s == "missing") + print(f"\n Files: {filled} filled, {empty} empty, {missing} missing (of {len(expected)} total)") + + return 0 diff --git a/cli/commands/validate.py b/cli/commands/validate.py new file mode 100644 index 0000000..549d414 --- /dev/null +++ b/cli/commands/validate.py @@ -0,0 +1,109 @@ +"""cortex validate — verify consistency of CORTEX installation.""" + +from pathlib import Path + +from ..core import manifest, templates + + +def add_arguments(parser): + parser.add_argument( + "target", + nargs="?", + default=".", + help="Target repository root (default: current directory).", + ) + + +def run(args): + target = Path(args.target).resolve() + errors = [] + ok = [] + + # 1. MANIFEST.yaml integrity + try: + m = manifest.load() + ok.append("MANIFEST.yaml loaded successfully") + except (FileNotFoundError, Exception) as e: + print(f"ERROR: Cannot load MANIFEST.yaml: {e}") + return 1 + + # 2. required_project_files defined + full = manifest.full_files(m) + lite = manifest.lite_files(m) + if full: + ok.append(f"required_project_files: {len(full)} files defined") + else: + errors.append("required_project_files is empty or missing in MANIFEST.yaml") + if lite: + ok.append(f"lite_files: {len(lite)} files defined") + else: + errors.append("lite_files is empty or missing in MANIFEST.yaml") + + # 3. lite_files is subset of full + if lite and full: + extra = set(lite) - set(full) + if extra: + errors.append(f"lite_files contains entries not in required_project_files: {extra}") + else: + ok.append("lite_files is a valid subset of required_project_files") + + # 4. Every manifest file has a template source + source_dir = manifest._ROOT / "templates" + missing_sources = [f for f in full if not (source_dir / f).exists()] + if missing_sources: + errors.append(f"Template sources missing: {', '.join(missing_sources)}") + else: + ok.append(f"All {len(full)} template source files exist") + + # 5. No orphan templates + if source_dir.is_dir(): + template_names = {f.name for f in source_dir.iterdir() if f.is_file()} + orphans = template_names - set(full) + if orphans: + errors.append(f"Orphan templates (not in MANIFEST.yaml): {', '.join(orphans)}") + else: + ok.append("No orphan templates — all files tracked in MANIFEST.yaml") + + # 6. Target .cortex/ consistency (if installed) + if templates.exists(target): + installed = templates.installed_files(target) + missing_in_target = templates.missing_files(target, full) + status = templates.file_status(target, installed) + filled = sum(1 for s in status.values() if s == "filled") + empty = sum(1 for s in status.values() if s == "empty") + + ok.append(f".cortex/ found: {len(installed)} files installed") + if missing_in_target: + # Check if at least lite files are present + missing_lite = templates.missing_files(target, lite) + if missing_lite: + errors.append(f"Essential files missing from .cortex/: {', '.join(missing_lite)}") + else: + ok.append(f"Lite files present; {len(missing_in_target)} full-mode file(s) missing") + else: + ok.append("All required files present in .cortex/") + + if empty: + empty_names = [f for f, s in status.items() if s == "empty"] + ok.append(f"{filled} file(s) filled, {empty} still empty: {', '.join(empty_names)}") + else: + ok.append(".cortex/ not installed in target (validation is source-only)") + + # Report + print("CORTEX Validation Report") + print("=" * 40) + if ok: + print(f"\n PASSED ({len(ok)}):") + for item in ok: + print(f" [+] {item}") + if errors: + print(f"\n FAILED ({len(errors)}):") + for item in errors: + print(f" [X] {item}") + print() + if errors: + print(f" Result: FAIL — {len(errors)} error(s)") + return 1 + else: + print(f" Result: PASS — {len(ok)} checks passed") + return 0 diff --git a/cli/core/__init__.py b/cli/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/core/manifest.py b/cli/core/manifest.py new file mode 100644 index 0000000..9d35148 --- /dev/null +++ b/cli/core/manifest.py @@ -0,0 +1,62 @@ +"""Load and query MANIFEST.yaml — the single source of truth.""" + +from pathlib import Path + +try: + import yaml +except ImportError: + yaml = None + +_ROOT = Path(__file__).resolve().parents[2] +MANIFEST_PATH = _ROOT / "MANIFEST.yaml" + + +def _parse_simple_yaml(path: Path) -> dict: + """Minimal YAML parser for list/scalar fields (no PyYAML needed).""" + data: dict = {} + current_key: str | None = None + with open(path) as f: + for line in f: + stripped = line.rstrip() + if ( + stripped.endswith(":") + and not stripped.startswith(" ") + and not stripped.startswith("-") + ): + current_key = stripped[:-1].strip() + data[current_key] = [] + elif stripped.startswith(" - ") and current_key is not None: + data[current_key].append(stripped.strip("- ").strip()) + elif not stripped.startswith(" ") and ":" in stripped: + key, _, value = stripped.partition(":") + current_key = None + data[key.strip()] = value.strip() + return data + + +def load(path: Path | None = None) -> dict: + """Load MANIFEST.yaml and return as dict.""" + p = path or MANIFEST_PATH + if not p.exists(): + raise FileNotFoundError(f"MANIFEST.yaml not found at {p}") + if yaml is not None: + with open(p) as f: + return yaml.safe_load(f) + return _parse_simple_yaml(p) + + +def full_files(manifest: dict | None = None) -> list[str]: + """Return the full file list from required_project_files.""" + m = manifest or load() + return m.get("required_project_files", []) + + +def lite_files(manifest: dict | None = None) -> list[str]: + """Return the lite file list.""" + m = manifest or load() + return m.get("lite_files", []) + + +def version(manifest: dict | None = None) -> str: + m = manifest or load() + return m.get("version", "unknown") diff --git a/cli/core/repo.py b/cli/core/repo.py new file mode 100644 index 0000000..8b53a91 --- /dev/null +++ b/cli/core/repo.py @@ -0,0 +1,64 @@ +"""Git repository inspection utilities.""" + +import subprocess +from pathlib import Path + + +def _run(cmd: list[str], cwd: Path | None = None) -> str | None: + """Run a git command and return stdout, or None on failure.""" + try: + result = subprocess.run( + cmd, capture_output=True, text=True, cwd=cwd, timeout=10 + ) + if result.returncode == 0: + return result.stdout.strip() + return None + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + + +def is_git_repo(target: Path) -> bool: + return _run(["git", "rev-parse", "--git-dir"], cwd=target) is not None + + +def current_branch(target: Path) -> str | None: + return _run(["git", "branch", "--show-current"], cwd=target) + + +def is_on_main(target: Path) -> bool: + branch = current_branch(target) + return branch in ("main", "master") + + +def is_clean(target: Path) -> bool: + status = _run(["git", "status", "--porcelain"], cwd=target) + return status == "" if status is not None else False + + +def recent_commits(target: Path, count: int = 5) -> list[str]: + output = _run( + ["git", "log", f"--oneline", f"-{count}"], cwd=target + ) + if output: + return output.splitlines() + return [] + + +def has_remote(target: Path) -> bool: + output = _run(["git", "remote", "-v"], cwd=target) + return bool(output) + + +def last_commit_date(target: Path) -> str | None: + return _run( + ["git", "log", "-1", "--format=%ci"], cwd=target + ) + + +def hooks_dir(target: Path) -> Path: + """Return the git hooks directory for the target repo.""" + output = _run(["git", "rev-parse", "--git-dir"], cwd=target) + if output: + git_dir = target / output if not Path(output).is_absolute() else Path(output) + return git_dir / "hooks" + return target / ".git" / "hooks" diff --git a/cli/core/scoring.py b/cli/core/scoring.py new file mode 100644 index 0000000..085060a --- /dev/null +++ b/cli/core/scoring.py @@ -0,0 +1,201 @@ +"""CSEI — CORTEX Structural Evolution Index — automated scoring by evidence.""" + +from pathlib import Path + +from . import manifest, repo, templates + + +def _clamp(value: int, low: int = 0, high: int = 5) -> int: + return max(low, min(high, value)) + + +def score_structure(target: Path, expected_files: list[str]) -> int: + """Score based on .cortex/ presence and file completeness.""" + if not templates.exists(target): + return 0 + status = templates.file_status(target, expected_files) + total = len(expected_files) + if total == 0: + return 0 + filled = sum(1 for s in status.values() if s == "filled") + present = sum(1 for s in status.values() if s != "missing") + # 1 point for .cortex/ existing, up to 4 more based on fill ratio + score = 1 + round((filled / total) * 4) + return _clamp(score) + + +def score_clarity(target: Path) -> int: + """Score based on project context and status clarity.""" + d = templates.cortex_dir(target) + score = 0 + # PROJECT_CONTEXT filled + if templates.is_file_filled(d / "PROJECT_CONTEXT.md"): + score += 2 + # CURRENT_STATUS has next safe step + next_step = templates.read_field(d / "CURRENT_STATUS.md", "Action") + if next_step: + score += 2 + # CURRENT_STATUS has current phase + phase = templates.read_field(d / "CURRENT_STATUS.md", "Name") + if phase: + score += 1 + return _clamp(score) + + +def score_safety(target: Path) -> int: + """Score based on branch discipline and hooks.""" + score = 0 + if repo.is_git_repo(target): + score += 1 + # Not on main + if not repo.is_on_main(target): + score += 1 + # Clean working tree + if repo.is_clean(target): + score += 1 + # Hooks installed + hooks_path = repo.hooks_dir(target) + if (hooks_path / "pre-commit").exists(): + score += 1 + if (hooks_path / "pre-push").exists(): + score += 1 + return _clamp(score) + + +def score_verification(target: Path) -> int: + """Score based on test and CI presence.""" + score = 0 + # Has test directory + test_dirs = ["tests", "test", "__tests__", "spec"] + for d in test_dirs: + if (target / d).is_dir(): + test_files = list((target / d).rglob("test_*.py")) + \ + list((target / d).rglob("*_test.py")) + \ + list((target / d).rglob("*.test.js")) + \ + list((target / d).rglob("*.test.ts")) + \ + list((target / d).rglob("*.spec.js")) + \ + list((target / d).rglob("*.spec.ts")) + if test_files: + score += 2 + else: + score += 1 + break + # Has CI + ci_paths = [ + target / ".github" / "workflows", + target / ".gitlab-ci.yml", + target / ".circleci", + target / "Jenkinsfile", + ] + for ci in ci_paths: + if ci.exists(): + score += 2 + break + # Has AUDIT_MATRIX filled + d = templates.cortex_dir(target) + if templates.is_file_filled(d / "AUDIT_MATRIX.md"): + score += 1 + return _clamp(score) + + +def score_continuity(target: Path) -> int: + """Score based on handoff and learning artifacts.""" + d = templates.cortex_dir(target) + score = 0 + # AGENT_HANDOFF filled + if templates.is_file_filled(d / "AGENT_HANDOFF.md"): + score += 2 + # LEARNING_LEDGER has entries + if templates.is_file_filled(d / "LEARNING_LEDGER.md"): + score += 2 + # CURRENT_STATUS has last stable branch + branch = templates.read_field(d / "CURRENT_STATUS.md", "Name") + if branch: + score += 1 + return _clamp(score) + + +def score_recoverability(target: Path) -> int: + """Score based on git health and documented stable state.""" + score = 0 + if repo.is_git_repo(target): + score += 1 + if repo.has_remote(target): + score += 1 + # Recent activity + date = repo.last_commit_date(target) + if date: + score += 1 + # Documented stable branch + d = templates.cortex_dir(target) + evidence = templates.read_field(d / "CURRENT_STATUS.md", "Evidence") + if evidence: + score += 1 + # RESTRUCTURING_PLAN filled + if templates.is_file_filled(d / "RESTRUCTURING_PLAN.md"): + score += 1 + return _clamp(score) + + +def score_regression_pressure(target: Path) -> int: + """Score regression pressure (higher = worse). 0 is best, 5 is worst.""" + d = templates.cortex_dir(target) + regression_file = d / "REGRESSION_MAP.md" + if not regression_file.exists(): + return 2 # Unknown = moderate assumed pressure + content = regression_file.read_text(encoding="utf-8", errors="ignore") + lines = [ + l for l in content.splitlines() + if l.strip().startswith("|") and not l.strip().startswith("|-") and not l.strip().startswith("| Regression") + ] + if not lines: + return 1 # Empty = low pressure + # Count regressions without guardrails + unguarded = 0 + for line in lines: + cells = [c.strip() for c in line.split("|")] + # Guardrail is typically the 5th column + if len(cells) >= 6: + guardrail = cells[5] + if not guardrail or guardrail.lower() in ("", "none", "pending", "todo"): + unguarded += 1 + total = len(lines) + if total == 0: + return 1 + if unguarded == 0: + return 0 # All guarded + ratio = unguarded / total + if ratio > 0.7: + return 4 + if ratio > 0.4: + return 3 + return 2 + + +def compute_csei(target: Path, expected_files: list[str] | None = None) -> dict: + """Compute the full CSEI score for a target repository.""" + if expected_files is None: + expected_files = manifest.full_files() + + structure = score_structure(target, expected_files) + clarity = score_clarity(target) + safety = score_safety(target) + verification = score_verification(target) + continuity = score_continuity(target) + recoverability = score_recoverability(target) + regression = score_regression_pressure(target) + + base = (structure + clarity + safety + verification + continuity + recoverability) / 6 + csei = round((base * 20) * (1 - (regression * 0.08))) + + return { + "structure": structure, + "clarity": clarity, + "safety": safety, + "verification": verification, + "continuity": continuity, + "recoverability": recoverability, + "regression_pressure": regression, + "base_score": round(base, 2), + "csei": csei, + } diff --git a/cli/core/templates.py b/cli/core/templates.py new file mode 100644 index 0000000..a14f9cd --- /dev/null +++ b/cli/core/templates.py @@ -0,0 +1,102 @@ +"""Inspect and validate .cortex/ template files in a target repo.""" + +import re +from pathlib import Path + +# Lines that indicate a template field is still empty +_EMPTY_PATTERNS = re.compile( + r"^[-|]\s*$|" # table row with only separators + r"^\s*-\s*\w+:\s*$|" # "- Field:" with no value + r"^\|\s*\|" # empty table cells +) + +# Minimum content length (bytes) to consider a file "filled" +_MIN_CONTENT_LENGTH = 150 + + +def cortex_dir(target: Path) -> Path: + return target / ".cortex" + + +def exists(target: Path) -> bool: + return cortex_dir(target).is_dir() + + +def installed_files(target: Path) -> list[str]: + """Return list of .cortex/ file names that exist.""" + d = cortex_dir(target) + if not d.is_dir(): + return [] + return sorted(f.name for f in d.iterdir() if f.is_file() and f.suffix == ".md") + + +def missing_files(target: Path, expected: list[str]) -> list[str]: + """Return expected files that are missing from .cortex/.""" + d = cortex_dir(target) + return [f for f in expected if not (d / f).exists()] + + +def is_file_filled(filepath: Path) -> bool: + """Check if a .cortex/ file has been filled with real content (not just template skeleton).""" + if not filepath.exists(): + return False + content = filepath.read_text(encoding="utf-8", errors="ignore") + # Strip markdown headers, empty lines, and common template markers + lines = [ + l.strip() + for l in content.splitlines() + if l.strip() + and not l.strip().startswith("#") + and not l.strip().startswith("```") + and not l.strip().startswith("---") + and not l.strip().startswith("Read ") + and not _EMPTY_PATTERNS.match(l.strip()) + ] + # Need meaningful content beyond skeleton + content_text = " ".join(lines) + return len(content_text) >= _MIN_CONTENT_LENGTH + + +def file_status(target: Path, expected: list[str]) -> dict[str, str]: + """Return status for each expected file: 'filled', 'empty', or 'missing'.""" + d = cortex_dir(target) + result = {} + for name in expected: + path = d / name + if not path.exists(): + result[name] = "missing" + elif is_file_filled(path): + result[name] = "filled" + else: + result[name] = "empty" + return result + + +def read_field(filepath: Path, field: str) -> str | None: + """Read a simple '- Field: value' from a markdown file.""" + if not filepath.exists(): + return None + for line in filepath.read_text(encoding="utf-8", errors="ignore").splitlines(): + stripped = line.strip() + if stripped.startswith(f"- {field}:"): + value = stripped[len(f"- {field}:"):].strip() + return value if value else None + return None + + +def read_section(filepath: Path, heading: str) -> list[str]: + """Read all lines under a ## heading until the next heading.""" + if not filepath.exists(): + return [] + lines = filepath.read_text(encoding="utf-8", errors="ignore").splitlines() + capture = False + result = [] + for line in lines: + if line.strip().startswith("## ") and heading.lower() in line.lower(): + capture = True + continue + if capture and line.strip().startswith("## "): + break + if capture and line.strip(): + result.append(line.strip()) + return result diff --git a/cli/hooks/post-checkout b/cli/hooks/post-checkout new file mode 100644 index 0000000..80d07c2 --- /dev/null +++ b/cli/hooks/post-checkout @@ -0,0 +1,23 @@ +#!/bin/sh +# CORTEX post-checkout hook — reminder of project state + +# Only run on branch switch (not file checkout) +if [ "$3" != "1" ]; then + exit 0 +fi + +BRANCH=$(git branch --show-current) +CORTEX_DIR=".cortex" + +if [ -d "$CORTEX_DIR" ]; then + echo "" + echo " CORTEX: switched to branch '$BRANCH'" + if [ -f "$CORTEX_DIR/CURRENT_STATUS.md" ]; then + PHASE=$(grep "^- Name:" "$CORTEX_DIR/CURRENT_STATUS.md" | head -1 | sed 's/- Name: //') + if [ -n "$PHASE" ]; then + echo " Current phase: $PHASE" + fi + fi + echo " Run 'cortex status' for full overview." + echo "" +fi diff --git a/cli/hooks/pre-commit b/cli/hooks/pre-commit new file mode 100644 index 0000000..6cedccb --- /dev/null +++ b/cli/hooks/pre-commit @@ -0,0 +1,17 @@ +#!/bin/sh +# CORTEX pre-commit hook — enforces branch isolation policy (Law 3) + +BRANCH=$(git branch --show-current) + +if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then + echo "" + echo " CORTEX: commit to '$BRANCH' blocked." + echo " Law 3 — Branch isolation is mandatory." + echo "" + echo " Create an isolated branch first:" + echo " git switch -c agent/" + echo "" + echo " To bypass (not recommended): git commit --no-verify" + echo "" + exit 1 +fi diff --git a/cli/hooks/pre-push b/cli/hooks/pre-push new file mode 100644 index 0000000..50afbf4 --- /dev/null +++ b/cli/hooks/pre-push @@ -0,0 +1,17 @@ +#!/bin/sh +# CORTEX pre-push hook — prevents direct push to main/master + +BRANCH=$(git branch --show-current) + +if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then + echo "" + echo " CORTEX: push to '$BRANCH' blocked." + echo " Only the operator should merge to main via pull request." + echo "" + echo " Push your feature branch instead:" + echo " git push -u origin $BRANCH" + echo "" + echo " To bypass (not recommended): git push --no-verify" + echo "" + exit 1 +fi diff --git a/cli/main.py b/cli/main.py new file mode 100644 index 0000000..c292f1c --- /dev/null +++ b/cli/main.py @@ -0,0 +1,95 @@ +"""CORTEX CLI entry point.""" + +import argparse +import sys + +from . import __version__ +from .commands import init, doctor, status, validate, score, hooks + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="cortex", + description="CORTEX — operational layer for AI-built projects.", + ) + parser.add_argument( + "--version", + action="version", + version=f"cortex {__version__}", + ) + + sub = parser.add_subparsers(dest="command") + + # cortex init + init_parser = sub.add_parser( + "init", + help="Install .cortex/ into a target repository.", + ) + init.add_arguments(init_parser) + + # cortex doctor + doctor_parser = sub.add_parser( + "doctor", + help="Check health of a .cortex/ installation.", + ) + doctor.add_arguments(doctor_parser) + + # cortex status + status_parser = sub.add_parser( + "status", + help="Quick overview of project state.", + ) + status.add_arguments(status_parser) + + # cortex validate + validate_parser = sub.add_parser( + "validate", + help="Verify consistency of CORTEX package and installation.", + ) + validate.add_arguments(validate_parser) + + # cortex score + score_parser = sub.add_parser( + "score", + help="Compute CSEI score from repository evidence.", + ) + score.add_arguments(score_parser) + + # cortex hooks + hooks_parser = sub.add_parser( + "hooks", + help="Install or remove git hooks that enforce CORTEX protocol.", + ) + hooks.add_arguments(hooks_parser) + + return parser + + +COMMANDS = { + "init": init.run, + "doctor": doctor.run, + "status": status.run, + "validate": validate.run, + "score": score.run, + "hooks": hooks.run, +} + + +def main(): + parser = build_parser() + args = parser.parse_args() + + if args.command is None: + parser.print_help() + return 0 + + handler = COMMANDS.get(args.command) + if handler is None: + parser.print_help() + return 1 + + return handler(args) + + +if __name__ == "__main__": + sys.exit(main() or 0) diff --git a/docs/installing.md b/docs/installing.md index 15f5a1a..c6bb701 100644 --- a/docs/installing.md +++ b/docs/installing.md @@ -1,32 +1,50 @@ # Installing CORTEX Into a Target Repository -This repository is distributed as a source package. -The standard installation path is the included Python script, which copies the template set into `.cortex/` inside a target repository. +CORTEX provides a CLI that installs `.cortex/` into a target repository, verifies health, computes scores, and enforces protocol via git hooks. ## Requirements -- `python3` +- `python3` (3.10+) - a local checkout of this repository - a writable target repository -## Preview the installation +## Quick start (CLI) + +```bash +# Install the CLI +pip install -e . + +# Install .cortex/ into a target project +cortex init /path/to/target-repo + +# Install git hooks for branch protection +cortex hooks install /path/to/target-repo + +# Check health +cortex doctor /path/to/target-repo + +# See project status and CSEI score +cortex status /path/to/target-repo +``` -Dry-run the installer first: +Or run without installing: ```bash -python3 scripts/install_cortex.py /path/to/target-repo --dry-run +python -m cli init /path/to/target-repo ``` -List the managed files without writing anything: +## Preview the installation + +Dry-run first: ```bash -python3 scripts/install_cortex.py --list-files +cortex init /path/to/target-repo --dry-run ``` ## Install the full template set ```bash -python3 scripts/install_cortex.py /path/to/target-repo +cortex init /path/to/target-repo ``` By default, existing `.cortex/` files are preserved. @@ -36,13 +54,13 @@ By default, existing `.cortex/` files are preserved. Lite mode installs only the 3 essential files (PROJECT_CONTEXT, CURRENT_STATUS, AGENT_HANDOFF) — ideal for small projects or first-time adoption: ```bash -python3 scripts/install_cortex.py /path/to/target-repo --lite +cortex init /path/to/target-repo --lite ``` To upgrade from Lite to Full later, run the installer again without `--lite`: ```bash -python3 scripts/install_cortex.py /path/to/target-repo +cortex init /path/to/target-repo ``` Existing files are preserved; only the missing ones are added. @@ -52,7 +70,35 @@ Existing files are preserved; only the missing ones are added. Use `--force` only when you intentionally want the repository's current `.cortex/` template files replaced by the versions from this package: ```bash -python3 scripts/install_cortex.py /path/to/target-repo --force +cortex init /path/to/target-repo --force +``` + +## Install git hooks + +CORTEX provides git hooks that enforce branch policy automatically: + +```bash +cortex hooks install /path/to/target-repo +``` + +This installs: +- **pre-commit**: blocks direct commits to main/master +- **pre-push**: blocks direct pushes to main/master +- **post-checkout**: reminds of project state on branch switch + +Remove with `cortex hooks remove /path/to/target-repo`. + +## Verify your installation + +```bash +# Check health of .cortex/ and repo state +cortex doctor /path/to/target-repo + +# Compute CSEI score from evidence +cortex score /path/to/target-repo + +# Validate CORTEX package consistency +cortex validate ``` ## What gets installed @@ -63,5 +109,20 @@ The installer reads [`MANIFEST.yaml`](../MANIFEST.yaml) to determine which files To see a worked example of filled `.cortex/` files, see [`examples/filled-cortex/`](../examples/filled-cortex/). -After installation, the next step is not feature work by default. -The operator or agent should return to [`START.md`](../START.md), inspect repository reality, and populate the installed files with evidence from the target project. +## Legacy installer + +The standalone script `scripts/install_cortex.py` is still available for environments where the CLI is not installed: + +```bash +python3 scripts/install_cortex.py /path/to/target-repo +``` + +## After installation + +The next step is not feature work by default. +The operator or agent should: +1. Return to [`START.md`](../START.md) +2. Inspect repository reality +3. Populate the installed files with evidence +4. Run `cortex score` to compute the initial CSEI +5. Document the next safe step in `.cortex/CURRENT_STATUS.md` diff --git a/docs/releasing.md b/docs/releasing.md index 2ea2cbc..c2c6329 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -10,27 +10,42 @@ Use this checklist before publishing a new public milestone of the CORTEX source - repository evidence over chat memory; - no silent authority escalation. 2. Confirm `README.md`, `START.md`, `PROTOCOL.md`, `MODES.md`, and `MANIFEST.yaml` still agree on the package purpose, entry flow, and default mode. -3. Confirm every file named in `MANIFEST.yaml` exists in `templates/` and is still the intended standard set. +3. Run automated validation: + +```bash +# Run all tests +python tests/test_installer.py +python tests/test_cli.py + +# Validate package consistency +python -m cli validate + +# Verify CLI works +python -m cli --version +``` + 4. Confirm the public root documents are present and current: - `LICENSE` - `README.md` - `CONTRIBUTING.md` - `SECURITY.md` -5. Run installer smoke checks: +5. Run a full installation smoke check: ```bash -python3 scripts/install_cortex.py --list-files tmpdir="$(mktemp -d)" -python3 scripts/install_cortex.py "$tmpdir" --dry-run -python3 scripts/install_cortex.py "$tmpdir" -python3 scripts/install_cortex.py "$tmpdir" -python3 scripts/install_cortex.py "$tmpdir" --force --dry-run +git -C "$tmpdir" init -b main +python -m cli init "$tmpdir" +python -m cli doctor "$tmpdir" +python -m cli score "$tmpdir" +python -m cli hooks install "$tmpdir" +python -m cli hooks status "$tmpdir" rm -rf "$tmpdir" ``` 6. Confirm public-facing links resolve, especially from `README.md`, `START.md`, `docs/`, and `adapters/`. 7. Review adapters and docs for wording that could accidentally imply uncontrolled build authority. -8. Update version metadata and milestone notes only after the package contents match the intended public state. +8. Confirm version is consistent across `MANIFEST.yaml`, `cli/__init__.py`, and `pyproject.toml`. +9. Update version metadata and milestone notes only after the package contents match the intended public state. ## Release bar diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9c35f99 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "cortex-os" +version = "4.0.0" +description = "CORTEX — operational layer for AI-built projects" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [ + { name = "Wallace Phillip Maclayne" }, +] +keywords = ["ai", "governance", "project-management", "agent", "continuity"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Quality Assurance", +] + +[project.scripts] +cortex = "cli.main:main" + +[project.urls] +Repository = "https://github.com/WPHILLIPMACLAYNE/cortex" + +[tool.setuptools.packages.find] +include = ["cli*"] + +[tool.setuptools.package-data] +"cli.hooks" = ["pre-commit", "pre-push", "post-checkout"] +"" = ["MANIFEST.yaml", "templates/*.md"] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..490a3db --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +"""Tests for the CORTEX CLI commands.""" + +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def cortex(*args): + """Run cortex CLI and return completed process.""" + return subprocess.run( + [sys.executable, "-m", "cli", *args], + capture_output=True, + text=True, + cwd=ROOT, + ) + + +def make_git_repo(): + """Create a temporary git repo and return its path.""" + tmp = tempfile.mkdtemp() + subprocess.run(["git", "init", "-b", "main", tmp], capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + capture_output=True, cwd=tmp, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + capture_output=True, cwd=tmp, + ) + return tmp + + +def cleanup(path): + import shutil + shutil.rmtree(path, ignore_errors=True) + + +# === cortex --version === + +def test_version(): + r = cortex("--version") + assert r.returncode == 0 + assert "cortex" in r.stdout + + +# === cortex validate === + +def test_validate_source(): + r = cortex("validate") + assert r.returncode == 0 + assert "PASS" in r.stdout + + +# === cortex init === + +def test_init_full(): + tmp = make_git_repo() + r = cortex("init", tmp) + assert r.returncode == 0 + assert "Installed CORTEX" in r.stdout + cortex_dir = Path(tmp) / ".cortex" + assert cortex_dir.is_dir() + assert (cortex_dir / "PROJECT_CONTEXT.md").exists() + assert (cortex_dir / "OPERATOR_PROFILE.md").exists() + cleanup(tmp) + + +def test_init_lite(): + tmp = make_git_repo() + r = cortex("init", tmp, "--lite") + assert r.returncode == 0 + assert "(lite)" in r.stdout + cortex_dir = Path(tmp) / ".cortex" + assert (cortex_dir / "PROJECT_CONTEXT.md").exists() + assert (cortex_dir / "CURRENT_STATUS.md").exists() + assert (cortex_dir / "AGENT_HANDOFF.md").exists() + assert not (cortex_dir / "OPERATOR_PROFILE.md").exists() + cleanup(tmp) + + +def test_init_dry_run(): + tmp = make_git_repo() + r = cortex("init", tmp, "--dry-run") + assert r.returncode == 0 + assert "Would install" in r.stdout + assert not (Path(tmp) / ".cortex").exists() + cleanup(tmp) + + +def test_init_force(): + tmp = make_git_repo() + cortex("init", tmp) + marker = Path(tmp) / ".cortex" / "PROJECT_CONTEXT.md" + marker.write_text("CUSTOM CONTENT") + r = cortex("init", tmp, "--force") + assert r.returncode == 0 + assert marker.read_text() != "CUSTOM CONTENT" + cleanup(tmp) + + +def test_init_skip_existing(): + tmp = make_git_repo() + cortex("init", tmp) + r = cortex("init", tmp) + assert r.returncode == 0 + assert "Skipped" in r.stdout + cleanup(tmp) + + +def test_init_lite_then_full_upgrade(): + tmp = make_git_repo() + cortex("init", tmp, "--lite") + assert not (Path(tmp) / ".cortex" / "AUDIT_MATRIX.md").exists() + r = cortex("init", tmp) + assert r.returncode == 0 + assert (Path(tmp) / ".cortex" / "AUDIT_MATRIX.md").exists() + cleanup(tmp) + + +# === cortex doctor === + +def test_doctor_no_cortex(): + tmp = make_git_repo() + r = cortex("doctor", tmp) + assert r.returncode == 1 + assert ".cortex/ not found" in r.stdout + cleanup(tmp) + + +def test_doctor_with_cortex(): + tmp = make_git_repo() + cortex("init", tmp) + r = cortex("doctor", tmp) + assert r.returncode == 0 + assert "Doctor Report" in r.stdout + assert "OK" in r.stdout + cleanup(tmp) + + +def test_doctor_warns_on_main(): + tmp = make_git_repo() + cortex("init", tmp) + r = cortex("doctor", tmp) + assert "main/master" in r.stdout + cleanup(tmp) + + +# === cortex status === + +def test_status_no_cortex(): + tmp = make_git_repo() + r = cortex("status", tmp) + assert r.returncode == 1 + assert "cortex init" in r.stdout + cleanup(tmp) + + +def test_status_with_cortex(): + tmp = make_git_repo() + cortex("init", tmp) + r = cortex("status", tmp) + assert r.returncode == 0 + assert "CSEI Score" in r.stdout + assert "Files:" in r.stdout + cleanup(tmp) + + +# === cortex score === + +def test_score_no_cortex(): + tmp = make_git_repo() + r = cortex("score", tmp) + assert r.returncode == 1 + cleanup(tmp) + + +def test_score_with_cortex(): + tmp = make_git_repo() + cortex("init", tmp) + r = cortex("score", tmp) + assert r.returncode == 0 + assert "CSEI" in r.stdout + assert "Structure" in r.stdout + assert "Final CSEI" in r.stdout + cleanup(tmp) + + +def test_score_update(): + tmp = make_git_repo() + cortex("init", tmp) + r = cortex("score", tmp, "--update") + assert r.returncode == 0 + assert "written to .cortex/CURRENT_STATUS.md" in r.stdout + content = (Path(tmp) / ".cortex" / "CURRENT_STATUS.md").read_text() + assert "- Structure:" in content + assert "- Final CSEI:" in content + cleanup(tmp) + + +# === cortex hooks === + +def test_hooks_install(): + tmp = make_git_repo() + r = cortex("hooks", "install", tmp) + assert r.returncode == 0 + assert "Installed hooks" in r.stdout + hooks_dir = Path(tmp) / ".git" / "hooks" + assert (hooks_dir / "pre-commit").exists() + assert (hooks_dir / "pre-push").exists() + cleanup(tmp) + + +def test_hooks_status(): + tmp = make_git_repo() + cortex("hooks", "install", tmp) + r = cortex("hooks", "status", tmp) + assert r.returncode == 0 + assert "ACTIVE (CORTEX)" in r.stdout + cleanup(tmp) + + +def test_hooks_remove(): + tmp = make_git_repo() + cortex("hooks", "install", tmp) + r = cortex("hooks", "remove", tmp) + assert r.returncode == 0 + assert "Removed" in r.stdout + hooks_dir = Path(tmp) / ".git" / "hooks" + assert not (hooks_dir / "pre-commit").exists() + cleanup(tmp) + + +def test_hooks_block_commit_on_main(): + tmp = make_git_repo() + cortex("hooks", "install", tmp) + # Try to commit on main + test_file = Path(tmp) / "test.txt" + test_file.write_text("test") + subprocess.run(["git", "add", "test.txt"], cwd=tmp, capture_output=True) + r = subprocess.run( + ["git", "commit", "-m", "test"], + cwd=tmp, capture_output=True, text=True, + ) + assert r.returncode != 0 + assert "blocked" in r.stderr or "blocked" in r.stdout + cleanup(tmp) + + +def test_hooks_allow_commit_on_branch(): + tmp = make_git_repo() + # Need an initial commit to create branch + test_file = Path(tmp) / "init.txt" + test_file.write_text("init") + subprocess.run(["git", "add", "init.txt"], cwd=tmp, capture_output=True) + subprocess.run(["git", "commit", "-m", "init"], cwd=tmp, capture_output=True) + # Install hooks and switch to branch + cortex("hooks", "install", tmp) + subprocess.run(["git", "switch", "-c", "agent/test"], cwd=tmp, capture_output=True) + # Commit should work + test_file2 = Path(tmp) / "test.txt" + test_file2.write_text("test") + subprocess.run(["git", "add", "test.txt"], cwd=tmp, capture_output=True) + r = subprocess.run( + ["git", "commit", "-m", "test on branch"], + cwd=tmp, capture_output=True, text=True, + ) + assert r.returncode == 0 + cleanup(tmp) + + +# === Manifest consistency === + +def test_manifest_template_consistency(): + """Every file in MANIFEST must have a template source.""" + sys.path.insert(0, str(ROOT)) + from cli.core import manifest + m = manifest.load() + full = manifest.full_files(m) + lite = manifest.lite_files(m) + templates_dir = ROOT / "templates" + for f in full: + assert (templates_dir / f).exists(), f"Template missing: {f}" + for f in lite: + assert f in full, f"Lite file not in full list: {f}" + + +if __name__ == "__main__": + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + passed = 0 + failed = 0 + for test in tests: + try: + test() + print(f" PASS {test.__name__}") + passed += 1 + except AssertionError as e: + print(f" FAIL {test.__name__}: {e}") + failed += 1 + except Exception as e: + print(f" ERROR {test.__name__}: {e}") + failed += 1 + print(f"\n{passed} passed, {failed} failed, {passed + failed} total") + raise SystemExit(1 if failed else 0)