engineering-process 1.0.0__tar.gz → 1.1.0__tar.gz

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 (70) hide show
  1. {engineering_process-1.0.0/engineering_process.egg-info → engineering_process-1.1.0}/PKG-INFO +67 -4
  2. {engineering_process-1.0.0 → engineering_process-1.1.0}/README.md +66 -3
  3. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process/__init__.py +1 -1
  4. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process/_supervisor_posix.py +24 -25
  5. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process/cli.py +55 -111
  6. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process/project.py +58 -1
  7. {engineering_process-1.0.0 → engineering_process-1.1.0/engineering_process.egg-info}/PKG-INFO +67 -4
  8. {engineering_process-1.0.0 → engineering_process-1.1.0}/process_assets/skills/finish-change/SKILL.md +5 -0
  9. {engineering_process-1.0.0 → engineering_process-1.1.0}/process_assets/skills/implement-change/SKILL.md +6 -0
  10. engineering_process-1.1.0/process_assets/skills/improve-process/SKILL.md +75 -0
  11. {engineering_process-1.0.0 → engineering_process-1.1.0}/process_assets/skills/plan-change/SKILL.md +6 -0
  12. {engineering_process-1.0.0 → engineering_process-1.1.0}/process_assets/skills/review-change/SKILL.md +7 -0
  13. {engineering_process-1.0.0 → engineering_process-1.1.0}/process_assets/skills/run-change/SKILL.md +10 -2
  14. {engineering_process-1.0.0 → engineering_process-1.1.0}/process_assets/skills/start-change/SKILL.md +8 -0
  15. {engineering_process-1.0.0 → engineering_process-1.1.0}/process_assets/skills/verify-change/SKILL.md +6 -0
  16. {engineering_process-1.0.0 → engineering_process-1.1.0}/pyproject.toml +1 -1
  17. {engineering_process-1.0.0 → engineering_process-1.1.0}/schemas/project.schema.json +58 -1
  18. {engineering_process-1.0.0 → engineering_process-1.1.0}/tests/test_architecture.py +1 -1
  19. {engineering_process-1.0.0 → engineering_process-1.1.0}/tests/test_automation.py +83 -3
  20. {engineering_process-1.0.0 → engineering_process-1.1.0}/tests/test_cli.py +22 -0
  21. {engineering_process-1.0.0 → engineering_process-1.1.0}/tests/test_commands.py +28 -0
  22. engineering_process-1.1.0/tests/test_contracts.py +232 -0
  23. engineering_process-1.1.0/tests/test_skills.py +88 -0
  24. engineering_process-1.0.0/process_assets/skills/improve-process/SKILL.md +0 -22
  25. engineering_process-1.0.0/tests/test_contracts.py +0 -67
  26. engineering_process-1.0.0/tests/test_skills.py +0 -53
  27. {engineering_process-1.0.0 → engineering_process-1.1.0}/LICENSE +0 -0
  28. {engineering_process-1.0.0 → engineering_process-1.1.0}/MANIFEST.in +0 -0
  29. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process/__main__.py +0 -0
  30. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process/_supervisor_windows.py +0 -0
  31. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process/_windows_job.py +0 -0
  32. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process/adoption.py +0 -0
  33. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process/commands.py +0 -0
  34. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process/contracts.py +0 -0
  35. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process/distribution.py +0 -0
  36. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process/helper_launch.py +0 -0
  37. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process/lifecycle.py +0 -0
  38. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process/publication_compat.py +0 -0
  39. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process/release.py +0 -0
  40. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process/repository.py +0 -0
  41. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process/requirements-dev.txt +0 -0
  42. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process/requirements-runtime.txt +0 -0
  43. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process/skills.py +0 -0
  44. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process/supervision.py +0 -0
  45. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process.egg-info/SOURCES.txt +0 -0
  46. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process.egg-info/dependency_links.txt +0 -0
  47. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process.egg-info/entry_points.txt +0 -0
  48. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process.egg-info/requires.txt +0 -0
  49. {engineering_process-1.0.0 → engineering_process-1.1.0}/engineering_process.egg-info/top_level.txt +0 -0
  50. {engineering_process-1.0.0 → engineering_process-1.1.0}/process-graph.json +0 -0
  51. {engineering_process-1.0.0 → engineering_process-1.1.0}/schemas/change.schema.json +0 -0
  52. {engineering_process-1.0.0 → engineering_process-1.1.0}/schemas/plan.schema.json +0 -0
  53. {engineering_process-1.0.0 → engineering_process-1.1.0}/schemas/process-graph.schema.json +0 -0
  54. {engineering_process-1.0.0 → engineering_process-1.1.0}/schemas/process-lock.schema.json +0 -0
  55. {engineering_process-1.0.0 → engineering_process-1.1.0}/schemas/project-legacy.schema.json +0 -0
  56. {engineering_process-1.0.0 → engineering_process-1.1.0}/schemas/receipt.schema.json +0 -0
  57. {engineering_process-1.0.0 → engineering_process-1.1.0}/schemas/release-change.schema.json +0 -0
  58. {engineering_process-1.0.0 → engineering_process-1.1.0}/schemas/release.schema.json +0 -0
  59. {engineering_process-1.0.0 → engineering_process-1.1.0}/schemas/review.schema.json +0 -0
  60. {engineering_process-1.0.0 → engineering_process-1.1.0}/schemas/run.schema.json +0 -0
  61. {engineering_process-1.0.0 → engineering_process-1.1.0}/setup.cfg +0 -0
  62. {engineering_process-1.0.0 → engineering_process-1.1.0}/templates/AGENTS.process.md +0 -0
  63. {engineering_process-1.0.0 → engineering_process-1.1.0}/templates/PULL_REQUEST_TEMPLATE.md +0 -0
  64. {engineering_process-1.0.0 → engineering_process-1.1.0}/templates/adopt-process-windows-job.py +0 -0
  65. {engineering_process-1.0.0 → engineering_process-1.1.0}/templates/adopt-process.py +0 -0
  66. {engineering_process-1.0.0 → engineering_process-1.1.0}/tests/test_adoption.py +0 -0
  67. {engineering_process-1.0.0 → engineering_process-1.1.0}/tests/test_lifecycle.py +0 -0
  68. {engineering_process-1.0.0 → engineering_process-1.1.0}/tests/test_publication_compat.py +0 -0
  69. {engineering_process-1.0.0 → engineering_process-1.1.0}/tests/test_release.py +0 -0
  70. {engineering_process-1.0.0 → engineering_process-1.1.0}/tests/test_repository.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engineering-process
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: A small agent-neutral engineering lifecycle with managed adoption
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/phuongnse/engineering-process
@@ -92,10 +92,72 @@ Commands are argument arrays, never shell strings. Each command has a finite tim
92
92
  Output has a hard aggregate budget; evidence stores byte counts and hashes, never raw
