diff --git a/skills/nelson/SKILL.md b/skills/nelson/SKILL.md index 8f52559..b6ac241 100644 --- a/skills/nelson/SKILL.md +++ b/skills/nelson/SKILL.md @@ -107,7 +107,7 @@ When The Estimate has been conducted, the Battle Plan inherits the analytical wo - For each task, consciously mark `admiralty-action-required: yes` or `no`. - Keep one task in progress per agent unless the mission explicitly requires multitasking. -Reference `references/admiralty-templates/battle-plan.md` for the battle plan template and `references/admiralty-templates/ship-manifest.md` for the ship manifest. +Reference `references/admiralty-templates/battle-plan.md` for the schema of each captain's brief and `references/admiralty-templates/ship-manifest.md` for the ship manifest. **Battle Plan Gate — Standing Order Check:** You MUST NOT finalize task assignments until each question below is answered in writing and any triggered standing order remedy has been applied. Show your reasoning — a bare yes/no is not sufficient. - `becalmed-fleet.md`: Should this mission use single-session instead of multi-agent? If yes, skip Step 4 — single-session has no squadron to form. @@ -124,6 +124,8 @@ Reference `references/admiralty-templates/battle-plan.md` for the battle plan te If any answer triggers a standing order, you MUST apply the corrective action and re-answer the question before proceeding. For situations not covered by this gate, consult the Standing Orders table below. +**Persist the drafted plan.** Before proceeding to Step 4, write the complete battle plan to `{mission-dir}/battle-plan.md` using the template at `references/admiralty-templates/battle-plan.md`. Include the commander's intent verbatim from Estimate §2, each task brief in template form, and the Standing Order Check answers. **This is a safe compaction point — admiral state is now fully on disk.** + **Structured Data Capture:** Task registration requires owners, which are assigned in Step 4. No `nelson-data.py` script calls at this step. ## 4. Form the Squadron @@ -367,6 +369,7 @@ Consult the specific procedure that matches the situation. ## Admiralty Doctrine - Include this instruction in any admiral's compaction summary: Re-read the quarterdeck report at the mission directory path to recover `{mission-dir}`. If the path is unknown, read `.nelson/.active-{SESSION_ID}` if you know the SESSION_ID, otherwise list `.nelson/missions/` and present the options to the user for selection. Then re-read `references/standing-orders/admiral-at-the-helm.md` to confirm you are in coordination role. +- Treat `/compact` as safe at any phase boundary (after Step 1, 2, 3, 4, and at every quarterdeck checkpoint in Step 6). The narrow unsafe window is inside Step 5 — between user approval and the admiral's `permission_granted` / phase advance / agent spawn turn. - Optimize for mission throughput, not equal work distribution. - Prefer replacing stalled agents over waiting on undefined blockers. - Recognise strong performance; motivation compounds across missions. diff --git a/skills/nelson/references/admiralty-templates/battle-plan.md b/skills/nelson/references/admiralty-templates/battle-plan.md index 6f376ef..08c8a2e 100644 --- a/skills/nelson/references/admiralty-templates/battle-plan.md +++ b/skills/nelson/references/admiralty-templates/battle-plan.md @@ -1,5 +1,7 @@ # Battle Plan Template +The rendered plan lives at `{mission-dir}/battle-plan.md` and is the prose authority for the mission — commander's intent and per-task briefs. The structured form at `{mission-dir}/battle-plan.json` is the execution-data authority (owners, dependencies, station tiers, file ownership). Keep them aligned: edit one, mirror the change in the other. + Every captain's brief opens with the commander's intent from the Estimate (§2) — one paragraph, verbatim. This is how each ship sails under a shared understanding of purpose. ```text diff --git a/skills/nelson/references/damage-control/session-resumption.md b/skills/nelson/references/damage-control/session-resumption.md index 957946e..b8a1a0b 100644 --- a/skills/nelson/references/damage-control/session-resumption.md +++ b/skills/nelson/references/damage-control/session-resumption.md @@ -17,3 +17,5 @@ Use when a session is interrupted (context limit, crash, timeout) and work must 7. Re-issue sailing orders with the original mission outcome and updated scope reflecting completed work. 8. Re-form the squadron at the minimum size needed for remaining tasks. 9. Resume quarterdeck rhythm from the next scheduled checkpoint. + +**Safe compaction windows.** State is fully persisted at every phase boundary: after Sailing Orders (Step 1), after the Estimate (Step 2), after the Battle Plan is drafted to disk (Step 3), after Formation (Step 4), and at every quarterdeck checkpoint (Step 6). The one unsafe window is *inside* Step 5: between the user granting permission and the admiral logging `permission_granted` + advancing to UNDERWAY + spawning agents — that whole sequence is a single tightly coupled turn. diff --git a/skills/nelson/scripts/nelson_data_lifecycle.py b/skills/nelson/scripts/nelson_data_lifecycle.py index 747db3c..9d2aed2 100644 --- a/skills/nelson/scripts/nelson_data_lifecycle.py +++ b/skills/nelson/scripts/nelson_data_lifecycle.py @@ -55,6 +55,48 @@ _CONFLICT_SCAN_SCRIPT = Path(__file__).resolve().parent / "nelson_conflict_scan.py" +# Per-phase recovery guidance for `cmd_recover`. UNDERWAY is intentionally +# omitted: when the mission is underway, recovery uses handoff-packet-derived +# actions instead of static guidance. +PHASE_RECOVERY_GUIDANCE: dict[str, list[str]] = { + "SAILING_ORDERS": [ + "You are in SAILING_ORDERS phase (Step 1).", + "Read sailing-orders.json for outcome, metric, deadline, and constraints.", + "Decide with the user whether to conduct The Estimate before drafting the Battle Plan.", + ], + "ESTIMATE": [ + "You are in ESTIMATE phase (Step 2).", + "Read sailing-orders.json and any partial estimate.md to resume the seven-question Maritime Tactical Estimate.", + "Continue from the next unanswered question; honour the Q1 and Q3 checkpoints with the user.", + ], + "BATTLE_PLAN": [ + "You are in BATTLE_PLAN phase (Step 3).", + "Read estimate.md for commander's intent and effects, and battle-plan.md for any drafted plan.", + "Complete the Standing Order Check, then persist the final plan to {mission-dir}/battle-plan.md before advancing to Step 4.", + ], + "FORMATION": [ + "You are in FORMATION phase (Step 4).", + "Read battle-plan.json for tasks and squadron, and battle-plan.md for the prose plan.", + "Confirm formation orders with the user before advancing to PERMISSION.", + ], + "PERMISSION": [ + "You are in PERMISSION phase (Step 5).", + "Read battle-plan.json and battle-plan.md, then re-display the complete battle plan and squadron formation.", + "Wait for explicit user permission before logging permission_granted and advancing to UNDERWAY.", + ], + "STAND_DOWN": [ + "You are in STAND_DOWN phase (Step 8).", + "Read stand-down.json for the recorded mission summary.", + "Verify {mission-dir}/captains-log.md exists and remove .nelson/.active-{SESSION_ID} if still present.", + ], +} + + +BATTLE_PLAN_MD_REQUIRED_PHASES: frozenset[str] = frozenset( + {"BATTLE_PLAN", "FORMATION", "PERMISSION"} +) + + # --------------------------------------------------------------------------- # Internal helper: _do_init (used by cmd_init and cmd_headless) # --------------------------------------------------------------------------- @@ -1717,17 +1759,38 @@ def _build_recovery_briefing( } ) + current_phase = ( + fleet_status.get("mission", {}).get("phase", "unknown") + if fleet_status + else "unknown" + ) + recommended_actions: list[str] = [] - for pkt in handoff_packets: - ship = pkt.get("ship_name", "unknown") - task_id = pkt.get("task_id") - recommended_actions.append( - f"Resume task {task_id} from handoff packet ({ship})" - ) - if not recommended_actions: - recommended_actions.append( - "No handoff packets found — review fleet-status.json for current state" - ) + # Phase guidance takes precedence over handoff packets when both exist — + # an anomalous mid-flight state, but if it occurs the phase wins because + # it reflects the admiral's last recorded position in the workflow. + if current_phase in PHASE_RECOVERY_GUIDANCE: + recommended_actions = list(PHASE_RECOVERY_GUIDANCE[current_phase]) + if ( + current_phase in BATTLE_PLAN_MD_REQUIRED_PHASES + and not (mission_dir / "battle-plan.md").is_file() + ): + recommended_actions.insert( + 0, + "WARNING: battle-plan.md is missing — rebuild from estimate.md", + ) + else: + # UNDERWAY (and any unknown phase): derive actions from handoff packets. + for pkt in handoff_packets: + ship = pkt.get("ship_name", "unknown") + task_id = pkt.get("task_id") + recommended_actions.append( + f"Resume task {task_id} from handoff packet ({ship})" + ) + if not recommended_actions: + recommended_actions.append( + "No handoff packets found — review fleet-status.json for current state" + ) return { "mission_dir": str(mission_dir), @@ -1736,6 +1799,7 @@ def _build_recovery_briefing( if fleet_status else "unknown" ), + "current_phase": current_phase, "fleet_status": fleet_status, "handoff_packets": handoff_packets, "pending_tasks": pending_tasks, @@ -1748,6 +1812,7 @@ def _format_recovery_text(briefing: dict) -> str: lines: list[str] = [] lines.append(f"[nelson-data] Recovery briefing for {briefing['mission_dir']}") lines.append(f" Status: {briefing['mission_status']}") + lines.append(f" Phase: {briefing.get('current_phase', 'unknown')}") lines.append("") fs = briefing.get("fleet_status") diff --git a/skills/nelson/scripts/test_nelson_data.py b/skills/nelson/scripts/test_nelson_data.py index 03bb6b7..9c0726f 100644 --- a/skills/nelson/scripts/test_nelson_data.py +++ b/skills/nelson/scripts/test_nelson_data.py @@ -11,6 +11,8 @@ import subprocess from pathlib import Path +import pytest + from conftest import ( add_squadron, add_task, @@ -19,6 +21,18 @@ read_json, run, ) +from nelson_data_lifecycle import ( + BATTLE_PLAN_MD_REQUIRED_PHASES, + PHASE_RECOVERY_GUIDANCE, +) + + +def _set_mission_phase(mission_dir: Path, phase: str) -> None: + """Test helper: rewrite fleet-status.json with the requested mission.phase.""" + fs_path = mission_dir / "fleet-status.json" + fs = json.loads(fs_path.read_text(encoding="utf-8")) + fs.setdefault("mission", {})["phase"] = phase + fs_path.write_text(json.dumps(fs, indent=2), encoding="utf-8") # --------------------------------------------------------------------------- @@ -1428,6 +1442,44 @@ def test_recover_text_output(self, tmp_path: Path) -> None: result = run("recover", "--mission-dir", str(mission_dir), "--format", "text") assert "[nelson-data] Recovery briefing" in result.stdout assert "HMS Argyll" in result.stdout + assert "Phase: " in result.stdout + + def test_recover_includes_phase_in_briefing(self, tmp_path: Path) -> None: + mission_dir = setup_mission_with_task(tmp_path) + result = run("recover", "--mission-dir", str(mission_dir)) + briefing = json.loads(result.stdout) + assert "current_phase" in briefing + assert briefing["current_phase"] + + @pytest.mark.parametrize("phase", sorted(PHASE_RECOVERY_GUIDANCE.keys())) + def test_recover_phase_specific_actions( + self, tmp_path: Path, phase: str + ) -> None: + mission_dir = setup_mission_with_task(tmp_path) + # Pre-create battle-plan.md so phases that check for it don't add a warning. + (mission_dir / "battle-plan.md").write_text("# Battle Plan\n", encoding="utf-8") + _set_mission_phase(mission_dir, phase) + result = run("recover", "--mission-dir", str(mission_dir)) + briefing = json.loads(result.stdout) + assert briefing["current_phase"] == phase + assert briefing["recommended_actions"] == PHASE_RECOVERY_GUIDANCE[phase] + + @pytest.mark.parametrize("phase", sorted(BATTLE_PLAN_MD_REQUIRED_PHASES)) + def test_recover_warns_when_battle_plan_md_missing( + self, tmp_path: Path, phase: str + ) -> None: + mission_dir = setup_mission_with_task(tmp_path) + _set_mission_phase(mission_dir, phase) + # Ensure battle-plan.md is absent + bp_md = mission_dir / "battle-plan.md" + if bp_md.exists(): + bp_md.unlink() + result = run("recover", "--mission-dir", str(mission_dir)) + briefing = json.loads(result.stdout) + assert any( + "battle-plan.md is missing" in action + for action in briefing["recommended_actions"] + ), briefing["recommended_actions"] def test_recover_no_active_mission_silent(self, tmp_path: Path) -> None: missions_dir = tmp_path / ".nelson" / "missions" @@ -1474,6 +1526,10 @@ def test_full_handoff_lifecycle(self, tmp_path: Path) -> None: ], ) + # Handoffs happen during UNDERWAY — recovery should fall back to + # handoff-packet-derived actions in that phase. + _set_mission_phase(mission_dir, "UNDERWAY") + # Recover result = run("recover", "--mission-dir", str(mission_dir)) briefing = json.loads(result.stdout)