@event4u/agent-config 1.13.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent-src/commands/agent-handoff.md +3 -0
- package/.agent-src/commands/agent-status.md +3 -0
- package/.agent-src/commands/agents-audit.md +4 -0
- package/.agent-src/commands/agents-cleanup.md +6 -1
- package/.agent-src/commands/agents-prepare.md +3 -0
- package/.agent-src/commands/analyze-reference-repo.md +4 -0
- package/.agent-src/commands/bug-fix.md +5 -1
- package/.agent-src/commands/bug-investigate.md +4 -0
- package/.agent-src/commands/chat-history-checkpoint.md +126 -0
- package/.agent-src/commands/chat-history-clear.md +5 -0
- package/.agent-src/commands/chat-history-resume.md +5 -0
- package/.agent-src/commands/chat-history.md +5 -0
- package/.agent-src/commands/check-current-md.md +126 -0
- package/.agent-src/commands/commit-in-chunks.md +98 -0
- package/.agent-src/commands/commit.md +4 -0
- package/.agent-src/commands/compress.md +3 -0
- package/.agent-src/commands/context-create.md +4 -0
- package/.agent-src/commands/context-refactor.md +4 -0
- package/.agent-src/commands/copilot-agents-init.md +3 -0
- package/.agent-src/commands/copilot-agents-optimize.md +3 -0
- package/.agent-src/commands/create-pr-description.md +4 -0
- package/.agent-src/commands/create-pr.md +4 -0
- package/.agent-src/commands/do-and-judge.md +4 -1
- package/.agent-src/commands/do-in-steps.md +3 -0
- package/.agent-src/commands/e2e-heal.md +4 -0
- package/.agent-src/commands/e2e-plan.md +4 -0
- package/.agent-src/commands/estimate-ticket.md +4 -1
- package/.agent-src/commands/feature-dev.md +4 -0
- package/.agent-src/commands/feature-explore.md +4 -0
- package/.agent-src/commands/feature-plan.md +4 -0
- package/.agent-src/commands/feature-refactor.md +4 -0
- package/.agent-src/commands/feature-roadmap.md +6 -0
- package/.agent-src/commands/fix-ci.md +4 -0
- package/.agent-src/commands/fix-portability.md +3 -0
- package/.agent-src/commands/fix-pr-bot-comments.md +4 -0
- package/.agent-src/commands/fix-pr-comments.md +4 -0
- package/.agent-src/commands/fix-pr-developer-comments.md +4 -0
- package/.agent-src/commands/fix-references.md +3 -0
- package/.agent-src/commands/fix-seeder.md +4 -0
- package/.agent-src/commands/implement-ticket.md +39 -13
- package/.agent-src/commands/jira-ticket.md +4 -0
- package/.agent-src/commands/judge.md +3 -0
- package/.agent-src/commands/memory-add.md +5 -3
- package/.agent-src/commands/memory-full.md +5 -2
- package/.agent-src/commands/memory-promote.md +7 -6
- package/.agent-src/commands/mode.md +3 -0
- package/.agent-src/commands/module-create.md +4 -0
- package/.agent-src/commands/module-explore.md +4 -0
- package/.agent-src/commands/onboard.md +24 -0
- package/.agent-src/commands/optimize-agents.md +4 -0
- package/.agent-src/commands/optimize-augmentignore.md +3 -0
- package/.agent-src/commands/optimize-rtk-filters.md +3 -0
- package/.agent-src/commands/optimize-skills.md +4 -0
- package/.agent-src/commands/override-create.md +4 -0
- package/.agent-src/commands/override-manage.md +4 -0
- package/.agent-src/commands/package-reset.md +3 -0
- package/.agent-src/commands/package-test.md +3 -0
- package/.agent-src/commands/prepare-for-review.md +4 -0
- package/.agent-src/commands/project-analyze.md +4 -0
- package/.agent-src/commands/project-health.md +4 -0
- package/.agent-src/commands/propose-memory.md +6 -8
- package/.agent-src/commands/quality-fix.md +4 -0
- package/.agent-src/commands/refine-ticket.md +4 -1
- package/.agent-src/commands/review-changes.md +4 -0
- package/.agent-src/commands/review-routing.md +4 -0
- package/.agent-src/commands/roadmap-create.md +7 -0
- package/.agent-src/commands/roadmap-execute.md +12 -1
- package/.agent-src/commands/rule-compliance-audit.md +4 -0
- package/.agent-src/commands/set-cost-profile.md +3 -0
- package/.agent-src/commands/sync-agent-settings.md +3 -0
- package/.agent-src/commands/sync-gitignore.md +3 -0
- package/.agent-src/commands/tests-create.md +4 -0
- package/.agent-src/commands/tests-execute.md +4 -0
- package/.agent-src/commands/threat-model.md +4 -0
- package/.agent-src/commands/update-form-request-messages.md +4 -0
- package/.agent-src/commands/upstream-contribute.md +4 -0
- package/.agent-src/commands/work.md +161 -0
- package/.agent-src/guidelines/agent-infra/engineering-memory-data-format.md +2 -6
- package/.agent-src/guidelines/agent-infra/layered-settings.md +0 -1
- package/.agent-src/guidelines/agent-infra/memory-access.md +0 -7
- package/.agent-src/guidelines/agent-infra/role-contracts.md +2 -4
- package/.agent-src/guidelines/agent-infra/self-improvement-pipeline.md +0 -1
- package/.agent-src/guidelines/php/patterns/strategy.md +180 -2
- package/.agent-src/personas/README.md +0 -1
- package/.agent-src/rules/artifact-drafting-protocol.md +7 -2
- package/.agent-src/rules/artifact-engagement-recording.md +133 -0
- package/.agent-src/rules/ask-when-uncertain.md +18 -13
- package/.agent-src/rules/augment-portability.md +8 -0
- package/.agent-src/rules/autonomous-execution.md +158 -0
- package/.agent-src/rules/chat-history.md +147 -118
- package/.agent-src/rules/cli-output-handling.md +26 -3
- package/.agent-src/rules/command-suggestion.md +133 -0
- package/.agent-src/rules/commit-policy.md +99 -0
- package/.agent-src/rules/direct-answers.md +114 -0
- package/.agent-src/rules/docs-sync.md +36 -0
- package/.agent-src/rules/downstream-changes.md +10 -9
- package/.agent-src/rules/improve-before-implement.md +9 -6
- package/.agent-src/rules/language-and-tone.md +81 -6
- package/.agent-src/rules/non-destructive-by-default.md +117 -0
- package/.agent-src/rules/package-ci-checks.md +4 -0
- package/.agent-src/rules/preservation-guard.md +20 -0
- package/.agent-src/rules/roadmap-progress-sync.md +103 -30
- package/.agent-src/rules/scope-control.md +42 -1
- package/.agent-src/rules/size-enforcement.md +1 -3
- package/.agent-src/rules/skill-quality.md +3 -8
- package/.agent-src/rules/ui-audit-before-build.md +106 -0
- package/.agent-src/rules/user-interaction.md +82 -50
- package/.agent-src/scripts/update_roadmap_progress.py +17 -5
- package/.agent-src/skills/blade-ui/SKILL.md +30 -5
- package/.agent-src/skills/command-routing/SKILL.md +32 -0
- package/.agent-src/skills/command-writing/SKILL.md +41 -2
- package/.agent-src/skills/description-assist/SKILL.md +21 -0
- package/.agent-src/skills/estimate-ticket/SKILL.md +0 -1
- package/.agent-src/skills/existing-ui-audit/SKILL.md +187 -0
- package/.agent-src/skills/fe-design/SKILL.md +72 -60
- package/.agent-src/skills/finishing-a-development-branch/SKILL.md +4 -0
- package/.agent-src/skills/flux/SKILL.md +31 -4
- package/.agent-src/skills/guideline-writing/SKILL.md +24 -2
- package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +51 -9
- package/.agent-src/skills/livewire/SKILL.md +30 -4
- package/.agent-src/skills/md-language-check/SKILL.md +103 -0
- package/.agent-src/skills/php-coder/SKILL.md +24 -0
- package/.agent-src/skills/react-shadcn-ui/SKILL.md +121 -0
- package/.agent-src/skills/refine-prompt/SKILL.md +220 -0
- package/.agent-src/skills/refine-ticket/SKILL.md +2 -4
- package/.agent-src/skills/roadmap-management/SKILL.md +10 -3
- package/.agent-src/skills/rule-writing/SKILL.md +23 -1
- package/.agent-src/skills/skill-writing/SKILL.md +1 -3
- package/.agent-src/skills/upstream-contribute/SKILL.md +1 -1
- package/.agent-src/skills/using-git-worktrees/SKILL.md +3 -1
- package/.agent-src/templates/AGENTS.md +24 -6
- package/.agent-src/templates/agent-settings.md +149 -0
- package/.agent-src/templates/roadmaps.md +8 -2
- package/.agent-src/templates/scripts/implement_ticket/__init__.py +63 -26
- package/.agent-src/templates/scripts/implement_ticket/__main__.py +8 -2
- package/.agent-src/templates/scripts/telemetry/__init__.py +42 -0
- package/.agent-src/templates/scripts/telemetry/aggregator.py +154 -0
- package/.agent-src/templates/scripts/telemetry/boundary.py +171 -0
- package/.agent-src/templates/scripts/telemetry/engagement.py +238 -0
- package/.agent-src/templates/scripts/telemetry/report_renderer.py +170 -0
- package/.agent-src/templates/scripts/telemetry/settings.py +112 -0
- package/.agent-src/templates/scripts/telemetry_record.py +166 -0
- package/.agent-src/templates/scripts/telemetry_report.py +161 -0
- package/.agent-src/templates/scripts/telemetry_status.py +142 -0
- package/.agent-src/templates/scripts/work_engine/__init__.py +58 -0
- package/.agent-src/templates/scripts/work_engine/__main__.py +9 -0
- package/.agent-src/templates/scripts/work_engine/cli.py +592 -0
- package/.agent-src/templates/scripts/{implement_ticket → work_engine}/delivery_state.py +7 -0
- package/.agent-src/templates/scripts/work_engine/directives/__init__.py +33 -0
- package/.agent-src/templates/scripts/work_engine/directives/backend/__init__.py +98 -0
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/analyze.py +1 -1
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/implement.py +2 -2
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/memory.py +1 -1
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/plan.py +1 -1
- package/.agent-src/templates/scripts/work_engine/directives/backend/refine.py +396 -0
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/report.py +36 -4
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/test.py +2 -2
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/verify.py +2 -2
- package/.agent-src/templates/scripts/work_engine/directives/mixed/__init__.py +116 -0
- package/.agent-src/templates/scripts/work_engine/directives/mixed/contract.py +254 -0
- package/.agent-src/templates/scripts/work_engine/directives/mixed/stitch.py +229 -0
- package/.agent-src/templates/scripts/work_engine/directives/mixed/ui.py +231 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/__init__.py +113 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/_passthrough.py +44 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/apply.py +241 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/audit.py +414 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/design.py +335 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/polish.py +510 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/review.py +468 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/__init__.py +119 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/_skipped.py +37 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/apply.py +165 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/refine.py +66 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/report.py +62 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/test.py +115 -0
- package/.agent-src/templates/scripts/work_engine/dispatcher.py +331 -0
- package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +54 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +32 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.py +103 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.py +44 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.py +42 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_heartbeat.py +50 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_turn_check.py +49 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/directive_set_guard.py +53 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/halt_surface_audit.py +50 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/state_shape_validation.py +52 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/trace.py +84 -0
- package/.agent-src/templates/scripts/work_engine/hooks/context.py +66 -0
- package/.agent-src/templates/scripts/work_engine/hooks/events.py +44 -0
- package/.agent-src/templates/scripts/work_engine/hooks/exceptions.py +79 -0
- package/.agent-src/templates/scripts/work_engine/hooks/registry.py +60 -0
- package/.agent-src/templates/scripts/work_engine/hooks/runner.py +73 -0
- package/.agent-src/templates/scripts/work_engine/hooks/settings.py +141 -0
- package/.agent-src/templates/scripts/work_engine/intent/__init__.py +47 -0
- package/.agent-src/templates/scripts/work_engine/intent/classify.py +280 -0
- package/.agent-src/templates/scripts/work_engine/migration/__init__.py +8 -0
- package/.agent-src/templates/scripts/work_engine/migration/v0_to_v1.py +199 -0
- package/.agent-src/templates/scripts/work_engine/resolvers/__init__.py +22 -0
- package/.agent-src/templates/scripts/work_engine/resolvers/diff.py +106 -0
- package/.agent-src/templates/scripts/work_engine/resolvers/file.py +113 -0
- package/.agent-src/templates/scripts/work_engine/resolvers/prompt.py +90 -0
- package/.agent-src/templates/scripts/work_engine/scoring/__init__.py +14 -0
- package/.agent-src/templates/scripts/work_engine/scoring/confidence.py +300 -0
- package/.agent-src/templates/scripts/work_engine/stack/__init__.py +31 -0
- package/.agent-src/templates/scripts/work_engine/stack/detect.py +187 -0
- package/.agent-src/templates/scripts/work_engine/state.py +641 -0
- package/.claude-plugin/marketplace.json +105 -2
- package/AGENTS.md +36 -8
- package/CHANGELOG.md +534 -0
- package/README.md +125 -4
- package/config/agent-settings.template.yml +45 -0
- package/config/gitignore-block.txt +4 -0
- package/docs/architecture.md +28 -1
- package/docs/development.md +1 -1
- package/docs/getting-started.md +2 -2
- package/docs/installation.md +86 -0
- package/docs/showcase.md +204 -0
- package/package.json +1 -1
- package/scripts/agent-config +199 -0
- package/scripts/audit_cloud_compatibility.py +288 -0
- package/scripts/build_cloud_bundle.py +458 -0
- package/scripts/build_linear_digest.py +263 -0
- package/scripts/chat_history.py +796 -7
- package/scripts/check_compression.py +139 -0
- package/scripts/check_iron_law_prominence.py +143 -0
- package/scripts/check_md_language.py +159 -0
- package/scripts/check_portability.py +36 -0
- package/scripts/check_reply_consistency.py +140 -0
- package/scripts/command_suggester/__init__.py +51 -0
- package/scripts/command_suggester/cooldown.py +132 -0
- package/scripts/command_suggester/loader.py +70 -0
- package/scripts/command_suggester/match.py +180 -0
- package/scripts/command_suggester/rank.py +120 -0
- package/scripts/command_suggester/render.py +86 -0
- package/scripts/command_suggester/sanitize.py +113 -0
- package/scripts/command_suggester/settings.py +125 -0
- package/scripts/command_suggester/types.py +78 -0
- package/scripts/hooks/augment-chat-history.sh +56 -0
- package/scripts/install-hooks.sh +67 -0
- package/scripts/install.py +150 -33
- package/scripts/lint_marketplace.py +27 -0
- package/scripts/migrate_command_suggestions.py +151 -0
- package/scripts/schemas/command.schema.json +41 -0
- package/scripts/skill_linter.py +67 -0
- package/scripts/sync_agent_settings.py +42 -12
- package/templates/consumer-settings/augment-cli-hooks.json +54 -0
- package/templates/consumer-settings/claude-settings.json +55 -1
- package/.agent-src/templates/scripts/implement_ticket/cli.py +0 -171
- package/.agent-src/templates/scripts/implement_ticket/dispatcher.py +0 -134
- package/.agent-src/templates/scripts/implement_ticket/steps/__init__.py +0 -49
- package/.agent-src/templates/scripts/implement_ticket/steps/refine.py +0 -140
- /package/.agent-src/templates/scripts/{implement_ticket → work_engine}/persona_policy.py +0 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Engagement event schema and JSONL appender (Phase 1).
|
|
2
|
+
|
|
3
|
+
Stdlib-only. No external deps. All validation is structural — Phase 5
|
|
4
|
+
adds the redaction validator on top. The contract here is:
|
|
5
|
+
|
|
6
|
+
{
|
|
7
|
+
"schema_version": 1,
|
|
8
|
+
"ts": "<ISO-8601 UTC>",
|
|
9
|
+
"task_id": "<repo-internal id>",
|
|
10
|
+
"boundary_kind": "task" | "phase-step" | "tool-call",
|
|
11
|
+
"consulted": {"skills": [...], "rules": [...], ...},
|
|
12
|
+
"applied": {"skills": [...], "rules": [...], ...},
|
|
13
|
+
"tokens_estimate": {"consulted_load": <int>} # optional
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
Design choices:
|
|
17
|
+
|
|
18
|
+
- Dataclass + manual ``validate()`` (no pydantic — keep the engine
|
|
19
|
+
install footprint flat, mirroring ``work_engine``).
|
|
20
|
+
- Append uses ``open(..., "a")`` with ``flush()`` so one record is one
|
|
21
|
+
line; concurrent-write durability is Phase 2's job (file-lock).
|
|
22
|
+
- ``parse_event`` round-trips a serialised line back into a dataclass
|
|
23
|
+
for the tests; production agents only ever ``append_event``.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
SCHEMA_VERSION = 1
|
|
35
|
+
MAX_ID_LEN = 200
|
|
36
|
+
|
|
37
|
+
ALLOWED_KINDS: tuple[str, ...] = (
|
|
38
|
+
"skills",
|
|
39
|
+
"rules",
|
|
40
|
+
"commands",
|
|
41
|
+
"guidelines",
|
|
42
|
+
"personas",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
ALLOWED_BOUNDARY_KINDS: tuple[str, ...] = (
|
|
46
|
+
"task",
|
|
47
|
+
"phase-step",
|
|
48
|
+
"tool-call",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Phase 5 redaction validator — keep id fields from leaking paths,
|
|
52
|
+
# free-text, or filenames. Repository-internal artefact ids and
|
|
53
|
+
# task ids never contain these characters.
|
|
54
|
+
_FORBIDDEN_ID_CHARS: tuple[str, ...] = ("/", "\\", "\n", "\r", "\t")
|
|
55
|
+
# Trailing alphabetic extension (`.md`, `.py`, `.json`, …). Restricting
|
|
56
|
+
# to alphabetic chars 1-8 long avoids false positives on version-like
|
|
57
|
+
# patterns (e.g. ``v1.0``, ``ticket-1.2``) while still catching the
|
|
58
|
+
# realistic file-extension leak surface.
|
|
59
|
+
_FILE_EXTENSION_RE = re.compile(r"\.[A-Za-z]{1,8}$")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class EngagementSchemaError(ValueError):
|
|
63
|
+
"""Raised when an event violates the schema."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def check_id_redaction(label: str, value: str) -> None:
|
|
67
|
+
"""Phase 5 redaction validator — reject path- and free-text-shaped ids.
|
|
68
|
+
|
|
69
|
+
Public surface: the schema layer calls this on every ``task_id``
|
|
70
|
+
and every ``consulted``/``applied`` artefact id before write; the
|
|
71
|
+
report renderer calls it on every id before emitting JSON, so a
|
|
72
|
+
pre-validator (or hand-edited) log can never leak into a shared
|
|
73
|
+
report.
|
|
74
|
+
|
|
75
|
+
Caller has already verified ``value`` is a non-empty string of
|
|
76
|
+
length ``<= MAX_ID_LEN``. This function adds the privacy floor:
|
|
77
|
+
no slashes, no backslashes, no embedded control chars, no leading
|
|
78
|
+
or trailing whitespace, no file-extension suffix.
|
|
79
|
+
|
|
80
|
+
Failure raises :class:`EngagementSchemaError` with a label that
|
|
81
|
+
points at the offending field (``task_id``, ``consulted.skills``,
|
|
82
|
+
``applied.rules`` …).
|
|
83
|
+
"""
|
|
84
|
+
if not isinstance(value, str):
|
|
85
|
+
raise EngagementSchemaError(f"{label} must be a string")
|
|
86
|
+
if not value:
|
|
87
|
+
raise EngagementSchemaError(f"{label} must be non-empty")
|
|
88
|
+
if len(value) > MAX_ID_LEN:
|
|
89
|
+
raise EngagementSchemaError(
|
|
90
|
+
f"{label} exceeds {MAX_ID_LEN} chars"
|
|
91
|
+
)
|
|
92
|
+
for ch in _FORBIDDEN_ID_CHARS:
|
|
93
|
+
if ch in value:
|
|
94
|
+
raise EngagementSchemaError(
|
|
95
|
+
f"{label} contains forbidden character {ch!r}; "
|
|
96
|
+
"id fields must be repository-internal artefact ids only "
|
|
97
|
+
"(no paths, no free-text)"
|
|
98
|
+
)
|
|
99
|
+
if value != value.strip():
|
|
100
|
+
raise EngagementSchemaError(
|
|
101
|
+
f"{label} must not start or end with whitespace"
|
|
102
|
+
)
|
|
103
|
+
if _FILE_EXTENSION_RE.search(value):
|
|
104
|
+
raise EngagementSchemaError(
|
|
105
|
+
f"{label} ends in a file extension; "
|
|
106
|
+
"id fields must be repository-internal artefact ids only "
|
|
107
|
+
"(strip path + extension before recording)"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class EngagementEvent:
|
|
113
|
+
ts: str
|
|
114
|
+
task_id: str
|
|
115
|
+
boundary_kind: str
|
|
116
|
+
consulted: dict[str, list[str]] = field(default_factory=dict)
|
|
117
|
+
applied: dict[str, list[str]] = field(default_factory=dict)
|
|
118
|
+
tokens_estimate: dict[str, int] | None = None
|
|
119
|
+
schema_version: int = SCHEMA_VERSION
|
|
120
|
+
|
|
121
|
+
def validate(self) -> None:
|
|
122
|
+
if not isinstance(self.ts, str) or not self.ts:
|
|
123
|
+
raise EngagementSchemaError("ts must be a non-empty string")
|
|
124
|
+
if not isinstance(self.task_id, str) or not self.task_id:
|
|
125
|
+
raise EngagementSchemaError("task_id must be a non-empty string")
|
|
126
|
+
if len(self.task_id) > MAX_ID_LEN:
|
|
127
|
+
raise EngagementSchemaError(
|
|
128
|
+
f"task_id exceeds {MAX_ID_LEN} chars"
|
|
129
|
+
)
|
|
130
|
+
check_id_redaction("task_id", self.task_id)
|
|
131
|
+
if self.boundary_kind not in ALLOWED_BOUNDARY_KINDS:
|
|
132
|
+
raise EngagementSchemaError(
|
|
133
|
+
f"boundary_kind must be one of {ALLOWED_BOUNDARY_KINDS!r}"
|
|
134
|
+
)
|
|
135
|
+
_validate_artefact_dict("consulted", self.consulted)
|
|
136
|
+
_validate_artefact_dict("applied", self.applied)
|
|
137
|
+
if self.tokens_estimate is not None:
|
|
138
|
+
if not isinstance(self.tokens_estimate, dict):
|
|
139
|
+
raise EngagementSchemaError(
|
|
140
|
+
"tokens_estimate must be a dict[str,int] or None"
|
|
141
|
+
)
|
|
142
|
+
for k, v in self.tokens_estimate.items():
|
|
143
|
+
if not isinstance(k, str) or not isinstance(v, int):
|
|
144
|
+
raise EngagementSchemaError(
|
|
145
|
+
"tokens_estimate keys must be str, values int"
|
|
146
|
+
)
|
|
147
|
+
if self.schema_version != SCHEMA_VERSION:
|
|
148
|
+
raise EngagementSchemaError(
|
|
149
|
+
f"schema_version must be {SCHEMA_VERSION}, got "
|
|
150
|
+
f"{self.schema_version!r}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def to_dict(self) -> dict[str, Any]:
|
|
154
|
+
self.validate()
|
|
155
|
+
out: dict[str, Any] = {
|
|
156
|
+
"schema_version": self.schema_version,
|
|
157
|
+
"ts": self.ts,
|
|
158
|
+
"task_id": self.task_id,
|
|
159
|
+
"boundary_kind": self.boundary_kind,
|
|
160
|
+
"consulted": _normalise_artefact_dict(self.consulted),
|
|
161
|
+
"applied": _normalise_artefact_dict(self.applied),
|
|
162
|
+
}
|
|
163
|
+
if self.tokens_estimate:
|
|
164
|
+
out["tokens_estimate"] = dict(self.tokens_estimate)
|
|
165
|
+
return out
|
|
166
|
+
|
|
167
|
+
def to_jsonl(self) -> str:
|
|
168
|
+
return json.dumps(self.to_dict(), sort_keys=True, separators=(",", ":")) + "\n"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _validate_artefact_dict(label: str, payload: Any) -> None:
|
|
172
|
+
if not isinstance(payload, dict):
|
|
173
|
+
raise EngagementSchemaError(f"{label} must be a dict[str,list[str]]")
|
|
174
|
+
for kind, ids in payload.items():
|
|
175
|
+
if kind not in ALLOWED_KINDS:
|
|
176
|
+
raise EngagementSchemaError(
|
|
177
|
+
f"{label}.{kind!r} is not an allowed artefact kind "
|
|
178
|
+
f"(allowed: {ALLOWED_KINDS!r})"
|
|
179
|
+
)
|
|
180
|
+
if not isinstance(ids, list):
|
|
181
|
+
raise EngagementSchemaError(
|
|
182
|
+
f"{label}.{kind} must be a list of str"
|
|
183
|
+
)
|
|
184
|
+
for art_id in ids:
|
|
185
|
+
if not isinstance(art_id, str) or not art_id:
|
|
186
|
+
raise EngagementSchemaError(
|
|
187
|
+
f"{label}.{kind} must contain non-empty str ids"
|
|
188
|
+
)
|
|
189
|
+
if len(art_id) > MAX_ID_LEN:
|
|
190
|
+
raise EngagementSchemaError(
|
|
191
|
+
f"{label}.{kind} id exceeds {MAX_ID_LEN} chars"
|
|
192
|
+
)
|
|
193
|
+
check_id_redaction(f"{label}.{kind}", art_id)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _normalise_artefact_dict(payload: dict[str, list[str]]) -> dict[str, list[str]]:
|
|
197
|
+
# Stable shape: only non-empty kinds, ids preserved in declared order.
|
|
198
|
+
return {kind: list(payload[kind]) for kind in ALLOWED_KINDS if payload.get(kind)}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def parse_event(line: str) -> EngagementEvent:
|
|
202
|
+
if not isinstance(line, str) or not line.strip():
|
|
203
|
+
raise EngagementSchemaError("line must be a non-empty JSONL record")
|
|
204
|
+
try:
|
|
205
|
+
raw = json.loads(line)
|
|
206
|
+
except json.JSONDecodeError as exc:
|
|
207
|
+
raise EngagementSchemaError(f"line is not valid JSON: {exc}") from exc
|
|
208
|
+
if not isinstance(raw, dict):
|
|
209
|
+
raise EngagementSchemaError("event must be a JSON object")
|
|
210
|
+
event = EngagementEvent(
|
|
211
|
+
ts=raw.get("ts", ""),
|
|
212
|
+
task_id=raw.get("task_id", ""),
|
|
213
|
+
boundary_kind=raw.get("boundary_kind", ""),
|
|
214
|
+
consulted=raw.get("consulted", {}) or {},
|
|
215
|
+
applied=raw.get("applied", {}) or {},
|
|
216
|
+
tokens_estimate=raw.get("tokens_estimate"),
|
|
217
|
+
schema_version=raw.get("schema_version", SCHEMA_VERSION),
|
|
218
|
+
)
|
|
219
|
+
event.validate()
|
|
220
|
+
return event
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def append_event(log_path: Path, event: EngagementEvent) -> None:
|
|
224
|
+
"""Append one validated event to the JSONL log.
|
|
225
|
+
|
|
226
|
+
Caller is responsible for the enabled-flag check — this function
|
|
227
|
+
writes unconditionally. Phase 2 wraps it with the settings probe.
|
|
228
|
+
"""
|
|
229
|
+
payload = event.to_jsonl()
|
|
230
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
with log_path.open("a", encoding="utf-8") as fh:
|
|
232
|
+
fh.write(payload)
|
|
233
|
+
fh.flush()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def now_utc_iso() -> str:
|
|
237
|
+
"""ISO-8601 UTC timestamp, second precision, ``Z`` suffix."""
|
|
238
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Engagement report renderer (Phase 4 Step 2).
|
|
2
|
+
|
|
3
|
+
Two output formats sharing one quartile-bucketing pass:
|
|
4
|
+
|
|
5
|
+
- **markdown** — human-friendly table grouped into Essential (top 20 %),
|
|
6
|
+
Useful (mid 60 %), Retirement candidates (bottom 20 %).
|
|
7
|
+
- **json** — machine-readable summary; the same buckets, plus the
|
|
8
|
+
raw aggregate metadata, so downstream tooling never re-parses
|
|
9
|
+
the JSONL.
|
|
10
|
+
|
|
11
|
+
The bucketing is rank-based on ``applied`` count (the signal we care
|
|
12
|
+
about). Ties keep the deterministic order from
|
|
13
|
+
``aggregator.rank_artefacts``. Empty inputs yield an empty-but-valid
|
|
14
|
+
report — the renderer never raises on an empty log.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any, Sequence
|
|
21
|
+
|
|
22
|
+
from .aggregator import AggregateResult, ArtefactStat, rank_artefacts
|
|
23
|
+
from .engagement import check_id_redaction
|
|
24
|
+
|
|
25
|
+
QUARTILE_TOP_RATIO = 0.20
|
|
26
|
+
QUARTILE_BOTTOM_RATIO = 0.20
|
|
27
|
+
|
|
28
|
+
BUCKET_TOP = "essential"
|
|
29
|
+
BUCKET_MID = "useful"
|
|
30
|
+
BUCKET_BOTTOM = "retirement_candidate"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class BucketedStat:
|
|
35
|
+
stat: ArtefactStat
|
|
36
|
+
bucket: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def bucketise(stats: Sequence[ArtefactStat]) -> list[BucketedStat]:
|
|
40
|
+
"""Assign each stat to a quartile bucket.
|
|
41
|
+
|
|
42
|
+
Rank-based: indices ``[0, top_cut)`` → essential,
|
|
43
|
+
``[top_cut, bottom_cut)`` → useful, ``[bottom_cut, n)`` →
|
|
44
|
+
retirement-candidate. Ranking from ``rank_artefacts`` is assumed.
|
|
45
|
+
|
|
46
|
+
For very small samples the cuts collapse:
|
|
47
|
+
n <= 1 → everything is essential
|
|
48
|
+
n <= 4 → top 1 essential, rest useful, none retirement
|
|
49
|
+
n >= 5 → at least 1 in each bucket
|
|
50
|
+
"""
|
|
51
|
+
n = len(stats)
|
|
52
|
+
if n == 0:
|
|
53
|
+
return []
|
|
54
|
+
if n <= 1:
|
|
55
|
+
return [BucketedStat(stat=stats[0], bucket=BUCKET_TOP)]
|
|
56
|
+
top_cut = max(1, int(round(n * QUARTILE_TOP_RATIO)))
|
|
57
|
+
bottom_cut = n - max(1, int(round(n * QUARTILE_BOTTOM_RATIO))) if n >= 5 else n
|
|
58
|
+
if bottom_cut <= top_cut:
|
|
59
|
+
bottom_cut = n # mid takes the rest, no retirement bucket
|
|
60
|
+
out: list[BucketedStat] = []
|
|
61
|
+
for idx, stat in enumerate(stats):
|
|
62
|
+
if idx < top_cut:
|
|
63
|
+
bucket = BUCKET_TOP
|
|
64
|
+
elif idx < bottom_cut:
|
|
65
|
+
bucket = BUCKET_MID
|
|
66
|
+
else:
|
|
67
|
+
bucket = BUCKET_BOTTOM
|
|
68
|
+
out.append(BucketedStat(stat=stat, bucket=bucket))
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def render_markdown(
|
|
73
|
+
aggregate: AggregateResult,
|
|
74
|
+
*,
|
|
75
|
+
top: int | None = None,
|
|
76
|
+
since_label: str | None = None,
|
|
77
|
+
) -> str:
|
|
78
|
+
"""Render a markdown report. ``top`` truncates each bucket; ``None`` keeps all."""
|
|
79
|
+
ranked = rank_artefacts(aggregate.stats())
|
|
80
|
+
bucketed = bucketise(ranked)
|
|
81
|
+
grouped: dict[str, list[BucketedStat]] = {BUCKET_TOP: [], BUCKET_MID: [], BUCKET_BOTTOM: []}
|
|
82
|
+
for entry in bucketed:
|
|
83
|
+
grouped[entry.bucket].append(entry)
|
|
84
|
+
|
|
85
|
+
lines: list[str] = []
|
|
86
|
+
lines.append("# Artefact Engagement Report")
|
|
87
|
+
lines.append("")
|
|
88
|
+
lines.append(f"- events parsed: **{aggregate.parsed_events}**")
|
|
89
|
+
lines.append(f"- events skipped (malformed): **{aggregate.skipped_lines}**")
|
|
90
|
+
if since_label:
|
|
91
|
+
lines.append(f"- window: **{since_label}**")
|
|
92
|
+
if aggregate.earliest_ts and aggregate.latest_ts:
|
|
93
|
+
lines.append(f"- ts range: `{aggregate.earliest_ts}` → `{aggregate.latest_ts}`")
|
|
94
|
+
lines.append("")
|
|
95
|
+
|
|
96
|
+
titles = {
|
|
97
|
+
BUCKET_TOP: "Essential (top 20 %)",
|
|
98
|
+
BUCKET_MID: "Useful (mid 60 %)",
|
|
99
|
+
BUCKET_BOTTOM: "Retirement candidates (bottom 20 %)",
|
|
100
|
+
}
|
|
101
|
+
for bucket in (BUCKET_TOP, BUCKET_MID, BUCKET_BOTTOM):
|
|
102
|
+
rows = grouped[bucket]
|
|
103
|
+
if top is not None:
|
|
104
|
+
rows = rows[:top]
|
|
105
|
+
lines.append(f"## {titles[bucket]}")
|
|
106
|
+
lines.append("")
|
|
107
|
+
if not rows:
|
|
108
|
+
lines.append("_(none)_")
|
|
109
|
+
lines.append("")
|
|
110
|
+
continue
|
|
111
|
+
lines.append("| kind | id | consulted | applied | applied/consulted | last seen |")
|
|
112
|
+
lines.append("|---|---|---:|---:|---:|---|")
|
|
113
|
+
for entry in rows:
|
|
114
|
+
s = entry.stat
|
|
115
|
+
# Phase 5 export gate — applies to markdown too, not just JSON.
|
|
116
|
+
check_id_redaction(f"buckets.{s.kind}.id", s.artefact_id)
|
|
117
|
+
lines.append(
|
|
118
|
+
f"| {s.kind} | `{s.artefact_id}` | {s.consulted} | {s.applied} "
|
|
119
|
+
f"| {s.applied_ratio:.2f} | `{s.last_seen_ts}` |"
|
|
120
|
+
)
|
|
121
|
+
lines.append("")
|
|
122
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def render_json(
|
|
126
|
+
aggregate: AggregateResult,
|
|
127
|
+
*,
|
|
128
|
+
top: int | None = None,
|
|
129
|
+
since_label: str | None = None,
|
|
130
|
+
) -> str:
|
|
131
|
+
ranked = rank_artefacts(aggregate.stats())
|
|
132
|
+
bucketed = bucketise(ranked)
|
|
133
|
+
grouped: dict[str, list[dict[str, Any]]] = {BUCKET_TOP: [], BUCKET_MID: [], BUCKET_BOTTOM: []}
|
|
134
|
+
for entry in bucketed:
|
|
135
|
+
grouped[entry.bucket].append(_stat_to_dict(entry.stat))
|
|
136
|
+
if top is not None:
|
|
137
|
+
for bucket in grouped:
|
|
138
|
+
grouped[bucket] = grouped[bucket][:top]
|
|
139
|
+
payload = {
|
|
140
|
+
"schema_version": 1,
|
|
141
|
+
"summary": {
|
|
142
|
+
"parsed_events": aggregate.parsed_events,
|
|
143
|
+
"skipped_lines": aggregate.skipped_lines,
|
|
144
|
+
"total_events": aggregate.total_events,
|
|
145
|
+
"earliest_ts": aggregate.earliest_ts,
|
|
146
|
+
"latest_ts": aggregate.latest_ts,
|
|
147
|
+
"since_label": since_label,
|
|
148
|
+
},
|
|
149
|
+
"buckets": grouped,
|
|
150
|
+
}
|
|
151
|
+
return json.dumps(payload, sort_keys=True, indent=2) + "\n"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _stat_to_dict(stat: ArtefactStat) -> dict[str, Any]:
|
|
155
|
+
# Phase 5 export gate: every id leaving the renderer is re-validated
|
|
156
|
+
# against the same redaction floor that the schema enforces on
|
|
157
|
+
# write. A pre-validator log (or one hand-edited offline) can never
|
|
158
|
+
# leak path-shaped or free-text content into a shared report.
|
|
159
|
+
check_id_redaction(f"buckets.{stat.kind}.id", stat.artefact_id)
|
|
160
|
+
return {
|
|
161
|
+
"kind": stat.kind,
|
|
162
|
+
"id": stat.artefact_id,
|
|
163
|
+
"consulted": stat.consulted,
|
|
164
|
+
"applied": stat.applied,
|
|
165
|
+
"applied_ratio": round(stat.applied_ratio, 4),
|
|
166
|
+
"last_seen_ts": stat.last_seen_ts,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
__all__ = ["BucketedStat", "bucketise", "render_markdown", "render_json"]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Shared settings reader for the ``telemetry:*`` CLI commands.
|
|
2
|
+
|
|
3
|
+
Reads the ``telemetry.artifact_engagement`` namespace from
|
|
4
|
+
``.agent-settings.yml``. Tolerates a missing file, a missing section,
|
|
5
|
+
and missing PyYAML — the default-off doctrine means "everything
|
|
6
|
+
unparseable means disabled".
|
|
7
|
+
|
|
8
|
+
Single source of truth so ``telemetry_record.py`` and
|
|
9
|
+
``telemetry_status.py`` cannot drift on what counts as "enabled".
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
DEFAULT_LOG_PATH = Path(".agent-engagement.jsonl")
|
|
18
|
+
DEFAULT_GRANULARITY = "task"
|
|
19
|
+
ALLOWED_GRANULARITIES = ("task", "phase-step", "tool-call")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class TelemetrySettings:
|
|
24
|
+
enabled: bool
|
|
25
|
+
granularity: str
|
|
26
|
+
log_path: Path
|
|
27
|
+
record_consulted: bool
|
|
28
|
+
record_applied: bool
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def section_present(self) -> bool:
|
|
32
|
+
# Distinguishes "disabled because section absent" from
|
|
33
|
+
# "disabled because someone wrote enabled: false". The status
|
|
34
|
+
# CLI uses this to render a different hint.
|
|
35
|
+
return self._section_present # type: ignore[attr-defined]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _coerce_bool(value: Any, default: bool) -> bool:
|
|
39
|
+
if isinstance(value, bool):
|
|
40
|
+
return value
|
|
41
|
+
if isinstance(value, str):
|
|
42
|
+
normalised = value.strip().lower()
|
|
43
|
+
if normalised in ("true", "yes", "on", "1"):
|
|
44
|
+
return True
|
|
45
|
+
if normalised in ("false", "no", "off", "0"):
|
|
46
|
+
return False
|
|
47
|
+
return default
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _coerce_str(value: Any, default: str, allowed: tuple[str, ...] | None = None) -> str:
|
|
51
|
+
if not isinstance(value, str) or not value.strip():
|
|
52
|
+
return default
|
|
53
|
+
candidate = value.strip()
|
|
54
|
+
if allowed and candidate not in allowed:
|
|
55
|
+
return default
|
|
56
|
+
return candidate
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _coerce_path(value: Any, default: Path) -> Path:
|
|
60
|
+
if not isinstance(value, str) or not value.strip():
|
|
61
|
+
return default
|
|
62
|
+
return Path(value.strip())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def read_settings(path: Path) -> TelemetrySettings:
|
|
66
|
+
"""Return parsed telemetry settings — never raises on missing data."""
|
|
67
|
+
section: dict[str, Any] = {}
|
|
68
|
+
section_present = False
|
|
69
|
+
|
|
70
|
+
if path.is_file():
|
|
71
|
+
try:
|
|
72
|
+
import yaml # type: ignore[import-not-found]
|
|
73
|
+
except ImportError:
|
|
74
|
+
yaml = None # type: ignore[assignment]
|
|
75
|
+
if yaml is not None:
|
|
76
|
+
try:
|
|
77
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
78
|
+
except Exception:
|
|
79
|
+
raw = {}
|
|
80
|
+
if isinstance(raw, dict):
|
|
81
|
+
tele = raw.get("telemetry")
|
|
82
|
+
if isinstance(tele, dict):
|
|
83
|
+
artefact = tele.get("artifact_engagement")
|
|
84
|
+
if isinstance(artefact, dict):
|
|
85
|
+
section = artefact
|
|
86
|
+
section_present = True
|
|
87
|
+
|
|
88
|
+
record = section.get("record") if isinstance(section.get("record"), dict) else {}
|
|
89
|
+
output = section.get("output") if isinstance(section.get("output"), dict) else {}
|
|
90
|
+
|
|
91
|
+
settings = TelemetrySettings(
|
|
92
|
+
enabled=_coerce_bool(section.get("enabled"), default=False),
|
|
93
|
+
granularity=_coerce_str(
|
|
94
|
+
section.get("granularity"),
|
|
95
|
+
default=DEFAULT_GRANULARITY,
|
|
96
|
+
allowed=ALLOWED_GRANULARITIES,
|
|
97
|
+
),
|
|
98
|
+
log_path=_coerce_path(output.get("path"), DEFAULT_LOG_PATH),
|
|
99
|
+
record_consulted=_coerce_bool(record.get("consulted"), default=True),
|
|
100
|
+
record_applied=_coerce_bool(record.get("applied"), default=True),
|
|
101
|
+
)
|
|
102
|
+
# Carry the section-present flag without breaking dataclass frozen-ness.
|
|
103
|
+
object.__setattr__(settings, "_section_present", section_present)
|
|
104
|
+
return settings
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
__all__ = [
|
|
108
|
+
"DEFAULT_GRANULARITY",
|
|
109
|
+
"DEFAULT_LOG_PATH",
|
|
110
|
+
"TelemetrySettings",
|
|
111
|
+
"read_settings",
|
|
112
|
+
]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""``./agent-config telemetry:record`` — append one engagement event.
|
|
3
|
+
|
|
4
|
+
Reads the ``telemetry.artifact_engagement`` namespace from
|
|
5
|
+
``.agent-settings.yml``. When ``enabled: false`` (default) the script
|
|
6
|
+
exits 0 silently and performs zero file IO — the default-off doctrine
|
|
7
|
+
for this whole feature.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
# JSON payload via --payload-file (consumed atomically)
|
|
11
|
+
./agent-config telemetry:record --payload-file payload.json
|
|
12
|
+
|
|
13
|
+
# JSON payload via stdin
|
|
14
|
+
cat payload.json | ./agent-config telemetry:record --stdin
|
|
15
|
+
|
|
16
|
+
# Direct construction (idempotent within boundary if reused via
|
|
17
|
+
# the BoundarySession class — at the CLI layer, each call writes
|
|
18
|
+
# one line)
|
|
19
|
+
./agent-config telemetry:record \\
|
|
20
|
+
--task-id ticket-PROJ-42 --boundary task \\
|
|
21
|
+
--consulted skills:php-coder --consulted rules:scope-control \\
|
|
22
|
+
--applied skills:php-coder
|
|
23
|
+
|
|
24
|
+
Exit codes:
|
|
25
|
+
0 success or disabled (silent)
|
|
26
|
+
1 schema-validation failure
|
|
27
|
+
2 IO / settings parse error
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import json
|
|
33
|
+
import sys
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
# Resolve sibling ``telemetry/`` package — Python adds the script's
|
|
37
|
+
# directory to sys.path automatically, so this import works whether
|
|
38
|
+
# the script is dispatched from the package or from a consumer copy.
|
|
39
|
+
from telemetry.boundary import record_event
|
|
40
|
+
from telemetry.engagement import (
|
|
41
|
+
EngagementEvent,
|
|
42
|
+
EngagementSchemaError,
|
|
43
|
+
now_utc_iso,
|
|
44
|
+
)
|
|
45
|
+
from telemetry.settings import TelemetrySettings, read_settings
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parse_kv_list(values: list[str]) -> dict[str, list[str]]:
|
|
49
|
+
"""Turn ``["skills:a", "skills:b", "rules:c"]`` into a kind→ids dict."""
|
|
50
|
+
out: dict[str, list[str]] = {}
|
|
51
|
+
for raw in values:
|
|
52
|
+
if ":" not in raw:
|
|
53
|
+
raise SystemExit(
|
|
54
|
+
f"❌ --consulted/--applied must be 'kind:id', got {raw!r}"
|
|
55
|
+
)
|
|
56
|
+
kind, _, art_id = raw.partition(":")
|
|
57
|
+
kind = kind.strip()
|
|
58
|
+
art_id = art_id.strip()
|
|
59
|
+
if not kind or not art_id:
|
|
60
|
+
raise SystemExit(
|
|
61
|
+
f"❌ empty kind or id in {raw!r}"
|
|
62
|
+
)
|
|
63
|
+
out.setdefault(kind, []).append(art_id)
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _build_event_from_args(args: argparse.Namespace) -> EngagementEvent:
|
|
68
|
+
return EngagementEvent(
|
|
69
|
+
ts=args.ts or now_utc_iso(),
|
|
70
|
+
task_id=args.task_id,
|
|
71
|
+
boundary_kind=args.boundary,
|
|
72
|
+
consulted=_parse_kv_list(args.consulted or []),
|
|
73
|
+
applied=_parse_kv_list(args.applied or []),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _build_event_from_payload(raw: str) -> EngagementEvent:
|
|
78
|
+
try:
|
|
79
|
+
data = json.loads(raw)
|
|
80
|
+
except json.JSONDecodeError as exc:
|
|
81
|
+
raise SystemExit(f"❌ payload is not valid JSON: {exc}")
|
|
82
|
+
if not isinstance(data, dict):
|
|
83
|
+
raise SystemExit("❌ payload must be a JSON object")
|
|
84
|
+
return EngagementEvent(
|
|
85
|
+
ts=data.get("ts") or now_utc_iso(),
|
|
86
|
+
task_id=data.get("task_id", ""),
|
|
87
|
+
boundary_kind=data.get("boundary_kind", ""),
|
|
88
|
+
consulted=data.get("consulted", {}) or {},
|
|
89
|
+
applied=data.get("applied", {}) or {},
|
|
90
|
+
tokens_estimate=data.get("tokens_estimate"),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main(argv: list[str] | None = None) -> int:
|
|
95
|
+
parser = argparse.ArgumentParser(
|
|
96
|
+
prog="agent-config telemetry:record",
|
|
97
|
+
description=(
|
|
98
|
+
"Append one artefact-engagement event to the local JSONL log. "
|
|
99
|
+
"Default-off — silent exit 0 unless explicitly enabled."
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
parser.add_argument("--task-id", default="")
|
|
103
|
+
parser.add_argument(
|
|
104
|
+
"--boundary",
|
|
105
|
+
default="task",
|
|
106
|
+
choices=("task", "phase-step", "tool-call"),
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument("--consulted", action="append")
|
|
109
|
+
parser.add_argument("--applied", action="append")
|
|
110
|
+
parser.add_argument("--ts", default="")
|
|
111
|
+
parser.add_argument("--payload-file", type=Path)
|
|
112
|
+
parser.add_argument("--stdin", action="store_true")
|
|
113
|
+
parser.add_argument(
|
|
114
|
+
"--settings",
|
|
115
|
+
type=Path,
|
|
116
|
+
default=Path(".agent-settings.yml"),
|
|
117
|
+
help="Override settings path (tests).",
|
|
118
|
+
)
|
|
119
|
+
parser.add_argument(
|
|
120
|
+
"--force",
|
|
121
|
+
action="store_true",
|
|
122
|
+
help="Bypass the enabled-flag (tests + maintainer one-shots).",
|
|
123
|
+
)
|
|
124
|
+
args = parser.parse_args(argv)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
settings: TelemetrySettings = read_settings(args.settings)
|
|
128
|
+
except OSError as exc:
|
|
129
|
+
print(f"❌ cannot read settings: {exc}", file=sys.stderr)
|
|
130
|
+
return 2
|
|
131
|
+
|
|
132
|
+
if not settings.enabled and not args.force:
|
|
133
|
+
# Default-off: silent success. Crucially: no payload parsing,
|
|
134
|
+
# no schema construction — zero work attributable to telemetry.
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
if args.payload_file:
|
|
138
|
+
try:
|
|
139
|
+
raw = args.payload_file.read_text(encoding="utf-8")
|
|
140
|
+
except OSError as exc:
|
|
141
|
+
print(f"❌ cannot read --payload-file: {exc}", file=sys.stderr)
|
|
142
|
+
return 2
|
|
143
|
+
event = _build_event_from_payload(raw)
|
|
144
|
+
elif args.stdin:
|
|
145
|
+
event = _build_event_from_payload(sys.stdin.read())
|
|
146
|
+
else:
|
|
147
|
+
if not args.task_id:
|
|
148
|
+
print("❌ --task-id required (or pass --payload-file/--stdin)",
|
|
149
|
+
file=sys.stderr)
|
|
150
|
+
return 1
|
|
151
|
+
event = _build_event_from_args(args)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
record_event(settings.log_path, event)
|
|
155
|
+
except EngagementSchemaError as exc:
|
|
156
|
+
print(f"❌ schema validation failed: {exc}", file=sys.stderr)
|
|
157
|
+
return 1
|
|
158
|
+
except OSError as exc:
|
|
159
|
+
print(f"❌ cannot write engagement log: {exc}", file=sys.stderr)
|
|
160
|
+
return 2
|
|
161
|
+
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
raise SystemExit(main())
|