93
93
  stdout or stderr that could contain secrets.
94
94
 
95
+ ### Production readiness
96
+
97
+ A consumer declares `.process/readiness.json` with production as its direction, its
98
+ current stage, immutable pack versions, and the state of every required capability.
99
+ An enforced capability maps to project-required profiles; a planned capability names
100
+ the concrete gap without pretending to have evidence:
101
+
102
+ {
103
+ "target": "production",
104
+ "stage": "production",
105
+ "packs": [{"id": "library-cli", "version": 1}],
106
+ "capabilities": [
107
+ {"id": "correctness", "state": "enforced", "evidenceProfiles": ["development"]},
108
+ {"id": "runtime-safety", "state": "enforced", "evidenceProfiles": ["development"]},
109
+ {"id": "compatibility", "state": "enforced", "evidenceProfiles": ["development"]},
110
+ {"id": "portability", "state": "enforced", "evidenceProfiles": ["development", "review"]},
111
+ {"id": "installability", "state": "enforced", "evidenceProfiles": ["review"]},
112
+ {"id": "distribution-integrity", "state": "enforced", "evidenceProfiles": ["review"]},
113
+ {"id": "adoption-integrity", "state": "enforced", "evidenceProfiles": ["development", "review"]}
114
+ ]
115
+ }
116
+
117
+ `project validate` and `doctor` resolve that declaration to the exact checks owned by
118
+ the consumer. The declaration does not make a weak command sufficient: normal CI and
119
+ independent review still judge whether those commands prove the named capability. A
120
+ building consumer may keep planned gaps while ordinary development continues. A
121
+ production-stage declaration fails closed if any capability remains planned.
122
+
123
+ The sidecar is a deliberate self-hosting boundary. Public authority N continues to
124
+ validate the unchanged strict `.process/project.json` while source N+1 validates and
125
+ self-applies the new readiness contract. Adoption leaves the consumer-owned sidecar
126
+ in place, so every later authority can repeat the same forward-compatible sequence.
127
+ Pack versions are also immutable: a process update must keep `operations@1` working
128
+ even after `operations@2` exists. Process adoption and pack upgrades are separate
129
+ consumer-owned changes, preventing a new standard from deadlocking authority adoption.
130
+
131
+ `library-cli@1` was derived from this repository as a real producer and self-consumer.
132
+ `operations@1` was then derived from renovate-ops and requires auditability, automation
133
+ correctness, bounded execution, least privilege, policy integrity, recovery, and
134
+ target-selection integrity. `desktop-media@1` is derived from LyricRail and keeps its
135
+ existing correctness, input, source-portability, audit, media, package, and recovery-
136
+ mechanism evidence enforced. Stable dependency/recovery claims, signing, key custody,
137
+ runtime/license delivery, Linux advisory resolution, real-host workspace security,
138
+ updater, incident recovery, and independent security review remain planned.
139
+ Consumers without readiness remain compatible during that evidence-backed rollout.
140
+
141
+ For each ordinary change, `run-change` first surfaces this readiness view. The accepted
142
+ request and consumer rules determine which capabilities are affected. Every change
143
+ retains the project's baseline `requiredProfiles`; start and plan add any conditional
144
+ evidence profiles needed by affected capabilities and include a planned gap only when
145
+ the accepted request explicitly selects it. Implement and review protect the enforced
146
+ floor. A planned-to-enforced promotion is a reviewed consumer source diff with fresh
147
+ evidence. Unrelated planned gaps remain visible but do not block development, and no
148
+ skill chooses product priorities or changes readiness automatically.
149
+
150
+ When a consumer incident exposes a reusable process gap, `improve-process` first keeps
151
+ the consumer safe, then prepares a sanitized GitHub issue draft from that checkout.
152
+ It deduplicates by consumer/process-version/invariant, requires owner authorization
153
+ before `gh issue create`, and uses an accepted issue as the later process change source
154
+ and `consumerEvidence`. No producer clone, consumer-CI write token, automatic process
155
+ mutation, or wait for a process release is required to continue consumer development.
156
+
95
157
  Pin the process in requirements/process.in:
