engineering-process 2.0.0__tar.gz → 2.0.2__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 (77) hide show
  1. {engineering_process-2.0.0/engineering_process.egg-info → engineering_process-2.0.2}/PKG-INFO +15 -1
  2. {engineering_process-2.0.0 → engineering_process-2.0.2}/README.md +14 -0
  3. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process/__init__.py +1 -1
  4. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process/cli.py +17 -1
  5. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process/commands.py +45 -4
  6. {engineering_process-2.0.0 → engineering_process-2.0.2/engineering_process.egg-info}/PKG-INFO +15 -1
  7. {engineering_process-2.0.0 → engineering_process-2.0.2}/process_assets/skills/change-verify/SKILL.md +5 -0
  8. {engineering_process-2.0.0 → engineering_process-2.0.2}/pyproject.toml +1 -1
  9. {engineering_process-2.0.0 → engineering_process-2.0.2}/schemas/run.schema.json +47 -0
  10. {engineering_process-2.0.0 → engineering_process-2.0.2}/tests/test_automation.py +10 -0
  11. {engineering_process-2.0.0 → engineering_process-2.0.2}/tests/test_cli.py +7 -0
  12. {engineering_process-2.0.0 → engineering_process-2.0.2}/tests/test_commands.py +122 -0
  13. {engineering_process-2.0.0 → engineering_process-2.0.2}/tests/test_contracts.py +56 -0
  14. {engineering_process-2.0.0 → engineering_process-2.0.2}/tests/test_lifecycle.py +43 -0
  15. {engineering_process-2.0.0 → engineering_process-2.0.2}/LICENSE +0 -0
  16. {engineering_process-2.0.0 → engineering_process-2.0.2}/MANIFEST.in +0 -0
  17. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process/__main__.py +0 -0
  18. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process/_supervisor_contract.py +0 -0
  19. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process/_supervisor_posix.py +0 -0
  20. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process/_supervisor_windows.py +0 -0
  21. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process/_windows_job.py +0 -0
  22. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process/adoption.py +0 -0
  23. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process/contracts.py +0 -0
  24. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process/distribution.py +0 -0
  25. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process/helper_launch.py +0 -0
  26. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process/lifecycle.py +0 -0
  27. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process/production_engineering.py +0 -0
  28. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process/project.py +0 -0
  29. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process/publication_compat.py +0 -0
  30. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process/release.py +0 -0
  31. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process/repository.py +0 -0
  32. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process/requirements-dev.txt +0 -0
  33. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process/requirements-runtime.txt +0 -0
  34. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process/skills.py +0 -0
  35. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process/supervision.py +0 -0
  36. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process.egg-info/SOURCES.txt +0 -0
  37. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process.egg-info/dependency_links.txt +0 -0
  38. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process.egg-info/entry_points.txt +0 -0
  39. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process.egg-info/requires.txt +0 -0
  40. {engineering_process-2.0.0 → engineering_process-2.0.2}/engineering_process.egg-info/top_level.txt +0 -0
  41. {engineering_process-2.0.0 → engineering_process-2.0.2}/process-graph.json +0 -0
  42. {engineering_process-2.0.0 → engineering_process-2.0.2}/process_assets/skills/change-complete/SKILL.md +0 -0
  43. {engineering_process-2.0.0 → engineering_process-2.0.2}/process_assets/skills/change-implement/SKILL.md +0 -0
  44. {engineering_process-2.0.0 → engineering_process-2.0.2}/process_assets/skills/change-plan/SKILL.md +0 -0
  45. {engineering_process-2.0.0 → engineering_process-2.0.2}/process_assets/skills/change-review/SKILL.md +0 -0
  46. {engineering_process-2.0.0 → engineering_process-2.0.2}/process_assets/skills/change-start/SKILL.md +0 -0
  47. {engineering_process-2.0.0 → engineering_process-2.0.2}/process_assets/skills/deliver-change/SKILL.md +0 -0
  48. {engineering_process-2.0.0 → engineering_process-2.0.2}/process_assets/skills/process-improve/SKILL.md +0 -0
  49. {engineering_process-2.0.0 → engineering_process-2.0.2}/process_assets/skills/production-engineering/SKILL.md +0 -0
  50. {engineering_process-2.0.0 → engineering_process-2.0.2}/process_assets/skills/production-engineering/invariants.json +0 -0
  51. {engineering_process-2.0.0 → engineering_process-2.0.2}/schemas/change.schema.json +0 -0
  52. {engineering_process-2.0.0 → engineering_process-2.0.2}/schemas/plan.schema.json +0 -0
  53. {engineering_process-2.0.0 → engineering_process-2.0.2}/schemas/process-graph.schema.json +0 -0
  54. {engineering_process-2.0.0 → engineering_process-2.0.2}/schemas/process-lock.schema.json +0 -0
  55. {engineering_process-2.0.0 → engineering_process-2.0.2}/schemas/production-engineering.schema.json +0 -0
  56. {engineering_process-2.0.0 → engineering_process-2.0.2}/schemas/project-legacy.schema.json +0 -0
  57. {engineering_process-2.0.0 → engineering_process-2.0.2}/schemas/project.schema.json +0 -0
  58. {engineering_process-2.0.0 → engineering_process-2.0.2}/schemas/receipt.schema.json +0 -0
  59. {engineering_process-2.0.0 → engineering_process-2.0.2}/schemas/release-change.schema.json +0 -0
  60. {engineering_process-2.0.0 → engineering_process-2.0.2}/schemas/release.schema.json +0 -0
  61. {engineering_process-2.0.0 → engineering_process-2.0.2}/schemas/review.schema.json +0 -0
  62. {engineering_process-2.0.0 → engineering_process-2.0.2}/setup.cfg +0 -0
  63. {engineering_process-2.0.0 → engineering_process-2.0.2}/templates/AGENTS.process.md +0 -0
  64. {engineering_process-2.0.0 → engineering_process-2.0.2}/templates/PULL_REQUEST_TEMPLATE.md +0 -0
  65. {engineering_process-2.0.0 → engineering_process-2.0.2}/templates/adopt-process-windows-job.py +0 -0
  66. {engineering_process-2.0.0 → engineering_process-2.0.2}/templates/adopt-process.py +0 -0
  67. {engineering_process-2.0.0 → engineering_process-2.0.2}/templates/renovate.json +0 -0
  68. {engineering_process-2.0.0 → engineering_process-2.0.2}/tests/test_adoption.py +0 -0
  69. {engineering_process-2.0.0 → engineering_process-2.0.2}/tests/test_architecture.py +0 -0
  70. {engineering_process-2.0.0 → engineering_process-2.0.2}/tests/test_distribution.py +0 -0
  71. {engineering_process-2.0.0 → engineering_process-2.0.2}/tests/test_production_engineering.py +0 -0
  72. {engineering_process-2.0.0 → engineering_process-2.0.2}/tests/test_publication_compat.py +0 -0
  73. {engineering_process-2.0.0 → engineering_process-2.0.2}/tests/test_pypi_cache_horizon.py +0 -0
  74. {engineering_process-2.0.0 → engineering_process-2.0.2}/tests/test_release.py +0 -0
  75. {engineering_process-2.0.0 → engineering_process-2.0.2}/tests/test_repository.py +0 -0
  76. {engineering_process-2.0.0 → engineering_process-2.0.2}/tests/test_skills.py +0 -0
  77. {engineering_process-2.0.0 → engineering_process-2.0.2}/tests/test_supervisor_posix.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engineering-process
