prizmkit 1.1.153 → 1.1.155

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 (178) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/adapters/codex/skill-adapter.js +1 -1
  3. package/bundled/adapters/pi/skill-adapter.js +2 -1
  4. package/bundled/dev-pipeline/.env.example +2 -6
  5. package/bundled/dev-pipeline/README.md +120 -113
  6. package/bundled/dev-pipeline/assets/skill-subagent-integration.md +1 -1
  7. package/bundled/dev-pipeline/prizmkit_runtime/checkpoint_state.py +355 -15
  8. package/bundled/dev-pipeline/prizmkit_runtime/cli.py +189 -110
  9. package/bundled/dev-pipeline/prizmkit_runtime/commands.py +146 -111
  10. package/bundled/dev-pipeline/prizmkit_runtime/daemon.py +4 -20
  11. package/bundled/dev-pipeline/prizmkit_runtime/gitops.py +48 -14
  12. package/bundled/dev-pipeline/prizmkit_runtime/interoperability.py +0 -1
  13. package/bundled/dev-pipeline/prizmkit_runtime/paths.py +0 -3
  14. package/bundled/dev-pipeline/prizmkit_runtime/reset.py +122 -15
  15. package/bundled/dev-pipeline/prizmkit_runtime/reset_preserve.py +1 -1
  16. package/bundled/dev-pipeline/prizmkit_runtime/runner_classification.py +50 -2
  17. package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +1 -10
  18. package/bundled/dev-pipeline/prizmkit_runtime/runner_prompts.py +3 -3
  19. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +139 -74
  20. package/bundled/dev-pipeline/prizmkit_runtime/runtime_commit.py +482 -0
  21. package/bundled/dev-pipeline/prizmkit_runtime/status.py +106 -1
  22. package/bundled/dev-pipeline/prizmkit_runtime/test_result.py +64 -11
  23. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +145 -367
  24. package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +91 -62
  25. package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +105 -69
  26. package/bundled/dev-pipeline/scripts/init-bugfix-pipeline.py +2 -2
  27. package/bundled/dev-pipeline/scripts/prompt_framework.py +36 -41
  28. package/bundled/dev-pipeline/scripts/update-bug-status.py +197 -71
  29. package/bundled/dev-pipeline/scripts/update-checkpoint.py +77 -16
  30. package/bundled/dev-pipeline/scripts/update-feature-status.py +46 -121
  31. package/bundled/dev-pipeline/scripts/update-refactor-status.py +46 -123
  32. package/bundled/dev-pipeline/scripts/utils.py +119 -0
  33. package/bundled/dev-pipeline/templates/bootstrap-prompt.md +22 -27
  34. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +71 -104
  35. package/bundled/dev-pipeline/templates/bug-fix-list-schema.json +3 -2
  36. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +59 -111
  37. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +37 -72
  38. package/bundled/dev-pipeline/templates/sections/artifact-manifest.md +39 -0
  39. package/bundled/dev-pipeline/templates/sections/bugfix-critical-paths.md +3 -1
  40. package/bundled/dev-pipeline/templates/sections/bugfix-phase-commit-report.md +8 -25
  41. package/bundled/dev-pipeline/templates/sections/bugfix-phase-diagnose-plan.md +3 -3
  42. package/bundled/dev-pipeline/templates/sections/bugfix-phase-implement.md +2 -2
  43. package/bundled/dev-pipeline/templates/sections/bugfix-phase-review.md +3 -3
  44. package/bundled/dev-pipeline/templates/sections/bugfix-phase-test.md +2 -2
  45. package/bundled/dev-pipeline/templates/sections/bugfix-reminders.md +4 -4
  46. package/bundled/dev-pipeline/templates/sections/bugfix-session-context.md +0 -1
  47. package/bundled/dev-pipeline/templates/sections/checkpoint-system.md +7 -4
  48. package/bundled/dev-pipeline/templates/sections/context-budget-rules.md +14 -27
  49. package/bundled/dev-pipeline/templates/sections/feature-completion-summary.md +20 -0
  50. package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +8 -71
  51. package/bundled/dev-pipeline/templates/sections/phase-implement-full.md +7 -7
  52. package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +3 -3
  53. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +4 -4
  54. package/bundled/dev-pipeline/templates/sections/phase-specify-plan-full.md +5 -17
  55. package/bundled/dev-pipeline/templates/sections/refactor-critical-paths.md +3 -1
  56. package/bundled/dev-pipeline/templates/sections/refactor-phase-commit-report.md +10 -23
  57. package/bundled/dev-pipeline/templates/sections/refactor-phase-implement.md +2 -2
  58. package/bundled/dev-pipeline/templates/sections/refactor-phase-plan.md +2 -2
  59. package/bundled/dev-pipeline/templates/sections/refactor-phase-review.md +3 -3
  60. package/bundled/dev-pipeline/templates/sections/refactor-phase-test.md +2 -2
  61. package/bundled/dev-pipeline/templates/sections/refactor-reminders.md +7 -7
  62. package/bundled/dev-pipeline/templates/sections/refactor-session-context.md +0 -1
  63. package/bundled/dev-pipeline/templates/sections/retrospective.md +12 -0
  64. package/bundled/dev-pipeline/templates/sections/runtime-commit-handoff.md +35 -0
  65. package/bundled/dev-pipeline/templates/session-status-schema.json +45 -62
  66. package/bundled/dev-pipeline/tests/test_auto_skip.py +111 -32
  67. package/bundled/dev-pipeline/tests/test_checkpoint_state.py +617 -30
  68. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +262 -194
  69. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +62 -23
  70. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +76 -28
  71. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +281 -206
  72. package/bundled/dev-pipeline/tests/test_recovery_workflow.py +322 -0
  73. package/bundled/dev-pipeline/tests/test_reset_modes.py +935 -0
  74. package/bundled/dev-pipeline/tests/test_reset_preserve.py +5 -7
  75. package/bundled/dev-pipeline/tests/test_runtime_commit.py +608 -0
  76. package/bundled/dev-pipeline/tests/test_unified_cli.py +617 -188
  77. package/bundled/skills/_metadata.json +9 -9
  78. package/bundled/skills/app-planner/SKILL.md +3 -3
  79. package/bundled/skills/app-planner/references/generated-plan-review.md +2 -1
  80. package/bundled/skills/app-planner/references/rules/backend/derivation-rules.md +56 -64
  81. package/bundled/skills/app-planner/references/rules/backend/fixed-rules.md +85 -240
  82. package/bundled/skills/app-planner/references/rules/backend/question-bank.md +25 -25
  83. package/bundled/skills/app-planner/references/rules/backend/question-manifest.json +16 -7
  84. package/bundled/skills/app-planner/references/rules/backend/template.md +74 -92
  85. package/bundled/skills/app-planner/references/rules/database/derivation-rules.md +93 -93
  86. package/bundled/skills/app-planner/references/rules/database/fixed-rules.md +66 -173
  87. package/bundled/skills/app-planner/references/rules/database/question-bank.md +31 -26
  88. package/bundled/skills/app-planner/references/rules/database/question-manifest.json +13 -4
  89. package/bundled/skills/app-planner/references/rules/database/template.md +76 -98
  90. package/bundled/skills/app-planner/references/rules/frontend/derivation-rules.md +68 -95
  91. package/bundled/skills/app-planner/references/rules/frontend/fixed-rules.md +93 -166
  92. package/bundled/skills/app-planner/references/rules/frontend/question-bank.md +46 -36
  93. package/bundled/skills/app-planner/references/rules/frontend/question-manifest.json +11 -2
  94. package/bundled/skills/app-planner/references/rules/frontend/template.md +79 -257
  95. package/bundled/skills/app-planner/references/rules/mobile/derivation-rules.md +91 -99
  96. package/bundled/skills/app-planner/references/rules/mobile/fixed-rules.md +73 -246
  97. package/bundled/skills/app-planner/references/rules/mobile/question-bank.md +17 -17
  98. package/bundled/skills/app-planner/references/rules/mobile/question-manifest.json +17 -2
  99. package/bundled/skills/app-planner/references/rules/mobile/template.md +73 -85
  100. package/bundled/skills/app-planner/references/rules-configuration.md +133 -65
  101. package/bundled/skills/app-planner/scripts/validate-rules-configuration.py +291 -0
  102. package/bundled/skills/bug-planner/references/schema-validation.md +1 -1
  103. package/bundled/skills/bug-planner/scripts/validate-bug-list.py +1 -1
  104. package/bundled/skills/bugfix-pipeline-launcher/SKILL.md +21 -13
  105. package/bundled/skills/feature-pipeline-launcher/SKILL.md +22 -14
  106. package/bundled/skills/prizmkit/SKILL.md +17 -18
  107. package/bundled/skills/prizmkit/references/workflow-state-protocol.md +22 -18
  108. package/bundled/skills/prizmkit-code-review/SKILL.md +22 -58
  109. package/bundled/skills/prizmkit-code-review/references/independent-code-review.md +6 -6
  110. package/bundled/skills/prizmkit-code-review/references/review-report-template.md +2 -2
  111. package/bundled/skills/prizmkit-committer/SKILL.md +82 -132
  112. package/bundled/skills/prizmkit-deploy/SKILL.md +27 -11
  113. package/bundled/skills/prizmkit-deploy/assets/deploy-document-template.md +89 -0
  114. package/bundled/skills/prizmkit-deploy/references/ci-cd-workflows.md +44 -14
  115. package/bundled/skills/prizmkit-deploy/references/cloud-platform-deploy.md +19 -3
  116. package/bundled/skills/prizmkit-deploy/references/database-setup.md +22 -12
  117. package/bundled/skills/prizmkit-deploy/references/deploy-config-schema.md +3 -1
  118. package/bundled/skills/prizmkit-deploy/references/deploy-history-schema.md +22 -6
  119. package/bundled/skills/prizmkit-deploy/references/deploy-metadata-schema.json +49 -0
  120. package/bundled/skills/prizmkit-deploy/references/live-validation-notes.md +1 -1
  121. package/bundled/skills/prizmkit-deploy/references/pending-input-schema.json +164 -0
  122. package/bundled/skills/prizmkit-deploy/references/ssh-adapter-flow.md +9 -8
  123. package/bundled/skills/prizmkit-deploy/references/ssh-execution-flow.md +6 -6
  124. package/bundled/skills/prizmkit-implement/SKILL.md +30 -66
  125. package/bundled/skills/prizmkit-implement/references/implementation-subagent-procedure.md +4 -4
  126. package/bundled/skills/prizmkit-init/SKILL.md +4 -0
  127. package/bundled/skills/prizmkit-plan/SKILL.md +21 -64
  128. package/bundled/skills/prizmkit-plan/references/artifact-identity.md +38 -0
  129. package/bundled/skills/prizmkit-plan/references/independent-plan-review.md +4 -4
  130. package/bundled/skills/prizmkit-plan/references/review-plan-spec-loop.md +5 -5
  131. package/bundled/skills/prizmkit-prizm-docs/SKILL.md +11 -1
  132. package/bundled/skills/prizmkit-retrospective/SKILL.md +63 -77
  133. package/bundled/skills/prizmkit-retrospective/references/knowledge-injection-steps.md +3 -3
  134. package/bundled/skills/prizmkit-retrospective/references/retrospective-result-schema.json +138 -0
  135. package/bundled/skills/prizmkit-retrospective/references/structural-sync-steps.md +17 -23
  136. package/bundled/skills/prizmkit-test/SKILL.md +64 -51
  137. package/bundled/skills/prizmkit-test/references/boundary-contract-and-test-double-guidance.md +206 -0
  138. package/bundled/skills/prizmkit-test/references/independent-test-review.md +17 -9
  139. package/bundled/skills/prizmkit-test/references/test-coverage-model.md +45 -21
  140. package/bundled/skills/prizmkit-test/references/test-report-template.md +20 -13
  141. package/bundled/skills/prizmkit-workflow/SKILL.md +35 -37
  142. package/bundled/skills/prizmkit-workflow/references/artifact-identity.md +34 -0
  143. package/bundled/skills/prizmkit-workflow/references/workflow-state-protocol.md +22 -18
  144. package/bundled/skills/recovery-workflow/SKILL.md +9 -6
  145. package/bundled/skills/recovery-workflow/evals/evals.json +2 -2
  146. package/bundled/skills/recovery-workflow/references/detection.md +7 -6
  147. package/bundled/skills/recovery-workflow/scripts/detect-recovery-state.py +16 -6
  148. package/bundled/skills/refactor-pipeline-launcher/SKILL.md +21 -13
  149. package/bundled/templates/project-memory-template.md +19 -11
  150. package/package.json +1 -1
  151. package/src/scaffold.js +33 -22
  152. package/bundled/dev-pipeline/prizmkit_runtime/runner_recovery.py +0 -228
  153. package/bundled/dev-pipeline/scripts/generate-recovery-prompt.py +0 -767
  154. package/bundled/dev-pipeline/templates/sections/bugfix-artifacts.md +0 -16
  155. package/bundled/dev-pipeline/templates/sections/bugfix-session-status.md +0 -31
  156. package/bundled/dev-pipeline/templates/sections/critical-paths-agent.md +0 -7
  157. package/bundled/dev-pipeline/templates/sections/critical-paths-lite.md +0 -7
  158. package/bundled/dev-pipeline/templates/sections/directory-convention-agent.md +0 -9
  159. package/bundled/dev-pipeline/templates/sections/directory-convention-lite.md +0 -6
  160. package/bundled/dev-pipeline/templates/sections/headless-commit-authorization.md +0 -15
  161. package/bundled/dev-pipeline/templates/sections/phase-commit.md +0 -61
  162. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-agent-suffix.md +0 -24
  163. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-lite-suffix.md +0 -12
  164. package/bundled/dev-pipeline/templates/sections/phase-implement-agent.md +0 -44
  165. package/bundled/dev-pipeline/templates/sections/phase-implement-lite.md +0 -25
  166. package/bundled/dev-pipeline/templates/sections/phase-plan-agent.md +0 -20
  167. package/bundled/dev-pipeline/templates/sections/phase-plan-lite.md +0 -20
  168. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +0 -20
  169. package/bundled/dev-pipeline/templates/sections/refactor-artifacts.md +0 -17
  170. package/bundled/dev-pipeline/templates/sections/refactor-session-status.md +0 -28
  171. package/bundled/dev-pipeline/templates/sections/test-failure-recovery-agent.md +0 -52
  172. package/bundled/dev-pipeline/templates/sections/test-failure-recovery-lite.md +0 -52
  173. package/bundled/skills/prizmkit-code-review/references/workflow-state-protocol.md +0 -174
  174. package/bundled/skills/prizmkit-committer/references/workflow-state-protocol.md +0 -174
  175. package/bundled/skills/prizmkit-implement/references/workflow-state-protocol.md +0 -174
  176. package/bundled/skills/prizmkit-plan/references/workflow-state-protocol.md +0 -174
  177. package/bundled/skills/prizmkit-retrospective/references/workflow-state-protocol.md +0 -174
  178. package/bundled/skills/prizmkit-test/references/external-contract-mock-guidance.md +0 -119