96
158
 
97
159
  --only-binary :all:
98
- engineering-process==0.9.0
160
+ engineering-process==1.0.1
99
161
 
100
162
  Generate requirements/process.txt with hashes, install that lock, then run:
101
163
 
@@ -232,7 +294,7 @@ At any point:
232
294
  ## Release to consumer PR
233
295
 
234
296
  Every opted-in consumer uses Renovate's pip-compile manager. Its engineering-process
235
- package rule is enabled, never automerges, and runs exactly:
297
+ package rule keeps the adoption pull request in draft and runs exactly:
236
298
 
237
299
  python .process/adopt-process.py --project-root . --requirements-lock requirements/process.txt
238
300
 
@@ -247,7 +309,8 @@ The release workflow publishes exact wheel and sdist bytes to PyPI, verifies the
247
309
  registry hashes, creates the immutable GitHub release, and sends one authenticated
248
310
  engineering-process-published event to renovate-ops. That control plane runs Renovate
249
311
  for each repository whose protected config explicitly opts in. Each consumer's normal
250
- CI and independent review decide whether its PR can merge.
312
+ CI and independent review decide whether its draft PR can merge; the consumer owner
313
+ authorizes that merge.
251
314
 
252
315
  This repository opts in through .github/renovate.json, so it receives the same
253
316
  adoption PR as every other consumer. See SELF_HOSTING.md and RELEASING.md.
@@ -68,10 +68,72 @@ Commands are argument arrays, never shell strings. Each command has a finite tim
68
68
  Output has a hard aggregate budget; evidence stores byte counts and hashes, never raw
69
69
  stdout or stderr that could contain secrets.
70
70
 
71
+ ### Production readiness
72
+
73
+ A consumer declares `.process/readiness.json` with production as its direction, its
74
+ current stage, immutable pack versions, and the state of every required capability.
75
+ An enforced capability maps to project-required profiles; a planned capability names
76
+ the concrete gap without pretending to have evidence:
77
+
78
+ {
79
+ "target": "production",
80
+ "stage": "production",
81
+ "packs": [{"id": "library-cli", "version": 1}],
82
+ "capabilities": [
83
+ {"id": "correctness", "state": "enforced", "evidenceProfiles": ["development"]},
84
+ {"id": "runtime-safety", "state": "enforced", "evidenceProfiles": ["development"]},
85
+ {"id": "compatibility", "state": "enforced", "evidenceProfiles": ["development"]},
86
+ {"id": "portability", "state": "enforced", "evidenceProfiles": ["development", "review"]},
87
+ {"id": "installability", "state": "enforced", "evidenceProfiles": ["review"]},
88
+ {"id": "distribution-integrity", "state": "enforced", "evidenceProfiles": ["review"]},
89
+ {"id": "adoption-integrity", "state": "enforced", "evidenceProfiles": ["development", "review"]}
90
+ ]
91
+ }
92
+
93
+ `project validate` and `doctor` resolve that declaration to the exact checks owned by
94
+ the consumer. The declaration does not make a weak command sufficient: normal CI and
95
+ independent review still judge whether those commands prove the named capability. A
96
+ building consumer may keep planned gaps while ordinary development continues. A
97
+ production-stage declaration fails closed if any capability remains planned.
98
+
99
+ The sidecar is a deliberate self-hosting boundary. Public authority N continues to
100
+ validate the unchanged strict `.process/project.json` while source N+1 validates and
101
+ self-applies the new readiness contract. Adoption leaves the consumer-owned sidecar
102
+ in place, so every later authority can repeat the same forward-compatible sequence.
103
+ Pack versions are also immutable: a process update must keep `operations@1` working
104
+ even after `operations@2` exists. Process adoption and pack upgrades are separate
105
+ consumer-owned changes, preventing a new standard from deadlocking authority adoption.
106
+
107
+ `library-cli@1` was derived from this repository as a real producer and self-consumer.
108
+ `operations@1` was then derived from renovate-ops and requires auditability, automation
109
+ correctness, bounded execution, least privilege, policy integrity, recovery, and
110
+ target-selection integrity. `desktop-media@1` is derived from LyricRail and keeps its
111
+ existing correctness, input, source-portability, audit, media, package, and recovery-
112
+ mechanism evidence enforced. Stable dependency/recovery claims, signing, key custody,
113
+ runtime/license delivery, Linux advisory resolution, real-host workspace security,
114
+ updater, incident recovery, and independent security review remain planned.
115
+ Consumers without readiness remain compatible during that evidence-backed rollout.
116
+
117
+ For each ordinary change, `run-change` first surfaces this readiness view. The accepted
118
+ request and consumer rules determine which capabilities are affected. Every change
119
+ retains the project's baseline `requiredProfiles`; start and plan add any conditional
120
+ evidence profiles needed by affected capabilities and include a planned gap only when
121
+ the accepted request explicitly selects it. Implement and review protect the enforced
122
+ floor. A planned-to-enforced promotion is a reviewed consumer source diff with fresh
123
+ evidence. Unrelated planned gaps remain visible but do not block development, and no
124
+ skill chooses product priorities or changes readiness automatically.
125
+
126
+ When a consumer incident exposes a reusable process gap, `improve-process` first keeps
127
+ the consumer safe, then prepares a sanitized GitHub issue draft from that checkout.
128
+ It deduplicates by consumer/process-version/invariant, requires owner authorization
129
+ before `gh issue create`, and uses an accepted issue as the later process change source
130
+ and `consumerEvidence`. No producer clone, consumer-CI write token, automatic process
131
+ mutation, or wait for a process release is required to continue consumer development.
132
+
71
133
  Pin the process in requirements/process.in:
72
134
 
73
135
  --only-binary :all:
74
- engineering-process==0.9.0
136
+ engineering-process==1.0.1
75
137
 
76
138
  Generate requirements/process.txt with hashes, install that lock, then run:
77
139
 
@@ -208,7 +270,7 @@ At any point:
208
270
  ## Release to consumer PR
209
271
 
210
272
  Every opted-in consumer uses Renovate's pip-compile manager. Its engineering-process
211
- package rule is enabled, never automerges, and runs exactly:
273
+ package rule keeps the adoption pull request in draft and runs exactly:
212
274
 
213
275
  python .process/adopt-process.py --project-root . --requirements-lock requirements/process.txt
214
276
 
@@ -223,7 +285,8 @@ The release workflow publishes exact wheel and sdist bytes to PyPI, verifies the
223
285
  registry hashes, creates the immutable GitHub release, and sends one authenticated
224
286
  engineering-process-published event to renovate-ops. That control plane runs Renovate
225
287
  for each repository whose protected config explicitly opts in. Each consumer's normal
226
- CI and independent review decide whether its PR can merge.
288
+ CI and independent review decide whether its draft PR can merge; the consumer owner
289
+ authorizes that merge.
227
290
 
228
291
  This repository opts in through .github/renovate.json, so it receives the same
229
292
  adoption PR as every other consumer. See SELF_HOSTING.md and RELEASING.md.
@@ -1,3 +1,3 @@
1
1
  """Agent-neutral engineering process."""
2
2
 
3
- VERSION = "1.0.0"
3
+ VERSION = "1.1.0"
@@ -98,13 +98,24 @@ def _pid_alive(pid: int) -> bool:
98
98
  status = Path(f"/proc/{pid}/stat")
99
99
  if status.is_file():
100
100
  try:
101
- return status.read_text(encoding="utf-8").split()[2] != "Z"
101
+ state = status.read_text(encoding="utf-8").rsplit(")", 1)[1].split()[0]
102
+ if state == "Z":
103
+ try:
104
+ os.waitpid(pid, os.WNOHANG)
105
+ except OSError:
106
+ pass
107
+ return False
102
108
  except (OSError, IndexError):
103
109
  pass
104
110
  return True
105
111
 
106
112
 
107
113
  def _process_group_exists(process_group: int) -> bool:
114
+ try:
115
+ while os.waitpid(-process_group, os.WNOHANG)[0]:
116
+ pass
117
+ except OSError:
118
+ pass
108
119
  try:
109
120
  os.killpg(process_group, 0)
110
121
  except ProcessLookupError:
@@ -114,6 +125,14 @@ def _process_group_exists(process_group: int) -> bool:
114
125
  return True
115
126
 
116
127
 
128
+ def _signal_processes(processes: set[int], signal_number: int) -> None:
129
+ for pid in processes:
130
+ try:
131
+ os.kill(pid, signal_number)
132
+ except OSError:
133
+ pass
134
+
135
+
117
136
  def _wait_for_process_group(process_group: int, timeout: float) -> bool:
118
137
  deadline = time.monotonic() + timeout
119
138
  while time.monotonic() < deadline:
@@ -144,11 +163,7 @@ def _terminate_group(process_group: int, grace_seconds: float) -> CleanupOutcome
144
163
  return CleanupOutcome(
145
164
  bounded=bounded,
146
165
  descendants_found=True,
147
- error=(
148
- None
149
- if bounded
150
- else "command process group could not be terminated within the bounded grace period"
151
- ),
166
+ error=None if bounded else "command process group exceeded its termination grace period",
152
167
  )
153
168
 
154
169
 
