engineering-process 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 (69) hide show
  1. engineering_process/__init__.py +3 -0
  2. engineering_process/_download_worker.py +73 -0
  3. engineering_process/_supervisor_posix.py +162 -0
  4. engineering_process/_supervisor_windows.py +148 -0
  5. engineering_process/_windows_job.py +419 -0
  6. engineering_process/bootstrap.py +217 -0
  7. engineering_process/bundles.py +81 -0
  8. engineering_process/cli.py +1012 -0
  9. engineering_process/contracts.py +1274 -0
  10. engineering_process/distribution.py +90 -0
  11. engineering_process/environment.py +753 -0
  12. engineering_process/helper_launch.py +37 -0
  13. engineering_process/lifecycle.py +1024 -0
  14. engineering_process/managed.py +89 -0
  15. engineering_process/markdown.py +78 -0
  16. engineering_process/publication.py +463 -0
  17. engineering_process/requirements-build.txt +1 -0
  18. engineering_process/requirements-dev.txt +10 -0
  19. engineering_process/requirements-runtime.txt +3 -0
  20. engineering_process/runner.py +191 -0
  21. engineering_process/runtime.py +55 -0
  22. engineering_process/skills.py +118 -0
  23. engineering_process/supervision.py +72 -0
  24. engineering_process/syncing.py +398 -0
  25. engineering_process/tooling.py +891 -0
  26. engineering_process-0.1.0.data/data/share/engineering-process/bundles.json +47 -0
  27. engineering_process-0.1.0.data/data/share/engineering-process/examples/change.json +26 -0
  28. engineering_process-0.1.0.data/data/share/engineering-process/examples/plan.json +28 -0
  29. engineering_process-0.1.0.data/data/share/engineering-process/examples/project.json +54 -0
  30. engineering_process-0.1.0.data/data/share/engineering-process/examples/review.json +31 -0
  31. engineering_process-0.1.0.data/data/share/engineering-process/schemas/change.schema.json +88 -0
  32. engineering_process-0.1.0.data/data/share/engineering-process/schemas/lifecycle.schema.json +122 -0
  33. engineering_process-0.1.0.data/data/share/engineering-process/schemas/plan.schema.json +105 -0
  34. engineering_process-0.1.0.data/data/share/engineering-process/schemas/process-lock.schema.json +39 -0
  35. engineering_process-0.1.0.data/data/share/engineering-process/schemas/project.schema.json +515 -0
  36. engineering_process-0.1.0.data/data/share/engineering-process/schemas/review.schema.json +140 -0
  37. engineering_process-0.1.0.data/data/share/engineering-process/schemas/reviewer-attestation.schema.json +30 -0
  38. engineering_process-0.1.0.data/data/share/engineering-process/schemas/verification.schema.json +120 -0
  39. engineering_process-0.1.0.data/data/share/engineering-process/skills/assess-design/SKILL.md +39 -0
  40. engineering_process-0.1.0.data/data/share/engineering-process/skills/build-frontend/SKILL.md +38 -0
  41. engineering_process-0.1.0.data/data/share/engineering-process/skills/build-frontend-foundation/SKILL.md +36 -0
  42. engineering_process-0.1.0.data/data/share/engineering-process/skills/change-api/SKILL.md +36 -0
  43. engineering_process-0.1.0.data/data/share/engineering-process/skills/cross-repo-change/SKILL.md +37 -0
  44. engineering_process-0.1.0.data/data/share/engineering-process/skills/define-change-contract/SKILL.md +40 -0
  45. engineering_process-0.1.0.data/data/share/engineering-process/skills/design-module/SKILL.md +37 -0
  46. engineering_process-0.1.0.data/data/share/engineering-process/skills/evolve-process/SKILL.md +37 -0
  47. engineering_process-0.1.0.data/data/share/engineering-process/skills/finish-change/SKILL.md +34 -0
  48. engineering_process-0.1.0.data/data/share/engineering-process/skills/govern-ui/SKILL.md +40 -0
  49. engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-change/SKILL.md +41 -0
  50. engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-module/SKILL.md +36 -0
  51. engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-use-case/SKILL.md +36 -0
  52. engineering_process-0.1.0.data/data/share/engineering-process/skills/integrate-mcp/SKILL.md +37 -0
  53. engineering_process-0.1.0.data/data/share/engineering-process/skills/maintain-docs/SKILL.md +35 -0
  54. engineering_process-0.1.0.data/data/share/engineering-process/skills/plan-change/SKILL.md +37 -0
  55. engineering_process-0.1.0.data/data/share/engineering-process/skills/publish-change/SKILL.md +38 -0
  56. engineering_process-0.1.0.data/data/share/engineering-process/skills/review-change/SKILL.md +49 -0
  57. engineering_process-0.1.0.data/data/share/engineering-process/skills/run-change/SKILL.md +51 -0
  58. engineering_process-0.1.0.data/data/share/engineering-process/skills/run-change/references/execution.md +88 -0
  59. engineering_process-0.1.0.data/data/share/engineering-process/skills/run-project-command/SKILL.md +55 -0
  60. engineering_process-0.1.0.data/data/share/engineering-process/skills/specify-use-case/SKILL.md +37 -0
  61. engineering_process-0.1.0.data/data/share/engineering-process/skills/verify-change/SKILL.md +38 -0
  62. engineering_process-0.1.0.data/data/share/engineering-process/templates/AGENTS.process.md +17 -0
  63. engineering_process-0.1.0.data/data/share/engineering-process/templates/PULL_REQUEST_TEMPLATE.md +27 -0
  64. engineering_process-0.1.0.dist-info/METADATA +387 -0
  65. engineering_process-0.1.0.dist-info/RECORD +69 -0
  66. engineering_process-0.1.0.dist-info/WHEEL +5 -0
  67. engineering_process-0.1.0.dist-info/entry_points.txt +2 -0
  68. engineering_process-0.1.0.dist-info/licenses/LICENSE +21 -0
  69. engineering_process-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,191 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import stat
