From 602a6354ccbe9e984c94a5adf0071957dbd5233c Mon Sep 17 00:00:00 2001 From: Younes Abouelnagah Date: Thu, 5 Nov 2026 12:48:32 -0500 Subject: [PATCH] feat(epic): persist parallelism override and add capacity plan An explicit --max-parallelism now persists in the epic checkpoint (parallelism_source=override) until a later explicit value replaces it, so continuation passes that omit the flag no longer fall back to the policy default. epic-status exposes details.parallelism_source. helmet-epic gains a short Capacity and model plan: explicit model routing by role, separate worker and collaboration pools, slot backfilling with a compact capacity record, bounded review and audit assignments, and a compact continuation record instead of accumulated history. Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- docs/helmet-epic.md | 3 +- pyproject.toml | 2 +- skills/helmet-epic/SKILL.md | 68 +++++++++++++++++++++++----- src/hermes_helmet/__init__.py | 2 +- src/hermes_helmet/cli.py | 5 ++- src/hermes_helmet/helmet_epic.py | 22 ++++++--- tests/test_helmet_epic.py | 77 ++++++++++++++++++++++++++++++++ 9 files changed, 162 insertions(+), 21 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index e10c801..fa43d3d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "hermes-helmet", "displayName": "Hermes Helmet", - "version": "1.0.1", + "version": "1.1.0", "description": "First-officer skills for Hermes Helmet: setup, single-issue delivery, dependent-issue coordination, and multi-part writing (Helmet Write), plus the Captain's Bridge mod, which opens a pane bound to the current conversation with /captains-bridge and prepares a source-bound explanation in the background.", "author": { "name": "Machine Wisdom", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index e9a8f86..ba32b80 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "hermes-helmet", - "version": "1.0.1", + "version": "1.1.0", "description": "First-officer skills for Hermes Helmet: setup, single-issue delivery, dependent-issue coordination, and multi-part writing.", "author": { "name": "Machine Wisdom", diff --git a/docs/helmet-epic.md b/docs/helmet-epic.md index 5d018d8..a7e8e11 100644 --- a/docs/helmet-epic.md +++ b/docs/helmet-epic.md @@ -25,7 +25,8 @@ yourself. `PREFLIGHT → load/validate graph → classify children → dispatch ready frontier → WAITING/DONE` It invokes `helmet-issue` for each ready child with bounded parallelism -(`max_epic_parallelism`, default 2), continues independent branches when another +(`max_epic_parallelism`, default 2; an explicit `--max-parallelism` persists +for the epic until a later explicit value replaces it), continues independent branches when another child blocks, and never dispatch-labels the epic root. ## Graph model diff --git a/pyproject.toml b/pyproject.toml index bfa7d29..ceb488e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "hermes-helmet" -version = "1.0.1" +version = "1.1.0" description = "Open-source software factory for coding agents: delegation, review, and repair" readme = "README.md" requires-python = ">=3.11" diff --git a/skills/helmet-epic/SKILL.md b/skills/helmet-epic/SKILL.md index 1c33701..d9438ff 100644 --- a/skills/helmet-epic/SKILL.md +++ b/skills/helmet-epic/SKILL.md @@ -43,17 +43,16 @@ Do not use for: ## Accepted delivery outcome -Start from the accepted outcome and current evidence. Use actual Captain/worker -capacity and owner availability; a default parallelism limit does not imply two -available humans. Identify release-critical children, optional work, and final +Start from the accepted outcome and current evidence. Plan capacity with +[Capacity and model plan](#capacity-and-model-plan); a default parallelism limit +does not imply two available humans. Identify release-critical children, optional work, and final owner-only actions. Do not add acceptance gates because a reviewer suggested polish, a higher score, or stronger proof. Adopt explicit user scope changes with their effect on timing visible. Probe risky access, identity, topology, and API assumptions through the smallest real authorized path before dispatching dependent machinery. A synthetic -validator is not a substitute for executing the system. Keep a short current -frontier: outcome, active child/PR, next event, wait handle/cursor, and blocker. +validator is not a substitute for executing the system. Optional work does not become a release prerequisite; required acceptance remains required. Any proposed graph change still follows graph acceptance. @@ -200,9 +199,57 @@ helmet install-skills --target hermes announcement while waiting. When a pass reaches pending work, register continuation using the shared procedure below and announce its confirmed mechanism and effective cadence once. -4. Verify observed GitHub login is Captain and differs from worker. -5. Load epic root: must be open; must **not** carry `dispatch_label`. -6. Stop on authority mismatch, closed root, missing label config, or bad budgets. +4. Apply the [capacity and model plan](#capacity-and-model-plan). +5. Verify observed GitHub login is Captain and differs from worker. +6. Load epic root: must be open; must **not** carry `dispatch_label`. +7. Stop on authority mismatch, closed root, missing label config, or bad budgets. + +### Capacity and model plan + +The orchestrator schedules, reconciles state, and keeps evidence. It delegates +implementation and independent review rather than repeating them. + +1. **Models.** Once at startup, tell the user: for a long epic, select a + low-cost, tool-capable model for this orchestration chat. You cannot change + it yourself; continue either way. Select every delegated model explicitly by + role: the configured builder for implementation, a capable review model at + medium effort for standards and security/spec review, and a bounded + escalation for one hard question. Run deterministic tools before model + judgment. Helmet model lanes (`docs/model-lanes.md`) configure Hermes and its + services, not this chat. +2. **Two pools.** Helmet worker slots are `max_parallelism`. Host collaboration + slots are the subagents this host allows besides the parent. Neither limit + sets the other. +3. **Fill slots.** On startup and on each completion, failure, repair, or + released slot, reconcile the graph, adopt active work, then fill free slots + with eligible, non-duplicate work. `--max-parallelism` persists for the epic + until a later explicit value replaces it (`details.parallelism_source`). + Record each pass compactly: + + ```text + Worker pool: authorized=10 active=8 eligible-ready=2 -> dispatch 2 + Review pool: usable=3 active=2 eligible-ready=1 -> dispatch 1 + Idle slots: none + ``` + + An idle slot with eligible work needs a reason: dependency, ownership, or a + security gate. Duplicate work does not count as utilization. +4. **Review assignments.** State the repository, exact head and base, review + axis and standard/spec, model and effort, changed scope, evidence that may be + reused, and the required verdict and evidence artifact. Give self-contained + context, not the full chat. Key reviews by repository + PR + head + axis and + adopt an active review before spawning another. After a repair or base + update, reuse earlier evidence only for proven-unchanged blobs; exact-head + approval stays required. +5. **Audits.** Split large audits into disjoint packets that fit one worker + context, with exact files, exclusions, expected output, and a coverage + contract. The parent reconciles coverage and findings; it does not re-read + every packet. +6. **Continuation record.** Each wake carries only: accepted authority, graph + fingerprint, deadline, the capacity record, active handles, changed heads, + new results, open gates, the next action, and evidence pointers. Replace the + previous record; do not append history. Keep receipts in artifacts and read + them only when a pending decision needs them. ### 2. LOAD + VALIDATE GRAPH @@ -231,7 +278,8 @@ Buckets (always re-read GitHub + child checkpoints before acting): ### 4. DISPATCH FRONTIER -1. Ready frontier = ready children, capped by `max_parallelism - active`. +1. Ready frontier = ready children, capped by `max_parallelism - active` + (persisted override or policy default). 2. Default: continue independent branches when another fails/blocks. 3. For each frontier URL invoke `helmet-issue` helpers (`helmet issue` / `run_preflight_and_adopt`) with epic body for merge inheritance. @@ -283,7 +331,7 @@ dependents stay blocked on that closure, not on the merged code. See - [ ] Root never receives dispatch_label - [ ] Body fallback Parent / Blocked-by links validate; cycles rejected - [ ] Linear graphs invoke helmet-issue in dependency order -- [ ] Parallel graphs run at most `max_epic_parallelism` (default 2) +- [ ] Parallel graphs run at most the persisted override or `max_epic_parallelism` (default 2) - [ ] Blocked branch does not stop unrelated ready children (default) - [ ] Graph change → GRAPH_CHANGED until `--accept-graph` - [ ] Reinvocation resumes without duplicate roots diff --git a/src/hermes_helmet/__init__.py b/src/hermes_helmet/__init__.py index 7b7cad1..7fdeaeb 100644 --- a/src/hermes_helmet/__init__.py +++ b/src/hermes_helmet/__init__.py @@ -7,7 +7,7 @@ from hermes_helmet.authority import Policy, load_authority, load_policy, render_crew_contract -__version__ = "1.0.1" +__version__ = "1.1.0" __all__ = [ "Policy", diff --git a/src/hermes_helmet/cli.py b/src/hermes_helmet/cli.py index f9879c4..63f737b 100644 --- a/src/hermes_helmet/cli.py +++ b/src/hermes_helmet/cli.py @@ -1591,7 +1591,10 @@ def build_parser() -> argparse.ArgumentParser: "--max-parallelism", type=int, default=None, - help="Override policy max_epic_parallelism for this pass", + help=( + "Override policy max_epic_parallelism; persisted for this epic " + "until a later explicit value replaces it" + ), ) epic_p.add_argument( "--child", diff --git a/src/hermes_helmet/helmet_epic.py b/src/hermes_helmet/helmet_epic.py index d96ac5f..f9398d7 100644 --- a/src/hermes_helmet/helmet_epic.py +++ b/src/hermes_helmet/helmet_epic.py @@ -235,6 +235,7 @@ class EpicCheckpoint: graph_fingerprint: str | None = None accepted_fingerprint: str | None = None max_parallelism: int = 2 + parallelism_source: str = "policy" continue_independent_branches: bool = True active_children: list[str] = field(default_factory=list) completed_children: list[str] = field(default_factory=list) @@ -395,6 +396,11 @@ def _str_list(key: str) -> list[str]: else str(raw.get("accepted_fingerprint")) ), max_parallelism=max(1, int(raw.get("max_parallelism") or 2)), + parallelism_source=( + "override" + if raw.get("parallelism_source") == "override" + else "policy" + ), continue_independent_branches=bool( raw.get("continue_independent_branches", True) ), @@ -1865,11 +1871,16 @@ def run_epic_pass( checkpoint.host_continuation = host_continuation checkpoint.one_pass_only = one_pass_only checkpoint.continue_independent_branches = continue_independent_branches - parallelism = ( - max_parallelism - if max_parallelism is not None - else max(1, int(policy.max_epic_parallelism)) - ) + # An explicit override persists for this epic until a later explicit + # value replaces it, so continuations that omit the flag keep capacity. + if max_parallelism is not None: + parallelism = max(1, int(max_parallelism)) + checkpoint.parallelism_source = "override" + elif checkpoint.parallelism_source == "override": + parallelism = max(1, int(checkpoint.max_parallelism)) + else: + parallelism = max(1, int(policy.max_epic_parallelism)) + checkpoint.parallelism_source = "policy" checkpoint.max_parallelism = parallelism if not checkpoint.started_at: checkpoint.started_at = _utc_now() @@ -2431,6 +2442,7 @@ def build_epic_status( blocker=blocker, terminal=state in EPIC_TERMINAL, details={ + "parallelism_source": checkpoint.parallelism_source, "graph_source": graph.source, "child_count": len(graph.children), "epic_merge_mode": checkpoint.epic_merge_mode, diff --git a/tests/test_helmet_epic.py b/tests/test_helmet_epic.py index b5ab831..6b13774 100644 --- a/tests/test_helmet_epic.py +++ b/tests/test_helmet_epic.py @@ -1120,6 +1120,83 @@ def test_ready_frontier_caps_at_two_and_skips_blocked(self) -> None: [], ) + def test_active_children_consume_override_slots(self) -> None: + ready = [f"https://github.com/x/y/issues/{n}" for n in range(1, 6)] + buckets = { + "active": [f"https://github.com/x/y/issues/{n}" for n in range(10, 18)], + "ready": ready, + "failed": [], + } + self.assertEqual( + he.compute_ready_frontier(buckets, max_parallelism=10), ready[:2] + ) + + def _no_dispatch_pass(self, ck: Path, policy=None, **kwargs): + checkpoint, report, _ = he.run_epic_pass( + policy or self.policy, + EPIC_URL, + checkpoint_dir=ck, + runner=self.runner, + extra_child_urls=[CHILD_A, CHILD_B, CHILD_C, CHILD_H], + apply_dispatch=False, + accept_graph=True, + **kwargs, + ) + return checkpoint, report + + def test_parallelism_override_persists_across_passes(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + ck = Path(tmp) + first, _ = self._no_dispatch_pass(ck, max_parallelism=10) + self.assertEqual( + (first.max_parallelism, first.parallelism_source), (10, "override") + ) + second, report = self._no_dispatch_pass(ck) + self.assertEqual( + (second.max_parallelism, second.parallelism_source), + (10, "override"), + ) + self.assertEqual(report.max_parallelism, 10) + self.assertEqual(report.details.get("parallelism_source"), "override") + # A policy change does not silently undo the accepted override. + third, _ = self._no_dispatch_pass( + ck, policy=_policy(max_epic_parallelism=3) + ) + self.assertEqual(third.max_parallelism, 10) + # A later explicit value replaces it. + fourth, _ = self._no_dispatch_pass(ck, max_parallelism=4) + fifth, _ = self._no_dispatch_pass(ck) + self.assertEqual( + (fourth.max_parallelism, fifth.max_parallelism), (4, 4) + ) + + def test_policy_parallelism_follows_policy_without_override(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + ck = Path(tmp) + first, _ = self._no_dispatch_pass(ck) + self.assertEqual( + (first.max_parallelism, first.parallelism_source), (2, "policy") + ) + second, _ = self._no_dispatch_pass( + ck, policy=_policy(max_epic_parallelism=5) + ) + self.assertEqual( + (second.max_parallelism, second.parallelism_source), (5, "policy") + ) + + def test_legacy_checkpoint_parallelism_loads_as_policy(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + ck = Path(tmp) + self._no_dispatch_pass(ck, max_parallelism=7) + path = he.epic_checkpoint_path(ck, EPIC_URL) + raw = json.loads(path.read_text()) + raw.pop("parallelism_source") + path.write_text(json.dumps(raw)) + loaded = he.load_epic_checkpoint(path, expected_epic_url=EPIC_URL) + self.assertEqual(loaded.parallelism_source, "policy") + again, _ = self._no_dispatch_pass(ck) + self.assertEqual(again.max_parallelism, 2) + def test_linear_then_parallel_invocation_order(self) -> None: invoked: list[str] = []