@event4u/agent-config 2.10.0 → 2.12.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.
Files changed (94) hide show
  1. package/.agent-src/commands/agents.md +1 -0
  2. package/.agent-src/commands/challenge-me.md +1 -0
  3. package/.agent-src/commands/chat-history.md +1 -0
  4. package/.agent-src/commands/context.md +1 -0
  5. package/.agent-src/commands/council.md +1 -0
  6. package/.agent-src/commands/feature.md +1 -0
  7. package/.agent-src/commands/fix.md +1 -0
  8. package/.agent-src/commands/grill-me.md +1 -0
  9. package/.agent-src/commands/judge.md +1 -0
  10. package/.agent-src/commands/memory.md +1 -0
  11. package/.agent-src/commands/module.md +1 -0
  12. package/.agent-src/commands/onboard.md +32 -4
  13. package/.agent-src/commands/optimize.md +1 -0
  14. package/.agent-src/commands/override.md +1 -0
  15. package/.agent-src/commands/roadmap.md +1 -0
  16. package/.agent-src/commands/tests.md +1 -0
  17. package/.agent-src/skills/canvas-design/SKILL.md +132 -0
  18. package/.agent-src/skills/canvas-design/evals/triggers.json +16 -0
  19. package/.agent-src/skills/doc-coauthoring/SKILL.md +129 -0
  20. package/.agent-src/skills/doc-coauthoring/evals/triggers.json +16 -0
  21. package/.agent-src/skills/nextjs-patterns/SKILL.md +203 -0
  22. package/.agent-src/skills/skill-writing/SKILL.md +101 -16
  23. package/.agent-src/skills/sql-writing/SKILL.md +1 -1
  24. package/.agent-src/skills/symfony-workflow/SKILL.md +173 -0
  25. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +4 -0
  26. package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +3 -0
  27. package/.agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.py +162 -0
  28. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +24 -6
  29. package/.agent-src/templates/scripts/work_engine/scoring/decision_engine.py +351 -0
  30. package/.claude-plugin/marketplace.json +5 -1
  31. package/CHANGELOG.md +68 -0
  32. package/README.md +37 -8
  33. package/config/agent-settings.template.yml +66 -0
  34. package/docs/architecture.md +1 -1
  35. package/docs/contracts/STABILITY.md +16 -0
  36. package/docs/contracts/adr-chat-history-split.md +1 -0
  37. package/docs/contracts/adr-forecast-construction-shape.md +1 -0
  38. package/docs/contracts/adr-gtm-context-spine.md +1 -0
  39. package/docs/contracts/adr-level-6-productization.md +147 -0
  40. package/docs/contracts/adr-settings-sync-engine.md +1 -0
  41. package/docs/contracts/adr-wing4-context-spine.md +1 -0
  42. package/docs/contracts/agent-memory-contract.md +1 -0
  43. package/docs/contracts/agents-md-tech-stack.md +1 -0
  44. package/docs/contracts/audit-log-v1.md +1 -0
  45. package/docs/contracts/command-clusters.md +1 -0
  46. package/docs/contracts/command-surface-tiers.md +1 -0
  47. package/docs/contracts/context-paths.md +1 -0
  48. package/docs/contracts/cost-profile-defaults.md +105 -0
  49. package/docs/contracts/cross-wing-handoff.md +1 -0
  50. package/docs/contracts/decision-engine-gates.md +115 -0
  51. package/docs/contracts/decision-trace-v1.md +1 -0
  52. package/docs/contracts/file-ownership-matrix.md +1 -0
  53. package/docs/contracts/hook-architecture-v1.md +1 -0
  54. package/docs/contracts/implement-ticket-flow.md +1 -0
  55. package/docs/contracts/installed-tools-lockfile.md +1 -0
  56. package/docs/contracts/kernel-membership.md +1 -0
  57. package/docs/contracts/linear-ai-rules-inclusion.md +1 -0
  58. package/docs/contracts/linear-ai-three-layers.md +1 -0
  59. package/docs/contracts/linter-structural-model.md +1 -0
  60. package/docs/contracts/load-context-budget-model.md +1 -0
  61. package/docs/contracts/load-context-schema.md +1 -0
  62. package/docs/contracts/memory-visibility-v1.md +1 -0
  63. package/docs/contracts/one-off-script-lifecycle.md +1 -0
  64. package/docs/contracts/orchestration-dsl-v1.md +1 -0
  65. package/docs/contracts/package-self-orientation.md +1 -0
  66. package/docs/contracts/persona-schema.md +1 -0
  67. package/docs/contracts/release-trunk-sync.md +104 -0
  68. package/docs/contracts/roadmap-complexity-standard.md +1 -0
  69. package/docs/contracts/rule-classification.md +1 -0
  70. package/docs/contracts/rule-interactions.md +26 -0
  71. package/docs/contracts/rule-priority-hierarchy.md +1 -0
  72. package/docs/contracts/rule-router.md +1 -0
  73. package/docs/contracts/settings-sync-yaml-subset.md +1 -0
  74. package/docs/contracts/skill-domains.md +1 -0
  75. package/docs/contracts/tier-3-contrib-plugin.md +1 -0
  76. package/docs/contracts/ui-stack-extension.md +1 -0
  77. package/docs/contracts/ui-track-flow.md +1 -0
  78. package/docs/customization.md +1 -1
  79. package/docs/getting-started.md +3 -1
  80. package/docs/installation.md +8 -6
  81. package/package.json +1 -1
  82. package/scripts/ai_council/clients.py +17 -4
  83. package/scripts/ai_council/orchestrator.py +6 -2
  84. package/scripts/check_beta_review_markers.py +127 -0
  85. package/scripts/check_references.py +25 -0
  86. package/scripts/check_release_trunk_sync.py +152 -0
  87. package/scripts/council_cli.py +36 -5
  88. package/scripts/install.py +3 -3
  89. package/scripts/run_skill_evals.py +185 -0
  90. package/scripts/schemas/command.schema.json +5 -0
  91. package/scripts/schemas/skill.schema.json +4 -0
  92. package/scripts/skill_linter.py +82 -3
  93. package/scripts/smoke_quickstart.py +134 -0
  94. 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"]
