@event4u/agent-config 1.12.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 (260) 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 +81 -3
  108. package/.agent-src/scripts/update_roadmap_progress.py +48 -6
  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/github-workflows/roadmap-progress-check.yml +63 -0
  134. package/.agent-src/templates/hooks/pre-commit-roadmap-progress +60 -0
  135. package/.agent-src/templates/roadmaps.md +8 -2
  136. package/.agent-src/templates/scripts/implement_ticket/__init__.py +63 -26
  137. package/.agent-src/templates/scripts/implement_ticket/__main__.py +8 -2
  138. package/.agent-src/templates/scripts/memory_lookup.py +382 -21
  139. package/.agent-src/templates/scripts/memory_status.py +110 -9
  140. package/.agent-src/templates/scripts/telemetry/__init__.py +42 -0
  141. package/.agent-src/templates/scripts/telemetry/aggregator.py +154 -0
  142. package/.agent-src/templates/scripts/telemetry/boundary.py +171 -0
  143. package/.agent-src/templates/scripts/telemetry/engagement.py +238 -0
  144. package/.agent-src/templates/scripts/telemetry/report_renderer.py +170 -0
  145. package/.agent-src/templates/scripts/telemetry/settings.py +112 -0
  146. package/.agent-src/templates/scripts/telemetry_record.py +166 -0
  147. package/.agent-src/templates/scripts/telemetry_report.py +161 -0
  148. package/.agent-src/templates/scripts/telemetry_status.py +142 -0
  149. package/.agent-src/templates/scripts/work_engine/__init__.py +58 -0
  150. package/.agent-src/templates/scripts/work_engine/__main__.py +9 -0
  151. package/.agent-src/templates/scripts/work_engine/cli.py +592 -0
  152. package/.agent-src/templates/scripts/{implement_ticket → work_engine}/delivery_state.py +7 -0
  153. package/.agent-src/templates/scripts/work_engine/directives/__init__.py +33 -0
  154. package/.agent-src/templates/scripts/work_engine/directives/backend/__init__.py +98 -0
  155. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/analyze.py +1 -1
  156. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/implement.py +2 -2
  157. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/memory.py +1 -1
  158. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/plan.py +1 -1
  159. package/.agent-src/templates/scripts/work_engine/directives/backend/refine.py +396 -0
  160. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/report.py +36 -4
  161. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/test.py +2 -2
  162. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/verify.py +2 -2
  163. package/.agent-src/templates/scripts/work_engine/directives/mixed/__init__.py +116 -0
  164. package/.agent-src/templates/scripts/work_engine/directives/mixed/contract.py +254 -0
  165. package/.agent-src/templates/scripts/work_engine/directives/mixed/stitch.py +229 -0
  166. package/.agent-src/templates/scripts/work_engine/directives/mixed/ui.py +231 -0
  167. package/.agent-src/templates/scripts/work_engine/directives/ui/__init__.py +113 -0
  168. package/.agent-src/templates/scripts/work_engine/directives/ui/_passthrough.py +44 -0
  169. package/.agent-src/templates/scripts/work_engine/directives/ui/apply.py +241 -0
  170. package/.agent-src/templates/scripts/work_engine/directives/ui/audit.py +414 -0
  171. package/.agent-src/templates/scripts/work_engine/directives/ui/design.py +335 -0
  172. package/.agent-src/templates/scripts/work_engine/directives/ui/polish.py +510 -0
  173. package/.agent-src/templates/scripts/work_engine/directives/ui/review.py +468 -0
  174. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/__init__.py +119 -0
  175. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/_skipped.py +37 -0
  176. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/apply.py +165 -0
  177. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/refine.py +66 -0
  178. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/report.py +62 -0
  179. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/test.py +115 -0
  180. package/.agent-src/templates/scripts/work_engine/dispatcher.py +331 -0
  181. package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +54 -0
  182. package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +32 -0
  183. package/.agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.py +103 -0
  184. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.py +44 -0
  185. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.py +42 -0
  186. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_heartbeat.py +50 -0
  187. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_turn_check.py +49 -0
  188. package/.agent-src/templates/scripts/work_engine/hooks/builtin/directive_set_guard.py +53 -0
  189. package/.agent-src/templates/scripts/work_engine/hooks/builtin/halt_surface_audit.py +50 -0
  190. package/.agent-src/templates/scripts/work_engine/hooks/builtin/state_shape_validation.py +52 -0
  191. package/.agent-src/templates/scripts/work_engine/hooks/builtin/trace.py +84 -0
  192. package/.agent-src/templates/scripts/work_engine/hooks/context.py +66 -0
  193. package/.agent-src/templates/scripts/work_engine/hooks/events.py +44 -0
  194. package/.agent-src/templates/scripts/work_engine/hooks/exceptions.py +79 -0
  195. package/.agent-src/templates/scripts/work_engine/hooks/registry.py +60 -0
  196. package/.agent-src/templates/scripts/work_engine/hooks/runner.py +73 -0
  197. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +141 -0
  198. package/.agent-src/templates/scripts/work_engine/intent/__init__.py +47 -0
  199. package/.agent-src/templates/scripts/work_engine/intent/classify.py +280 -0
  200. package/.agent-src/templates/scripts/work_engine/migration/__init__.py +8 -0
  201. package/.agent-src/templates/scripts/work_engine/migration/v0_to_v1.py +199 -0
  202. package/.agent-src/templates/scripts/work_engine/resolvers/__init__.py +22 -0
  203. package/.agent-src/templates/scripts/work_engine/resolvers/diff.py +106 -0
  204. package/.agent-src/templates/scripts/work_engine/resolvers/file.py +113 -0
  205. package/.agent-src/templates/scripts/work_engine/resolvers/prompt.py +90 -0
  206. package/.agent-src/templates/scripts/work_engine/scoring/__init__.py +14 -0
  207. package/.agent-src/templates/scripts/work_engine/scoring/confidence.py +300 -0
  208. package/.agent-src/templates/scripts/work_engine/stack/__init__.py +31 -0
  209. package/.agent-src/templates/scripts/work_engine/stack/detect.py +187 -0
  210. package/.agent-src/templates/scripts/work_engine/state.py +641 -0
  211. package/.claude-plugin/marketplace.json +105 -2
  212. package/AGENTS.md +36 -8
  213. package/CHANGELOG.md +558 -0
  214. package/README.md +146 -4
  215. package/composer.json +3 -0
  216. package/config/agent-settings.template.yml +45 -0
  217. package/config/gitignore-block.txt +4 -0
  218. package/docs/architecture.md +28 -1
  219. package/docs/development.md +1 -1
  220. package/docs/getting-started.md +3 -2
  221. package/docs/installation.md +86 -0
  222. package/docs/showcase.md +204 -0
  223. package/package.json +9 -1
  224. package/scripts/agent-config +274 -0
  225. package/scripts/audit_cloud_compatibility.py +288 -0
  226. package/scripts/build_cloud_bundle.py +458 -0
  227. package/scripts/build_linear_digest.py +263 -0
  228. package/scripts/chat_history.py +796 -7
  229. package/scripts/check_compression.py +139 -0
  230. package/scripts/check_iron_law_prominence.py +143 -0
  231. package/scripts/check_md_language.py +159 -0
  232. package/scripts/check_portability.py +36 -0
  233. package/scripts/check_reply_consistency.py +140 -0
  234. package/scripts/command_suggester/__init__.py +51 -0
  235. package/scripts/command_suggester/cooldown.py +132 -0
  236. package/scripts/command_suggester/loader.py +70 -0
  237. package/scripts/command_suggester/match.py +180 -0
  238. package/scripts/command_suggester/rank.py +120 -0
  239. package/scripts/command_suggester/render.py +86 -0
  240. package/scripts/command_suggester/sanitize.py +113 -0
  241. package/scripts/command_suggester/settings.py +125 -0
  242. package/scripts/command_suggester/types.py +78 -0
  243. package/scripts/hooks/augment-chat-history.sh +56 -0
  244. package/scripts/install-hooks.sh +67 -0
  245. package/scripts/install.py +150 -33
  246. package/scripts/lint_marketplace.py +27 -0
  247. package/scripts/memory_lookup.py +143 -7
  248. package/scripts/memory_status.py +76 -14
  249. package/scripts/migrate_command_suggestions.py +151 -0
  250. package/scripts/postinstall.sh +16 -0
  251. package/scripts/schemas/command.schema.json +41 -0
  252. package/scripts/skill_linter.py +67 -0
  253. package/scripts/sync_agent_settings.py +42 -12
  254. package/templates/consumer-settings/augment-cli-hooks.json +54 -0
  255. package/templates/consumer-settings/claude-settings.json +55 -1
  256. package/.agent-src/templates/scripts/implement_ticket/cli.py +0 -171
  257. package/.agent-src/templates/scripts/implement_ticket/dispatcher.py +0 -134
  258. package/.agent-src/templates/scripts/implement_ticket/steps/__init__.py +0 -49
  259. package/.agent-src/templates/scripts/implement_ticket/steps/refine.py +0 -140
  260. /package/.agent-src/templates/scripts/{implement_ticket → work_engine}/persona_policy.py +0 -0
