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,1024 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import tempfile
8
+ from contextlib import contextmanager
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .contracts import (
14
+ ContractError,
15
+ PROFILE_PATTERN,
16
+ Project,
17
+ _validate_legacy_review,
18
+ read_json,
19
+ validate_change,
20
+ validate_plan,
21
+ validate_review,
22
+ )
23
+ from .environment import require_environment_profile
24
+ from .runner import run_profile, source_state
25
+
26
+
27
+ PHASES = {
28
+ "specified",
29
+ "planned",
30
+ "implementing",
31
+ "verified",
32
+ "review-pending",
33
+ "changes-requested",
34
+ "approved",
35
+ "completed",
36
+ }
37
+
38
+ FINDING_IDENTITY_FIELDS = (
39
+ "id",
40
+ "severity",
41
+ "path",
42
+ "line",
43
+ "summary",
44
+ "evidence",
45
+ )
46
+ UNRESOLVED_FINDING_STATUSES = {"open", "deferred"}
47
+
48
+
49
+ def _timestamp() -> str:
50
+ return datetime.now(UTC).isoformat().replace("+00:00", "Z")
51
+
52
+
53
+ def _digest_bytes(content: bytes) -> str:
54
+ return f"sha256:{hashlib.sha256(content).hexdigest()}"
55
+
56
+
57
+ def _digest_file(path: Path) -> str:
58
+ try:
59
+ return _digest_bytes(path.read_bytes())
60
+ except OSError as error:
61
+ raise ContractError(f"{path}: cannot read lifecycle artifact: {error}") from error
62
+
63
+
64
+ def _resolve_commit(project_root: Path, reference: str) -> str:
65
+ try:
66
+ result = subprocess.run(
67
+ ["git", "rev-parse", "--verify", f"{reference}^{{commit}}"],
68
+ cwd=project_root,
69
+ check=False,
70
+ stdin=subprocess.DEVNULL,
71
+ stdout=subprocess.PIPE,
72
+ stderr=subprocess.PIPE,
73
+ timeout=10,
74
+ )
75
+ except (OSError, subprocess.TimeoutExpired) as error:
76
+ raise ContractError(f"cannot resolve comparison base {reference}: {error}") from error
77
+ if result.returncode != 0:
78
+ detail = result.stderr.decode("utf-8", errors="replace").strip()
79
+ raise ContractError(
80
+ f"comparison base {reference} does not resolve to a commit"
81
+ + (f": {detail}" if detail else "")
82
+ )
83
+ return result.stdout.decode("ascii").strip()
84
+
85
+
86
+ def _actor(actor_id: str, context_id: str, kind: str) -> dict[str, str]:
87
+ if not actor_id or actor_id != actor_id.strip() or len(actor_id) > 256:
88
+ raise ContractError("actor id must be a non-empty trimmed value up to 256 characters")
89
+ if not context_id or context_id != context_id.strip() or len(context_id) > 256:
90
+ raise ContractError(
91
+ "context id must be a non-empty trimmed value up to 256 characters"
92
+ )
93
+ if kind not in {"agent", "human"}:
94
+ raise ContractError("actor kind must be agent or human")
95
+ return {"actorId": actor_id, "contextId": context_id, "kind": kind}
96
+
97
+
98
+ def _runs_root(project_root: Path) -> Path:
99
+ return project_root / ".process" / "runs"
100
+
101
+
102
+ def lifecycle_environment_issues(project_root: Path) -> list[str]:
103
+ issues: list[str] = []
104
+ try:
105
+ top = subprocess.run(
106
+ ["git", "rev-parse", "--show-toplevel"],
107
+ cwd=project_root,
108
+ check=False,
109
+ stdin=subprocess.DEVNULL,
110
+ stdout=subprocess.PIPE,
111
+ stderr=subprocess.DEVNULL,
112
+ timeout=10,
113
+ )
114
+ except (OSError, subprocess.TimeoutExpired) as error:
115
+ return [f"cannot inspect Git lifecycle boundary: {error}"]
116
+ if top.returncode != 0:
117
+ return ["canonical lifecycle requires a Git repository"]
118
+ try:
119
+ repository_root = Path(top.stdout.decode("utf-8").strip()).resolve()
120
+ except UnicodeDecodeError:
121
+ return ["Git repository root is not valid UTF-8"]
122
+ if repository_root != project_root.resolve():
123
+ issues.append(
124
+ f"project root {project_root.resolve()} must equal Git root {repository_root}"
125
+ )
126
+ ignore = subprocess.run(
127
+ ["git", "check-ignore", "-q", ".process/runs/__process_probe__"],
128
+ cwd=project_root,
129
+ check=False,
130
+ stdin=subprocess.DEVNULL,
131
+ stdout=subprocess.DEVNULL,
132
+ stderr=subprocess.DEVNULL,
133
+ timeout=10,
134
+ )
135
+ if ignore.returncode != 0:
136
+ issues.append(
137
+ ".process/runs/ must be ignored so lifecycle evidence cannot dirty source"
138
+ )
139
+ return issues
140
+
141
+
142
+ def _run_root(project_root: Path, change_id: str) -> Path:
143
+ if PROFILE_PATTERN.fullmatch(change_id) is None or len(change_id) > 64:
144
+ raise ContractError(f"invalid change id: {change_id}")
145
+ return _runs_root(project_root) / change_id
146
+
147
+
148
+ @contextmanager
149
+ def _change_lock(project_root: Path, change_id: str):
150
+ lock_root = _runs_root(project_root) / ".locks"
151
+ lock_root.mkdir(parents=True, exist_ok=True)
152
+ path = lock_root / f"{change_id}.lock"
153
+ handle = path.open("a+b")
154
+ try:
155
+ if os.name == "posix":
156
+ import fcntl
157
+
158
+ try:
159
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
160
+ except BlockingIOError as error:
161
+ raise ContractError(
162
+ f"change {change_id} is being mutated by another process"
163
+ ) from error
164
+ else:
165
+ import msvcrt
166
+
167
+ if path.stat().st_size == 0:
168
+ handle.write(b"0")
169
+ handle.flush()
170
+ handle.seek(0)
171
+ try:
172
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
173
+ except OSError as error:
174
+ raise ContractError(
175
+ f"change {change_id} is being mutated by another process"
176
+ ) from error
177
+ yield
178
+ finally:
179
+ try:
180
+ if os.name == "posix":
181
+ import fcntl
182
+
183
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
184
+ else:
185
+ import msvcrt
186
+
187
+ handle.seek(0)
188
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
189
+ except OSError:
190
+ pass
191
+ handle.close()
192
+
193
+
194
+ def _state_path(project_root: Path, change_id: str) -> Path:
195
+ return _run_root(project_root, change_id) / "state.json"
196
+
197
+
198
+ def _relative(project_root: Path, path: Path) -> str:
199
+ root = project_root.resolve()
200
+ resolved = path.resolve()
201
+ try:
202
+ return resolved.relative_to(root).as_posix()
203
+ except ValueError as error:
204
+ raise ContractError(f"{path}: lifecycle artifacts must stay within the project") from error
205
+
206
+
207
+ def _write_atomic(path: Path, document: dict[str, Any]) -> None:
208
+ path.parent.mkdir(parents=True, exist_ok=True)
209
+ content = (
210
+ json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
211
+ ).encode("utf-8")
212
+ descriptor, temporary_name = tempfile.mkstemp(
213
+ prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
214
+ )
215
+ temporary = Path(temporary_name)
216
+ try:
217
+ with os.fdopen(descriptor, "wb") as output:
218
+ output.write(content)
219
+ output.flush()
220
+ os.fsync(output.fileno())
221
+ os.replace(temporary, path)
222
+ finally:
223
+ if temporary.exists():
224
+ temporary.unlink()
225
+
226
+
227
+ def _copy_document(
228
+ project_root: Path,
229
+ source: Path,
230
+ destination: Path,
231
+ ) -> dict[str, str]:
232
+ document = read_json(source)
233
+ content = (
234
+ json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
235
+ ).encode("utf-8")
236
+ destination.parent.mkdir(parents=True, exist_ok=True)
237
+ try:
238
+ destination.write_bytes(content)
239
+ except OSError as error:
240
+ raise ContractError(f"{destination}: cannot write lifecycle artifact: {error}") from error
241
+ return {
242
+ "path": _relative(project_root, destination),
243
+ "digest": _digest_bytes(content),
244
+ }
245
+
246
+
247
+ def _artifact_path(project_root: Path, artifact: dict[str, str]) -> Path:
248
+ relative = artifact.get("path")
249
+ digest = artifact.get("digest")
250
+ if not isinstance(relative, str) or not isinstance(digest, str):
251
+ raise ContractError("lifecycle state contains an invalid artifact reference")
252
+ path = (project_root / relative).resolve()
253
+ _relative(project_root, path)
254
+ if _digest_file(path) != digest:
255
+ raise ContractError(f"{path}: lifecycle artifact digest is stale")
256
+ return path
257
+
258
+
259
+ def _event(
260
+ state: dict[str, Any],
261
+ event: str,
262
+ actor: dict[str, str] | None,
263
+ **details: Any,
264
+ ) -> None:
265
+ state["revision"] += 1
266
+ record: dict[str, Any] = {
267
+ "revision": state["revision"],
268
+ "event": event,
269
+ "at": _timestamp(),
270
+ }
271
+ if actor is not None:
272
+ record["actor"] = actor
273
+ record.update(details)
274
+ state["history"].append(record)
275
+
276
+
277
+ def _validate_state(state: Any, path: Path) -> dict[str, Any]:
278
+ if not isinstance(state, dict):
279
+ raise ContractError(f"{path}: lifecycle state must be an object")
280
+ required = {
281
+ "schemaVersion",
282
+ "changeId",
283
+ "project",
284
+ "phase",
285
+ "cycle",
286
+ "revision",
287
+ "comparisonBase",
288
+ "contract",
289
+ "plan",
290
+ "implementationActors",
291
+ "verification",
292
+ "pendingFindings",
293
+ "reviewAssignment",
294
+ "review",
295
+ "completion",
296
+ "history",
297
+ }
298
+ missing = sorted(required - set(state))
299
+ extra = sorted(set(state) - required)
300
+ if missing or extra:
301
+ detail = []
302
+ if missing:
303
+ detail.append(f"missing: {', '.join(missing)}")
304
+ if extra:
305
+ detail.append(f"unknown: {', '.join(extra)}")
306
+ raise ContractError(f"{path}: invalid lifecycle state ({'; '.join(detail)})")
307
+ if state["schemaVersion"] != 2:
308
+ raise ContractError(f"{path}.schemaVersion: must be 2")
309
+ if state["phase"] not in PHASES:
310
+ raise ContractError(f"{path}.phase: invalid phase")
311
+ if not isinstance(state["cycle"], int) or state["cycle"] < 1:
312
+ raise ContractError(f"{path}.cycle: must be a positive integer")
313
+ if not isinstance(state["revision"], int) or state["revision"] < 1:
314
+ raise ContractError(f"{path}.revision: must be a positive integer")
315
+ if not isinstance(state["history"], list) or not state["history"]:
316
+ raise ContractError(f"{path}.history: must not be empty")
317
+ if not isinstance(state["pendingFindings"], list):
318
+ raise ContractError(f"{path}.pendingFindings: must be an array")
319
+ return state
320
+
321
+
322
+ def _same_finding_identity(left: dict[str, Any], right: dict[str, Any]) -> bool:
323
+ return all(left.get(field) == right.get(field) for field in FINDING_IDENTITY_FIELDS)
324
+
325
+
326
+ def _replay_pending_findings(
327
+ project_root: Path, state: dict[str, Any], path: Path
328
+ ) -> list[dict[str, Any]]:
329
+ pending: dict[str, dict[str, Any]] = {}
330
+ review_events = 0
331
+ for event in state.get("history", []):
332
+ if not isinstance(event, dict) or event.get("event") != "review-submitted":
333
+ continue
334
+ review_events += 1
335
+ artifact = event.get("report")
336
+ if not isinstance(artifact, dict):
337
+ raise ContractError(
338
+ f"{path}: cannot safely migrate review history without report artifacts"
339
+ )
340
+ report_path = _artifact_path(project_root, artifact)
341
+ report = read_json(report_path)
342
+ _validate_legacy_review(report, str(report_path))
343
+ for finding in report["findings"]:
344
+ identifier = finding["id"]
345
+ previous = pending.get(identifier)
346
+ if previous is not None and not _same_finding_identity(previous, finding):
347
+ raise ContractError(
348
+ f"{path}: cannot safely migrate finding {identifier} because its "
349
+ "identity changed"
350
+ )
351
+ if finding["status"] in UNRESOLVED_FINDING_STATUSES:
352
+ pending[identifier] = dict(finding)
353
+ elif previous is not None:
354
+ del pending[identifier]
355
+ existing = state.get("pendingFindings")
356
+ if review_events == 0 and isinstance(existing, list):
357
+ return [dict(finding) for finding in existing]
358
+ return list(pending.values())
359
+
360
+
361
+ def _migrate_state(project_root: Path, state: Any, path: Path) -> dict[str, Any]:
362
+ if not isinstance(state, dict) or state.get("schemaVersion") != 1:
363
+ return state
364
+ migrated = dict(state)
365
+ migrated["pendingFindings"] = _replay_pending_findings(project_root, state, path)
366
+ if migrated["pendingFindings"] and migrated.get("phase") in {
367
+ "approved",
368
+ "completed",
369
+ }:
370
+ migrated["phase"] = "changes-requested"
371
+ migrated["completion"] = None
372
+ migrated["schemaVersion"] = 2
373
+ return migrated
374
+
375
+
376
+ def load_state(project_root: Path, change_id: str) -> dict[str, Any]:
377
+ path = _state_path(project_root, change_id)
378
+ state = _migrate_state(project_root, read_json(path), path)
379
+ return _validate_state(state, path)
380
+
381
+
382
+ def _save_state(project_root: Path, state: dict[str, Any]) -> None:
383
+ _validate_state(state, _state_path(project_root, state["changeId"]))
384
+ _write_atomic(_state_path(project_root, state["changeId"]), state)
385
+
386
+
387
+ def _require_phase(state: dict[str, Any], *allowed: str) -> None:
388
+ if state["phase"] not in allowed:
389
+ expected = ", ".join(allowed)
390
+ raise ContractError(
391
+ f"change {state['changeId']} is {state['phase']}; expected {expected}"
392
+ )
393
+
394
+
395
+ def _contract(project_root: Path, state: dict[str, Any]) -> dict[str, Any]:
396
+ document = read_json(_artifact_path(project_root, state["contract"]))
397
+ validate_change(document, "registered change")
398
+ return document
399
+
400
+
401
+ def _plan(project_root: Path, state: dict[str, Any]) -> dict[str, Any]:
402
+ if state["plan"] is None:
403
+ raise ContractError(f"change {state['changeId']} has no registered plan")
404
+ document = read_json(_artifact_path(project_root, state["plan"]))
405
+ validate_plan(document, "registered plan")
406
+ return document
407
+
408
+
409
+ def _start_change_unlocked(
410
+ project_root: Path,
411
+ project: Project,
412
+ contract_path: Path,
413
+ *,
414
+ actor_id: str,
415
+ context_id: str,
416
+ kind: str,
417
+ ) -> dict[str, Any]:
418
+ document = read_json(contract_path)
419
+ validate_change(document, str(contract_path))
420
+ change_id = document["id"]
421
+ if project.identifier not in document["affectedProjects"]:
422
+ raise ContractError(
423
+ f"change {change_id} does not include project {project.identifier}"
424
+ )
425
+ missing_profiles = sorted(set(document["requiredProfiles"]) - set(project.profiles))
426
+ if missing_profiles:
427
+ raise ContractError(
428
+ f"change {change_id} requires undefined profiles: {', '.join(missing_profiles)}"
429
+ )
430
+ missing_baseline = sorted(
431
+ set(project.required_profiles) - set(document["requiredProfiles"])
432
+ )
433
+ if missing_baseline:
434
+ raise ContractError(
435
+ f"change {change_id} omits project lifecycle profiles: "
436
+ f"{', '.join(missing_baseline)}"
437
+ )
438
+ run_root = _run_root(project_root, change_id)
439
+ if run_root.exists():
440
+ raise ContractError(f"change {change_id} already exists")
441
+ actor = _actor(actor_id, context_id, kind)
442
+ comparison_base = _resolve_commit(project_root, document["comparisonBase"])
443
+ contract = _copy_document(project_root, contract_path, run_root / "contract.json")
444
+ now = _timestamp()
445
+ state: dict[str, Any] = {
446
+ "schemaVersion": 2,
447
+ "changeId": change_id,
448
+ "project": project.identifier,
449
+ "phase": "specified",
450
+ "cycle": 1,
451
+ "revision": 1,
452
+ "comparisonBase": comparison_base,
453
+ "contract": contract,
454
+ "plan": None,
455
+ "implementationActors": [],
456
+ "verification": [],
457
+ "pendingFindings": [],
458
+ "reviewAssignment": None,
459
+ "review": None,
460
+ "completion": None,
461
+ "history": [
462
+ {
463
+ "revision": 1,
464
+ "event": "specified",
465
+ "at": now,
466
+ "actor": actor,
467
+ "comparisonBaseRef": document["comparisonBase"],
468
+ "comparisonBase": comparison_base,
469
+ }
470
+ ],
471
+ }
472
+ _save_state(project_root, state)
473
+ return state
474
+
475
+
476
+ def _register_plan_unlocked(
477
+ project_root: Path,
478
+ project: Project,
479
+ change_id: str,
480
+ plan_path: Path,
481
+ *,
482
+ actor_id: str,
483
+ context_id: str,
484
+ kind: str,
485
+ ) -> dict[str, Any]:
486
+ state = load_state(project_root, change_id)
487
+ _require_phase(state, "specified")
488
+ contract = _contract(project_root, state)
489
+ if contract["signOff"]["required"] and contract["signOff"]["status"] != "approved":
490
+ raise ContractError(f"change {change_id} requires sign-off before planning")
491
+ document = read_json(plan_path)
492
+ validate_plan(document, str(plan_path))
493
+ if document["changeId"] != change_id:
494
+ raise ContractError(f"plan changeId does not match {change_id}")
495
+ if document["contractDigest"] != state["contract"]["digest"]:
496
+ raise ContractError("plan contractDigest does not match the registered contract")
497
+ if document["openDecisions"]:
498
+ raise ContractError("implementation plan has unresolved open decisions")
499
+ contract_criteria = {item["id"] for item in contract["acceptanceCriteria"]}
500
+ planned_criteria = {item["criterionId"] for item in document["acceptancePlan"]}
501
+ if contract_criteria != planned_criteria:
502
+ missing = sorted(contract_criteria - planned_criteria)
503
+ extra = sorted(planned_criteria - contract_criteria)
504
+ raise ContractError(
505
+ "acceptance plan must map the exact contract criteria"
506
+ + (f"; missing: {', '.join(missing)}" if missing else "")
507
+ + (f"; unknown: {', '.join(extra)}" if extra else "")
508
+ )
509
+ used_profiles = {
510
+ profile
511
+ for item in document["workItems"]
512
+ for profile in item["verificationProfiles"]
513
+ } | {
514
+ profile
515
+ for item in document["acceptancePlan"]
516
+ for profile in item["verificationProfiles"]
517
+ }
518
+ unknown_profiles = sorted(used_profiles - set(project.profiles))
519
+ if unknown_profiles:
520
+ raise ContractError(
521
+ f"plan references undefined profiles: {', '.join(unknown_profiles)}"
522
+ )
523
+ missing_required = sorted(set(contract["requiredProfiles"]) - used_profiles)
524
+ if missing_required:
525
+ raise ContractError(
526
+ f"plan does not use required profiles: {', '.join(missing_required)}"
527
+ )
528
+ actor = _actor(actor_id, context_id, kind)
529
+ artifact = _copy_document(
530
+ project_root, plan_path, _run_root(project_root, change_id) / "plan.json"
531
+ )
532
+ state["plan"] = artifact
533
+ state["phase"] = "planned"
534
+ _event(state, "planned", actor)
535
+ _save_state(project_root, state)
536
+ return state
537
+
538
+
539
+ def _begin_implementation_unlocked(
540
+ project_root: Path,
541
+ change_id: str,
542
+ *,
543
+ actor_id: str,
544
+ context_id: str,
545
+ kind: str,
546
+ ) -> dict[str, Any]:
547
+ state = load_state(project_root, change_id)
548
+ _require_phase(state, "planned", "implementing", "changes-requested")
549
+ _contract(project_root, state)
550
+ _plan(project_root, state)
551
+ actor = _actor(actor_id, context_id, kind)
552
+ if state["phase"] == "changes-requested":
553
+ state["cycle"] += 1
554
+ state["implementationActors"] = []
555
+ state["verification"] = []
556
+ state["reviewAssignment"] = None
557
+ state["review"] = None
558
+ if actor not in state["implementationActors"]:
559
+ state["implementationActors"].append(actor)
560
+ state["phase"] = "implementing"
561
+ _event(state, "implementation-started", actor, cycle=state["cycle"])
562
+ _save_state(project_root, state)
563
+ return state
564
+
565
+
566
+ def _verify_change_unlocked(
567
+ project_root: Path,
568
+ project: Project,
569
+ change_id: str,
570
+ profile: str,
571
+ *,
572
+ actor_id: str,
573
+ context_id: str,
574
+ kind: str,
575
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
576
+ state = load_state(project_root, change_id)
577
+ _require_phase(state, "implementing")
578
+ contract = _contract(project_root, state)
579
+ _plan(project_root, state)
580
+ actor = _actor(actor_id, context_id, kind)
581
+ if actor not in state["implementationActors"]:
582
+ raise ContractError("only a registered implementation actor may record verification")
583
+ if profile not in contract["requiredProfiles"]:
584
+ raise ContractError(f"profile {profile} is not required by change {change_id}")
585
+ require_environment_profile(project_root, project, profile=profile)
586
+ report = run_profile(project_root, project, profile)
587
+ report_path = (
588
+ _run_root(project_root, change_id)
589
+ / "verification"
590
+ / f"cycle-{state['cycle']}-{profile}.json"
591
+ )
592
+ _write_atomic(report_path, report)
593
+ eligible = (
594
+ report["status"] == "passed"
595
+ and report["checkpoint"] is not None
596
+ and report["workingTreeDirty"] is False
597
+ and report["workspaceFingerprint"] is not None
598
+ and not report["sourceChangedDuringVerification"]
599
+ and report["workspaceFingerprint"] == report["completedWorkspaceFingerprint"]
600
+ )
601
+ if not eligible:
602
+ _event(
603
+ state,
604
+ "verification-rejected",
605
+ actor,
606
+ cycle=state["cycle"],
607
+ profile=profile,
608
+ report=_relative(project_root, report_path),
609
+ reportDigest=_digest_file(report_path),
610
+ )
611
+ _save_state(project_root, state)
612
+ raise ContractError(
613
+ "lifecycle verification requires passing checks on a clean immutable checkpoint"
614
+ )
615
+ evidence = {
616
+ "profile": profile,
617
+ "path": _relative(project_root, report_path),
618
+ "digest": _digest_file(report_path),
619
+ "checkpoint": report["checkpoint"],
620
+ "workspaceFingerprint": report["workspaceFingerprint"],
621
+ }
622
+ state["verification"] = [
623
+ item for item in state["verification"] if item["profile"] != profile
624
+ ]
625
+ state["verification"].append(evidence)
626
+ state["verification"].sort(key=lambda item: item["profile"])
627
+ current_profiles = {item["profile"] for item in state["verification"]}
628
+ required_profiles = set(contract["requiredProfiles"])
629
+ checkpoints = {item["checkpoint"] for item in state["verification"]}
630
+ fingerprints = {item["workspaceFingerprint"] for item in state["verification"]}
631
+ if required_profiles <= current_profiles and len(checkpoints) == 1 and len(fingerprints) == 1:
632
+ state["phase"] = "verified"
633
+ _event(
634
+ state,
635
+ "verification-recorded",
636
+ actor,
637
+ cycle=state["cycle"],
638
+ profile=profile,
639
+ phase=state["phase"],
640
+ evidence=evidence,
641
+ )
642
+ _save_state(project_root, state)
643
+ return state, report
644
+
645
+
646
+ def _start_review_unlocked(
647
+ project_root: Path,
648
+ change_id: str,
649
+ *,
650
+ actor_id: str,
651
+ context_id: str,
652
+ kind: str,
653
+ method: str,
654
+ attested_by: str,
655
+ evidence: str,
656
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
657
+ state = load_state(project_root, change_id)
658
+ _require_phase(state, "verified")
659
+ _contract(project_root, state)
660
+ _plan(project_root, state)
661
+ reviewer = _actor(actor_id, context_id, kind)
662
+ if method not in {"isolated-context", "separate-person"}:
663
+ raise ContractError("review independence method is invalid")
664
+ if (kind == "agent" and method != "isolated-context") or (
665
+ kind == "human" and method != "separate-person"
666
+ ):
667
+ raise ContractError("review independence method does not match reviewer kind")
668
+ if not attested_by or attested_by != attested_by.strip() or len(attested_by) > 256:
669
+ raise ContractError("review attester must be a non-empty trimmed value")
670
+ if attested_by in {reviewer["actorId"], reviewer["contextId"]}:
671
+ raise ContractError("review independence cannot be self-attested")
672
+ if not evidence or evidence != evidence.strip() or len(evidence) > 2000:
673
+ raise ContractError("review independence evidence must be a non-empty trimmed value")
674
+ actor_ids = {item["actorId"] for item in state["implementationActors"]}
675
+ context_ids = {item["contextId"] for item in state["implementationActors"]}
676
+ if reviewer["actorId"] in actor_ids or reviewer["contextId"] in context_ids:
677
+ raise ContractError(
678
+ "independent review requires an actor id and context id unused by implementation"
679
+ )
680
+ source = source_state(project_root)
681
+ checkpoints = {item["checkpoint"] for item in state["verification"]}
682
+ fingerprints = {item["workspaceFingerprint"] for item in state["verification"]}
683
+ if (
684
+ source["dirty"] is not False
685
+ or source["checkpoint"] is None
686
+ or source["fingerprint"] is None
687
+ or checkpoints != {source["checkpoint"]}
688
+ or fingerprints != {source["fingerprint"]}
689
+ ):
690
+ raise ContractError("review cannot start because verification evidence is stale")
691
+ assignment = {
692
+ "changeId": change_id,
693
+ "cycle": state["cycle"],
694
+ "checkpoint": source["checkpoint"],
695
+ "workspaceFingerprint": source["fingerprint"],
696
+ "comparisonBase": state["comparisonBase"],
697
+ "reviewer": reviewer,
698
+ "independence": {
699
+ "method": method,
700
+ "attestedBy": attested_by,
701
+ "evidence": evidence,
702
+ },
703
+ "contract": state["contract"],
704
+ "plan": state["plan"],
705
+ "verification": state["verification"],
706
+ "pendingFindings": state["pendingFindings"],
707
+ }
708
+ assignment_path = _run_root(project_root, change_id) / f"review-request-{state['cycle']}.json"
709
+ _write_atomic(assignment_path, assignment)
710
+ assignment["path"] = _relative(project_root, assignment_path)
711
+ state["reviewAssignment"] = assignment
712
+ state["phase"] = "review-pending"
713
+ _event(
714
+ state,
715
+ "review-started",
716
+ reviewer,
717
+ cycle=state["cycle"],
718
+ request=assignment["path"],
719
+ )
720
+ _save_state(project_root, state)
721
+ return state, assignment
722
+
723
+
724
+ def _submit_review_unlocked(
725
+ project_root: Path,
726
+ change_id: str,
727
+ report_path: Path,
728
+ ) -> dict[str, Any]:
729
+ state = load_state(project_root, change_id)
730
+ _require_phase(state, "review-pending")
731
+ assignment = state["reviewAssignment"]
732
+ if not isinstance(assignment, dict):
733
+ raise ContractError("review assignment is missing")
734
+ document = read_json(report_path)
735
+ validate_review(document, str(report_path))
736
+ for field in (
737
+ "changeId",
738
+ "cycle",
739
+ "checkpoint",
740
+ "workspaceFingerprint",
741
+ "comparisonBase",
742
+ "reviewer",
743
+ "independence",
744
+ ):
745
+ if document[field] != assignment[field]:
746
+ raise ContractError(f"review report {field} does not match its assignment")
747
+ findings_by_id = {finding["id"]: finding for finding in document["findings"]}
748
+ for pending in state["pendingFindings"]:
749
+ current = findings_by_id.get(pending["id"])
750
+ if current is None:
751
+ raise ContractError(
752
+ f"review report must carry forward pending finding {pending['id']}"
753
+ )
754
+ changed = [
755
+ field
756
+ for field in FINDING_IDENTITY_FIELDS
757
+ if current[field] != pending[field]
758
+ ]
759
+ if changed:
760
+ raise ContractError(
761
+ f"review finding {pending['id']} changed immutable fields: "
762
+ + ", ".join(changed)
763
+ )
764
+ source = source_state(project_root)
765
+ if (
766
+ source["dirty"] is not False
767
+ or source["checkpoint"] != document["checkpoint"]
768
+ or source["fingerprint"] != document["workspaceFingerprint"]
769
+ ):
770
+ raise ContractError("review report is stale for the current source")
771
+ destination = (
772
+ _run_root(project_root, change_id) / f"review-{state['cycle']}.json"
773
+ )
774
+ artifact = _copy_document(project_root, report_path, destination)
775
+ state["review"] = artifact
776
+ state["pendingFindings"] = [
777
+ dict(finding)
778
+ for finding in document["findings"]
779
+ if finding["status"] in UNRESOLVED_FINDING_STATUSES
780
+ ]
781
+ state["phase"] = (
782
+ "approved" if document["verdict"] == "approved" else "changes-requested"
783
+ )
784
+ _event(
785
+ state,
786
+ "review-submitted",
787
+ document["reviewer"],
788
+ cycle=state["cycle"],
789
+ verdict=document["verdict"],
790
+ report=artifact,
791
+ )
792
+ _save_state(project_root, state)
793
+ return state
794
+
795
+
796
+ def _finish_change_unlocked(
797
+ project_root: Path,
798
+ change_id: str,
799
+ *,
800
+ actor_id: str,
801
+ context_id: str,
802
+ kind: str,
803
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
804
+ state = load_state(project_root, change_id)
805
+ _require_phase(state, "approved")
806
+ contract = _contract(project_root, state)
807
+ _plan(project_root, state)
808
+ if state["review"] is None:
809
+ raise ContractError("approved change has no review artifact")
810
+ if state["pendingFindings"]:
811
+ raise ContractError("completion requires every pending finding to be resolved")
812
+ review = read_json(_artifact_path(project_root, state["review"]))
813
+ validate_review(review, "registered review")
814
+ source = source_state(project_root)
815
+ if (
816
+ source["dirty"] is not False
817
+ or source["checkpoint"] != review["checkpoint"]
818
+ or source["fingerprint"] != review["workspaceFingerprint"]
819
+ ):
820
+ raise ContractError("approved review is stale for the current source")
821
+ required = set(contract["requiredProfiles"])
822
+ if {item["profile"] for item in state["verification"]} != required:
823
+ raise ContractError("completion requires the exact required verification profiles")
824
+ for item in state["verification"]:
825
+ report = read_json(_artifact_path(project_root, item))
826
+ if report.get("status") != "passed":
827
+ raise ContractError(f"verification profile {item['profile']} is not passing")
828
+ if (
829
+ report.get("checkpoint") != review["checkpoint"]
830
+ or report.get("workspaceFingerprint") != review["workspaceFingerprint"]
831
+ ):
832
+ raise ContractError(f"verification profile {item['profile']} is stale")
833
+ actor = _actor(actor_id, context_id, kind)
834
+ completion = {
835
+ "schemaVersion": 1,
836
+ "changeId": change_id,
837
+ "cycle": state["cycle"],
838
+ "checkpoint": review["checkpoint"],
839
+ "workspaceFingerprint": review["workspaceFingerprint"],
840
+ "comparisonBase": state["comparisonBase"],
841
+ "completedAt": _timestamp(),
842
+ "completedBy": actor,
843
+ "contract": state["contract"],
844
+ "plan": state["plan"],
845
+ "verification": state["verification"],
846
+ "review": state["review"],
847
+ }
848
+ completion_path = _run_root(project_root, change_id) / "completion.json"
849
+ _write_atomic(completion_path, completion)
850
+ state["completion"] = {
851
+ "path": _relative(project_root, completion_path),
852
+ "digest": _digest_file(completion_path),
853
+ }
854
+ state["phase"] = "completed"
855
+ _event(state, "completed", actor, cycle=state["cycle"])
856
+ _save_state(project_root, state)
857
+ return state, completion
858
+
859
+
860
+ def lifecycle_status(project_root: Path, change_id: str) -> dict[str, Any]:
861
+ state = load_state(project_root, change_id)
862
+ issues: list[str] = []
863
+ for name in ("contract", "plan", "review", "completion"):
864
+ artifact = state[name]
865
+ if artifact is None:
866
+ continue
867
+ try:
868
+ _artifact_path(project_root, artifact)
869
+ except ContractError as error:
870
+ issues.append(str(error))
871
+ for evidence in state["verification"]:
872
+ try:
873
+ _artifact_path(project_root, evidence)
874
+ except ContractError as error:
875
+ issues.append(str(error))
876
+ if state["phase"] in {"verified", "review-pending", "approved", "completed"}:
877
+ source = source_state(project_root)
878
+ checkpoints = {item["checkpoint"] for item in state["verification"]}
879
+ fingerprints = {item["workspaceFingerprint"] for item in state["verification"]}
880
+ if (
881
+ source["dirty"] is not False
882
+ or source["checkpoint"] is None
883
+ or source["fingerprint"] is None
884
+ or checkpoints != {source["checkpoint"]}
885
+ or fingerprints != {source["fingerprint"]}
886
+ ):
887
+ issues.append("current source no longer matches lifecycle verification")
888
+ return {**state, "current": not issues, "issues": issues}
889
+
890
+
891
+ def start_change(
892
+ project_root: Path,
893
+ project: Project,
894
+ contract_path: Path,
895
+ *,
896
+ actor_id: str,
897
+ context_id: str,
898
+ kind: str,
899
+ ) -> dict[str, Any]:
900
+ document = read_json(contract_path)
901
+ validate_change(document, str(contract_path))
902
+ change_id = document["id"]
903
+ with _change_lock(project_root, change_id):
904
+ return _start_change_unlocked(
905
+ project_root,
906
+ project,
907
+ contract_path,
908
+ actor_id=actor_id,
909
+ context_id=context_id,
910
+ kind=kind,
911
+ )
912
+
913
+
914
+ def register_plan(
915
+ project_root: Path,
916
+ project: Project,
917
+ change_id: str,
918
+ plan_path: Path,
919
+ *,
920
+ actor_id: str,
921
+ context_id: str,
922
+ kind: str,
923
+ ) -> dict[str, Any]:
924
+ with _change_lock(project_root, change_id):
925
+ return _register_plan_unlocked(
926
+ project_root,
927
+ project,
928
+ change_id,
929
+ plan_path,
930
+ actor_id=actor_id,
931
+ context_id=context_id,
932
+ kind=kind,
933
+ )
934
+
935
+
936
+ def begin_implementation(
937
+ project_root: Path,
938
+ change_id: str,
939
+ *,
940
+ actor_id: str,
941
+ context_id: str,
942
+ kind: str,
943
+ ) -> dict[str, Any]:
944
+ with _change_lock(project_root, change_id):
945
+ return _begin_implementation_unlocked(
946
+ project_root,
947
+ change_id,
948
+ actor_id=actor_id,
949
+ context_id=context_id,
950
+ kind=kind,
951
+ )
952
+
953
+
954
+ def verify_change(
955
+ project_root: Path,
956
+ project: Project,
957
+ change_id: str,
958
+ profile: str,
959
+ *,
960
+ actor_id: str,
961
+ context_id: str,
962
+ kind: str,
963
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
964
+ with _change_lock(project_root, change_id):
965
+ return _verify_change_unlocked(
966
+ project_root,
967
+ project,
968
+ change_id,
969
+ profile,
970
+ actor_id=actor_id,
971
+ context_id=context_id,
972
+ kind=kind,
973
+ )
974
+
975
+
976
+ def start_review(
977
+ project_root: Path,
978
+ change_id: str,
979
+ *,
980
+ actor_id: str,
981
+ context_id: str,
982
+ kind: str,
983
+ method: str,
984
+ attested_by: str,
985
+ evidence: str,
986
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
987
+ with _change_lock(project_root, change_id):
988
+ return _start_review_unlocked(
989
+ project_root,
990
+ change_id,
991
+ actor_id=actor_id,
992
+ context_id=context_id,
993
+ kind=kind,
994
+ method=method,
995
+ attested_by=attested_by,
996
+ evidence=evidence,
997
+ )
998
+
999
+
1000
+ def submit_review(
1001
+ project_root: Path,
1002
+ change_id: str,
1003
+ report_path: Path,
1004
+ ) -> dict[str, Any]:
1005
+ with _change_lock(project_root, change_id):
1006
+ return _submit_review_unlocked(project_root, change_id, report_path)
1007
+
1008
+
1009
+ def finish_change(
1010
+ project_root: Path,
1011
+ change_id: str,
1012
+ *,
1013
+ actor_id: str,
1014
+ context_id: str,
1015
+ kind: str,
1016
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
1017
+ with _change_lock(project_root, change_id):
1018
+ return _finish_change_unlocked(
1019
+ project_root,
1020
+ change_id,
1021
+ actor_id=actor_id,
1022
+ context_id=context_id,
1023
+ kind=kind,
1024
+ )