@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.
Files changed (252) hide show
  1. package/.agent-src/commands/agent-handoff.md +3 -0
  2. package/.agent-src/commands/agent-status.md +3 -0
  3. package/.agent-src/commands/agents-audit.md +4 -0
  4. package/.agent-src/commands/agents-cleanup.md +6 -1
  5. package/.agent-src/commands/agents-prepare.md +3 -0
  6. package/.agent-src/commands/analyze-reference-repo.md +4 -0
  7. package/.agent-src/commands/bug-fix.md +5 -1
  8. package/.agent-src/commands/bug-investigate.md +4 -0
  9. package/.agent-src/commands/chat-history-checkpoint.md +126 -0
  10. package/.agent-src/commands/chat-history-clear.md +5 -0
  11. package/.agent-src/commands/chat-history-resume.md +5 -0
  12. package/.agent-src/commands/chat-history.md +5 -0
  13. package/.agent-src/commands/check-current-md.md +126 -0
  14. package/.agent-src/commands/commit-in-chunks.md +98 -0
  15. package/.agent-src/commands/commit.md +4 -0
  16. package/.agent-src/commands/compress.md +3 -0
  17. package/.agent-src/commands/context-create.md +4 -0
  18. package/.agent-src/commands/context-refactor.md +4 -0
  19. package/.agent-src/commands/copilot-agents-init.md +3 -0
  20. package/.agent-src/commands/copilot-agents-optimize.md +3 -0
  21. package/.agent-src/commands/create-pr-description.md +4 -0
  22. package/.agent-src/commands/create-pr.md +4 -0
  23. package/.agent-src/commands/do-and-judge.md +4 -1
  24. package/.agent-src/commands/do-in-steps.md +3 -0
  25. package/.agent-src/commands/e2e-heal.md +4 -0
  26. package/.agent-src/commands/e2e-plan.md +4 -0
  27. package/.agent-src/commands/estimate-ticket.md +4 -1
  28. package/.agent-src/commands/feature-dev.md +4 -0
  29. package/.agent-src/commands/feature-explore.md +4 -0
  30. package/.agent-src/commands/feature-plan.md +4 -0
  31. package/.agent-src/commands/feature-refactor.md +4 -0
  32. package/.agent-src/commands/feature-roadmap.md +6 -0
  33. package/.agent-src/commands/fix-ci.md +4 -0
  34. package/.agent-src/commands/fix-portability.md +3 -0
  35. package/.agent-src/commands/fix-pr-bot-comments.md +4 -0
  36. package/.agent-src/commands/fix-pr-comments.md +4 -0
  37. package/.agent-src/commands/fix-pr-developer-comments.md +4 -0
  38. package/.agent-src/commands/fix-references.md +3 -0
  39. package/.agent-src/commands/fix-seeder.md +4 -0
  40. package/.agent-src/commands/implement-ticket.md +39 -13
  41. package/.agent-src/commands/jira-ticket.md +4 -0
  42. package/.agent-src/commands/judge.md +3 -0
  43. package/.agent-src/commands/memory-add.md +5 -3
  44. package/.agent-src/commands/memory-full.md +5 -2
  45. package/.agent-src/commands/memory-promote.md +7 -6
  46. package/.agent-src/commands/mode.md +3 -0
  47. package/.agent-src/commands/module-create.md +4 -0
  48. package/.agent-src/commands/module-explore.md +4 -0
  49. package/.agent-src/commands/onboard.md +24 -0
  50. package/.agent-src/commands/optimize-agents.md +4 -0
  51. package/.agent-src/commands/optimize-augmentignore.md +3 -0
  52. package/.agent-src/commands/optimize-rtk-filters.md +3 -0
  53. package/.agent-src/commands/optimize-skills.md +4 -0
  54. package/.agent-src/commands/override-create.md +4 -0
  55. package/.agent-src/commands/override-manage.md +4 -0
  56. package/.agent-src/commands/package-reset.md +3 -0
  57. package/.agent-src/commands/package-test.md +3 -0
  58. package/.agent-src/commands/prepare-for-review.md +4 -0
  59. package/.agent-src/commands/project-analyze.md +4 -0
  60. package/.agent-src/commands/project-health.md +4 -0
  61. package/.agent-src/commands/propose-memory.md +6 -8
  62. package/.agent-src/commands/quality-fix.md +4 -0
  63. package/.agent-src/commands/refine-ticket.md +4 -1
  64. package/.agent-src/commands/review-changes.md +4 -0
  65. package/.agent-src/commands/review-routing.md +4 -0
  66. package/.agent-src/commands/roadmap-create.md +7 -0
  67. package/.agent-src/commands/roadmap-execute.md +12 -1
  68. package/.agent-src/commands/rule-compliance-audit.md +4 -0
  69. package/.agent-src/commands/set-cost-profile.md +3 -0
  70. package/.agent-src/commands/sync-agent-settings.md +3 -0
  71. package/.agent-src/commands/sync-gitignore.md +3 -0
  72. package/.agent-src/commands/tests-create.md +4 -0
  73. package/.agent-src/commands/tests-execute.md +4 -0
  74. package/.agent-src/commands/threat-model.md +4 -0
  75. package/.agent-src/commands/update-form-request-messages.md +4 -0
  76. package/.agent-src/commands/upstream-contribute.md +4 -0
  77. package/.agent-src/commands/work.md +161 -0
  78. package/.agent-src/guidelines/agent-infra/engineering-memory-data-format.md +2 -6
  79. package/.agent-src/guidelines/agent-infra/layered-settings.md +0 -1
  80. package/.agent-src/guidelines/agent-infra/memory-access.md +0 -7
  81. package/.agent-src/guidelines/agent-infra/role-contracts.md +2 -4
  82. package/.agent-src/guidelines/agent-infra/self-improvement-pipeline.md +0 -1
  83. package/.agent-src/guidelines/php/patterns/strategy.md +180 -2
  84. package/.agent-src/personas/README.md +0 -1
  85. package/.agent-src/rules/artifact-drafting-protocol.md +7 -2
  86. package/.agent-src/rules/artifact-engagement-recording.md +133 -0
  87. package/.agent-src/rules/ask-when-uncertain.md +18 -13
  88. package/.agent-src/rules/augment-portability.md +8 -0
  89. package/.agent-src/rules/autonomous-execution.md +158 -0
  90. package/.agent-src/rules/chat-history.md +147 -118
  91. package/.agent-src/rules/cli-output-handling.md +26 -3
  92. package/.agent-src/rules/command-suggestion.md +133 -0
  93. package/.agent-src/rules/commit-policy.md +99 -0
  94. package/.agent-src/rules/direct-answers.md +114 -0
  95. package/.agent-src/rules/docs-sync.md +36 -0
  96. package/.agent-src/rules/downstream-changes.md +10 -9
  97. package/.agent-src/rules/improve-before-implement.md +9 -6
  98. package/.agent-src/rules/language-and-tone.md +81 -6
  99. package/.agent-src/rules/non-destructive-by-default.md +117 -0
  100. package/.agent-src/rules/package-ci-checks.md +4 -0
  101. package/.agent-src/rules/preservation-guard.md +20 -0
  102. package/.agent-src/rules/roadmap-progress-sync.md +103 -30
  103. package/.agent-src/rules/scope-control.md +42 -1
  104. package/.agent-src/rules/size-enforcement.md +1 -3
  105. package/.agent-src/rules/skill-quality.md +3 -8
  106. package/.agent-src/rules/ui-audit-before-build.md +106 -0
  107. package/.agent-src/rules/user-interaction.md +82 -50
  108. package/.agent-src/scripts/update_roadmap_progress.py +17 -5
  109. package/.agent-src/skills/blade-ui/SKILL.md +30 -5
  110. package/.agent-src/skills/command-routing/SKILL.md +32 -0
  111. package/.agent-src/skills/command-writing/SKILL.md +41 -2
  112. package/.agent-src/skills/description-assist/SKILL.md +21 -0
  113. package/.agent-src/skills/estimate-ticket/SKILL.md +0 -1
  114. package/.agent-src/skills/existing-ui-audit/SKILL.md +187 -0
  115. package/.agent-src/skills/fe-design/SKILL.md +72 -60
  116. package/.agent-src/skills/finishing-a-development-branch/SKILL.md +4 -0
  117. package/.agent-src/skills/flux/SKILL.md +31 -4
  118. package/.agent-src/skills/guideline-writing/SKILL.md +24 -2
  119. package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +51 -9
  120. package/.agent-src/skills/livewire/SKILL.md +30 -4
  121. package/.agent-src/skills/md-language-check/SKILL.md +103 -0
  122. package/.agent-src/skills/php-coder/SKILL.md +24 -0
  123. package/.agent-src/skills/react-shadcn-ui/SKILL.md +121 -0
  124. package/.agent-src/skills/refine-prompt/SKILL.md +220 -0
  125. package/.agent-src/skills/refine-ticket/SKILL.md +2 -4
  126. package/.agent-src/skills/roadmap-management/SKILL.md +10 -3
  127. package/.agent-src/skills/rule-writing/SKILL.md +23 -1
  128. package/.agent-src/skills/skill-writing/SKILL.md +1 -3
  129. package/.agent-src/skills/upstream-contribute/SKILL.md +1 -1
  130. package/.agent-src/skills/using-git-worktrees/SKILL.md +3 -1
  131. package/.agent-src/templates/AGENTS.md +24 -6
  132. package/.agent-src/templates/agent-settings.md +149 -0
  133. package/.agent-src/templates/roadmaps.md +8 -2
  134. package/.agent-src/templates/scripts/implement_ticket/__init__.py +63 -26
  135. package/.agent-src/templates/scripts/implement_ticket/__main__.py +8 -2
  136. package/.agent-src/templates/scripts/telemetry/__init__.py +42 -0
  137. package/.agent-src/templates/scripts/telemetry/aggregator.py +154 -0
  138. package/.agent-src/templates/scripts/telemetry/boundary.py +171 -0
  139. package/.agent-src/templates/scripts/telemetry/engagement.py +238 -0
  140. package/.agent-src/templates/scripts/telemetry/report_renderer.py +170 -0
  141. package/.agent-src/templates/scripts/telemetry/settings.py +112 -0
  142. package/.agent-src/templates/scripts/telemetry_record.py +166 -0
  143. package/.agent-src/templates/scripts/telemetry_report.py +161 -0
  144. package/.agent-src/templates/scripts/telemetry_status.py +142 -0
  145. package/.agent-src/templates/scripts/work_engine/__init__.py +58 -0
  146. package/.agent-src/templates/scripts/work_engine/__main__.py +9 -0
  147. package/.agent-src/templates/scripts/work_engine/cli.py +592 -0
  148. package/.agent-src/templates/scripts/{implement_ticket → work_engine}/delivery_state.py +7 -0
  149. package/.agent-src/templates/scripts/work_engine/directives/__init__.py +33 -0
  150. package/.agent-src/templates/scripts/work_engine/directives/backend/__init__.py +98 -0
  151. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/analyze.py +1 -1
  152. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/implement.py +2 -2
  153. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/memory.py +1 -1
  154. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/plan.py +1 -1
  155. package/.agent-src/templates/scripts/work_engine/directives/backend/refine.py +396 -0
  156. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/report.py +36 -4
  157. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/test.py +2 -2
  158. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/verify.py +2 -2
  159. package/.agent-src/templates/scripts/work_engine/directives/mixed/__init__.py +116 -0
  160. package/.agent-src/templates/scripts/work_engine/directives/mixed/contract.py +254 -0
  161. package/.agent-src/templates/scripts/work_engine/directives/mixed/stitch.py +229 -0
  162. package/.agent-src/templates/scripts/work_engine/directives/mixed/ui.py +231 -0
  163. package/.agent-src/templates/scripts/work_engine/directives/ui/__init__.py +113 -0
  164. package/.agent-src/templates/scripts/work_engine/directives/ui/_passthrough.py +44 -0
  165. package/.agent-src/templates/scripts/work_engine/directives/ui/apply.py +241 -0
  166. package/.agent-src/templates/scripts/work_engine/directives/ui/audit.py +414 -0
  167. package/.agent-src/templates/scripts/work_engine/directives/ui/design.py +335 -0
  168. package/.agent-src/templates/scripts/work_engine/directives/ui/polish.py +510 -0
  169. package/.agent-src/templates/scripts/work_engine/directives/ui/review.py +468 -0
  170. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/__init__.py +119 -0
  171. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/_skipped.py +37 -0
  172. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/apply.py +165 -0
  173. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/refine.py +66 -0
  174. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/report.py +62 -0
  175. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/test.py +115 -0
  176. package/.agent-src/templates/scripts/work_engine/dispatcher.py +331 -0
  177. package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +54 -0
  178. package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +32 -0
  179. package/.agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.py +103 -0
  180. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.py +44 -0
  181. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.py +42 -0
  182. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_heartbeat.py +50 -0
  183. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_turn_check.py +49 -0
  184. package/.agent-src/templates/scripts/work_engine/hooks/builtin/directive_set_guard.py +53 -0
  185. package/.agent-src/templates/scripts/work_engine/hooks/builtin/halt_surface_audit.py +50 -0
  186. package/.agent-src/templates/scripts/work_engine/hooks/builtin/state_shape_validation.py +52 -0
  187. package/.agent-src/templates/scripts/work_engine/hooks/builtin/trace.py +84 -0
  188. package/.agent-src/templates/scripts/work_engine/hooks/context.py +66 -0
  189. package/.agent-src/templates/scripts/work_engine/hooks/events.py +44 -0
  190. package/.agent-src/templates/scripts/work_engine/hooks/exceptions.py +79 -0
  191. package/.agent-src/templates/scripts/work_engine/hooks/registry.py +60 -0
  192. package/.agent-src/templates/scripts/work_engine/hooks/runner.py +73 -0
  193. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +141 -0
  194. package/.agent-src/templates/scripts/work_engine/intent/__init__.py +47 -0
  195. package/.agent-src/templates/scripts/work_engine/intent/classify.py +280 -0
  196. package/.agent-src/templates/scripts/work_engine/migration/__init__.py +8 -0
  197. package/.agent-src/templates/scripts/work_engine/migration/v0_to_v1.py +199 -0
  198. package/.agent-src/templates/scripts/work_engine/resolvers/__init__.py +22 -0
  199. package/.agent-src/templates/scripts/work_engine/resolvers/diff.py +106 -0
  200. package/.agent-src/templates/scripts/work_engine/resolvers/file.py +113 -0
  201. package/.agent-src/templates/scripts/work_engine/resolvers/prompt.py +90 -0
  202. package/.agent-src/templates/scripts/work_engine/scoring/__init__.py +14 -0
  203. package/.agent-src/templates/scripts/work_engine/scoring/confidence.py +300 -0
  204. package/.agent-src/templates/scripts/work_engine/stack/__init__.py +31 -0
  205. package/.agent-src/templates/scripts/work_engine/stack/detect.py +187 -0
  206. package/.agent-src/templates/scripts/work_engine/state.py +641 -0
  207. package/.claude-plugin/marketplace.json +105 -2
  208. package/AGENTS.md +36 -8
  209. package/CHANGELOG.md +534 -0
  210. package/README.md +125 -4
  211. package/config/agent-settings.template.yml +45 -0
  212. package/config/gitignore-block.txt +4 -0
  213. package/docs/architecture.md +28 -1
  214. package/docs/development.md +1 -1
  215. package/docs/getting-started.md +2 -2
  216. package/docs/installation.md +86 -0
  217. package/docs/showcase.md +204 -0
  218. package/package.json +1 -1
  219. package/scripts/agent-config +199 -0
  220. package/scripts/audit_cloud_compatibility.py +288 -0
  221. package/scripts/build_cloud_bundle.py +458 -0
  222. package/scripts/build_linear_digest.py +263 -0
  223. package/scripts/chat_history.py +796 -7
  224. package/scripts/check_compression.py +139 -0
  225. package/scripts/check_iron_law_prominence.py +143 -0
  226. package/scripts/check_md_language.py +159 -0
  227. package/scripts/check_portability.py +36 -0
  228. package/scripts/check_reply_consistency.py +140 -0
  229. package/scripts/command_suggester/__init__.py +51 -0
  230. package/scripts/command_suggester/cooldown.py +132 -0
  231. package/scripts/command_suggester/loader.py +70 -0
  232. package/scripts/command_suggester/match.py +180 -0
  233. package/scripts/command_suggester/rank.py +120 -0
  234. package/scripts/command_suggester/render.py +86 -0
  235. package/scripts/command_suggester/sanitize.py +113 -0
  236. package/scripts/command_suggester/settings.py +125 -0
  237. package/scripts/command_suggester/types.py +78 -0
  238. package/scripts/hooks/augment-chat-history.sh +56 -0
  239. package/scripts/install-hooks.sh +67 -0
  240. package/scripts/install.py +150 -33
  241. package/scripts/lint_marketplace.py +27 -0
  242. package/scripts/migrate_command_suggestions.py +151 -0
  243. package/scripts/schemas/command.schema.json +41 -0
  244. package/scripts/skill_linter.py +67 -0
  245. package/scripts/sync_agent_settings.py +42 -12
  246. package/templates/consumer-settings/augment-cli-hooks.json +54 -0
  247. package/templates/consumer-settings/claude-settings.json +55 -1
  248. package/.agent-src/templates/scripts/implement_ticket/cli.py +0 -171
  249. package/.agent-src/templates/scripts/implement_ticket/dispatcher.py +0 -134
  250. package/.agent-src/templates/scripts/implement_ticket/steps/__init__.py +0 -49
  251. package/.agent-src/templates/scripts/implement_ticket/steps/refine.py +0 -140
  252. /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())