@deftai/directive-content 0.55.2 → 0.56.1
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/.githooks/pre-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +2 -2
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +47 -1
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/scripts/_agents_md.py +494 -0
- package/scripts/_cache_fetch.py +635 -0
- package/scripts/_cache_quota.py +529 -0
- package/scripts/_cache_refresh.py +163 -0
- package/scripts/_cache_validate.py +209 -0
- package/scripts/_content_root.py +42 -0
- package/scripts/_doctor_state.py +277 -0
- package/scripts/_event_detect.py +305 -0
- package/scripts/_events.py +514 -0
- package/scripts/_lifecycle_hygiene.py +568 -0
- package/scripts/_pathspec.py +91 -0
- package/scripts/_policy_show_cli.py +266 -0
- package/scripts/_precutover.py +92 -0
- package/scripts/_project_context.py +224 -0
- package/scripts/_project_definition_io.py +164 -0
- package/scripts/_relocate_snapshot.py +209 -0
- package/scripts/_relocate_states.py +343 -0
- package/scripts/_resolve_preflight_path.py +152 -0
- package/scripts/_safe_subprocess.py +167 -0
- package/scripts/_session_start_hook.py +205 -0
- package/scripts/_sor_gate_diff.py +365 -0
- package/scripts/_stdio_utf8.py +59 -0
- package/scripts/_triage_bootstrap_gitignore.py +904 -0
- package/scripts/_triage_classify_cli.py +122 -0
- package/scripts/_triage_queue_cli.py +625 -0
- package/scripts/_triage_scope_cli.py +343 -0
- package/scripts/_triage_scope_drift_cli.py +121 -0
- package/scripts/_triage_scope_ignores.py +286 -0
- package/scripts/_triage_scope_milestone.py +432 -0
- package/scripts/_triage_scope_mutations.py +337 -0
- package/scripts/_triage_scope_renderers.py +207 -0
- package/scripts/_triage_smoketest_stages.py +674 -0
- package/scripts/_triage_subscribe_cli.py +140 -0
- package/scripts/_triage_welcome_cli.py +421 -0
- package/scripts/_vbrief_build.py +239 -0
- package/scripts/_vbrief_fidelity.py +479 -0
- package/scripts/_vbrief_legacy.py +589 -0
- package/scripts/_vbrief_reconciliation.py +883 -0
- package/scripts/_vbrief_routing.py +277 -0
- package/scripts/_vbrief_safety.py +778 -0
- package/scripts/_vbrief_sources.py +312 -0
- package/scripts/_vbrief_speckit.py +262 -0
- package/scripts/_vbrief_story_quality.py +353 -0
- package/scripts/_vbrief_validation.py +299 -0
- package/scripts/build_dist.py +412 -0
- package/scripts/cache.py +1078 -0
- package/scripts/cache_scanner.py +745 -0
- package/scripts/candidates_log.py +432 -0
- package/scripts/capacity_backfill.py +680 -0
- package/scripts/capacity_show.py +653 -0
- package/scripts/ci_local.py +689 -0
- package/scripts/code_structure_validate.py +765 -0
- package/scripts/codebase_default_extractor.py +495 -0
- package/scripts/codebase_map.py +304 -0
- package/scripts/codebase_map_fresh.py +104 -0
- package/scripts/codebase_projection_registry.py +94 -0
- package/scripts/codebase_provider.py +582 -0
- package/scripts/doctor.py +2257 -0
- package/scripts/framework_commands.py +505 -0
- package/scripts/gh_rest.py +882 -0
- package/scripts/github_auth_modes.py +437 -0
- package/scripts/github_body.py +292 -0
- package/scripts/ip_risk.py +531 -0
- package/scripts/issue_emit.py +670 -0
- package/scripts/issue_ingest.py +1064 -0
- package/scripts/migrate_preflight.py +418 -0
- package/scripts/migrate_vbrief.py +2677 -0
- package/scripts/monitor_pr.py +401 -0
- package/scripts/pack_migrate_lessons.py +336 -0
- package/scripts/pack_migrate_patterns.py +254 -0
- package/scripts/pack_migrate_rules.py +350 -0
- package/scripts/pack_migrate_skills.py +423 -0
- package/scripts/pack_migrate_strategies.py +311 -0
- package/scripts/pack_migrate_swarm_spec.py +250 -0
- package/scripts/pack_render.py +434 -0
- package/scripts/packs_slice.py +712 -0
- package/scripts/platform_capabilities.py +336 -0
- package/scripts/policy.py +2826 -0
- package/scripts/policy_set.py +324 -0
- package/scripts/pr_check_closing_keywords.py +524 -0
- package/scripts/pr_check_protected_issues.py +267 -0
- package/scripts/pr_merge_readiness.py +1004 -0
- package/scripts/pr_wait_mergeable.py +669 -0
- package/scripts/prd_render.py +159 -0
- package/scripts/preflight_architecture_sor.py +974 -0
- package/scripts/preflight_branch.py +289 -0
- package/scripts/preflight_cache.py +974 -0
- package/scripts/preflight_gh.py +721 -0
- package/scripts/preflight_implementation.py +272 -0
- package/scripts/preflight_story_start.py +838 -0
- package/scripts/preflight_wip_cap.py +149 -0
- package/scripts/probe_session.py +545 -0
- package/scripts/project_render.py +293 -0
- package/scripts/quarantine_ext.py +237 -0
- package/scripts/reconcile_issues.py +1442 -0
- package/scripts/refresh-path.ps1 +107 -0
- package/scripts/release.py +2030 -0
- package/scripts/release_e2e.py +1011 -0
- package/scripts/release_publish.py +486 -0
- package/scripts/release_rollback.py +980 -0
- package/scripts/relocate.py +1034 -0
- package/scripts/resolve_changelog_unreleased.py +667 -0
- package/scripts/resolve_version.py +490 -0
- package/scripts/resume_conditions.py +706 -0
- package/scripts/ritual_sentinel.py +609 -0
- package/scripts/roadmap_render.py +635 -0
- package/scripts/rule_ownership_lint.py +325 -0
- package/scripts/scm.py +591 -0
- package/scripts/scope_audit_log.py +387 -0
- package/scripts/scope_decompose.py +654 -0
- package/scripts/scope_demote.py +509 -0
- package/scripts/scope_lifecycle.py +1126 -0
- package/scripts/scope_undo.py +772 -0
- package/scripts/session_start.py +406 -0
- package/scripts/setup_ghx.py +339 -0
- package/scripts/setup_windows.ps1 +220 -0
- package/scripts/slice_audit.py +585 -0
- package/scripts/slice_record.py +530 -0
- package/scripts/slice_record_existing.py +692 -0
- package/scripts/slug_normalize.py +178 -0
- package/scripts/spec_render.py +477 -0
- package/scripts/spec_validate.py +238 -0
- package/scripts/subagent_monitor.py +658 -0
- package/scripts/swarm_complete_cohort.py +644 -0
- package/scripts/swarm_launch.py +1206 -0
- package/scripts/swarm_readiness.py +554 -0
- package/scripts/swarm_verify_review_clean.py +438 -0
- package/scripts/swarm_worktrees.py +497 -0
- package/scripts/toolchain-check.py +52 -0
- package/scripts/triage_actions.py +871 -0
- package/scripts/triage_bootstrap.py +1153 -0
- package/scripts/triage_bulk.py +630 -0
- package/scripts/triage_classify.py +932 -0
- package/scripts/triage_help.py +1685 -0
- package/scripts/triage_queue.py +1944 -0
- package/scripts/triage_reconcile.py +581 -0
- package/scripts/triage_refresh.py +643 -0
- package/scripts/triage_scope.py +999 -0
- package/scripts/triage_scope_drift.py +575 -0
- package/scripts/triage_smoketest.py +396 -0
- package/scripts/triage_subscribe.py +399 -0
- package/scripts/triage_summary.py +1011 -0
- package/scripts/triage_welcome.py +1178 -0
- package/scripts/ts_check_lane.py +86 -0
- package/scripts/validate-links.py +64 -0
- package/scripts/validate_strategy_output.py +212 -0
- package/scripts/vbrief_activate.py +228 -0
- package/scripts/vbrief_migrate_conformance.py +368 -0
- package/scripts/vbrief_reconcile_graph.py +306 -0
- package/scripts/vbrief_reconcile_labels.py +460 -0
- package/scripts/vbrief_reconcile_umbrellas.py +741 -0
- package/scripts/vbrief_validate.py +1195 -0
- package/scripts/verify-stubs.py +61 -0
- package/scripts/verify_capacity.py +160 -0
- package/scripts/verify_encoding.py +699 -0
- package/scripts/verify_hooks_installed.py +206 -0
- package/scripts/verify_investigation.py +360 -0
- package/scripts/verify_judgment_gates.py +827 -0
- package/scripts/verify_no_task_runtime.py +171 -0
- package/scripts/verify_scm_boundary.py +509 -0
- package/scripts/verify_session_ritual.py +389 -0
- package/scripts/verify_tools.py +426 -0
- package/scripts/verify_vbrief_conformance.py +478 -0
- package/tasks/architecture.yml +13 -0
- package/tasks/cache.yml +69 -0
- package/tasks/capacity.yml +38 -0
- package/tasks/change.yml +46 -0
- package/tasks/changelog.yml +24 -0
- package/tasks/ci.yml +49 -0
- package/tasks/codebase.yml +47 -0
- package/tasks/commit.yml +30 -0
- package/tasks/core.yml +126 -0
- package/tasks/deployments.yml +54 -0
- package/tasks/framework.yml +74 -0
- package/tasks/install.yml +60 -0
- package/tasks/issue.yml +50 -0
- package/tasks/migrate.yml +73 -0
- package/tasks/packs.yml +92 -0
- package/tasks/policy.yml +75 -0
- package/tasks/pr.yml +89 -0
- package/tasks/prd.yml +39 -0
- package/tasks/project.yml +27 -0
- package/tasks/reconcile.yml +32 -0
- package/tasks/relocate.yml +56 -0
- package/tasks/roadmap.yml +28 -0
- package/tasks/scm.yml +126 -0
- package/tasks/scope-undo.yml +36 -0
- package/tasks/scope.yml +141 -0
- package/tasks/session.yml +19 -0
- package/tasks/setup.yml +37 -0
- package/tasks/slice.yml +69 -0
- package/tasks/spec.yml +41 -0
- package/tasks/swarm.yml +85 -0
- package/tasks/toolchain.yml +13 -0
- package/tasks/triage-actions.yml +94 -0
- package/tasks/triage-bootstrap.yml +43 -0
- package/tasks/triage-bulk.yml +75 -0
- package/tasks/triage-classify.yml +30 -0
- package/tasks/triage-queue.yml +50 -0
- package/tasks/triage-reconcile.yml +29 -0
- package/tasks/triage-scope-drift.yml +29 -0
- package/tasks/triage-scope.yml +31 -0
- package/tasks/triage-smoketest.yml +33 -0
- package/tasks/triage-subscribe.yml +36 -0
- package/tasks/triage-summary.yml +29 -0
- package/tasks/triage-welcome.yml +32 -0
- package/tasks/ts.yml +328 -0
- package/tasks/vbrief.yml +206 -0
- package/tasks/verify.yml +292 -0
- package/templates/agents-entry.md +1 -1
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
"""_events.py -- behavioral-event emit helper for framework events (#635).
|
|
2
|
+
|
|
3
|
+
Lands the 4 behavioral events from ``events/registry.json`` (the unified
|
|
4
|
+
registry, post-#706 unification per the Repair Authority [AXIOM] proposal
|
|
5
|
+
in #709 + the data-file-convention follow-up in #710): paired
|
|
6
|
+
``session:interrupted`` / ``session:resumed``, ``plan:approved``, and
|
|
7
|
+
``legacy:detected``. The 5 sibling detection-bound events live in the
|
|
8
|
+
same registry under ``category: detection-bound`` and are emitted via
|
|
9
|
+
``scripts/_event_detect.py`` instead -- both helpers consume the same
|
|
10
|
+
data file but enforce different category boundaries so behavioral and
|
|
11
|
+
detection-bound emission paths remain semantically distinct.
|
|
12
|
+
|
|
13
|
+
Why a structural helper: per the canonical #642 workflow comment locked
|
|
14
|
+
decisions and the Rule Authority [AXIOM] block in ``main.md``, framework
|
|
15
|
+
events are a STRUCTURAL artifact, not prose. The emit helper + JSONL
|
|
16
|
+
append-only log are the deterministic encoding form; ``events/registry.json``
|
|
17
|
+
describes the contract.
|
|
18
|
+
|
|
19
|
+
Storage
|
|
20
|
+
-------
|
|
21
|
+
Events are appended to ``.deft-cache/events.jsonl`` under the project
|
|
22
|
+
root. The log lives under ``.deft-cache/`` (already covered by the
|
|
23
|
+
canonical gitignore deposit) rather than ``.deft/`` because, since #11
|
|
24
|
+
made the ``.deft/core/`` payload a committed artifact, ``.deft/`` is no
|
|
25
|
+
longer blanket-gitignored. The old #401-era assumption that ``.deft/``
|
|
26
|
+
was gitignored went stale, so a default log under ``.deft/`` leaked as an
|
|
27
|
+
untracked file in consumers (#1465). The file is project-local and
|
|
28
|
+
ephemeral. Tests inject a temp path to keep tests hermetic.
|
|
29
|
+
|
|
30
|
+
Pairing
|
|
31
|
+
-------
|
|
32
|
+
``session:interrupted`` / ``session:resumed`` MUST be co-emitted: every
|
|
33
|
+
resumed event carries an ``interrupted_id`` referencing the open
|
|
34
|
+
interrupt. ``validate_pairing`` returns the list of orphan resumed
|
|
35
|
+
records; an empty list means the pair invariant holds. The vBRIEF
|
|
36
|
+
acceptance criterion (3) is enforced via this helper.
|
|
37
|
+
|
|
38
|
+
CLI
|
|
39
|
+
---
|
|
40
|
+
Agents emit by invoking::
|
|
41
|
+
|
|
42
|
+
python -m scripts._events emit <name> --payload '<json>'
|
|
43
|
+
python -m scripts._events emit session:interrupted \
|
|
44
|
+
--session-id <s> --reason context-window-shift
|
|
45
|
+
python -m scripts._events emit session:resumed \
|
|
46
|
+
--session-id <s> --interrupted-id <id>
|
|
47
|
+
python -m scripts._events emit plan:approved \
|
|
48
|
+
--plan-ref <url> --approver <login> --approval-phrase yes
|
|
49
|
+
python -m scripts._events validate-pairing
|
|
50
|
+
|
|
51
|
+
Handlers
|
|
52
|
+
--------
|
|
53
|
+
v0 of this surface is emit-only. The behavioral vBRIEF explicitly defers
|
|
54
|
+
downstream consumer effects (what plan:approved triggers, what action
|
|
55
|
+
legacy:detected drives) to follow-up work. This module exposes
|
|
56
|
+
``read_events`` so consumers can be written in subsequent PRs without
|
|
57
|
+
churning the emit contract.
|
|
58
|
+
|
|
59
|
+
Issue: #635 (epic), #642 (workflow umbrella), #634 (determinism ladder),
|
|
60
|
+
#709 (Repair Authority [AXIOM] -- the rule motivating the registry
|
|
61
|
+
unification), #710 (data-file-convention check follow-up).
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
from __future__ import annotations
|
|
65
|
+
|
|
66
|
+
import argparse
|
|
67
|
+
import json
|
|
68
|
+
import os
|
|
69
|
+
import secrets
|
|
70
|
+
import sys
|
|
71
|
+
import time
|
|
72
|
+
from collections.abc import Iterable
|
|
73
|
+
from datetime import UTC, datetime
|
|
74
|
+
from functools import lru_cache
|
|
75
|
+
from pathlib import Path
|
|
76
|
+
from typing import Any
|
|
77
|
+
|
|
78
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
79
|
+
|
|
80
|
+
from _content_root import content_root # noqa: E402
|
|
81
|
+
|
|
82
|
+
# Default event log location (project-local). Lives under ``.deft-cache/``
|
|
83
|
+
# -- which the canonical gitignore deposit already covers -- rather than
|
|
84
|
+
# ``.deft/``, which is no longer blanket-gitignored now that ``.deft/core/``
|
|
85
|
+
# is a committed payload (#11). The prior ``.deft/events.jsonl`` default
|
|
86
|
+
# leaked as an untracked file in consumers (#1465).
|
|
87
|
+
DEFAULT_EVENT_LOG: Path = Path(".deft-cache") / "events.jsonl"
|
|
88
|
+
|
|
89
|
+
# Path to the unified events registry (data file). Resolved relative to
|
|
90
|
+
# this module so tests and direct script invocations both find it without
|
|
91
|
+
# depending on cwd.
|
|
92
|
+
_REGISTRY_PATH: Path = (
|
|
93
|
+
content_root(Path(__file__).resolve().parent.parent) / "events" / "registry.json"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Behavioral event category enum value -- this helper only emits events
|
|
97
|
+
# that carry ``category: "behavioral"`` in the unified registry. Sibling
|
|
98
|
+
# detection-bound events are emitted via ``scripts/_event_detect.py``.
|
|
99
|
+
_BEHAVIORAL_CATEGORY: str = "behavioral"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@lru_cache(maxsize=1)
|
|
103
|
+
def _load_behavioral_registry() -> tuple[
|
|
104
|
+
frozenset[str], dict[str, tuple[str, ...]]
|
|
105
|
+
]:
|
|
106
|
+
"""Return ``(KNOWN_EVENTS, REQUIRED_PAYLOAD)`` from the unified registry.
|
|
107
|
+
|
|
108
|
+
Reads ``events/registry.json`` once per process and filters to events
|
|
109
|
+
whose ``category`` matches ``_BEHAVIORAL_CATEGORY``. Required payload
|
|
110
|
+
fields are derived from a hard-coded contract (per-event tuples below)
|
|
111
|
+
rather than the registry's free-form payload-description map -- the
|
|
112
|
+
registry's payload field describes shape and intent for humans/schema
|
|
113
|
+
consumers, but the emit-time required-field gate is a code contract
|
|
114
|
+
that the unified registry's free-form descriptions don't encode
|
|
115
|
+
losslessly. The hard-coded ``_REQUIRED_BEHAVIORAL_PAYLOAD`` below is
|
|
116
|
+
the single source of truth for emit-time payload validation.
|
|
117
|
+
"""
|
|
118
|
+
data = json.loads(_REGISTRY_PATH.read_text(encoding="utf-8"))
|
|
119
|
+
behavioral_names = frozenset(
|
|
120
|
+
event["name"]
|
|
121
|
+
for event in data.get("events", [])
|
|
122
|
+
if isinstance(event, dict)
|
|
123
|
+
and event.get("category") == _BEHAVIORAL_CATEGORY
|
|
124
|
+
and "name" in event
|
|
125
|
+
)
|
|
126
|
+
return behavioral_names, dict(_REQUIRED_BEHAVIORAL_PAYLOAD)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# Per-event required payload fields. Single source of truth for the
|
|
130
|
+
# emit-time required-field gate. Mirrors the canonical payload contracts
|
|
131
|
+
# documented in events/registry.json under category=behavioral.
|
|
132
|
+
_REQUIRED_BEHAVIORAL_PAYLOAD: dict[str, tuple[str, ...]] = {
|
|
133
|
+
"session:interrupted": ("session_id", "reason"),
|
|
134
|
+
"session:resumed": ("session_id", "interrupted_id"),
|
|
135
|
+
"plan:approved": ("plan_ref", "approver"),
|
|
136
|
+
"legacy:detected": ("title", "source", "range", "size_bytes"),
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _registered_behavioral_names() -> frozenset[str]:
|
|
141
|
+
return _load_behavioral_registry()[0]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _required_payload_map() -> dict[str, tuple[str, ...]]:
|
|
145
|
+
return _load_behavioral_registry()[1]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def clear_registry_cache() -> None:
|
|
149
|
+
"""Reset the in-process registry cache. Used by tests."""
|
|
150
|
+
_load_behavioral_registry.cache_clear()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class _LazyKnownEvents:
|
|
154
|
+
"""Lazy proxy for ``KNOWN_EVENTS`` that hits the unified registry.
|
|
155
|
+
|
|
156
|
+
Read-only ``frozenset``-compatible accessor so existing test code
|
|
157
|
+
using ``KNOWN_EVENTS == frozenset({...})`` and ``name in KNOWN_EVENTS``
|
|
158
|
+
continues to work unchanged. Resolves on every access so a test that
|
|
159
|
+
points the registry at a fixture path can still re-read after
|
|
160
|
+
``clear_registry_cache()``.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def _resolved(self) -> frozenset[str]:
|
|
164
|
+
return _registered_behavioral_names()
|
|
165
|
+
|
|
166
|
+
def __contains__(self, item: object) -> bool:
|
|
167
|
+
return item in self._resolved()
|
|
168
|
+
|
|
169
|
+
def __iter__(self):
|
|
170
|
+
return iter(self._resolved())
|
|
171
|
+
|
|
172
|
+
def __len__(self) -> int:
|
|
173
|
+
return len(self._resolved())
|
|
174
|
+
|
|
175
|
+
def __eq__(self, other: object) -> bool:
|
|
176
|
+
return self._resolved() == other
|
|
177
|
+
|
|
178
|
+
def __hash__(self) -> int:
|
|
179
|
+
return hash(self._resolved())
|
|
180
|
+
|
|
181
|
+
def __repr__(self) -> str:
|
|
182
|
+
return repr(self._resolved())
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class _LazyRequiredPayload:
|
|
186
|
+
"""Lazy proxy for ``REQUIRED_PAYLOAD`` -- behaves like a read-only dict."""
|
|
187
|
+
|
|
188
|
+
def _resolved(self) -> dict[str, tuple[str, ...]]:
|
|
189
|
+
return _required_payload_map()
|
|
190
|
+
|
|
191
|
+
def __contains__(self, item: object) -> bool:
|
|
192
|
+
return item in self._resolved()
|
|
193
|
+
|
|
194
|
+
def __getitem__(self, key: str) -> tuple[str, ...]:
|
|
195
|
+
return self._resolved()[key]
|
|
196
|
+
|
|
197
|
+
def get(
|
|
198
|
+
self, key: str, default: tuple[str, ...] = ()
|
|
199
|
+
) -> tuple[str, ...]:
|
|
200
|
+
return self._resolved().get(key, default)
|
|
201
|
+
|
|
202
|
+
def __iter__(self):
|
|
203
|
+
return iter(self._resolved())
|
|
204
|
+
|
|
205
|
+
def __len__(self) -> int:
|
|
206
|
+
return len(self._resolved())
|
|
207
|
+
|
|
208
|
+
def items(self):
|
|
209
|
+
return self._resolved().items()
|
|
210
|
+
|
|
211
|
+
def keys(self):
|
|
212
|
+
return self._resolved().keys()
|
|
213
|
+
|
|
214
|
+
def values(self):
|
|
215
|
+
return self._resolved().values()
|
|
216
|
+
|
|
217
|
+
def __eq__(self, other: object) -> bool:
|
|
218
|
+
return self._resolved() == other
|
|
219
|
+
|
|
220
|
+
def __repr__(self) -> str:
|
|
221
|
+
return repr(self._resolved())
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# Public lazy module-level constants. Existing callers (tests, CLI, the
|
|
225
|
+
# migrator's _legacy_event_emitter) keep their existing import shape:
|
|
226
|
+
# from _events import KNOWN_EVENTS, REQUIRED_PAYLOAD
|
|
227
|
+
# but the values now resolve from events/registry.json on each access
|
|
228
|
+
# rather than a hard-coded frozenset.
|
|
229
|
+
KNOWN_EVENTS: _LazyKnownEvents = _LazyKnownEvents()
|
|
230
|
+
REQUIRED_PAYLOAD: _LazyRequiredPayload = _LazyRequiredPayload()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
# Public API
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _resolve_log_path(log_path: Path | str | None) -> Path:
|
|
239
|
+
if log_path is not None:
|
|
240
|
+
return Path(log_path)
|
|
241
|
+
env_path = os.environ.get("DEFT_EVENT_LOG")
|
|
242
|
+
if env_path:
|
|
243
|
+
return Path(env_path)
|
|
244
|
+
return DEFAULT_EVENT_LOG
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _new_event_id() -> str:
|
|
248
|
+
"""Return a sortable, collision-resistant event id.
|
|
249
|
+
|
|
250
|
+
Format: ``<unix-ns>-<8-hex-rand>``. Sortable by emission time so the
|
|
251
|
+
log can be diffed cleanly; rand suffix prevents collision when two
|
|
252
|
+
emissions land in the same nanosecond (rare but possible on Windows).
|
|
253
|
+
"""
|
|
254
|
+
return f"{time.time_ns()}-{secrets.token_hex(4)}"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def emit(
|
|
258
|
+
name: str,
|
|
259
|
+
payload: dict[str, Any],
|
|
260
|
+
*,
|
|
261
|
+
log_path: Path | str | None = None,
|
|
262
|
+
detected_at: str | None = None,
|
|
263
|
+
) -> dict[str, Any]:
|
|
264
|
+
"""Append an event record to the JSONL log and return it.
|
|
265
|
+
|
|
266
|
+
``name`` MUST be a registered event from ``KNOWN_EVENTS``. ``payload``
|
|
267
|
+
MUST contain the required fields for that event per
|
|
268
|
+
``REQUIRED_PAYLOAD``. ``detected_at`` defaults to the current UTC
|
|
269
|
+
timestamp (ISO 8601, second precision).
|
|
270
|
+
|
|
271
|
+
Raises ``ValueError`` for an unknown event name or a missing required
|
|
272
|
+
payload field.
|
|
273
|
+
"""
|
|
274
|
+
behavioral_names = _registered_behavioral_names()
|
|
275
|
+
if name not in behavioral_names:
|
|
276
|
+
raise ValueError(
|
|
277
|
+
f"unknown event {name!r}; expected one of "
|
|
278
|
+
f"{sorted(behavioral_names)}"
|
|
279
|
+
)
|
|
280
|
+
required = _required_payload_map().get(name, ())
|
|
281
|
+
missing = [k for k in required if k not in payload]
|
|
282
|
+
if missing:
|
|
283
|
+
raise ValueError(
|
|
284
|
+
f"event {name!r} payload missing required fields: {missing}"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
record: dict[str, Any] = {
|
|
288
|
+
"event": name,
|
|
289
|
+
"id": _new_event_id(),
|
|
290
|
+
"detected_at": detected_at
|
|
291
|
+
or datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
292
|
+
"payload": dict(payload),
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
target = _resolve_log_path(log_path)
|
|
296
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
297
|
+
line = json.dumps(record, ensure_ascii=False, sort_keys=True)
|
|
298
|
+
with target.open("a", encoding="utf-8") as fh:
|
|
299
|
+
fh.write(line)
|
|
300
|
+
fh.write("\n")
|
|
301
|
+
return record
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def read_events(
|
|
305
|
+
log_path: Path | str | None = None,
|
|
306
|
+
) -> list[dict[str, Any]]:
|
|
307
|
+
"""Return all events from the log in emission order.
|
|
308
|
+
|
|
309
|
+
Missing log returns an empty list. Malformed lines are skipped (the
|
|
310
|
+
log is append-only; a partial last line could survive a crash).
|
|
311
|
+
"""
|
|
312
|
+
target = _resolve_log_path(log_path)
|
|
313
|
+
if not target.exists():
|
|
314
|
+
return []
|
|
315
|
+
out: list[dict[str, Any]] = []
|
|
316
|
+
with target.open("r", encoding="utf-8") as fh:
|
|
317
|
+
for line in fh:
|
|
318
|
+
line = line.strip()
|
|
319
|
+
if not line:
|
|
320
|
+
continue
|
|
321
|
+
try:
|
|
322
|
+
out.append(json.loads(line))
|
|
323
|
+
except json.JSONDecodeError:
|
|
324
|
+
continue
|
|
325
|
+
return out
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def validate_pairing(
|
|
329
|
+
events: Iterable[dict[str, Any]] | None = None,
|
|
330
|
+
*,
|
|
331
|
+
log_path: Path | str | None = None,
|
|
332
|
+
) -> list[dict[str, Any]]:
|
|
333
|
+
"""Return the list of orphan ``session:resumed`` records.
|
|
334
|
+
|
|
335
|
+
A ``session:resumed`` is an orphan if its ``interrupted_id`` does not
|
|
336
|
+
match the ``id`` of any prior, *unconsumed* ``session:interrupted``
|
|
337
|
+
in the same log. Each interrupt id satisfies at most one resumed
|
|
338
|
+
event -- a second ``session:resumed`` referencing the same
|
|
339
|
+
``interrupted_id`` is treated as an orphan (1:1 pairing per the
|
|
340
|
+
vBRIEF "co-emitted" semantics, Greptile #706 P2).
|
|
341
|
+
|
|
342
|
+
Pass ``events`` explicitly to validate an in-memory stream; otherwise
|
|
343
|
+
the helper reads the configured log path.
|
|
344
|
+
"""
|
|
345
|
+
if events is None:
|
|
346
|
+
events = read_events(log_path=log_path)
|
|
347
|
+
open_interrupts: set[str] = set()
|
|
348
|
+
orphans: list[dict[str, Any]] = []
|
|
349
|
+
for record in events:
|
|
350
|
+
name = record.get("event")
|
|
351
|
+
if name == "session:interrupted":
|
|
352
|
+
event_id = record.get("id")
|
|
353
|
+
if isinstance(event_id, str):
|
|
354
|
+
open_interrupts.add(event_id)
|
|
355
|
+
elif name == "session:resumed":
|
|
356
|
+
payload = record.get("payload") or {}
|
|
357
|
+
ref = payload.get("interrupted_id")
|
|
358
|
+
if isinstance(ref, str) and ref in open_interrupts:
|
|
359
|
+
# 1:1 pairing: consume the interrupt id so a second
|
|
360
|
+
# session:resumed referencing the same id is reported
|
|
361
|
+
# as an orphan.
|
|
362
|
+
open_interrupts.discard(ref)
|
|
363
|
+
else:
|
|
364
|
+
orphans.append(record)
|
|
365
|
+
return orphans
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
# CLI
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _parse_payload_args(args: argparse.Namespace) -> dict[str, Any]:
|
|
374
|
+
"""Build the payload dict from the parsed CLI args.
|
|
375
|
+
|
|
376
|
+
The ``--payload`` JSON arg, when present, is the seed; named flags
|
|
377
|
+
overlay on top so agents can mix structured input with one-off
|
|
378
|
+
fields.
|
|
379
|
+
"""
|
|
380
|
+
payload: dict[str, Any] = {}
|
|
381
|
+
if args.payload:
|
|
382
|
+
try:
|
|
383
|
+
data = json.loads(args.payload)
|
|
384
|
+
except json.JSONDecodeError as exc:
|
|
385
|
+
raise SystemExit(f"--payload is not valid JSON: {exc}") from exc
|
|
386
|
+
if not isinstance(data, dict):
|
|
387
|
+
raise SystemExit("--payload must be a JSON object")
|
|
388
|
+
payload.update(data)
|
|
389
|
+
|
|
390
|
+
# Named overlays (only set if provided).
|
|
391
|
+
name_to_field = {
|
|
392
|
+
"session_id": args.session_id,
|
|
393
|
+
"reason": args.reason,
|
|
394
|
+
"interrupted_id": args.interrupted_id,
|
|
395
|
+
"plan_ref": args.plan_ref,
|
|
396
|
+
"approver": args.approver,
|
|
397
|
+
"approval_phrase": args.approval_phrase,
|
|
398
|
+
"pr_number": args.pr_number,
|
|
399
|
+
"detail": args.detail,
|
|
400
|
+
"title": args.title,
|
|
401
|
+
"source": args.source,
|
|
402
|
+
"range": args.range_,
|
|
403
|
+
"size_bytes": args.size_bytes,
|
|
404
|
+
"inline": args.inline,
|
|
405
|
+
"sidecar": args.sidecar,
|
|
406
|
+
"flagged": args.flagged,
|
|
407
|
+
}
|
|
408
|
+
for k, v in name_to_field.items():
|
|
409
|
+
if v is not None:
|
|
410
|
+
payload[k] = v
|
|
411
|
+
return payload
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _build_arg_parser() -> argparse.ArgumentParser:
|
|
415
|
+
parser = argparse.ArgumentParser(
|
|
416
|
+
prog="python -m scripts._events",
|
|
417
|
+
description="Emit and inspect structural framework events.",
|
|
418
|
+
)
|
|
419
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
420
|
+
|
|
421
|
+
emit_p = sub.add_parser("emit", help="Append an event to the log.")
|
|
422
|
+
emit_p.add_argument("name", choices=sorted(_registered_behavioral_names()))
|
|
423
|
+
emit_p.add_argument("--payload", help="JSON object with the payload.")
|
|
424
|
+
emit_p.add_argument("--log", dest="log", help="Override event log path.")
|
|
425
|
+
# Convenience flags. Using --range- on the dest avoids shadowing the
|
|
426
|
+
# builtin ``range``.
|
|
427
|
+
emit_p.add_argument("--session-id")
|
|
428
|
+
emit_p.add_argument("--reason")
|
|
429
|
+
emit_p.add_argument("--interrupted-id")
|
|
430
|
+
emit_p.add_argument("--plan-ref")
|
|
431
|
+
emit_p.add_argument("--approver")
|
|
432
|
+
emit_p.add_argument("--approval-phrase")
|
|
433
|
+
emit_p.add_argument("--pr-number", type=int)
|
|
434
|
+
emit_p.add_argument("--detail")
|
|
435
|
+
emit_p.add_argument("--title")
|
|
436
|
+
emit_p.add_argument("--source")
|
|
437
|
+
emit_p.add_argument("--range", dest="range_")
|
|
438
|
+
emit_p.add_argument("--size-bytes", type=int)
|
|
439
|
+
emit_p.add_argument(
|
|
440
|
+
"--inline",
|
|
441
|
+
type=lambda s: s.lower() in {"1", "true", "yes"},
|
|
442
|
+
default=None,
|
|
443
|
+
)
|
|
444
|
+
emit_p.add_argument("--sidecar")
|
|
445
|
+
emit_p.add_argument(
|
|
446
|
+
"--flagged",
|
|
447
|
+
type=lambda s: s.lower() in {"1", "true", "yes"},
|
|
448
|
+
default=None,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
list_p = sub.add_parser("list", help="Print events as JSON lines.")
|
|
452
|
+
list_p.add_argument("--log", dest="log", help="Override event log path.")
|
|
453
|
+
|
|
454
|
+
pair_p = sub.add_parser(
|
|
455
|
+
"validate-pairing",
|
|
456
|
+
help="Exit non-zero if any session:resumed is orphan.",
|
|
457
|
+
)
|
|
458
|
+
pair_p.add_argument("--log", dest="log", help="Override event log path.")
|
|
459
|
+
|
|
460
|
+
return parser
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def main(argv: list[str] | None = None) -> int:
|
|
464
|
+
parser = _build_arg_parser()
|
|
465
|
+
args = parser.parse_args(argv)
|
|
466
|
+
|
|
467
|
+
if args.cmd == "emit":
|
|
468
|
+
payload = _parse_payload_args(args)
|
|
469
|
+
try:
|
|
470
|
+
record = emit(args.name, payload, log_path=args.log)
|
|
471
|
+
except ValueError as exc:
|
|
472
|
+
sys.stderr.write(f"emit failed: {exc}\n")
|
|
473
|
+
return 2
|
|
474
|
+
sys.stdout.write(json.dumps(record, ensure_ascii=False))
|
|
475
|
+
sys.stdout.write("\n")
|
|
476
|
+
return 0
|
|
477
|
+
|
|
478
|
+
if args.cmd == "list":
|
|
479
|
+
for record in read_events(log_path=args.log):
|
|
480
|
+
sys.stdout.write(
|
|
481
|
+
json.dumps(record, ensure_ascii=False, sort_keys=True)
|
|
482
|
+
)
|
|
483
|
+
sys.stdout.write("\n")
|
|
484
|
+
return 0
|
|
485
|
+
|
|
486
|
+
if args.cmd == "validate-pairing":
|
|
487
|
+
orphans = validate_pairing(log_path=args.log)
|
|
488
|
+
if orphans:
|
|
489
|
+
sys.stderr.write(
|
|
490
|
+
f"orphan session:resumed records ({len(orphans)}): "
|
|
491
|
+
f"{[r.get('id') for r in orphans]}\n"
|
|
492
|
+
)
|
|
493
|
+
return 1
|
|
494
|
+
sys.stdout.write("ok\n")
|
|
495
|
+
return 0
|
|
496
|
+
|
|
497
|
+
parser.print_help()
|
|
498
|
+
return 2
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
__all__ = [
|
|
502
|
+
"DEFAULT_EVENT_LOG",
|
|
503
|
+
"KNOWN_EVENTS",
|
|
504
|
+
"REQUIRED_PAYLOAD",
|
|
505
|
+
"clear_registry_cache",
|
|
506
|
+
"emit",
|
|
507
|
+
"main",
|
|
508
|
+
"read_events",
|
|
509
|
+
"validate_pairing",
|
|
510
|
+
]
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
if __name__ == "__main__": # pragma: no cover - thin CLI shim
|
|
514
|
+
raise SystemExit(main())
|