prizmkit 1.1.154 → 1.1.156
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.
- package/bundled/VERSION.json +3 -3
- package/bundled/adapters/codex/skill-adapter.js +1 -1
- package/bundled/adapters/pi/skill-adapter.js +2 -1
- package/bundled/dev-pipeline/.env.example +2 -6
- package/bundled/dev-pipeline/README.md +21 -27
- package/bundled/dev-pipeline/prizmkit_runtime/checkpoint_state.py +341 -15
- package/bundled/dev-pipeline/prizmkit_runtime/cli.py +0 -3
- package/bundled/dev-pipeline/prizmkit_runtime/daemon.py +1 -15
- package/bundled/dev-pipeline/prizmkit_runtime/gitops.py +48 -14
- package/bundled/dev-pipeline/prizmkit_runtime/runner_classification.py +50 -2
- package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +0 -9
- package/bundled/dev-pipeline/prizmkit_runtime/runner_prompts.py +3 -3
- package/bundled/dev-pipeline/prizmkit_runtime/runners.py +120 -22
- package/bundled/dev-pipeline/prizmkit_runtime/runtime_commit.py +482 -0
- package/bundled/dev-pipeline/prizmkit_runtime/test_result.py +64 -11
- package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +145 -367
- package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +91 -62
- package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +105 -69
- package/bundled/dev-pipeline/scripts/prompt_framework.py +36 -41
- package/bundled/dev-pipeline/scripts/update-bug-status.py +1 -4
- package/bundled/dev-pipeline/scripts/update-checkpoint.py +61 -13
- package/bundled/dev-pipeline/scripts/update-feature-status.py +1 -4
- package/bundled/dev-pipeline/scripts/update-refactor-status.py +1 -4
- package/bundled/dev-pipeline/templates/bootstrap-prompt.md +22 -27
- package/bundled/dev-pipeline/templates/bootstrap-tier3.md +71 -104
- package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +59 -111
- package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +37 -72
- package/bundled/dev-pipeline/templates/sections/artifact-manifest.md +39 -0
- package/bundled/dev-pipeline/templates/sections/bugfix-critical-paths.md +3 -1
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-commit-report.md +8 -25
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-diagnose-plan.md +3 -3
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-implement.md +2 -2
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-review.md +3 -3
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-test.md +2 -2
- package/bundled/dev-pipeline/templates/sections/bugfix-reminders.md +4 -4
- package/bundled/dev-pipeline/templates/sections/bugfix-session-context.md +0 -1
- package/bundled/dev-pipeline/templates/sections/checkpoint-system.md +7 -4
- package/bundled/dev-pipeline/templates/sections/context-budget-rules.md +14 -27
- package/bundled/dev-pipeline/templates/sections/feature-completion-summary.md +20 -0
- package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +8 -71
- package/bundled/dev-pipeline/templates/sections/phase-implement-full.md +7 -7
- package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +3 -3
- package/bundled/dev-pipeline/templates/sections/phase-review-full.md +4 -4
- package/bundled/dev-pipeline/templates/sections/phase-specify-plan-full.md +5 -17
- package/bundled/dev-pipeline/templates/sections/refactor-critical-paths.md +3 -1
- package/bundled/dev-pipeline/templates/sections/refactor-phase-commit-report.md +10 -23
- package/bundled/dev-pipeline/templates/sections/refactor-phase-implement.md +2 -2
- package/bundled/dev-pipeline/templates/sections/refactor-phase-plan.md +2 -2
- package/bundled/dev-pipeline/templates/sections/refactor-phase-review.md +3 -3
- package/bundled/dev-pipeline/templates/sections/refactor-phase-test.md +2 -2
- package/bundled/dev-pipeline/templates/sections/refactor-reminders.md +7 -7
- package/bundled/dev-pipeline/templates/sections/refactor-session-context.md +0 -1
- package/bundled/dev-pipeline/templates/sections/retrospective.md +12 -0
- package/bundled/dev-pipeline/templates/sections/runtime-commit-handoff.md +35 -0
- package/bundled/dev-pipeline/templates/session-status-schema.json +45 -62
- package/bundled/dev-pipeline/tests/test_checkpoint_state.py +469 -18
- package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +262 -175
- package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +62 -23
- package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +76 -28
- package/bundled/dev-pipeline/tests/test_python_runner_parity.py +168 -18
- package/bundled/dev-pipeline/tests/test_recovery_workflow.py +111 -0
- package/bundled/dev-pipeline/tests/test_reset_preserve.py +0 -2
- package/bundled/dev-pipeline/tests/test_runtime_commit.py +608 -0
- package/bundled/dev-pipeline/tests/test_unified_cli.py +167 -25
- package/bundled/skills/_metadata.json +9 -9
- package/bundled/skills/app-planner/SKILL.md +3 -3
- package/bundled/skills/app-planner/references/generated-plan-review.md +2 -1
- package/bundled/skills/app-planner/references/rules/backend/derivation-rules.md +56 -64
- package/bundled/skills/app-planner/references/rules/backend/fixed-rules.md +85 -240
- package/bundled/skills/app-planner/references/rules/backend/question-bank.md +25 -25
- package/bundled/skills/app-planner/references/rules/backend/question-manifest.json +16 -7
- package/bundled/skills/app-planner/references/rules/backend/template.md +74 -92
- package/bundled/skills/app-planner/references/rules/database/derivation-rules.md +93 -93
- package/bundled/skills/app-planner/references/rules/database/fixed-rules.md +66 -173
- package/bundled/skills/app-planner/references/rules/database/question-bank.md +31 -26
- package/bundled/skills/app-planner/references/rules/database/question-manifest.json +13 -4
- package/bundled/skills/app-planner/references/rules/database/template.md +76 -98
- package/bundled/skills/app-planner/references/rules/frontend/derivation-rules.md +68 -95
- package/bundled/skills/app-planner/references/rules/frontend/fixed-rules.md +93 -166
- package/bundled/skills/app-planner/references/rules/frontend/question-bank.md +46 -36
- package/bundled/skills/app-planner/references/rules/frontend/question-manifest.json +11 -2
- package/bundled/skills/app-planner/references/rules/frontend/template.md +79 -257
- package/bundled/skills/app-planner/references/rules/mobile/derivation-rules.md +91 -99
- package/bundled/skills/app-planner/references/rules/mobile/fixed-rules.md +73 -246
- package/bundled/skills/app-planner/references/rules/mobile/question-bank.md +17 -17
- package/bundled/skills/app-planner/references/rules/mobile/question-manifest.json +17 -2
- package/bundled/skills/app-planner/references/rules/mobile/template.md +73 -85
- package/bundled/skills/app-planner/references/rules-configuration.md +133 -65
- package/bundled/skills/app-planner/scripts/validate-rules-configuration.py +291 -0
- package/bundled/skills/prizmkit/SKILL.md +17 -18
- package/bundled/skills/prizmkit/references/workflow-state-protocol.md +22 -18
- package/bundled/skills/prizmkit-code-review/SKILL.md +22 -58
- package/bundled/skills/prizmkit-code-review/references/independent-code-review.md +6 -6
- package/bundled/skills/prizmkit-code-review/references/review-report-template.md +2 -2
- package/bundled/skills/prizmkit-committer/SKILL.md +82 -132
- package/bundled/skills/prizmkit-deploy/SKILL.md +27 -11
- package/bundled/skills/prizmkit-deploy/assets/deploy-document-template.md +89 -0
- package/bundled/skills/prizmkit-deploy/references/ci-cd-workflows.md +44 -14
- package/bundled/skills/prizmkit-deploy/references/cloud-platform-deploy.md +19 -3
- package/bundled/skills/prizmkit-deploy/references/database-setup.md +22 -12
- package/bundled/skills/prizmkit-deploy/references/deploy-config-schema.md +3 -1
- package/bundled/skills/prizmkit-deploy/references/deploy-history-schema.md +22 -6
- package/bundled/skills/prizmkit-deploy/references/deploy-metadata-schema.json +49 -0
- package/bundled/skills/prizmkit-deploy/references/live-validation-notes.md +1 -1
- package/bundled/skills/prizmkit-deploy/references/pending-input-schema.json +164 -0
- package/bundled/skills/prizmkit-deploy/references/ssh-adapter-flow.md +9 -8
- package/bundled/skills/prizmkit-deploy/references/ssh-execution-flow.md +6 -6
- package/bundled/skills/prizmkit-implement/SKILL.md +30 -66
- package/bundled/skills/prizmkit-implement/references/implementation-subagent-procedure.md +4 -4
- package/bundled/skills/prizmkit-init/SKILL.md +4 -0
- package/bundled/skills/prizmkit-plan/SKILL.md +21 -64
- package/bundled/skills/prizmkit-plan/references/artifact-identity.md +38 -0
- package/bundled/skills/prizmkit-plan/references/independent-plan-review.md +4 -4
- package/bundled/skills/prizmkit-plan/references/review-plan-spec-loop.md +5 -5
- package/bundled/skills/prizmkit-prizm-docs/SKILL.md +75 -11
- package/bundled/skills/prizmkit-prizm-docs/assets/prizm-docs-format.md +191 -56
- package/bundled/skills/prizmkit-prizm-docs/references/op-init.md +18 -18
- package/bundled/skills/prizmkit-prizm-docs/references/op-rebuild.md +14 -8
- package/bundled/skills/prizmkit-prizm-docs/references/op-status.md +12 -5
- package/bundled/skills/prizmkit-prizm-docs/references/op-update.md +23 -12
- package/bundled/skills/prizmkit-prizm-docs/references/op-validate.md +13 -9
- package/bundled/skills/prizmkit-retrospective/SKILL.md +94 -97
- package/bundled/skills/prizmkit-retrospective/references/knowledge-injection-steps.md +79 -37
- package/bundled/skills/prizmkit-retrospective/references/retrospective-result-schema.json +138 -0
- package/bundled/skills/prizmkit-retrospective/references/structural-sync-steps.md +111 -54
- package/bundled/skills/prizmkit-test/SKILL.md +64 -51
- package/bundled/skills/prizmkit-test/references/boundary-contract-and-test-double-guidance.md +206 -0
- package/bundled/skills/prizmkit-test/references/independent-test-review.md +17 -9
- package/bundled/skills/prizmkit-test/references/test-coverage-model.md +45 -21
- package/bundled/skills/prizmkit-test/references/test-report-template.md +20 -13
- package/bundled/skills/prizmkit-workflow/SKILL.md +35 -37
- package/bundled/skills/prizmkit-workflow/references/artifact-identity.md +34 -0
- package/bundled/skills/prizmkit-workflow/references/workflow-state-protocol.md +22 -18
- package/bundled/skills/recovery-workflow/SKILL.md +2 -1
- package/bundled/skills/recovery-workflow/references/detection.md +4 -3
- package/bundled/skills/recovery-workflow/scripts/detect-recovery-state.py +15 -5
- package/bundled/templates/hooks/validate-prizm-docs.py +124 -12
- package/package.json +1 -1
- package/src/scaffold.js +33 -22
- package/bundled/dev-pipeline/templates/sections/bugfix-artifacts.md +0 -16
- package/bundled/dev-pipeline/templates/sections/bugfix-session-status.md +0 -31
- package/bundled/dev-pipeline/templates/sections/critical-paths-agent.md +0 -7
- package/bundled/dev-pipeline/templates/sections/critical-paths-lite.md +0 -7
- package/bundled/dev-pipeline/templates/sections/directory-convention-agent.md +0 -9
- package/bundled/dev-pipeline/templates/sections/directory-convention-lite.md +0 -6
- package/bundled/dev-pipeline/templates/sections/headless-commit-authorization.md +0 -15
- package/bundled/dev-pipeline/templates/sections/phase-commit.md +0 -61
- package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-agent-suffix.md +0 -24
- package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-lite-suffix.md +0 -12
- package/bundled/dev-pipeline/templates/sections/phase-implement-agent.md +0 -44
- package/bundled/dev-pipeline/templates/sections/phase-implement-lite.md +0 -25
- package/bundled/dev-pipeline/templates/sections/phase-plan-agent.md +0 -20
- package/bundled/dev-pipeline/templates/sections/phase-plan-lite.md +0 -20
- package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +0 -20
- package/bundled/dev-pipeline/templates/sections/refactor-artifacts.md +0 -17
- package/bundled/dev-pipeline/templates/sections/refactor-session-status.md +0 -28
- package/bundled/dev-pipeline/templates/sections/test-failure-recovery-agent.md +0 -52
- package/bundled/dev-pipeline/templates/sections/test-failure-recovery-lite.md +0 -52
- package/bundled/skills/prizmkit-code-review/references/workflow-state-protocol.md +0 -174
- package/bundled/skills/prizmkit-committer/references/workflow-state-protocol.md +0 -174
- package/bundled/skills/prizmkit-implement/references/workflow-state-protocol.md +0 -174
- package/bundled/skills/prizmkit-plan/references/workflow-state-protocol.md +0 -174
- package/bundled/skills/prizmkit-retrospective/references/workflow-state-protocol.md +0 -174
- 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
|
|
@@ -23,8 +23,6 @@ _RESULT_KEYS = {
|
|
|
23
23
|
"independent_review",
|
|
24
24
|
"repair_rounds",
|
|
25
25
|
"production_changed",
|
|
26
|
-
"review_required",
|
|
27
|
-
"review_scope",
|
|
28
26
|
"unresolved_items",
|
|
29
27
|
}
|
|
30
28
|
_INDEPENDENT_REVIEW_KEYS = {
|
|
@@ -33,14 +31,40 @@ _INDEPENDENT_REVIEW_KEYS = {
|
|
|
33
31
|
"downgrade_reason",
|
|
34
32
|
"final_state_rechecked",
|
|
35
33
|
}
|
|
34
|
+
_LEGACY_RESULT_KEYS = _RESULT_KEYS | {"review_required", "review_scope"}
|
|
36
35
|
|
|
37
36
|
|
|
38
37
|
def load_test_result(path: str | Path) -> dict[str, Any] | None:
|
|
39
|
-
"""Load one
|
|
38
|
+
"""Load one strict route-free terminal projection, or return None."""
|
|
40
39
|
try:
|
|
41
40
|
value = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
42
41
|
except (OSError, ValueError, TypeError):
|
|
43
42
|
return None
|
|
43
|
+
return _validate_test_result_value(value)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_legacy_test_result(path: str | Path) -> dict[str, Any] | None:
|
|
47
|
+
"""Read a historical pass projection without permitting new legacy output."""
|
|
48
|
+
try:
|
|
49
|
+
value = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
50
|
+
except (OSError, ValueError, TypeError):
|
|
51
|
+
return None
|
|
52
|
+
if not isinstance(value, dict):
|
|
53
|
+
return None
|
|
54
|
+
if set(value) == _RESULT_KEYS:
|
|
55
|
+
return _validate_test_result_value(value)
|
|
56
|
+
if set(value) != _LEGACY_RESULT_KEYS:
|
|
57
|
+
return None
|
|
58
|
+
if value.get("review_required") is not False or value.get("review_scope") is not None:
|
|
59
|
+
return None
|
|
60
|
+
normalized = {
|
|
61
|
+
key: item for key, item in value.items()
|
|
62
|
+
if key not in {"review_required", "review_scope"}
|
|
63
|
+
}
|
|
64
|
+
return _validate_test_result_value(normalized)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _validate_test_result_value(value: object) -> dict[str, Any] | None:
|
|
44
68
|
if not isinstance(value, dict):
|
|
45
69
|
return None
|
|
46
70
|
if set(value) != _RESULT_KEYS or value.get("schema_version") != 1:
|
|
@@ -79,11 +103,6 @@ def load_test_result(path: str | Path) -> dict[str, Any] | None:
|
|
|
79
103
|
|
|
80
104
|
if not isinstance(value.get("production_changed"), bool):
|
|
81
105
|
return None
|
|
82
|
-
if not isinstance(value.get("review_required"), bool):
|
|
83
|
-
return None
|
|
84
|
-
expected_scope = "delta" if value["review_required"] else None
|
|
85
|
-
if value.get("review_scope") != expected_scope:
|
|
86
|
-
return None
|
|
87
106
|
unresolved = value.get("unresolved_items")
|
|
88
107
|
if not isinstance(unresolved, list) or any(
|
|
89
108
|
not isinstance(item, str) or not item.strip() for item in unresolved
|
|
@@ -91,8 +110,10 @@ def load_test_result(path: str | Path) -> dict[str, Any] | None:
|
|
|
91
110
|
return None
|
|
92
111
|
|
|
93
112
|
if value["result"] == "TEST_PASS":
|
|
94
|
-
if
|
|
113
|
+
if unresolved or rounds < 1:
|
|
95
114
|
return None
|
|
115
|
+
elif not unresolved:
|
|
116
|
+
return None
|
|
96
117
|
return value
|
|
97
118
|
|
|
98
119
|
|
|
@@ -110,14 +131,46 @@ def valid_test_artifact_pair(
|
|
|
110
131
|
*,
|
|
111
132
|
required_result: str | None = None,
|
|
112
133
|
) -> bool:
|
|
113
|
-
"""Validate one sibling report/result pair and
|
|
134
|
+
"""Validate one strict sibling report/result pair and terminal result."""
|
|
135
|
+
return _valid_test_artifact_pair(
|
|
136
|
+
report_path,
|
|
137
|
+
result_path,
|
|
138
|
+
required_result=required_result,
|
|
139
|
+
legacy=False,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def valid_legacy_test_artifact_pair(
|
|
144
|
+
report_path: str | Path,
|
|
145
|
+
result_path: str | Path,
|
|
146
|
+
*,
|
|
147
|
+
required_result: str | None = None,
|
|
148
|
+
) -> bool:
|
|
149
|
+
"""Validate a historical completed pair for read-only recovery."""
|
|
150
|
+
return _valid_test_artifact_pair(
|
|
151
|
+
report_path,
|
|
152
|
+
result_path,
|
|
153
|
+
required_result=required_result,
|
|
154
|
+
legacy=True,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _valid_test_artifact_pair(
|
|
159
|
+
report_path: str | Path,
|
|
160
|
+
result_path: str | Path,
|
|
161
|
+
*,
|
|
162
|
+
required_result: str | None,
|
|
163
|
+
legacy: bool,
|
|
164
|
+
) -> bool:
|
|
114
165
|
report = Path(report_path)
|
|
115
166
|
result = Path(result_path)
|
|
116
167
|
if not report.is_file() or not result.is_file():
|
|
117
168
|
return False
|
|
118
169
|
if report.resolve().parent != result.resolve().parent:
|
|
119
170
|
return False
|
|
120
|
-
projection =
|
|
171
|
+
projection = (
|
|
172
|
+
load_legacy_test_result(result) if legacy else load_test_result(result)
|
|
173
|
+
)
|
|
121
174
|
if projection is None:
|
|
122
175
|
return False
|
|
123
176
|
if required_result is not None and projection.get("result") != required_result:
|