3
- Version: 2.0.0
3
+ Version: 2.0.2
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
@@ -114,6 +114,20 @@ Commands are argument arrays, never shell strings. Each command has a finite tim
114
114
  Output has a hard aggregate budget; evidence stores byte counts and hashes, never raw
115
115
  stdout or stderr that could contain secrets.
116
116
 
117
+ A failed profile also reports a safe `diagnostic` descriptor containing only its
118
+ validated profile identifier, check identifier, canonical one-based profile position,
119
+ and a fixed `processctl` argument array. The position remains unambiguous even when a
120
+ compatible project has duplicate check identifiers. Run that array from the project
121
+ root to reproduce only the failed configured check through the same bounded runner,
122
+ for example:
123
+
124
+ processctl verify --profile rust --check-position 3
125
+
126
+ This selective run is diagnostic only: it does not expose captured output, replace a
127
+ required full-profile run, or count as lifecycle verification evidence. Maintainers
128
+ who need semantic tool output must use a tool-owned structured report or run the
129
+ consumer command in an appropriately trusted environment.
130
+
117
131
  ### Production readiness
118
132
 
119
133
  A consumer declares `.process/readiness.json` with production as its direction, its
@@ -90,6 +90,20 @@ Commands are argument arrays, never shell strings. Each command has a finite tim
90
90
  Output has a hard aggregate budget; evidence stores byte counts and hashes, never raw
