@@ -360,6 +360,169 @@ def _emit_replay_plan(run: dict[str, Any], replay_class: str,
360360 return plan_id
361361
362362
363+ # ---------------------------------------------------------------------------
364+ # agentplane seal client (Advance 2)
365+ #
366+ # Seal local reasoning receipts via agentplane (the evidence-sealing authority),
367+ # turning local receipts into externally-verifiable sealed evidence. The shared
368+ # seal contract is documented in the task spec; the agentplane side is built by
369+ # a parallel agent to match exactly.
370+ # ---------------------------------------------------------------------------
371+
372+ def agentplane_dir () -> Path :
373+ return Path (env ("AGENTPLANE_DIR" , str (Path .home () / "dev" / "agentplane" )))
374+
375+
376+ def agentplane_seal_tool () -> Path :
377+ return agentplane_dir () / "tools" / "seal_reasoning_receipt.py"
378+
379+
380+ def _resolve_reasoning_run_dir (run_spec : str ) -> Path | None :
381+ """Resolve a run dir under reasoning_evidence_root().
382+
383+ `run_spec` is a run hex, a full run URN, or "latest". For "latest", pick the
384+ most recently modified <hex>/receipt.json. Returns the run dir or None.
385+ """
386+ root = reasoning_evidence_root ()
387+ spec = (run_spec or "latest" ).strip ()
388+ if spec and spec .lower () != "latest" :
389+ run_hex = spec .rsplit (":" , 1 )[- 1 ] if ":" in spec else spec
390+ cand = root / run_hex
391+ if (cand / "receipt.json" ).exists ():
392+ return cand
393+ return None
394+ # latest: newest receipt.json by mtime
395+ best : tuple [float , Path ] | None = None
396+ try :
397+ for d in root .iterdir ():
398+ if not d .is_dir ():
399+ continue
400+ rc = d / "receipt.json"
401+ if rc .exists ():
402+ m = rc .stat ().st_mtime
403+ if best is None or m > best [0 ]:
404+ best = (m , d )
405+ except FileNotFoundError :
406+ return None
407+ return best [1 ] if best else None
408+
409+
410+ def _do_reasoning_seal (request : dict [str , Any ]) -> dict [str , Any ]:
411+ """Seal a local reasoning receipt via the agentplane seal tool.
412+
413+ Exception-safe and graceful: if agentplane is absent, returns
414+ {sealed: false, reason: "agentplane not available", ...} rather than erroring.
415+ """
416+ try :
417+ explicit_receipt = request .get ("receipt" )
418+ if explicit_receipt :
419+ receipt_path = Path (str (explicit_receipt )).expanduser ()
420+ run_dir = receipt_path .parent
421+ else :
422+ run_dir = _resolve_reasoning_run_dir (str (request .get ("run" , "latest" )))
423+ if run_dir is None :
424+ return response ("reasoning_seal" , {
425+ "sealed" : False ,
426+ "reason" : "no reasoning receipt found" ,
427+ "run" : request .get ("run" , "latest" ),
428+ })
429+ receipt_path = run_dir / "receipt.json"
430+
431+ run_json = run_dir / "run.json"
432+ events_path = reasoning_event_stream_path ()
433+ sealed_dir = run_dir / "sealed"
434+
435+ tool = agentplane_seal_tool ()
436+ if not tool .exists ():
437+ return response ("reasoning_seal" , {
438+ "sealed" : False ,
439+ "reason" : "agentplane not available" ,
440+ "checked" : str (tool ),
441+ "run" : request .get ("run" , "latest" ),
442+ })
443+
444+ sealed_dir .mkdir (parents = True , exist_ok = True )
445+ cmd = [
446+ sys .executable , str (tool ),
447+ "--receipt" , str (receipt_path ),
448+ "--run" , str (run_json ),
449+ "--events" , str (events_path ),
450+ "--out-dir" , str (sealed_dir ),
451+ ]
452+ proc = subprocess .run (
453+ cmd , check = False , text = True ,
454+ stdout = subprocess .PIPE , stderr = subprocess .PIPE , timeout = 30 ,
455+ )
456+ out = (proc .stdout or "" ).strip ()
457+ parsed : dict [str , Any ] = {}
458+ for line in reversed (out .splitlines ()):
459+ line = line .strip ()
460+ if line .startswith ("{" ):
461+ try :
462+ parsed = json .loads (line )
463+ break
464+ except json .JSONDecodeError :
465+ continue
466+ if not parsed :
467+ return response ("reasoning_seal" , {
468+ "sealed" : False ,
469+ "reason" : "seal tool returned no parseable result" ,
470+ "stderr" : redact_secrets ((proc .stderr or "" )[:200 ]),
471+ "run" : request .get ("run" , "latest" ),
472+ })
473+ return response ("reasoning_seal" , {
474+ "sealed" : bool (parsed .get ("sealed" )),
475+ "evidence_id" : parsed .get ("evidence_id" ),
476+ "sealed_path" : parsed .get ("sealed_path" ),
477+ "seal_hash" : parsed .get ("seal_hash" ),
478+ "run" : request .get ("run" , "latest" ),
479+ })
480+ except Exception as exc :
481+ return response ("reasoning_seal" , {
482+ "sealed" : False ,
483+ "reason" : f"seal error: { exc } " ,
484+ "run" : request .get ("run" , "latest" ),
485+ })
486+
487+
488+ def _do_reasoning_seal_verify (request : dict [str , Any ]) -> dict [str , Any ]:
489+ """Verify a sealed reasoning record via the agentplane seal tool."""
490+ try :
491+ sealed_path = request .get ("sealed_path" )
492+ if not sealed_path :
493+ return response ("reasoning_seal_verify" , {
494+ "verified" : False , "reason" : "sealed_path required" })
495+ tool = agentplane_seal_tool ()
496+ if not tool .exists ():
497+ return response ("reasoning_seal_verify" , {
498+ "verified" : False ,
499+ "reason" : "agentplane not available" ,
500+ "checked" : str (tool ),
501+ })
502+ proc = subprocess .run (
503+ [sys .executable , str (tool ), "--verify" , str (sealed_path )],
504+ check = False , text = True ,
505+ stdout = subprocess .PIPE , stderr = subprocess .PIPE , timeout = 30 ,
506+ )
507+ out = (proc .stdout or "" ).strip ()
508+ for line in reversed (out .splitlines ()):
509+ line = line .strip ()
510+ if line .startswith ("{" ):
511+ try :
512+ parsed = json .loads (line )
513+ return response ("reasoning_seal_verify" , parsed )
514+ except json .JSONDecodeError :
515+ continue
516+ return response ("reasoning_seal_verify" , {
517+ "verified" : False ,
518+ "reason" : "verify tool returned no parseable result" ,
519+ "stderr" : redact_secrets ((proc .stderr or "" )[:200 ]),
520+ })
521+ except Exception as exc :
522+ return response ("reasoning_seal_verify" , {
523+ "verified" : False , "reason" : f"verify error: { exc } " })
524+
525+
363526def git_info (cwd : str | None = None ) -> dict [str , str | None ]:
364527 """Return {repo_root, git_branch} for cwd, or nulls if not a git repo."""
365528 kwargs : dict [str , Any ] = {
@@ -1554,11 +1717,153 @@ def _adaptive_plan_step(goal: str, step_idx: int, step: dict[str, Any], step_out
15541717 return step
15551718
15561719
1720+ # ---------------------------------------------------------------------------
1721+ # Forge / chain action governance (Advance 1)
1722+ #
1723+ # Every attestable forge/chain action is governed at the dispatch chokepoint:
1724+ # we open a ReasoningRun, run the real handler, emit a single safe ReasoningEvent
1725+ # summarizing the outcome, close the run, and inject the run + receipt URNs into
1726+ # the returned payload. This means 30+ action branches are attested without
1727+ # touching any of them. ALL governing is exception-safe: any failure falls back
1728+ # to the un-governed inner result so evidence emission can never break an action.
1729+ # ---------------------------------------------------------------------------
1730+
1731+ # Substrings that mark a forge/chain action as mutating (has side effects).
1732+ _MUTATING_FORGE_TOKENS = (
1733+ "create" , "fork" , "merge" , "close" , "comment" , "delete" , "release" ,
1734+ "secret" , "label_create" , "edit" , "update" ,
1735+ )
1736+ # Actions that are always mutating regardless of token analysis.
1737+ _ALWAYS_MUTATING_ACTIONS = ("chain_run" , "session_to_pr" )
1738+
1739+
1740+ def _normalize_action (action : Any ) -> str :
1741+ """Lowercase + dashes->underscores for policy classification."""
1742+ if not isinstance (action , str ):
1743+ return ""
1744+ return action .strip ().lower ().replace ("-" , "_" )
1745+
1746+
1747+ def is_attestable_action (action : Any ) -> bool :
1748+ """True if `action` is a forge/chain control op that must be governed."""
1749+ norm = _normalize_action (action )
1750+ if not norm :
1751+ return False
1752+ if norm in ("chain_run" , "session_to_pr" ):
1753+ return True
1754+ return norm .startswith ("gh_" ) or norm .startswith ("gitea_" )
1755+
1756+
1757+ def is_mutating_action (action : Any ) -> bool :
1758+ """Classify an attestable action as mutating (side-effect) vs read-only."""
1759+ norm = _normalize_action (action )
1760+ if norm in _ALWAYS_MUTATING_ACTIONS :
1761+ return True
1762+ return any (tok in norm for tok in _MUTATING_FORGE_TOKENS )
1763+
1764+
1765+ def _forge_event_summary (action : str , resp : dict [str , Any ]) -> str :
1766+ """Derive a short, safe one-liner from the response's top-level fields.
1767+
1768+ NEVER dumps the full resp. Looks only at coarse status fields.
1769+ """
1770+ data = resp .get ("data" ) if isinstance (resp , dict ) else None
1771+ src = data if isinstance (data , dict ) else (resp if isinstance (resp , dict ) else {})
1772+ bits : list [str ] = []
1773+ for key in ("created" , "merged" , "closed" , "forge" , "repo" , "name" ,
1774+ "number" , "url" , "online" , "ok" , "run" ):
1775+ if key in src and not isinstance (src [key ], (dict , list )):
1776+ bits .append (f"{ key } ={ src [key ]} " )
1777+ tail = ", " .join (bits [:4 ]) if bits else "done"
1778+ return redact_secrets (f"{ action } -> { tail } " )[:200 ]
1779+
1780+
1781+ def _resp_indicates_failure (resp : dict [str , Any ]) -> bool :
1782+ """Heuristic: did the wrapped action fail?
1783+
1784+ Conservative: only the top-level error status, or an explicit mutating-action
1785+ failure flag (ok/created/merged == False), counts as a failure. A benign
1786+ field like `online: false` or `error` on a read-only status response is NOT a
1787+ failure — being offline is a valid, successful read-only observation.
1788+ """
1789+ if not isinstance (resp , dict ):
1790+ return False
1791+ if resp .get ("status" ) == "error" :
1792+ return True
1793+ data = resp .get ("data" )
1794+ if isinstance (data , dict ):
1795+ for key in ("created" , "merged" , "closed" , "deleted" , "ok" ):
1796+ if data .get (key ) is False :
1797+ return True
1798+ return False
1799+
1800+
1801+ def handle_request (request : dict [str , Any ]) -> dict [str , Any ]:
1802+ """Governing wrapper around dispatch.
1803+
1804+ For attestable forge/chain actions, open a ReasoningRun, run the real
1805+ handler, emit a safe summary event, close the run, and inject the run +
1806+ receipt URNs into the returned payload. Otherwise dispatch unchanged.
1807+ """
1808+ action = request .get ("action" )
1809+ if not is_attestable_action (action ):
1810+ return _handle_request_inner (request )
1811+
1812+ # Open the run BEFORE dispatch, but keep the run open in a way that a
1813+ # failure during governing never re-runs the (possibly mutating) action.
1814+ run : dict [str , Any ] | None = None
1815+ try :
1816+ run = _open_reasoning_run (
1817+ task_summary = f"forge action: { action } " , agent = "turtle-forge" )
1818+ except Exception :
1819+ run = None
1820+
1821+ # Dispatch the real action exactly once, OUTSIDE the governing try/except.
1822+ resp = _handle_request_inner (request )
1823+
1824+ if run is None :
1825+ return resp
1826+
1827+ try :
1828+ action_norm = _normalize_action (action )
1829+ action_dashed = action_norm .replace ("_" , "-" )
1830+ mutating = is_mutating_action (action )
1831+ replay_class = "non-replayable-side-effect" if mutating else "evidence-only"
1832+
1833+ failed = _resp_indicates_failure (resp )
1834+ run_status = "failed" if failed else "completed"
1835+ data = resp .get ("data" ) if isinstance (resp , dict ) else None
1836+ forge_val = data .get ("forge" ) if isinstance (data , dict ) else None
1837+ _emit_reasoning_event (
1838+ run ,
1839+ event_type = f"forge.{ action_dashed } " ,
1840+ summary = _forge_event_summary (action_norm , resp ),
1841+ trust_level = "trusted-control-input" ,
1842+ trace_level = "workspace-safe" ,
1843+ extra = {
1844+ "action" : action_norm ,
1845+ "forge" : forge_val ,
1846+ "ok" : not failed ,
1847+ },
1848+ )
1849+ receipt = _close_reasoning_run (run , status = run_status , replay_class = replay_class )
1850+
1851+ # Inject run + receipt URNs into the returned payload's data dict.
1852+ if isinstance (resp , dict ) and isinstance (resp .get ("data" ), dict ):
1853+ resp ["data" ]["reasoning_run" ] = run .get ("id" , "" )
1854+ resp ["data" ]["reasoning_receipt" ] = receipt .get ("id" , "" )
1855+ return resp
1856+ except Exception :
1857+ # Evidence/governing failures must never break the action: the action
1858+ # already ran exactly once above, so just return its result unwrapped.
1859+ return resp
1860+
1861+
15571862# ---------------------------------------------------------------------------
15581863# Request handler
15591864# ---------------------------------------------------------------------------
15601865
1561- def handle_request (request : dict [str , Any ]) -> dict [str , Any ]: # noqa: C901
1866+ def _handle_request_inner (request : dict [str , Any ]) -> dict [str , Any ]: # noqa: C901
15621867 action = request .get ("action" )
15631868
15641869 # ------------------------------------------------------------------
@@ -3204,7 +3509,7 @@ def handle_request(request: dict[str, Any]) -> dict[str, Any]: # noqa: C901
32043509 "steps" : [{"command" : s .get ("cmd" , s ) if isinstance (s , dict ) else str (s ),
32053510 "description" : s .get ("desc" , "" ) if isinstance (s , dict ) else "" } for s in steps ],
32063511 }
3207- return handle_request (plan_request )
3512+ return _handle_request_inner (plan_request )
32083513
32093514 if action in ("coach_analyze" , "coach-analyze" ):
32103515 """Detect inefficient command patterns and teach better alternatives."""
@@ -5270,6 +5575,15 @@ done
52705575 "runs" : run_count ,
52715576 })
52725577
5578+ # ------------------------------------------------------------------
5579+ # agentplane seal client (Advance 2)
5580+ # ------------------------------------------------------------------
5581+ if action in ("reasoning_seal" , "reasoning-seal" ):
5582+ return _do_reasoning_seal (request )
5583+
5584+ if action in ("reasoning_seal_verify" , "reasoning-seal-verify" ):
5585+ return _do_reasoning_seal_verify (request )
5586+
52735587 # ------------------------------------------------------------------
52745588 # gh-parity forge actions
52755589 # ------------------------------------------------------------------
0 commit comments