@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.
- 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 +81 -3
- package/.agent-src/scripts/update_roadmap_progress.py +48 -6
- 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/github-workflows/roadmap-progress-check.yml +63 -0
- package/.agent-src/templates/hooks/pre-commit-roadmap-progress +60 -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/memory_lookup.py +382 -21
- package/.agent-src/templates/scripts/memory_status.py +110 -9
- 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 +558 -0
- package/README.md +146 -4
- package/composer.json +3 -0
- 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 +3 -2
- package/docs/installation.md +86 -0
- package/docs/showcase.md +204 -0
- package/package.json +9 -1
- package/scripts/agent-config +274 -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/memory_lookup.py +143 -7
- package/scripts/memory_status.py +76 -14
- package/scripts/migrate_command_suggestions.py +151 -0
- package/scripts/postinstall.sh +16 -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,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")
|