hardproof 0.3.0__tar.gz → 0.3.1__tar.gz
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-0.3.0/hardproof.egg-info → hardproof-0.3.1}/PKG-INFO +7 -6
- {hardproof-0.3.0 → hardproof-0.3.1}/README.md +6 -5
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/__init__.py +1 -1
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/commands/shared.py +93 -10
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/policy/stage_rules.py +3 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/workcells.py +60 -7
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/storage/repository.py +53 -9
- {hardproof-0.3.0 → hardproof-0.3.1/hardproof.egg-info}/PKG-INFO +7 -6
- {hardproof-0.3.0 → hardproof-0.3.1}/pyproject.toml +1 -1
- {hardproof-0.3.0 → hardproof-0.3.1}/LICENSE +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/NOTICE +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/commands/__init__.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/commands/cli.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/commands/slash.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/compat.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/config.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/constants.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/domain/__init__.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/domain/enums.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/domain/models.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/domain/snapshots.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/domain/transitions.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/domain/workcells.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/errors.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/hooks/__init__.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/hooks/context.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/hooks/sessions.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/hooks/tool_policy.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/hooks/verification.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/migrations/001_initial.sql +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/migrations/002_gatehouse.sql +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/migrations/003_workcells.sql +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/migrations/__init__.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/paths.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/plugin.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/policy/__init__.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/policy/packs.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/policy/profiles.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/policy/stage_graph.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/policy/terminal.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/policy/tool_rules.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/policy/trace.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/policy/verification_rules.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/policy/waivers.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/py.typed +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/__init__.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/approvals.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/artifacts.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/authority.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/decisions.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/evidence.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/hermes_children.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/reports.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/risks.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/runs.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/sessions.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/tasks.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/waivers.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/workcell_artifacts.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/skills/deliver/SKILL.md +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/skills/design/SKILL.md +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/skills/discover/SKILL.md +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/skills/implement/SKILL.md +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/skills/learn/SKILL.md +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/skills/orchestrate/SKILL.md +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/skills/plan/SKILL.md +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/skills/review/SKILL.md +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/skills/verify/SKILL.md +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/storage/__init__.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/storage/database.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/storage/migrations.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/templates/__init__.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/templates/completion.md +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/templates/design.md +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/templates/discovery.md +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/templates/plan.md +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/templates/review.md +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/tools/__init__.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/tools/handlers.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/tools/schemas.py +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof.egg-info/SOURCES.txt +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof.egg-info/dependency_links.txt +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof.egg-info/entry_points.txt +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof.egg-info/requires.txt +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/hardproof.egg-info/top_level.txt +0 -0
- {hardproof-0.3.0 → hardproof-0.3.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hardproof
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A persistent, risk-aware engineering protocol for Hermes Agent
|
|
5
5
|
Author: Hardproof contributors
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -36,8 +36,9 @@ Dynamic: license-file
|
|
|
36
36
|
[](https://github.com/asimons81/hardproof/actions/workflows/ci.yml)
|
|
37
37
|
[](LICENSE)
|
|
38
38
|
[](https://pypi.org/project/hardproof/)
|
|
39
|
+
[](https://x.com/asimons81)
|
|
39
40
|
|
|
40
|
-
> **Alpha software.** v0.
|
|
41
|
+
> **Alpha software.** v0.3.0 Workcells is the current public alpha on PyPI. v0.3.1 Workcells Hardening has been prepared. v0.4.0 Challenge Chamber development has not begun. Commands, schemas, and contracts may change before v1.0.0.
|
|
41
42
|
|
|
42
43
|
Hardproof gives coding agents a persistent, risk-aware engineering process that turns ambiguous software requests into reviewed, verified results while preserving the evidence behind every completion claim.
|
|
43
44
|
|
|
@@ -45,12 +46,12 @@ Hardproof gives coding agents a persistent, risk-aware engineering process that
|
|
|
45
46
|
|
|
46
47
|
## Current Release
|
|
47
48
|
|
|
48
|
-
|
|
|
49
|
-
|
|
50
|
-
|
|
|
49
|
+
| Current | v0.3.0 Workcells | Released on GitHub and PyPI |
|
|
50
|
+
| Hardening | v0.3.1 Workcells Hardening | Prepared, pending release |
|
|
51
|
+
| Previous | v0.2.0 Gatehouse | Released on GitHub and PyPI |
|
|
51
52
|
| Previous | v0.1.1 Core Heat | Released on GitHub and PyPI |
|
|
52
53
|
| Previous | v0.1.0 Core Heat | Released on GitHub |
|
|
53
|
-
| Future | v0.
|
|
54
|
+
| Future | v0.4.0 Challenge Chamber | Not started |
|
|
54
55
|
|
|
55
56
|
## Install
|
|
56
57
|
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
[](https://github.com/asimons81/hardproof/actions/workflows/ci.yml)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://pypi.org/project/hardproof/)
|
|
8
|
+
[](https://x.com/asimons81)
|
|
8
9
|
|
|
9
|
-
> **Alpha software.** v0.
|
|
10
|
+
> **Alpha software.** v0.3.0 Workcells is the current public alpha on PyPI. v0.3.1 Workcells Hardening has been prepared. v0.4.0 Challenge Chamber development has not begun. Commands, schemas, and contracts may change before v1.0.0.
|
|
10
11
|
|
|
11
12
|
Hardproof gives coding agents a persistent, risk-aware engineering process that turns ambiguous software requests into reviewed, verified results while preserving the evidence behind every completion claim.
|
|
12
13
|
|
|
@@ -14,12 +15,12 @@ Hardproof gives coding agents a persistent, risk-aware engineering process that
|
|
|
14
15
|
|
|
15
16
|
## Current Release
|
|
16
17
|
|
|
17
|
-
|
|
|
18
|
-
|
|
19
|
-
|
|
|
18
|
+
| Current | v0.3.0 Workcells | Released on GitHub and PyPI |
|
|
19
|
+
| Hardening | v0.3.1 Workcells Hardening | Prepared, pending release |
|
|
20
|
+
| Previous | v0.2.0 Gatehouse | Released on GitHub and PyPI |
|
|
20
21
|
| Previous | v0.1.1 Core Heat | Released on GitHub and PyPI |
|
|
21
22
|
| Previous | v0.1.0 Core Heat | Released on GitHub |
|
|
22
|
-
| Future | v0.
|
|
23
|
+
| Future | v0.4.0 Challenge Chamber | Not started |
|
|
23
24
|
|
|
24
25
|
## Install
|
|
25
26
|
|
|
@@ -17,7 +17,7 @@ from uuid import uuid4
|
|
|
17
17
|
import yaml
|
|
18
18
|
|
|
19
19
|
from hardproof.compat import inspect_context
|
|
20
|
-
from hardproof.config import DEFAULTS, ConfigError, config_fingerprint, load_config
|
|
20
|
+
from hardproof.config import DEFAULTS, ConfigError, config_fingerprint, load_config, _MODEL_TIERS
|
|
21
21
|
from hardproof.domain.enums import ApprovalGate, RiskLevel, RunProfile, RunStage
|
|
22
22
|
from hardproof.domain.models import Run, SessionBinding, VerificationCheck, new_id, utc_now
|
|
23
23
|
from hardproof.paths import ProjectPaths
|
|
@@ -142,6 +142,7 @@ class CommandService:
|
|
|
142
142
|
approved_review=any(event.event_type == "review_approved" for event in events),
|
|
143
143
|
recorded_change=any(event.event_type == "change_recorded" for event in events),
|
|
144
144
|
learning_skipped=any(event.event_type == "learning_skipped" for event in events),
|
|
145
|
+
workcell_required_unresolved=self.repository.count_unresolved_required_workcells(run_id),
|
|
145
146
|
)
|
|
146
147
|
|
|
147
148
|
def transition_facts(self, run_id: str) -> TransitionFacts:
|
|
@@ -370,27 +371,76 @@ class CommandService:
|
|
|
370
371
|
required = {"key", "title", "objective", "acceptance"}
|
|
371
372
|
if not required <= set(value):
|
|
372
373
|
raise ValueError("every Workcell task requires key, title, objective, and acceptance")
|
|
374
|
+
unknown = sorted(set(value) - required - {
|
|
375
|
+
"dependencies", "read_scope", "write_scope", "required", "priority", "model_tier",
|
|
376
|
+
})
|
|
377
|
+
if unknown:
|
|
378
|
+
raise ValueError(f"unknown Workcell task keys: {', '.join(unknown)}")
|
|
379
|
+
key = str(value["key"])
|
|
380
|
+
if not key or len(key) > 128 or not key.replace("-", "").replace("_", "").isalnum():
|
|
381
|
+
raise ValueError("Workcell task key must be 1-128 filename-safe characters")
|
|
382
|
+
title = str(value["title"])
|
|
383
|
+
if not title.strip() or len(title) > 256:
|
|
384
|
+
raise ValueError("Workcell task title must be 1-256 characters")
|
|
385
|
+
objective = str(value["objective"])
|
|
386
|
+
if not objective.strip() or len(objective) > 4096:
|
|
387
|
+
raise ValueError("Workcell task objective must be 1-4096 characters")
|
|
373
388
|
acceptance = value["acceptance"]
|
|
389
|
+
if not isinstance(acceptance, list) or not acceptance or len(acceptance) > 32:
|
|
390
|
+
raise ValueError("Workcell task acceptance must be a non-empty list of at most 32 strings")
|
|
391
|
+
for item in acceptance:
|
|
392
|
+
if not isinstance(item, str) or not item.strip() or len(item) > 512:
|
|
393
|
+
raise ValueError("Workcell task acceptance items must be 1-512 character strings")
|
|
374
394
|
dependencies = value.get("dependencies", [])
|
|
395
|
+
if not isinstance(dependencies, list) or len(dependencies) > 32:
|
|
396
|
+
raise ValueError("Workcell task dependencies must be a list of at most 32 strings")
|
|
397
|
+
for item in dependencies:
|
|
398
|
+
if not isinstance(item, str) or not item.strip():
|
|
399
|
+
raise ValueError("Workcell task dependencies must be non-empty strings")
|
|
375
400
|
read_scope = value.get("read_scope", [])
|
|
376
401
|
write_scope = value.get("write_scope", [])
|
|
377
|
-
for name,
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
402
|
+
for name, scope in [("read_scope", read_scope), ("write_scope", write_scope)]:
|
|
403
|
+
if not isinstance(scope, list) or len(scope) > 32:
|
|
404
|
+
raise ValueError(f"Workcell task {name} must be a list of at most 32 strings")
|
|
405
|
+
for item in scope:
|
|
406
|
+
if not isinstance(item, str) or not item.strip():
|
|
407
|
+
raise ValueError(f"Workcell task {name} items must be non-empty strings")
|
|
408
|
+
if item.startswith("/") or ".." in item.split("/"):
|
|
409
|
+
raise ValueError(f"Workcell task {name} path must be project-relative without traversal: {item}")
|
|
410
|
+
req_bool = value.get("required", True)
|
|
411
|
+
if not isinstance(req_bool, bool):
|
|
412
|
+
raise ValueError("Workcell task required must be boolean")
|
|
413
|
+
priority = value.get("priority", 0)
|
|
414
|
+
if not isinstance(priority, int) or isinstance(priority, bool) or not -128 <= priority <= 127:
|
|
415
|
+
raise ValueError("Workcell task priority must be an integer between -128 and 127")
|
|
416
|
+
model_tier = value.get("model_tier")
|
|
417
|
+
if model_tier is not None:
|
|
418
|
+
if not isinstance(model_tier, str) or model_tier not in _MODEL_TIERS:
|
|
419
|
+
raise ValueError(f"Workcell task model_tier must be one of {sorted(_MODEL_TIERS)}")
|
|
383
420
|
specs.append(WorkcellTaskSpec(
|
|
384
421
|
str(value["key"]), str(value["title"]), str(value["objective"]), tuple(acceptance),
|
|
385
|
-
tuple(dependencies),
|
|
386
|
-
|
|
422
|
+
tuple(dependencies), req_bool, tuple(read_scope), tuple(write_scope),
|
|
423
|
+
priority, model_tier,
|
|
387
424
|
))
|
|
388
425
|
config = load_config(self.paths.config).workcells
|
|
389
426
|
if not config.enabled:
|
|
390
427
|
raise PermissionError("Workcells are disabled by project configuration")
|
|
428
|
+
run_id = self.active_run_id()
|
|
429
|
+
active_run = self.repository.get_run(run_id)
|
|
430
|
+
if active_run.profile is RunProfile.QUICK and len(specs) > 3:
|
|
431
|
+
raise ValueError("Quick profile Workcell graphs are limited to at most 3 tasks")
|
|
432
|
+
# Validate profile minimum tier for every task
|
|
433
|
+
minimum = config.profile_minimum_tiers.get(active_run.profile, config.default_model_tier)
|
|
434
|
+
for spec in specs:
|
|
435
|
+
tier = spec.model_tier or config.default_model_tier
|
|
436
|
+
tier_index = {t: i for i, t in enumerate(sorted(_MODEL_TIERS))}
|
|
437
|
+
if tier_index.get(tier, -1) < tier_index.get(minimum, 0):
|
|
438
|
+
raise ValueError(f"Workcell task {spec.key} model tier {tier} is below profile minimum {minimum}")
|
|
391
439
|
created = WorkcellService(
|
|
392
440
|
self.repository, maximum_attempts=config.maximum_attempts,
|
|
393
441
|
default_model_tier=config.default_model_tier,
|
|
442
|
+
claim_timeout_seconds=config.claim_timeout_seconds,
|
|
443
|
+
maximum_active_children=config.maximum_active_children,
|
|
394
444
|
).create_graph(self.active_run_id(), tuple(specs))
|
|
395
445
|
return CommandResult(
|
|
396
446
|
True,
|
|
@@ -410,6 +460,8 @@ class CommandService:
|
|
|
410
460
|
default_model_tier=config.default_model_tier, brief_size_limit=config.brief_size_limit,
|
|
411
461
|
context_manifest_size_limit=config.context_manifest_size_limit,
|
|
412
462
|
result_size_limit=config.result_size_limit,
|
|
463
|
+
claim_timeout_seconds=config.claim_timeout_seconds,
|
|
464
|
+
maximum_active_children=config.maximum_active_children,
|
|
413
465
|
).launch_next(
|
|
414
466
|
self.active_run_id(), project_root=self.context.project_root,
|
|
415
467
|
adapter=HermesChildAdapter(self.context.hermes_context), claimant=self.context.actor,
|
|
@@ -428,12 +480,43 @@ class CommandService:
|
|
|
428
480
|
default_model_tier=config.default_model_tier, brief_size_limit=config.brief_size_limit,
|
|
429
481
|
context_manifest_size_limit=config.context_manifest_size_limit,
|
|
430
482
|
result_size_limit=config.result_size_limit,
|
|
483
|
+
claim_timeout_seconds=config.claim_timeout_seconds,
|
|
484
|
+
maximum_active_children=config.maximum_active_children,
|
|
431
485
|
).process_result(rest[1], project_root=self.context.project_root, actor=self.context.actor)
|
|
432
486
|
return CommandResult(True, f"Workcell result accepted: {outcome}.", self.active_run_id())
|
|
433
487
|
if rest[0] == "reconcile" and len(rest) == 2:
|
|
434
488
|
attempt = self.repository.reconcile_workcell_attempt(rest[1], actor=self.context.actor)
|
|
435
489
|
return CommandResult(True, f"Workcell attempt {attempt.attempt_id}: {attempt.state.value}.", self.active_run_id())
|
|
436
|
-
|
|
490
|
+
if rest[0] == "retry" and len(rest) >= 3:
|
|
491
|
+
task_id_or_key = rest[1]
|
|
492
|
+
reason = " ".join(rest[2:])
|
|
493
|
+
run_id = self.active_run_id()
|
|
494
|
+
run = self.repository.get_run(run_id)
|
|
495
|
+
if run.stage not in (RunStage.IMPLEMENT, RunStage.REVIEW):
|
|
496
|
+
raise PermissionError("Workcell retry requires IMPLEMENT or REVIEW stage")
|
|
497
|
+
rows = self.repository.list_workcell_task_rows(run_id)
|
|
498
|
+
task = next(
|
|
499
|
+
(row for row in rows if row["id"] == task_id_or_key or row["task_key"] == task_id_or_key),
|
|
500
|
+
None,
|
|
501
|
+
)
|
|
502
|
+
if task is None:
|
|
503
|
+
raise LookupError("Workcell task not found")
|
|
504
|
+
prior_attempts = self.repository.list_workcell_attempts(str(task["id"]))
|
|
505
|
+
if not prior_attempts:
|
|
506
|
+
raise ValueError("Workcell task has no attempts to retry")
|
|
507
|
+
latest = prior_attempts[-1]
|
|
508
|
+
if latest.state.value not in {"blocked", "failed", "interrupted", "cancelled"}:
|
|
509
|
+
raise ValueError("Workcell task's latest attempt cannot be retried")
|
|
510
|
+
decision_id = self.repository.authorize_workcell_retry(
|
|
511
|
+
str(task["id"]), latest.attempt_id, actor=self.context.actor,
|
|
512
|
+
reason=reason, material_change="human-authorized retry",
|
|
513
|
+
new_context_sha256=latest.context_sha256, new_model_tier=str(task["model_tier"]),
|
|
514
|
+
)
|
|
515
|
+
return CommandResult(True, f"Workcell retry authorized: {decision_id}.", run_id)
|
|
516
|
+
raise ValueError(
|
|
517
|
+
"usage: workcells <status|plan --tasks-json JSON|run-next|result ATTEMPT|"
|
|
518
|
+
"reconcile ATTEMPT|retry TASK REASON...>"
|
|
519
|
+
)
|
|
437
520
|
|
|
438
521
|
def _approve(self, rest: list[str]) -> CommandResult:
|
|
439
522
|
if not rest:
|
|
@@ -26,6 +26,7 @@ class TransitionFacts:
|
|
|
26
26
|
approved_review: bool = False
|
|
27
27
|
recorded_change: bool = False
|
|
28
28
|
learning_skipped: bool = False
|
|
29
|
+
workcell_required_unresolved: int = 0
|
|
29
30
|
|
|
30
31
|
def has_artifact(self, kind: ArtifactKind) -> bool:
|
|
31
32
|
return any(item.kind is kind for item in self.artifacts)
|
|
@@ -60,6 +61,8 @@ def _gate_for_forward(run: Run, target: RunStage, facts: TransitionFacts) -> lis
|
|
|
60
61
|
elif run.stage is RunStage.IMPLEMENT and target is RunStage.REVIEW:
|
|
61
62
|
if not facts.recorded_change and not any(task.status is TaskStatus.COMPLETED for task in facts.tasks):
|
|
62
63
|
blockers.append("completed task or recorded change missing")
|
|
64
|
+
if facts.workcell_required_unresolved > 0:
|
|
65
|
+
blockers.append(f"required Workcells are unresolved: {facts.workcell_required_unresolved} task(s) not completed")
|
|
63
66
|
elif run.stage is RunStage.REVIEW and target is RunStage.VERIFY:
|
|
64
67
|
if run.profile is not RunProfile.QUICK and not (
|
|
65
68
|
facts.approved_review or facts.has_human_approval(ApprovalGate.WAIVER)
|
|
@@ -42,7 +42,8 @@ class WorkcellService:
|
|
|
42
42
|
def __init__(
|
|
43
43
|
self, repository: RunRepository, *, maximum_attempts: int, default_model_tier: str,
|
|
44
44
|
brief_size_limit: int = 65_536, context_manifest_size_limit: int = 32_768,
|
|
45
|
-
result_size_limit: int = 65_536,
|
|
45
|
+
result_size_limit: int = 65_536, claim_timeout_seconds: int = 900,
|
|
46
|
+
maximum_active_children: int = 1,
|
|
46
47
|
) -> None:
|
|
47
48
|
self.repository = repository
|
|
48
49
|
self.maximum_attempts = maximum_attempts
|
|
@@ -50,6 +51,8 @@ class WorkcellService:
|
|
|
50
51
|
self.brief_size_limit = brief_size_limit
|
|
51
52
|
self.context_manifest_size_limit = context_manifest_size_limit
|
|
52
53
|
self.result_size_limit = result_size_limit
|
|
54
|
+
self.claim_timeout_seconds = claim_timeout_seconds
|
|
55
|
+
self.maximum_active_children = maximum_active_children
|
|
53
56
|
|
|
54
57
|
def create_graph(self, run_id: str, specs: tuple[WorkcellTaskSpec, ...]) -> CreatedWorkcellGraph:
|
|
55
58
|
run = self.repository.get_run(run_id)
|
|
@@ -107,19 +110,33 @@ class WorkcellService:
|
|
|
107
110
|
def launch_next(
|
|
108
111
|
self, run_id: str, *, project_root: str | Path, adapter: ChildSessionAdapter, claimant: str = "scheduler"
|
|
109
112
|
) -> ChildLaunch | None:
|
|
110
|
-
"""Claim, materialize, and launch one deterministic ready Workcell task.
|
|
113
|
+
"""Claim, materialize, and launch one deterministic ready Workcell task.
|
|
114
|
+
|
|
115
|
+
The attempt is claimed BEFORE the child context is finalized so the child
|
|
116
|
+
receives its complete identity contract (including attempt_id) and can
|
|
117
|
+
produce a valid result.json without post-launch repository introspection.
|
|
118
|
+
"""
|
|
111
119
|
self.refresh_readiness(run_id)
|
|
112
120
|
ready = [item for item in self.repository.list_workcell_task_rows(run_id) if item["status"] == "ready"]
|
|
113
121
|
if not ready:
|
|
114
122
|
return None
|
|
115
123
|
selected = ready[0]
|
|
124
|
+
# Scope check: run must be in IMPLEMENT and not paused/aborted
|
|
125
|
+
run = self.repository.get_run(run_id)
|
|
126
|
+
if run.stage is not RunStage.IMPLEMENT or run.status.value in ("paused", "aborted"):
|
|
127
|
+
raise PermissionError("Workcell launch requires active IMPLEMENT stage")
|
|
116
128
|
detail = self.repository.get_workcell_task_detail(str(selected["id"]))
|
|
117
129
|
root = Path(project_root).resolve()
|
|
130
|
+
|
|
131
|
+
# Pre-generate the attempt identity so the child receives it in its contract
|
|
132
|
+
attempt_id = new_id("workcell-attempt")
|
|
118
133
|
attempt_number = int(cast(int, detail["attempt_count"])) + 1
|
|
119
134
|
store = WorkcellArtifactStore(
|
|
120
135
|
root, run_id, str(detail["task_key"]), attempt_number,
|
|
121
136
|
maximum_bytes=max(self.brief_size_limit, self.context_manifest_size_limit, self.result_size_limit),
|
|
122
137
|
)
|
|
138
|
+
relative_base = store.attempt_directory.relative_to(root).as_posix()
|
|
139
|
+
|
|
123
140
|
brief = "\n".join((
|
|
124
141
|
f"# Workcell task: {detail['task_key']}", "", f"## Objective\n{detail['objective']}",
|
|
125
142
|
"", "## Acceptance criteria", *(f"- {item}" for item in cast(tuple[str, ...], detail["acceptance"])),
|
|
@@ -127,18 +144,37 @@ class WorkcellService:
|
|
|
127
144
|
"", "## Allowed write scope", *(f"- {item}" for item in cast(tuple[str, ...], detail["write_scope"]) or ("- none declared",)),
|
|
128
145
|
"", "## Constraints\nDo not create approvals or waivers. Return only the versioned result contract.", "",
|
|
129
146
|
))
|
|
147
|
+
|
|
148
|
+
# The child contract includes every identity the child must echo in result.json
|
|
149
|
+
result_path = f"{relative_base}/result.json"
|
|
130
150
|
context = {
|
|
131
|
-
"version": 1,
|
|
132
|
-
"
|
|
133
|
-
"
|
|
151
|
+
"version": 1,
|
|
152
|
+
"run_id": run_id,
|
|
153
|
+
"task_id": str(detail["id"]),
|
|
154
|
+
"task_key": str(detail["task_key"]),
|
|
155
|
+
"attempt_id": attempt_id,
|
|
156
|
+
"attempt_number": attempt_number,
|
|
157
|
+
"graph_revision_id": str(detail["graph_revision_id"]),
|
|
158
|
+
"model_tier": str(detail["model_tier"]),
|
|
159
|
+
"brief_path": "brief.md",
|
|
160
|
+
"result_path": "result.json",
|
|
134
161
|
}
|
|
135
162
|
serialized_context = json.dumps(context, sort_keys=True, separators=(",", ":"))
|
|
136
163
|
context_sha256 = hashlib.sha256(serialized_context.encode("utf-8")).hexdigest()
|
|
137
|
-
|
|
164
|
+
|
|
165
|
+
# Enforce configured active-child bounds before attempting to claim
|
|
166
|
+
active_count = len(self.repository.list_active_workcell_attempts(run_id))
|
|
167
|
+
if active_count >= self.maximum_active_children:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
# Claim the attempt with pre-generated identity so the SHA-256 matches
|
|
138
171
|
attempt = self.repository.claim_workcell_task(
|
|
139
172
|
str(detail["id"]), claimant=claimant, model_tier=str(detail["model_tier"]),
|
|
140
173
|
context_sha256=context_sha256, brief_path=f"{relative_base}/brief.md",
|
|
141
|
-
context_manifest_path=f"{relative_base}/context.json",
|
|
174
|
+
context_manifest_path=f"{relative_base}/context.json",
|
|
175
|
+
result_path=result_path,
|
|
176
|
+
attempt_id=attempt_id, attempt_number=attempt_number,
|
|
177
|
+
claim_timeout_seconds=self.claim_timeout_seconds,
|
|
142
178
|
)
|
|
143
179
|
try:
|
|
144
180
|
if len(brief.encode("utf-8")) > self.brief_size_limit:
|
|
@@ -151,6 +187,10 @@ class WorkcellService:
|
|
|
151
187
|
self.repository.mark_workcell_attempt_running(
|
|
152
188
|
attempt.attempt_id, child_session_id=launch.child_session_id, child_handle=launch.raw,
|
|
153
189
|
)
|
|
190
|
+
# Verify the child received the correct attempt_id by writing result.json
|
|
191
|
+
result = store.attempt_directory / "result.json"
|
|
192
|
+
if result.exists():
|
|
193
|
+
raise RuntimeError("Workcell result path was pre-populated before child action")
|
|
154
194
|
except Exception as exc:
|
|
155
195
|
self.repository.close_workcell_attempt(
|
|
156
196
|
attempt.attempt_id, outcome="failed", actor=claimant, reason=f"launch failed: {type(exc).__name__}",
|
|
@@ -164,6 +204,10 @@ class WorkcellService:
|
|
|
164
204
|
child_session_id = detail["child_session_id"]
|
|
165
205
|
if not isinstance(child_session_id, str) or not child_session_id:
|
|
166
206
|
raise ValueError("Workcell attempt has no reported child session identity")
|
|
207
|
+
# Scope check: verify the attempt belongs to a run in IMPLEMENT stage
|
|
208
|
+
run = self.repository.get_run(str(detail["run_id"]))
|
|
209
|
+
if run.stage is not RunStage.IMPLEMENT:
|
|
210
|
+
raise PermissionError("Workcell result processing requires run in IMPLEMENT stage")
|
|
167
211
|
root = Path(project_root).resolve()
|
|
168
212
|
result_path = root / safe_project_relative(str(detail["result_path"]))
|
|
169
213
|
if not result_path.is_file() or result_path.is_symlink():
|
|
@@ -182,6 +226,15 @@ class WorkcellService:
|
|
|
182
226
|
for value in (*result.changed_paths, *result.artifacts_produced):
|
|
183
227
|
if not (root / safe_project_relative(value)).exists():
|
|
184
228
|
raise ValueError("Workcell result claims a missing path")
|
|
229
|
+
# Enforce write scope: every changed path and artifact must match declared write scope
|
|
230
|
+
write_scope = cast(tuple[str, ...], detail.get("write_scope") or ())
|
|
231
|
+
if write_scope:
|
|
232
|
+
import fnmatch
|
|
233
|
+
for path in (*result.changed_paths, *result.artifacts_produced):
|
|
234
|
+
if not any(fnmatch.fnmatch(path.replace("\\", "/"), pattern) for pattern in write_scope):
|
|
235
|
+
raise ValueError(
|
|
236
|
+
f"Workcell result path '{path}' is outside declared write scope {sorted(write_scope)}"
|
|
237
|
+
)
|
|
185
238
|
if result.reported_status == "succeeded":
|
|
186
239
|
expected = set(cast(tuple[str, ...], detail["acceptance"]))
|
|
187
240
|
if not expected.issubset(set(result.acceptance_completed)):
|
|
@@ -360,12 +360,22 @@ class RunRepository:
|
|
|
360
360
|
brief_path: str,
|
|
361
361
|
context_manifest_path: str,
|
|
362
362
|
result_path: str,
|
|
363
|
+
attempt_id: str | None = None,
|
|
364
|
+
attempt_number: int | None = None,
|
|
365
|
+
claim_timeout_seconds: int = 900,
|
|
363
366
|
) -> WorkcellAttempt:
|
|
364
|
-
"""Atomically create the sole active attempt for a ready Workcell task.
|
|
367
|
+
"""Atomically create the sole active attempt for a ready Workcell task.
|
|
368
|
+
|
|
369
|
+
When attempt_id and attempt_number are provided, use them directly
|
|
370
|
+
(pre-generated by the caller for the child handoff contract).
|
|
371
|
+
Otherwise generate fresh values (backward compatible).
|
|
372
|
+
|
|
373
|
+
claim_timeout_seconds controls the claim expiry window (default 900s/15m).
|
|
374
|
+
"""
|
|
365
375
|
if not claimant.strip() or not model_tier.strip() or len(context_sha256) != 64:
|
|
366
376
|
raise ValueError("invalid Workcell claim")
|
|
367
377
|
timestamp = utc_now()
|
|
368
|
-
expires_at = (datetime.now(timezone.utc) + timedelta(
|
|
378
|
+
expires_at = (datetime.now(timezone.utc) + timedelta(seconds=claim_timeout_seconds)).isoformat(
|
|
369
379
|
timespec="milliseconds"
|
|
370
380
|
).replace("+00:00", "Z")
|
|
371
381
|
with self.database.connect() as connection:
|
|
@@ -380,9 +390,9 @@ class RunRepository:
|
|
|
380
390
|
raise ValueError("Workcell task is not ready to claim")
|
|
381
391
|
if int(row["attempt_count"]) >= int(row["maximum_attempts"]):
|
|
382
392
|
raise ValueError("Workcell task has exhausted its attempts")
|
|
383
|
-
|
|
393
|
+
actual_attempt_id = attempt_id if attempt_id is not None else new_id("workcell-attempt")
|
|
384
394
|
launch_token = new_id("workcell-launch")
|
|
385
|
-
|
|
395
|
+
actual_attempt_number = attempt_number if attempt_number is not None else int(row["attempt_count"]) + 1
|
|
386
396
|
connection.execute(
|
|
387
397
|
"""INSERT INTO workcell_attempts(
|
|
388
398
|
id, run_id, task_id, attempt_number, state, launch_token, model_tier,
|
|
@@ -391,7 +401,7 @@ class RunRepository:
|
|
|
391
401
|
updated_at, terminal_reason
|
|
392
402
|
) VALUES (?, ?, ?, ?, 'starting', ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, ?, ?, NULL)""",
|
|
393
403
|
(
|
|
394
|
-
|
|
404
|
+
actual_attempt_id, row["run_id"], task_id, actual_attempt_number, launch_token,
|
|
395
405
|
model_tier, context_sha256, brief_path, context_manifest_path, result_path,
|
|
396
406
|
timestamp, timestamp,
|
|
397
407
|
),
|
|
@@ -399,12 +409,12 @@ class RunRepository:
|
|
|
399
409
|
connection.execute(
|
|
400
410
|
"""INSERT INTO workcell_claims(task_id, attempt_id, claim_token, claimant, acquired_at, expires_at)
|
|
401
411
|
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
402
|
-
(task_id,
|
|
412
|
+
(task_id, actual_attempt_id, launch_token, claimant, timestamp, expires_at),
|
|
403
413
|
)
|
|
404
414
|
cursor = connection.execute(
|
|
405
415
|
"""UPDATE workcell_tasks SET status='starting', attempt_count=?, updated_at=?
|
|
406
416
|
WHERE id=? AND status='ready'""",
|
|
407
|
-
(
|
|
417
|
+
(actual_attempt_number, timestamp, task_id),
|
|
408
418
|
)
|
|
409
419
|
if cursor.rowcount != 1:
|
|
410
420
|
raise RuntimeError("Workcell task changed concurrently")
|
|
@@ -413,7 +423,7 @@ class RunRepository:
|
|
|
413
423
|
run_id, task_id, attempt_id, event_type, actor, child_session_id,
|
|
414
424
|
previous_state, new_state, details_json, correlation_id, created_at
|
|
415
425
|
) VALUES (?, ?, ?, 'claim_acquired', ?, NULL, 'ready', 'starting', '{}', ?, ?)""",
|
|
416
|
-
(row["run_id"], task_id,
|
|
426
|
+
(row["run_id"], task_id, actual_attempt_id, claimant, launch_token, timestamp),
|
|
417
427
|
)
|
|
418
428
|
except Exception:
|
|
419
429
|
connection.rollback()
|
|
@@ -421,7 +431,7 @@ class RunRepository:
|
|
|
421
431
|
else:
|
|
422
432
|
connection.commit()
|
|
423
433
|
return WorkcellAttempt.create(
|
|
424
|
-
|
|
434
|
+
actual_attempt_id, str(row["run_id"]), task_id, actual_attempt_number, launch_token, model_tier, context_sha256
|
|
425
435
|
)
|
|
426
436
|
|
|
427
437
|
def list_workcell_attempts(self, task_id: str) -> tuple[WorkcellAttempt, ...]:
|
|
@@ -779,6 +789,40 @@ class RunRepository:
|
|
|
779
789
|
connection.commit()
|
|
780
790
|
return tuple(ready)
|
|
781
791
|
|
|
792
|
+
def count_unresolved_required_workcells(self, run_id: str) -> int:
|
|
793
|
+
"""Return the count of required Workcell tasks not in a terminal successful state.
|
|
794
|
+
|
|
795
|
+
Used by transition gates to prevent advancing past IMPLEMENT while
|
|
796
|
+
required Workcells are pending, starting, running, blocked, failed,
|
|
797
|
+
interrupted, cancelled, or escalated.
|
|
798
|
+
"""
|
|
799
|
+
with self.database.connect() as connection:
|
|
800
|
+
row = connection.execute(
|
|
801
|
+
"""SELECT COUNT(*) FROM workcell_tasks
|
|
802
|
+
WHERE run_id=? AND required=1 AND status != 'succeeded'""",
|
|
803
|
+
(run_id,),
|
|
804
|
+
).fetchone()
|
|
805
|
+
return int(row[0]) if row else 0
|
|
806
|
+
|
|
807
|
+
def list_active_workcell_attempts(self, run_id: str) -> tuple[WorkcellAttempt, ...]:
|
|
808
|
+
"""Return all Workcell attempts currently in starting or running state for a run."""
|
|
809
|
+
with self.database.connect() as connection:
|
|
810
|
+
rows = connection.execute(
|
|
811
|
+
"""SELECT attempt.* FROM workcell_attempts AS attempt
|
|
812
|
+
JOIN workcell_tasks AS task ON task.id=attempt.task_id
|
|
813
|
+
WHERE task.run_id=? AND attempt.state IN ('starting', 'running')
|
|
814
|
+
ORDER BY attempt.created_at""",
|
|
815
|
+
(run_id,),
|
|
816
|
+
).fetchall()
|
|
817
|
+
return tuple(
|
|
818
|
+
WorkcellAttempt(
|
|
819
|
+
str(row["id"]), str(row["run_id"]), str(row["task_id"]), int(row["attempt_number"]),
|
|
820
|
+
str(row["launch_token"]), str(row["model_tier"]), str(row["context_sha256"]),
|
|
821
|
+
AttemptState(str(row["state"])), row["child_session_id"], row["terminal_reason"],
|
|
822
|
+
)
|
|
823
|
+
for row in rows
|
|
824
|
+
)
|
|
825
|
+
|
|
782
826
|
def add_verification_check(self, check: VerificationCheck) -> None:
|
|
783
827
|
with self.database.connect() as connection:
|
|
784
828
|
connection.execute(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hardproof
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A persistent, risk-aware engineering protocol for Hermes Agent
|
|
5
5
|
Author: Hardproof contributors
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -36,8 +36,9 @@ Dynamic: license-file
|
|
|
36
36
|
[](https://github.com/asimons81/hardproof/actions/workflows/ci.yml)
|
|
37
37
|
[](LICENSE)
|
|
38
38
|
[](https://pypi.org/project/hardproof/)
|
|
39
|
+
[](https://x.com/asimons81)
|
|
39
40
|
|
|
40
|
-
> **Alpha software.** v0.
|
|
41
|
+
> **Alpha software.** v0.3.0 Workcells is the current public alpha on PyPI. v0.3.1 Workcells Hardening has been prepared. v0.4.0 Challenge Chamber development has not begun. Commands, schemas, and contracts may change before v1.0.0.
|
|
41
42
|
|
|
42
43
|
Hardproof gives coding agents a persistent, risk-aware engineering process that turns ambiguous software requests into reviewed, verified results while preserving the evidence behind every completion claim.
|
|
43
44
|
|
|
@@ -45,12 +46,12 @@ Hardproof gives coding agents a persistent, risk-aware engineering process that
|
|
|
45
46
|
|
|
46
47
|
## Current Release
|
|
47
48
|
|
|
48
|
-
|
|
|
49
|
-
|
|
50
|
-
|
|
|
49
|
+
| Current | v0.3.0 Workcells | Released on GitHub and PyPI |
|
|
50
|
+
| Hardening | v0.3.1 Workcells Hardening | Prepared, pending release |
|
|
51
|
+
| Previous | v0.2.0 Gatehouse | Released on GitHub and PyPI |
|
|
51
52
|
| Previous | v0.1.1 Core Heat | Released on GitHub and PyPI |
|
|
52
53
|
| Previous | v0.1.0 Core Heat | Released on GitHub |
|
|
53
|
-
| Future | v0.
|
|
54
|
+
| Future | v0.4.0 Challenge Chamber | Not started |
|
|
54
55
|
|
|
55
56
|
## Install
|
|
56
57
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|