feat(model-divergence): full enforcement — manifest, transition, claim, audit, loop gates, detect script

Completes all 3 model-divergence enforcement subtasks:

- scripts/detect_models.py: probes opencode.json + localhost endpoints,
  builds models.json with --json/--write/--force
- scripts/status.py: CONFLICT_MATRIX, --model flag, --transition --model,
  --claim --model, --audit Category 6, model-divergence brake gate in
  --check-gate, helpers for manifest loading and conflict checking
- scripts/loop-runner.py: _role_model() helper + {model} passed via extras
  dict to _invoke_harness for implement, verify, orchestrate roles
- tests/test_model_divergence.py: 33 tests covering all enforcement layers
- Single-LLM mode: record model advisory, no conflict check
- Multi-LLM mode (2+ models): conflict matrix enforced at transition, claim,
  and loop brake gate
- Project-level models.json preferred over global ~/.automaton/models.json
This commit is contained in:
Lap Tran
2026-06-26 13:23:17 -04:00
parent bc7daf8590
commit 35e449b03e
648 changed files with 1120 additions and 15492 deletions
+54 -15
View File
@@ -375,6 +375,9 @@ def _invoke_harness(
"""Build the harness command from loop.json and invoke it. Returns stdout.
extras: substitution tokens specific to this role ({artifact}, {verdict}, etc).
If extras contains a "model" key, the ``{model}`` token in the harness
command is substituted. The caller is responsible for passing the model
via extras (extracted from loop.json role config or manifest default).
"""
resolved_prompt = prompt_path
if loop_path is not None:
@@ -524,6 +527,24 @@ def _role_prompt(cfg: dict, role: str) ->Optional[str]:
return role_cfg.get("prompt")
def _role_model(cfg: dict, role: str) -> Optional[str]:
"""Get the model configured for a role in loop.json, or the manifest default."""
roles = cfg.get("roles") or {}
role_cfg = roles.get(role) or {}
model = role_cfg.get("model")
if model:
return model
models_file = AUTOMATON_DIR / "models.json"
if models_file.exists():
try:
import json as _mj
manifest = _mj.loads(models_file.read_text())
return manifest.get("default")
except (OSError, _mj.JSONDecodeError):
pass
return None
# ---------------------------------------------------------------------------
# Work sources (task add-goal-mode)
# ---------------------------------------------------------------------------
@@ -809,27 +830,39 @@ def cmd_tick(args, runner_state: Optional[dict] = None) -> dict:
out_dir = _outputs_dir(loop_path)
tick_num = state.get('iteration_count', 0) + 1
impl_output = str(out_dir / f"tick{tick_num}-implement.json")
impl_model = _role_model(cfg, "implement")
implement_extras = {
"output": impl_output,
"current_task": current_task,
"task_brief": task_brief,
"acceptance_criteria": acceptance,
"next_hint": next_hint,
}
if impl_model:
implement_extras["model"] = impl_model
implement_stdout = _invoke_harness(
harness_cfg, "implement", implement_prompt, cwd,
extras={"output": impl_output,
"current_task": current_task,
"task_brief": task_brief,
"acceptance_criteria": acceptance,
"next_hint": next_hint},
extras=implement_extras,
loop_path=loop_path, tick_num=tick_num)
(Path(impl_output)).write_text(implement_stdout)
# Step 6: spawn Verify
verify_prompt = _role_prompt(cfg, "verify") or ""
verify_output = str(out_dir / f"tick{tick_num}-verify.json")
verify_model = _role_model(cfg, "verify")
verify_extras = {
"output": verify_output,
"artifact": impl_output,
"current_task": current_task,
"task_brief": task_brief,
"acceptance_criteria": acceptance,
"next_hint": next_hint,
}
if verify_model:
verify_extras["model"] = verify_model
verify_stdout = _invoke_harness(
harness_cfg, "verify", verify_prompt, cwd,
extras={"output": verify_output,
"artifact": impl_output,
"current_task": current_task,
"task_brief": task_brief,
"acceptance_criteria": acceptance,
"next_hint": next_hint},
extras=verify_extras,
loop_path=loop_path, tick_num=tick_num)
(Path(verify_output)).write_text(verify_stdout)
@@ -854,12 +887,18 @@ def cmd_tick(args, runner_state: Optional[dict] = None) -> dict:
# Step 9: spawn Orchestrate
orch_prompt = _role_prompt(cfg, "orchestrate") or ""
orch_output = str(out_dir / f"tick{tick_num}-orchestrate.json")
orch_model = _role_model(cfg, "orchestrate")
orch_extras = {
"output": orch_output,
"verdict": json.dumps(verdict),
"current_task": current_task,
"current_phase": state.get("current_phase", ""),
}
if orch_model:
orch_extras["model"] = orch_model
orch_stdout = _invoke_harness(
harness_cfg, "orchestrate", orch_prompt, cwd,
extras={"output": orch_output,
"verdict": json.dumps(verdict),
"current_task": current_task,
"current_phase": state.get("current_phase", "")},
extras=orch_extras,
loop_path=loop_path, tick_num=tick_num)
(Path(orch_output)).write_text(orch_stdout)