@@ -250,24 +265,12 @@ class PosixProcessSupervisor:
250
265
  live = {pid for pid in candidates if _pid_alive(pid)}
251
266
  if not live:
252
267
  return CleanupOutcome(bounded=True)
253
- for pid in live:
254
- try:
255
- os.kill(pid, signal.SIGTERM)
256
- except ProcessLookupError:
257
- pass
258
- except OSError:
259
- pass
268
+ _signal_processes(live, signal.SIGTERM)
260
269
  deadline = time.monotonic() + grace_seconds
261
270
  while time.monotonic() < deadline and any(_pid_alive(pid) for pid in live):
262
271
  time.sleep(0.02)
263
272
  survivors = {pid for pid in live if _pid_alive(pid)}
264
- for pid in survivors:
265
- try:
266
- os.kill(pid, signal.SIGKILL)
267
- except ProcessLookupError:
268
- pass
269
- except OSError:
270
- pass
273
+ _signal_processes(survivors, signal.SIGKILL)
271
274
  deadline = time.monotonic() + grace_seconds
272
275
  while time.monotonic() < deadline and any(
273
276
  _pid_alive(pid) for pid in survivors
@@ -282,11 +285,7 @@ class PosixProcessSupervisor:
282
285
  return CleanupOutcome(
283
286
  bounded=bounded,
284
287
  descendants_found=True,
285
- error=(
286
- None
287
- if bounded
288
- else "detached descendants survived bounded termination"
289
- ),
288
+ error=None if bounded else "detached descendants survived bounded termination",
290
289
  )
291
290
 
292
291
  def terminate(
@@ -34,7 +34,7 @@ from .lifecycle import (
34
34
  submit_review,
35
35
  verify_change,
36
36
  )
37
- from .project import load_project
37
+ from .project import load_project, readiness_summary
38
38
  from .publication_compat import (
39
39
  branch_issues,
40
40
  commit_issues,
@@ -97,6 +97,7 @@ def command_project_validate(args: argparse.Namespace) -> Result:
97
97
  project=project["project"],
98
98
  profiles=sorted(project["profiles"]),
99
99
  requiredProfiles=project["lifecycle"]["requiredProfiles"],
100
+ readiness=readiness_summary(project),
100
101
  ), 0
101
102
 
102
103
 
@@ -155,6 +156,7 @@ def command_doctor(args: argparse.Namespace) -> Result:
155
156
  project=project["project"],
156
157
  processVersion=VERSION,
157
158
  profiles=sorted(project["profiles"]),
159
+ readiness=readiness_summary(project),
158
160
  issues=issues,
159
161
  ), (0 if not issues else 1)
160
162
 
@@ -341,47 +343,27 @@ def command_change_status(args: argparse.Namespace) -> Result:
341
343
 
342
344
 
343
345
  def command_release_validate(args: argparse.Namespace) -> Result:
344
- details = validate_release(
345
- args.project_root, _process_root(args), tag=args.tag
346
- )
346
+ details = validate_release(args.project_root, _process_root(args), tag=args.tag)
347
347
  return _result("release validate", **details), 0
348
348
 
349
349
 
350
- def _publication_result(command: str, details: dict[str, Any]) -> Result:
351
- issues = details["issues"]
352
- status = "passed" if not issues else "failed"
353
- return _result(command, status=status, **details), (0 if not issues else 1)
354
-
355
-
356
- def command_publication_branch(args: argparse.Namespace) -> Result:
357
- return _publication_result(
358
- "publication validate-branch", {"issues": branch_issues(args.branch)}
359
- )
360
-
361
-
362
- def command_publication_commit(args: argparse.Namespace) -> Result:
363
- return _publication_result(
364
- "publication validate-commit", {"issues": commit_issues(args.subject)}
365
- )
366
-
367
-
368
- def command_publication_range(args: argparse.Namespace) -> Result:
369
- return _publication_result(
370
- "publication validate-range",
371
- validate_range(args.project_root, args.branch, args.range_spec),
372
- )
373
-
374
-
375
- def command_publication_pr(args: argparse.Namespace) -> Result:
376
- return _publication_result(
377
- "publication validate-pr",
378
- validate_pull_request(
350
+ def command_publication(args: argparse.Namespace) -> Result:
351
+ name = args.publication_command
352
+ if name == "validate-branch":
353
+ details = {"issues": branch_issues(args.branch)}
354
+ elif name == "validate-commit":
355
+ details = {"issues": commit_issues(args.subject)}
356
+ elif name == "validate-range":
357
+ details = validate_range(args.project_root, args.branch, args.range_spec)
358
+ else:
359
+ details = validate_pull_request(
379
360
  title=args.title,
380
361
  branch=args.branch,
381
362
  state=args.state,
382
363
  body_path=args.body_file,
383
- ),
384
- )
364
+ )
365
+ status = "passed" if not details["issues"] else "failed"
366
+ return _result(f"publication {name}", status=status, **details), (0 if status == "passed" else 1)
385
367
 
386
368
 
387
369
  def _add_common(parser: argparse.ArgumentParser, *, project: bool = True) -> None:
@@ -397,151 +379,113 @@ def _add_actor(parser: argparse.ArgumentParser) -> None:
397
379
  parser.add_argument("--actor-kind", choices=("agent", "human"), default="agent")
398
380
 
399
381
 
382
+ def _leaf(subparsers: Any, name: str, handler: Callable[..., Result], *, project: bool = True, help: str | None = None) -> argparse.ArgumentParser:
383
+ parser = subparsers.add_parser(name, help=help)
384
+ _add_common(parser, project=project)
385
+ parser.set_defaults(handler=handler)
386
+ return parser
387
+
388
+
400
389
  def build_parser() -> argparse.ArgumentParser:
401
390
  parser = argparse.ArgumentParser(
402
391
  prog="processctl", description="Small, enforceable engineering process"
403
392
  )
404
393
  parser.add_argument("--version", action="version", version=VERSION)
405
394
  commands = parser.add_subparsers(dest="command", required=True)
406
-
407
395
  project = commands.add_parser("project", help="Validate consumer configuration")
408
396
  project_commands = project.add_subparsers(dest="project_command", required=True)
409
- project_validate = project_commands.add_parser("validate")
410
- _add_common(project_validate)
411
- project_validate.set_defaults(handler=command_project_validate)
412
-
397
+ _leaf(project_commands, "validate", command_project_validate)
413
398
  lock = commands.add_parser("lock", help="Validate the adopted process lock")
414
399
  lock_commands = lock.add_subparsers(dest="lock_command", required=True)
415
- lock_validate = lock_commands.add_parser("validate")
416
- _add_common(lock_validate)
417
- lock_validate.set_defaults(handler=command_lock_validate)
418
-
400
+ _leaf(lock_commands, "validate", command_lock_validate)
419
401
  contract = commands.add_parser("contract", help="Validate a JSON contract")
420
402
  contract_commands = contract.add_subparsers(dest="contract_command", required=True)
421
- contract_validate = contract_commands.add_parser("validate")
403
+ contract_validate = _leaf(
404
+ contract_commands, "validate", command_contract_validate, project=False
405
+ )
422
406
  contract_validate.add_argument("--kind", choices=CONTRACT_KINDS, required=True)
423
407
  contract_validate.add_argument("path", type=Path)
424
- _add_common(contract_validate, project=False)
425
- contract_validate.set_defaults(handler=command_contract_validate)
426
-
427
408
  skills = commands.add_parser("skills", help="Validate the complete skill graph")
428
409
  skills_commands = skills.add_subparsers(dest="skills_command", required=True)
429
- skills_validate = skills_commands.add_parser("validate")
410
+ skills_validate = _leaf(
411
+ skills_commands, "validate", command_skills_validate, project=False
412
+ )
430
413
  skills_validate.add_argument("--root", type=Path)
431
- _add_common(skills_validate, project=False)
432
- skills_validate.set_defaults(handler=command_skills_validate)
433
-
434
- doctor = commands.add_parser("doctor", help="Validate one consumer integration")
435
- _add_common(doctor)
414
+ doctor = _leaf(commands, "doctor", command_doctor, help="Validate one consumer integration")
436
415
  doctor.add_argument("--profile")
437
- doctor.set_defaults(handler=command_doctor)
438
-
439
- setup = commands.add_parser("setup", help=argparse.SUPPRESS)
440
- _add_common(setup)
416
+ setup = _leaf(commands, "setup", command_setup, help=argparse.SUPPRESS)
441
417
  setup.add_argument("--profile", required=True)
442
418
  setup.add_argument("--apply", action="store_true")
443
419
  setup.add_argument("--allow", action="append", default=[])
444
- setup.set_defaults(handler=command_setup)
445
-
446
- verify = commands.add_parser("verify", help="Run a project verification profile")
447
- _add_common(verify)
420
+ verify = _leaf(commands, "verify", command_verify, help="Run a project verification profile")
448
421
  verify.add_argument("--profile", required=True)
449
- verify.set_defaults(handler=command_verify)
450
-
451
422
  adoption = commands.add_parser("adoption", help="Apply or check managed adoption")
452
423
  adoption_commands = adoption.add_subparsers(dest="adoption_command", required=True)
453
424
  for name, handler in (("apply", command_adoption_apply), ("check", command_adoption_check)):
454
- adoption_command = adoption_commands.add_parser(name)
455
- _add_common(adoption_command)
425
+ adoption_command = _leaf(adoption_commands, name, handler)
456
426
  adoption_command.add_argument("--requirements-lock", type=Path, required=True)
457
427
  if name == "apply":
458
428
  adoption_command.add_argument("--requirements-source", type=Path)
459
429
  adoption_command.add_argument("--expected-requirements-digest")
460
- adoption_command.set_defaults(handler=handler)
461
-
462
430
  change = commands.add_parser("change", help="Run the six-phase change lifecycle")
463
431
  change_commands = change.add_subparsers(dest="change_command", required=True)
464
-
465
- change_start = change_commands.add_parser("start")
466
- _add_common(change_start)
432
+ change_start = _leaf(change_commands, "start", command_change_start)
467
433
  _add_actor(change_start)
468
434
  change_start.add_argument("--contract", type=Path, required=True)
469
- change_start.set_defaults(handler=command_change_start)
470
-
471
- change_plan = change_commands.add_parser("plan")
472
- _add_common(change_plan)
435
+ change_plan = _leaf(change_commands, "plan", command_change_plan)
473
436
  _add_actor(change_plan)
474
437
  change_plan.add_argument("--change-id", required=True)
475
438
  change_plan.add_argument("--plan", type=Path, required=True)
476
- change_plan.set_defaults(handler=command_change_plan)
477
-
478
- change_implement = change_commands.add_parser("implement")
479
- _add_common(change_implement)
439
+ change_implement = _leaf(change_commands, "implement", command_change_implement)
480
440
  _add_actor(change_implement)
481
441
  change_implement.add_argument("--change-id", required=True)
482
- change_implement.set_defaults(handler=command_change_implement)
483
-
484
- change_verify = change_commands.add_parser("verify")
485
- _add_common(change_verify)
442
+ change_verify = _leaf(change_commands, "verify", command_change_verify)
486
443
  change_verify.add_argument("--change-id", required=True)
487
444
  change_verify.add_argument("--profile", required=True)
488
- change_verify.set_defaults(handler=command_change_verify)
489
445
 
490
446
  review = change_commands.add_parser("review")
491
447
  review_commands = review.add_subparsers(dest="review_command", required=True)
492
- review_start = review_commands.add_parser("start")
493
- _add_common(review_start)
448
+ review_start = _leaf(review_commands, "start", command_change_review_start)
494
449
  _add_actor(review_start)
495
450
  review_start.add_argument("--change-id", required=True)
496
- review_start.set_defaults(handler=command_change_review_start)
497
- review_submit = review_commands.add_parser("submit")
498
- _add_common(review_submit)
451
+ review_submit = _leaf(review_commands, "submit", command_change_review_submit)
499
452
  review_submit.add_argument("--change-id", required=True)
500
453
  review_submit.add_argument("--review", type=Path, required=True)
501
- review_submit.set_defaults(handler=command_change_review_submit)
502
454
 
503
- change_finish = change_commands.add_parser("finish")
504
- _add_common(change_finish)
455
+ change_finish = _leaf(change_commands, "finish", command_change_finish)
505
456
  _add_actor(change_finish)
506
457
  change_finish.add_argument("--change-id", required=True)
507
- change_finish.set_defaults(handler=command_change_finish)
508
458
 
509
- change_status = change_commands.add_parser("status")
510
- _add_common(change_status)
459
+ change_status = _leaf(change_commands, "status", command_change_status)
511
460
  change_status.add_argument("--change-id", required=True)
512
- change_status.set_defaults(handler=command_change_status)
513
461
 
514
462
  release = commands.add_parser("release", help="Validate release identity")
515
463
  release_commands = release.add_subparsers(dest="release_command", required=True)
516
- release_validate = release_commands.add_parser("validate")
517
- _add_common(release_validate)
464
+ release_validate = _leaf(release_commands, "validate", command_release_validate)
518
465
  release_validate.add_argument("--tag")
519
- release_validate.set_defaults(handler=command_release_validate)
520
466
 
521
467
  publication = commands.add_parser("publication", help=argparse.SUPPRESS)
522
468
  publication_commands = publication.add_subparsers(
523
469
  dest="publication_command", required=True
524
470
  )
525
- publication_branch = publication_commands.add_parser("validate-branch")
471
+ publication_branch = _leaf(
472
+ publication_commands, "validate-branch", command_publication, project=False
473
+ )
526
474
  publication_branch.add_argument("--branch", required=True)
527
- _add_common(publication_branch, project=False)
528
- publication_branch.set_defaults(handler=command_publication_branch)
529
- publication_commit = publication_commands.add_parser("validate-commit")
475
+ publication_commit = _leaf(
476
+ publication_commands, "validate-commit", command_publication, project=False
477
+ )
530
478
  publication_commit.add_argument("--subject", required=True)
531
- _add_common(publication_commit, project=False)
532
- publication_commit.set_defaults(handler=command_publication_commit)
533
- publication_range = publication_commands.add_parser("validate-range")
534
- _add_common(publication_range)
479
+ publication_range = _leaf(publication_commands, "validate-range", command_publication)
535
480
  publication_range.add_argument("--branch", required=True)
536
481
  publication_range.add_argument("--range", dest="range_spec", required=True)
537
- publication_range.set_defaults(handler=command_publication_range)
538
- publication_pr = publication_commands.add_parser("validate-pr")
482
+ publication_pr = _leaf(
483
+ publication_commands, "validate-pr", command_publication, project=False
484
+ )
539
485
  publication_pr.add_argument("--title", required=True)
540
486
  publication_pr.add_argument("--branch", required=True)
541
487
  publication_pr.add_argument("--state", required=True)
542
488
  publication_pr.add_argument("--body-file", type=Path)
543
- _add_common(publication_pr, project=False)
544
- publication_pr.set_defaults(handler=command_publication_pr)
545
489
  return parser
546
490
 
547
491
 
@@ -10,6 +10,11 @@ from .distribution import schemas_root
10
10
 
11
11
 
12
12
  PROJECT_SCHEMA_VERSION = 5
13
+ PACK_CAPABILITIES = {
14
+ ("desktop-media", 1): set("application-correctness authoritative-input-integrity cross-platform-portability dependency-audit dependency-security incident-recovery independent-security-review key-custody linux-release-security media-pipeline-integrity package-security recovery-integrity recovery-mechanism-integrity release-integrity runtime-delivery-integrity update-integrity workspace-security".split()),
15
+ ("library-cli", 1): set("adoption-integrity compatibility correctness distribution-integrity installability portability runtime-safety".split()),
16
+ ("operations", 1): set("auditability automation-correctness bounded-execution least-privilege policy-integrity recovery target-selection-integrity".split()),
17
+ }
13
18
 
14
19
 
15
20
  def project_path(project_root: Path) -> Path:
@@ -17,7 +22,16 @@ def project_path(project_root: Path) -> Path:
17
22
 
18
23
 
19
24
  def load_project(project_root: Path, process_root: Path) -> dict[str, Any]:
20
- return normalize_project(read_json(project_path(project_root)), process_root)
25
+ project = normalize_project(read_json(project_path(project_root)), process_root)
26
+ path = project_root / ".process" / "readiness.json"
27
+ if not path.is_file():
28
+ return project
29
+ if "readiness" in project:
30
+ raise ProcessError("readiness must use either project.json or readiness.json, not both")
31
+ project["readiness"] = read_json(path)
32
+ validate_document(project, "project", schema_root=schemas_root(process_root), source=str(path))
33
+ readiness_summary(project)
34
+ return project
21
35
 
22
36
 
23
37
  def normalize_project(value: Any, process_root: Path) -> dict[str, Any]:
@@ -103,9 +117,52 @@ def normalize_project(value: Any, process_root: Path) -> dict[str, Any]:
103
117
  schema_root=schemas_root(process_root),
104
118
  source="normalized project configuration",
105
119
  )