91
91
  stdout or stderr that could contain secrets.
92
92
 
93
+ A failed profile also reports a safe `diagnostic` descriptor containing only its
94
+ validated profile identifier, check identifier, canonical one-based profile position,
95
+ and a fixed `processctl` argument array. The position remains unambiguous even when a
96
+ compatible project has duplicate check identifiers. Run that array from the project
97
+ root to reproduce only the failed configured check through the same bounded runner,
98
+ for example:
99
+
100
+ processctl verify --profile rust --check-position 3
101
+
102
+ This selective run is diagnostic only: it does not expose captured output, replace a
103
+ required full-profile run, or count as lifecycle verification evidence. Maintainers
104
+ who need semantic tool output must use a tool-owned structured report or run the
105
+ consumer command in an appropriately trusted environment.
106
+
93
107
  ### Production readiness
94
108
 
95
109
  A consumer declares `.process/readiness.json` with production as its direction, its
@@ -1,3 +1,3 @@
1
1
  """Agent-neutral engineering process."""
2
2
 
3
- VERSION = "2.0.0"
3
+ VERSION = "2.0.2"
@@ -61,6 +61,16 @@ def _root(value: str) -> Path:
61
61
  return path
62
62
 
63
63
 
64
+ def _check_position(value: str) -> int:
65
+ try:
66
+ position = int(value)
67
+ except ValueError as error:
68
+ raise argparse.ArgumentTypeError("check position must be an integer") from error
69
+ if position < 1 or position > 32:
70
+ raise argparse.ArgumentTypeError("check position must be between 1 and 32")
71
+ return position
72
+
73
+
64
74
  def _process_root(args: argparse.Namespace) -> Path:
65
75
  return distribution_root(args.process_root)
66
76
 
@@ -209,7 +219,12 @@ def command_verify(args: argparse.Namespace) -> Result:
209
219
  process_root = _process_root(args)
210
220
  project = load_project(args.project_root, process_root)
211
221
  before = repository_snapshot(args.project_root)
212
- report = run_profile(args.project_root, project, args.profile)
222
+ report = run_profile(
223
+ args.project_root,
224
+ project,
225
+ args.profile,
226
+ check_position=args.check_position,
227
+ )
213
228
  after = repository_snapshot(args.project_root)
214
229
  if not same_checkpoint(before, after):
215
230
  report["status"] = "failed"
@@ -433,6 +448,7 @@ def build_parser() -> argparse.ArgumentParser:
433
448
  setup.add_argument("--allow", action="append", default=[])
434
449
  verify = _leaf(commands, "verify", command_verify, help="Run a project verification profile")
435
450
  verify.add_argument("--profile", required=True)
451
+ verify.add_argument("--check-position", type=_check_position)
436
452
  adoption = commands.add_parser("adoption", help="Apply or check managed adoption")
437
453
  adoption_commands = adoption.add_subparsers(dest="adoption_command", required=True)
438
454
  for name, handler in (("apply", command_adoption_apply), ("check", command_adoption_check)):
@@ -199,20 +199,61 @@ def run_profile(
199
199
  project_root: Path,
200
200
  project: dict[str, Any],
201
201
  profile: str,
202
+ *,
203
+ check_position: int | None = None,
202
204
  ) -> dict[str, Any]:
203
- checks = project["profiles"].get(profile)
204
- if checks is None:
205
+ configured_checks = project["profiles"].get(profile)
206
+ if configured_checks is None:
205
207
  raise ProcessError(f"unknown verification profile: {profile}")
208
+ if check_position is None:
209
+ checks = configured_checks
210
+ positions = range(1, len(checks) + 1)
211
+ else:
212
+ if check_position < 1 or check_position > len(configured_checks):
213
+ raise ProcessError(
214
+ f"profile {profile} has no check at position: {check_position}"
215
+ )
216
+ checks = [configured_checks[check_position - 1]]
217
+ positions = [check_position]
206
218
  reports: list[dict[str, Any]] = []
207
- for check in checks:
219
+ failed_position: int | None = None
220
+ for position, check in zip(positions, checks, strict=True):
208
221
  report = run_check(project_root, check)
209
222
  reports.append(report)