@@ -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(enabled=False)
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
- decision_engine = data.get("decision_engine")
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
+ ]
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Shared agent configuration \u2014 skills for AI coding tools (Claude Code, Augment, Cursor, Cline, Windsurf, Gemini CLI).",
9
- "version": "2.10.0",
9
+ "version": "2.12.0",
10
10
  "keywords": [
11
11
  "agent-config",
12
12
  "skills",
@@ -71,6 +71,7 @@
71
71
  "./.claude/skills/bug-fix",
72
72
  "./.claude/skills/bug-investigate",
73
73
  "./.claude/skills/build-buy-partner",
74
+ "./.claude/skills/canvas-design",
74
75
  "./.claude/skills/challenge-me",
75
76
  "./.claude/skills/challenge-me-vision",
76
77
  "./.claude/skills/challenge-me-with-docs",
@@ -126,6 +127,7 @@
126
127
  "./.claude/skills/devcontainer",
127
128
  "./.claude/skills/developer-like-execution",
128
129
  "./.claude/skills/discovery-interview",
130
+ "./.claude/skills/doc-coauthoring",
129
131
  "./.claude/skills/docker",
130
132
  "./.claude/skills/dto-creator",
131
133
  "./.claude/skills/e2e-heal",
@@ -219,6 +221,7 @@
219
221
  "./.claude/skills/module-explore",
220
222
  "./.claude/skills/module-management",
221
223
  "./.claude/skills/multi-tenancy",
224
+ "./.claude/skills/nextjs-patterns",
222
225
  "./.claude/skills/okr-tree-modeling",
223
226
  "./.claude/skills/onboard",
224
227
  "./.claude/skills/onboarding-design",
@@ -316,6 +319,7 @@
316
319
  "./.claude/skills/sql-writing",
317
320
  "./.claude/skills/stakeholder-tradeoff",
318
321
  "./.claude/skills/subagent-orchestration",
322
+ "./.claude/skills/symfony-workflow",
319
323
  "./.claude/skills/sync-agent-settings",
320
324
  "./.claude/skills/sync-gitignore",
321
325
  "./.claude/skills/sync-gitignore-fix",