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,398 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import sysconfig
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ from . import VERSION
11
+ from .bundles import load_bundles
12
+ from .contracts import ContractError, ProcessLock, read_json, validate_process_lock
13
+ from .distribution import asset_root, distribution_digest, skills_root
14
+ from .managed import (
15
+ managed_agents_block,
16
+ managed_agents_visibility_issues,
17
+ merge_managed_agents,
18
+ )
19
+ from .publication import (
20
+ managed_pull_request_block,
21
+ managed_pull_request_visibility_issues,
22
+ merge_managed_pull_request_template,
23
+ validate_project_extensions,
24
+ )
25
+ from .skills import MARKER_NAME, validate_skills
26
+
27
+
28
+ def default_process_root() -> Path:
29
+ source_root = Path(__file__).resolve().parent.parent
30
+ candidates = (source_root, Path(sysconfig.get_path("data")).resolve())
31
+ for candidate in candidates:
32
+ try:
33
+ asset_root(candidate)
34
+ except ContractError:
35
+ continue
36
+ return candidate
37
+ return candidates[-1]
38
+
39
+
40
+ def process_skills_root(process_root: Path) -> Path:
41
+ return skills_root(process_root)
42
+
43
+
44
+ def load_lock(project_root: Path) -> ProcessLock:
45
+ path = project_root / ".process" / "process.lock"
46
+ return validate_process_lock(read_json(path), str(path))
47
+
48
+
49
+ def _marker(lock: ProcessLock, skill: str) -> dict[str, object]:
50
+ return {
51
+ "schemaVersion": 1,
52
+ "distribution": "engineering-process",
53
+ "version": lock.version,
54
+ "digest": lock.digest,
55
+ "skill": skill,
56
+ }
57
+
58
+
59
+ def _read_marker(path: Path) -> dict[str, object] | None:
60
+ marker_path = path / MARKER_NAME
61
+ if not marker_path.is_file():
62
+ return None
63
+ value = read_json(marker_path)
64
+ return value if isinstance(value, dict) else None
65
+
66
+
67
+ def _files(path: Path, *, ignore_marker: bool) -> dict[str, bytes]:
68
+ result: dict[str, bytes] = {}
69
+ if not path.is_dir():
70
+ return result
71
+ for item in sorted(candidate for candidate in path.rglob("*") if candidate.is_file()):
72
+ if ignore_marker and item.name == MARKER_NAME:
73
+ continue
74
+ result[item.relative_to(path).as_posix()] = item.read_bytes()
75
+ return result
76
+
77
+
78
+ def managed_parent_issues(project_root: Path) -> list[str]:
79
+ issues: list[str] = []
80
+ for path in (
81
+ project_root / ".process",
82
+ project_root / ".agents",
83
+ project_root / ".agents" / "skills",
84
+ project_root / ".github",
85
+ ):
86
+ if path.is_symlink():
87
+ issues.append(f"{path}: managed parent must not be a symlink")
88
+ return issues
89
+
90
+
91
+ def skill_target_ownership_issues(project_root: Path) -> list[str]:
92
+ target_root = project_root / ".agents" / "skills"
93
+ if target_root.is_symlink():
94
+ return [f"{target_root}: managed skills root must not be a symlink"]
95
+ if not os.path.lexists(target_root):
96
+ return []
97
+ if not target_root.is_dir():
98
+ return [f"{target_root}: managed skills root must be a directory"]
99
+
100
+ issues: list[str] = []
101
+ for target in target_root.iterdir():
102
+ if target.is_symlink():
103
+ issues.append(f"{target}: unmanaged symlink in managed skills root")
104
+ continue
105
+ if target.is_file():
106
+ issues.append(
107
+ f"{target}: unmanaged project skill asset; process capabilities must come "
108
+ "from the pinned engineering-process distribution"
109
+ )
110
+ continue
111
+ if not target.is_dir():
112
+ issues.append(f"{target}: unsupported entry in managed skills root")
113
+ continue
114
+ if not (target / "SKILL.md").is_file():
115
+ continue
116
+ marker = _read_marker(target)
117
+ if not marker or marker.get("distribution") != "engineering-process":
118
+ issues.append(
119
+ f"{target}: unmanaged project skill; process capabilities must come "
120
+ "from the pinned engineering-process distribution"
121
+ )
122
+ return issues
123
+
124
+
125
+ def selected_skill_target_issues(
126
+ project_root: Path, skills: tuple[str, ...]
127
+ ) -> list[str]:
128
+ target_root = project_root / ".agents" / "skills"
129
+ issues: list[str] = []
130
+ for skill in skills:
131
+ target = target_root / skill
132
+ if target.is_symlink():
133
+ issues.append(f"{target}: selected managed skill target must not be a symlink")
134
+ continue
135
+ if not os.path.lexists(target):
136
+ continue
137
+ if not target.is_dir():
138
+ issues.append(f"{target}: selected managed skill target must be a directory")
139
+ continue
140
+ marker = _read_marker(target)
141
+ if not marker or marker.get("distribution") != "engineering-process":
142
+ issues.append(f"{target}: refusing to overwrite an unmanaged skill target")
143
+ return issues
144
+
145
+
146
+ def _pull_request_template_source(process_root: Path) -> tuple[Path, str]:
147
+ path = asset_root(process_root) / "templates" / "PULL_REQUEST_TEMPLATE.md"
148
+ if not path.is_file():
149
+ raise ContractError(f"{path}: missing pull-request template")
150
+ try:
151
+ return path, path.read_text(encoding="utf-8")
152
+ except (OSError, UnicodeError) as error:
153
+ raise ContractError(f"{path}: cannot read pull-request template: {error}") from error
154
+
155
+
156
+ def _agents_source(process_root: Path) -> tuple[Path, str]:
157
+ path = asset_root(process_root) / "templates" / "AGENTS.process.md"
158
+ if not path.is_file():
159
+ raise ContractError(f"{path}: missing agent contract template")
160
+ try:
161
+ return path, path.read_text(encoding="utf-8")
162
+ except (OSError, UnicodeError) as error:
163
+ raise ContractError(f"{path}: cannot read agent contract template: {error}") from error
164
+
165
+
166
+ def _agents_issues(project_root: Path, process_root: Path) -> list[str]:
167
+ _, source = _agents_source(process_root)
168
+ target = project_root / "AGENTS.md"
169
+ if target.is_symlink():
170
+ return [f"{target}: managed agent contract must not be a symlink"]
171
+ if not target.is_file():
172
+ return [f"{target}: missing managed engineering-process agent contract"]
173
+ try:
174
+ current = target.read_text(encoding="utf-8")
175
+ source_block = managed_agents_block(source).strip()
176
+ target_block = managed_agents_block(current).strip()
177
+ except (OSError, UnicodeError, ContractError) as error:
178
+ return [f"{target}: invalid managed agent contract: {error}"]
179
+ if source_block != target_block:
180
+ issues = [
181
+ f"{target}: managed agent contract differs from the pinned distribution"
182
+ ]
183
+ else:
184
+ issues = []
185
+ issues.extend(
186
+ f"{target}: {issue}" for issue in managed_agents_visibility_issues(current)
187
+ )
188
+ return issues
189
+
190
+
191
+ def _sync_agents(project_root: Path, process_root: Path) -> None:
192
+ _, source = _agents_source(process_root)
193
+ target = project_root / "AGENTS.md"
194
+ if target.is_symlink():
195
+ raise ContractError(f"{target}: managed agent contract must not be a symlink")
196
+ try:
197
+ current = target.read_text(encoding="utf-8") if target.is_file() else ""
198
+ except (OSError, UnicodeError) as error:
199
+ raise ContractError(f"{target}: cannot read agent contract: {error}") from error
200
+ updated = merge_managed_agents(current, source)
201
+ if current != updated:
202
+ target.write_text(updated, encoding="utf-8")
203
+
204
+
205
+ def _pull_request_template_issues(
206
+ project_root: Path, process_root: Path
207
+ ) -> list[str]:
208
+ _, source = _pull_request_template_source(process_root)
209
+ target = project_root / ".github" / "PULL_REQUEST_TEMPLATE.md"
210
+ if target.is_symlink():
211
+ return [f"{target}: managed pull-request template must not be a symlink"]
212
+ if not target.is_file():
213
+ return [f"{target}: missing managed pull-request template"]
214
+ try:
215
+ current = target.read_text(encoding="utf-8")
216
+ source_block = managed_pull_request_block(source).strip()
217
+ target_block = managed_pull_request_block(current).strip()
218
+ except (OSError, UnicodeError, ContractError) as error:
219
+ return [f"{target}: invalid managed pull-request template: {error}"]
220
+ issues = [
221
+ f"{target}: managed pull-request template differs from the pinned distribution"
222
+ ] if source_block != target_block else []
223
+ issues.extend(
224
+ f"{target}: {issue}"
225
+ for issue in managed_pull_request_visibility_issues(current)
226
+ )
227
+ issues.extend(
228
+ f"{target}: {issue}"
229
+ for issue in validate_project_extensions(current, allow_pending=True)
230
+ )
231
+ return issues
232
+
233
+
234
+ def _sync_pull_request_template(project_root: Path, process_root: Path) -> None:
235
+ _, source = _pull_request_template_source(process_root)
236
+ target = project_root / ".github" / "PULL_REQUEST_TEMPLATE.md"
237
+ if target.is_symlink():
238
+ raise ContractError(f"{target}: managed pull-request template must not be a symlink")
239
+ try:
240
+ current = target.read_text(encoding="utf-8") if target.is_file() else ""
241
+ except (OSError, UnicodeError) as error:
242
+ raise ContractError(f"{target}: cannot read pull-request template: {error}") from error
243
+ updated = merge_managed_pull_request_template(current, source)
244
+ if current != updated:
245
+ target.parent.mkdir(parents=True, exist_ok=True)
246
+ target.write_text(updated, encoding="utf-8")
247
+
248
+
249
+ def synchronized_state(
250
+ project_root: Path,
251
+ process_root: Path,
252
+ lock: ProcessLock,
253
+ ) -> list[str]:
254
+ source_root = process_skills_root(process_root)
255
+ target_root = project_root / ".agents" / "skills"
256
+ issues = validate_skills(source_root, lock.skills)
257
+ bundles = load_bundles(process_root, source_root)
258
+ missing_core = sorted(set(bundles["core"]) - set(lock.skills))
259
+ if missing_core:
260
+ issues.append(
261
+ "process.lock omits mandatory core skills: " + ", ".join(missing_core)
262
+ )
263
+ if issues:
264
+ return issues
265
+ if lock.version != VERSION:
266
+ issues.append(
267
+ f"process.lock pins {lock.version}, but processctl is {VERSION}"
268
+ )
269
+ actual_digest = distribution_digest(process_root, lock.skills)
270
+ if actual_digest != lock.digest:
271
+ issues.append(
272
+ f"process.lock digest {lock.digest} does not match source {actual_digest}"
273
+ )
274
+ for skill in lock.skills:
275
+ source = source_root / skill
276
+ target = target_root / skill
277
+ if _read_marker(target) != _marker(lock, skill):
278
+ issues.append(f"{target}: missing or stale managed-skill marker")
279
+ continue
280
+ if _files(source, ignore_marker=True) != _files(target, ignore_marker=True):
281
+ issues.append(f"{target}: content differs from the pinned skill")
282
+ issues.extend(skill_target_ownership_issues(project_root))
283
+ issues.extend(managed_parent_issues(project_root))
284
+ if target_root.is_dir():
285
+ for target in target_root.iterdir():
286
+ if not target.is_dir() or not (target / "SKILL.md").is_file():
287
+ continue
288
+ marker = _read_marker(target)
289
+ if (
290
+ marker
291
+ and marker.get("distribution") == "engineering-process"
292
+ and target.name not in lock.skills
293
+ ):
294
+ issues.append(f"{target}: stale managed skill is not present in process.lock")
295
+ issues.extend(_agents_issues(project_root, process_root))
296
+ issues.extend(_pull_request_template_issues(project_root, process_root))
297
+ return issues
298
+
299
+
300
+ def sync_skills(project_root: Path, process_root: Path, *, check: bool) -> list[str]:
301
+ project_root = project_root.resolve()
302
+ process_root = process_root.resolve()
303
+ lock = load_lock(project_root)
304
+ ownership_issues = [
305
+ *managed_parent_issues(project_root),
306
+ *skill_target_ownership_issues(project_root),
307
+ *selected_skill_target_issues(project_root, lock.skills),
308
+ ]
309
+ if ownership_issues and not check:
310
+ raise ContractError("\n".join(ownership_issues))
311
+ issues = synchronized_state(project_root, process_root, lock)
312
+ if check or not issues:
313
+ return issues
314
+
315
+ source_root = process_skills_root(process_root)
316
+ source_issues = validate_skills(source_root, lock.skills)
317
+ bundles = load_bundles(process_root, source_root)
318
+ missing_core = sorted(set(bundles["core"]) - set(lock.skills))
319
+ if source_issues or missing_core:
320
+ details = list(source_issues)
321
+ if missing_core:
322
+ details.append(
323
+ "process.lock omits mandatory core skills: "
324
+ + ", ".join(missing_core)
325
+ )
326
+ raise ContractError("\n".join(details))
327
+ if lock.version != VERSION:
328
+ raise ContractError(
329
+ f"process.lock pins {lock.version}, but processctl is {VERSION}"
330
+ )
331
+ actual_digest = distribution_digest(process_root, lock.skills)
332
+ if actual_digest != lock.digest:
333
+ raise ContractError(
334
+ f"process.lock digest {lock.digest} does not match source {actual_digest}"
335
+ )
336
+
337
+ target_root = project_root / ".agents" / "skills"
338
+ existing_targets = [target_root / skill for skill in lock.skills]
339
+ stale_targets: list[Path] = []
340
+ if target_root.is_dir():
341
+ for target in target_root.iterdir():
342
+ if not target.is_dir():
343
+ continue
344
+ marker = _read_marker(target)
345
+ if marker and marker.get("distribution") == "engineering-process":
346
+ if target.name not in lock.skills:
347
+ stale_targets.append(target)
348
+
349
+ _sync_agents(project_root, process_root)
350
+ _sync_pull_request_template(project_root, process_root)
351
+
352
+ target_root.mkdir(parents=True, exist_ok=True)
353
+
354
+ stage_parent = target_root.parent
355
+ stage = Path(tempfile.mkdtemp(prefix=".engineering-process-stage-", dir=stage_parent))
356
+ staged_skills = stage / "skills"
357
+ backup = stage / "backup"
358
+ staged_skills.mkdir()
359
+ backup.mkdir()
360
+ try:
361
+ for skill in lock.skills:
362
+ target = staged_skills / skill
363
+ shutil.copytree(source_root / skill, target)
364
+ (target / MARKER_NAME).write_text(
365
+ json.dumps(
366
+ _marker(lock, skill),
367
+ ensure_ascii=False,
368
+ indent=2,
369
+ sort_keys=True,
370
+ )
371
+ + "\n",
372
+ encoding="utf-8",
373
+ )
374
+
375
+ moved: list[Path] = []
376
+ installed: list[Path] = []
377
+ try:
378
+ for target in [*existing_targets, *stale_targets]:
379
+ if target.exists():
380
+ target.rename(backup / target.name)
381
+ moved.append(target)
382
+ for skill in lock.skills:
383
+ target = target_root / skill
384
+ (staged_skills / skill).rename(target)
385
+ installed.append(target)
386
+ except Exception:
387
+ for target in installed:
388
+ if target.exists():
389
+ shutil.rmtree(target)
390
+ for original in moved:
391
+ saved = backup / original.name
392
+ if saved.exists():
393
+ saved.rename(original)
394
+ raise
395
+ finally:
396
+ shutil.rmtree(stage, ignore_errors=True)
397
+
398
+ return synchronized_state(project_root, process_root, lock)