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.
Files changed (86) hide show
  1. {hardproof-0.3.0/hardproof.egg-info → hardproof-0.3.1}/PKG-INFO +7 -6
  2. {hardproof-0.3.0 → hardproof-0.3.1}/README.md +6 -5
  3. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/__init__.py +1 -1
  4. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/commands/shared.py +93 -10
  5. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/policy/stage_rules.py +3 -0
  6. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/workcells.py +60 -7
  7. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/storage/repository.py +53 -9
  8. {hardproof-0.3.0 → hardproof-0.3.1/hardproof.egg-info}/PKG-INFO +7 -6
  9. {hardproof-0.3.0 → hardproof-0.3.1}/pyproject.toml +1 -1
  10. {hardproof-0.3.0 → hardproof-0.3.1}/LICENSE +0 -0
  11. {hardproof-0.3.0 → hardproof-0.3.1}/NOTICE +0 -0
  12. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/commands/__init__.py +0 -0
  13. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/commands/cli.py +0 -0
  14. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/commands/slash.py +0 -0
  15. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/compat.py +0 -0
  16. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/config.py +0 -0
  17. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/constants.py +0 -0
  18. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/domain/__init__.py +0 -0
  19. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/domain/enums.py +0 -0
  20. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/domain/models.py +0 -0
  21. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/domain/snapshots.py +0 -0
  22. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/domain/transitions.py +0 -0
  23. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/domain/workcells.py +0 -0
  24. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/errors.py +0 -0
  25. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/hooks/__init__.py +0 -0
  26. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/hooks/context.py +0 -0
  27. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/hooks/sessions.py +0 -0
  28. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/hooks/tool_policy.py +0 -0
  29. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/hooks/verification.py +0 -0
  30. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/migrations/001_initial.sql +0 -0
  31. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/migrations/002_gatehouse.sql +0 -0
  32. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/migrations/003_workcells.sql +0 -0
  33. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/migrations/__init__.py +0 -0
  34. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/paths.py +0 -0
  35. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/plugin.py +0 -0
  36. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/policy/__init__.py +0 -0
  37. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/policy/packs.py +0 -0
  38. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/policy/profiles.py +0 -0
  39. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/policy/stage_graph.py +0 -0
  40. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/policy/terminal.py +0 -0
  41. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/policy/tool_rules.py +0 -0
  42. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/policy/trace.py +0 -0
  43. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/policy/verification_rules.py +0 -0
  44. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/policy/waivers.py +0 -0
  45. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/py.typed +0 -0
  46. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/__init__.py +0 -0
  47. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/approvals.py +0 -0
  48. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/artifacts.py +0 -0
  49. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/authority.py +0 -0
  50. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/decisions.py +0 -0
  51. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/evidence.py +0 -0
  52. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/hermes_children.py +0 -0
  53. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/reports.py +0 -0
  54. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/risks.py +0 -0
  55. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/runs.py +0 -0
  56. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/sessions.py +0 -0
  57. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/tasks.py +0 -0
  58. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/waivers.py +0 -0
  59. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/services/workcell_artifacts.py +0 -0
  60. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/skills/deliver/SKILL.md +0 -0
  61. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/skills/design/SKILL.md +0 -0
  62. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/skills/discover/SKILL.md +0 -0
  63. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/skills/implement/SKILL.md +0 -0
  64. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/skills/learn/SKILL.md +0 -0
  65. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/skills/orchestrate/SKILL.md +0 -0
  66. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/skills/plan/SKILL.md +0 -0
  67. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/skills/review/SKILL.md +0 -0
  68. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/skills/verify/SKILL.md +0 -0
  69. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/storage/__init__.py +0 -0
  70. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/storage/database.py +0 -0
  71. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/storage/migrations.py +0 -0
  72. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/templates/__init__.py +0 -0
  73. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/templates/completion.md +0 -0
  74. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/templates/design.md +0 -0
  75. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/templates/discovery.md +0 -0
  76. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/templates/plan.md +0 -0
  77. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/templates/review.md +0 -0
  78. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/tools/__init__.py +0 -0
  79. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/tools/handlers.py +0 -0
  80. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof/tools/schemas.py +0 -0
  81. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof.egg-info/SOURCES.txt +0 -0
  82. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof.egg-info/dependency_links.txt +0 -0
  83. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof.egg-info/entry_points.txt +0 -0
  84. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof.egg-info/requires.txt +0 -0
  85. {hardproof-0.3.0 → hardproof-0.3.1}/hardproof.egg-info/top_level.txt +0 -0
  86. {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.0
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
  [![CI](https://github.com/asimons81/hardproof/actions/workflows/ci.yml/badge.svg)](https://github.com/asimons81/hardproof/actions/workflows/ci.yml)
37
37
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
38
38
  [![PyPI](https://img.shields.io/pypi/v/hardproof)](https://pypi.org/project/hardproof/)
39
+ [![X Follow](https://img.shields.io/twitter/follow/asimons81?style=social)](https://x.com/asimons81)
39
40
 
40
- > **Alpha software.** v0.2.0 Gatehouse is the current signed public alpha on PyPI. v0.3.0 Workcells development has not begun. Commands, schemas, and contracts may change before v1.0.0.
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
- | Release | Version | Status |
49
- |---------|---------|--------|
50
- | Public alpha | v0.2.0 Gatehouse | Released on GitHub and PyPI |
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.3.0 Workcells | Not started |
54
+ | Future | v0.4.0 Challenge Chamber | Not started |
54
55
 
55
56
  ## Install
56
57
 
@@ -5,8 +5,9 @@
5
5
  [![CI](https://github.com/asimons81/hardproof/actions/workflows/ci.yml/badge.svg)](https://github.com/asimons81/hardproof/actions/workflows/ci.yml)
6
6
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
7
7
  [![PyPI](https://img.shields.io/pypi/v/hardproof)](https://pypi.org/project/hardproof/)
8
+ [![X Follow](https://img.shields.io/twitter/follow/asimons81?style=social)](https://x.com/asimons81)
8
9
 
9
- > **Alpha software.** v0.2.0 Gatehouse is the current signed public alpha on PyPI. v0.3.0 Workcells development has not begun. Commands, schemas, and contracts may change before v1.0.0.
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
- | Release | Version | Status |
18
- |---------|---------|--------|
19
- | Public alpha | v0.2.0 Gatehouse | Released on GitHub and PyPI |
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.3.0 Workcells | Not started |
23
+ | Future | v0.4.0 Challenge Chamber | Not started |
23
24
 
24
25
  ## Install
25
26
 
@@ -1,3 +1,3 @@
1
1
  """Hardproof public package."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.1"
@@ -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, sequence in {
378
- "acceptance": acceptance, "dependencies": dependencies,
379
- "read_scope": read_scope, "write_scope": write_scope,
380
- }.items():
381
- if not isinstance(sequence, list) or any(not isinstance(item, str) for item in sequence):
382
- raise ValueError(f"Workcell task {name} must be a list of strings")
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), bool(value.get("required", True)), tuple(read_scope), tuple(write_scope),
386
- int(value.get("priority", 0)), str(value["model_tier"]) if "model_tier" in value else None,
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
- raise ValueError("usage: workcells <status|plan --tasks-json JSON|run-next|result ATTEMPT|reconcile> [attempt-id]")
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, "run_id": run_id, "task_id": detail["id"], "task_key": detail["task_key"],
132
- "graph_revision_id": detail["graph_revision_id"], "model_tier": detail["model_tier"],
133
- "brief_path": "brief.md", "result_path": "result.json",
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
- relative_base = store.attempt_directory.relative_to(root).as_posix()
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", result_path=f"{relative_base}/result.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(minutes=15)).isoformat(
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
- attempt_id = new_id("workcell-attempt")
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
- attempt_number = int(row["attempt_count"]) + 1
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
- attempt_id, row["run_id"], task_id, attempt_number, launch_token,
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, attempt_id, launch_token, claimant, timestamp, expires_at),
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
- (attempt_number, timestamp, task_id),
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, attempt_id, claimant, launch_token, timestamp),
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
- attempt_id, str(row["run_id"]), task_id, attempt_number, launch_token, model_tier, context_sha256
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.0
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
  [![CI](https://github.com/asimons81/hardproof/actions/workflows/ci.yml/badge.svg)](https://github.com/asimons81/hardproof/actions/workflows/ci.yml)
37
37
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
38
38
  [![PyPI](https://img.shields.io/pypi/v/hardproof)](https://pypi.org/project/hardproof/)
39
+ [![X Follow](https://img.shields.io/twitter/follow/asimons81?style=social)](https://x.com/asimons81)
39
40
 
40
- > **Alpha software.** v0.2.0 Gatehouse is the current signed public alpha on PyPI. v0.3.0 Workcells development has not begun. Commands, schemas, and contracts may change before v1.0.0.
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
- | Release | Version | Status |
49
- |---------|---------|--------|
50
- | Public alpha | v0.2.0 Gatehouse | Released on GitHub and PyPI |
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.3.0 Workcells | Not started |
54
+ | Future | v0.4.0 Challenge Chamber | Not started |
54
55
 
55
56
  ## Install
56
57
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hardproof"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "A persistent, risk-aware engineering protocol for Hermes Agent"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
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