Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -1777,6 +1777,10 @@ async def _handle_goal_command(self, event: "MessageEvent") -> str:
if not args or lower == "status":
return mgr.status_line()

# /goal show → print the active goal's completion contract
if lower == "show":
return f"{mgr.status_line()}\n{mgr.render_contract()}"

if lower == "pause":
state = mgr.pause(reason="user-paused")
if state is None:
Expand Down Expand Up @@ -1832,9 +1836,38 @@ async def _handle_goal_command(self, event: "MessageEvent") -> str:
return "▶ Wait barrier cleared — goal loop resumes."
return "No wait barrier set."

# /goal draft <objective> → draft a structured completion contract,
# then set it. The aux LLM call is sync; run it off the event loop.
draft_contract_obj = None
if lower.startswith("draft"):
objective = args[len("draft"):].strip()
if not objective:
return "Usage: /goal draft <objective in plain language>"
try:
import asyncio
from hermes_cli.goals import draft_contract

draft_contract_obj = await asyncio.get_running_loop().run_in_executor(
None, draft_contract, objective
)
except Exception as exc:
logger.debug("goal draft failed: %s", exc)
draft_contract_obj = None
args = objective # the goal text is the objective
contract = draft_contract_obj
else:
# Inline `field: value` lines parse into a completion contract;
# the remaining prose is the goal headline. Plain free-form goals
# (no such lines) behave exactly as before.
from hermes_cli.goals import parse_contract

headline, parsed = parse_contract(args)
args = headline or args
contract = parsed if not parsed.is_empty() else None

# Otherwise — treat the remaining text as the new goal.
try:
state = mgr.set(args)
state = mgr.set(args, contract=contract)
except ValueError as exc:
return t("gateway.goal.invalid", error=str(exc))

Expand All @@ -1855,7 +1888,13 @@ async def _handle_goal_command(self, event: "MessageEvent") -> str:
except Exception as exc:
logger.debug("goal kickoff enqueue failed: %s", exc)

return t("gateway.goal.set", budget=state.max_turns, goal=state.goal)
base = t("gateway.goal.set", budget=state.max_turns, goal=state.goal)
if state.has_contract():
return f"{base}\nCompletion contract:\n{state.contract.render_block()}"
if lower.startswith("draft"):
# Drafting was requested but the aux model couldn't produce one.
return f"{base}\n(Couldn't draft a contract — running as a free-form goal.)"
return base

