hardproof 0.1.1__py3-none-any.whl
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.
- hardproof/__init__.py +3 -0
- hardproof/commands/__init__.py +1 -0
- hardproof/commands/cli.py +94 -0
- hardproof/commands/shared.py +463 -0
- hardproof/commands/slash.py +32 -0
- hardproof/compat.py +88 -0
- hardproof/config.py +152 -0
- hardproof/constants.py +5 -0
- hardproof/domain/__init__.py +7 -0
- hardproof/domain/enums.py +76 -0
- hardproof/domain/models.py +316 -0
- hardproof/domain/snapshots.py +80 -0
- hardproof/domain/transitions.py +32 -0
- hardproof/errors.py +13 -0
- hardproof/hooks/__init__.py +1 -0
- hardproof/hooks/context.py +99 -0
- hardproof/hooks/sessions.py +29 -0
- hardproof/hooks/tool_policy.py +113 -0
- hardproof/hooks/verification.py +54 -0
- hardproof/migrations/001_initial.sql +108 -0
- hardproof/migrations/__init__.py +1 -0
- hardproof/paths.py +51 -0
- hardproof/plugin.py +175 -0
- hardproof/policy/__init__.py +1 -0
- hardproof/policy/profiles.py +59 -0
- hardproof/policy/stage_rules.py +122 -0
- hardproof/policy/tool_rules.py +144 -0
- hardproof/policy/verification_rules.py +26 -0
- hardproof/py.typed +1 -0
- hardproof/services/__init__.py +1 -0
- hardproof/services/approvals.py +35 -0
- hardproof/services/artifacts.py +67 -0
- hardproof/services/decisions.py +37 -0
- hardproof/services/evidence.py +221 -0
- hardproof/services/reports.py +259 -0
- hardproof/services/runs.py +71 -0
- hardproof/services/sessions.py +53 -0
- hardproof/services/tasks.py +92 -0
- hardproof/skills/deliver/SKILL.md +34 -0
- hardproof/skills/design/SKILL.md +34 -0
- hardproof/skills/discover/SKILL.md +34 -0
- hardproof/skills/implement/SKILL.md +35 -0
- hardproof/skills/learn/SKILL.md +34 -0
- hardproof/skills/orchestrate/SKILL.md +35 -0
- hardproof/skills/plan/SKILL.md +34 -0
- hardproof/skills/review/SKILL.md +34 -0
- hardproof/skills/verify/SKILL.md +34 -0
- hardproof/storage/__init__.py +6 -0
- hardproof/storage/database.py +50 -0
- hardproof/storage/migrations.py +73 -0
- hardproof/storage/repository.py +321 -0
- hardproof/templates/__init__.py +1 -0
- hardproof/templates/completion.md +19 -0
- hardproof/templates/design.md +19 -0
- hardproof/templates/discovery.md +19 -0
- hardproof/templates/plan.md +19 -0
- hardproof/templates/review.md +19 -0
- hardproof/tools/__init__.py +1 -0
- hardproof/tools/handlers.py +212 -0
- hardproof/tools/schemas.py +96 -0
- hardproof-0.1.1.dist-info/METADATA +179 -0
- hardproof-0.1.1.dist-info/RECORD +67 -0
- hardproof-0.1.1.dist-info/WHEEL +5 -0
- hardproof-0.1.1.dist-info/entry_points.txt +2 -0
- hardproof-0.1.1.dist-info/licenses/LICENSE +175 -0
- hardproof-0.1.1.dist-info/licenses/NOTICE +14 -0
- hardproof-0.1.1.dist-info/top_level.txt +1 -0
hardproof/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared, slash, and terminal command surfaces."""
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""`hermes hardproof` argparse adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from hardproof.commands.shared import CommandService
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _configure(parser: argparse.ArgumentParser) -> None:
|
|
12
|
+
sub = parser.add_subparsers(dest="hardproof_command", required=True)
|
|
13
|
+
start = sub.add_parser("start")
|
|
14
|
+
start.add_argument("profile", choices=("quick", "standard", "critical"))
|
|
15
|
+
start.add_argument("request", nargs="+")
|
|
16
|
+
sub.add_parser("status")
|
|
17
|
+
approve = sub.add_parser("approve")
|
|
18
|
+
approve.add_argument("gate", choices=("design", "plan", "completion"))
|
|
19
|
+
approve.add_argument("reason", nargs="*")
|
|
20
|
+
waive = sub.add_parser("waive")
|
|
21
|
+
waive.add_argument("gate")
|
|
22
|
+
waive.add_argument("reason", nargs="+")
|
|
23
|
+
pause = sub.add_parser("pause")
|
|
24
|
+
pause.add_argument("reason", nargs="*")
|
|
25
|
+
resume = sub.add_parser("resume")
|
|
26
|
+
resume.add_argument("run_id", nargs="?")
|
|
27
|
+
abort = sub.add_parser("abort")
|
|
28
|
+
abort.add_argument("reason", nargs="+")
|
|
29
|
+
sub.add_parser("evidence")
|
|
30
|
+
export = sub.add_parser("export")
|
|
31
|
+
export.add_argument("path", nargs="?")
|
|
32
|
+
sub.add_parser("doctor")
|
|
33
|
+
sub.add_parser("runs")
|
|
34
|
+
show = sub.add_parser("show")
|
|
35
|
+
show.add_argument("run_id")
|
|
36
|
+
config = sub.add_parser("config").add_subparsers(dest="config_command", required=True)
|
|
37
|
+
config.add_parser("init")
|
|
38
|
+
config.add_parser("validate")
|
|
39
|
+
db = sub.add_parser("db").add_subparsers(dest="db_command", required=True)
|
|
40
|
+
db.add_parser("migrate")
|
|
41
|
+
sub.add_parser("complete")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
45
|
+
parser = argparse.ArgumentParser(prog="hermes hardproof")
|
|
46
|
+
_configure(parser)
|
|
47
|
+
return parser
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _to_argv(args: argparse.Namespace) -> list[str]:
|
|
51
|
+
command = args.hardproof_command
|
|
52
|
+
if command == "start":
|
|
53
|
+
return [command, args.profile, *args.request]
|
|
54
|
+
if command == "approve":
|
|
55
|
+
return [command, args.gate, *args.reason]
|
|
56
|
+
if command == "waive":
|
|
57
|
+
return [command, args.gate, *args.reason]
|
|
58
|
+
if command in {"pause", "abort"}:
|
|
59
|
+
return [command, *args.reason]
|
|
60
|
+
if command == "resume":
|
|
61
|
+
return [command, *([args.run_id] if args.run_id else [])]
|
|
62
|
+
if command == "export":
|
|
63
|
+
return [command, *([args.path] if args.path else [])]
|
|
64
|
+
if command == "show":
|
|
65
|
+
return [command, args.run_id]
|
|
66
|
+
if command == "config":
|
|
67
|
+
return [command, args.config_command]
|
|
68
|
+
if command == "db":
|
|
69
|
+
return [command, args.db_command]
|
|
70
|
+
return [command]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def run_cli(
|
|
74
|
+
args: argparse.Namespace,
|
|
75
|
+
*,
|
|
76
|
+
service_factory: Callable[[], CommandService],
|
|
77
|
+
) -> str:
|
|
78
|
+
try:
|
|
79
|
+
return service_factory().execute(_to_argv(args)).text
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
return f"Hardproof error: {exc}"[:499]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def register_cli(ctx: Any, service_factory: Callable[[], CommandService]) -> None:
|
|
85
|
+
def handler(args: argparse.Namespace) -> str:
|
|
86
|
+
return run_cli(args, service_factory=service_factory)
|
|
87
|
+
|
|
88
|
+
ctx.register_cli_command(
|
|
89
|
+
"hardproof",
|
|
90
|
+
"Manage persistent Hardproof engineering runs",
|
|
91
|
+
_configure,
|
|
92
|
+
handler,
|
|
93
|
+
description="Start, inspect, approve, pause, verify, and export Hardproof runs",
|
|
94
|
+
)
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""Single command implementation used by every human-facing surface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import sqlite3
|
|
10
|
+
import subprocess
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
from uuid import uuid4
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from hardproof.compat import inspect_context
|
|
19
|
+
from hardproof.config import DEFAULTS, ConfigError, load_config
|
|
20
|
+
from hardproof.domain.enums import ApprovalGate, RunProfile, RunStage
|
|
21
|
+
from hardproof.domain.models import Run, SessionBinding, VerificationCheck, new_id, utc_now
|
|
22
|
+
from hardproof.paths import ProjectPaths
|
|
23
|
+
from hardproof.policy.stage_rules import TransitionFacts
|
|
24
|
+
from hardproof.services.approvals import ApprovalService
|
|
25
|
+
from hardproof.services.evidence import evidence_with_freshness
|
|
26
|
+
from hardproof.services.runs import RunService
|
|
27
|
+
from hardproof.services.reports import ReportService
|
|
28
|
+
from hardproof.storage.database import Database, DatabaseCorruptionError
|
|
29
|
+
from hardproof.storage.migrations import MigrationError, migrate
|
|
30
|
+
from hardproof.storage.repository import RunRepository
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_SECRET = re.compile(
|
|
34
|
+
r"(?i)\b(api[_-]?key|token|password|authorization|cookie)\s*[:=]\s*([^\s,;]+)"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _redact(value: str) -> str:
|
|
39
|
+
return _SECRET.sub(lambda match: f"{match.group(1)}=[REDACTED]", value)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class CommandContext:
|
|
44
|
+
project_root: Path
|
|
45
|
+
actor: str
|
|
46
|
+
source: str
|
|
47
|
+
session_id: str | None = None
|
|
48
|
+
platform: str | None = None
|
|
49
|
+
hermes_context: Any | None = None
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
project_root: str | Path,
|
|
54
|
+
*,
|
|
55
|
+
actor: str,
|
|
56
|
+
source: str,
|
|
57
|
+
session_id: str | None = None,
|
|
58
|
+
platform: str | None = None,
|
|
59
|
+
hermes_context: Any | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
object.__setattr__(self, "project_root", Path(project_root).resolve())
|
|
62
|
+
object.__setattr__(self, "actor", actor)
|
|
63
|
+
object.__setattr__(self, "source", source)
|
|
64
|
+
object.__setattr__(self, "session_id", session_id)
|
|
65
|
+
object.__setattr__(self, "platform", platform)
|
|
66
|
+
object.__setattr__(self, "hermes_context", hermes_context)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class CommandResult:
|
|
71
|
+
ok: bool
|
|
72
|
+
text: str
|
|
73
|
+
run_id: str | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class CommandService:
|
|
77
|
+
def __init__(self, context: CommandContext) -> None:
|
|
78
|
+
self.context = context
|
|
79
|
+
self.paths = ProjectPaths(context.project_root)
|
|
80
|
+
self.database = Database(self.paths.database)
|
|
81
|
+
migrate(self.database)
|
|
82
|
+
self.repository = RunRepository(self.database)
|
|
83
|
+
self.run_service = RunService(self.repository)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def _active_pointer(self) -> Path:
|
|
87
|
+
return self.paths.root / "state" / "active-run"
|
|
88
|
+
|
|
89
|
+
def _set_active(self, run_id: str) -> None:
|
|
90
|
+
self._active_pointer.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
temporary = self._active_pointer.with_name(f"active-run.{uuid4().hex}.tmp")
|
|
92
|
+
temporary.write_text(f"{run_id}\n", encoding="utf-8")
|
|
93
|
+
os.replace(temporary, self._active_pointer)
|
|
94
|
+
|
|
95
|
+
def active_run_id(self) -> str:
|
|
96
|
+
if not self._active_pointer.exists():
|
|
97
|
+
raise LookupError("no active Hardproof run in this workspace")
|
|
98
|
+
run_id = self._active_pointer.read_text(encoding="utf-8").strip()
|
|
99
|
+
if not run_id:
|
|
100
|
+
raise LookupError("active run pointer is empty")
|
|
101
|
+
self.repository.get_run(run_id)
|
|
102
|
+
return run_id
|
|
103
|
+
|
|
104
|
+
def _git_root(self) -> Path | None:
|
|
105
|
+
result = subprocess.run(
|
|
106
|
+
["git", "-C", str(self.context.project_root), "rev-parse", "--show-toplevel"],
|
|
107
|
+
capture_output=True, text=True, check=False, timeout=10,
|
|
108
|
+
)
|
|
109
|
+
return Path(result.stdout.strip()).resolve() if result.returncode == 0 else None
|
|
110
|
+
|
|
111
|
+
def _ensure_state_ignored(self, git_root: Path) -> None:
|
|
112
|
+
probe = subprocess.run(
|
|
113
|
+
["git", "-C", str(git_root), "check-ignore", "-q", ".hardproof/state/probe"],
|
|
114
|
+
capture_output=True, check=False, timeout=10,
|
|
115
|
+
)
|
|
116
|
+
if probe.returncode == 0:
|
|
117
|
+
return
|
|
118
|
+
exclude = git_root / ".git" / "info" / "exclude"
|
|
119
|
+
exclude.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
existing = exclude.read_text(encoding="utf-8") if exclude.exists() else ""
|
|
121
|
+
separator = "" if not existing or existing.endswith("\n") else "\n"
|
|
122
|
+
exclude.write_text(existing + separator + ".hardproof/\n", encoding="utf-8")
|
|
123
|
+
|
|
124
|
+
def _facts(self, run_id: str) -> TransitionFacts:
|
|
125
|
+
events = self.repository.list_events(run_id)
|
|
126
|
+
return TransitionFacts(
|
|
127
|
+
artifacts=self.repository.list_artifacts(run_id),
|
|
128
|
+
approvals=self.repository.list_approvals(run_id),
|
|
129
|
+
tasks=self.repository.list_tasks(run_id),
|
|
130
|
+
evidence=evidence_with_freshness(
|
|
131
|
+
self.repository.list_evidence(run_id), self.context.project_root
|
|
132
|
+
),
|
|
133
|
+
approved_review=any(event.event_type == "review_approved" for event in events),
|
|
134
|
+
recorded_change=any(event.event_type == "change_recorded" for event in events),
|
|
135
|
+
learning_skipped=any(event.event_type == "learning_skipped" for event in events),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def transition_facts(self, run_id: str) -> TransitionFacts:
|
|
139
|
+
"""Return current durable facts for model-requested transition evaluation."""
|
|
140
|
+
return self._facts(run_id)
|
|
141
|
+
|
|
142
|
+
def execute(self, argv: list[str]) -> CommandResult:
|
|
143
|
+
if not argv:
|
|
144
|
+
raise ValueError("a Hardproof subcommand is required")
|
|
145
|
+
command, rest = argv[0], argv[1:]
|
|
146
|
+
handlers: dict[str, Callable[[list[str]], CommandResult]] = {
|
|
147
|
+
"start": self._start, "status": self._status, "approve": self._approve,
|
|
148
|
+
"waive": self._waive, "pause": self._pause, "resume": self._resume,
|
|
149
|
+
"abort": self._abort, "evidence": self._evidence, "export": self._export,
|
|
150
|
+
"doctor": self._doctor, "runs": self._runs, "show": self._show,
|
|
151
|
+
"config": self._config, "db": self._db, "complete": self._complete,
|
|
152
|
+
"migrate-state": self._migrate_state,
|
|
153
|
+
}
|
|
154
|
+
if command not in handlers:
|
|
155
|
+
raise ValueError(f"unknown Hardproof subcommand: {command}")
|
|
156
|
+
return handlers[command](rest)
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def _expect_no_args(command: str, rest: list[str]) -> None:
|
|
160
|
+
if rest:
|
|
161
|
+
raise ValueError(f"{command} does not accept arguments")
|
|
162
|
+
|
|
163
|
+
def _start(self, rest: list[str]) -> CommandResult:
|
|
164
|
+
if len(rest) < 2:
|
|
165
|
+
raise ValueError("usage: start <quick|standard|critical> <request>")
|
|
166
|
+
git_root = self._git_root()
|
|
167
|
+
if git_root is None:
|
|
168
|
+
raise ValueError("Hardproof managed runs require a Git repository")
|
|
169
|
+
self._ensure_state_ignored(git_root)
|
|
170
|
+
try:
|
|
171
|
+
profile = RunProfile(rest[0])
|
|
172
|
+
except ValueError as exc:
|
|
173
|
+
raise ValueError("profile must be quick, standard, or critical") from exc
|
|
174
|
+
request = " ".join(rest[1:]).strip()
|
|
175
|
+
run = Run.create(str(self.context.project_root), request, profile)
|
|
176
|
+
self.repository.create_run(run)
|
|
177
|
+
config = load_config(self.paths.config)
|
|
178
|
+
for item in config.verification_checks:
|
|
179
|
+
self.repository.add_verification_check(VerificationCheck(
|
|
180
|
+
new_id("check"), run.id, item.name, item.command, item.required, item.timeout_seconds
|
|
181
|
+
))
|
|
182
|
+
if self.context.session_id:
|
|
183
|
+
self.repository.save_session_binding(SessionBinding(
|
|
184
|
+
self.context.session_id, run.id, self.context.platform, utc_now()
|
|
185
|
+
))
|
|
186
|
+
self._set_active(run.id)
|
|
187
|
+
return CommandResult(True, f"Hardproof run {run.id} started in INTAKE ({profile.value}).", run.id)
|
|
188
|
+
|
|
189
|
+
def _status(self, rest: list[str]) -> CommandResult:
|
|
190
|
+
self._expect_no_args("status", rest)
|
|
191
|
+
run = self.repository.get_run(self.active_run_id())
|
|
192
|
+
return CommandResult(
|
|
193
|
+
True,
|
|
194
|
+
f"Run: {run.id}\nProfile: {run.profile.value}\nStage: {run.stage.value}\nStatus: {run.status.value}",
|
|
195
|
+
run.id,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def _approve(self, rest: list[str]) -> CommandResult:
|
|
199
|
+
if not rest:
|
|
200
|
+
raise ValueError("usage: approve <design|plan|completion> [reason]")
|
|
201
|
+
try:
|
|
202
|
+
gate = ApprovalGate(rest[0])
|
|
203
|
+
except ValueError as exc:
|
|
204
|
+
raise ValueError("approval gate must be design, plan, or completion") from exc
|
|
205
|
+
if gate not in {ApprovalGate.DESIGN, ApprovalGate.PLAN, ApprovalGate.COMPLETION}:
|
|
206
|
+
raise ValueError("approval gate must be design, plan, or completion")
|
|
207
|
+
ApprovalService(self.repository).create_human(
|
|
208
|
+
self.active_run_id(), gate, actor=self.context.actor,
|
|
209
|
+
source=self.context.source, reason=" ".join(rest[1:]) or None,
|
|
210
|
+
)
|
|
211
|
+
return CommandResult(True, f"Human {gate.value} approval recorded.", self.active_run_id())
|
|
212
|
+
|
|
213
|
+
def _waive(self, rest: list[str]) -> CommandResult:
|
|
214
|
+
if len(rest) < 2:
|
|
215
|
+
raise ValueError("usage: waive <gate> <reason>")
|
|
216
|
+
reason = f"{rest[0]}: {' '.join(rest[1:])}"
|
|
217
|
+
ApprovalService(self.repository).create_human(
|
|
218
|
+
self.active_run_id(), ApprovalGate.WAIVER, actor=self.context.actor,
|
|
219
|
+
source=self.context.source, reason=reason,
|
|
220
|
+
)
|
|
221
|
+
return CommandResult(True, f"Human waiver recorded for {rest[0]}.", self.active_run_id())
|
|
222
|
+
|
|
223
|
+
def _pause(self, rest: list[str]) -> CommandResult:
|
|
224
|
+
run = self.run_service.pause(self.active_run_id(), reason=" ".join(rest) or "human pause")
|
|
225
|
+
return CommandResult(True, f"Run {run.id} is {run.stage.value}.", run.id)
|
|
226
|
+
|
|
227
|
+
def _resume(self, rest: list[str]) -> CommandResult:
|
|
228
|
+
if len(rest) > 1:
|
|
229
|
+
raise ValueError("usage: resume [run-id]")
|
|
230
|
+
if rest:
|
|
231
|
+
self.repository.get_run(rest[0])
|
|
232
|
+
self._set_active(rest[0])
|
|
233
|
+
run = self.run_service.resume(self.active_run_id(), reason="human resume")
|
|
234
|
+
if self.context.session_id:
|
|
235
|
+
self.repository.save_session_binding(SessionBinding(
|
|
236
|
+
self.context.session_id, run.id, self.context.platform, utc_now()
|
|
237
|
+
))
|
|
238
|
+
return CommandResult(True, f"Run {run.id} resumed at {run.stage.value}.", run.id)
|
|
239
|
+
|
|
240
|
+
def _abort(self, rest: list[str]) -> CommandResult:
|
|
241
|
+
if not rest:
|
|
242
|
+
raise ValueError("usage: abort <reason>")
|
|
243
|
+
run_id = self.active_run_id()
|
|
244
|
+
run = self.run_service.transition(
|
|
245
|
+
run_id, RunStage.ABORTED, self._facts(run_id), reason=" ".join(rest)
|
|
246
|
+
)
|
|
247
|
+
return CommandResult(True, f"Run {run.id} is {run.stage.value}.", run.id)
|
|
248
|
+
|
|
249
|
+
def _evidence(self, rest: list[str]) -> CommandResult:
|
|
250
|
+
self._expect_no_args("evidence", rest)
|
|
251
|
+
run_id = self.active_run_id()
|
|
252
|
+
evidence = self.repository.list_evidence(run_id)
|
|
253
|
+
if not evidence:
|
|
254
|
+
return CommandResult(True, "No verification evidence recorded.", run_id)
|
|
255
|
+
lines = [
|
|
256
|
+
f"{item.check_name}: {item.status.value} exit={item.exit_code if item.exit_code is not None else 'unknown'}"
|
|
257
|
+
for item in evidence
|
|
258
|
+
]
|
|
259
|
+
return CommandResult(True, "\n".join(lines), run_id)
|
|
260
|
+
|
|
261
|
+
def _export(self, rest: list[str]) -> CommandResult:
|
|
262
|
+
if len(rest) > 1:
|
|
263
|
+
raise ValueError("usage: export [path]")
|
|
264
|
+
run = self.repository.get_run(self.active_run_id())
|
|
265
|
+
destination = Path(rest[0]).expanduser().resolve() if rest else None
|
|
266
|
+
paths = ReportService(
|
|
267
|
+
self.repository, self.context.project_root, self.paths.run_directory(run.id)
|
|
268
|
+
).export(run.id, destination=destination, format="both")
|
|
269
|
+
rendered = ", ".join(str(path) for path in paths.values())
|
|
270
|
+
return CommandResult(True, f"Export written to {rendered}.", run.id)
|
|
271
|
+
|
|
272
|
+
def _doctor(self, rest: list[str]) -> CommandResult:
|
|
273
|
+
self._expect_no_args("doctor", rest)
|
|
274
|
+
checks: list[tuple[str, bool, str]] = []
|
|
275
|
+
git_root = self._git_root()
|
|
276
|
+
checks.append(("Git repository", git_root is not None, str(git_root) if git_root else "not found"))
|
|
277
|
+
try:
|
|
278
|
+
load_config(self.paths.config)
|
|
279
|
+
checks.append(("Config", True, "valid"))
|
|
280
|
+
except ConfigError as exc:
|
|
281
|
+
checks.append(("Config", False, str(exc)))
|
|
282
|
+
try:
|
|
283
|
+
migrate(self.database)
|
|
284
|
+
checks.append(("Database", True, "schema current"))
|
|
285
|
+
except (MigrationError, DatabaseCorruptionError) as exc:
|
|
286
|
+
checks.append(("Database", False, str(exc)))
|
|
287
|
+
writable = os.access(self.paths.root, os.W_OK) if self.paths.root.exists() else os.access(self.context.project_root, os.W_OK)
|
|
288
|
+
checks.append(("Write access", writable, "available" if writable else "denied"))
|
|
289
|
+
if self.context.hermes_context is not None:
|
|
290
|
+
report = inspect_context(self.context.hermes_context)
|
|
291
|
+
checks.append(("Hermes API", report.compatible, report.hermes_version or "unknown version"))
|
|
292
|
+
try:
|
|
293
|
+
active = self.active_run_id()
|
|
294
|
+
checks.append(("Active binding", True, active))
|
|
295
|
+
except LookupError:
|
|
296
|
+
checks.append(("Active binding", True, "none"))
|
|
297
|
+
text = "\n".join(f"{'PASS' if ok else 'FAIL'} {name}: {detail}" for name, ok, detail in checks)
|
|
298
|
+
return CommandResult(all(ok for _, ok, _ in checks), text)
|
|
299
|
+
|
|
300
|
+
def _runs(self, rest: list[str]) -> CommandResult:
|
|
301
|
+
self._expect_no_args("runs", rest)
|
|
302
|
+
runs = self.repository.list_runs()
|
|
303
|
+
text = "\n".join(f"{run.id} {run.profile.value} {run.stage.value} {run.status.value}" for run in runs)
|
|
304
|
+
return CommandResult(True, text or "No Hardproof runs found.")
|
|
305
|
+
|
|
306
|
+
def _show(self, rest: list[str]) -> CommandResult:
|
|
307
|
+
if len(rest) != 1:
|
|
308
|
+
raise ValueError("usage: show <run-id>")
|
|
309
|
+
run = self.repository.get_run(rest[0])
|
|
310
|
+
return CommandResult(
|
|
311
|
+
True,
|
|
312
|
+
f"Run: {run.id}\nRequest: {_redact(run.request)}\nProfile: {run.profile.value}\nStage: {run.stage.value}\nStatus: {run.status.value}",
|
|
313
|
+
run.id,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def _config(self, rest: list[str]) -> CommandResult:
|
|
317
|
+
if len(rest) != 1 or rest[0] not in {"init", "validate"}:
|
|
318
|
+
raise ValueError("usage: config <init|validate>")
|
|
319
|
+
if rest[0] == "validate":
|
|
320
|
+
load_config(self.paths.config)
|
|
321
|
+
return CommandResult(True, f"Config is valid: {self.paths.config}")
|
|
322
|
+
if self.paths.config.exists():
|
|
323
|
+
raise ValueError(f"config already exists: {self.paths.config}")
|
|
324
|
+
self.paths.config.parent.mkdir(parents=True, exist_ok=True)
|
|
325
|
+
self.paths.config.write_text(yaml.safe_dump(DEFAULTS, sort_keys=True), encoding="utf-8")
|
|
326
|
+
load_config(self.paths.config)
|
|
327
|
+
return CommandResult(True, f"Config initialized: {self.paths.config}")
|
|
328
|
+
|
|
329
|
+
def _db(self, rest: list[str]) -> CommandResult:
|
|
330
|
+
if rest != ["migrate"]:
|
|
331
|
+
raise ValueError("usage: db migrate")
|
|
332
|
+
applied = migrate(self.database)
|
|
333
|
+
detail = ", ".join(map(str, applied)) if applied else "already current"
|
|
334
|
+
return CommandResult(True, f"Database migration: {detail}")
|
|
335
|
+
|
|
336
|
+
def _migrate_state(self, rest: list[str]) -> CommandResult:
|
|
337
|
+
"""Migrate a pre-rename .crucible state directory to .hardproof."""
|
|
338
|
+
self._expect_no_args("migrate-state", rest)
|
|
339
|
+
project = self.context.project_root
|
|
340
|
+
old_dir = project / ".crucible"
|
|
341
|
+
new_dir = project / ".hardproof"
|
|
342
|
+
backup_dir = project / ".hardproof.backup"
|
|
343
|
+
|
|
344
|
+
old_exists = old_dir.exists()
|
|
345
|
+
new_exists = new_dir.exists()
|
|
346
|
+
|
|
347
|
+
if not old_exists:
|
|
348
|
+
return CommandResult(
|
|
349
|
+
False,
|
|
350
|
+
f"No pre-rename state directory found at {old_dir}. Nothing to migrate."
|
|
351
|
+
)
|
|
352
|
+
if new_exists and old_exists:
|
|
353
|
+
# CommandService.__init__ may have auto-created .hardproof/state/.
|
|
354
|
+
# If the new directory is empty aside from that skeleton, remove it
|
|
355
|
+
# so migration can proceed.
|
|
356
|
+
auto_created = True
|
|
357
|
+
for item in new_dir.rglob("*"):
|
|
358
|
+
rel = item.relative_to(new_dir)
|
|
359
|
+
parts = rel.parts
|
|
360
|
+
if parts[0] == "state" and len(parts) <= 2:
|
|
361
|
+
continue # state/ or state/hardproof.db*
|
|
362
|
+
auto_created = False
|
|
363
|
+
break
|
|
364
|
+
if auto_created:
|
|
365
|
+
shutil.rmtree(new_dir, ignore_errors=True)
|
|
366
|
+
else:
|
|
367
|
+
return CommandResult(
|
|
368
|
+
False,
|
|
369
|
+
f"Both {old_dir} and {new_dir} exist and {new_dir} has active state. "
|
|
370
|
+
f"Resolve manually: remove one directory before migrating."
|
|
371
|
+
)
|
|
372
|
+
if new_exists and not old_exists:
|
|
373
|
+
return CommandResult(False, f"{new_dir} already exists. Nothing to migrate.")
|
|
374
|
+
if backup_dir.exists():
|
|
375
|
+
return CommandResult(
|
|
376
|
+
False,
|
|
377
|
+
f"Backup directory {backup_dir} already exists. "
|
|
378
|
+
f"Remove it or rename it before retrying migration."
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
shutil.copytree(old_dir, backup_dir, symlinks=False)
|
|
383
|
+
except OSError as exc:
|
|
384
|
+
return CommandResult(False, f"Failed to create backup at {backup_dir}: {exc}")
|
|
385
|
+
|
|
386
|
+
db_path = old_dir / "state" / "hardproof.db"
|
|
387
|
+
db_integrity_ok = True
|
|
388
|
+
if db_path.exists():
|
|
389
|
+
try:
|
|
390
|
+
conn = sqlite3.connect(str(db_path))
|
|
391
|
+
cursor = conn.execute("PRAGMA integrity_check")
|
|
392
|
+
result = cursor.fetchone()
|
|
393
|
+
conn.close()
|
|
394
|
+
if result and result[0] != "ok":
|
|
395
|
+
db_integrity_ok = False
|
|
396
|
+
except Exception as exc:
|
|
397
|
+
return CommandResult(
|
|
398
|
+
False,
|
|
399
|
+
f"Source database at {db_path} is unreadable: {exc}. "
|
|
400
|
+
f"Backup preserved at {backup_dir}."
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
if not db_integrity_ok:
|
|
404
|
+
return CommandResult(
|
|
405
|
+
False,
|
|
406
|
+
f"Source database at {db_path} failed integrity check. "
|
|
407
|
+
f"Backup preserved at {backup_dir}. Manual recovery required."
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
shutil.copytree(old_dir, new_dir, symlinks=False)
|
|
412
|
+
except OSError as exc:
|
|
413
|
+
shutil.rmtree(backup_dir, ignore_errors=True)
|
|
414
|
+
return CommandResult(False, f"Failed to copy {old_dir} to {new_dir}: {exc}")
|
|
415
|
+
|
|
416
|
+
new_db = new_dir / "state" / "hardproof.db"
|
|
417
|
+
if new_db.exists():
|
|
418
|
+
try:
|
|
419
|
+
conn = sqlite3.connect(str(new_db))
|
|
420
|
+
cursor = conn.execute("PRAGMA integrity_check")
|
|
421
|
+
result = cursor.fetchone()
|
|
422
|
+
conn.close()
|
|
423
|
+
if not result or result[0] != "ok":
|
|
424
|
+
shutil.rmtree(new_dir, ignore_errors=True)
|
|
425
|
+
return CommandResult(
|
|
426
|
+
False,
|
|
427
|
+
f"Migrated database at {new_db} failed integrity check. "
|
|
428
|
+
f"New directory removed. Backup preserved at {backup_dir}."
|
|
429
|
+
)
|
|
430
|
+
except Exception as exc:
|
|
431
|
+
shutil.rmtree(new_dir, ignore_errors=True)
|
|
432
|
+
return CommandResult(
|
|
433
|
+
False,
|
|
434
|
+
f"Migrated database at {new_db} is unreadable: {exc}. "
|
|
435
|
+
f"New directory removed. Backup preserved at {backup_dir}."
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
report = {
|
|
439
|
+
"migrated_at": utc_now(),
|
|
440
|
+
"source": str(old_dir),
|
|
441
|
+
"destination": str(new_dir),
|
|
442
|
+
"backup": str(backup_dir),
|
|
443
|
+
"database_integrity": "ok",
|
|
444
|
+
}
|
|
445
|
+
report_path = new_dir / "migration-report.json"
|
|
446
|
+
report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
447
|
+
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
448
|
+
|
|
449
|
+
return CommandResult(
|
|
450
|
+
True,
|
|
451
|
+
f"State migrated from {old_dir} to {new_dir}.\n"
|
|
452
|
+
f"Backup preserved at {backup_dir}.\n"
|
|
453
|
+
f"Migration report at {report_path}.\n"
|
|
454
|
+
f"The old .crucible directory was not removed. Delete it after confirming the migration."
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
def _complete(self, rest: list[str]) -> CommandResult:
|
|
458
|
+
self._expect_no_args("complete", rest)
|
|
459
|
+
run_id = self.active_run_id()
|
|
460
|
+
run = self.run_service.transition(
|
|
461
|
+
run_id, RunStage.COMPLETE, self._facts(run_id), reason="human completion command"
|
|
462
|
+
)
|
|
463
|
+
return CommandResult(True, f"Run {run.id} is COMPLETE.", run.id)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Hermes `/hardproof` adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
from collections.abc import Awaitable
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from hardproof.commands.shared import CommandService
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def make_slash_handler(
|
|
13
|
+
service_factory: Callable[[], CommandService],
|
|
14
|
+
) -> Callable[[str], Awaitable[str]]:
|
|
15
|
+
async def handler(raw_args: str) -> str:
|
|
16
|
+
try:
|
|
17
|
+
argv = shlex.split(raw_args, posix=True)
|
|
18
|
+
result = service_factory().execute(argv)
|
|
19
|
+
return result.text
|
|
20
|
+
except Exception as exc:
|
|
21
|
+
return f"Hardproof error: {exc}"[:499]
|
|
22
|
+
|
|
23
|
+
return handler
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def register_slash(ctx: Any, service_factory: Callable[[], CommandService]) -> None:
|
|
27
|
+
ctx.register_command(
|
|
28
|
+
"hardproof",
|
|
29
|
+
make_slash_handler(service_factory),
|
|
30
|
+
description="Manage a persistent Hardproof engineering run",
|
|
31
|
+
args_hint="<subcommand> [arguments]",
|
|
32
|
+
)
|
hardproof/compat.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Public Hermes compatibility boundary.
|
|
2
|
+
|
|
3
|
+
This module uses capability detection only. It deliberately avoids Hermes
|
|
4
|
+
private attributes and keeps version drift out of Hardproof's domain services.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from importlib import metadata
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
REQUIRED_CAPABILITIES = (
|
|
16
|
+
"register_tool",
|
|
17
|
+
"register_hook",
|
|
18
|
+
"register_skill",
|
|
19
|
+
"register_command",
|
|
20
|
+
"register_cli_command",
|
|
21
|
+
"dispatch_tool",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
OPTIONAL_CAPABILITIES = (
|
|
25
|
+
"subagent_lifecycle_hooks",
|
|
26
|
+
"kanban_lifecycle_hooks",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CompatibilityError(RuntimeError):
|
|
31
|
+
"""Raised when Hermes lacks a required public plugin capability."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class CompatibilityReport:
|
|
36
|
+
"""Serializable result of inspecting a Hermes plugin context."""
|
|
37
|
+
|
|
38
|
+
compatible: bool
|
|
39
|
+
hermes_version: str | None
|
|
40
|
+
profile_name: str
|
|
41
|
+
required: dict[str, bool]
|
|
42
|
+
optional: dict[str, bool]
|
|
43
|
+
missing_required: tuple[str, ...]
|
|
44
|
+
|
|
45
|
+
def to_json(self) -> str:
|
|
46
|
+
"""Return deterministic JSON suitable for doctor output."""
|
|
47
|
+
return json.dumps(asdict(self), sort_keys=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _installed_version() -> str | None:
|
|
51
|
+
try:
|
|
52
|
+
return metadata.version("hermes-agent")
|
|
53
|
+
except metadata.PackageNotFoundError:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def inspect_context(
|
|
58
|
+
ctx: Any,
|
|
59
|
+
*,
|
|
60
|
+
distribution_version: str | None = None,
|
|
61
|
+
) -> CompatibilityReport:
|
|
62
|
+
"""Inspect only Hardproof's documented public Hermes surface."""
|
|
63
|
+
required = {
|
|
64
|
+
name: callable(getattr(ctx, name, None)) for name in REQUIRED_CAPABILITIES
|
|
65
|
+
}
|
|
66
|
+
# Hermes 0.18.x exposes these as hook names rather than context methods.
|
|
67
|
+
# Task 2 reports them conservatively; later releases feature-detect the
|
|
68
|
+
# lifecycle events during registration without relying on private state.
|
|
69
|
+
optional = {name: False for name in OPTIONAL_CAPABILITIES}
|
|
70
|
+
missing = tuple(name for name, present in required.items() if not present)
|
|
71
|
+
profile = getattr(ctx, "profile_name", "default")
|
|
72
|
+
return CompatibilityReport(
|
|
73
|
+
compatible=not missing,
|
|
74
|
+
hermes_version=distribution_version or _installed_version(),
|
|
75
|
+
profile_name=profile if isinstance(profile, str) else "default",
|
|
76
|
+
required=required,
|
|
77
|
+
optional=optional,
|
|
78
|
+
missing_required=missing,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def require_compatible(ctx: Any) -> CompatibilityReport:
|
|
83
|
+
"""Return a report or refuse unsafe partial plugin registration."""
|
|
84
|
+
report = inspect_context(ctx)
|
|
85
|
+
if not report.compatible:
|
|
86
|
+
missing = ", ".join(report.missing_required)
|
|
87
|
+
raise CompatibilityError(f"Hermes is missing required public capabilities: {missing}")
|
|
88
|
+
return report
|