lean-sdd 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. lean_sdd/__init__.py +1 -0
  2. lean_sdd/__main__.py +6 -0
  3. lean_sdd/cli.py +65 -0
  4. lean_sdd/doctor.py +150 -0
  5. lean_sdd/installer.py +108 -0
  6. lean_sdd/templates.py +52 -0
  7. lean_sdd-0.1.0.dist-info/METADATA +630 -0
  8. lean_sdd-0.1.0.dist-info/RECORD +40 -0
  9. lean_sdd-0.1.0.dist-info/WHEEL +4 -0
  10. lean_sdd-0.1.0.dist-info/entry_points.txt +2 -0
  11. lean_sdd-0.1.0.dist-info/licenses/LICENSE +21 -0
  12. lean_sdd_templates/.agents/.env.example +15 -0
  13. lean_sdd_templates/.agents/.gitignore +5 -0
  14. lean_sdd_templates/.agents/README.md +139 -0
  15. lean_sdd_templates/.agents/github-issue-contract.md +83 -0
  16. lean_sdd_templates/.agents/github-mcp-contract.md +152 -0
  17. lean_sdd_templates/.agents/github-workflow-acceptance.md +28 -0
  18. lean_sdd_templates/.agents/lib/github-identity.sh +50 -0
  19. lean_sdd_templates/.agents/lib/github-issues.sh +94 -0
  20. lean_sdd_templates/.agents/lib/github-mcp.sh +52 -0
  21. lean_sdd_templates/.agents/lib/github-pr.sh +250 -0
  22. lean_sdd_templates/.agents/lib/mcp.sh +340 -0
  23. lean_sdd_templates/.agents/skills/create-issue/SKILL.md +58 -0
  24. lean_sdd_templates/.agents/skills/create-issue/scripts/create-issue.sh +21 -0
  25. lean_sdd_templates/.agents/skills/finalize-implementation/SKILL.md +119 -0
  26. lean_sdd_templates/.agents/skills/finalize-implementation/references/phase-contracts.md +163 -0
  27. lean_sdd_templates/.agents/skills/finalize-implementation/scripts/merge-implementation.sh +68 -0
  28. lean_sdd_templates/.agents/skills/finalize-implementation/scripts/publish-implementation.sh +48 -0
  29. lean_sdd_templates/.agents/skills/get-issue/SKILL.md +52 -0
  30. lean_sdd_templates/.agents/skills/get-issue/scripts/get-issue.sh +7 -0
  31. lean_sdd_templates/.agents/skills/git-change-workflow/SKILL.md +64 -0
  32. lean_sdd_templates/.agents/skills/git-change-workflow/scripts/prepare-change-branch.sh +63 -0
  33. lean_sdd_templates/.agents/skills/list-repositories/SKILL.md +55 -0
  34. lean_sdd_templates/.agents/skills/list-repositories/scripts/list-repositories.sh +63 -0
  35. lean_sdd_templates/.agents/skills/update-issue/SKILL.md +157 -0
  36. lean_sdd_templates/.agents/skills/update-issue/scripts/close-issue.sh +20 -0
  37. lean_sdd_templates/.agents/skills/update-issue/scripts/comment-issue.sh +24 -0
  38. lean_sdd_templates/AGENTS.md +206 -0
  39. lean_sdd_templates/CODING_STANDARDS.md +160 -0
  40. lean_sdd_templates/CONSTITUTION.md +179 -0
