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.
- 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 +120 -113
- package/bundled/dev-pipeline/assets/skill-subagent-integration.md +1 -1
- package/bundled/dev-pipeline/prizmkit_runtime/checkpoint_state.py +355 -15
- package/bundled/dev-pipeline/prizmkit_runtime/cli.py +189 -110
- package/bundled/dev-pipeline/prizmkit_runtime/commands.py +146 -111
- package/bundled/dev-pipeline/prizmkit_runtime/daemon.py +4 -20
- package/bundled/dev-pipeline/prizmkit_runtime/gitops.py +48 -14
- package/bundled/dev-pipeline/prizmkit_runtime/interoperability.py +0 -1
- package/bundled/dev-pipeline/prizmkit_runtime/paths.py +0 -3
- package/bundled/dev-pipeline/prizmkit_runtime/reset.py +122 -15
- package/bundled/dev-pipeline/prizmkit_runtime/reset_preserve.py +1 -1
- package/bundled/dev-pipeline/prizmkit_runtime/runner_classification.py +50 -2
- package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +1 -10
- package/bundled/dev-pipeline/prizmkit_runtime/runner_prompts.py +3 -3
- package/bundled/dev-pipeline/prizmkit_runtime/runners.py +139 -74
- package/bundled/dev-pipeline/prizmkit_runtime/runtime_commit.py +482 -0
- package/bundled/dev-pipeline/prizmkit_runtime/status.py +106 -1
- 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/init-bugfix-pipeline.py +2 -2
- package/bundled/dev-pipeline/scripts/prompt_framework.py +36 -41
- package/bundled/dev-pipeline/scripts/update-bug-status.py +197 -71
- package/bundled/dev-pipeline/scripts/update-checkpoint.py +77 -16
- package/bundled/dev-pipeline/scripts/update-feature-status.py +46 -121
- package/bundled/dev-pipeline/scripts/update-refactor-status.py +46 -123
- package/bundled/dev-pipeline/scripts/utils.py +119 -0
- 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/bug-fix-list-schema.json +3 -2
- 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_auto_skip.py +111 -32
- package/bundled/dev-pipeline/tests/test_checkpoint_state.py +617 -30
- package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +262 -194
- 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 +281 -206
- package/bundled/dev-pipeline/tests/test_recovery_workflow.py +322 -0
- package/bundled/dev-pipeline/tests/test_reset_modes.py +935 -0
- package/bundled/dev-pipeline/tests/test_reset_preserve.py +5 -7
- package/bundled/dev-pipeline/tests/test_runtime_commit.py +608 -0
- package/bundled/dev-pipeline/tests/test_unified_cli.py +617 -188
- 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/bug-planner/references/schema-validation.md +1 -1
- package/bundled/skills/bug-planner/scripts/validate-bug-list.py +1 -1
- package/bundled/skills/bugfix-pipeline-launcher/SKILL.md +21 -13
- package/bundled/skills/feature-pipeline-launcher/SKILL.md +22 -14
- 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 +11 -1
- package/bundled/skills/prizmkit-retrospective/SKILL.md +63 -77
- package/bundled/skills/prizmkit-retrospective/references/knowledge-injection-steps.md +3 -3
- package/bundled/skills/prizmkit-retrospective/references/retrospective-result-schema.json +138 -0
- package/bundled/skills/prizmkit-retrospective/references/structural-sync-steps.md +17 -23
- 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 +9 -6
- package/bundled/skills/recovery-workflow/evals/evals.json +2 -2
- package/bundled/skills/recovery-workflow/references/detection.md +7 -6
- package/bundled/skills/recovery-workflow/scripts/detect-recovery-state.py +16 -6
- package/bundled/skills/refactor-pipeline-launcher/SKILL.md +21 -13
- package/bundled/templates/project-memory-template.md +19 -11
- package/package.json +1 -1
- package/src/scaffold.js +33 -22
- package/bundled/dev-pipeline/prizmkit_runtime/runner_recovery.py +0 -228
- package/bundled/dev-pipeline/scripts/generate-recovery-prompt.py +0 -767
- 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
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
|
|
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))
|