arkaos 4.16.0 → 4.18.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/README.md +1 -1
- package/VERSION +1 -1
- package/arka/SKILL.md +1 -1
- package/config/agents-provenance.yaml +126 -0
- package/config/skills-provenance.yaml +357 -0
- package/core/agents/provenance.py +117 -0
- package/core/agents/registry_gen.py +75 -57
- package/core/cognition/insights/store.py +114 -7
- package/core/cognition/memory/schemas.py +45 -5
- package/core/governance/harness_scanner.py +776 -0
- package/core/governance/harness_scanner_cli.py +131 -0
- package/core/hooks/pre_tool_use.py +59 -0
- package/core/provenance.py +108 -0
- package/core/skills/__init__.py +21 -0
- package/core/skills/provenance.py +157 -0
- package/core/workflow/config_guard.py +177 -0
- package/core/workflow/transcript_scope.py +46 -0
- package/departments/dev/skills/click-path-audit/SKILL.md +76 -0
- package/departments/dev/skills/pr-test-analyzer/SKILL.md +79 -0
- package/departments/dev/skills/silent-failure-hunter/SKILL.md +72 -0
- package/departments/dev/skills/type-design-analyzer/SKILL.md +73 -0
- package/installer/cli.js +24 -0
- package/installer/doctor.js +46 -0
- package/knowledge/agents-registry-v2.json +259 -1
- package/knowledge/skills-manifest.json +1005 -237
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/marketplace_gen.py +57 -1
- package/scripts/skill_validator.py +133 -62
|
@@ -11,6 +11,77 @@ from datetime import datetime
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
13
|
from core.agents.loader import load_agent
|
|
14
|
+
from core.agents.provenance import Provenance, agent_provenance
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _provenance_entry(prov: Provenance) -> dict:
|
|
18
|
+
"""The full origin/source/licence trail for the registry — not just
|
|
19
|
+
origin, so the shipped file can answer 'what licence is this under?'."""
|
|
20
|
+
entry = {"origin": prov.origin}
|
|
21
|
+
if not prov.is_first_party:
|
|
22
|
+
entry["source"] = prov.source
|
|
23
|
+
entry["license"] = prov.license
|
|
24
|
+
return entry
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _dna_dict(dna) -> dict:
|
|
28
|
+
"""The four-framework behavioral DNA, flattened for the registry."""
|
|
29
|
+
return {
|
|
30
|
+
"disc": {
|
|
31
|
+
"primary": dna.disc.primary.value,
|
|
32
|
+
"secondary": dna.disc.secondary.value,
|
|
33
|
+
"label": dna.disc.label,
|
|
34
|
+
},
|
|
35
|
+
"enneagram": {
|
|
36
|
+
"type": dna.enneagram.type.value,
|
|
37
|
+
"wing": dna.enneagram.wing,
|
|
38
|
+
"label": dna.enneagram.label,
|
|
39
|
+
},
|
|
40
|
+
"big_five": {
|
|
41
|
+
"O": dna.big_five.openness,
|
|
42
|
+
"C": dna.big_five.conscientiousness,
|
|
43
|
+
"E": dna.big_five.extraversion,
|
|
44
|
+
"A": dna.big_five.agreeableness,
|
|
45
|
+
"N": dna.big_five.neuroticism,
|
|
46
|
+
},
|
|
47
|
+
"mbti": dna.mbti.type.value,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _agent_entry(agent, prov: Provenance, yaml_file: Path, root: Path) -> dict:
|
|
52
|
+
"""One registry entry for a loaded agent + its provenance."""
|
|
53
|
+
return {
|
|
54
|
+
"id": agent.id,
|
|
55
|
+
"name": agent.name,
|
|
56
|
+
"role": agent.role,
|
|
57
|
+
"department": agent.department,
|
|
58
|
+
"tier": agent.tier,
|
|
59
|
+
"model": agent.get_model(),
|
|
60
|
+
"provenance": _provenance_entry(prov),
|
|
61
|
+
"parent_squad": agent.parent_squad,
|
|
62
|
+
"sub_squad_role": agent.sub_squad_role,
|
|
63
|
+
**_dna_dict(agent.behavioral_dna),
|
|
64
|
+
"authority": {
|
|
65
|
+
k: v for k, v in agent.authority.model_dump().items()
|
|
66
|
+
if v and v != [] and k not in ("delegates_to", "escalates_to")
|
|
67
|
+
},
|
|
68
|
+
"expertise_domains": agent.expertise.domains,
|
|
69
|
+
"frameworks": agent.expertise.frameworks,
|
|
70
|
+
"knowledge_sources": agent.expertise.knowledge_sources,
|
|
71
|
+
"file": str(yaml_file.relative_to(root)),
|
|
72
|
+
"memory_path": agent.memory_path,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _summaries(agents: list[dict]) -> dict:
|
|
77
|
+
"""Tier, department, and DISC-primary counts for the registry meta."""
|
|
78
|
+
tiers, depts, disc = {}, {}, {}
|
|
79
|
+
for a in agents:
|
|
80
|
+
tiers[a["tier"]] = tiers.get(a["tier"], 0) + 1
|
|
81
|
+
depts[a["department"]] = depts.get(a["department"], 0) + 1
|
|
82
|
+
primary = a["disc"]["primary"]
|
|
83
|
+
disc[primary] = disc.get(primary, 0) + 1
|
|
84
|
+
return {"tiers": tiers, "departments": depts, "disc_distribution": disc}
|
|
14
85
|
|
|
15
86
|
|
|
16
87
|
def generate_registry(departments_dir: str | Path, output_path: str | Path) -> dict:
|
|
@@ -34,72 +105,19 @@ def generate_registry(departments_dir: str | Path, output_path: str | Path) -> d
|
|
|
34
105
|
for yaml_file in sorted(departments_dir.glob("*/agents/**/*.yaml")):
|
|
35
106
|
try:
|
|
36
107
|
agent = load_agent(yaml_file)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"role": agent.role,
|
|
41
|
-
"department": agent.department,
|
|
42
|
-
"tier": agent.tier,
|
|
43
|
-
"model": agent.get_model(),
|
|
44
|
-
"parent_squad": agent.parent_squad,
|
|
45
|
-
"sub_squad_role": agent.sub_squad_role,
|
|
46
|
-
"disc": {
|
|
47
|
-
"primary": agent.behavioral_dna.disc.primary.value,
|
|
48
|
-
"secondary": agent.behavioral_dna.disc.secondary.value,
|
|
49
|
-
"label": agent.behavioral_dna.disc.label,
|
|
50
|
-
},
|
|
51
|
-
"enneagram": {
|
|
52
|
-
"type": agent.behavioral_dna.enneagram.type.value,
|
|
53
|
-
"wing": agent.behavioral_dna.enneagram.wing,
|
|
54
|
-
"label": agent.behavioral_dna.enneagram.label,
|
|
55
|
-
},
|
|
56
|
-
"big_five": {
|
|
57
|
-
"O": agent.behavioral_dna.big_five.openness,
|
|
58
|
-
"C": agent.behavioral_dna.big_five.conscientiousness,
|
|
59
|
-
"E": agent.behavioral_dna.big_five.extraversion,
|
|
60
|
-
"A": agent.behavioral_dna.big_five.agreeableness,
|
|
61
|
-
"N": agent.behavioral_dna.big_five.neuroticism,
|
|
62
|
-
},
|
|
63
|
-
"mbti": agent.behavioral_dna.mbti.type.value,
|
|
64
|
-
"authority": {
|
|
65
|
-
k: v for k, v in agent.authority.model_dump().items()
|
|
66
|
-
if v and v != [] and k not in ("delegates_to", "escalates_to")
|
|
67
|
-
},
|
|
68
|
-
"expertise_domains": agent.expertise.domains,
|
|
69
|
-
"frameworks": agent.expertise.frameworks,
|
|
70
|
-
"knowledge_sources": agent.expertise.knowledge_sources,
|
|
71
|
-
"file": str(yaml_file.relative_to(departments_dir.parent)),
|
|
72
|
-
"memory_path": agent.memory_path,
|
|
73
|
-
}
|
|
74
|
-
agents.append(entry)
|
|
108
|
+
prov = agent_provenance(yaml_file)
|
|
109
|
+
agents.append(_agent_entry(
|
|
110
|
+
agent, prov, yaml_file, departments_dir.parent))
|
|
75
111
|
except Exception as e:
|
|
76
112
|
errors.append(f"{yaml_file.name}: {e}")
|
|
77
113
|
|
|
78
|
-
# Build tier summary
|
|
79
|
-
tier_counts = {}
|
|
80
|
-
for a in agents:
|
|
81
|
-
tier_counts[a["tier"]] = tier_counts.get(a["tier"], 0) + 1
|
|
82
|
-
|
|
83
|
-
# Build department summary
|
|
84
|
-
dept_counts = {}
|
|
85
|
-
for a in agents:
|
|
86
|
-
dept_counts[a["department"]] = dept_counts.get(a["department"], 0) + 1
|
|
87
|
-
|
|
88
|
-
# Build DISC distribution
|
|
89
|
-
disc_counts = {}
|
|
90
|
-
for a in agents:
|
|
91
|
-
p = a["disc"]["primary"]
|
|
92
|
-
disc_counts[p] = disc_counts.get(p, 0) + 1
|
|
93
|
-
|
|
94
114
|
registry = {
|
|
95
115
|
"_meta": {
|
|
96
116
|
"version": "2.0.0",
|
|
97
117
|
"generated": datetime.now().isoformat(),
|
|
98
118
|
"total_agents": len(agents),
|
|
99
119
|
"generator": "core/agents/registry_gen.py",
|
|
100
|
-
|
|
101
|
-
"departments": dept_counts,
|
|
102
|
-
"disc_distribution": disc_counts,
|
|
120
|
+
**_summaries(agents),
|
|
103
121
|
},
|
|
104
122
|
"agents": agents,
|
|
105
123
|
}
|
|
@@ -5,10 +5,14 @@ project filtering, presentation lifecycle, and dismissal analytics.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import sqlite3
|
|
8
|
-
from datetime import
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
|
-
from core.cognition.memory.schemas import
|
|
11
|
+
from core.cognition.memory.schemas import (
|
|
12
|
+
INSTINCT_CONFIDENCE_MAX,
|
|
13
|
+
INSTINCT_CONFIDENCE_MIN,
|
|
14
|
+
ActionableInsight,
|
|
15
|
+
)
|
|
12
16
|
|
|
13
17
|
|
|
14
18
|
class InsightStore:
|
|
@@ -26,6 +30,27 @@ class InsightStore:
|
|
|
26
30
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
27
31
|
return conn
|
|
28
32
|
|
|
33
|
+
# Instinct columns added after the original 12-column schema. An
|
|
34
|
+
# operator's existing DB has the old shape, so these are ALTER-added
|
|
35
|
+
# with defaults rather than baked into CREATE TABLE — a fresh install
|
|
36
|
+
# gets them from CREATE, an upgrade gets them from _migrate.
|
|
37
|
+
#
|
|
38
|
+
# Each entry carries the FULL, literal ALTER statement — never an
|
|
39
|
+
# f-string built from parts. SQLite cannot parameterise a DDL
|
|
40
|
+
# identifier, so the only safe construction is a constant statement,
|
|
41
|
+
# and a constant statement is also the only one a scanner can trust:
|
|
42
|
+
# there is no interpolation site to become an injection the day these
|
|
43
|
+
# names turn dynamic.
|
|
44
|
+
_INSTINCT_COLUMNS = (
|
|
45
|
+
("confidence",
|
|
46
|
+
"ALTER TABLE insights ADD COLUMN confidence REAL NOT NULL DEFAULT 0.5"),
|
|
47
|
+
("scope",
|
|
48
|
+
"ALTER TABLE insights ADD COLUMN scope TEXT NOT NULL DEFAULT 'project'"),
|
|
49
|
+
("evidence_count",
|
|
50
|
+
"ALTER TABLE insights ADD COLUMN evidence_count "
|
|
51
|
+
"INTEGER NOT NULL DEFAULT 1"),
|
|
52
|
+
)
|
|
53
|
+
|
|
29
54
|
def _init_db(self) -> None:
|
|
30
55
|
with self._conn() as conn:
|
|
31
56
|
conn.execute("""
|
|
@@ -41,9 +66,13 @@ class InsightStore:
|
|
|
41
66
|
recommendation TEXT NOT NULL,
|
|
42
67
|
context TEXT NOT NULL,
|
|
43
68
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
44
|
-
presented_at TEXT
|
|
69
|
+
presented_at TEXT,
|
|
70
|
+
confidence REAL NOT NULL DEFAULT 0.5,
|
|
71
|
+
scope TEXT NOT NULL DEFAULT 'project',
|
|
72
|
+
evidence_count INTEGER NOT NULL DEFAULT 1
|
|
45
73
|
)
|
|
46
74
|
""")
|
|
75
|
+
self._migrate(conn)
|
|
47
76
|
conn.execute(
|
|
48
77
|
"CREATE INDEX IF NOT EXISTS idx_insights_project ON insights (project)"
|
|
49
78
|
)
|
|
@@ -51,6 +80,16 @@ class InsightStore:
|
|
|
51
80
|
"CREATE INDEX IF NOT EXISTS idx_insights_status ON insights (status)"
|
|
52
81
|
)
|
|
53
82
|
|
|
83
|
+
def _migrate(self, conn: sqlite3.Connection) -> None:
|
|
84
|
+
"""Add instinct columns to a pre-existing table. Idempotent."""
|
|
85
|
+
existing = {
|
|
86
|
+
row["name"]
|
|
87
|
+
for row in conn.execute("PRAGMA table_info(insights)").fetchall()
|
|
88
|
+
}
|
|
89
|
+
for name, alter_stmt in self._INSTINCT_COLUMNS:
|
|
90
|
+
if name not in existing:
|
|
91
|
+
conn.execute(alter_stmt)
|
|
92
|
+
|
|
54
93
|
def _row_to_insight(self, row: sqlite3.Row) -> ActionableInsight:
|
|
55
94
|
data = dict(row)
|
|
56
95
|
# Map trigger_source back to trigger (SQL keyword workaround)
|
|
@@ -69,8 +108,8 @@ class InsightStore:
|
|
|
69
108
|
INSERT OR REPLACE INTO insights
|
|
70
109
|
(id, project, trigger_source, date_generated, category,
|
|
71
110
|
severity, title, description, recommendation, context,
|
|
72
|
-
status, presented_at)
|
|
73
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
111
|
+
status, presented_at, confidence, scope, evidence_count)
|
|
112
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
74
113
|
""",
|
|
75
114
|
(
|
|
76
115
|
insight.id,
|
|
@@ -85,6 +124,9 @@ class InsightStore:
|
|
|
85
124
|
insight.context,
|
|
86
125
|
insight.status,
|
|
87
126
|
insight.presented_at.isoformat() if insight.presented_at else None,
|
|
127
|
+
insight.confidence,
|
|
128
|
+
insight.scope,
|
|
129
|
+
insight.evidence_count,
|
|
88
130
|
),
|
|
89
131
|
)
|
|
90
132
|
|
|
@@ -130,11 +172,12 @@ class InsightStore:
|
|
|
130
172
|
"""Mark insights as presented and record the timestamp."""
|
|
131
173
|
if not ids:
|
|
132
174
|
return
|
|
133
|
-
now = datetime.now(
|
|
175
|
+
now = datetime.now(UTC).isoformat()
|
|
134
176
|
placeholders = ",".join("?" * len(ids))
|
|
135
177
|
with self._conn() as conn:
|
|
136
178
|
conn.execute(
|
|
137
|
-
|
|
179
|
+
"UPDATE insights SET status = 'presented', presented_at = ? "
|
|
180
|
+
f"WHERE id IN ({placeholders})",
|
|
138
181
|
[now, *ids],
|
|
139
182
|
)
|
|
140
183
|
|
|
@@ -151,5 +194,69 @@ class InsightStore:
|
|
|
151
194
|
).fetchall()
|
|
152
195
|
return {r["category"]: r["cnt"] for r in rows}
|
|
153
196
|
|
|
197
|
+
def reinforce(self, insight_id: str, step: float = 0.1) -> None:
|
|
198
|
+
"""Corroboration: raise confidence (clamped) and count evidence.
|
|
199
|
+
|
|
200
|
+
The clamp lives in ``ActionableInsight``; the SQL bounds mirror it
|
|
201
|
+
so a direct UPDATE cannot push a value past the band either.
|
|
202
|
+
"""
|
|
203
|
+
with self._conn() as conn:
|
|
204
|
+
conn.execute(
|
|
205
|
+
"""
|
|
206
|
+
UPDATE insights
|
|
207
|
+
SET confidence = MIN(?, confidence + ?),
|
|
208
|
+
evidence_count = evidence_count + 1
|
|
209
|
+
WHERE id = ?
|
|
210
|
+
""",
|
|
211
|
+
(INSTINCT_CONFIDENCE_MAX, step, insight_id),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def weaken(self, insight_id: str, step: float = 0.2) -> None:
|
|
215
|
+
"""Contradiction: lower confidence (clamped to the band floor).
|
|
216
|
+
|
|
217
|
+
A correction cuts harder than a corroboration lifts — the default
|
|
218
|
+
step is larger — because one contradiction outweighs several
|
|
219
|
+
passive confirmations.
|
|
220
|
+
"""
|
|
221
|
+
with self._conn() as conn:
|
|
222
|
+
conn.execute(
|
|
223
|
+
"UPDATE insights SET confidence = MAX(?, confidence - ?) WHERE id = ?",
|
|
224
|
+
(INSTINCT_CONFIDENCE_MIN, step, insight_id),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def promotable(
|
|
228
|
+
self, min_projects: int = 2, min_confidence: float = 0.8
|
|
229
|
+
) -> list[str]:
|
|
230
|
+
"""Titles seen as project-scoped across >= min_projects distinct
|
|
231
|
+
projects with mean confidence >= min_confidence.
|
|
232
|
+
|
|
233
|
+
The signal B7 (``/arka promote``) consumes: a pattern that has
|
|
234
|
+
proven itself in more than one project has earned global scope.
|
|
235
|
+
Grouped by title because that is the instinct's stable identity
|
|
236
|
+
across projects (the id is per-occurrence).
|
|
237
|
+
"""
|
|
238
|
+
with self._conn() as conn:
|
|
239
|
+
rows = conn.execute(
|
|
240
|
+
"""
|
|
241
|
+
SELECT title FROM insights
|
|
242
|
+
WHERE scope = 'project'
|
|
243
|
+
GROUP BY title
|
|
244
|
+
HAVING COUNT(DISTINCT project) >= ?
|
|
245
|
+
AND AVG(confidence) >= ?
|
|
246
|
+
""",
|
|
247
|
+
(min_projects, min_confidence),
|
|
248
|
+
).fetchall()
|
|
249
|
+
return [r["title"] for r in rows]
|
|
250
|
+
|
|
251
|
+
def promote_to_global(self, title: str) -> int:
|
|
252
|
+
"""Flip every project-scoped insight with this title to global.
|
|
253
|
+
Returns the number of rows promoted."""
|
|
254
|
+
with self._conn() as conn:
|
|
255
|
+
cur = conn.execute(
|
|
256
|
+
"UPDATE insights SET scope = 'global' WHERE title = ? AND scope = 'project'",
|
|
257
|
+
(title,),
|
|
258
|
+
)
|
|
259
|
+
return cur.rowcount
|
|
260
|
+
|
|
154
261
|
def close(self) -> None:
|
|
155
262
|
"""No-op — connections are opened per-operation."""
|
|
@@ -7,12 +7,10 @@ Three core models:
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import uuid
|
|
10
|
-
from datetime import
|
|
11
|
-
from typing import Literal
|
|
10
|
+
from datetime import UTC, datetime
|
|
12
11
|
|
|
13
12
|
from pydantic import BaseModel, Field, field_validator
|
|
14
13
|
|
|
15
|
-
|
|
16
14
|
# --- Allowed categories and statuses ---
|
|
17
15
|
|
|
18
16
|
RAW_CAPTURE_CATEGORIES = ("decision", "solution", "pattern", "error", "config")
|
|
@@ -35,7 +33,7 @@ ACTIONABLE_INSIGHT_STATUSES = ("pending", "presented", "accepted", "dismissed")
|
|
|
35
33
|
|
|
36
34
|
|
|
37
35
|
def _utc_now() -> datetime:
|
|
38
|
-
return datetime.now(
|
|
36
|
+
return datetime.now(UTC)
|
|
39
37
|
|
|
40
38
|
|
|
41
39
|
def _new_uuid() -> str:
|
|
@@ -97,8 +95,26 @@ class KnowledgeEntry(BaseModel):
|
|
|
97
95
|
return max(0.0, min(1.0, v))
|
|
98
96
|
|
|
99
97
|
|
|
98
|
+
# Instinct confidence lives in a band, never at the poles. 0.3 is a weak
|
|
99
|
+
# hypothesis worth keeping; 0.9 is strong but never certainty (an agent
|
|
100
|
+
# that is "certain" from a handful of observations is the failure the
|
|
101
|
+
# band prevents). Mirrors the ECC continuous-learning instinct model.
|
|
102
|
+
INSTINCT_CONFIDENCE_MIN = 0.3
|
|
103
|
+
INSTINCT_CONFIDENCE_MAX = 0.9
|
|
104
|
+
INSTINCT_SCOPES = ("project", "global")
|
|
105
|
+
|
|
106
|
+
|
|
100
107
|
class ActionableInsight(BaseModel):
|
|
101
|
-
"""Actionable insight — generated by agents for proactive presentation.
|
|
108
|
+
"""Actionable insight — generated by agents for proactive presentation.
|
|
109
|
+
|
|
110
|
+
Carries the ECC-style instinct fields (confidence, scope,
|
|
111
|
+
evidence_count) so an insight can strengthen on corroboration, weaken
|
|
112
|
+
on contradiction, and be promoted from project to global once the
|
|
113
|
+
same signal is seen across enough projects with high enough mean
|
|
114
|
+
confidence (see ``InsightStore.promotable``). All three default to
|
|
115
|
+
backwards-compatible values, so an insight written before this schema
|
|
116
|
+
reads as a fresh, project-scoped hypothesis.
|
|
117
|
+
"""
|
|
102
118
|
|
|
103
119
|
id: str = Field(default_factory=_new_uuid)
|
|
104
120
|
project: str
|
|
@@ -113,6 +129,30 @@ class ActionableInsight(BaseModel):
|
|
|
113
129
|
status: str = "pending"
|
|
114
130
|
presented_at: datetime | None = None
|
|
115
131
|
|
|
132
|
+
confidence: float = 0.5
|
|
133
|
+
scope: str = "project"
|
|
134
|
+
evidence_count: int = 1
|
|
135
|
+
|
|
136
|
+
@field_validator("confidence")
|
|
137
|
+
@classmethod
|
|
138
|
+
def clamp_confidence(cls, v: float) -> float:
|
|
139
|
+
"""Confidence is bounded to the instinct band, never the poles."""
|
|
140
|
+
return max(INSTINCT_CONFIDENCE_MIN, min(INSTINCT_CONFIDENCE_MAX, v))
|
|
141
|
+
|
|
142
|
+
@field_validator("scope")
|
|
143
|
+
@classmethod
|
|
144
|
+
def validate_scope(cls, v: str) -> str:
|
|
145
|
+
if v not in INSTINCT_SCOPES:
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f"Invalid scope '{v}'. Must be one of: {INSTINCT_SCOPES}"
|
|
148
|
+
)
|
|
149
|
+
return v
|
|
150
|
+
|
|
151
|
+
@field_validator("evidence_count")
|
|
152
|
+
@classmethod
|
|
153
|
+
def evidence_count_is_positive(cls, v: int) -> int:
|
|
154
|
+
return max(1, v)
|
|
155
|
+
|
|
116
156
|
@field_validator("category")
|
|
117
157
|
@classmethod
|
|
118
158
|
def validate_category(cls, v: str) -> str:
|