@event4u/agent-config 2.9.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent-src/commands/agents.md +1 -0
- package/.agent-src/commands/challenge-me.md +1 -0
- package/.agent-src/commands/chat-history.md +1 -0
- package/.agent-src/commands/context.md +1 -0
- package/.agent-src/commands/council.md +1 -0
- package/.agent-src/commands/feature.md +1 -0
- package/.agent-src/commands/fix.md +1 -0
- package/.agent-src/commands/grill-me.md +1 -0
- package/.agent-src/commands/judge.md +1 -0
- package/.agent-src/commands/memory.md +1 -0
- package/.agent-src/commands/module.md +1 -0
- package/.agent-src/commands/onboard.md +32 -4
- package/.agent-src/commands/optimize.md +1 -0
- package/.agent-src/commands/override.md +1 -0
- package/.agent-src/commands/roadmap.md +1 -0
- package/.agent-src/commands/tests.md +1 -0
- package/.agent-src/rules/no-roadmap-references.md +19 -0
- package/.agent-src/skills/nextjs-patterns/SKILL.md +203 -0
- package/.agent-src/skills/symfony-workflow/SKILL.md +173 -0
- package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +4 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +3 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.py +162 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +32 -3
- package/.agent-src/templates/scripts/work_engine/hooks/settings.py +24 -6
- package/.agent-src/templates/scripts/work_engine/scoring/decision_engine.py +351 -0
- package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +147 -1
- package/.claude-plugin/marketplace.json +3 -1
- package/CHANGELOG.md +65 -0
- package/README.md +66 -17
- package/config/agent-settings.template.yml +85 -0
- package/docs/architecture.md +1 -1
- package/docs/contracts/STABILITY.md +16 -0
- package/docs/contracts/adr-chat-history-split.md +1 -0
- package/docs/contracts/adr-forecast-construction-shape.md +1 -0
- package/docs/contracts/adr-gtm-context-spine.md +1 -0
- package/docs/contracts/adr-level-6-productization.md +147 -0
- package/docs/contracts/adr-settings-sync-engine.md +1 -0
- package/docs/contracts/adr-wing4-context-spine.md +1 -0
- package/docs/contracts/agent-memory-contract.md +1 -0
- package/docs/contracts/agents-md-tech-stack.md +1 -0
- package/docs/contracts/audit-log-v1.md +1 -0
- package/docs/contracts/command-clusters.md +1 -0
- package/docs/contracts/command-surface-tiers.md +1 -0
- package/docs/contracts/context-paths.md +1 -0
- package/docs/contracts/cost-profile-defaults.md +105 -0
- package/docs/contracts/cross-wing-handoff.md +1 -0
- package/docs/contracts/decision-engine-gates.md +115 -0
- package/docs/contracts/decision-trace-v1.md +31 -0
- package/docs/contracts/file-ownership-matrix.md +1 -0
- package/docs/contracts/hook-architecture-v1.md +47 -0
- package/docs/contracts/implement-ticket-flow.md +1 -0
- package/docs/contracts/installed-tools-lockfile.md +1 -0
- package/docs/contracts/kernel-membership.md +1 -0
- package/docs/contracts/linear-ai-rules-inclusion.md +1 -0
- package/docs/contracts/linear-ai-three-layers.md +1 -0
- package/docs/contracts/linter-structural-model.md +1 -0
- package/docs/contracts/load-context-budget-model.md +1 -0
- package/docs/contracts/load-context-schema.md +1 -0
- package/docs/contracts/memory-visibility-v1.md +34 -0
- package/docs/contracts/one-off-script-lifecycle.md +1 -0
- package/docs/contracts/orchestration-dsl-v1.md +1 -0
- package/docs/contracts/package-self-orientation.md +1 -0
- package/docs/contracts/persona-schema.md +1 -0
- package/docs/contracts/release-trunk-sync.md +104 -0
- package/docs/contracts/roadmap-complexity-standard.md +1 -0
- package/docs/contracts/rule-classification.md +1 -0
- package/docs/contracts/rule-interactions.md +26 -0
- package/docs/contracts/rule-priority-hierarchy.md +1 -0
- package/docs/contracts/rule-router.md +1 -0
- package/docs/contracts/settings-sync-yaml-subset.md +139 -0
- package/docs/contracts/skill-domains.md +1 -0
- package/docs/contracts/tier-3-contrib-plugin.md +1 -0
- package/docs/contracts/ui-stack-extension.md +1 -0
- package/docs/contracts/ui-track-flow.md +1 -0
- package/docs/customization.md +1 -1
- package/docs/getting-started.md +3 -1
- package/docs/installation.md +8 -6
- package/docs/readme-split-plan.md +102 -0
- package/package.json +1 -1
- package/scripts/_cli/cmd_settings_check.py +171 -0
- package/scripts/agent-config +40 -0
- package/scripts/chat_history.py +19 -0
- package/scripts/check_beta_review_markers.py +127 -0
- package/scripts/check_council_references.py +46 -5
- package/scripts/check_release_trunk_sync.py +152 -0
- package/scripts/hooks/dispatch_hook.py +5 -1
- package/scripts/hooks/replay_hook.py +144 -0
- package/scripts/hooks/state_io.py +24 -1
- package/scripts/hooks_doctor.py +184 -0
- package/scripts/install.py +3 -3
- package/scripts/lint_hook_concern_budget.py +203 -0
- package/scripts/roadmap_progress_hook.py +11 -0
- package/scripts/schemas/command.schema.json +5 -0
- package/scripts/skill_linter.py +11 -2
- package/scripts/smoke_quickstart.py +134 -0
- package/scripts/validate_decision_engine.py +124 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""``DecisionGateHook`` — refuse to advance when an opt-in gate fires.
|
|
2
|
+
|
|
3
|
+
Bridges :mod:`work_engine.scoring.decision_engine` into the dispatcher
|
|
4
|
+
hook bus. Reads the gate config from
|
|
5
|
+
:class:`work_engine.hooks.settings.HookSettings.decision_engine` and
|
|
6
|
+
fires on ``AFTER_STEP`` only for the phase each gate owns.
|
|
7
|
+
|
|
8
|
+
Gate-conflict resolution and the non-TTY timeout protocol live in
|
|
9
|
+
``docs/contracts/decision-engine-gates.md``. The hook only consumes
|
|
10
|
+
them; it never re-implements gate logic.
|
|
11
|
+
|
|
12
|
+
Three actions, mapped 1:1 from :func:`evaluate_gates`:
|
|
13
|
+
|
|
14
|
+
- ``stop`` → raise :class:`HookHalt` with a numbered-option
|
|
15
|
+
surface. Dispatcher returns ``BLOCKED``.
|
|
16
|
+
- ``warn`` → raise :class:`HookError` so the runner logs the
|
|
17
|
+
reason and the step proceeds.
|
|
18
|
+
- ``ask_timeout`` → non-interactive context; apply
|
|
19
|
+
``on_block_fallback`` and re-resolve to ``stop``
|
|
20
|
+
or ``warn``. ``block_reason=ask_timeout`` is
|
|
21
|
+
surfaced verbatim so the trace records it.
|
|
22
|
+
- ``ask`` → interactive context; for the CLI integration this
|
|
23
|
+
collapses to ``stop`` with the prompt surface,
|
|
24
|
+
matching how every other ``HookHalt`` is rendered.
|
|
25
|
+
The interactive resumption path is owned by the
|
|
26
|
+
CLI, not by the hook.
|
|
27
|
+
|
|
28
|
+
Default-off: when ``settings.decision_engine`` is ``None`` or every
|
|
29
|
+
gate is ``off`` the hook short-circuits without examining state.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from ...scoring.decision_engine import (
|
|
36
|
+
DecisionEngineSettings,
|
|
37
|
+
GateDecision,
|
|
38
|
+
evaluate_gates,
|
|
39
|
+
)
|
|
40
|
+
from ...scoring.decision_trace import (
|
|
41
|
+
derive_confidence_band,
|
|
42
|
+
derive_risk_class,
|
|
43
|
+
summarise_memory,
|
|
44
|
+
summarise_verify,
|
|
45
|
+
)
|
|
46
|
+
from ..context import HookContext
|
|
47
|
+
from ..events import HookEvent
|
|
48
|
+
from ..exceptions import HookError, HookHalt
|
|
49
|
+
from ..registry import HookRegistry
|
|
50
|
+
|
|
51
|
+
_BLOCK_REASON_PREFIX = "decision_gate"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class DecisionGateHook:
|
|
55
|
+
"""Evaluate decision-engine gates on every ``AFTER_STEP``.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
settings:
|
|
60
|
+
Resolved :class:`DecisionEngineSettings`. The hook stores it as
|
|
61
|
+
a frozen reference; tests pass a fresh instance per scenario.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, settings: DecisionEngineSettings) -> None:
|
|
65
|
+
self._settings = settings
|
|
66
|
+
|
|
67
|
+
def register(self, registry: HookRegistry) -> None:
|
|
68
|
+
"""Register the gate callback on ``AFTER_STEP``."""
|
|
69
|
+
registry.register(HookEvent.AFTER_STEP, self._evaluate)
|
|
70
|
+
|
|
71
|
+
# -- lifecycle callback ------------------------------------------
|
|
72
|
+
|
|
73
|
+
def _evaluate(self, ctx: HookContext) -> None:
|
|
74
|
+
if not self._settings.any_gate_active:
|
|
75
|
+
return
|
|
76
|
+
phase = ctx.step_name
|
|
77
|
+
if not phase:
|
|
78
|
+
return
|
|
79
|
+
delivery = ctx.delivery
|
|
80
|
+
memory = summarise_memory(getattr(delivery, "memory", None))
|
|
81
|
+
verify = summarise_verify(getattr(delivery, "verify", None))
|
|
82
|
+
ambiguity = bool(getattr(delivery, "questions", None))
|
|
83
|
+
decision = evaluate_gates(
|
|
84
|
+
self._settings,
|
|
85
|
+
phase=phase,
|
|
86
|
+
confidence_band=derive_confidence_band(
|
|
87
|
+
memory_hits=memory["hits"],
|
|
88
|
+
verify_claims=verify["claims"],
|
|
89
|
+
verify_first_try_passes=verify["first_try_passes"],
|
|
90
|
+
ambiguity_flag=ambiguity,
|
|
91
|
+
),
|
|
92
|
+
risk_class=derive_risk_class(
|
|
93
|
+
getattr(delivery, "changes", None),
|
|
94
|
+
),
|
|
95
|
+
memory_hits=memory["hits"],
|
|
96
|
+
)
|
|
97
|
+
if decision is None:
|
|
98
|
+
return
|
|
99
|
+
self._apply(decision)
|
|
100
|
+
|
|
101
|
+
# -- action dispatch ----------------------------------------------
|
|
102
|
+
|
|
103
|
+
def _apply(self, decision: GateDecision) -> None:
|
|
104
|
+
action = decision.action
|
|
105
|
+
if action == "warn":
|
|
106
|
+
raise HookError(self._format_reason(decision))
|
|
107
|
+
if action == "ask_timeout":
|
|
108
|
+
fallback = self._settings.on_block_fallback
|
|
109
|
+
if fallback == "warn":
|
|
110
|
+
raise HookError(self._format_reason(decision, suffix="ask_timeout"))
|
|
111
|
+
raise HookHalt(
|
|
112
|
+
f"{_BLOCK_REASON_PREFIX}:{decision.gate_id}:ask_timeout",
|
|
113
|
+
surface=self._surface(decision, suffix="ask_timeout"),
|
|
114
|
+
)
|
|
115
|
+
raise HookHalt(
|
|
116
|
+
f"{_BLOCK_REASON_PREFIX}:{decision.gate_id}",
|
|
117
|
+
surface=self._surface(decision),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# -- formatting helpers -------------------------------------------
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def _format_reason(decision: GateDecision, *, suffix: str = "") -> str:
|
|
124
|
+
tag = f"{_BLOCK_REASON_PREFIX}:{decision.gate_id}"
|
|
125
|
+
if suffix:
|
|
126
|
+
tag = f"{tag}:{suffix}"
|
|
127
|
+
return f"{tag} — {decision.reason}"
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _surface(
|
|
131
|
+
decision: GateDecision, *, suffix: str = "",
|
|
132
|
+
) -> list[str]:
|
|
133
|
+
header = f"Decision-engine gate fired: {decision.gate_id} (phase={decision.phase})"
|
|
134
|
+
if suffix:
|
|
135
|
+
header = f"{header} [{suffix}]"
|
|
136
|
+
return [
|
|
137
|
+
header,
|
|
138
|
+
f"Reason: {decision.reason}",
|
|
139
|
+
"1) Address the gate condition and resume.",
|
|
140
|
+
"2) Lower the gate in `.agent-settings.yml` "
|
|
141
|
+
"(`decision_engine` block) and resume.",
|
|
142
|
+
"3) Abort the run.",
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def build_decision_gate_hook(
|
|
147
|
+
settings: Any,
|
|
148
|
+
) -> DecisionGateHook | None:
|
|
149
|
+
"""Construct the hook from a :class:`DecisionEngineSettings`-like
|
|
150
|
+
object. Returns ``None`` when the config is absent or every gate is
|
|
151
|
+
``off``; the bootstrap layer then skips registration entirely.
|
|
152
|
+
"""
|
|
153
|
+
if settings is None:
|
|
154
|
+
return None
|
|
155
|
+
if not isinstance(settings, DecisionEngineSettings):
|
|
156
|
+
return None
|
|
157
|
+
if not settings.any_gate_active:
|
|
158
|
+
return None
|
|
159
|
+
return DecisionGateHook(settings)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
__all__ = ["DecisionGateHook", "build_decision_gate_hook"]
|
|
@@ -23,8 +23,11 @@ from __future__ import annotations
|
|
|
23
23
|
|
|
24
24
|
from typing import Any, Iterable
|
|
25
25
|
|
|
26
|
+
from ...scoring.decision_trace import summarise_memory, summarise_verify
|
|
26
27
|
from ...scoring.memory_visibility import (
|
|
27
28
|
DEFAULT_ASKED_TYPES,
|
|
29
|
+
compute_affected,
|
|
30
|
+
format_changed_decisions_block,
|
|
28
31
|
format_line,
|
|
29
32
|
should_emit,
|
|
30
33
|
summarise_visibility,
|
|
@@ -82,20 +85,46 @@ class MemoryVisibilityHook:
|
|
|
82
85
|
visibility_off=self._visibility_off,
|
|
83
86
|
):
|
|
84
87
|
return
|
|
85
|
-
|
|
88
|
+
affected = self._derive_affected(work, memory)
|
|
89
|
+
line = format_line(summary, affected=affected)
|
|
86
90
|
if not line:
|
|
87
91
|
return
|
|
92
|
+
block = format_changed_decisions_block(
|
|
93
|
+
summary.get("ids") or [], affected,
|
|
94
|
+
)
|
|
88
95
|
existing = getattr(work, "report", "") or ""
|
|
89
|
-
|
|
96
|
+
rendered = line if block is None else f"{line}\n\n{block}"
|
|
97
|
+
if line in existing and (block is None or block in existing):
|
|
90
98
|
return
|
|
91
99
|
sep = "\n\n" if existing else ""
|
|
92
100
|
try:
|
|
93
|
-
work.report = f"{existing}{sep}{
|
|
101
|
+
work.report = f"{existing}{sep}{rendered}"
|
|
94
102
|
except AttributeError as exc:
|
|
95
103
|
raise HookError(
|
|
96
104
|
"memory-visibility: state.report not writable",
|
|
97
105
|
) from exc
|
|
98
106
|
|
|
107
|
+
def _derive_affected(self, work: Any, memory: Any) -> list[str] | None:
|
|
108
|
+
"""Compute the closed-list ``affected`` keys for this work step.
|
|
109
|
+
|
|
110
|
+
Reuses the decision-trace summarisers so the counterfactual
|
|
111
|
+
matches the trace hook's view of the same WorkState. Returns
|
|
112
|
+
``None`` when memory was not consulted (hits == 0); callers
|
|
113
|
+
then omit the ``· affected: …`` segment per the contract.
|
|
114
|
+
"""
|
|
115
|
+
memory_summary = summarise_memory(memory)
|
|
116
|
+
verify_summary = summarise_verify(getattr(work, "verify", None))
|
|
117
|
+
ambiguity = bool(getattr(work, "questions", None))
|
|
118
|
+
return compute_affected(
|
|
119
|
+
memory_hits=memory_summary["hits"],
|
|
120
|
+
verify_claims=verify_summary["claims"],
|
|
121
|
+
verify_first_try_passes=verify_summary["first_try_passes"],
|
|
122
|
+
ambiguity_flag=ambiguity,
|
|
123
|
+
changes=getattr(work, "changes", None),
|
|
124
|
+
applied_rules=getattr(work, "applied_rules", None),
|
|
125
|
+
test_plan=getattr(work, "test_plan", None),
|
|
126
|
+
)
|
|
127
|
+
|
|
99
128
|
|
|
100
129
|
def derive_visibility(memory: Any) -> str | None:
|
|
101
130
|
"""Convenience helper: render the line directly from a memory list.
|
|
@@ -28,6 +28,11 @@ from pathlib import Path
|
|
|
28
28
|
from typing import Any
|
|
29
29
|
|
|
30
30
|
from work_engine._lib.agent_settings import load_agent_settings
|
|
31
|
+
from work_engine.scoring.decision_engine import (
|
|
32
|
+
DecisionEngineConfigError,
|
|
33
|
+
DecisionEngineSettings,
|
|
34
|
+
parse as _parse_decision_engine,
|
|
35
|
+
)
|
|
31
36
|
|
|
32
37
|
DEFAULT_SETTINGS_FILE = ".agent-settings.yml"
|
|
33
38
|
DEFAULT_CHAT_HISTORY_SCRIPT = "scripts/chat_history.py"
|
|
@@ -41,6 +46,11 @@ class HookSettings:
|
|
|
41
46
|
empty regardless of the per-hook fields; this is the default when no
|
|
42
47
|
settings file exists or no ``hooks`` block is declared, and it is
|
|
43
48
|
what keeps golden-replay tests byte-stable.
|
|
49
|
+
|
|
50
|
+
``decision_engine`` carries the parsed gate config so
|
|
51
|
+
:class:`DecisionGateHook` can read it without re-parsing
|
|
52
|
+
``.agent-settings.yml``. When the block is absent or malformed the
|
|
53
|
+
field stays at the default (every gate ``off``).
|
|
44
54
|
"""
|
|
45
55
|
|
|
46
56
|
enabled: bool = False
|
|
@@ -54,6 +64,7 @@ class HookSettings:
|
|
|
54
64
|
cost_profile: str = "standard"
|
|
55
65
|
chat_history_enabled: bool = False
|
|
56
66
|
chat_history_script: str = DEFAULT_CHAT_HISTORY_SCRIPT
|
|
67
|
+
decision_engine: DecisionEngineSettings = DecisionEngineSettings()
|
|
57
68
|
|
|
58
69
|
|
|
59
70
|
_DEFAULT = HookSettings()
|
|
@@ -89,8 +100,18 @@ def _settings_from_raw(data: dict[str, Any]) -> HookSettings:
|
|
|
89
100
|
if not isinstance(hooks, dict):
|
|
90
101
|
return _DEFAULT
|
|
91
102
|
enabled = _coerce_bool(hooks.get("enabled"), False)
|
|
103
|
+
|
|
104
|
+
decision_engine_raw = data.get("decision_engine")
|
|
105
|
+
try:
|
|
106
|
+
decision_engine_settings = _parse_decision_engine(decision_engine_raw)
|
|
107
|
+
except DecisionEngineConfigError:
|
|
108
|
+
decision_engine_settings = DecisionEngineSettings()
|
|
109
|
+
|
|
92
110
|
if not enabled:
|
|
93
|
-
return HookSettings(
|
|
111
|
+
return HookSettings(
|
|
112
|
+
enabled=False,
|
|
113
|
+
decision_engine=decision_engine_settings,
|
|
114
|
+
)
|
|
94
115
|
|
|
95
116
|
chat_section = hooks.get("chat_history")
|
|
96
117
|
if isinstance(chat_section, dict):
|
|
@@ -108,11 +129,7 @@ def _settings_from_raw(data: dict[str, Any]) -> HookSettings:
|
|
|
108
129
|
and _coerce_bool(global_chat.get("enabled"), False)
|
|
109
130
|
)
|
|
110
131
|
|
|
111
|
-
|
|
112
|
-
decision_trace_on = (
|
|
113
|
-
isinstance(decision_engine, dict)
|
|
114
|
-
and _coerce_bool(decision_engine.get("surface_traces"), False)
|
|
115
|
-
)
|
|
132
|
+
decision_trace_on = decision_engine_settings.surface_traces
|
|
116
133
|
|
|
117
134
|
memory_section = data.get("memory")
|
|
118
135
|
visibility_off = False
|
|
@@ -154,6 +171,7 @@ def _settings_from_raw(data: dict[str, Any]) -> HookSettings:
|
|
|
154
171
|
cost_profile=cost_profile,
|
|
155
172
|
chat_history_enabled=chat_block_enabled and global_chat_on,
|
|
156
173
|
chat_history_script=chat_script,
|
|
174
|
+
decision_engine=decision_engine_settings,
|
|
157
175
|
)
|
|
158
176
|
|
|
159
177
|
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Decision-engine gates — schema, validation, and per-phase evaluation.
|
|
2
|
+
|
|
3
|
+
Reads the optional ``decision_engine:`` block from ``.agent-settings.yml``.
|
|
4
|
+
Absent block = current behaviour (observe-only, no gates fire).
|
|
5
|
+
|
|
6
|
+
Schema (all keys optional; the parser rejects unknown keys hard):
|
|
7
|
+
|
|
8
|
+
- ``surface_traces`` (bool, default ``false``) — opt-in for
|
|
9
|
+
``DecisionTraceHook``. Predates the gates; lives here so the
|
|
10
|
+
``decision_engine:`` block has one source-of-truth schema.
|
|
11
|
+
- ``min_confidence`` (``low``/``medium``/``high``/``off``, default
|
|
12
|
+
``off``) — confidence-band floor; Phase=Plan refuses to advance
|
|
13
|
+
when the band is below.
|
|
14
|
+
- ``block_on_risk`` (``low``/``medium``/``high``/``off``, default
|
|
15
|
+
``off``) — risk-class ceiling; Phase=Implement refuses to advance
|
|
16
|
+
when risk exceeds.
|
|
17
|
+
- ``require_memory_hits`` (bool, default ``false``) — Phase=Refine
|
|
18
|
+
demands ``memory_hits >= 1``.
|
|
19
|
+
- ``on_block`` (``stop``/``ask``/``warn``, default ``stop``) —
|
|
20
|
+
what happens when a gate fires.
|
|
21
|
+
- ``ask_timeout_seconds`` (int, default ``30``) — timeout when
|
|
22
|
+
``on_block=ask`` runs in a non-interactive context (no TTY, or
|
|
23
|
+
``CI=true``).
|
|
24
|
+
- ``on_block_fallback`` (``stop``/``warn``, default ``stop``) —
|
|
25
|
+
resolution after ``ask_timeout`` elapses.
|
|
26
|
+
|
|
27
|
+
Gate-conflict resolution (first match wins, only one gate fires per
|
|
28
|
+
phase):
|
|
29
|
+
|
|
30
|
+
1. ``block_on_risk`` (highest impact)
|
|
31
|
+
2. ``require_memory_hits``
|
|
32
|
+
3. ``min_confidence`` (lowest impact)
|
|
33
|
+
|
|
34
|
+
See ``docs/contracts/decision-engine-gates.md`` for the full
|
|
35
|
+
priority matrix.
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import os
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from typing import Any, Callable
|
|
42
|
+
|
|
43
|
+
ALLOWED_KEYS: frozenset[str] = frozenset({
|
|
44
|
+
"surface_traces",
|
|
45
|
+
"min_confidence",
|
|
46
|
+
"block_on_risk",
|
|
47
|
+
"require_memory_hits",
|
|
48
|
+
"on_block",
|
|
49
|
+
"ask_timeout_seconds",
|
|
50
|
+
"on_block_fallback",
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
_LEVEL_VALUES: frozenset[str] = frozenset({"low", "medium", "high", "off"})
|
|
54
|
+
_LEVEL_RANK: dict[str, int] = {"low": 1, "medium": 2, "high": 3}
|
|
55
|
+
_ON_BLOCK_VALUES: frozenset[str] = frozenset({"stop", "ask", "warn"})
|
|
56
|
+
_FALLBACK_VALUES: frozenset[str] = frozenset({"stop", "warn"})
|
|
57
|
+
|
|
58
|
+
GATE_PRIORITY: tuple[str, ...] = (
|
|
59
|
+
"block_on_risk",
|
|
60
|
+
"require_memory_hits",
|
|
61
|
+
"min_confidence",
|
|
62
|
+
)
|
|
63
|
+
"""Conflict-resolution order. Highest-impact gate first; the first
|
|
64
|
+
firing gate emits its reason and downstream gates are skipped."""
|
|
65
|
+
|
|
66
|
+
_PHASE_FOR_GATE: dict[str, str] = {
|
|
67
|
+
"block_on_risk": "implement",
|
|
68
|
+
"require_memory_hits": "refine",
|
|
69
|
+
"min_confidence": "plan",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class DecisionEngineConfigError(ValueError):
|
|
74
|
+
"""Raised when the ``decision_engine:`` block is malformed."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class DecisionEngineSettings:
|
|
79
|
+
"""Resolved ``decision_engine:`` block. Frozen to keep gate
|
|
80
|
+
evaluations replay-stable."""
|
|
81
|
+
|
|
82
|
+
surface_traces: bool = False
|
|
83
|
+
min_confidence: str = "off"
|
|
84
|
+
block_on_risk: str = "off"
|
|
85
|
+
require_memory_hits: bool = False
|
|
86
|
+
on_block: str = "stop"
|
|
87
|
+
ask_timeout_seconds: int = 30
|
|
88
|
+
on_block_fallback: str = "stop"
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def any_gate_active(self) -> bool:
|
|
92
|
+
"""True when at least one gate is enabled."""
|
|
93
|
+
return (
|
|
94
|
+
self.min_confidence != "off"
|
|
95
|
+
or self.block_on_risk != "off"
|
|
96
|
+
or self.require_memory_hits
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class GateDecision:
|
|
102
|
+
"""Outcome of one gate evaluation. ``action`` is the resolved
|
|
103
|
+
response after applying ``on_block`` plus the non-TTY fallback."""
|
|
104
|
+
|
|
105
|
+
gate_id: str
|
|
106
|
+
phase: str
|
|
107
|
+
reason: str
|
|
108
|
+
action: str # "stop" | "warn" | "ask" | "ask_timeout"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse(data: Any) -> DecisionEngineSettings:
|
|
112
|
+
"""Parse a ``decision_engine`` block into validated settings.
|
|
113
|
+
|
|
114
|
+
Returns defaults when ``data`` is ``None`` (block absent) or an
|
|
115
|
+
empty mapping. Raises :class:`DecisionEngineConfigError` on
|
|
116
|
+
unknown keys or invalid values.
|
|
117
|
+
"""
|
|
118
|
+
if data is None:
|
|
119
|
+
return DecisionEngineSettings()
|
|
120
|
+
if not isinstance(data, dict):
|
|
121
|
+
raise DecisionEngineConfigError(
|
|
122
|
+
"decision_engine: must be a mapping, got "
|
|
123
|
+
f"{type(data).__name__}"
|
|
124
|
+
)
|
|
125
|
+
unknown = set(data.keys()) - ALLOWED_KEYS
|
|
126
|
+
if unknown:
|
|
127
|
+
raise DecisionEngineConfigError(
|
|
128
|
+
"decision_engine: unknown key(s): "
|
|
129
|
+
+ ", ".join(sorted(unknown))
|
|
130
|
+
+ ". Allowed: " + ", ".join(sorted(ALLOWED_KEYS))
|
|
131
|
+
)
|
|
132
|
+
return DecisionEngineSettings(
|
|
133
|
+
surface_traces=_coerce_bool(data.get("surface_traces"), False),
|
|
134
|
+
min_confidence=_coerce_level(
|
|
135
|
+
data.get("min_confidence", "off"), "min_confidence",
|
|
136
|
+
),
|
|
137
|
+
block_on_risk=_coerce_level(
|
|
138
|
+
data.get("block_on_risk", "off"), "block_on_risk",
|
|
139
|
+
),
|
|
140
|
+
require_memory_hits=_coerce_bool(
|
|
141
|
+
data.get("require_memory_hits"), False,
|
|
142
|
+
),
|
|
143
|
+
on_block=_coerce_choice(
|
|
144
|
+
data.get("on_block", "stop"), "on_block", _ON_BLOCK_VALUES,
|
|
145
|
+
),
|
|
146
|
+
ask_timeout_seconds=_coerce_int(
|
|
147
|
+
data.get("ask_timeout_seconds", 30), "ask_timeout_seconds",
|
|
148
|
+
),
|
|
149
|
+
on_block_fallback=_coerce_choice(
|
|
150
|
+
data.get("on_block_fallback", "stop"),
|
|
151
|
+
"on_block_fallback", _FALLBACK_VALUES,
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _coerce_bool(value: Any, default: bool) -> bool:
|
|
158
|
+
if isinstance(value, bool):
|
|
159
|
+
return value
|
|
160
|
+
if value is None:
|
|
161
|
+
return default
|
|
162
|
+
if isinstance(value, str):
|
|
163
|
+
s = value.strip().lower()
|
|
164
|
+
if s in ("true", "yes", "on", "1"):
|
|
165
|
+
return True
|
|
166
|
+
if s in ("false", "no", "off", "0"):
|
|
167
|
+
return False
|
|
168
|
+
raise DecisionEngineConfigError(
|
|
169
|
+
f"decision_engine.{value!r}: expected bool"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _coerce_level(value: Any, key: str) -> str:
|
|
174
|
+
if value is None:
|
|
175
|
+
return "off"
|
|
176
|
+
# YAML 1.1 parses unquoted ``off`` as boolean False; accept it as
|
|
177
|
+
# the off sentinel so writers don't have to quote. Boolean True
|
|
178
|
+
# stays rejected — there is no defensible level it maps to.
|
|
179
|
+
if isinstance(value, bool):
|
|
180
|
+
if value is False:
|
|
181
|
+
return "off"
|
|
182
|
+
raise DecisionEngineConfigError(
|
|
183
|
+
f"decision_engine.{key}: boolean True is not a valid level "
|
|
184
|
+
"(quote a string: low/medium/high/off)"
|
|
185
|
+
)
|
|
186
|
+
if not isinstance(value, str):
|
|
187
|
+
raise DecisionEngineConfigError(
|
|
188
|
+
f"decision_engine.{key}: expected string, got "
|
|
189
|
+
f"{type(value).__name__}"
|
|
190
|
+
)
|
|
191
|
+
s = value.strip().lower()
|
|
192
|
+
if s not in _LEVEL_VALUES:
|
|
193
|
+
raise DecisionEngineConfigError(
|
|
194
|
+
f"decision_engine.{key}: invalid value {value!r}. "
|
|
195
|
+
"Allowed: " + ", ".join(sorted(_LEVEL_VALUES))
|
|
196
|
+
)
|
|
197
|
+
return s
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _coerce_choice(value: Any, key: str, allowed: frozenset[str]) -> str:
|
|
201
|
+
if not isinstance(value, str):
|
|
202
|
+
raise DecisionEngineConfigError(
|
|
203
|
+
f"decision_engine.{key}: expected string, got "
|
|
204
|
+
f"{type(value).__name__}"
|
|
205
|
+
)
|
|
206
|
+
s = value.strip().lower()
|
|
207
|
+
if s not in allowed:
|
|
208
|
+
raise DecisionEngineConfigError(
|
|
209
|
+
f"decision_engine.{key}: invalid value {value!r}. "
|
|
210
|
+
"Allowed: " + ", ".join(sorted(allowed))
|
|
211
|
+
)
|
|
212
|
+
return s
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _coerce_int(value: Any, key: str) -> int:
|
|
216
|
+
if isinstance(value, bool):
|
|
217
|
+
raise DecisionEngineConfigError(
|
|
218
|
+
f"decision_engine.{key}: expected int, got bool"
|
|
219
|
+
)
|
|
220
|
+
if isinstance(value, int):
|
|
221
|
+
if value < 0:
|
|
222
|
+
raise DecisionEngineConfigError(
|
|
223
|
+
f"decision_engine.{key}: must be >= 0"
|
|
224
|
+
)
|
|
225
|
+
return value
|
|
226
|
+
raise DecisionEngineConfigError(
|
|
227
|
+
f"decision_engine.{key}: expected int, got "
|
|
228
|
+
f"{type(value).__name__}"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def evaluate_gates(
|
|
233
|
+
settings: DecisionEngineSettings,
|
|
234
|
+
*,
|
|
235
|
+
phase: str,
|
|
236
|
+
confidence_band: str | None,
|
|
237
|
+
risk_class: str | None,
|
|
238
|
+
memory_hits: int,
|
|
239
|
+
is_interactive: Callable[[], bool] | None = None,
|
|
240
|
+
) -> GateDecision | None:
|
|
241
|
+
"""Evaluate gates for ``phase``. Returns the first firing gate, or
|
|
242
|
+
``None`` when no gate fires.
|
|
243
|
+
|
|
244
|
+
Conflict resolution follows :data:`GATE_PRIORITY` — only the first
|
|
245
|
+
matching gate's phase is considered. Each gate maps to exactly one
|
|
246
|
+
phase via :data:`_PHASE_FOR_GATE`.
|
|
247
|
+
"""
|
|
248
|
+
if not settings.any_gate_active:
|
|
249
|
+
return None
|
|
250
|
+
for gate_id in GATE_PRIORITY:
|
|
251
|
+
if _PHASE_FOR_GATE.get(gate_id) != phase:
|
|
252
|
+
continue
|
|
253
|
+
decision = _evaluate_single(
|
|
254
|
+
gate_id, settings,
|
|
255
|
+
confidence_band=confidence_band,
|
|
256
|
+
risk_class=risk_class,
|
|
257
|
+
memory_hits=memory_hits,
|
|
258
|
+
)
|
|
259
|
+
if decision is not None:
|
|
260
|
+
action = _resolve_action(settings, is_interactive)
|
|
261
|
+
return GateDecision(
|
|
262
|
+
gate_id=decision.gate_id,
|
|
263
|
+
phase=decision.phase,
|
|
264
|
+
reason=decision.reason,
|
|
265
|
+
action=action,
|
|
266
|
+
)
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _evaluate_single(
|
|
271
|
+
gate_id: str,
|
|
272
|
+
settings: DecisionEngineSettings,
|
|
273
|
+
*,
|
|
274
|
+
confidence_band: str | None,
|
|
275
|
+
risk_class: str | None,
|
|
276
|
+
memory_hits: int,
|
|
277
|
+
) -> GateDecision | None:
|
|
278
|
+
if gate_id == "min_confidence" and settings.min_confidence != "off":
|
|
279
|
+
floor = _LEVEL_RANK[settings.min_confidence]
|
|
280
|
+
actual = _LEVEL_RANK.get((confidence_band or "").lower(), 0)
|
|
281
|
+
if actual < floor:
|
|
282
|
+
return GateDecision(
|
|
283
|
+
gate_id=gate_id, phase="plan", action="stop",
|
|
284
|
+
reason=(
|
|
285
|
+
f"confidence_band={confidence_band!r} below floor "
|
|
286
|
+
f"min_confidence={settings.min_confidence!r}"
|
|
287
|
+
),
|
|
288
|
+
)
|
|
289
|
+
elif gate_id == "block_on_risk" and settings.block_on_risk != "off":
|
|
290
|
+
ceiling = _LEVEL_RANK[settings.block_on_risk]
|
|
291
|
+
actual = _LEVEL_RANK.get((risk_class or "").lower(), 0)
|
|
292
|
+
if actual >= ceiling:
|
|
293
|
+
return GateDecision(
|
|
294
|
+
gate_id=gate_id, phase="implement", action="stop",
|
|
295
|
+
reason=(
|
|
296
|
+
f"risk_class={risk_class!r} at/above ceiling "
|
|
297
|
+
f"block_on_risk={settings.block_on_risk!r}"
|
|
298
|
+
),
|
|
299
|
+
)
|
|
300
|
+
elif gate_id == "require_memory_hits" and settings.require_memory_hits:
|
|
301
|
+
if memory_hits < 1:
|
|
302
|
+
return GateDecision(
|
|
303
|
+
gate_id=gate_id, phase="refine", action="stop",
|
|
304
|
+
reason=(
|
|
305
|
+
f"memory_hits={memory_hits} but "
|
|
306
|
+
"require_memory_hits=true (need >= 1)"
|
|
307
|
+
),
|
|
308
|
+
)
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _resolve_action(
|
|
313
|
+
settings: DecisionEngineSettings,
|
|
314
|
+
is_interactive: Callable[[], bool] | None,
|
|
315
|
+
) -> str:
|
|
316
|
+
"""Map ``on_block`` to an action, applying the non-TTY fallback.
|
|
317
|
+
|
|
318
|
+
Non-interactive context = either ``is_interactive()`` returns
|
|
319
|
+
False, or the ``CI`` env var is truthy. ``on_block=ask`` collapses
|
|
320
|
+
to ``ask_timeout`` (consumer applies ``on_block_fallback``).
|
|
321
|
+
"""
|
|
322
|
+
if settings.on_block in ("stop", "warn"):
|
|
323
|
+
return settings.on_block
|
|
324
|
+
interactive = (
|
|
325
|
+
is_interactive() if is_interactive is not None
|
|
326
|
+
else _default_is_interactive()
|
|
327
|
+
)
|
|
328
|
+
if interactive:
|
|
329
|
+
return "ask"
|
|
330
|
+
return "ask_timeout"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _default_is_interactive() -> bool:
|
|
334
|
+
if os.environ.get("CI", "").strip().lower() in ("1", "true", "yes"):
|
|
335
|
+
return False
|
|
336
|
+
try:
|
|
337
|
+
import sys
|
|
338
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
339
|
+
except (AttributeError, ValueError):
|
|
340
|
+
return False
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
__all__ = [
|
|
344
|
+
"ALLOWED_KEYS",
|
|
345
|
+
"GATE_PRIORITY",
|
|
346
|
+
"DecisionEngineConfigError",
|
|
347
|
+
"DecisionEngineSettings",
|
|
348
|
+
"GateDecision",
|
|
349
|
+
"evaluate_gates",
|
|
350
|
+
"parse",
|
|
351
|
+
]
|