@@ -0,0 +1,42 @@
1
+ """``telemetry`` — artefact engagement recording (default-off).
2
+
3
+ The package owns the local-only engagement log
4
+ (``.agent-engagement.jsonl``) that records, at task boundaries, which
5
+ artefacts (skills, rules, commands, guidelines, personas) the agent
6
+ ``consulted`` (loaded into context) and ``applied`` (cited or directly
7
+ drove a decision).
8
+
9
+ Architectural constraints (from
10
+ ``agents/roadmaps/road-to-artifact-engagement-telemetry.md`` Phase 1):
11
+
12
+ - Default-off. ``telemetry.artifact_engagement.enabled: false`` in
13
+ ``.agent-settings.yml`` produces zero file IO and zero token cost.
14
+ - Local only. No server-side aggregation, no cross-repo sync.
15
+ - ID-only payloads. No paths, no file contents, no prompts, no
16
+ secrets ever reach the log.
17
+ - Append-only JSONL. One event per task / phase-step boundary.
18
+ - Strict schema. Unknown artefact kinds are rejected.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from .engagement import (
23
+ ALLOWED_BOUNDARY_KINDS,
24
+ ALLOWED_KINDS,
25
+ SCHEMA_VERSION,
26
+ EngagementEvent,
27
+ EngagementSchemaError,
28
+ append_event,
29
+ now_utc_iso,
30
+ parse_event,
31
+ )
32
+
33
+ __all__ = [
34
+ "ALLOWED_BOUNDARY_KINDS",
35
+ "ALLOWED_KINDS",
36
+ "SCHEMA_VERSION",
37
+ "EngagementEvent",
38
+ "EngagementSchemaError",
39
+ "append_event",
40
+ "now_utc_iso",
41
+ "parse_event",
42
+ ]
@@ -0,0 +1,154 @@
1
+ """Engagement-log aggregator (Phase 4).
2
+
3
+ Pure-stdlib reader: streams ``.agent-engagement.jsonl``, groups events by
4
+ ``(kind, id)``, and returns per-artefact statistics. The renderer in
5
+ ``report_renderer.py`` consumes the dataclasses produced here.
6
+
7
+ Design contract:
8
+
9
+ - **Skip, don't crash.** Malformed JSONL lines are counted in
10
+ ``AggregateResult.skipped_lines`` and dropped. Phase 4 Step 4 locks
11
+ this behaviour: a single corrupt line in a 10k-line log must not
12
+ block the report.
13
+ - **No IO besides the log read.** No network, no settings reads, no
14
+ log creation. Caller (CLI) is responsible for feeding a real path.
15
+ - **``since`` is exclusive on the lower bound** — ``since`` of
16
+ ``2026-04-01T00:00:00Z`` keeps events with ``ts > since``. ``None``
17
+ means "include everything".
18
+ - **Stats are sort-stable.** ``rank_artefacts`` returns a list ordered
19
+ by ``applied`` desc, ``consulted`` desc, then ``(kind, id)`` asc, so
20
+ two reports over the same log render byte-identical.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass, field
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+ from typing import Iterable, Iterator
28
+
29
+ from .engagement import EngagementEvent, EngagementSchemaError, parse_event
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ArtefactStat:
34
+ kind: str
35
+ artefact_id: str
36
+ consulted: int
37
+ applied: int
38
+ last_seen_ts: str
39
+
40
+ @property
41
+ def applied_ratio(self) -> float:
42
+ """Applied / consulted. ``0.0`` when never consulted (impossible
43
+ in practice — applied is a strict subset of consulted — but the
44
+ guard keeps the division safe for malformed inputs)."""
45
+ return (self.applied / self.consulted) if self.consulted else 0.0
46
+
47
+
48
+ @dataclass
49
+ class AggregateResult:
50
+ total_events: int = 0
51
+ parsed_events: int = 0
52
+ skipped_lines: int = 0
53
+ earliest_ts: str | None = None
54
+ latest_ts: str | None = None
55
+ artefacts: dict[tuple[str, str], dict[str, object]] = field(default_factory=dict)
56
+
57
+ def stats(self) -> list[ArtefactStat]:
58
+ """Materialise the accumulated buckets as immutable stats."""
59
+ out: list[ArtefactStat] = []
60
+ for (kind, art_id), bucket in self.artefacts.items():
61
+ out.append(
62
+ ArtefactStat(
63
+ kind=kind,
64
+ artefact_id=art_id,
65
+ consulted=int(bucket["consulted"]),
66
+ applied=int(bucket["applied"]),
67
+ last_seen_ts=str(bucket["last_seen_ts"]),
68
+ )
69
+ )
70
+ return out
71
+
72
+
73
+ def _parse_iso(ts: str) -> datetime | None:
74
+ """Parse a ``%Y-%m-%dT%H:%M:%SZ`` stamp into UTC. Returns ``None``
75
+ for malformed stamps so the caller can skip the comparison cleanly.
76
+ """
77
+ if not isinstance(ts, str) or not ts:
78
+ return None
79
+ try:
80
+ # strptime with literal Z handles the ``now_utc_iso`` format.
81
+ return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
82
+ except ValueError:
83
+ return None
84
+
85
+
86
+ def _iter_events(log_path: Path) -> Iterator[tuple[int, EngagementEvent | None]]:
87
+ """Yield ``(line_number, event_or_None)``. ``None`` signals a skip."""
88
+ if not log_path.is_file():
89
+ return
90
+ with log_path.open("r", encoding="utf-8") as fh:
91
+ for line_no, line in enumerate(fh, start=1):
92
+ stripped = line.strip()
93
+ if not stripped:
94
+ continue
95
+ try:
96
+ event = parse_event(stripped + "\n")
97
+ except EngagementSchemaError:
98
+ yield line_no, None
99
+ continue
100
+ yield line_no, event
101
+
102
+
103
+ def aggregate(
104
+ log_path: Path,
105
+ *,
106
+ since: datetime | None = None,
107
+ ) -> AggregateResult:
108
+ """Stream the JSONL log and compute per-artefact stats."""
109
+ result = AggregateResult()
110
+ for _line_no, event in _iter_events(log_path):
111
+ result.total_events += 1
112
+ if event is None:
113
+ result.skipped_lines += 1
114
+ continue
115
+ ts = _parse_iso(event.ts)
116
+ if since is not None and ts is not None and ts <= since:
117
+ continue
118
+ result.parsed_events += 1
119
+ if result.earliest_ts is None or event.ts < result.earliest_ts:
120
+ result.earliest_ts = event.ts
121
+ if result.latest_ts is None or event.ts > result.latest_ts:
122
+ result.latest_ts = event.ts
123
+ _accumulate(result.artefacts, event.consulted, event.applied, event.ts)
124
+ return result
125
+
126
+
127
+ def _accumulate(
128
+ bucket: dict[tuple[str, str], dict[str, object]],
129
+ consulted: dict[str, list[str]],
130
+ applied: dict[str, list[str]],
131
+ ts: str,
132
+ ) -> None:
133
+ for kind, ids in consulted.items():
134
+ for art_id in ids:
135
+ entry = bucket.setdefault((kind, art_id), {"consulted": 0, "applied": 0, "last_seen_ts": ""})
136
+ entry["consulted"] = int(entry["consulted"]) + 1 # type: ignore[operator]
137
+ if ts > str(entry["last_seen_ts"]):
138
+ entry["last_seen_ts"] = ts
139
+ for kind, ids in applied.items():
140
+ for art_id in ids:
141
+ entry = bucket.setdefault((kind, art_id), {"consulted": 0, "applied": 0, "last_seen_ts": ""})
142
+ entry["applied"] = int(entry["applied"]) + 1 # type: ignore[operator]
143
+ if ts > str(entry["last_seen_ts"]):
144
+ entry["last_seen_ts"] = ts
145
+
146
+
147
+ def rank_artefacts(stats: Iterable[ArtefactStat]) -> list[ArtefactStat]:
148
+ return sorted(
149
+ stats,
150
+ key=lambda s: (-s.applied, -s.consulted, s.kind, s.artefact_id),
151
+ )
152
+
153
+
154
+ __all__ = ["ArtefactStat", "AggregateResult", "aggregate", "rank_artefacts"]
@@ -0,0 +1,171 @@
1
+ """Boundary detection + concurrent-safe recording (Phase 2).
2
+
3
+ Two responsibilities, one module:
4
+
5
+ 1. ``BoundarySession`` — in-process coalescing. Multiple ``add_*`` calls
6
+ within one task / phase-step / tool-call boundary merge into a
7
+ single emitted event (set-union on ``consulted`` / ``applied``).
8
+ Idempotent: calling ``flush()`` twice without new additions is a
9
+ no-op; calling ``add_consulted("skills", ["x"])`` twice records
10
+ ``"x"`` once.
11
+
12
+ 2. ``record_event`` — cross-process durability. Uses ``fcntl.flock``
13
+ (POSIX) so concurrent writers from separate ``./agent-config
14
+ telemetry:record`` invocations cannot interleave inside one JSONL
15
+ line. On non-POSIX (no ``fcntl``) we fall back to a best-effort
16
+ append; the package only ships on POSIX-compatible CI today.
17
+
18
+ The CLI in ``cli.py`` is the only caller that should touch the log
19
+ path directly. Agent-side flows wire through ``BoundarySession``.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ from contextlib import contextmanager
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Iterable, Iterator
28
+
29
+ try: # POSIX advisory file locking
30
+ import fcntl # type: ignore[import-not-found]
31
+ _HAS_FCNTL = True
32
+ except ImportError: # pragma: no cover — Windows / sandbox
33
+ _HAS_FCNTL = False
34
+
35
+ from .engagement import (
36
+ ALLOWED_BOUNDARY_KINDS,
37
+ ALLOWED_KINDS,
38
+ EngagementEvent,
39
+ EngagementSchemaError,
40
+ now_utc_iso,
41
+ )
42
+
43
+
44
+ @dataclass
45
+ class BoundarySession:
46
+ """Collect artefact engagements for one boundary, flush once.
47
+
48
+ Use as a context manager — ``__exit__`` flushes on clean exit and
49
+ suppresses on exception (so failed tasks don't pollute the log).
50
+ """
51
+
52
+ task_id: str
53
+ boundary_kind: str
54
+ log_path: Path
55
+ consulted: dict[str, set[str]] = field(default_factory=dict)
56
+ applied: dict[str, set[str]] = field(default_factory=dict)
57
+ _flushed: bool = False
58
+ _has_data: bool = False
59
+
60
+ def __post_init__(self) -> None:
61
+ if self.boundary_kind not in ALLOWED_BOUNDARY_KINDS:
62
+ raise EngagementSchemaError(
63
+ f"boundary_kind must be one of {ALLOWED_BOUNDARY_KINDS!r}"
64
+ )
65
+ if not isinstance(self.task_id, str) or not self.task_id:
66
+ raise EngagementSchemaError("task_id must be a non-empty string")
67
+
68
+ def add_consulted(self, kind: str, ids: Iterable[str]) -> None:
69
+ self._merge(self.consulted, kind, ids)
70
+
71
+ def add_applied(self, kind: str, ids: Iterable[str]) -> None:
72
+ self._merge(self.applied, kind, ids)
73
+
74
+ def _merge(self, bucket: dict[str, set[str]], kind: str, ids: Iterable[str]) -> None:
75
+ if kind not in ALLOWED_KINDS:
76
+ raise EngagementSchemaError(
77
+ f"{kind!r} is not an allowed artefact kind "
78
+ f"(allowed: {ALLOWED_KINDS!r})"
79
+ )
80
+ target = bucket.setdefault(kind, set())
81
+ for art_id in ids:
82
+ if not isinstance(art_id, str) or not art_id:
83
+ raise EngagementSchemaError(
84
+ f"{kind} ids must be non-empty strings"
85
+ )
86
+ target.add(art_id)
87
+ self._has_data = True
88
+
89
+ def to_event(self) -> EngagementEvent:
90
+ return EngagementEvent(
91
+ ts=now_utc_iso(),
92
+ task_id=self.task_id,
93
+ boundary_kind=self.boundary_kind,
94
+ consulted={k: sorted(v) for k, v in self.consulted.items() if v},
95
+ applied={k: sorted(v) for k, v in self.applied.items() if v},
96
+ )
97
+
98
+ def flush(self) -> bool:
99
+ """Write one merged event to the log. Returns True if written.
100
+
101
+ No-op when already flushed or no data was added — keeps the
102
+ boundary idempotent.
103
+ """
104
+ if self._flushed or not self._has_data:
105
+ return False
106
+ record_event(self.log_path, self.to_event())
107
+ self._flushed = True
108
+ return True
109
+
110
+ def __enter__(self) -> "BoundarySession":
111
+ return self
112
+
113
+ def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
114
+ if exc_type is None:
115
+ self.flush()
116
+ # On exception: do nothing — failed boundary, no record.
117
+
118
+
119
+ def record_event(log_path: Path, event: EngagementEvent) -> None:
120
+ """Append one event under an exclusive file lock.
121
+
122
+ The lock guarantees that two concurrent writers append two
123
+ complete, well-formed lines instead of one interleaved line. We
124
+ open with ``"a"`` so each write atomically extends EOF on POSIX
125
+ once the lock is held.
126
+ """
127
+ event.validate()
128
+ payload = event.to_jsonl().encode("utf-8")
129
+ log_path.parent.mkdir(parents=True, exist_ok=True)
130
+ fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
131
+ try:
132
+ if _HAS_FCNTL:
133
+ fcntl.flock(fd, fcntl.LOCK_EX)
134
+ try:
135
+ written = 0
136
+ while written < len(payload):
137
+ written += os.write(fd, payload[written:])
138
+ os.fsync(fd)
139
+ finally:
140
+ if _HAS_FCNTL:
141
+ fcntl.flock(fd, fcntl.LOCK_UN)
142
+ finally:
143
+ os.close(fd)
144
+
145
+
146
+ @contextmanager
147
+ def open_boundary(
148
+ task_id: str,
149
+ boundary_kind: str,
150
+ log_path: Path,
151
+ ) -> Iterator[BoundarySession]:
152
+ """Convenience context manager around ``BoundarySession``.
153
+
154
+ >>> with open_boundary("ticket-1", "task", Path(".agent-engagement.jsonl")) as s:
155
+ ... s.add_consulted("skills", ["php-coder"])
156
+ ... s.add_applied("skills", ["php-coder"])
157
+ """
158
+ session = BoundarySession(
159
+ task_id=task_id,
160
+ boundary_kind=boundary_kind,
161
+ log_path=log_path,
162
+ )
163
+ with session:
164
+ yield session
165
+
166
+
167
+ __all__ = [
168
+ "BoundarySession",
169
+ "open_boundary",
170
+ "record_event",
171
+ ]
@@ -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")