@@ -0,0 +1,482 @@
1
+ """Runtime-owned local commit execution for prepared pipeline tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import subprocess
9
+ import tempfile
10
+ from dataclasses import dataclass
11
+ from pathlib import Path, PurePosixPath
12
+ from typing import Mapping, Sequence
13
+
14
+ from .checkpoint_state import (
15
+ load_checkpoint_state,
16
+ semantic_state_key,
17
+ validate_checkpoint_data,
18
+ )
19
+ from .gitops import HIDDEN_TOOL_WORKTREE_EXCLUDES
20
+
21
+ REQUEST_FILENAME = "runtime-commit-request.json"
22
+ REQUEST_SCHEMA_VERSION = 1
23
+ _HEX_COMMIT = re.compile(r"^[0-9a-fA-F]{40,64}$")
24
+ _FORBIDDEN_EXACT_PATHS = {
25
+ ".claude/settings.local.json",
26
+ ".codebuddy/settings.local.json",
27
+ ".codex/settings.local.json",
28
+ ".agents/settings.local.json",
29
+ ".pi/settings.local.json",
30
+ }
31
+ _SENSITIVE_NAMES = {
32
+ ".env",
33
+ "id_rsa",
34
+ "id_dsa",
35
+ "id_ed25519",
36
+ "credentials.json",
37
+ "secrets.json",
38
+ }
39
+ _SENSITIVE_SUFFIXES = {".pem", ".p12", ".pfx"}
40
+ _SUPPORT_ROOT_FILES = {"agents.md", "skills-lock.json"}
41
+ _SUPPORT_ROOT_DIRS = {".claude", ".codebuddy", ".codex", ".agents", ".pi"}
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class RuntimeCommitRequest:
46
+ """Validated commit preparation emitted by the committer stage."""
47
+
48
+ artifact_dir: str
49
+ base_head: str
50
+ commit_message: str
51
+ intended_paths: tuple[str, ...]
52
+ runtime_commit_hash: str = ""
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class RuntimeCommitResult:
57
+ """Observable result of one runtime commit finalization attempt."""
58
+
59
+ status: str
60
+ reason: str = ""
61
+ commit_hash: str = ""
62
+ recovered: bool = False
63
+
64
+ @property
65
+ def committed(self) -> bool:
66
+ return self.status == "committed"
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class _GitResult:
71
+ return_code: int
72
+ stdout: str
73
+ stderr: str
74
+
75
+
76
+ def finalize_runtime_commit(
77
+ project_root: Path,
78
+ *,
79
+ artifact_dir: Path,
80
+ checkpoint_path: Path,
81
+ ) -> RuntimeCommitResult:
82
+ """Execute and finalize a valid COMMIT_PENDING runtime handoff.
83
+
84
+ A completed checkpoint is accepted only for idempotent cleanup recovery
85
+ when a request remains after commit finalization passed.
86
+ """
87
+ try:
88
+ root = project_root.resolve()
89
+ artifact = artifact_dir.resolve()
90
+ checkpoint = checkpoint_path.resolve()
91
+ except (OSError, RuntimeError):
92
+ return RuntimeCommitResult("failed", "unsafe_artifact_identity")
93
+ if not _within(artifact, root) or not _within(checkpoint, artifact):
94
+ return RuntimeCommitResult("failed", "unsafe_artifact_identity")
95
+
96
+ state = load_checkpoint_state(checkpoint, project_root=root)
97
+ if not state.valid:
98
+ return RuntimeCommitResult("failed", f"invalid_checkpoint:{state.error_code}")
99
+ if not state.semantic_commit_ready and not state.semantic_complete:
100
+ return RuntimeCommitResult("not_ready", "checkpoint_not_commit_ready")
101
+
102
+ request_path = artifact / REQUEST_FILENAME
103
+ request, error = _load_request(request_path, root, artifact)
104
+ if request is None:
105
+ if state.semantic_complete and error == "commit_request_missing":
106
+ return RuntimeCommitResult("not_ready", "runtime_commit_already_finalized")
107
+ return RuntimeCommitResult("failed", error or "invalid_commit_request")
108
+
109
+ if any(_is_tracked(root, path) for path in (request_path, checkpoint)):
110
+ return RuntimeCommitResult("failed", "runtime_metadata_must_be_untracked")
111
+
112
+ current_head = _git(root, "rev-parse", "HEAD")
113
+ if current_head.return_code != 0:
114
+ return RuntimeCommitResult("failed", "git_head_unavailable")
115
+ head = current_head.stdout.strip()
116
+ recovered = False
117
+
118
+ if head == request.base_head:
119
+ if request.runtime_commit_hash:
120
+ return RuntimeCommitResult("failed", "premature_runtime_commit_hash")
121
+ if state.semantic_complete:
122
+ return RuntimeCommitResult("failed", "completed_checkpoint_without_commit")
123
+ changed, status_error = _task_visible_changed_paths(
124
+ root,
125
+ artifact_dir=artifact,
126
+ checkpoint_path=checkpoint,
127
+ request_path=request_path,
128
+ )
129
+ if status_error:
130
+ return RuntimeCommitResult("failed", status_error)
131
+ if set(changed) != set(request.intended_paths):
132
+ return RuntimeCommitResult("failed", "intended_paths_mismatch")
133
+ staged = _stage_intended_paths(root, request.intended_paths)
134
+ if staged.return_code != 0:
135
+ return RuntimeCommitResult("failed", "git_stage_failed")
136
+ if _git(root, "diff", "--cached", "--quiet", "--exit-code").return_code == 0:
137
+ return RuntimeCommitResult("failed", "empty_staged_change")
138
+ committed = _git(
139
+ root,
140
+ "-c", "core.hooksPath=/dev/null",
141
+ "-c", "commit.gpgSign=false",
142
+ "commit", "-m", request.commit_message,
143
+ )
144
+ if committed.return_code != 0:
145
+ return RuntimeCommitResult("failed", "git_commit_failed")
146
+ head_result = _git(root, "rev-parse", "HEAD")
147
+ if head_result.return_code != 0:
148
+ return RuntimeCommitResult("failed", "commit_identity_unavailable")
149
+ head = head_result.stdout.strip()
150
+ if not _matches_prepared_commit(root, request, head):
151
+ return RuntimeCommitResult("failed", "git_commit_content_mismatch", head)
152
+ hash_error = _record_runtime_commit_hash(request_path, head)
153
+ if hash_error:
154
+ return RuntimeCommitResult("failed", hash_error, head)
155
+ elif _matches_prepared_commit(root, request, head):
156
+ if request.runtime_commit_hash and request.runtime_commit_hash.lower() != head.lower():
157
+ return RuntimeCommitResult("failed", "runtime_commit_hash_mismatch")
158
+ recovered = True
159
+ if not request.runtime_commit_hash:
160
+ hash_error = _record_runtime_commit_hash(request_path, head)
161
+ if hash_error:
162
+ return RuntimeCommitResult("failed", hash_error, head, recovered)
163
+ else:
164
+ return RuntimeCommitResult("failed", "base_head_mismatch")
165
+
166
+ checkpoint_error = _finalize_checkpoint(
167
+ checkpoint,
168
+ commit_hash=head,
169
+ base_head=request.base_head,
170
+ )
171
+ if checkpoint_error:
172
+ return RuntimeCommitResult("failed", checkpoint_error, head, recovered)
173
+
174
+ final_state = load_checkpoint_state(checkpoint, project_root=root)
175
+ if not final_state.semantic_complete:
176
+ return RuntimeCommitResult("failed", "checkpoint_not_completed_after_commit", head, recovered)
177
+
178
+ try:
179
+ request_path.unlink(missing_ok=True)
180
+ except OSError:
181
+ return RuntimeCommitResult("failed", "commit_request_cleanup_failed", head, recovered)
182
+ return RuntimeCommitResult("committed", "runtime_commit_completed", head, recovered)
183
+
184
+
185
+ def _load_request(
186
+ request_path: Path,
187
+ project_root: Path,
188
+ artifact_dir: Path,
189
+ ) -> tuple[RuntimeCommitRequest | None, str]:
190
+ try:
191
+ raw = json.loads(request_path.read_text(encoding="utf-8"))
192
+ except FileNotFoundError:
193
+ return None, "commit_request_missing"
194
+ except (OSError, UnicodeError, json.JSONDecodeError):
195
+ return None, "commit_request_invalid_json"
196
+ if not isinstance(raw, Mapping) or raw.get("schema_version") != REQUEST_SCHEMA_VERSION:
197
+ return None, "commit_request_invalid_schema"
198
+ required_keys = {
199
+ "schema_version", "artifact_dir", "base_head", "commit_message", "intended_paths",
200
+ }
201
+ allowed_keys = required_keys | {"runtime_commit_hash"}
202
+ if not required_keys.issubset(raw) or not set(raw).issubset(allowed_keys):
203
+ return None, "commit_request_invalid_schema"
204
+
205
+ expected_artifact = artifact_dir.relative_to(project_root).as_posix()
206
+ artifact_text = str(raw.get("artifact_dir") or "").strip().replace("\\", "/")
207
+ if artifact_text != expected_artifact:
208
+ return None, "commit_request_artifact_mismatch"
209
+
210
+ base_head = str(raw.get("base_head") or "").strip()
211
+ if not _HEX_COMMIT.fullmatch(base_head):
212
+ return None, "commit_request_base_head_invalid"
213
+
214
+ message = str(raw.get("commit_message") or "").strip()
215
+ if not message or "\n" in message or len(message) > 240:
216
+ return None, "commit_request_message_invalid"
217
+
218
+ raw_paths = raw.get("intended_paths")
219
+ if not isinstance(raw_paths, list) or not raw_paths:
220
+ return None, "commit_request_paths_invalid"
221
+ intended: list[str] = []
222
+ for value in raw_paths:
223
+ relative = _safe_relative_path(value)
224
+ if relative is None or _forbidden_commit_path(relative, expected_artifact):
225
+ return None, "commit_request_path_unsafe"
226
+ intended.append(relative)
227
+ if len(intended) != len(set(intended)):
228
+ return None, "commit_request_paths_duplicate"
229
+
230
+ runtime_commit_hash = str(raw.get("runtime_commit_hash") or "").strip()
231
+ if runtime_commit_hash and not _HEX_COMMIT.fullmatch(runtime_commit_hash):
232
+ return None, "runtime_commit_hash_invalid"
233
+
234
+ return RuntimeCommitRequest(
235
+ artifact_dir=expected_artifact,
236
+ base_head=base_head.lower(),
237
+ commit_message=message,
238
+ intended_paths=tuple(intended),
239
+ runtime_commit_hash=runtime_commit_hash.lower(),
240
+ ), ""
241
+
242
+
243
+ def _safe_relative_path(value: object) -> str | None:
244
+ text = str(value or "").strip().replace("\\", "/")
245
+ if not text or text.startswith("/") or re.match(r"^[A-Za-z]:/", text):
246
+ return None
247
+ path = PurePosixPath(text)
248
+ if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts):
249
+ return None
250
+ return path.as_posix()
251
+
252
+
253
+ def _forbidden_commit_path(path: str, artifact_dir: str) -> bool:
254
+ lower = path.lower()
255
+ name = PurePosixPath(lower).name
256
+ if lower in _FORBIDDEN_EXACT_PATHS:
257
+ return True
258
+ if lower == ".git" or lower.startswith(".git/"):
259
+ return True
260
+ if _known_support_path(lower):
261
+ return True
262
+ artifact_prefix = artifact_dir.lower().rstrip("/") + "/"
263
+ if lower in {
264
+ artifact_prefix + REQUEST_FILENAME,
265
+ artifact_prefix + "workflow-checkpoint.json",
266
+ }:
267
+ return True
268
+ if name in _SENSITIVE_NAMES or (name.startswith(".env.") and name != ".env.example"):
269
+ return True
270
+ return PurePosixPath(lower).suffix in _SENSITIVE_SUFFIXES
271
+
272
+
273
+ def _task_visible_changed_paths(
274
+ project_root: Path,
275
+ *,
276
+ artifact_dir: Path,
277
+ checkpoint_path: Path,
278
+ request_path: Path,
279
+ ) -> tuple[tuple[str, ...], str]:
280
+ result = _git(
281
+ project_root,
282
+ "status",
283
+ "--porcelain=v1",
284
+ "-z",
285
+ "--untracked-files=all",
286
+ "--",
287
+ ".",
288
+ *HIDDEN_TOOL_WORKTREE_EXCLUDES,
289
+ )
290
+ if result.return_code != 0:
291
+ return (), "git_status_failed"
292
+ local_paths = {
293
+ checkpoint_path.relative_to(project_root).as_posix(),
294
+ request_path.relative_to(project_root).as_posix(),
295
+ }
296
+ visible = []
297
+ for path in _status_record_paths(result.stdout):
298
+ if path in local_paths:
299
+ continue
300
+ if _known_support_path(path.lower()) or path.lower() in _FORBIDDEN_EXACT_PATHS:
301
+ continue
302
+ visible.append(path)
303
+ return tuple(dict.fromkeys(visible)), ""
304
+
305
+
306
+ def _known_support_path(lower_path: str) -> bool:
307
+ path = PurePosixPath(lower_path)
308
+ parts = path.parts
309
+ if lower_path in _SUPPORT_ROOT_FILES:
310
+ return True
311
+ if parts and parts[0] in _SUPPORT_ROOT_DIRS:
312
+ return True
313
+ if lower_path == ".prizmkit-worktree" or lower_path.startswith(
314
+ ".prizmkit-worktree/"
315
+ ):
316
+ return True
317
+ if (
318
+ len(parts) >= 2
319
+ and parts[0].startswith(".")
320
+ and parts[1] in {"worktree", "worktrees"}
321
+ ):
322
+ return True
323
+ return lower_path == ".prizmkit" or lower_path.startswith(".prizmkit/")
324
+
325
+
326
+ def _status_record_paths(stdout: str) -> tuple[str, ...]:
327
+ records = stdout.split("\0")
328
+ changed: list[str] = []
329
+ index = 0
330
+ while index < len(records):
331
+ record = records[index]
332
+ index += 1
333
+ if not record or len(record) < 4 or record[2] != " ":
334
+ continue
335
+ status = record[:2]
336
+ changed.append(record[3:])
337
+ if "R" in status or "C" in status:
338
+ if index < len(records) and records[index]:
339
+ changed.append(records[index])
340
+ index += 1
341
+ return tuple(dict.fromkeys(path for path in changed if path))
342
+
343
+
344
+ def _stage_intended_paths(project_root: Path, paths: Sequence[str]) -> _GitResult:
345
+ pathspecs = tuple(f":(top,literal){path}" for path in paths)
346
+ return _git(project_root, "add", "-A", "--", *pathspecs)
347
+
348
+
349
+ def _matches_prepared_commit(project_root: Path, request: RuntimeCommitRequest, head: str) -> bool:
350
+ parents = _git(project_root, "show", "-s", "--format=%P", head)
351
+ subject = _git(project_root, "show", "-s", "--format=%s", head)
352
+ paths = _git(
353
+ project_root,
354
+ "diff-tree", "--no-commit-id", "--name-only", "--no-renames", "-r", "-z", head,
355
+ )
356
+ if any(result.return_code != 0 for result in (parents, subject, paths)):
357
+ return False
358
+ parent_list = parents.stdout.strip().split()
359
+ changed = {path for path in paths.stdout.split("\0") if path}
360
+ return (
361
+ len(parent_list) == 1
362
+ and parent_list[0].lower() == request.base_head.lower()
363
+ and subject.stdout.strip() == request.commit_message
364
+ and bool(changed)
365
+ and changed == set(request.intended_paths)
366
+ )
367
+
368
+
369
+ def _record_runtime_commit_hash(path: Path, commit_hash: str) -> str:
370
+ try:
371
+ data = json.loads(path.read_text(encoding="utf-8"))
372
+ except (OSError, UnicodeError, json.JSONDecodeError):
373
+ return "commit_request_hash_record_failed"
374
+ if not isinstance(data, dict):
375
+ return "commit_request_hash_record_failed"
376
+ data["runtime_commit_hash"] = commit_hash
377
+ error = _write_json_atomic(path, data)
378
+ return "commit_request_hash_record_failed" if error else ""
379
+
380
+
381
+ def _is_tracked(project_root: Path, path: Path) -> bool:
382
+ try:
383
+ relative = path.relative_to(project_root).as_posix()
384
+ except ValueError:
385
+ return True
386
+ return _git(project_root, "ls-files", "--error-unmatch", "--", relative).return_code == 0
387
+
388
+
389
+ def _finalize_checkpoint(
390
+ path: Path,
391
+ *,
392
+ commit_hash: str,
393
+ base_head: str,
394
+ ) -> str:
395
+ """Finalize COMMITTED and persist the Runtime's verified commit receipt."""
396
+ state = load_checkpoint_state(path)
397
+ if not state.valid:
398
+ return "checkpoint_commit_finalization_rejected"
399
+ if state.semantic_complete:
400
+ return ""
401
+ if not state.semantic_commit_ready:
402
+ return "checkpoint_commit_finalization_rejected"
403
+ data = dict(state.raw_data)
404
+ raw_steps = data.get("steps")
405
+ if not isinstance(raw_steps, list):
406
+ return "checkpoint_commit_finalization_rejected"
407
+ committers = [
408
+ step for step in raw_steps
409
+ if isinstance(step, dict) and step.get("skill") == "prizmkit-committer"
410
+ ]
411
+ if len(committers) != 1:
412
+ return "checkpoint_commit_finalization_rejected"
413
+ committer = committers[0]
414
+ committer["status"] = "completed"
415
+ committer["stage_result"] = "COMMITTED"
416
+
417
+ state_key = semantic_state_key(str(data.get("workflow_type") or ""))
418
+ semantic = data.get(state_key)
419
+ if not isinstance(semantic, dict):
420
+ return "checkpoint_commit_finalization_rejected"
421
+ semantic.update(
422
+ {
423
+ "schema_version": 1,
424
+ "orchestrator": "pipeline-runtime",
425
+ "stage": "committer",
426
+ "status": "completed",
427
+ "stage_result": "COMMITTED",
428
+ "current_stage": None,
429
+ "next_stage": None,
430
+ "resume_from": None,
431
+ "blocked_reason": None,
432
+ "terminal_status": "WORKFLOW_COMPLETED",
433
+ "runtime_base_head": base_head,
434
+ "runtime_commit_hash": commit_hash,
435
+ }
436
+ )
437
+ candidate = validate_checkpoint_data(data, path=path, exists=True)
438
+ if not candidate.valid or not candidate.semantic_complete:
439
+ return "checkpoint_commit_finalization_rejected"
440
+ if _write_json_atomic(path, data):
441
+ return "checkpoint_commit_finalization_failed"
442
+ finalized = load_checkpoint_state(path)
443
+ return "" if finalized.semantic_complete else "checkpoint_commit_finalization_rejected"
444
+
445
+
446
+ def _write_json_atomic(path: Path, data: Mapping[str, object]) -> str:
447
+ temp_path: str | None = None
448
+ try:
449
+ path.parent.mkdir(parents=True, exist_ok=True)
450
+ fd, temp_path = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
451
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
452
+ json.dump(data, handle, indent=2, ensure_ascii=False)
453
+ handle.write("\n")
454
+ os.replace(temp_path, path)
455
+ except OSError:
456
+ if temp_path:
457
+ try:
458
+ os.unlink(temp_path)
459
+ except OSError:
460
+ pass
461
+ return "atomic_json_write_failed"
462
+ return ""
463
+
464
+
465
+ def _git(project_root: Path, *args: str) -> _GitResult:
466
+ completed = subprocess.run(
467
+ ["git", *args],
468
+ cwd=project_root,
469
+ stdout=subprocess.PIPE,
470
+ stderr=subprocess.PIPE,
471
+ text=True,
472
+ check=False,
473
+ )
474
+ return _GitResult(completed.returncode, completed.stdout, completed.stderr)
475
+
476
+
477
+ def _within(path: Path, root: Path) -> bool:
478
+ try:
479
+ path.resolve().relative_to(root.resolve())
480
+ except ValueError:
481
+ return False
482
+ return True
@@ -2,7 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from collections.abc import Mapping
5
+ import json
6
+ from collections.abc import Callable, Mapping
6
7
  from dataclasses import dataclass, field