210
223
  if report["status"] != "passed":
224
+ failed_position = position
211
225
  break
212
- return {
226
+ result = {
213
227
  "profile": profile,
214
228
  "status": "passed" if len(reports) == len(checks) and all(
215
229
  report["status"] == "passed" for report in reports
216
230
  ) else "failed",
231
+ "scope": (
232
+ {"kind": "profile"}
233
+ if check_position is None
234
+ else {
235
+ "kind": "check",
236
+ "check": checks[0]["id"],
237
+ "position": check_position,
238
+ }
239
+ ),
217
240
  "checks": reports,
218
241
  }
242
+ failed = next((report for report in reports if report["status"] == "failed"), None)
243
+ if failed is not None:
244
+ assert failed_position is not None
245
+ result["diagnostic"] = {
246
+ "kind": "selective-check-reproduction",
247
+ "profile": profile,
248
+ "check": failed["id"],
249
+ "position": failed_position,
250
+ "command": [
251
+ "processctl",
252
+ "verify",
253
+ "--profile",
254
+ profile,
255
+ "--check-position",
256
+ str(failed_position),
257
+ ],
258
+ }
259
+ return result
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engineering-process
3
- Version: 2.0.0
3
+ Version: 2.0.2
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
@@ -114,6 +114,20 @@ Commands are argument arrays, never shell strings. Each command has a finite tim
114
114
  Output has a hard aggregate budget; evidence stores byte counts and hashes, never raw
115
115
  stdout or stderr that could contain secrets.
116
116
 
117
+ A failed profile also reports a safe `diagnostic` descriptor containing only its
118
+ validated profile identifier, check identifier, canonical one-based profile position,
119
+ and a fixed `processctl` argument array. The position remains unambiguous even when a
120
+ compatible project has duplicate check identifiers. Run that array from the project
121
+ root to reproduce only the failed configured check through the same bounded runner,
122
+ for example:
123
+
124
+ processctl verify --profile rust --check-position 3
125
+
126
+ This selective run is diagnostic only: it does not expose captured output, replace a
127
+ required full-profile run, or count as lifecycle verification evidence. Maintainers
128
+ who need semantic tool output must use a tool-owned structured report or run the
129
+ consumer command in an appropriately trusted environment.
130
+
117
131
  ### Production readiness
118
132
 
119
133
  A consumer declares `.process/readiness.json` with production as its direction, its
@@ -16,6 +16,11 @@ or stream failure, failed descendant cleanup, or tracked repository mutation is
16
16
  failure and leaves the change in implementing. Successfully cleaned post-exit
17
17
  descendants remain recorded without replacing the foreground command result.
18
18
 
19
+ A failed report may include a safe selective-reproduction argument array. It can
20
+ confirm the isolated failure through the same bounded runner, but it remains a
21
+ diagnostic action: never submit it in place of the required full profile, and do not
22
+ expect it to reveal captured stdout or stderr.
23
+
19
24
  The contract must already include conditional profiles required by affected enforced
20
25
  capabilities. Run those profiles exactly; do not run every planned production gate for
21
26
  an unrelated change, and do not treat a passing baseline profile as evidence for a
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "engineering-process"
7
- version = "2.0.0"
7
+ version = "2.0.2"
8
8
  description = "A small agent-neutral engineering lifecycle with managed adoption"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -147,6 +147,51 @@
147
147
  "stderr": {"$ref": "#/$defs/stream"}
148
148
  }
149
149
  },