6
+ import subprocess
7
+ import sys
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .contracts import Check, ContractError, Project
13
+ from .environment import (
14
+ environment_command_bindings,
15
+ environment_path_entries,
16
+ execute_command,
17
+ )
18
+ from .tooling import ManagedCommandBinding
19
+
20
+
21
+ def _timestamp() -> str:
22
+ return datetime.now(UTC).isoformat().replace("+00:00", "Z")
23
+
24
+
25
+ def _git(root: Path, arguments: list[str]) -> subprocess.CompletedProcess[bytes]:
26
+ return subprocess.run(
27
+ ["git", *arguments],
28
+ cwd=root,
29
+ check=False,
30
+ stdin=subprocess.DEVNULL,
31
+ stdout=subprocess.PIPE,
32
+ stderr=subprocess.DEVNULL,
33
+ timeout=10,
34
+ )
35
+
36
+
37
+ def _source_state(root: Path) -> dict[str, Any]:
38
+ status_result = _git(
39
+ root,
40
+ ["status", "--porcelain=v1", "-z", "--untracked-files=all"],
41
+ )
42
+ if status_result.returncode != 0:
43
+ return {"checkpoint": None, "dirty": None, "fingerprint": None}
44
+
45
+ head_result = _git(root, ["rev-parse", "HEAD"])
46
+ checkpoint = (
47
+ head_result.stdout.decode("ascii").strip()
48
+ if head_result.returncode == 0
49
+ else None
50
+ )
51
+ diff = b""
52
+ if checkpoint is not None:
53
+ diff_result = _git(
54
+ root,
55
+ ["diff", "--binary", "--no-ext-diff", "--no-textconv", "HEAD", "--"],
56
+ )
57
+ if diff_result.returncode != 0:
58
+ return {"checkpoint": checkpoint, "dirty": True, "fingerprint": None}
59
+ diff = diff_result.stdout
60
+
61
+ untracked_result = _git(
62
+ root,
63
+ ["ls-files", "--others", "--exclude-standard", "-z"],
64
+ )
65
+ if untracked_result.returncode != 0:
66
+ return {
67
+ "checkpoint": checkpoint,
68
+ "dirty": bool(status_result.stdout),
69
+ "fingerprint": None,
70
+ }
71
+
72
+ digest = hashlib.sha256()
73
+ digest.update(b"checkpoint\0")
74
+ digest.update((checkpoint or "").encode("ascii"))
75
+ digest.update(b"\0status\0")
76
+ digest.update(status_result.stdout)
77
+ digest.update(b"\0diff\0")
78
+ digest.update(diff)
79
+ for encoded_path in sorted(
80
+ path for path in untracked_result.stdout.split(b"\0") if path
81
+ ):
82
+ relative = os.fsdecode(encoded_path)
83
+ path = root / relative
84
+ digest.update(b"\0untracked\0")
85
+ digest.update(encoded_path)
86
+ try:
87
+ mode = path.lstat().st_mode
88
+ if stat.S_ISLNK(mode):
89
+ digest.update(b"\0symlink\0")
90
+ digest.update(os.fsencode(os.readlink(path)))
91
+ elif stat.S_ISREG(mode):
92
+ digest.update(b"\0file\0")
93
+ with path.open("rb") as source:
94
+ while chunk := source.read(1024 * 1024):
95
+ digest.update(chunk)
96
+ else:
97
+ digest.update(b"\0other\0")
98
+ digest.update(str(mode).encode("ascii"))
99
+ except OSError as error:
100
+ digest.update(b"\0unreadable\0")
101
+ digest.update(str(error).encode("utf-8", errors="replace"))
102
+ return {
103
+ "checkpoint": checkpoint,
104
+ "dirty": bool(status_result.stdout),
105
+ "fingerprint": f"sha256:{digest.hexdigest()}",
106
+ }
107
+
108
+
109
+ def source_state(root: Path) -> dict[str, Any]:
110
+ """Return the checkpoint state used to bind lifecycle evidence."""
111
+ return _source_state(root)
112
+
113
+
114
+ def _run_check(
115
+ root: Path,
116
+ check: Check,
117
+ *,
118
+ path_entries: tuple[Path, ...] = (),
119
+ command_bindings: dict[str, ManagedCommandBinding] | None = None,
120
+ ) -> dict[str, Any]:
121
+ print(f"[{check.identifier}] {' '.join(check.run)}", file=sys.stderr)
122
+ execution = execute_command(
123
+ root,
124
+ identifier=check.identifier,
125
+ run=check.run,
126
+ timeout_seconds=check.timeout_seconds,
127
+ working_directory=check.working_directory,
128
+ path_entries=path_entries,
129
+ command_bindings=command_bindings,
130
+ stream_output=True,
131
+ )
132
+ allowed = {
133
+ "id",
134
+ "status",
135
+ "exitCode",
136
+ "startedAt",
137
+ "durationMs",
138
+ "workingDirectory",
139
+ "command",
140
+ "commandSha256",
141
+ "error",
142
+ "pathEntries",
143
+ }
144
+ return {key: value for key, value in execution.items() if key in allowed}
145
+
146
+
147
+ def run_profile(root: Path, project: Project, profile: str) -> dict[str, Any]:
148
+ checks = project.profiles.get(profile)
149
+ if checks is None:
150
+ available = ", ".join(sorted(project.profiles))
151
+ raise ContractError(
152
+ f"unknown profile {profile}; available profiles: {available}"
153
+ )
154
+ started = _timestamp()
155
+ source_before = _source_state(root)
156
+ path_entries = environment_path_entries(project, profile=profile)
157
+ command_bindings = environment_command_bindings(project, profile=profile)
158
+ results = [
159
+ _run_check(
160
+ root,
161
+ check,
162
+ path_entries=path_entries,
163
+ command_bindings=command_bindings,
164
+ )
165
+ for check in checks
166
+ ]
167
+ source_after = _source_state(root)
168
+ source_changed = (
169
+ source_before["fingerprint"] is not None
170
+ and source_after["fingerprint"] is not None
171
+ and source_before["fingerprint"] != source_after["fingerprint"]
172
+ )
173
+ status = (
174
+ "passed"
175
+ if all(item["status"] == "passed" for item in results) and not source_changed
176
+ else "failed"
177
+ )
178
+ return {
179
+ "schemaVersion": 1,
180
+ "project": project.identifier,
181
+ "profile": profile,
182
+ "checkpoint": source_before["checkpoint"],
183
+ "workingTreeDirty": source_before["dirty"],
184
+ "workspaceFingerprint": source_before["fingerprint"],
185
+ "completedWorkspaceFingerprint": source_after["fingerprint"],
186
+ "sourceChangedDuringVerification": source_changed,
187
+ "startedAt": started,
188
+ "completedAt": _timestamp(),
189
+ "status": status,
190
+ "checks": results,
191
+ }
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from importlib import metadata, resources
5
+
6
+ from .contracts import ContractError
7
+
8
+
9
+ PIN_PATTERN = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)==([^\s]+)$")
10
+
11
+
12
+ def runtime_dependency_pins() -> dict[str, str]:
13
+ text = (
14
+ resources.files("engineering_process")
15
+ .joinpath("requirements-runtime.txt")
16
+ .read_text(encoding="utf-8")
17
+ )
18
+ pins: dict[str, str] = {}
19
+ for line_number, raw_line in enumerate(text.splitlines(), 1):
20
+ line = raw_line.strip()
21
+ if not line or line.startswith("#"):
22
+ continue
23
+ match = PIN_PATTERN.fullmatch(line)
24
+ if match is None:
25
+ raise ContractError(
26
+ "engineering_process/requirements-runtime.txt:"
27
+ f"{line_number}: runtime dependencies must use exact name==version pins"
28
+ )
29
+ name, version = match.groups()
30
+ normalized = re.sub(r"[-_.]+", "-", name).lower()
31
+ if normalized in pins:
32
+ raise ContractError(
33
+ "engineering_process/requirements-runtime.txt: duplicate dependency "
34
+ f"{normalized}"
35
+ )
36
+ pins[normalized] = version
37
+ if not pins:
38
+ raise ContractError("runtime dependency lock must not be empty")
39
+ return pins
40
+
41
+
42
+ def assert_runtime_dependencies() -> None:
43
+ mismatches: list[str] = []
44
+ for name, expected in runtime_dependency_pins().items():
45
+ try:
46
+ actual = metadata.version(name)
47
+ except metadata.PackageNotFoundError:
48
+ mismatches.append(f"{name} is missing; expected {expected}")
49
+ continue
50
+ if actual != expected:
51
+ mismatches.append(f"{name} is {actual}; expected {expected}")
52
+ if mismatches:
53
+ raise ContractError(
54
+ "engineering-process runtime dependency mismatch: " + "; ".join(mismatches)
55
+ )
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import re
5
+ from pathlib import Path
6
+
7
+ from .contracts import ContractError, SKILL_PATTERN
8
+
9
+
10
+ FRONTMATTER = re.compile(
11
+ r"\A---\n(?P<header>.*?)\n---\n(?P<body>.*)\Z",
12
+ re.DOTALL,
13
+ )
14
+ FORBIDDEN_CORE_TERMS = (
15
+ "apply_patch",
16
+ "claude",
17
+ "codex",
18
+ "cursor",
19
+ "gemini",
20
+ "gpt-",
21
+ "reasoning_effort",
22
+ "spawn_agent",
23
+ "subagent",
24
+ )
25
+ MARKER_NAME = ".engineering-process.json"
26
+
27
+
28
+ def skill_directories(root: Path) -> list[Path]:
29
+ if not root.is_dir():
30
+ raise ContractError(f"{root}: skills root does not exist")
31
+ return sorted(
32
+ path for path in root.iterdir() if path.is_dir() and (path / "SKILL.md").is_file()
33
+ )
34
+
35
+
36
+ def validate_skills(root: Path, selected: tuple[str, ...] | None = None) -> list[str]:
37
+ directories = {path.name: path for path in skill_directories(root)}
38
+ names = tuple(sorted(directories)) if selected is None else selected
39
+ issues: list[str] = []
40
+ missing = sorted(set(names) - set(directories))
41
+ if missing:
42
+ issues.append(f"{root}: missing skills: {', '.join(missing)}")
43
+
44
+ for name in names:
45
+ directory = directories.get(name)
46
+ if directory is None:
47
+ continue
48
+ if SKILL_PATTERN.fullmatch(name) is None or len(name) > 64:
49
+ issues.append(f"{directory}: invalid skill directory name")
50
+ path = directory / "SKILL.md"
51
+ try:
52
+ text = path.read_text(encoding="utf-8")
53
+ except (OSError, UnicodeError) as error:
54
+ issues.append(f"{path}: cannot read UTF-8 content: {error}")
55
+ continue
56
+ if text.startswith("\ufeff"):
57
+ issues.append(f"{path}: UTF-8 BOM is not allowed")
58
+ if "\r" in text:
59
+ issues.append(f"{path}: use LF line endings")
60
+ if "TODO" in text or "[TODO" in text:
61
+ issues.append(f"{path}: unresolved template marker")
62
+ if len(text.splitlines()) > 500:
63
+ issues.append(f"{path}: exceeds the 500-line portability limit")
64
+
65
+ match = FRONTMATTER.match(text)
66
+ if match is None:
67
+ issues.append(f"{path}: invalid YAML frontmatter boundary")
68
+ continue
69
+ header: dict[str, str] = {}
70
+ for line_number, line in enumerate(match.group("header").splitlines(), 2):
71
+ if not line or line.startswith((" ", "\t", "#")) or ":" not in line:
72
+ issues.append(f"{path}:{line_number}: unsupported frontmatter syntax")
73
+ continue
74
+ key, value = line.split(":", 1)
75
+ if key in header:
76
+ issues.append(f"{path}:{line_number}: duplicate frontmatter key {key}")
77
+ header[key] = value.strip()
78
+ extra = sorted(set(header) - {"name", "description"})
79
+ if extra:
80
+ issues.append(
81
+ f"{path}: core skills allow only name and description; found {extra}"
82
+ )
83
+ if header.get("name") != name:
84
+ issues.append(f"{path}: frontmatter name must match {name}")
85
+ description = header.get("description", "")
86
+ if not description or len(description) > 1024:
87
+ issues.append(f"{path}: description must contain 1 to 1024 characters")
88
+ if not match.group("body").lstrip().startswith("# "):
89
+ issues.append(f"{path}: body must start with an H1")
90
+
91
+ lowered = text.lower()
92
+ for term in FORBIDDEN_CORE_TERMS:
93
+ if term in lowered:
94
+ issues.append(f"{path}: agent-specific core term is forbidden: {term}")
95
+ for line_number, line in enumerate(text.splitlines(), 1):
96
+ if re.search(r"(?:^|[\s(])/(?:home|users|workspace|tmp)/", line.lower()):
97
+ issues.append(f"{path}:{line_number}: absolute local path is forbidden")
98
+ return issues
99
+
100
+
101
+ def skill_digest(root: Path, selected: tuple[str, ...] | None = None) -> str:
102
+ issues = validate_skills(root, selected)
103
+ if issues:
104
+ raise ContractError("\n".join(issues))
105
+ directories = {path.name: path for path in skill_directories(root)}
106
+ names = tuple(sorted(directories)) if selected is None else selected
107
+ digest = hashlib.sha256()
108
+ for name in names:
109
+ directory = directories[name]
110
+ for path in sorted(item for item in directory.rglob("*") if item.is_file()):
111
+ if path.name == MARKER_NAME:
112
+ continue
113
+ relative = path.relative_to(root).as_posix()
114
+ digest.update(relative.encode("utf-8"))
115
+ digest.update(b"\0")
116
+ digest.update(path.read_bytes())
117
+ digest.update(b"\0")
118
+ return f"sha256:{digest.hexdigest()}"
@@ -0,0 +1,72 @@
1
+ """Portable foreground-task supervision.
2
+
3
+ The public execution contract is deliberately platform-neutral. Kernel-specific
4
+ process containment lives in the selected backend and must not leak into consumer
5
+ manifests, reports, or lifecycle code.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ import os
12
+ from pathlib import Path
13
+ import subprocess
14
+ from typing import Mapping, Protocol
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class CleanupOutcome:
19
+ """Result of a bounded process-tree cleanup operation."""
20
+
21
+ bounded: bool
22
+ descendants_found: bool = False
23
+ error: str | None = None
24
+
25
+
26
+ class ProcessSupervisor(Protocol):
27
+ """OS backend for one finite, non-interactive foreground task."""
28
+
29
+ def resolve_application(
30
+ self,
31
+ command: str,
32
+ *,
33
+ working_directory: Path,
34
+ environment: Mapping[str, str],
35
+ ) -> Path: ...
36
+
37
+ def spawn(
38
+ self,
39
+ command: tuple[str, ...],
40
+ *,
41
+ working_directory: Path,
42
+ environment: Mapping[str, str],
43
+ ) -> subprocess.Popen[bytes]: ...
44
+
45
+ def terminate(
46
+ self,
47
+ process: subprocess.Popen[bytes],
48
+ *,
49
+ grace_seconds: float,
50
+ ) -> CleanupOutcome: ...
51
+
52
+ def finalize(
53
+ self,
54
+ process: subprocess.Popen[bytes],
55
+ *,
56
+ grace_seconds: float,
57
+ ) -> CleanupOutcome: ...
58
+
59
+
60
+ def process_supervisor(*, platform_name: str | None = None) -> ProcessSupervisor:
61
+ """Select the sole platform adapter used by the execution layer."""
62
+
63
+ selected = os.name if platform_name is None else platform_name
64
+ if selected == "posix":
65
+ from ._supervisor_posix import POSIX_SUPERVISOR
66
+
67
+ return POSIX_SUPERVISOR
68
+ if selected == "nt":
69
+ from ._supervisor_windows import WINDOWS_SUPERVISOR
70
+
71
+ return WINDOWS_SUPERVISOR
72
+ raise OSError(f"unsupported process supervision platform: {selected}")