engineering-process 2.3.0__tar.gz → 2.3.1__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 (101) hide show
  1. {engineering_process-2.3.0/engineering_process.egg-info → engineering_process-2.3.1}/PKG-INFO +32 -6
  2. {engineering_process-2.3.0 → engineering_process-2.3.1}/README.md +31 -5
  3. engineering_process-2.3.1/RELEASE_NOTES.md +18 -0
  4. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/__init__.py +1 -1
  5. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/cli.py +5 -1
  6. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/contracts.py +14 -3
  7. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/lifecycle.py +73 -3
  8. engineering_process-2.3.1/engineering_process/review_contexts.py +291 -0
  9. {engineering_process-2.3.0 → engineering_process-2.3.1/engineering_process.egg-info}/PKG-INFO +32 -6
  10. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process.egg-info/SOURCES.txt +3 -1
  11. {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/skills/change-review/SKILL.md +31 -2
  12. {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/skills/deliver-change/SKILL.md +3 -0
  13. {engineering_process-2.3.0 → engineering_process-2.3.1}/pyproject.toml +1 -1
  14. {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/run.schema.json +28 -1
  15. {engineering_process-2.3.0 → engineering_process-2.3.1}/templates/adopt-process.py +10 -0
  16. {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_architecture.py +1 -0
  17. {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_automation.py +5 -15
  18. {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_cli.py +1 -0
  19. engineering_process-2.3.1/tests/test_pypi_cache.py +184 -0
  20. engineering_process-2.3.1/tests/test_review_contexts.py +507 -0
  21. engineering_process-2.3.0/RELEASE_NOTES.md +0 -23
  22. engineering_process-2.3.0/tests/test_pypi_cache_horizon.py +0 -81
  23. {engineering_process-2.3.0 → engineering_process-2.3.1}/LICENSE +0 -0
  24. {engineering_process-2.3.0 → engineering_process-2.3.1}/MANIFEST.in +0 -0
  25. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/__main__.py +0 -0
  26. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/_supervisor_contract.py +0 -0
  27. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/_supervisor_posix.py +0 -0
  28. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/_supervisor_windows.py +0 -0
  29. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/_windows_job.py +0 -0
  30. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/adoption.py +0 -0
  31. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/artifact_standards.py +0 -0
  32. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/automation_name.py +0 -0
  33. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/commands.py +0 -0
  34. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/distribution.py +0 -0
  35. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/helper_launch.py +0 -0
  36. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/issue.py +0 -0
  37. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/pr_description.py +0 -0
  38. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/production_engineering.py +0 -0
  39. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/project.py +0 -0
  40. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/publication_compat.py +0 -0
  41. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/release.py +0 -0
  42. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/release_notes.py +0 -0
  43. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/repository.py +0 -0
  44. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/requirements-dev.txt +0 -0
  45. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/requirements-runtime.txt +0 -0
  46. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/skills.py +0 -0
  47. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/source_publication.py +0 -0
  48. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process/supervision.py +0 -0
  49. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process.egg-info/dependency_links.txt +0 -0
  50. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process.egg-info/entry_points.txt +0 -0
  51. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process.egg-info/requires.txt +0 -0
  52. {engineering_process-2.3.0 → engineering_process-2.3.1}/engineering_process.egg-info/top_level.txt +0 -0
  53. {engineering_process-2.3.0 → engineering_process-2.3.1}/process-graph.json +0 -0
  54. {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/skills/change-complete/SKILL.md +0 -0
  55. {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/skills/change-implement/SKILL.md +0 -0
  56. {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/skills/change-plan/SKILL.md +0 -0
  57. {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/skills/change-start/SKILL.md +0 -0
  58. {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/skills/change-verify/SKILL.md +0 -0
  59. {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/skills/process-improve/SKILL.md +0 -0
  60. {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/skills/production-engineering/SKILL.md +0 -0
  61. {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/skills/production-engineering/invariants.json +0 -0
  62. {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/standards/automation-name.v1.json +0 -0
  63. {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/standards/issue.v1.json +0 -0
  64. {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/standards/pull-request.v1.json +0 -0
  65. {engineering_process-2.3.0 → engineering_process-2.3.1}/process_assets/standards/release-notes.v1.json +0 -0
  66. {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/artifact-selection.schema.json +0 -0
  67. {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/artifact-standard.schema.json +0 -0
  68. {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/automation-name-data.schema.json +0 -0
  69. {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/change.schema.json +0 -0
  70. {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/issue-data.schema.json +0 -0
  71. {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/plan.schema.json +0 -0
  72. {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/pr-description-data.schema.json +0 -0
  73. {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/process-graph.schema.json +0 -0
  74. {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/process-lock.schema.json +0 -0
  75. {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/production-engineering.schema.json +0 -0
  76. {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/project-legacy.schema.json +0 -0
  77. {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/project.schema.json +0 -0
  78. {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/receipt.schema.json +0 -0
  79. {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/release-change.schema.json +0 -0
  80. {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/release-notes-data.schema.json +0 -0
  81. {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/release.schema.json +0 -0
  82. {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/renovate-preset.schema.json +0 -0
  83. {engineering_process-2.3.0 → engineering_process-2.3.1}/schemas/review.schema.json +0 -0
  84. {engineering_process-2.3.0 → engineering_process-2.3.1}/setup.cfg +0 -0
  85. {engineering_process-2.3.0 → engineering_process-2.3.1}/templates/AGENTS.process.md +0 -0
  86. {engineering_process-2.3.0 → engineering_process-2.3.1}/templates/PULL_REQUEST_TEMPLATE.md +0 -0
  87. {engineering_process-2.3.0 → engineering_process-2.3.1}/templates/adopt-process-windows-job.py +0 -0
  88. {engineering_process-2.3.0 → engineering_process-2.3.1}/templates/renovate.json +0 -0
  89. {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_adoption.py +0 -0
  90. {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_artifact_standards.py +0 -0
  91. {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_commands.py +0 -0
  92. {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_contracts.py +0 -0
  93. {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_distribution.py +0 -0
  94. {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_lifecycle.py +0 -0
  95. {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_production_engineering.py +0 -0
  96. {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_publication_compat.py +0 -0
  97. {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_publication_workflow.py +0 -0
  98. {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_release.py +0 -0
  99. {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_repository.py +0 -0
  100. {engineering_process-2.3.0 → engineering_process-2.3.1}/tests/test_skills.py +0 -0
  101. {engineering_process-2.3.0 → engineering_process-2.3.1}/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.3.0
3
+ Version: 2.3.1
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
@@ -64,19 +64,35 @@ under .process/receipts. Both paths are ignored by Git. Verification evidence is
64
64
  to HEAD plus a fingerprint of tracked and non-ignored untracked files. Any relevant
65
65
  mutation invalidates it.
66
66
 
67
- Independent review is one direct rule: neither the reviewer actor nor reviewer context
68
- may have implemented the current cycle. There is no attestation hierarchy,
67
+ Neither the reviewer actor nor reviewer context may have implemented the current
68
+ cycle. An agent reviewer context also cannot be recorded in another accepted change
69
+ in this repository or its registered worktrees. There is no attestation hierarchy,
69
70
  recommendation chain, authority-transition protocol, remote-evidence federation, or
70
71
  second handwritten validator.
71
72
 
72
- The coordinator hands review to an actual reviewer. A new agent review starts in a
73
- fresh context with the accepted source artifacts and no suggested verdict; the same
74
- reviewer continues corrections. The reviewer inspects the change and authors its
73
+ The coordinator hands each new change to a newly spawned reviewer, with the accepted
74
+ source artifacts and no inherited implementation or other-change review conversation.
75
+ The same reviewer continues only that change's corrections. The reviewer inspects the change and authors its
75
76
  own report. The runner's existing task/session interaction and returned result make
76
77
  that work inspectable. Sharing a provider or account is allowed; this is a
77
78
  workflow for independent judgment, not authenticated identity or merge enforcement.
78
79
  See [change-review](process_assets/skills/change-review/SKILL.md) for the handoff.
79
80
 
81
+ Start, submission and finish check schema-valid canonical run history with bounded
82
+ worktree/file reads. A short OS lock in common Git metadata serializes canonical
83
+ history writes and review checks; profiles run outside it and reload current state
84
+ before recording results. The lock stores no identity registry. The check rejects known context reuse,
85
+ but cannot establish native conversation freshness or inspect removed history,
86
+ other clones or other repositories. Keep native creation, non-inherited dispatch
87
+ and effective settings evidence in the existing handoff.
88
+
89
+ `processctl change review replace-reused` repairs only an initial pending assignment
90
+ proven to reuse another change's agent context, before a submitted review or normal
91
+ report file exists. It validates a fresh replacement and current verification,
92
+ preserves the prior assignment and conflict in canonical history, and leaves cycle
93
+ and evidence unchanged. Valid assignments and reviewed correction cycles cannot use
94
+ this operation. It does not provide an unrestricted reviewer reset.
95
+
80
96
  Every delegated agent and reviewer follows the active user-selected model and
81
97
  reasoning effort under [deliver-change's settings rule](process_assets/skills/deliver-change/SKILL.md#preserve-agent-execution-settings).
82
98
  It covers fresh agents and resumed sessions, requires native runtime confirmation,
@@ -585,6 +601,16 @@ package rule keeps the adoption pull request in draft and runs exactly:
585
601
 
586
602
  python .process/adopt-process.py --project-root . --requirements-lock requirements/process.txt
587
603
 
604
+ The bootstrap refreshes `engineering-process` index metadata using pip's
605
+ `--refresh-package` option, with `--no-cache-dir` for older pip. Exact pins, hashes,
606
+ isolated installation and bounded failures still apply. Consumer CI that installs
607
+ the lock directly must also request fresh metadata: use
608
+ `--refresh-package engineering-process` with pip 26.2 or newer, or the portable
609
+ `--no-cache-dir` option. Keep this setting on the process installation command;
610
+ other dependency caches do not need to be disabled. For non-isolated pip-compile,
611
+ the equivalent `PIP_REFRESH_PACKAGE=engineering-process` environment setting keeps
612
+ fresh release lookup compatible with older pip, which revalidated by default.
613
+
588
614
  postUpgradeTasks.fileFilters includes the managed paths, so Renovate commits the new
589
615
  hash lock and the fully materialized process in the same pull request. A self-hosted
590
616
  Renovate administrator must allow only this anchored command and must keep shell
@@ -40,19 +40,35 @@ under .process/receipts. Both paths are ignored by Git. Verification evidence is
40
40
  to HEAD plus a fingerprint of tracked and non-ignored untracked files. Any relevant
41
41
  mutation invalidates it.
42
42
 
43
- Independent review is one direct rule: neither the reviewer actor nor reviewer context
44
- may have implemented the current cycle. There is no attestation hierarchy,
43
+ Neither the reviewer actor nor reviewer context may have implemented the current
44
+ cycle. An agent reviewer context also cannot be recorded in another accepted change
45
+ in this repository or its registered worktrees. There is no attestation hierarchy,
45
46
  recommendation chain, authority-transition protocol, remote-evidence federation, or
46
47
  second handwritten validator.
47
48
 
48
- The coordinator hands review to an actual reviewer. A new agent review starts in a
49
- fresh context with the accepted source artifacts and no suggested verdict; the same
50
- reviewer continues corrections. The reviewer inspects the change and authors its
49
+ The coordinator hands each new change to a newly spawned reviewer, with the accepted
50
+ source artifacts and no inherited implementation or other-change review conversation.
51
+ The same reviewer continues only that change's corrections. The reviewer inspects the change and authors its
51
52
  own report. The runner's existing task/session interaction and returned result make
52
53
  that work inspectable. Sharing a provider or account is allowed; this is a
53
54
  workflow for independent judgment, not authenticated identity or merge enforcement.
54
55
  See [change-review](process_assets/skills/change-review/SKILL.md) for the handoff.
55
56
 
57
+ Start, submission and finish check schema-valid canonical run history with bounded
58
+ worktree/file reads. A short OS lock in common Git metadata serializes canonical
59
+ history writes and review checks; profiles run outside it and reload current state
60
+ before recording results. The lock stores no identity registry. The check rejects known context reuse,
61
+ but cannot establish native conversation freshness or inspect removed history,
62
+ other clones or other repositories. Keep native creation, non-inherited dispatch
63
+ and effective settings evidence in the existing handoff.
64
+
65
+ `processctl change review replace-reused` repairs only an initial pending assignment
66
+ proven to reuse another change's agent context, before a submitted review or normal
67
+ report file exists. It validates a fresh replacement and current verification,
68
+ preserves the prior assignment and conflict in canonical history, and leaves cycle
69
+ and evidence unchanged. Valid assignments and reviewed correction cycles cannot use
70
+ this operation. It does not provide an unrestricted reviewer reset.
71
+
56
72
  Every delegated agent and reviewer follows the active user-selected model and
57
73
  reasoning effort under [deliver-change's settings rule](process_assets/skills/deliver-change/SKILL.md#preserve-agent-execution-settings).
58
74
  It covers fresh agents and resumed sessions, requires native runtime confirmation,
@@ -561,6 +577,16 @@ package rule keeps the adoption pull request in draft and runs exactly:
561
577
 
562
578
  python .process/adopt-process.py --project-root . --requirements-lock requirements/process.txt
563
579
 
580
+ The bootstrap refreshes `engineering-process` index metadata using pip's
581
+ `--refresh-package` option, with `--no-cache-dir` for older pip. Exact pins, hashes,
582
+ isolated installation and bounded failures still apply. Consumer CI that installs
583
+ the lock directly must also request fresh metadata: use
584
+ `--refresh-package engineering-process` with pip 26.2 or newer, or the portable
585
+ `--no-cache-dir` option. Keep this setting on the process installation command;
586
+ other dependency caches do not need to be disabled. For non-isolated pip-compile,
587
+ the equivalent `PIP_REFRESH_PACKAGE=engineering-process` environment setting keeps
588
+ fresh release lookup compatible with older pip, which revalidated by default.
589
+
564
590
  postUpgradeTasks.fileFilters includes the managed paths, so Renovate commits the new
565
591
  hash lock and the fully materialized process in the same pull request. A self-hosted
566
592
  Renovate administrator must allow only this anchored command and must keep shell
@@ -0,0 +1,18 @@
1
+ # Engineering Process v2.3.1
2
+
3
+ Changes since v2.3.0.
4
+
5
+ ## Fixes
6
+
7
+ - Refresh package metadata when installing the hash\-locked process so newly published releases remain visible through warm pip caches\; remove the publication\-time cache expiry wait\. Consumer CI using pip 26\.2 or newer should pass \-\-refresh\-package engineering\-process\, or use \-\-no\-cache\-dir with older pip\. ([#134](https://github.com/phuongnse/engineering-process/issues/134))
8
+ - Reject recorded agent reviewer\-context reuse across changes in the same repository and its worktrees\. Preserve same\-change correction reviews\, and provide an audited replacement only for a reused initial assignment with no submitted review\. Native fresh\-session and unchanged model\/effort evidence remain required from the host\. ([#182](https://github.com/phuongnse/engineering-process/issues/182))
9
+
10
+ ## Upgrade and compatibility
11
+
12
+ Merge the complete hash-locked package/adoption PR, update the local and CI environments to the selected version, and start a fresh agent session.
13
+
14
+ Consumer CI, naming conventions and branch-protection settings remain consumer-owned; adoption does not configure them automatically.
15
+
16
+ See [versioning and compatibility](https://github.com/phuongnse/engineering-process/blob/v2.3.1/VERSIONING.md) and [adoption guidance](https://github.com/phuongnse/engineering-process/blob/v2.3.1/SELF_HOSTING.md).
17
+
18
+ [Full change comparison](https://github.com/phuongnse/engineering-process/compare/v2.3.0...v2.3.1)
@@ -1,3 +1,3 @@
1
1
  """Agent-neutral engineering process."""
2
2
 
3
- VERSION = "2.3.0"
3
+ VERSION = "2.3.1"
@@ -330,9 +330,10 @@ def command_change_review_start(args: argparse.Namespace) -> Result:
330
330
  actor_id=args.actor,
331
331
  context_id=args.context,
332
332
  kind=args.actor_kind,
333
+ replace_reused=args.review_command == "replace-reused",
333
334
  )
334
335
  return _state_result(
335
- "change review start",
336
+ f"change review {args.review_command}",
336
337
  state,
337
338
  assignment=state["reviewAssignment"],
338
339
  processSignals=process_improvement_signals(state),
@@ -542,6 +543,9 @@ def build_parser() -> argparse.ArgumentParser:
542
543
  review_start = _leaf(review_commands, "start", command_change_review_start)
543
544
  _add_actor(review_start)
544
545
  review_start.add_argument("--change-id", required=True)
546
+ review_replace = _leaf(review_commands, "replace-reused", command_change_review_start)
547
+ _add_actor(review_replace)
548
+ review_replace.add_argument("--change-id", required=True)
545
549
  review_submit = _leaf(review_commands, "submit", command_change_review_submit)
546
550
  review_submit.add_argument("--change-id", required=True)
547
551
  review_submit.add_argument("--review", type=Path, required=True)
@@ -51,9 +51,20 @@ def read_json(path: Path, *, maximum_bytes: int = MAX_JSON_BYTES) -> Any:
51
51
  if size > maximum_bytes:
52
52
  raise ProcessError(f"{path} exceeds {maximum_bytes} bytes")
53
53
  try:
54
- return json.loads(path.read_text(encoding="utf-8"))
55
- except (OSError, UnicodeError, json.JSONDecodeError) as error:
56
- raise ProcessError(f"{path} is not valid UTF-8 JSON: {error}") from error
54
+ with path.open("rb") as stream:
55
+ content = stream.read(maximum_bytes + 1)
56
+ except OSError as error:
57
+ raise ProcessError(f"cannot read {path}: {error}") from error
58
+ if len(content) > maximum_bytes:
59
+ raise ProcessError(f"{path} exceeds {maximum_bytes} bytes")
60
+ return parse_json_bytes(content, source=str(path))
61
+
62
+
63
+ def parse_json_bytes(content: bytes, *, source: str) -> Any:
64
+ try:
65
+ return json.loads(content.decode("utf-8"))
66
+ except (UnicodeError, json.JSONDecodeError) as error:
67
+ raise ProcessError(f"{source} is not valid UTF-8 JSON: {error}") from error
57
68
 
58
69
 
59
70
  def canonical_bytes(value: Any) -> bytes:
@@ -37,6 +37,11 @@ from .production_engineering import (
37
37
  )
38
38
  from .source_publication import branch_issues, current_branch, validate_current_source
39
39
  from .repository import repository_snapshot, resolve_commit, same_checkpoint
40
+ from .review_contexts import (
41
+ recorded_context_conflict,
42
+ require_unreused_context,
43
+ history_transaction,
44
+ )
40
45
 
41
46
 
42
47
  NEXT_COMMAND = {
@@ -134,6 +139,7 @@ def _require_phase(state: dict[str, Any], *phases: str) -> None:
134
139
  )
135
140
 
136
141
 
142
+ @history_transaction
137
143
  def start_change(
138
144
  project_root: Path,
139
145
  process_root: Path,
@@ -215,6 +221,7 @@ def start_change(
215
221
  return state
216
222
 
217
223
 
224
+ @history_transaction
218
225
  def register_plan(
219
226
  project_root: Path,
220
227
  process_root: Path,
@@ -251,6 +258,7 @@ def register_plan(
251
258
  return state
252
259
 
253
260
 
261
+ @history_transaction
254
262
  def begin_implementation(
255
263
  project_root: Path,
256
264
  process_root: Path,
@@ -322,13 +330,34 @@ def verify_change(
322
330
  raise ProcessError(f"profile {profile} is not required by change {change_id}")
323
331
 
324
332
  before = repository_snapshot(project_root)
333
+ report = run_profile(project_root, project, profile)
334
+ return _record_verification(
335
+ project_root, process_root, change_id, state["cycle"], before, report
336
+ )
337
+
338
+
339
+ @history_transaction
340
+ def _record_verification(
341
+ project_root: Path,
342
+ process_root: Path,
343
+ change_id: str,
344
+ cycle: int,
345
+ before: dict[str, Any],
346
+ report: dict[str, Any],
347
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
348
+ # Profiles run outside the lock. Reload so their publication cannot erase
349
+ # participants or evidence recorded by another command while they ran.
350
+ state = _load_state(project_root, process_root, change_id)
351
+ _require_phase(state, "implementing")
352
+ if state["cycle"] != cycle:
353
+ raise ProcessError("implementation cycle changed while verification was running")
325
354
  state["verification"] = {
326
355
  name: report
327
356
  for name, report in state["verification"].items()
328
357
  if same_checkpoint(report["checkpoint"], before)
329
358
  }
330
- report = run_profile(project_root, project, profile)
331
359
  after = repository_snapshot(project_root)
360
+ profile = report["profile"]
332
361
  report["checkpoint"] = after
333
362
  report["recordedAt"] = _now()
334
363
  if not same_checkpoint(before, after):
@@ -356,6 +385,7 @@ def verify_change(
356
385
  return state, report
357
386
 
358
387
 
388
+ @history_transaction
359
389
  def start_review(
360
390
  project_root: Path,
361
391
  process_root: Path,
@@ -364,9 +394,29 @@ def start_review(
364
394
  actor_id: str,
365
395
  context_id: str,
366
396
  kind: str,
397
+ replace_reused: bool = False,
367
398
  ) -> dict[str, Any]:
368
399
  state = _load_state(project_root, process_root, change_id)
369
- _require_phase(state, "verified")
400
+ previous_assignment = conflict = None
401
+ if replace_reused:
402
+ _require_phase(state, "review-pending")
403
+ if (state["reviewAssignment"] is None or state["review"] is not None
404
+ or state["reviewHistory"]
405
+ or any(event["event"] == "review-submitted" for event in state["history"])):
406
+ raise ProcessError("only an unsubmitted initial review assignment can be replaced")
407
+ previous_assignment = deepcopy(state["reviewAssignment"])
408
+ if previous_assignment["reviewer"]["kind"] != "agent":
409
+ raise ProcessError("replace-reused requires a recorded reused agent context")
410
+ report_path = _run_path(project_root, change_id).with_name(f"review-{state['cycle']}.json")
411
+ if report_path.exists() or report_path.is_symlink():
412
+ raise ProcessError("a review report already exists; assignment replacement would discard it")
413
+ conflict = recorded_context_conflict(
414
+ project_root, process_root, state, previous_assignment["reviewer"]["contextId"]
415
+ )
416
+ if conflict is None:
417
+ raise ProcessError("the pending reviewer context has no recorded cross-change reuse")
418
+ else:
419
+ _require_phase(state, "verified")
370
420
  reviewer = _actor(actor_id, context_id, kind)
371
421
  if state["reviewHistory"]:
372
422
  original_reviewer = state["reviewHistory"][0]["document"]["reviewer"]
@@ -383,6 +433,7 @@ def start_review(
383
433
  raise ProcessError("reviewer actor must be independent from implementation")
384
434
  if any(item["contextId"] == reviewer["contextId"] for item in implementers):
385
435
  raise ProcessError("reviewer context must be independent from implementation")
436
+ require_unreused_context(project_root, process_root, state, reviewer)
386
437
 
387
438
  checkpoint = repository_snapshot(project_root)
388
439
  required = state["contract"]["document"]["requiredProfiles"]
@@ -393,6 +444,10 @@ def start_review(
393
444
  for name in required
394
445
  ):
395
446
  raise ProcessError("verification evidence is stale or incomplete")
447
+ if previous_assignment is not None and not same_checkpoint(
448
+ previous_assignment["checkpoint"], checkpoint
449
+ ):
450
+ raise ProcessError("repository changed after the reused review assignment")
396
451
  state["reviewAssignment"] = {
397
452
  "reviewer": reviewer,
398
453
  "checkpoint": checkpoint,
@@ -403,11 +458,21 @@ def start_review(
403
458
  ),
404
459
  }
405
460
  state["phase"] = "review-pending"
406
- _event(state, "review-started", reviewer)
461
+ if previous_assignment is None:
462
+ _event(state, "review-started", reviewer)
463
+ else:
464
+ _event(
465
+ state, "review-assignment-replaced", reviewer,
466
+ reason="cross-change-context-reuse",
467
+ previousAssignment=previous_assignment,
468
+ replacementAssignment=deepcopy(state["reviewAssignment"]),
469
+ conflict=conflict,
470
+ )
407
471
  _save_state(project_root, process_root, state)
408
472
  return state
409
473
 
410
474
 
475
+ @history_transaction
411
476
  def submit_review(
412
477
  project_root: Path,
413
478
  process_root: Path,
@@ -428,6 +493,7 @@ def submit_review(
428
493
  raise ProcessError("review changeId does not match lifecycle state")
429
494
  if review["reviewer"] != assignment["reviewer"]:
430
495
  raise ProcessError("reviewer does not match the assigned independent identity")
496
+ require_unreused_context(project_root, process_root, state, assignment["reviewer"])
431
497
  if not same_checkpoint(review["checkpoint"], assignment["checkpoint"]):
432
498
  raise ProcessError("review checkpoint does not match the assignment")
433
499
  current = repository_snapshot(project_root)
@@ -535,6 +601,7 @@ def submit_review(
535
601
  return state
536
602
 
537
603
 
604
+ @history_transaction
538
605
  def finish_change(
539
606
  project_root: Path,
540
607
  process_root: Path,
@@ -546,6 +613,9 @@ def finish_change(
546
613
  ) -> tuple[dict[str, Any], dict[str, Any]]:
547
614
  state = _load_state(project_root, process_root, change_id)
548
615
  _require_phase(state, "approved")
616
+ require_unreused_context(
617
+ project_root, process_root, state, state["reviewAssignment"]["reviewer"]
618
+ )
549
619
  actor = _actor(actor_id, context_id, kind)
550
620
  project = load_project(project_root, process_root)
551
621
  checkpoint = repository_snapshot(project_root)
@@ -0,0 +1,291 @@
1
+ """Recorded reviewer-context ownership within one Git repository."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import contextmanager
6
+ from functools import wraps
7
+ import os
8
+ from pathlib import Path
9
+ import stat
10
+ import subprocess
11
+ import tempfile
12
+ import time
13
+ from typing import Any, Callable, Iterator
14
+
15
+ from .contracts import (
16
+ MAX_JSON_BYTES,
17
+ ProcessError,
18
+ digest_json,
19
+ parse_json_bytes,
20
+ validate_document,
21
+ )
22
+ from .distribution import schemas_root
23
+
24
+
25
+ MAX_WORKTREES = 64
26
+ MAX_RUN_ENTRIES = 512
27
+ MAX_TOTAL_BYTES = 64_000_000
28
+ MAX_GIT_BYTES = 256_000
29
+ GIT_TIMEOUT_SECONDS = 10
30
+ SCAN_TIMEOUT_SECONDS = 30
31
+ LOCK_TIMEOUT_SECONDS = 10
32
+
33
+
34
+ def _check_deadline(deadline: float) -> None:
35
+ if time.monotonic() >= deadline:
36
+ raise ProcessError("review history exceeds its time limit")
37
+
38
+
39
+ def _git(root: Path, arguments: list[str], *, deadline: float | None = None) -> bytes:
40
+ # These fixed metadata commands cannot invoke a pager or consumer shell command.
41
+ command_deadline = time.monotonic() + GIT_TIMEOUT_SECONDS
42
+ if deadline is not None:
43
+ _check_deadline(deadline)
44
+ command_deadline = min(command_deadline, deadline)
45
+ with tempfile.TemporaryFile() as output:
46
+ try:
47
+ process = subprocess.Popen(
48
+ ["git", "--no-pager", *arguments], cwd=root,
49
+ stdin=subprocess.DEVNULL, stdout=output, stderr=subprocess.STDOUT,
50
+ )
51
+ except OSError as error:
52
+ raise ProcessError(f"cannot inspect review worktrees: {error}") from error
53
+ try:
54
+ while process.poll() is None:
55
+ if os.fstat(output.fileno()).st_size > MAX_GIT_BYTES:
56
+ raise ProcessError("review worktree Git output exceeded its byte limit")
57
+ if time.monotonic() >= command_deadline:
58
+ raise ProcessError("review worktree Git command timed out")
59
+ time.sleep(0.01)
60
+ output.seek(0)
61
+ content = output.read(MAX_GIT_BYTES + 1)
62
+ if len(content) > MAX_GIT_BYTES:
63
+ raise ProcessError("review worktree Git output exceeded its byte limit")
64
+ if process.returncode:
65
+ detail = content[:2000].decode("utf-8", errors="replace").strip()
66
+ raise ProcessError(f"cannot inspect review worktrees: {detail}")
67
+ if deadline is not None:
68
+ _check_deadline(deadline)
69
+ return content
70
+ finally:
71
+ if process.poll() is None:
72
+ process.kill()
73
+ process.wait(timeout=5)
74
+
75
+
76
+ def _safe_info(path: Path, *, directory: bool) -> os.stat_result:
77
+ info = path.lstat()
78
+ reparse = getattr(info, "st_file_attributes", 0) & getattr(
79
+ stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0
80
+ )
81
+ if stat.S_ISLNK(info.st_mode) or reparse:
82
+ raise ProcessError(f"review history must not traverse a link or reparse point: {path}")
83
+ expected = stat.S_ISDIR if directory else stat.S_ISREG
84
+ if not expected(info.st_mode):
85
+ raise ProcessError(f"review history has an unsupported file type: {path}")
86
+ return info
87
+
88
+
89
+ def _identity(info: os.stat_result) -> tuple[int, ...]:
90
+ return (info.st_dev, info.st_ino, info.st_mode, info.st_size, info.st_mtime_ns)
91
+
92
+
93
+ def _directory_identity(path: Path) -> tuple[int, ...]:
94
+ info = _safe_info(path, directory=True)
95
+ return (info.st_dev, info.st_ino, info.st_mode)
96
+
97
+
98
+ def _common_directory(root: Path, *, deadline: float | None = None) -> Path:
99
+ result = _git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"],
100
+ deadline=deadline)
101
+ path = Path(os.fsdecode(result.removesuffix(b"\n")))
102
+ if not path.is_absolute():
103
+ raise ProcessError("Git common directory must be absolute")
104
+ _safe_info(path, directory=True)
105
+ return path.resolve()
106
+
107
+
108
+ @contextmanager
109
+ def _review_lock(root: Path) -> Iterator[None]:
110
+ common = _common_directory(root)
111
+ path = common / "engineering-process-review.lock"
112
+ flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0)
113
+ descriptor = os.open(path, flags, 0o600)
114
+ try:
115
+ opened = os.fstat(descriptor)
116
+ current = _safe_info(path, directory=False)
117
+ if (current.st_dev, current.st_ino, current.st_mode) != (
118
+ opened.st_dev, opened.st_ino, opened.st_mode
119
+ ):
120
+ raise ProcessError("review transaction lock changed while opening")
121
+ deadline = time.monotonic() + LOCK_TIMEOUT_SECONDS
122
+ while True:
123
+ try:
124
+ if os.name == "nt":
125
+ import msvcrt
126
+ os.lseek(descriptor, 0, os.SEEK_SET)
127
+ msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1)
128
+ else:
129
+ import fcntl
130
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
131
+ break
132
+ except OSError as error:
133
+ if time.monotonic() >= deadline:
134
+ raise ProcessError("review transaction lock timed out") from error
135
+ time.sleep(0.02)
136
+ try:
137
+ current = _safe_info(path, directory=False)
138
+ if (current.st_dev, current.st_ino) != (opened.st_dev, opened.st_ino):
139
+ raise ProcessError("review transaction lock was replaced")
140
+ yield
141
+ finally:
142
+ if os.name == "nt":
143
+ os.lseek(descriptor, 0, os.SEEK_SET)
144
+ msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1)
145
+ else:
146
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
147
+ finally:
148
+ os.close(descriptor)
149
+
150
+
151
+ def history_transaction(function: Callable[..., Any]) -> Callable[..., Any]:
152
+ @wraps(function)
153
+ def locked(project_root: Path, *args: Any, **kwargs: Any) -> Any:
154
+ try:
155
+ with _review_lock(project_root):
156
+ return function(project_root, *args, **kwargs)
157
+ except OSError as error:
158
+ raise ProcessError(f"cannot inspect or lock lifecycle history: {error}") from error
159
+ return locked
160
+
161
+
162
+ def _worktrees(root: Path, *, deadline: float) -> list[Path]:
163
+ raw = _git(root, ["worktree", "list", "--porcelain", "-z"], deadline=deadline)
164
+ roots = []
165
+ for record in raw.split(b"\0\0"):
166
+ _check_deadline(deadline)
167
+ if not record:
168
+ continue
169
+ fields = record.split(b"\0")
170
+ if not fields[0].startswith(b"worktree "):
171
+ raise ProcessError("Git returned an invalid review worktree record")
172
+ if b"bare" in fields:
173
+ continue
174
+ path = Path(os.fsdecode(fields[0][len(b"worktree "):]))
175
+ if not path.is_absolute():
176
+ raise ProcessError("review worktree paths must be absolute")
177
+ _safe_info(path, directory=True)
178
+ roots.append(path.resolve())
179
+ if len(roots) > MAX_WORKTREES:
180
+ raise ProcessError("review history exceeds its worktree limit")
181
+ if root.resolve() not in roots or len(roots) != len(set(roots)):
182
+ raise ProcessError("Git returned an incomplete or duplicate review worktree inventory")
183
+ return sorted(roots)
184
+
185
+
186
+ def _read_run(path: Path, parents: list[Path], process_root: Path) -> tuple[dict[str, Any], int]:
187
+ before = [_directory_identity(parent) for parent in parents]
188
+ info = _safe_info(path, directory=False)
189
+ if info.st_size > MAX_JSON_BYTES:
190
+ raise ProcessError(f"{path} exceeds {MAX_JSON_BYTES} bytes")
191
+ descriptor = os.open(
192
+ path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
193
+ | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0),
194
+ )
195
+ with os.fdopen(descriptor, "rb") as stream:
196
+ if _identity(os.fstat(stream.fileno())) != _identity(info):
197
+ raise ProcessError(f"review history changed while opening: {path}")
198
+ if before != [_directory_identity(parent) for parent in parents]:
199
+ raise ProcessError(f"review history path changed while opening: {path}")
200
+ content = stream.read(MAX_JSON_BYTES + 1)
201
+ after = _safe_info(path, directory=False)
202
+ if _identity(after) != _identity(info) or len(content) != info.st_size:
203
+ raise ProcessError(f"review history changed while reading: {path}")
204
+ if before != [_directory_identity(parent) for parent in parents]:
205
+ raise ProcessError(f"review history path changed while reading: {path}")
206
+ state = validate_document(
207
+ parse_json_bytes(content, source=str(path)), "run",
208
+ schema_root=schemas_root(process_root), source=str(path),
209
+ )
210
+ if state["changeId"] != path.parent.name:
211
+ raise ProcessError(f"{path}: change identity mismatch")
212
+ return state, len(content)
213
+
214
+
215
+ def recorded_context_conflict(
216
+ project_root: Path, process_root: Path, current: dict[str, Any], context_id: str,
217
+ ) -> dict[str, str] | None:
218
+ """Return one recorded foreign use; absence only covers the inspected history."""
219
+ deadline = time.monotonic() + SCAN_TIMEOUT_SECONDS
220
+ common = _common_directory(project_root, deadline=deadline)
221
+ roots = _worktrees(project_root, deadline=deadline)
222
+ accepted = (current["changeId"], current["contract"]["digest"])
223
+ total = entries = 0
224
+ for root in roots:
225
+ _check_deadline(deadline)
226
+ if _common_directory(root, deadline=deadline) != common:
227
+ raise ProcessError(f"registered review worktree changed repository: {root}")
228
+ process = root / ".process"
229
+ runs = process / "runs"
230
+ try:
231
+ _safe_info(process, directory=True)
232
+ _safe_info(runs, directory=True)
233
+ except FileNotFoundError:
234
+ _check_deadline(deadline)
235
+ continue
236
+ directories = []
237
+ with os.scandir(runs) as children:
238
+ for child in children:
239
+ entries += 1
240
+ if entries > MAX_RUN_ENTRIES or time.monotonic() >= deadline:
241
+ raise ProcessError("review history exceeds its entry or time limit")
242
+ directories.append(Path(child.path))
243
+ for directory in sorted(directories):
244
+ _check_deadline(deadline)
245
+ info = directory.lstat()
246
+ if stat.S_ISREG(info.st_mode):
247
+ continue
248
+ _safe_info(directory, directory=True)
249
+ path = directory / "run.json"
250
+ try:
251
+ path.lstat()
252
+ except FileNotFoundError:
253
+ continue
254
+ state, size = _read_run(path, [root, process, runs, directory], process_root)
255
+ _check_deadline(deadline)
256
+ total += size
257
+ if total > MAX_TOTAL_BYTES:
258
+ raise ProcessError("review history exceeds its aggregate byte limit")
259
+ if (state["changeId"], state["contract"]["digest"]) == accepted:
260
+ continue
261
+ actors = [event["actor"] for event in state["history"]]
262
+ actors.extend(item["actor"] for item in state["implementations"])
263
+ if state["reviewAssignment"] is not None:
264
+ actors.append(state["reviewAssignment"]["reviewer"])
265
+ for event in state["history"]:
266
+ if event["event"] == "review-assignment-replaced":
267
+ actors.append(event["details"]["previousAssignment"]["reviewer"])
268
+ if any(actor["contextId"] == context_id for actor in actors):
269
+ return {
270
+ "changeId": state["changeId"],
271
+ "contractDigest": state["contract"]["digest"],
272
+ "runPath": str(path),
273
+ "runDigest": digest_json(state),
274
+ }
275
+ if _worktrees(project_root, deadline=deadline) != roots:
276
+ raise ProcessError("review worktree inventory changed during inspection")
277
+ _check_deadline(deadline)
278
+ return None
279
+
280
+
281
+ def require_unreused_context(
282
+ project_root: Path, process_root: Path, state: dict[str, Any], reviewer: dict[str, str],
283
+ ) -> None:
284
+ if reviewer["kind"] != "agent":
285
+ return
286
+ conflict = recorded_context_conflict(project_root, process_root, state, reviewer["contextId"])
287
+ if conflict is not None:
288
+ raise ProcessError(
289
+ f"reviewer context was used by another change: {conflict['changeId']} "
290
+ f"({conflict['runPath']}); spawn a fresh reviewer for this change"
291
+ )