150
+ "verificationScope": {
151
+ "oneOf": [
152
+ {
153
+ "type": "object",
154
+ "additionalProperties": false,
155
+ "required": ["kind"],
156
+ "properties": {"kind": {"const": "profile"}}
157
+ },
158
+ {
159
+ "type": "object",
160
+ "additionalProperties": false,
161
+ "required": ["kind", "check", "position"],
162
+ "properties": {
163
+ "kind": {"const": "check"},
164
+ "check": {"$ref": "#/$defs/id"},
165
+ "position": {"type": "integer", "minimum": 1, "maximum": 32}
166
+ }
167
+ }
168
+ ]
169
+ },
170
+ "diagnostic": {
171
+ "type": "object",
172
+ "additionalProperties": false,
173
+ "required": ["kind", "profile", "check", "position", "command"],
174
+ "properties": {
175
+ "kind": {"const": "selective-check-reproduction"},
176
+ "profile": {"$ref": "#/$defs/id"},
177
+ "check": {"$ref": "#/$defs/id"},
178
+ "position": {"type": "integer", "minimum": 1, "maximum": 32},
179
+ "command": {
180
+ "type": "array",
181
+ "minItems": 6,
182
+ "maxItems": 6,
183
+ "prefixItems": [
184
+ {"const": "processctl"},
185
+ {"const": "verify"},
186
+ {"const": "--profile"},
187
+ {"$ref": "#/$defs/id"},
188
+ {"const": "--check-position"},
189
+ {"type": "string", "pattern": "^(?:[1-9]|[12][0-9]|3[0-2])$"}
190
+ ],
191
+ "items": false
192
+ }
193
+ }
194
+ },
150
195
  "verificationReport": {
151
196
  "type": "object",
152
197
  "additionalProperties": false,
@@ -154,6 +199,8 @@
154
199
  "properties": {
155
200
  "profile": {"$ref": "#/$defs/id"},
156
201
  "status": {"enum": ["passed", "failed"]},
202
+ "scope": {"$ref": "#/$defs/verificationScope"},
203
+ "diagnostic": {"$ref": "#/$defs/diagnostic"},
157
204
  "checks": {
158
205
  "type": "array",
159
206
  "maxItems": 32,
@@ -269,6 +269,16 @@ class AutomationTests(unittest.TestCase):
269
269
  encoding="utf-8"
270
270
  )
271
271
  self.assertTrue(workflow.startswith("name: CI\n"))
272
+ events = workflow.split("on:\n", maxsplit=1)[1].split(
273
+ "\npermissions:\n", maxsplit=1
274
+ )[0]
275
+ self.assertEqual(
276
+ " pull_request:\n"
277
+ " types: [opened, synchronize, reopened, ready_for_review]\n"
278
+ " push:\n"
279
+ " branches: [main]\n",
280
+ events,
281
+ )
272
282
  policy_job = " policy-verification:\n" + workflow.split(
273
283
  " policy-verification:\n", maxsplit=1
274
284
  )[1].split("\n adopted-process:\n", maxsplit=1)[0]
@@ -38,6 +38,13 @@ class CliTests(unittest.TestCase):
38
38
  set(subparsers.choices),
39
39
  )
40
40
 
41
+ def test_verify_accepts_an_explicit_diagnostic_check(self) -> None:
42
+ args = build_parser().parse_args(
43
+ ["verify", "--profile", "rust", "--check-position", "2"]
44
+ )
45
+ self.assertEqual("rust", args.profile)
46
+ self.assertEqual(2, args.check_position)
47
+
41
48
  def test_skills_validate_emits_machine_readable_result(self) -> None:
42
49
  output = io.StringIO()
43
50
  with contextlib.redirect_stdout(output):
@@ -12,6 +12,7 @@ import unittest
12
12
  from unittest.mock import Mock, patch
13
13
 
14
14
  from engineering_process.commands import run_check, run_profile
15
+ from engineering_process.contracts import ProcessError
15
16
  from engineering_process.supervision import CleanupOutcome
16
17
 
17
18
 
@@ -82,6 +83,127 @@ class CommandTests(unittest.TestCase):
82
83
  report = run_profile(Path(directory), project, "development")
83
84
  self.assertEqual("failed", report["status"])
84
85
  self.assertEqual(["fail"], [item["id"] for item in report["checks"]])
86
+ self.assertEqual({"kind": "profile"}, report["scope"])
87
+ self.assertEqual(
88
+ {
89
+ "kind": "selective-check-reproduction",
90
+ "profile": "development",
91
+ "check": "fail",
92
+ "position": 1,
93
+ "command": [
94
+ "processctl",
95
+ "verify",
96
+ "--profile",
97
+ "development",
98
+ "--check-position",
99
+ "1",
100
+ ],
101
+ },
102
+ report["diagnostic"],
103
+ )
104
+
105
+ def test_profile_failure_descriptor_excludes_child_details(self) -> None:
106
+ secret = "TOPSECRET-DIAGNOSTIC-VALUE"
107
+ project = {
108
+ "profiles": {
109
+ "rust": [
110
+ {
111
+ "id": "format",
112
+ "run": [sys.executable, "-c", "raise SystemExit(0)"],
113
+ "timeoutSeconds": 10,
114
+ },
115
+ {
116
+ "id": "rust-tests",
117
+ "run": [
118
+ sys.executable,
119
+ "-c",
120
+ f"import sys; print({secret!r}); print({secret!r}, file=sys.stderr); raise SystemExit(101)",
121
+ ],
122
+ "timeoutSeconds": 10,
123
+ },
124
+ ]
125
+ }
126
+ }
127
+ with tempfile.TemporaryDirectory(prefix="secret-working-directory-") as directory:
128
+ report = run_profile(Path(directory), project, "rust")
129
+ self.assertEqual(["format", "rust-tests"], [item["id"] for item in report["checks"]])
130
+ self.assertEqual(101, report["checks"][-1]["exitCode"])
131
+ self.assertEqual("rust-tests", report["diagnostic"]["check"])
132
+ self.assertEqual(2, report["diagnostic"]["position"])
133
+ rendered = repr(report)
134
+ self.assertNotIn(secret, rendered)
135
+ self.assertNotIn(sys.executable, rendered)
136
+ self.assertNotIn("secret-working-directory", rendered)
137
+
138
+ def test_selective_reproduction_runs_only_the_named_check(self) -> None:
139
+ project = {
140
+ "profiles": {
141
+ "rust": [
142
+ {
143
+ "id": "would-fail",
144
+ "run": [sys.executable, "-c", "raise SystemExit(7)"],
145
+ "timeoutSeconds": 10,
146
+ },
147
+ {
148
+ "id": "rust-tests",
149
+ "run": [sys.executable, "-c", "raise SystemExit(0)"],
150
+ "timeoutSeconds": 10,
151
+ },
152
+ ]
153
+ }
154
+ }
155
+ with tempfile.TemporaryDirectory() as directory:
156
+ report = run_profile(Path(directory), project, "rust", check_position=2)
157
+ self.assertEqual("passed", report["status"])
158
+ self.assertEqual(
159
+ {"kind": "check", "check": "rust-tests", "position": 2},
160
+ report["scope"],
161
+ )
162
+ self.assertEqual(["rust-tests"], [item["id"] for item in report["checks"]])
163
+ self.assertNotIn("diagnostic", report)
164
+
165
+ def test_duplicate_ids_reproduce_one_authoritative_position(self) -> None:
166
+ project = {
167
+ "profiles": {
168
+ "rust": [
169
+ {
170
+ "id": "rust-tests",
171
+ "run": [sys.executable, "-c", "raise SystemExit(0)"],
172
+ "timeoutSeconds": 10,
173
+ },
174
+ {
175
+ "id": "rust-tests",
176
+ "run": [sys.executable, "-c", "raise SystemExit(101)"],
177
+ "timeoutSeconds": 10,
178
+ }
179
+ ]
180
+ }
181
+ }
182
+ with tempfile.TemporaryDirectory() as directory:
183
+ report = run_profile(Path(directory), project, "rust")
184
+ reproduced = run_profile(
185
+ Path(directory), project, "rust", check_position=2
186
+ )
187
+ self.assertEqual(2, report["diagnostic"]["position"])
188
+ self.assertEqual(["rust-tests"], [item["id"] for item in reproduced["checks"]])
189
+ self.assertEqual(101, reproduced["checks"][0]["exitCode"])
190
+
191
+ def test_selective_reproduction_rejects_unknown_position(self) -> None:
192
+ project = {
193
+ "profiles": {
194
+ "rust": [
195
+ {
196
+ "id": "rust-tests",
197
+ "run": [sys.executable, "-c", "raise SystemExit(0)"],
198
+ "timeoutSeconds": 10,
199
+ }
200
+ ]
201
+ }
202
+ }
203
+ with tempfile.TemporaryDirectory() as directory, self.assertRaisesRegex(
204
+ ProcessError, "profile rust has no check at position: 2"
205
+ ):
206
+ run_profile(Path(directory), project, "rust", check_position=2)
85
207
 
86
208
  def test_timeout_is_a_failure(self) -> None:
87
209
  with tempfile.TemporaryDirectory() as directory:
@@ -81,6 +81,62 @@ class ContractTests(unittest.TestCase):
81
81
  with self.assertRaisesRegex(ProcessError, "Additional properties"):
82
82
  validate_document(project, "project", schema_root=SCHEMAS)
83
83
 
84
+ def test_run_schema_accepts_legacy_and_safe_diagnostic_reports(self) -> None:
85
+ schema = read_json(SCHEMAS / "run.schema.json")
86
+ validator = Draft202012Validator(schema).evolve(
87
+ schema=schema["$defs"]["verificationReport"]
88
+ )
89
+ report = {
90
+ "profile": "rust",
91
+ "status": "failed",
92
+ "checks": [
93
+ {
94
+ "id": "rust-tests",
95
+ "status": "failed",
96
+ "exitCode": 101,
97
+ "timedOut": False,
98
+ "outputExceeded": False,
99
+ "descendantsTerminated": False,
100
+ "streamFailed": False,
101
+ "durationMs": 1,
102
+ "stdout": {
103
+ "bytes": 0,
104
+ "sha256": f"sha256:{'0' * 64}",
105
+ "truncated": False,
106
+ },
107
+ "stderr": {
108
+ "bytes": 0,
109
+ "sha256": f"sha256:{'0' * 64}",
110
+ "truncated": False,
111
+ },
112
+ }
113
+ ],
114
+ "checkpoint": {
115
+ "head": "0" * 40,
116
+ "fingerprint": f"sha256:{'0' * 64}",
117
+ "fileCount": 1,
118
+ "byteCount": 1,
119
+ },
120
+ "recordedAt": "2026-09-07T00:00:00+00:00",
121
+ }
122
+ validator.validate(report)
123
+ report["scope"] = {"kind": "profile"}
124
+ report["diagnostic"] = {
125
+ "kind": "selective-check-reproduction",
126
+ "profile": "rust",
127
+ "check": "rust-tests",
128
+ "position": 1,
129
+ "command": [
130
+ "processctl",
131
+ "verify",
132
+ "--profile",
133
+ "rust",
134
+ "--check-position",
135
+ "1",
136
+ ],
137
+ }
138
+ validator.validate(report)
139
+
84
140
  def test_review_v6_requires_durable_non_blocking_dispositions(self) -> None:
85
141
  review = {
86
142
  "schemaVersion": 6,
@@ -256,6 +256,49 @@ class LifecycleTests(unittest.TestCase):
256
256
  )
257
257
  self.assertIsNone(lifecycle_status(self.root, PROCESS_ROOT, "sample-change")["nextCommand"])
258
258
 
259
+ def test_failed_profile_persists_only_safe_diagnostic_metadata(self) -> None:
260
+ self.begin()
261
+ secret = "TOPSECRET-LIFECYCLE-VALUE"
262
+ self.project["profiles"]["development"] = [
263
+ {
264
+ "id": "format",
265
+ "run": [sys.executable, "-c", "raise SystemExit(0)"],
266
+ "timeoutSeconds": 10,
267
+ },
268
+ {
269
+ "id": "rust-tests",
270
+ "run": [
271
+ sys.executable,
272
+ "-c",
273
+ f"import sys; print({secret!r}, file=sys.stderr); raise SystemExit(101)",
274
+ ],
275
+ "timeoutSeconds": 10,
276
+ },
277
+ ]
278
+ state, report = verify_change(
279
+ self.root, PROCESS_ROOT, self.project, "sample-change", "development"
280
+ )
281
+ self.assertEqual("implementing", state["phase"])
282
+ self.assertEqual({"kind": "profile"}, report["scope"])
283
+ self.assertEqual("rust-tests", report["diagnostic"]["check"])
284
+ persisted = (
285
+ self.root / ".process" / "runs" / "sample-change" / "run.json"
286
+ ).read_text(encoding="utf-8")
287
+ self.assertNotIn(secret, persisted)
288
+ self.assertNotIn(sys.executable, persisted)
289
+
290
+ def test_existing_run_without_diagnostic_fields_remains_readable(self) -> None:
291
+ self.begin()
292
+ verify_change(
293
+ self.root, PROCESS_ROOT, self.project, "sample-change", "development"
294
+ )
295
+ run_path = self.root / ".process" / "runs" / "sample-change" / "run.json"
296
+ run = json.loads(run_path.read_text(encoding="utf-8"))
297
+ run["verification"]["development"].pop("scope")
298
+ write_json(run_path, run)
299
+ state = lifecycle_status(self.root, PROCESS_ROOT, "sample-change")
300
+ self.assertEqual("implementing", state["phase"])
301
+
259
302
  def test_plan_registration_requires_every_canonical_invariant(self) -> None:
260
303
  start_change(
261
304
  self.root,