lean_sdd/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Install the Lean-SDD workflow into a development project."""
lean_sdd/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Support invocation with python -m lean_sdd."""
2
+
3
+ from lean_sdd.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
lean_sdd/cli.py ADDED
@@ -0,0 +1,65 @@
1
+ """Command-line interface for installation and local readiness diagnostics."""
2
+
3
+ import argparse
4
+ import subprocess
5
+ import sys
6
+ from collections.abc import Sequence
7
+ from pathlib import Path
8
+
9
+ from lean_sdd.doctor import diagnose
10
+ from lean_sdd.installer import InstallError, initialize
11
+ from lean_sdd.templates import package_version
12
+
13
+
14
+ def main(argv: Sequence[str] | None = None) -> int:
15
+ """Return 0 on success, 1 on operational failure, or argparse's usage exit 2."""
16
+ parser = argparse.ArgumentParser(
17
+ prog="lean-sdd", description="Install the Lean-SDD GitHub workflow."
18
+ )
19
+ parser.add_argument(
20
+ "--version", action="version", version=f"%(prog)s {package_version()}"
21
+ )
22
+ commands = parser.add_subparsers(dest="command", required=True)
23
+ init = commands.add_parser(
24
+ "init", help="Install workflow files without overwriting conflicts."
25
+ )
26
+ init.add_argument("directory", nargs="?", type=Path, default=Path("."))
27
+ init.add_argument(
28
+ "--dry-run", action="store_true", help="Check and list changes without writing."
29
+ )
30
+ doctor = commands.add_parser(
31
+ "doctor", help="Check local setup without network access."
32
+ )
33
+ doctor.add_argument("directory", nargs="?", type=Path, default=Path("."))
34
+ args = parser.parse_args(argv)
35
+ try:
36
+ if args.command == "init":
37
+ result = initialize(args.directory, dry_run=args.dry_run)
38
+ for name in result.created:
39
+ print(f"{'Would create' if args.dry_run else 'Created'} {name}")
40
+ print(f"{len(result.unchanged)} identical files left unchanged.")
41
+ if not args.dry_run:
42
+ print(
43
+ "Workflow installed. Read .agents/README.md "
44
+ "and run lean-sdd doctor."
45
+ )
46
+ print(
47
+ "Application scaffolding and GitHub live acceptance "
48
+ "remain separate steps."
49
+ )
50
+ return 0
51
+ checks = diagnose(args.directory)
52
+ for check in checks:
53
+ print(f"{check.status}: {check.message}")
54
+ print(
55
+ "Local checks only: no network access, "
56
+ "credential verification, or project tests."
57
+ )
58
+ print(
59
+ "Follow .agents/README.md for repository selection "
60
+ "and live MCP verification."
61
+ )
62
+ return 1 if any(check.status == "FAIL" for check in checks) else 0
63
+ except (InstallError, OSError, ValueError, subprocess.TimeoutExpired) as error:
64
+ print(f"ERROR: {error}", file=sys.stderr)
65
+ return 1
lean_sdd/doctor.py ADDED
@@ -0,0 +1,150 @@
1
+ """Local diagnostics only: do not load credentials or invoke project scripts."""
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import tomllib
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Literal
11
+
12
+ from lean_sdd.installer import InstallError, check_path
13
+ from lean_sdd.templates import STATE_PATH, installation_state, template_files
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Check:
18
+ """A local observation; warnings are not claims of workflow readiness."""
19
+
20
+ status: Literal["PASS", "WARN", "FAIL"]
21
+ message: str
22
+
23
+
24
+ def _run(arguments: list[str], target: Path) -> subprocess.CompletedProcess[str]:
25
+ environment = os.environ.copy()
26
+ # Noninteractive Bash can otherwise execute arbitrary startup commands.
27
+ environment.pop("BASH_ENV", None)
28
+ environment.pop("ENV", None)
29
+ return subprocess.run(
30
+ arguments,
31
+ cwd=target,
32
+ env=environment,
33
+ capture_output=True,
34
+ text=True,
35
+ timeout=10,
36
+ check=False,
37
+ )
38
+
39
+
40
+ def _installation_checks(target: Path) -> list[Check]:
41
+ payload = template_files()
42
+ expected = json.loads(installation_state(payload))
43
+ state_path = target / STATE_PATH
44
+ check_path(state_path)
45
+ if not state_path.is_file():
46
+ return [Check("FAIL", "Installation record missing; run lean-sdd init.")]
47
+ if state_path.stat().st_size > 1_000_000:
48
+ return [Check("FAIL", "Installation record is too large.")]
49
+ try:
50
+ state = json.loads(state_path.read_text(encoding="utf-8"))
51
+ except (ValueError, UnicodeError):
52
+ return [Check("FAIL", "Installation record is not valid JSON.")]
53
+ if state != expected:
54
+ return [
55
+ Check(
56
+ "FAIL",
57
+ "Installation record differs from this installer release. "
58
+ "Use the matching installer or review the record; no upgrade was made.",
59
+ )
60
+ ]
61
+ checks = [Check("PASS", "Installation version and template record match.")]
62
+ for name, content in payload.items():
63
+ path = target / name
64
+ try:
65
+ check_path(path)
66
+ except InstallError:
67
+ checks.append(Check("FAIL", f"Unsafe installed path: {name}"))
68
+ continue
69
+ if not path.is_file():
70
+ checks.append(Check("FAIL", f"Missing installed file: {name}"))
71
+ elif path.stat().st_size != len(content) or path.read_bytes() != content:
72
+ checks.append(Check("WARN", f"Locally modified template: {name}"))
73
+ if len(checks) == 1:
74
+ checks.append(Check("PASS", "All installed templates match this release."))
75
+ return checks
76
+
77
+
78
+ def diagnose(target: Path) -> list[Check]:
79
+ """Check installed files, prerequisites, and configuration without networking."""
80
+ target = target.expanduser().absolute()
81
+ check_path(target, directory=True)
82
+ if not target.is_dir():
83
+ raise InstallError("Doctor requires an existing project directory.")
84
+ checks = _installation_checks(target)
85
+ commands = ("bash", "git", "curl", "jq", "awk", "grep", "mktemp", "uv")
86
+ for command in commands:
87
+ checks.append(
88
+ Check("PASS", f"{command} is available.")
89
+ if shutil.which(command)
90
+ else Check("FAIL", f"Required command missing: {command}")
91
+ )
92
+ if shutil.which("bash"):
93
+ bash = _run(
94
+ ["bash", "--noprofile", "--norc", "-c", 'printf "%s" "$BASH_VERSION"'],
95
+ target,
96
+ )
97
+ major = bash.stdout.partition(".")[0]
98
+ checks.append(
99
+ Check("PASS", "Bash 4 or newer is available.")
100
+ if bash.returncode == 0 and major.isdigit() and int(major) >= 4
101
+ else Check("FAIL", "Workflow scripts require Bash 4 or newer.")
102
+ )
103
+ endpoint = os.environ.get("GITHUB_MCP_URL", "")
104
+ if not endpoint:
105
+ checks.append(Check("WARN", "GITHUB_MCP_URL is not exported."))
106
+ elif endpoint.rstrip("/") != "https://api.githubcopilot.com/mcp":
107
+ checks.append(
108
+ Check("FAIL", "GITHUB_MCP_URL is not the supported official endpoint.")
109
+ )
110
+ else:
111
+ checks.append(Check("PASS", "Official GitHub MCP endpoint configured."))
112
+ token = os.environ.get("GITHUB_TOKEN", "")
113
+ checks.append(
114
+ Check("PASS", "GITHUB_TOKEN is exported; validity and permissions not checked.")
115
+ if token
116
+ else Check("WARN", "GITHUB_TOKEN is not exported; .env files are not loaded.")
117
+ )
118
+ if shutil.which("git"):
119
+ git = _run(["git", "rev-parse", "--show-toplevel"], target)
120
+ if git.returncode or Path(git.stdout.strip()).resolve() != target.resolve():
121
+ checks.append(Check("WARN", "Target is not a Git worktree root."))
122
+ else:
123
+ checks.append(Check("PASS", "Target is a Git worktree root."))
124
+ project = target / "pyproject.toml"
125
+ check_path(project)
126
+ if project.is_file():
127
+ try:
128
+ with project.open("rb") as stream:
129
+ config = tomllib.load(stream)
130
+ checks.append(
131
+ Check("PASS", "Python project metadata is present.")
132
+ if isinstance(config.get("project"), dict)
133
+ else Check("WARN", "pyproject.toml has no [project] metadata.")
134
+ )
135
+ except tomllib.TOMLDecodeError:
136
+ checks.append(Check("FAIL", "pyproject.toml is invalid TOML."))
137
+ else:
138
+ checks.append(
139
+ Check("WARN", "No pyproject.toml; prepare the Python project separately.")
140
+ )
141
+ lock = target / "uv.lock"
142
+ check_path(lock)
143
+ checks.append(
144
+ Check("PASS", "uv.lock is present; freshness not checked.")
145
+ if lock.is_file()
146
+ else Check(
147
+ "WARN", "No uv.lock; lock the project dependencies before validation."
148
+ )
149
+ )
150
+ return checks
lean_sdd/installer.py ADDED
@@ -0,0 +1,108 @@
1
+ """Conservative installation: preflight every file and never overwrite one."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from lean_sdd.templates import STATE_PATH, installation_state, template_files
7
+
8
+
9
+ class InstallError(Exception):
10
+ """A target cannot be initialized without risking existing content."""
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class InstallResult:
15
+ """Report the planned or completed changes relative to the target."""
16
+
17
+ created: tuple[str, ...]
18
+ unchanged: tuple[str, ...]
19
+
20
+
21
+ def check_path(path: Path, *, directory: bool = False) -> None:
22
+ """Reject symlinks and non-directory ancestors before accessing a target."""
23
+ for parent in reversed(path.parents):
24
+ if parent.is_symlink() or (parent.exists() and not parent.is_dir()):
25
+ raise InstallError(f"Unsafe parent directory: {parent}")
26
+ if path.is_symlink():
27
+ raise InstallError(f"Refusing symbolic link: {path}")
28
+ if path.exists() and (not path.is_dir() if directory else not path.is_file()):
29
+ raise InstallError(f"Unexpected file type: {path}")
30
+
31
+
32
+ def _matches(path: Path, content: bytes) -> bool:
33
+ return path.stat().st_size == len(content) and path.read_bytes() == content
34
+
35
+
36
+ def _make_directories(path: Path, created: list[Path]) -> None:
37
+ missing = []
38
+ while not path.exists():
39
+ missing.append(path)
40
+ path = path.parent
41
+ for directory in reversed(missing):
42
+ directory.mkdir()
43
+ created.append(directory)
44
+
45
+
46
+ def initialize(target: Path, *, dry_run: bool = False) -> InstallResult:
47
+ """Install missing files, failing before writes on any content conflict.
48
+
49
+ Existing identical files are left untouched. On a write failure, remove
50
+ only newly created files and empty directories from this invocation. The
51
+ target must not be concurrently modified while initialization is running.
52
+ """
53
+ target = target.expanduser().absolute()
54
+ check_path(target, directory=True)
55
+ payload = template_files()
56
+ payload[STATE_PATH] = installation_state(payload)
57
+ missing: list[str] = []
58
+ unchanged: list[str] = []
59
+ conflicts: list[str] = []
60
+ for name, content in payload.items():
61
+ destination = target / name
62
+ check_path(destination)
63
+ if destination.exists():
64
+ if _matches(destination, content):
65
+ unchanged.append(name)
66
+ else:
67
+ conflicts.append(name)
68
+ else:
69
+ missing.append(name)
70
+ if conflicts:
71
+ raise InstallError(
72
+ "Conflicting files; nothing was written:\n "
73
+ + "\n ".join(conflicts)
74
+ + "\nReview differences using a separate empty directory. "
75
+ "Existing rules and installation versions are never overwritten."
76
+ )
77
+ if not dry_run:
78
+ written: list[Path] = []
79
+ directories: list[Path] = []
80
+ try:
81
+ for name in missing:
82
+ destination = target / name
83
+ check_path(destination)
84
+ _make_directories(destination.parent, directories)
85
+ with destination.open("xb") as stream:
86
+ written.append(destination)
87
+ stream.write(payload[name])
88
+ if destination.suffix == ".sh":
89
+ destination.chmod(0o755)
90
+ except (OSError, InstallError) as error:
91
+ cleanup_failures: list[str] = []
92
+ for path in reversed(written):
93
+ try:
94
+ path.unlink()
95
+ except OSError:
96
+ cleanup_failures.append(str(path))
97
+ for path in reversed(directories):
98
+ try:
99
+ path.rmdir()
100
+ except OSError:
101
+ cleanup_failures.append(str(path))
102
+ detail = (
103
+ " Review leftover paths: " + ", ".join(cleanup_failures)
104
+ if cleanup_failures
105
+ else " Newly created files were removed."
106
+ )
107
+ raise InstallError(f"Installation failed: {error}.{detail}") from error
108
+ return InstallResult(tuple(missing), tuple(unchanged))
lean_sdd/templates.py ADDED
@@ -0,0 +1,52 @@
1
+ """Read the explicitly bundled, versioned workflow resources."""
2
+
3
+ import hashlib
4
+ import json
5
+ from importlib.metadata import version
6
+ from importlib.resources import files
7
+ from importlib.resources.abc import Traversable
8
+ from pathlib import PurePosixPath
9
+
10
+ STATE_PATH = ".agents/lean-sdd-install.json"
11
+
12
+
13
+ def package_version() -> str:
14
+ """Return the version of the installed distribution."""
15
+ return version("lean-sdd-cli")
16
+
17
+
18
+ def _read_tree(root: Traversable, prefix: str = "") -> dict[str, bytes]:
19
+ result: dict[str, bytes] = {}
20
+ for entry in sorted(root.iterdir(), key=lambda child: child.name):
21
+ name = f"{prefix}{entry.name}"
22
+ if entry.is_dir():
23
+ result.update(_read_tree(entry, f"{name}/"))
24
+ elif entry.is_file():
25
+ result[name] = entry.read_bytes()
26
+ return result
27
+
28
+
29
+ def template_files() -> dict[str, bytes]:
30
+ """Read installed resources, including dotfiles, without locating a checkout."""
31
+ payload = _read_tree(files("lean_sdd_templates"))
32
+ required = {"AGENTS.md", "CONSTITUTION.md", "CODING_STANDARDS.md"}
33
+ if not required.issubset(payload) or STATE_PATH in payload:
34
+ raise ValueError("Installed template bundle is incomplete or invalid.")
35
+ for name in payload:
36
+ path = PurePosixPath(name)
37
+ if path.is_absolute() or ".." in path.parts or "\\" in name:
38
+ raise ValueError("Installed template bundle contains an unsafe path.")
39
+ return payload
40
+
41
+
42
+ def installation_state(payload: dict[str, bytes]) -> bytes:
43
+ """Describe the original installed templates, without machine-local values."""
44
+ state = {
45
+ "schema_version": 1,
46
+ "template_version": package_version(),
47
+ "files": {
48
+ name: hashlib.sha256(content).hexdigest()
49
+ for name, content in sorted(payload.items())
50
+ },
51
+ }
52
+ return (json.dumps(state, indent=2, sort_keys=True) + "\n").encode("utf-8")