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.
Files changed (67) hide show
  1. hardproof/__init__.py +3 -0
  2. hardproof/commands/__init__.py +1 -0
  3. hardproof/commands/cli.py +94 -0
  4. hardproof/commands/shared.py +463 -0
  5. hardproof/commands/slash.py +32 -0
  6. hardproof/compat.py +88 -0
  7. hardproof/config.py +152 -0
  8. hardproof/constants.py +5 -0
  9. hardproof/domain/__init__.py +7 -0
  10. hardproof/domain/enums.py +76 -0
  11. hardproof/domain/models.py +316 -0
  12. hardproof/domain/snapshots.py +80 -0
  13. hardproof/domain/transitions.py +32 -0
  14. hardproof/errors.py +13 -0
  15. hardproof/hooks/__init__.py +1 -0
  16. hardproof/hooks/context.py +99 -0
  17. hardproof/hooks/sessions.py +29 -0
  18. hardproof/hooks/tool_policy.py +113 -0
  19. hardproof/hooks/verification.py +54 -0
  20. hardproof/migrations/001_initial.sql +108 -0
  21. hardproof/migrations/__init__.py +1 -0
  22. hardproof/paths.py +51 -0
  23. hardproof/plugin.py +175 -0
  24. hardproof/policy/__init__.py +1 -0
  25. hardproof/policy/profiles.py +59 -0
  26. hardproof/policy/stage_rules.py +122 -0
  27. hardproof/policy/tool_rules.py +144 -0
  28. hardproof/policy/verification_rules.py +26 -0
  29. hardproof/py.typed +1 -0
  30. hardproof/services/__init__.py +1 -0
  31. hardproof/services/approvals.py +35 -0
  32. hardproof/services/artifacts.py +67 -0
  33. hardproof/services/decisions.py +37 -0
  34. hardproof/services/evidence.py +221 -0
  35. hardproof/services/reports.py +259 -0
  36. hardproof/services/runs.py +71 -0
  37. hardproof/services/sessions.py +53 -0
  38. hardproof/services/tasks.py +92 -0
  39. hardproof/skills/deliver/SKILL.md +34 -0
  40. hardproof/skills/design/SKILL.md +34 -0
  41. hardproof/skills/discover/SKILL.md +34 -0
  42. hardproof/skills/implement/SKILL.md +35 -0
  43. hardproof/skills/learn/SKILL.md +34 -0
  44. hardproof/skills/orchestrate/SKILL.md +35 -0
  45. hardproof/skills/plan/SKILL.md +34 -0
  46. hardproof/skills/review/SKILL.md +34 -0
  47. hardproof/skills/verify/SKILL.md +34 -0
  48. hardproof/storage/__init__.py +6 -0
  49. hardproof/storage/database.py +50 -0
  50. hardproof/storage/migrations.py +73 -0
  51. hardproof/storage/repository.py +321 -0
  52. hardproof/templates/__init__.py +1 -0
  53. hardproof/templates/completion.md +19 -0
  54. hardproof/templates/design.md +19 -0
  55. hardproof/templates/discovery.md +19 -0
  56. hardproof/templates/plan.md +19 -0
  57. hardproof/templates/review.md +19 -0
  58. hardproof/tools/__init__.py +1 -0
  59. hardproof/tools/handlers.py +212 -0
  60. hardproof/tools/schemas.py +96 -0
  61. hardproof-0.1.1.dist-info/METADATA +179 -0
  62. hardproof-0.1.1.dist-info/RECORD +67 -0
  63. hardproof-0.1.1.dist-info/WHEEL +5 -0
  64. hardproof-0.1.1.dist-info/entry_points.txt +2 -0
  65. hardproof-0.1.1.dist-info/licenses/LICENSE +175 -0
  66. hardproof-0.1.1.dist-info/licenses/NOTICE +14 -0
  67. hardproof-0.1.1.dist-info/top_level.txt +1 -0
hardproof/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Hardproof public package."""
2
+
3
+ __version__ = "0.1.1"
@@ -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