120
+ missing = sorted(set(required_profiles(normalized)) - set(normalized["profiles"]))
121
+ if missing:
122
+ raise ProcessError("project requires unknown profiles: " + ", ".join(missing))
123
+ readiness_summary(normalized)
106
124
  return normalized
107
125
 
108
126
 
127
+ def readiness_summary(project: dict[str, Any]) -> dict[str, Any] | None:
128
+ readiness = project.get("readiness")
129
+ if readiness is None:
130
+ return None
131
+ entries = readiness["capabilities"]
132
+ capabilities = {item["id"]: item for item in entries}
133
+ if len(capabilities) != len(entries):
134
+ raise ProcessError("readiness capability ids must be unique")
135
+ packs = [(item["id"], item["version"]) for item in readiness["packs"]]
136
+ unsupported = [f"{name}@{version}" for name, version in packs if (name, version) not in PACK_CAPABILITIES]
137
+ if unsupported:
138
+ raise ProcessError("unsupported readiness pack versions: " + ", ".join(unsupported))
139
+ required = set().union(*(PACK_CAPABILITIES[pack] for pack in packs))
140
+ missing = sorted(required - capabilities.keys())
141
+ if missing:
142
+ raise ProcessError("readiness packs require missing capabilities: " + ", ".join(missing))
143
+ available = set(project["profiles"])
144
+ mandatory_profiles = set(required_profiles(project))
145
+ coverage: dict[str, Any] = {}
146
+ planned: list[str] = []
147
+ for capability_id, item in sorted(capabilities.items()):
148
+ if item["state"] == "planned":
149
+ coverage[capability_id] = {"state": "planned", "gap": item["gap"]}
150
+ planned.append(capability_id)
151
+ continue
152
+ evidence = set(item["evidenceProfiles"])
153
+ unknown = sorted(evidence - available)
154
+ if unknown:
155
+ raise ProcessError(f"readiness capability {capability_id} references unknown profiles: " + ", ".join(unknown))
156
+ if evidence.isdisjoint(mandatory_profiles):
157
+ raise ProcessError(f"readiness capability {capability_id} relies only on optional profiles: " + ", ".join(sorted(evidence)))
158
+ coverage[capability_id] = {"state": "enforced", "evidence": {
159
+ name: [check["id"] for check in project["profiles"][name]] for name in item["evidenceProfiles"]
160
+ }}
161
+ if readiness["stage"] == "production" and planned:
162
+ raise ProcessError("production readiness cannot contain planned capabilities: " + ", ".join(planned))
163
+ return {"target": readiness["target"], "stage": readiness["stage"], "packs": readiness["packs"], "capabilities": coverage, "plannedCapabilities": planned}
164
+
165
+
109
166
  def required_profiles(project: dict[str, Any]) -> tuple[str, ...]:
110
167
  return tuple(project["lifecycle"]["requiredProfiles"])
111
168