async def _handle_subgoal_command(self, event: "MessageEvent") -> str:
"""Handle /subgoal for gateway platforms (mirror of CLI handler).
Expand Down
87 changes: 82 additions & 5 deletions hermes_cli/cli_commands_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1775,7 +1775,7 @@ def _handle_browser_command(self, cmd: str):
print()

def _handle_goal_command(self, cmd: str) -> None:
"""Dispatch /goal subcommands: set / status / pause / resume / clear."""
"""Dispatch /goal subcommands: set / draft / show / status / pause / resume / clear."""
from cli import _DIM, _RST, _cprint
parts = (cmd or "").strip().split(None, 1)
arg = parts[1].strip() if len(parts) > 1 else ""
Expand All @@ -1792,6 +1792,25 @@ def _handle_goal_command(self, cmd: str) -> None:
_cprint(f" {mgr.status_line()}")
return

# /goal show → print the active goal's completion contract
if lower == "show":
_cprint(f" {mgr.status_line()}")
_cprint(f" {mgr.render_contract()}")
return

# /goal draft <objective> → expand plain text into a structured
# completion contract (outcome / verification / constraints /
# boundaries / stop_when) and set it as the active goal. Adapted
# from Codex's "let the agent draft the goal" guidance: the contract
# makes "done" evidence-based instead of a loose vibe check.
if lower.startswith("draft"):
objective = arg[len("draft"):].strip()
if not objective:
_cprint(" Usage: /goal draft <objective in plain language>")
return
self._handle_goal_draft(objective)
return

if lower == "pause":
state = mgr.pause(reason="user-paused")
if state is None:
Expand Down Expand Up @@ -1853,18 +1872,30 @@ def _handle_goal_command(self, cmd: str) -> None:
_cprint(f" {_DIM}No wait barrier set.{_RST}")
return

# Otherwise treat the arg as the goal text.
# Otherwise treat the arg as the goal text. Inline `field: value`
# lines (verify:, constraints:, boundaries:, stop when:) are parsed
# into a completion contract; the remaining prose is the headline.
# A plain free-form goal with no such lines behaves exactly as before.
from hermes_cli.goals import parse_contract

headline, contract = parse_contract(arg)
goal_text = headline or arg
try:
state = mgr.set(arg)
state = mgr.set(goal_text, contract=contract if not contract.is_empty() else None)
except ValueError as exc:
_cprint(f" Invalid goal: {exc}")
return

_cprint(f" ⊙ Goal set ({state.max_turns}-turn budget): {state.goal}")
if state.has_contract():
_cprint(f" {_DIM}Completion contract:{_RST}")
for line in state.contract.render_block().splitlines():
_cprint(f" {line}")
_cprint(
f" {_DIM}After each turn, a judge model will check if the goal is done. "
f" {_DIM}After each turn, a judge model checks if the goal is done"
f"{' against the contract above' if state.has_contract() else ''}. "
f"Hermes keeps working until it is, you pause/clear it, or the budget is "
f"exhausted. Use /goal status, /goal pause, /goal resume, /goal clear.{_RST}"
f"exhausted. Use /goal status, /goal show, /goal pause, /goal resume, /goal clear.{_RST}"
)
# Kick the loop off immediately so the user doesn't have to send a
# separate message after setting the goal.
Expand All @@ -1873,6 +1904,52 @@ def _handle_goal_command(self, cmd: str) -> None:
except Exception:
pass

def _handle_goal_draft(self, objective: str) -> None:
"""Draft a structured completion contract from a plain objective and
set it as the active goal. Falls back to a bare goal if the aux model
can't produce a contract."""
from cli import _DIM, _RST, _cprint
from hermes_cli.goals import draft_contract

mgr = self._get_goal_manager()
if mgr is None:
_cprint(f" {_DIM}Goals unavailable (no active session).{_RST}")
return

_cprint(f" {_DIM}Drafting completion contract…{_RST}")
try:
contract = draft_contract(objective)
except Exception as exc:
import logging as _logging
_logging.getLogger(__name__).debug("goal draft failed: %s", exc)
contract = None

try:
state = mgr.set(objective, contract=contract)
except ValueError as exc:
_cprint(f" Invalid goal: {exc}")
return

_cprint(f" ⊙ Goal set ({state.max_turns}-turn budget): {state.goal}")
if state.has_contract():
_cprint(f" {_DIM}Drafted completion contract:{_RST}")
for line in state.contract.render_block().splitlines():
_cprint(f" {line}")
_cprint(
f" {_DIM}Tighten any field by re-setting the goal with inline "
f"lines (e.g. verify: <command>), then /goal resume. "
f"Use /goal show to review.{_RST}"
)
else:
_cprint(
f" {_DIM}Couldn't draft a contract (aux model unavailable) — "
f"running as a free-form goal. The per-turn judge still applies.{_RST}"
)
try:
self._pending_input.put(state.goal)
except Exception:
pass

def _handle_subgoal_command(self, cmd: str) -> None:
"""Dispatch /subgoal subcommands.

Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ class CommandDef:
CommandDef("steer", "Inject a message after the next tool call without interrupting", "Session",
args_hint="<prompt>"),
CommandDef("goal", "Set a standing goal Hermes works on across turns until achieved", "Session",
args_hint="[text | pause | resume | clear | status | wait <pid> | unwait]"),
args_hint="[text | draft <text> | show | pause | resume | clear | status | wait <pid> | unwait]"),
CommandDef("subgoal", "Add or manage extra criteria on the active goal", "Session",
args_hint="[text | remove N | clear]"),
CommandDef("status", "Show session, model, token, and context info", "Session"),
Expand Down
Loading
Loading