arkaos 3.75.2 → 3.77.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/VERSION +1 -1
- package/arka/skills/flow/SKILL.md +26 -0
- package/config/constitution.yaml +10 -0
- package/config/hooks/post-tool-use.sh +27 -0
- package/config/hooks/stop.sh +57 -0
- package/core/governance/__pycache__/activation_tracker.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/agent_activation_cli.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/design_system_lint.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/design_system_lint_cli.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/dna_fidelity.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/dna_fidelity_cli.cpython-313.pyc +0 -0
- package/core/governance/activation_tracker.py +178 -0
- package/core/governance/agent_activation_cli.py +107 -0
- package/core/governance/design_system_lint.py +197 -0
- package/core/governance/design_system_lint_cli.py +92 -0
- package/core/governance/dna_fidelity.py +246 -0
- package/core/governance/dna_fidelity_cli.py +149 -0
- package/core/synapse/__pycache__/pattern_library_layer.cpython-313.pyc +0 -0
- package/departments/dev/agents/tech-lead.yaml +18 -0
- package/departments/quality/agents/copy-director.yaml +23 -0
- package/departments/quality/agents/cqo.yaml +20 -0
- package/departments/quality/agents/tech-director.yaml +21 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.77.0
|
|
@@ -81,6 +81,20 @@ spec why divergence is justified. Manual audit:
|
|
|
81
81
|
Dispatch specialists via the `Agent` tool. The squad lead from Phase 3
|
|
82
82
|
names them. Specialists run in parallel when work is independent.
|
|
83
83
|
|
|
84
|
+
**Design-system check on UI work (PR6 v3.77.0, SHOULD `design-system-locked`).**
|
|
85
|
+
Before dispatching frontend/landing specialists, run the per-project
|
|
86
|
+
linter to surface UI/UX drift:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
python -m core.governance.design_system_lint_cli <project_path>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
If the project has a `design-system.yaml`, violations come back with
|
|
93
|
+
file:line + the suggestion text declared in the YAML. The frontend
|
|
94
|
+
specialist should fix existing violations OR document why the new work
|
|
95
|
+
diverges. Projects without a `design-system.yaml` skip the check.
|
|
96
|
+
Template: `docs/examples/design-system-example.yaml`.
|
|
97
|
+
|
|
84
98
|
**Experience injection (PR3 v3.74.0).** When a specialist is dispatched,
|
|
85
99
|
Synapse layer `L2.6 AgentExperiences`
|
|
86
100
|
(`core/synapse/agent_experiences_layer.py`) detects the
|
|
@@ -194,6 +208,18 @@ For each item, in order:
|
|
|
194
208
|
Do not skip items. Do not batch QA or Security across multiple
|
|
195
209
|
items — each item runs the full gate chain.
|
|
196
210
|
|
|
211
|
+
**DNA fidelity check at turn end (PR5 v3.76.0, SHOULD `dna-fidelity-warn`).**
|
|
212
|
+
The Stop hook (`config/hooks/stop.sh`) invokes
|
|
213
|
+
`core.governance.dna_fidelity.check_fidelity(agent_id, output)` for the
|
|
214
|
+
current persona (from `[arka:routing]` or `[arka:dispatch]`). Violations
|
|
215
|
+
of `avoid_patterns` or missing `opening_phrases` from the agent's YAML
|
|
216
|
+
`signature_markers` block are recorded to
|
|
217
|
+
`~/.arkaos/telemetry/dna-fidelity.jsonl` — soft-warn only in v3.76.0.
|
|
218
|
+
Operator audit: `python -m core.governance.dna_fidelity_cli summary`.
|
|
219
|
+
Each Agent dispatch also logs to
|
|
220
|
+
`~/.arkaos/telemetry/agent-activations.jsonl`; surface dormant agents
|
|
221
|
+
via `python -m core.governance.agent_activation_cli dead`.
|
|
222
|
+
|
|
197
223
|
### Phase 13 — Detailed summary
|
|
198
224
|
When the TODO list is exhausted, emit a final summary: what was done,
|
|
199
225
|
where it lives, how to verify, what is open for next time.
|
package/config/constitution.yaml
CHANGED
|
@@ -217,6 +217,16 @@ enforcement_levels:
|
|
|
217
217
|
rule: "Bottom-line first output. Lead with answer, then why, then how. Confidence tags on assessments."
|
|
218
218
|
enforcement: "See config/standards/communication.md for full standard"
|
|
219
219
|
|
|
220
|
+
# ─── Rule added in PR6 Squad Intelligence Upgrade (2026-05-28) ───────
|
|
221
|
+
- id: design-system-locked
|
|
222
|
+
rule: "Each project SHOULD declare a `design-system.yaml` at its root listing tokens (colors, spacing, fonts), allowed_components, file_globs to scan, and forbidden_patterns with suggestions. The per-project linter (core.governance.design_system_lint) detects UI/UX drift — hex literals outside the palette, inline style attributes, raw HTML where a Nuxt UI component exists, etc. Adoption is opt-in per project (no YAML at project root → no violations). v3.77.0 is advisory-only; pre-commit hook integration lands in v3.77.x once the rule sets stabilise across the operator's projects."
|
|
223
|
+
enforcement: "Run via `python -m core.governance.design_system_lint_cli <project_path>` (text or JSON output, optional --exit-on-violations). Example template at `docs/examples/design-system-example.yaml`."
|
|
224
|
+
|
|
225
|
+
# ─── Rule added in PR5 Squad Intelligence Upgrade (2026-05-28) ───────
|
|
226
|
+
- id: dna-fidelity-warn
|
|
227
|
+
rule: "Agent outputs are compared against the `signature_markers` block in each agent's YAML at the end of every turn. Forbidden patterns (avoid_patterns) and missing opening phrases (opening_phrases) generate FidelityViolation records in ~/.arkaos/telemetry/dna-fidelity.jsonl. v3.76.0 is soft-warn — violations are recorded for telemetry and operator review, not blocked. Hard-block mode lands later once the marker set is calibrated against real production usage."
|
|
228
|
+
enforcement: "Stop hook (config/hooks/stop.sh) calls core.governance.dna_fidelity.check_fidelity at turn end. Telemetry inspection: python -m core.governance.dna_fidelity_cli list|summary. Agents in scope for v3.76.0: tech-lead-paulo, cqo-marta, copy-director-eduardo, tech-director-francisca. Remaining 61 agent YAMLs get signature_markers in v3.76.x curation work."
|
|
229
|
+
|
|
220
230
|
# ─── Rule added in PR4 Squad Intelligence Upgrade (2026-05-28) ───────
|
|
221
231
|
- id: pattern-library-first
|
|
222
232
|
rule: "Before designing any new feature, consult the Pattern Library (core.knowledge.pattern_cards). If a similar pattern exists, reuse it or explicitly document why divergence is justified in the spec. New patterns SHOULD be registered with record_pattern() after Quality Gate APPROVED so future feature work inherits the prior art."
|
|
@@ -165,6 +165,33 @@ PY
|
|
|
165
165
|
fi
|
|
166
166
|
fi
|
|
167
167
|
fi
|
|
168
|
+
|
|
169
|
+
# ─── Activation tracking (PR5 v3.76.0) ─────────────────────────────
|
|
170
|
+
# Record every Task/Agent dispatch regardless of subagent_type (CQO or
|
|
171
|
+
# any other). Runs AFTER the cqo branch so verdicts don't block it.
|
|
172
|
+
# Never blocks — _ACT_ROOT reuses the same resolution as _AE_ROOT.
|
|
173
|
+
if [ -n "$SUBAGENT_TYPE" ]; then
|
|
174
|
+
_ACT_ROOT="${ARKAOS_ROOT:-}"
|
|
175
|
+
if [ -z "$_ACT_ROOT" ] && [ -f "$HOME/.arkaos/.repo-path" ]; then
|
|
176
|
+
_ACT_ROOT=$(cat "$HOME/.arkaos/.repo-path" 2>/dev/null)
|
|
177
|
+
fi
|
|
178
|
+
[ -z "$_ACT_ROOT" ] && _ACT_ROOT="$HOME/.arkaos"
|
|
179
|
+
SUBAGENT_TYPE="$SUBAGENT_TYPE" \
|
|
180
|
+
SESSION_ID="$SESSION_ID_PTU" \
|
|
181
|
+
ARKAOS_ROOT="$_ACT_ROOT" \
|
|
182
|
+
python3 - <<'PY' 2>/dev/null || true
|
|
183
|
+
import os, sys
|
|
184
|
+
sys.path.insert(0, os.environ["ARKAOS_ROOT"])
|
|
185
|
+
try:
|
|
186
|
+
from core.governance.activation_tracker import record_activation
|
|
187
|
+
record_activation(
|
|
188
|
+
subagent_type=os.environ.get("SUBAGENT_TYPE", ""),
|
|
189
|
+
session_id=os.environ.get("SESSION_ID", ""),
|
|
190
|
+
)
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
PY
|
|
194
|
+
fi
|
|
168
195
|
fi
|
|
169
196
|
|
|
170
197
|
# Only process if there was an error
|
package/config/hooks/stop.sh
CHANGED
|
@@ -20,6 +20,7 @@ TRANSCRIPT_PATH=""
|
|
|
20
20
|
STOP_HOOK_ACTIVE=""
|
|
21
21
|
CWD=""
|
|
22
22
|
EFFORT_LEVEL=""
|
|
23
|
+
ASSISTANT_MSG_STOP=""
|
|
23
24
|
if command -v jq &>/dev/null; then
|
|
24
25
|
SESSION_ID=$(echo "$input" | jq -r '.session_id // ""' 2>/dev/null)
|
|
25
26
|
TRANSCRIPT_PATH=$(echo "$input" | jq -r '.transcript_path // ""' 2>/dev/null)
|
|
@@ -29,6 +30,8 @@ if command -v jq &>/dev/null; then
|
|
|
29
30
|
# $CLAUDE_EFFORT env var. Soft-block checks (kb-cite, meta-tag) only
|
|
30
31
|
# run at high|xhigh; hard enforcement runs regardless.
|
|
31
32
|
EFFORT_LEVEL=$(echo "$input" | jq -r '.effort.level // ""' 2>/dev/null)
|
|
33
|
+
# PR5 v3.76.0 — DNA fidelity check needs the closing assistant message.
|
|
34
|
+
ASSISTANT_MSG_STOP=$(echo "$input" | jq -r '.assistant_message // ""' 2>/dev/null)
|
|
32
35
|
fi
|
|
33
36
|
# Fallback to env var if stdin didn't carry it
|
|
34
37
|
[ -z "$EFFORT_LEVEL" ] && EFFORT_LEVEL="${CLAUDE_EFFORT:-}"
|
|
@@ -39,6 +42,60 @@ fi
|
|
|
39
42
|
# (whether the next turn sees a [arka:suggest] line). Record the level
|
|
40
43
|
# on the telemetry row so we can later analyze suppression rates.
|
|
41
44
|
|
|
45
|
+
# ─── DNA Fidelity Check (PR5 v3.76.0) ───────────────────────────────────
|
|
46
|
+
# Fires for every session (no WF_MARKER dependency — fidelity is always
|
|
47
|
+
# worth measuring). Extracts the dispatched persona from the last routing/
|
|
48
|
+
# dispatch marker in the closing message, then calls check_fidelity() +
|
|
49
|
+
# record_fidelity(). Soft-warn only, never blocks. Zero violations are
|
|
50
|
+
# recorded too — absence of violations is signal.
|
|
51
|
+
if [ -n "$ASSISTANT_MSG_STOP" ] && [ -n "$SESSION_ID" ] && command -v python3 &>/dev/null; then
|
|
52
|
+
_FID_ROOT="${ARKAOS_ROOT:-}"
|
|
53
|
+
if [ -z "$_FID_ROOT" ] && [ -f "$HOME/.arkaos/.repo-path" ]; then
|
|
54
|
+
_FID_ROOT=$(cat "$HOME/.arkaos/.repo-path" 2>/dev/null)
|
|
55
|
+
fi
|
|
56
|
+
[ -z "$_FID_ROOT" ] && _FID_ROOT="$HOME/.arkaos"
|
|
57
|
+
|
|
58
|
+
# Extract persona: dispatch marker takes precedence over routing.
|
|
59
|
+
# Latest match wins (tail -1). Both patterns: [arka:dispatch] X -> Y
|
|
60
|
+
# and [arka:routing] X -> Y. Persona is the right-hand side, lowercased.
|
|
61
|
+
_FIDELITY_PERSONA=""
|
|
62
|
+
_DISPATCH_HIT=$(printf '%s' "$ASSISTANT_MSG_STOP" \
|
|
63
|
+
| grep -ioE '\[arka:dispatch\][[:space:]]*[A-Za-z0-9_-]+[[:space:]]*->[[:space:]]*[A-Za-z0-9_-]+' \
|
|
64
|
+
| tail -1)
|
|
65
|
+
if [ -n "$_DISPATCH_HIT" ]; then
|
|
66
|
+
_FIDELITY_PERSONA=$(printf '%s' "$_DISPATCH_HIT" \
|
|
67
|
+
| sed -E 's/.*->[[:space:]]*//' | tr '[:upper:]' '[:lower:]')
|
|
68
|
+
else
|
|
69
|
+
_ROUTING_HIT=$(printf '%s' "$ASSISTANT_MSG_STOP" \
|
|
70
|
+
| grep -ioE '\[arka:routing\][[:space:]]*[A-Za-z0-9_-]+[[:space:]]*->[[:space:]]*[A-Za-z0-9_-]+' \
|
|
71
|
+
| tail -1)
|
|
72
|
+
if [ -n "$_ROUTING_HIT" ]; then
|
|
73
|
+
_FIDELITY_PERSONA=$(printf '%s' "$_ROUTING_HIT" \
|
|
74
|
+
| sed -E 's/.*->[[:space:]]*//' | tr '[:upper:]' '[:lower:]')
|
|
75
|
+
fi
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
if [ -n "$_FIDELITY_PERSONA" ]; then
|
|
79
|
+
FIDELITY_PERSONA="$_FIDELITY_PERSONA" \
|
|
80
|
+
FIDELITY_SESSION_ID="$SESSION_ID" \
|
|
81
|
+
FIDELITY_MSG="$ASSISTANT_MSG_STOP" \
|
|
82
|
+
ARKAOS_ROOT="$_FID_ROOT" \
|
|
83
|
+
python3 - <<'PY' 2>/dev/null || true
|
|
84
|
+
import os, sys
|
|
85
|
+
sys.path.insert(0, os.environ["ARKAOS_ROOT"])
|
|
86
|
+
try:
|
|
87
|
+
from core.governance.dna_fidelity import check_fidelity, record_fidelity
|
|
88
|
+
agent_id = os.environ.get("FIDELITY_PERSONA", "")
|
|
89
|
+
session_id = os.environ.get("FIDELITY_SESSION_ID", "")
|
|
90
|
+
output = os.environ.get("FIDELITY_MSG", "")
|
|
91
|
+
violations = check_fidelity(agent_id, output)
|
|
92
|
+
record_fidelity(agent_id, session_id, violations)
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
PY
|
|
96
|
+
fi
|
|
97
|
+
fi
|
|
98
|
+
|
|
42
99
|
# Prevent infinite loops when Stop hook was triggered by its own decision.
|
|
43
100
|
if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
|
|
44
101
|
exit 0
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Activation Tracker — PR5 Squad Intelligence Upgrade v3.76.0.
|
|
2
|
+
|
|
3
|
+
Counts how often each agent (`subagent_type`) is dispatched via the Agent
|
|
4
|
+
tool. Surfaces top callers and dead agents (no activation in N days).
|
|
5
|
+
|
|
6
|
+
Wired into the PostToolUse hook in a later task. For now: persistence +
|
|
7
|
+
query layer. Mirrors the JSONL + path-safe pattern of agent_experiences.py.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from dataclasses import asdict, dataclass
|
|
16
|
+
from datetime import datetime, timedelta, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from core.shared import safe_session_id as _safe_session_id_module
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import fcntl
|
|
23
|
+
_HAS_FLOCK = True
|
|
24
|
+
except ImportError:
|
|
25
|
+
_HAS_FLOCK = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ─── Module-level constant (monkeypatched by tests) ───────────────────────────
|
|
29
|
+
|
|
30
|
+
TELEMETRY_PATH: Path = Path.home() / ".arkaos" / "telemetry" / "agent-activations.jsonl"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ─── Dataclass ────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Activation:
|
|
37
|
+
ts: str
|
|
38
|
+
subagent_type: str
|
|
39
|
+
session_id: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ─── Internal helpers ─────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
@contextmanager
|
|
45
|
+
def _locked_append(path: Path):
|
|
46
|
+
"""Append to path under POSIX flock; Windows falls back to O_APPEND atomicity."""
|
|
47
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
fh = path.open("a", encoding="utf-8")
|
|
49
|
+
try:
|
|
50
|
+
if _HAS_FLOCK:
|
|
51
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
|
52
|
+
yield fh
|
|
53
|
+
finally:
|
|
54
|
+
if _HAS_FLOCK:
|
|
55
|
+
try:
|
|
56
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
|
57
|
+
except OSError:
|
|
58
|
+
pass
|
|
59
|
+
fh.close()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _safe_id(value: str) -> str | None:
|
|
63
|
+
"""Delegate to safe_session_id for CWE-22 path-traversal guard."""
|
|
64
|
+
return _safe_session_id_module.safe_session_id(value)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _parse_ts(iso: str) -> datetime | None:
|
|
68
|
+
"""Parse an ISO timestamp string; return None on any failure."""
|
|
69
|
+
try:
|
|
70
|
+
return datetime.fromisoformat(iso)
|
|
71
|
+
except (TypeError, ValueError):
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load_all() -> list[dict]:
|
|
76
|
+
"""Single-pass read of TELEMETRY_PATH; skip malformed lines silently."""
|
|
77
|
+
entries: list[dict] = []
|
|
78
|
+
try:
|
|
79
|
+
with TELEMETRY_PATH.open(encoding="utf-8") as fh:
|
|
80
|
+
for line in fh:
|
|
81
|
+
if not line.strip():
|
|
82
|
+
continue
|
|
83
|
+
try:
|
|
84
|
+
entries.append(json.loads(line))
|
|
85
|
+
except json.JSONDecodeError:
|
|
86
|
+
continue
|
|
87
|
+
except OSError:
|
|
88
|
+
return []
|
|
89
|
+
return entries
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _filter_by_since(entries: list[dict], since: datetime | None) -> list[dict]:
|
|
93
|
+
"""Remove entries whose ts is strictly before `since`. Pass-through when since is None."""
|
|
94
|
+
if since is None:
|
|
95
|
+
return entries
|
|
96
|
+
result: list[dict] = []
|
|
97
|
+
for e in entries:
|
|
98
|
+
ts = _parse_ts(e.get("ts", ""))
|
|
99
|
+
if ts is not None and ts >= since:
|
|
100
|
+
result.append(e)
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _most_recent_per_agent(entries: list[dict]) -> dict[str, str]:
|
|
105
|
+
"""Return a map of subagent_type → latest ts string seen across all entries."""
|
|
106
|
+
latest: dict[str, str] = {}
|
|
107
|
+
for e in entries:
|
|
108
|
+
agent = e.get("subagent_type", "")
|
|
109
|
+
ts = e.get("ts", "")
|
|
110
|
+
if not agent or not ts:
|
|
111
|
+
continue
|
|
112
|
+
if agent not in latest or ts > latest[agent]:
|
|
113
|
+
latest[agent] = ts
|
|
114
|
+
return latest
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ─── Public API ───────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
def record_activation(subagent_type: str, session_id: str) -> None:
|
|
120
|
+
"""Append one JSONL activation record to TELEMETRY_PATH.
|
|
121
|
+
|
|
122
|
+
Silently drops when subagent_type is empty, fails the safe-id check,
|
|
123
|
+
or when filesystem I/O fails.
|
|
124
|
+
"""
|
|
125
|
+
if not subagent_type:
|
|
126
|
+
return
|
|
127
|
+
if _safe_id(subagent_type) is None:
|
|
128
|
+
return
|
|
129
|
+
record = asdict(Activation(
|
|
130
|
+
ts=datetime.now(timezone.utc).isoformat(),
|
|
131
|
+
subagent_type=subagent_type,
|
|
132
|
+
session_id=session_id,
|
|
133
|
+
))
|
|
134
|
+
try:
|
|
135
|
+
with _locked_append(TELEMETRY_PATH) as fh:
|
|
136
|
+
fh.write(json.dumps(record) + "\n")
|
|
137
|
+
except OSError:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def query_top_callers(
|
|
142
|
+
*,
|
|
143
|
+
limit: int = 10,
|
|
144
|
+
since: datetime | None = None,
|
|
145
|
+
) -> list[tuple[str, int]]:
|
|
146
|
+
"""Return the most-dispatched subagent types in descending count order.
|
|
147
|
+
|
|
148
|
+
Reads TELEMETRY_PATH, skips malformed lines silently. Optional `since`
|
|
149
|
+
filters to activations at or after that datetime. Capped at `limit`.
|
|
150
|
+
Empty store returns [].
|
|
151
|
+
"""
|
|
152
|
+
entries = _load_all()
|
|
153
|
+
entries = _filter_by_since(entries, since)
|
|
154
|
+
counter: Counter[str] = Counter()
|
|
155
|
+
for e in entries:
|
|
156
|
+
agent = e.get("subagent_type", "")
|
|
157
|
+
if agent:
|
|
158
|
+
counter[agent] += 1
|
|
159
|
+
return counter.most_common(limit)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def query_dead_agents(*, since_days: int = 30) -> list[tuple[str, str]]:
|
|
163
|
+
"""Return agents whose most-recent activation is older than `since_days`.
|
|
164
|
+
|
|
165
|
+
Returns [(subagent_type, last_seen_ts), ...] sorted ascending by
|
|
166
|
+
last_seen_ts (most-dormant first). Agents activated within the window
|
|
167
|
+
are excluded even if they have older activations too.
|
|
168
|
+
"""
|
|
169
|
+
cutoff = datetime.now(timezone.utc) - timedelta(days=since_days)
|
|
170
|
+
entries = _load_all()
|
|
171
|
+
recent_map = _most_recent_per_agent(entries)
|
|
172
|
+
dead: list[tuple[str, str]] = []
|
|
173
|
+
for agent, last_ts in recent_map.items():
|
|
174
|
+
ts = _parse_ts(last_ts)
|
|
175
|
+
if ts is not None and ts < cutoff:
|
|
176
|
+
dead.append((agent, last_ts))
|
|
177
|
+
dead.sort(key=lambda pair: pair[1])
|
|
178
|
+
return dead
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""CLI viewer for agent activation telemetry.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python -m core.governance.agent_activation_cli top [--since DAYS] [--limit N]
|
|
5
|
+
python -m core.governance.agent_activation_cli dead [--since-days N]
|
|
6
|
+
|
|
7
|
+
Examples:
|
|
8
|
+
python -m core.governance.agent_activation_cli top --limit 10
|
|
9
|
+
python -m core.governance.agent_activation_cli dead --since-days 30
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
from datetime import datetime, timedelta, timezone
|
|
17
|
+
|
|
18
|
+
from core.governance.activation_tracker import query_dead_agents, query_top_callers
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _since_dt(days: int | None) -> datetime | None:
|
|
22
|
+
if days is None:
|
|
23
|
+
return None
|
|
24
|
+
return datetime.now(timezone.utc) - timedelta(days=days)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _print_top(rows: list[tuple[str, int]]) -> int:
|
|
28
|
+
"""Print rank | subagent_type | call_count table."""
|
|
29
|
+
if not rows:
|
|
30
|
+
print("No activation records found.")
|
|
31
|
+
return 0
|
|
32
|
+
print(f"{'rank':>4} {'subagent_type':<36} {'calls':>6}")
|
|
33
|
+
print("-" * 50)
|
|
34
|
+
for rank, (agent, count) in enumerate(rows, start=1):
|
|
35
|
+
print(f"{rank:>4} {agent:<36} {count:>6}")
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_ts_for_display(iso: str) -> tuple[str, int]:
|
|
40
|
+
"""Return (formatted_ts, days_since) from an ISO string."""
|
|
41
|
+
try:
|
|
42
|
+
ts = datetime.fromisoformat(iso)
|
|
43
|
+
now = datetime.now(timezone.utc)
|
|
44
|
+
if ts.tzinfo is None:
|
|
45
|
+
ts = ts.replace(tzinfo=timezone.utc)
|
|
46
|
+
days_since = (now - ts).days
|
|
47
|
+
return ts.strftime("%Y-%m-%d %H:%M UTC"), days_since
|
|
48
|
+
except (TypeError, ValueError):
|
|
49
|
+
return iso, -1
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _print_dead(rows: list[tuple[str, str]]) -> int:
|
|
53
|
+
"""Print subagent_type | last_seen | days_since table."""
|
|
54
|
+
if not rows:
|
|
55
|
+
print("No dormant agents found.")
|
|
56
|
+
return 0
|
|
57
|
+
print(f"{'subagent_type':<36} {'last_seen':<22} {'days_since':>10}")
|
|
58
|
+
print("-" * 72)
|
|
59
|
+
for agent, last_ts in rows:
|
|
60
|
+
last_seen, days_since = _parse_ts_for_display(last_ts)
|
|
61
|
+
print(f"{agent:<36} {last_seen:<22} {days_since:>10}")
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _do_top(args: argparse.Namespace) -> int:
|
|
66
|
+
since = _since_dt(args.since)
|
|
67
|
+
rows = query_top_callers(limit=args.limit, since=since)
|
|
68
|
+
return _print_top(rows)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _do_dead(args: argparse.Namespace) -> int:
|
|
72
|
+
rows = query_dead_agents(since_days=args.since_days)
|
|
73
|
+
return _print_dead(rows)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
77
|
+
parser = argparse.ArgumentParser(
|
|
78
|
+
prog="python -m core.governance.agent_activation_cli",
|
|
79
|
+
description="Inspect agent activation telemetry.",
|
|
80
|
+
)
|
|
81
|
+
subparsers = parser.add_subparsers(dest="cmd", required=True)
|
|
82
|
+
|
|
83
|
+
top_p = subparsers.add_parser("top", help="Show most-dispatched agents by call count.")
|
|
84
|
+
top_p.add_argument("--since", type=int, default=None, help="Limit to last N days")
|
|
85
|
+
top_p.add_argument("--limit", type=int, default=10, help="Max records (default 10)")
|
|
86
|
+
|
|
87
|
+
dead_p = subparsers.add_parser("dead", help="Show agents with no activation in N days.")
|
|
88
|
+
dead_p.add_argument(
|
|
89
|
+
"--since-days", type=int, default=30, dest="since_days",
|
|
90
|
+
help="Dormancy threshold in days (default 30)",
|
|
91
|
+
)
|
|
92
|
+
return parser
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main(argv: list[str] | None = None) -> int:
|
|
96
|
+
parser = _build_parser()
|
|
97
|
+
args = parser.parse_args(argv if argv is not None else sys.argv[1:])
|
|
98
|
+
if args.cmd == "top":
|
|
99
|
+
return _do_top(args)
|
|
100
|
+
if args.cmd == "dead":
|
|
101
|
+
return _do_dead(args)
|
|
102
|
+
parser.print_help()
|
|
103
|
+
return 2
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__": # pragma: no cover
|
|
107
|
+
sys.exit(main())
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Design System Linter — PR6 Squad Intelligence Upgrade v3.77.0.
|
|
2
|
+
|
|
3
|
+
Scans a project for forbidden patterns declared in its design-system.yaml.
|
|
4
|
+
Opt-in per project: no YAML at root means no violations. Advisory-only in
|
|
5
|
+
v3.77.0; pre-commit hook integration lands in v3.77.x.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import asdict, dataclass, field # noqa: F401 (asdict kept for callers)
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Iterator
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ─── Dataclasses ──────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class DesignSystem:
|
|
22
|
+
"""Loaded design-system.yaml for a project."""
|
|
23
|
+
|
|
24
|
+
version: int = 1
|
|
25
|
+
project: str = ""
|
|
26
|
+
tokens: dict = field(default_factory=dict)
|
|
27
|
+
allowed_components: list[str] = field(default_factory=list)
|
|
28
|
+
file_globs: list[str] = field(default_factory=list)
|
|
29
|
+
forbidden_patterns: list[dict] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class DesignViolation:
|
|
34
|
+
file: str # relative to project_path, forward slashes
|
|
35
|
+
line: int # 1-indexed
|
|
36
|
+
pattern: str # the regex string from forbidden_patterns
|
|
37
|
+
suggestion: str # suggestion text from forbidden_patterns
|
|
38
|
+
matched_text: str # the actual matched substring (cap 200 chars)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ─── Internal helpers ─────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
def _default_file_globs() -> list[str]:
|
|
44
|
+
return ["**/*.vue", "**/*.tsx", "**/*.jsx"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _escape_glob_literal(s: str) -> str:
|
|
48
|
+
"""Escape a literal glob segment (no ** inside)."""
|
|
49
|
+
return re.escape(s).replace(r"\*", "[^/]*").replace(r"\?", "[^/]")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _glob_to_regex(glob_pattern: str) -> re.Pattern[str]:
|
|
53
|
+
"""Translate a glob pattern (with ** support) to a compiled regex.
|
|
54
|
+
|
|
55
|
+
Gitignore convention: ``**/`` = zero-or-more ``dir/`` prefixes (including
|
|
56
|
+
zero), so ``**/*.vue`` matches both ``App.vue`` and ``src/App.vue``.
|
|
57
|
+
Bare ``*`` = any non-slash run; ``?`` = one non-slash char.
|
|
58
|
+
"""
|
|
59
|
+
# Split on ** tokens, keeping them as delimiters.
|
|
60
|
+
tokens = re.split(r"(\*\*)", glob_pattern)
|
|
61
|
+
result = ""
|
|
62
|
+
for i, tok in enumerate(tokens):
|
|
63
|
+
if tok != "**":
|
|
64
|
+
result += _escape_glob_literal(tok)
|
|
65
|
+
continue
|
|
66
|
+
nxt = tokens[i + 1] if i + 1 < len(tokens) else ""
|
|
67
|
+
if nxt.startswith("/"):
|
|
68
|
+
# **/ → consume the slash, emit zero-or-more dir/ groups.
|
|
69
|
+
tokens[i + 1] = nxt[1:]
|
|
70
|
+
result += "(?:[^/]+/)*"
|
|
71
|
+
else:
|
|
72
|
+
# trailing ** or ** not followed by / → match any remaining path.
|
|
73
|
+
result += ".*"
|
|
74
|
+
return re.compile(f"^{result}$")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _glob_match(pattern: str, rel_path: str) -> bool:
|
|
78
|
+
"""Return True when rel_path (forward slashes) matches the glob pattern."""
|
|
79
|
+
try:
|
|
80
|
+
return bool(_glob_to_regex(pattern).match(rel_path))
|
|
81
|
+
except re.error:
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _iter_matching_files(
|
|
86
|
+
project_path: Path, file_globs: list[str]
|
|
87
|
+
) -> Iterator[Path]:
|
|
88
|
+
"""Yield all files under project_path matching any glob in file_globs."""
|
|
89
|
+
seen: set[Path] = set()
|
|
90
|
+
for glob in file_globs:
|
|
91
|
+
for path in project_path.rglob("*"):
|
|
92
|
+
if not path.is_file():
|
|
93
|
+
continue
|
|
94
|
+
rel = path.relative_to(project_path).as_posix()
|
|
95
|
+
if _glob_match(glob, rel) and path not in seen:
|
|
96
|
+
seen.add(path)
|
|
97
|
+
yield path
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_excluded(rel_path: str, exclude_paths: list[str]) -> bool:
|
|
101
|
+
"""Return True if rel_path matches any exclude_paths glob or is design-system.yaml."""
|
|
102
|
+
if rel_path == "design-system.yaml":
|
|
103
|
+
return True
|
|
104
|
+
return any(_glob_match(exc, rel_path) for exc in exclude_paths)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _scan_file_for_pattern(
|
|
108
|
+
file_path: Path,
|
|
109
|
+
project_path: Path,
|
|
110
|
+
compiled_re: re.Pattern[str],
|
|
111
|
+
pattern: str,
|
|
112
|
+
suggestion: str,
|
|
113
|
+
) -> Iterator[DesignViolation]:
|
|
114
|
+
"""Read one file and yield a DesignViolation for every regex match."""
|
|
115
|
+
rel = file_path.relative_to(project_path).as_posix()
|
|
116
|
+
try:
|
|
117
|
+
text = file_path.read_text(encoding="utf-8", errors="replace")
|
|
118
|
+
except OSError:
|
|
119
|
+
return
|
|
120
|
+
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
121
|
+
for match in compiled_re.finditer(line):
|
|
122
|
+
yield DesignViolation(
|
|
123
|
+
file=rel,
|
|
124
|
+
line=lineno,
|
|
125
|
+
pattern=pattern,
|
|
126
|
+
suggestion=suggestion,
|
|
127
|
+
matched_text=match.group(0)[:200],
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ─── Public API ───────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
def load_design_system(project_path: Path) -> DesignSystem | None:
|
|
134
|
+
"""Load design-system.yaml from project_path root.
|
|
135
|
+
|
|
136
|
+
Returns None when the file is absent or malformed.
|
|
137
|
+
"""
|
|
138
|
+
yaml_path = project_path / "design-system.yaml"
|
|
139
|
+
if not yaml_path.is_file():
|
|
140
|
+
return None
|
|
141
|
+
try:
|
|
142
|
+
raw = yaml.safe_load(yaml_path.read_text(encoding="utf-8"))
|
|
143
|
+
except (OSError, yaml.YAMLError):
|
|
144
|
+
return None
|
|
145
|
+
if not isinstance(raw, dict):
|
|
146
|
+
return None
|
|
147
|
+
return DesignSystem(
|
|
148
|
+
version=int(raw.get("version") or 1),
|
|
149
|
+
project=str(raw.get("project") or ""),
|
|
150
|
+
tokens=dict(raw.get("tokens") or {}),
|
|
151
|
+
allowed_components=list(raw.get("allowed_components") or []),
|
|
152
|
+
file_globs=list(raw.get("file_globs") or []),
|
|
153
|
+
forbidden_patterns=list(raw.get("forbidden_patterns") or []),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def lint_project(project_path: Path) -> list[DesignViolation]:
|
|
158
|
+
"""Scan project_path for design-system violations.
|
|
159
|
+
|
|
160
|
+
Returns an empty list when the project path does not exist, has no
|
|
161
|
+
design-system.yaml, or the YAML is malformed.
|
|
162
|
+
"""
|
|
163
|
+
if not project_path.is_dir():
|
|
164
|
+
return []
|
|
165
|
+
ds = load_design_system(project_path)
|
|
166
|
+
if ds is None:
|
|
167
|
+
return []
|
|
168
|
+
globs = ds.file_globs if ds.file_globs else _default_file_globs()
|
|
169
|
+
violations: list[DesignViolation] = []
|
|
170
|
+
for fp_dict in ds.forbidden_patterns:
|
|
171
|
+
if not isinstance(fp_dict, dict):
|
|
172
|
+
continue
|
|
173
|
+
_collect_pattern_violations(project_path, globs, fp_dict, violations)
|
|
174
|
+
return violations
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _collect_pattern_violations(
|
|
178
|
+
project_path: Path,
|
|
179
|
+
file_globs: list[str],
|
|
180
|
+
fp_dict: dict,
|
|
181
|
+
violations: list[DesignViolation],
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Compile one forbidden pattern and append matching violations in-place."""
|
|
184
|
+
raw_pattern = fp_dict.get("pattern", "")
|
|
185
|
+
suggestion = fp_dict.get("suggestion", "")
|
|
186
|
+
exclude_paths: list[str] = list(fp_dict.get("exclude_paths") or [])
|
|
187
|
+
try:
|
|
188
|
+
compiled = re.compile(raw_pattern)
|
|
189
|
+
except re.error:
|
|
190
|
+
return
|
|
191
|
+
for file_path in _iter_matching_files(project_path, file_globs):
|
|
192
|
+
rel = file_path.relative_to(project_path).as_posix()
|
|
193
|
+
if _is_excluded(rel, exclude_paths):
|
|
194
|
+
continue
|
|
195
|
+
violations.extend(
|
|
196
|
+
_scan_file_for_pattern(file_path, project_path, compiled, raw_pattern, suggestion)
|
|
197
|
+
)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""CLI for the Design System Linter (PR6 Squad Intelligence Upgrade v3.77.0).
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python -m core.governance.design_system_lint_cli <project_path> [--format text|json] [--exit-on-violations]
|
|
5
|
+
|
|
6
|
+
Examples:
|
|
7
|
+
python -m core.governance.design_system_lint_cli /path/to/project
|
|
8
|
+
python -m core.governance.design_system_lint_cli /path/to/project --format json --exit-on-violations
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from collections import defaultdict
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from core.governance.design_system_lint import (
|
|
20
|
+
DesignViolation,
|
|
21
|
+
lint_project,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
prog="python -m core.governance.design_system_lint_cli",
|
|
28
|
+
description="Scan a project for design-system violations.",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument("project_path", help="Path to the project root containing design-system.yaml.")
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--format",
|
|
33
|
+
choices=["text", "json"],
|
|
34
|
+
default="text",
|
|
35
|
+
help="Output format (default: text).",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--exit-on-violations",
|
|
39
|
+
action="store_true",
|
|
40
|
+
help="Exit with code 1 when violations are found.",
|
|
41
|
+
)
|
|
42
|
+
return parser
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _print_text(violations: list[DesignViolation]) -> None:
|
|
46
|
+
"""Group violations by file, sorted by file then line, and pretty-print."""
|
|
47
|
+
print(f"{len(violations)} design-system violation(s) found:")
|
|
48
|
+
by_file: dict[str, list[DesignViolation]] = defaultdict(list)
|
|
49
|
+
for v in violations:
|
|
50
|
+
by_file[v.file].append(v)
|
|
51
|
+
for file in sorted(by_file):
|
|
52
|
+
for v in sorted(by_file[file], key=lambda x: x.line):
|
|
53
|
+
truncated = v.matched_text[:60] + ("..." if len(v.matched_text) > 60 else "")
|
|
54
|
+
print(f" {v.file}:{v.line} {truncated}")
|
|
55
|
+
print(f" → {v.suggestion}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _print_json(violations: list[DesignViolation]) -> None:
|
|
59
|
+
"""Emit one JSON line per violation followed by a summary line (jsonl)."""
|
|
60
|
+
for v in violations:
|
|
61
|
+
print(json.dumps({
|
|
62
|
+
"file": v.file,
|
|
63
|
+
"line": v.line,
|
|
64
|
+
"pattern": v.pattern,
|
|
65
|
+
"suggestion": v.suggestion,
|
|
66
|
+
"matched_text": v.matched_text,
|
|
67
|
+
}))
|
|
68
|
+
print(json.dumps({"summary": True, "count": len(violations)}))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main(argv: list[str] | None = None) -> int:
|
|
72
|
+
parser = _build_parser()
|
|
73
|
+
args = parser.parse_args(argv if argv is not None else sys.argv[1:])
|
|
74
|
+
violations = lint_project(Path(args.project_path))
|
|
75
|
+
|
|
76
|
+
if not violations:
|
|
77
|
+
if args.format == "json":
|
|
78
|
+
print(json.dumps({"violations": [], "count": 0}))
|
|
79
|
+
else:
|
|
80
|
+
print("No design-system violations.")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
if args.format == "json":
|
|
84
|
+
_print_json(violations)
|
|
85
|
+
else:
|
|
86
|
+
_print_text(violations)
|
|
87
|
+
|
|
88
|
+
return 1 if args.exit_on_violations else 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__": # pragma: no cover
|
|
92
|
+
sys.exit(main())
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""DNA Fidelity Checker — PR5 Squad Intelligence Upgrade v3.76.0.
|
|
2
|
+
|
|
3
|
+
Compares agent output text against the `signature_markers` block declared
|
|
4
|
+
in the agent's YAML. Detects forbidden patterns (avoid_patterns) and missing
|
|
5
|
+
opening phrases, then records violations to telemetry.
|
|
6
|
+
|
|
7
|
+
Soft-warn mode for v1. Hard-block mode lands in a later PR once telemetry
|
|
8
|
+
shows the marker set is stable. Never raises — hook helpers must not break
|
|
9
|
+
the execution path.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from dataclasses import asdict, dataclass, field
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from functools import lru_cache
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
from core.shared import safe_session_id as _safe_session_id_module
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
import fcntl
|
|
28
|
+
_HAS_FLOCK = True
|
|
29
|
+
except ImportError:
|
|
30
|
+
_HAS_FLOCK = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ─── Module-level constants (monkeypatched by tests) ──────────────────────────
|
|
34
|
+
|
|
35
|
+
AGENT_YAML_SEARCH_DIRS: list[Path] = [
|
|
36
|
+
Path(__file__).resolve().parent.parent.parent / "departments",
|
|
37
|
+
]
|
|
38
|
+
TELEMETRY_PATH: Path = Path.home() / ".arkaos" / "telemetry" / "dna-fidelity.jsonl"
|
|
39
|
+
|
|
40
|
+
_OPENING_WINDOW: int = 300
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ─── Dataclasses ──────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class SignatureMarkers:
|
|
47
|
+
opening_phrases: list[str] = field(default_factory=list)
|
|
48
|
+
typical_patterns: list[str] = field(default_factory=list)
|
|
49
|
+
closing_style: str | None = None
|
|
50
|
+
avoid_patterns: list[str] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class FidelityViolation:
|
|
55
|
+
kind: str # "forbidden_pattern" | "missing_opening"
|
|
56
|
+
pattern: str
|
|
57
|
+
span: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ─── Internal helpers ─────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
def _register_yaml_entry(index: dict[str, Path], path: Path, data: dict) -> None:
|
|
63
|
+
"""Add all lookup keys for one agent YAML into the shared index dict.
|
|
64
|
+
|
|
65
|
+
Keys registered per file:
|
|
66
|
+
- ``data["id"].lower()`` e.g. ``tech-lead-paulo``
|
|
67
|
+
- ``data["name"].lower()`` e.g. ``paulo``
|
|
68
|
+
- last hyphen-separated segment of id e.g. ``paulo`` (deduped)
|
|
69
|
+
"""
|
|
70
|
+
agent_id: str | None = data.get("id")
|
|
71
|
+
if agent_id and isinstance(agent_id, str):
|
|
72
|
+
key = agent_id.lower()
|
|
73
|
+
index.setdefault(key, path)
|
|
74
|
+
suffix = key.rsplit("-", 1)[-1]
|
|
75
|
+
index.setdefault(suffix, path)
|
|
76
|
+
name: str | None = data.get("name")
|
|
77
|
+
if name and isinstance(name, str):
|
|
78
|
+
index.setdefault(name.lower(), path)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@lru_cache(maxsize=1)
|
|
82
|
+
def _index_agents() -> dict[str, Path]:
|
|
83
|
+
"""Walk AGENT_YAML_SEARCH_DIRS once and build a name/id → path index.
|
|
84
|
+
|
|
85
|
+
Three keys per YAML (id, name, id-suffix) allow callers to pass the
|
|
86
|
+
short persona name (``paulo``) instead of the full id (``tech-lead-paulo``).
|
|
87
|
+
"""
|
|
88
|
+
index: dict[str, Path] = {}
|
|
89
|
+
for search_dir in AGENT_YAML_SEARCH_DIRS:
|
|
90
|
+
if not search_dir.is_dir():
|
|
91
|
+
continue
|
|
92
|
+
for candidate in search_dir.rglob("*.yaml"):
|
|
93
|
+
try:
|
|
94
|
+
data = yaml.safe_load(candidate.read_text(encoding="utf-8"))
|
|
95
|
+
except (OSError, yaml.YAMLError):
|
|
96
|
+
continue
|
|
97
|
+
if isinstance(data, dict):
|
|
98
|
+
_register_yaml_entry(index, candidate, data)
|
|
99
|
+
return index
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _yaml_path_for(agent_id: str) -> Path | None:
|
|
103
|
+
"""Resolve an agent persona name to its YAML path via the index.
|
|
104
|
+
|
|
105
|
+
Returns None when agent_id fails the safe-session-id check (CWE-22
|
|
106
|
+
hardening) or when no matching YAML is found.
|
|
107
|
+
"""
|
|
108
|
+
if _safe_session_id_module.safe_session_id(agent_id) is None:
|
|
109
|
+
return None
|
|
110
|
+
return _index_agents().get(agent_id.lower())
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@lru_cache(maxsize=128)
|
|
114
|
+
def _load_markers(agent_id: str) -> SignatureMarkers | None:
|
|
115
|
+
"""Load and parse signature_markers from an agent YAML. Cached per process."""
|
|
116
|
+
path = _yaml_path_for(agent_id)
|
|
117
|
+
if path is None:
|
|
118
|
+
return None
|
|
119
|
+
try:
|
|
120
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
121
|
+
except (OSError, yaml.YAMLError):
|
|
122
|
+
return None
|
|
123
|
+
if not isinstance(raw, dict):
|
|
124
|
+
return None
|
|
125
|
+
block = raw.get("signature_markers")
|
|
126
|
+
if not block or not isinstance(block, dict):
|
|
127
|
+
return None
|
|
128
|
+
return SignatureMarkers(
|
|
129
|
+
opening_phrases=block.get("opening_phrases") or [],
|
|
130
|
+
typical_patterns=block.get("typical_patterns") or [],
|
|
131
|
+
closing_style=block.get("closing_style"),
|
|
132
|
+
avoid_patterns=block.get("avoid_patterns") or [],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# Chain cache invalidation so existing callers of `_load_markers.cache_clear()`
|
|
137
|
+
# also flush the agent index (both are file-system snapshots; stale index
|
|
138
|
+
# would return wrong paths after AGENT_YAML_SEARCH_DIRS is monkeypatched).
|
|
139
|
+
_original_load_markers_cache_clear = _load_markers.cache_clear
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _chained_cache_clear() -> None:
|
|
143
|
+
_index_agents.cache_clear()
|
|
144
|
+
_original_load_markers_cache_clear()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
_load_markers.cache_clear = _chained_cache_clear # type: ignore[method-assign]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _search_avoid_patterns(
|
|
151
|
+
output: str, patterns: list[str]
|
|
152
|
+
) -> list[FidelityViolation]:
|
|
153
|
+
"""Return one FidelityViolation per avoid_pattern that matches output."""
|
|
154
|
+
violations: list[FidelityViolation] = []
|
|
155
|
+
for pattern in patterns:
|
|
156
|
+
match = re.search(pattern, output, re.IGNORECASE)
|
|
157
|
+
if match:
|
|
158
|
+
violations.append(
|
|
159
|
+
FidelityViolation(
|
|
160
|
+
kind="forbidden_pattern",
|
|
161
|
+
pattern=pattern,
|
|
162
|
+
span=match.group(0),
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
return violations
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _check_opening(
|
|
169
|
+
output: str, opening_phrases: list[str]
|
|
170
|
+
) -> FidelityViolation | None:
|
|
171
|
+
"""Return a violation when none of the opening phrases appear near the top."""
|
|
172
|
+
if not opening_phrases:
|
|
173
|
+
return None
|
|
174
|
+
window = output[:_OPENING_WINDOW]
|
|
175
|
+
for phrase in opening_phrases:
|
|
176
|
+
if re.search(re.escape(phrase), window, re.IGNORECASE):
|
|
177
|
+
return None
|
|
178
|
+
return FidelityViolation(
|
|
179
|
+
kind="missing_opening",
|
|
180
|
+
pattern=", ".join(opening_phrases),
|
|
181
|
+
span=output[:80],
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@contextmanager
|
|
186
|
+
def _locked_append(path: Path):
|
|
187
|
+
"""Append to path under POSIX flock; Windows falls back to O_APPEND atomicity."""
|
|
188
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
fh = path.open("a", encoding="utf-8")
|
|
190
|
+
try:
|
|
191
|
+
if _HAS_FLOCK:
|
|
192
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
|
193
|
+
yield fh
|
|
194
|
+
finally:
|
|
195
|
+
if _HAS_FLOCK:
|
|
196
|
+
try:
|
|
197
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
|
198
|
+
except OSError:
|
|
199
|
+
pass
|
|
200
|
+
fh.close()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ─── Public API ───────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
def check_fidelity(agent_id: str, output: str) -> list[FidelityViolation]:
|
|
206
|
+
"""Compare output against the agent's signature_markers.
|
|
207
|
+
|
|
208
|
+
Returns a list of FidelityViolation. Empty list means clean pass or
|
|
209
|
+
no markers defined for the agent.
|
|
210
|
+
"""
|
|
211
|
+
markers = _load_markers(agent_id)
|
|
212
|
+
if markers is None:
|
|
213
|
+
return []
|
|
214
|
+
violations: list[FidelityViolation] = []
|
|
215
|
+
violations.extend(_search_avoid_patterns(output, markers.avoid_patterns))
|
|
216
|
+
opening_violation = _check_opening(output, markers.opening_phrases)
|
|
217
|
+
if opening_violation is not None:
|
|
218
|
+
violations.append(opening_violation)
|
|
219
|
+
return violations
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def record_fidelity(
|
|
223
|
+
agent_id: str,
|
|
224
|
+
session_id: str,
|
|
225
|
+
violations: list[FidelityViolation],
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Append one JSONL telemetry record for this fidelity check.
|
|
228
|
+
|
|
229
|
+
Silently drops when agent_id is unsafe or filesystem I/O fails.
|
|
230
|
+
Zero violations are still recorded — absence of violations is signal too.
|
|
231
|
+
"""
|
|
232
|
+
safe_id = _safe_session_id_module.safe_session_id(agent_id)
|
|
233
|
+
if safe_id is None:
|
|
234
|
+
return
|
|
235
|
+
record = {
|
|
236
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
237
|
+
"agent_id": agent_id,
|
|
238
|
+
"session_id": session_id,
|
|
239
|
+
"violation_count": len(violations),
|
|
240
|
+
"violations": [asdict(v) for v in violations],
|
|
241
|
+
}
|
|
242
|
+
try:
|
|
243
|
+
with _locked_append(TELEMETRY_PATH) as fh:
|
|
244
|
+
fh.write(json.dumps(record) + "\n")
|
|
245
|
+
except OSError:
|
|
246
|
+
return
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""CLI viewer for DNA fidelity telemetry.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python -m core.governance.dna_fidelity_cli list [--agent AGENT_ID] [--since DAYS] [--limit N]
|
|
5
|
+
python -m core.governance.dna_fidelity_cli summary [--since DAYS]
|
|
6
|
+
|
|
7
|
+
Examples:
|
|
8
|
+
python -m core.governance.dna_fidelity_cli list --limit 10
|
|
9
|
+
python -m core.governance.dna_fidelity_cli list --agent tech-lead-paulo --since 7
|
|
10
|
+
python -m core.governance.dna_fidelity_cli summary --since 30
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from datetime import datetime, timedelta, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
_TELEMETRY_PATH: Path = Path.home() / ".arkaos" / "telemetry" / "dna-fidelity.jsonl"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_records(since_dt: datetime | None, agent: str | None) -> list[dict]:
|
|
25
|
+
"""Read JSONL, filter by agent and since, skip malformed lines silently."""
|
|
26
|
+
results: list[dict] = []
|
|
27
|
+
try:
|
|
28
|
+
with _TELEMETRY_PATH.open(encoding="utf-8") as fh:
|
|
29
|
+
for line in fh:
|
|
30
|
+
if not line.strip():
|
|
31
|
+
continue
|
|
32
|
+
try:
|
|
33
|
+
rec = json.loads(line)
|
|
34
|
+
except json.JSONDecodeError:
|
|
35
|
+
continue
|
|
36
|
+
if agent and rec.get("agent_id") != agent:
|
|
37
|
+
continue
|
|
38
|
+
if since_dt is not None:
|
|
39
|
+
ts = _parse_ts(rec.get("ts", ""))
|
|
40
|
+
if ts is None or ts < since_dt:
|
|
41
|
+
continue
|
|
42
|
+
results.append(rec)
|
|
43
|
+
except OSError:
|
|
44
|
+
pass
|
|
45
|
+
results.sort(key=lambda r: r.get("ts", ""), reverse=True)
|
|
46
|
+
return results
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parse_ts(value: str) -> datetime | None:
|
|
50
|
+
"""Parse ISO timestamp; return None on failure."""
|
|
51
|
+
try:
|
|
52
|
+
return datetime.fromisoformat(value)
|
|
53
|
+
except (TypeError, ValueError):
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _since_dt(days: int | None) -> datetime | None:
|
|
58
|
+
if days is None:
|
|
59
|
+
return None
|
|
60
|
+
return datetime.now(timezone.utc) - timedelta(days=days)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _format_record(rec: dict, index: int) -> str:
|
|
64
|
+
"""Format a single fidelity record for list output."""
|
|
65
|
+
ts = rec.get("ts", "?")
|
|
66
|
+
agent = rec.get("agent_id", "?")
|
|
67
|
+
vcount = rec.get("violation_count", 0)
|
|
68
|
+
label = "CLEAN" if vcount == 0 else f"VIOLATIONS({vcount})"
|
|
69
|
+
lines = [f" [{index}] {ts} {agent} {label}"]
|
|
70
|
+
for v in (rec.get("violations") or [])[:3]:
|
|
71
|
+
lines.append(f" - {v.get('kind')} | {v.get('pattern')}")
|
|
72
|
+
return "\n".join(lines)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _do_list(args: argparse.Namespace) -> int:
|
|
76
|
+
"""List fidelity records, most recent first."""
|
|
77
|
+
since = _since_dt(args.since)
|
|
78
|
+
records = _load_records(since, args.agent)
|
|
79
|
+
records = records[: args.limit]
|
|
80
|
+
if not records:
|
|
81
|
+
print("No records found.")
|
|
82
|
+
return 0
|
|
83
|
+
scope = f"agent={args.agent}" if args.agent else "all agents"
|
|
84
|
+
print(f"DNA fidelity ({len(records)} record(s), most recent first) [{scope}]:\n")
|
|
85
|
+
for i, rec in enumerate(records, start=1):
|
|
86
|
+
print(_format_record(rec, i))
|
|
87
|
+
print()
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _do_summary(args: argparse.Namespace) -> int:
|
|
92
|
+
"""Aggregate by agent_id, show violation rate sorted descending."""
|
|
93
|
+
since = _since_dt(args.since)
|
|
94
|
+
records = _load_records(since, agent=None)
|
|
95
|
+
if not records:
|
|
96
|
+
print("No records found.")
|
|
97
|
+
return 0
|
|
98
|
+
totals: dict[str, int] = {}
|
|
99
|
+
violations: dict[str, int] = {}
|
|
100
|
+
for rec in records:
|
|
101
|
+
aid = rec.get("agent_id", "?")
|
|
102
|
+
totals[aid] = totals.get(aid, 0) + 1
|
|
103
|
+
if rec.get("violation_count", 0) > 0:
|
|
104
|
+
violations[aid] = violations.get(aid, 0) + 1
|
|
105
|
+
rows = sorted(
|
|
106
|
+
totals.keys(),
|
|
107
|
+
key=lambda a: violations.get(a, 0) / totals[a],
|
|
108
|
+
reverse=True,
|
|
109
|
+
)
|
|
110
|
+
print(f"{'agent':<32} {'total':>6} {'violations':>10} {'rate':>8}")
|
|
111
|
+
print("-" * 62)
|
|
112
|
+
for aid in rows:
|
|
113
|
+
total = totals[aid]
|
|
114
|
+
viol = violations.get(aid, 0)
|
|
115
|
+
rate = viol / total * 100
|
|
116
|
+
print(f"{aid:<32} {total:>6} {viol:>10} {rate:>7.1f}%")
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
121
|
+
parser = argparse.ArgumentParser(
|
|
122
|
+
prog="python -m core.governance.dna_fidelity_cli",
|
|
123
|
+
description="Inspect DNA fidelity telemetry records.",
|
|
124
|
+
)
|
|
125
|
+
subparsers = parser.add_subparsers(dest="cmd", required=True)
|
|
126
|
+
|
|
127
|
+
list_p = subparsers.add_parser("list", help="List fidelity records, most recent first.")
|
|
128
|
+
list_p.add_argument("--agent", default=None, help="Filter by agent_id")
|
|
129
|
+
list_p.add_argument("--since", type=int, default=None, help="Show records from last N days")
|
|
130
|
+
list_p.add_argument("--limit", type=int, default=20, help="Max records (default 20)")
|
|
131
|
+
|
|
132
|
+
summary_p = subparsers.add_parser("summary", help="Aggregate violation rate per agent.")
|
|
133
|
+
summary_p.add_argument("--since", type=int, default=None, help="Aggregate last N days only")
|
|
134
|
+
return parser
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main(argv: list[str] | None = None) -> int:
|
|
138
|
+
parser = _build_parser()
|
|
139
|
+
args = parser.parse_args(argv if argv is not None else sys.argv[1:])
|
|
140
|
+
if args.cmd == "list":
|
|
141
|
+
return _do_list(args)
|
|
142
|
+
if args.cmd == "summary":
|
|
143
|
+
return _do_summary(args)
|
|
144
|
+
parser.print_help()
|
|
145
|
+
return 2
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__": # pragma: no cover
|
|
149
|
+
sys.exit(main())
|
|
Binary file
|
|
@@ -71,3 +71,21 @@ communication:
|
|
|
71
71
|
avoid:
|
|
72
72
|
- "blame language"
|
|
73
73
|
- "vague task assignments"
|
|
74
|
+
|
|
75
|
+
# PR5 v3.76.0 — DNA fidelity markers (soft-warn). Regex case-insensitive.
|
|
76
|
+
# Paulo (lead, supportive style) does not gate on opening phrases — only
|
|
77
|
+
# detects sycophant/placeholder language that contradicts his DISC=I
|
|
78
|
+
# servant-leader tone.
|
|
79
|
+
signature_markers:
|
|
80
|
+
opening_phrases: []
|
|
81
|
+
typical_patterns:
|
|
82
|
+
- "vamos"
|
|
83
|
+
- "alinhamos"
|
|
84
|
+
- "OK"
|
|
85
|
+
closing_style: null
|
|
86
|
+
avoid_patterns:
|
|
87
|
+
- "you're absolutely right"
|
|
88
|
+
- "amazing work"
|
|
89
|
+
- "great job, everyone"
|
|
90
|
+
- "I appreciate your patience"
|
|
91
|
+
- "thanks for understanding"
|
|
@@ -72,3 +72,26 @@ communication:
|
|
|
72
72
|
- "approving text with known issues"
|
|
73
73
|
- "subjective opinions without evidence"
|
|
74
74
|
- "AI cliches: leverage, utilize, robust, streamline, cutting-edge"
|
|
75
|
+
|
|
76
|
+
# PR5 v3.76.0 — DNA fidelity markers (soft-warn). Regex case-insensitive.
|
|
77
|
+
signature_markers:
|
|
78
|
+
opening_phrases:
|
|
79
|
+
- "Copy & Language"
|
|
80
|
+
- "Reviewed"
|
|
81
|
+
typical_patterns:
|
|
82
|
+
- "PASS"
|
|
83
|
+
- "FAIL"
|
|
84
|
+
- "pt-PT"
|
|
85
|
+
- "AI cliche"
|
|
86
|
+
closing_style: null
|
|
87
|
+
avoid_patterns:
|
|
88
|
+
- "delve into"
|
|
89
|
+
- "tapestry"
|
|
90
|
+
- "in today's fast-paced"
|
|
91
|
+
- "navigate the landscape"
|
|
92
|
+
- "underscore"
|
|
93
|
+
- "leverage"
|
|
94
|
+
- "utilize"
|
|
95
|
+
- "robust"
|
|
96
|
+
- "streamline"
|
|
97
|
+
- "cutting-edge"
|
|
@@ -75,3 +75,23 @@ communication:
|
|
|
75
75
|
- "ambiguous quality feedback"
|
|
76
76
|
- "soft approvals with caveats"
|
|
77
77
|
- "skipping review steps"
|
|
78
|
+
|
|
79
|
+
# PR5 v3.76.0 — DNA fidelity markers (soft-warn). Regex case-insensitive.
|
|
80
|
+
signature_markers:
|
|
81
|
+
opening_phrases:
|
|
82
|
+
- "Quality Gate Verdict:"
|
|
83
|
+
- "Verdict:"
|
|
84
|
+
typical_patterns:
|
|
85
|
+
- "REJECTED"
|
|
86
|
+
- "APPROVED"
|
|
87
|
+
- "Final:"
|
|
88
|
+
- "does not"
|
|
89
|
+
closing_style: "Final:"
|
|
90
|
+
avoid_patterns:
|
|
91
|
+
- "you're absolutely right"
|
|
92
|
+
- "I appreciate your"
|
|
93
|
+
- "happy to help"
|
|
94
|
+
- "great question"
|
|
95
|
+
- "let me know if"
|
|
96
|
+
- "softening"
|
|
97
|
+
- "no offense"
|
|
@@ -78,3 +78,24 @@ communication:
|
|
|
78
78
|
- "vague feedback without specific location"
|
|
79
79
|
- "approving with known technical debt"
|
|
80
80
|
- "skipping test coverage check"
|
|
81
|
+
|
|
82
|
+
# PR5 v3.76.0 — DNA fidelity markers (soft-warn). Regex case-insensitive.
|
|
83
|
+
signature_markers:
|
|
84
|
+
opening_phrases:
|
|
85
|
+
- "Technical & UX"
|
|
86
|
+
- "Technical & UX Quality"
|
|
87
|
+
typical_patterns:
|
|
88
|
+
- "B1."
|
|
89
|
+
- "M1."
|
|
90
|
+
- "PASS"
|
|
91
|
+
- "FAIL"
|
|
92
|
+
- "30-line"
|
|
93
|
+
closing_style: null
|
|
94
|
+
avoid_patterns:
|
|
95
|
+
- "I think"
|
|
96
|
+
- "I believe"
|
|
97
|
+
- "perhaps"
|
|
98
|
+
- "kind of"
|
|
99
|
+
- "sort of"
|
|
100
|
+
- "might be a"
|
|
101
|
+
- "could be a problem"
|
package/package.json
CHANGED