7
8
  from pathlib import Path
8
9
 
@@ -69,3 +70,107 @@ def default_status_targets(paths: RuntimePaths) -> dict[str, StatusTarget]:
69
70
  "bugfix": StatusTarget("bugfix", paths.bugfix_plan, paths.bugfix_state_dir),
70
71
  "refactor": StatusTarget("refactor", paths.refactor_plan, paths.refactor_state_dir),
71
72
  }
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class AggregateStatusResult:
77
+ """Deterministic read-only status output across every pipeline family."""
78
+
79
+ exit_code: int
80
+ output: str
81
+
82
+
83
+ _FAMILY_PLAN_KEYS = {
84
+ "feature": "features",
85
+ "bugfix": "bugs",
86
+ "refactor": "refactors",
87
+ }
88
+
89
+
90
+ def _load_json_object(path: Path, label: str) -> dict[str, object]:
91
+ try:
92
+ data = json.loads(path.read_text(encoding="utf-8"))
93
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
94
+ raise ValueError(f"malformed {label}: {exc}") from exc
95
+ if not isinstance(data, dict):
96
+ raise ValueError(f"malformed {label}: expected a JSON object")
97
+ return data
98
+
99
+
100
+ def _validate_status_inputs(target: StatusTarget) -> None:
101
+ """Validate only state consumed by status, without creating or repairing it."""
102
+ plan = _load_json_object(target.plan_path, "plan list")
103
+ items = plan.get(_FAMILY_PLAN_KEYS[target.kind])
104
+ if not isinstance(items, list):
105
+ raise ValueError(
106
+ f"malformed plan list: {_FAMILY_PLAN_KEYS[target.kind]} must be an array"
107
+ )
108
+
109
+ pipeline_state = target.state_dir / "pipeline.json"
110
+ if pipeline_state.exists():
111
+ if not pipeline_state.is_file():
112
+ raise ValueError("malformed runtime state: pipeline.json is not a file")
113
+ _load_json_object(pipeline_state, "runtime state pipeline.json")
114
+
115
+ for item in items:
116
+ if not isinstance(item, dict):
117
+ raise ValueError("malformed plan list: every task must be a JSON object")
118
+ item_id = item.get("id")
119
+ if not isinstance(item_id, str) or not item_id.strip():
120
+ raise ValueError("malformed plan list: every task requires a non-empty id")
121
+ status_path = target.state_dir / item_id / "status.json"
122
+ if status_path.exists():
123
+ if not status_path.is_file():
124
+ raise ValueError(
125
+ f"malformed runtime state: {item_id}/status.json is not a file"
126
+ )
127
+ _load_json_object(status_path, f"runtime state {item_id}/status.json")
128
+
129
+
130
+ def _render_family_result(result: object) -> tuple[int, str]:
131
+ exit_code = getattr(result, "exit_code", None)
132
+ render = getattr(result, "render", None)
133
+ if not isinstance(exit_code, int) or not callable(render):
134
+ raise ValueError("unparseable configured family result")
135
+ try:
136
+ rendered = render()
137
+ except Exception as exc: # isolate one broken family result
138
+ raise ValueError(f"unparseable configured family result: {exc}") from exc
139
+ if not isinstance(rendered, str) or not rendered.strip():
140
+ raise ValueError("unparseable configured family result: empty output")
141
+ return exit_code, rendered.strip()
142
+
143
+
144
+ def aggregate_family_status(
145
+ paths: RuntimePaths,
146
+ status_handler: Callable[[str], object],
147
+ ) -> AggregateStatusResult:
148
+ """Aggregate canonical family status handlers without mutating runtime state."""
149
+ targets = default_status_targets(paths)
150
+ sections: list[str] = []
151
+ failed = False
152
+
153
+ for family in ("feature", "bugfix", "refactor"):
154
+ target = targets[family]
155
+ label = family.upper()
156
+ body: str
157
+ if not target.plan_path.exists():
158
+ body = "not configured: plan list not found"
159
+ elif not target.plan_path.is_file():
160
+ failed = True
161
+ body = "error: malformed plan list: path is not a file"
162
+ else:
163
+ try:
164
+ _validate_status_inputs(target)
165
+ family_exit, rendered = _render_family_result(status_handler(family))
166
+ if family_exit != 0:
167
+ failed = True
168
+ body = f"error: {rendered}"
169
+ else:
170
+ body = rendered
171
+ except Exception as exc: # render remaining family sections truthfully
172
+ failed = True
173
+ body = f"error: {exc}"
174
+ sections.append(f"{label}\n{'=' * len(label)}\n{body}")
175
+
176
+ return AggregateStatusResult(1 if failed else 0, "\n\n".join(sections))