intui 1.0.0__py3-none-any.whl
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.
- intui/__init__.py +79 -0
- intui/actions/__init__.py +15 -0
- intui/actions/confirm.py +63 -0
- intui/actions/files.py +58 -0
- intui/actions/intents.py +44 -0
- intui/adapters/__init__.py +10 -0
- intui/adapters/intentforge.py +259 -0
- intui/app.py +189 -0
- intui/console/__init__.py +12 -0
- intui/console/app.py +297 -0
- intui/console/cli.py +137 -0
- intui/console/runner.py +50 -0
- intui/emit.py +380 -0
- intui/events/__init__.py +36 -0
- intui/events/envelope.py +144 -0
- intui/events/process.py +117 -0
- intui/events/recording.py +62 -0
- intui/events/sources.py +230 -0
- intui/events/stream.py +103 -0
- intui/events/validate.py +108 -0
- intui/kit/__init__.py +71 -0
- intui/kit/activity_strip.py +63 -0
- intui/kit/chip.py +100 -0
- intui/kit/command_bar.py +101 -0
- intui/kit/command_palette.py +126 -0
- intui/kit/conversation_log.py +56 -0
- intui/kit/diff_viewer.py +139 -0
- intui/kit/evidence_panel.py +72 -0
- intui/kit/file_tree.py +147 -0
- intui/kit/lanes.py +116 -0
- intui/kit/metrics_panel.py +66 -0
- intui/kit/mode_strip.py +70 -0
- intui/kit/prompt_input.py +45 -0
- intui/kit/state/__init__.py +226 -0
- intui/kit/state/activity.py +45 -0
- intui/kit/state/artifacts.py +390 -0
- intui/kit/state/commands.py +140 -0
- intui/kit/state/conversation.py +134 -0
- intui/kit/state/metrics.py +182 -0
- intui/kit/state/model.py +115 -0
- intui/kit/state/modes.py +68 -0
- intui/kit/state/reduce.py +160 -0
- intui/kit/state/run_status.py +55 -0
- intui/kit/state/selectors.py +250 -0
- intui/kit/state/views.py +81 -0
- intui/kit/state/workspace.py +216 -0
- intui/kit/tree.py +87 -0
- intui/kit/view_router.py +62 -0
- intui/py.typed +0 -0
- intui/state/__init__.py +18 -0
- intui/state/reducer.py +47 -0
- intui/state/snapshot.py +59 -0
- intui/state/store.py +130 -0
- intui/state/timeline.py +53 -0
- intui/theming/__init__.py +19 -0
- intui/theming/default.py +25 -0
- intui/theming/signal_style.py +59 -0
- intui/theming/theme.py +46 -0
- intui/viewmodels/__init__.py +6 -0
- intui/viewmodels/health.py +41 -0
- intui/viewmodels/selector.py +40 -0
- intui/widgets/__init__.py +15 -0
- intui/widgets/bound.py +99 -0
- intui/widgets/bridge.py +87 -0
- intui/widgets/signal.py +141 -0
- intui-1.0.0.dist-info/METADATA +173 -0
- intui-1.0.0.dist-info/RECORD +70 -0
- intui-1.0.0.dist-info/WHEEL +4 -0
- intui-1.0.0.dist-info/entry_points.txt +2 -0
- intui-1.0.0.dist-info/licenses/LICENSE +21 -0
intui/__init__.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""in-TUI-tion: a library for rich, first-class terminal user interfaces.
|
|
2
|
+
|
|
3
|
+
The engine-free pipeline core is re-exported here (``import intui`` pulls in
|
|
4
|
+
no terminal engine). The rendering layer lives in :mod:`intui.widgets` and
|
|
5
|
+
:mod:`intui.app` — import those explicitly when building a UI.
|
|
6
|
+
|
|
7
|
+
Public API contract: specs/001-core-library-foundation/contracts/public-api.md
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from intui.actions import ConfirmationFlow, Intent, IntentHandler
|
|
11
|
+
from intui.emit import RunRecorder, run_recorder
|
|
12
|
+
from intui.events import (
|
|
13
|
+
EnvelopeError,
|
|
14
|
+
Event,
|
|
15
|
+
EventSource,
|
|
16
|
+
EventStream,
|
|
17
|
+
JsonlReplaySource,
|
|
18
|
+
MemorySource,
|
|
19
|
+
Scope,
|
|
20
|
+
StreamError,
|
|
21
|
+
StreamHealth,
|
|
22
|
+
StreamIssue,
|
|
23
|
+
StreamState,
|
|
24
|
+
parse_event,
|
|
25
|
+
read_recording,
|
|
26
|
+
validate_event,
|
|
27
|
+
validate_stream,
|
|
28
|
+
write_recording,
|
|
29
|
+
)
|
|
30
|
+
from intui.state import Reducer, ReducerError, SliceReducer, Snapshot, Store, compose_reducers
|
|
31
|
+
from intui.theming import (
|
|
32
|
+
DEFAULT_THEME,
|
|
33
|
+
MotionMode,
|
|
34
|
+
StatusStyle,
|
|
35
|
+
Theme,
|
|
36
|
+
resolve_status_style,
|
|
37
|
+
)
|
|
38
|
+
from intui.viewmodels import HealthView, Selector, health_view, selector
|
|
39
|
+
|
|
40
|
+
__version__ = "1.0.0"
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"ConfirmationFlow",
|
|
44
|
+
"DEFAULT_THEME",
|
|
45
|
+
"EnvelopeError",
|
|
46
|
+
"Event",
|
|
47
|
+
"EventSource",
|
|
48
|
+
"EventStream",
|
|
49
|
+
"HealthView",
|
|
50
|
+
"Intent",
|
|
51
|
+
"IntentHandler",
|
|
52
|
+
"JsonlReplaySource",
|
|
53
|
+
"MemorySource",
|
|
54
|
+
"MotionMode",
|
|
55
|
+
"Reducer",
|
|
56
|
+
"ReducerError",
|
|
57
|
+
"RunRecorder",
|
|
58
|
+
"Scope",
|
|
59
|
+
"Selector",
|
|
60
|
+
"SliceReducer",
|
|
61
|
+
"Snapshot",
|
|
62
|
+
"StatusStyle",
|
|
63
|
+
"Store",
|
|
64
|
+
"StreamError",
|
|
65
|
+
"StreamHealth",
|
|
66
|
+
"StreamIssue",
|
|
67
|
+
"StreamState",
|
|
68
|
+
"Theme",
|
|
69
|
+
"compose_reducers",
|
|
70
|
+
"health_view",
|
|
71
|
+
"parse_event",
|
|
72
|
+
"read_recording",
|
|
73
|
+
"resolve_status_style",
|
|
74
|
+
"run_recorder",
|
|
75
|
+
"selector",
|
|
76
|
+
"validate_event",
|
|
77
|
+
"validate_stream",
|
|
78
|
+
"write_recording",
|
|
79
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""intui.actions: intents, the risky-action confirmation flow, and opt-in
|
|
2
|
+
file-action helpers an app calls to fulfill file intents."""
|
|
3
|
+
|
|
4
|
+
from intui.actions.confirm import ConfirmationFlow
|
|
5
|
+
from intui.actions.files import delete_path, open_in_editor, save_copy
|
|
6
|
+
from intui.actions.intents import Intent, IntentHandler
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ConfirmationFlow",
|
|
10
|
+
"Intent",
|
|
11
|
+
"IntentHandler",
|
|
12
|
+
"delete_path",
|
|
13
|
+
"open_in_editor",
|
|
14
|
+
"save_copy",
|
|
15
|
+
]
|
intui/actions/confirm.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Confirmation flow for risky intents (FR-016).
|
|
2
|
+
|
|
3
|
+
Lifecycle: ``created -> (confirming -> confirmed | cancelled) -> delivered``.
|
|
4
|
+
Non-risky intents skip the confirming states. While a confirmation is
|
|
5
|
+
pending, the prompt owns the interaction: further submissions are rejected
|
|
6
|
+
until the pending intent is resolved.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
from intui.actions.intents import Intent
|
|
14
|
+
|
|
15
|
+
Deliver = Callable[[Intent], None]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfirmationFlow:
|
|
19
|
+
"""Pure state machine; UI prompts subscribe via ``on_pending_changed``."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
deliver: Deliver,
|
|
24
|
+
on_pending_changed: Callable[[Intent | None], None] | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self._deliver = deliver
|
|
27
|
+
self._on_pending_changed = on_pending_changed
|
|
28
|
+
self._pending: Intent | None = None
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def pending(self) -> Intent | None:
|
|
32
|
+
return self._pending
|
|
33
|
+
|
|
34
|
+
def submit(self, intent: Intent) -> bool:
|
|
35
|
+
"""Submit an intent. Returns False if rejected (confirmation pending)."""
|
|
36
|
+
if self._pending is not None:
|
|
37
|
+
return False
|
|
38
|
+
if intent.risky:
|
|
39
|
+
self._set_pending(intent)
|
|
40
|
+
return True
|
|
41
|
+
self._deliver(intent)
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
def confirm(self) -> None:
|
|
45
|
+
"""Deliver the pending intent. No-op when nothing is pending."""
|
|
46
|
+
pending, self._pending = self._pending, None
|
|
47
|
+
if pending is not None:
|
|
48
|
+
self._notify()
|
|
49
|
+
self._deliver(pending)
|
|
50
|
+
|
|
51
|
+
def cancel(self) -> None:
|
|
52
|
+
"""Discard the pending intent — it is never delivered."""
|
|
53
|
+
if self._pending is not None:
|
|
54
|
+
self._pending = None
|
|
55
|
+
self._notify()
|
|
56
|
+
|
|
57
|
+
def _set_pending(self, intent: Intent) -> None:
|
|
58
|
+
self._pending = intent
|
|
59
|
+
self._notify()
|
|
60
|
+
|
|
61
|
+
def _notify(self) -> None:
|
|
62
|
+
if self._on_pending_changed is not None:
|
|
63
|
+
self._on_pending_changed(self._pending)
|
intui/actions/files.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Opt-in file-action helpers an application calls to fulfill file intents.
|
|
2
|
+
|
|
3
|
+
These are conveniences — the library NEVER calls them on its own (Principle III).
|
|
4
|
+
An app wires them from its ``handle_intent`` (or enables them in the console via
|
|
5
|
+
``build_console(file_actions=True)``). Engine-free (stdlib only); ``open_in_editor``
|
|
6
|
+
takes an injectable ``run`` so callers/tests can avoid launching a process.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
Runner = Callable[[list[str]], Any]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def delete_path(path: Path | str) -> None:
|
|
23
|
+
"""Delete a file. Raises ``FileNotFoundError`` if it is absent."""
|
|
24
|
+
Path(path).unlink()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def save_copy(src: Path | str, dst: Path | str) -> Path:
|
|
28
|
+
"""Copy ``src`` to ``dst`` and return the destination path."""
|
|
29
|
+
shutil.copy(src, dst)
|
|
30
|
+
return Path(dst)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def open_in_editor(
|
|
34
|
+
path: Path | str,
|
|
35
|
+
*,
|
|
36
|
+
editor: str | None = None,
|
|
37
|
+
run: Runner | None = None,
|
|
38
|
+
) -> list[str]:
|
|
39
|
+
"""Open ``path`` in an editor and return the argv that was invoked.
|
|
40
|
+
|
|
41
|
+
Resolution order: ``editor`` → ``$EDITOR`` → ``$VISUAL`` → a platform opener
|
|
42
|
+
(``cmd /c start`` on Windows, ``open`` on macOS, ``xdg-open`` elsewhere).
|
|
43
|
+
``run`` defaults to :class:`subprocess.Popen`; inject it to capture the argv
|
|
44
|
+
without launching anything.
|
|
45
|
+
"""
|
|
46
|
+
runner: Runner = run if run is not None else subprocess.Popen
|
|
47
|
+
target = str(path)
|
|
48
|
+
chosen = editor or os.environ.get("EDITOR") or os.environ.get("VISUAL")
|
|
49
|
+
if chosen:
|
|
50
|
+
argv = [chosen, target]
|
|
51
|
+
elif sys.platform == "win32":
|
|
52
|
+
argv = ["cmd", "/c", "start", "", target]
|
|
53
|
+
elif sys.platform == "darwin":
|
|
54
|
+
argv = ["open", target]
|
|
55
|
+
else:
|
|
56
|
+
argv = ["xdg-open", target]
|
|
57
|
+
runner(argv)
|
|
58
|
+
return argv
|
intui/actions/intents.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Intents: named, validated user-triggered requests (Principle III).
|
|
2
|
+
|
|
3
|
+
The application's :class:`IntentHandler` is the sole mutation seam — the
|
|
4
|
+
library delivers intents and never mutates application state itself
|
|
5
|
+
(FR-014). Handlers typically respond by appending new events, which flow
|
|
6
|
+
back through the pipeline.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from types import MappingProxyType
|
|
14
|
+
from typing import Any, Protocol
|
|
15
|
+
|
|
16
|
+
_EMPTY_PAYLOAD: Mapping[str, Any] = MappingProxyType({})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class Intent:
|
|
21
|
+
"""A named user-triggered request.
|
|
22
|
+
|
|
23
|
+
``risky=True`` intents are held by the confirmation flow until the user
|
|
24
|
+
explicitly confirms (FR-016).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
payload: Mapping[str, Any] = field(default_factory=lambda: _EMPTY_PAYLOAD)
|
|
29
|
+
risky: bool = False
|
|
30
|
+
|
|
31
|
+
def __eq__(self, other: object) -> bool:
|
|
32
|
+
if not isinstance(other, Intent):
|
|
33
|
+
return NotImplemented
|
|
34
|
+
return (
|
|
35
|
+
self.name == other.name
|
|
36
|
+
and dict(self.payload) == dict(other.payload)
|
|
37
|
+
and self.risky == other.risky
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class IntentHandler(Protocol):
|
|
42
|
+
"""The application seam: receives confirmed intents asynchronously."""
|
|
43
|
+
|
|
44
|
+
async def __call__(self, intent: Intent) -> None: ...
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""intui.adapters: engine-free normalizers from third-party producers.
|
|
2
|
+
|
|
3
|
+
An adapter maps a producer's on-the-wire events onto the canonical in-TUI-tion
|
|
4
|
+
vocabulary so the zero-config runner renders them with no producer-specific code
|
|
5
|
+
in the console or the kit. Adapters import no terminal engine (layering guard).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from intui.adapters.intentforge import IntentForgeSource, adapt_record
|
|
9
|
+
|
|
10
|
+
__all__ = ["IntentForgeSource", "adapt_record"]
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""IntentForge adapter: normalize IF run-trace ndjson into canonical events.
|
|
2
|
+
|
|
3
|
+
Engine-free. IntentForge emits ``--event-stream ndjson`` lines of
|
|
4
|
+
``{"type":"run_trace_event","event":{"sequence","name","payload"}}`` and a final
|
|
5
|
+
``{"type":"summary","summary":{...}}``. The inner record is NOT an intui envelope,
|
|
6
|
+
so this module *transforms* each record into one (vs. the plain unwrap that
|
|
7
|
+
:class:`~intui.events.NdjsonStreamSource` does). IntentForge is not imported —
|
|
8
|
+
the coupling is to its on-the-wire JSON shape only.
|
|
9
|
+
|
|
10
|
+
Mapping verified against the IF repo 2026-06-14 (run_trace.py, assembly_executor
|
|
11
|
+
.py, assembly_benchmark.py, file_diff.py, cli.py). See
|
|
12
|
+
specs/010-intentforge-adapter/data-model.md.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import AsyncIterable, AsyncIterator, Iterable, Mapping, Sequence
|
|
18
|
+
from datetime import UTC, datetime, timedelta
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from intui.events.envelope import Event, Scope
|
|
23
|
+
from intui.events.sources import _aiter_subprocess_lines, _aiter_text_lines
|
|
24
|
+
|
|
25
|
+
#: Deterministic time base — IF events carry no timestamp, so we synthesize a
|
|
26
|
+
#: monotonic one from the record ``sequence`` to keep replays deterministic.
|
|
27
|
+
_EPOCH = datetime(2020, 1, 1, tzinfo=UTC)
|
|
28
|
+
|
|
29
|
+
#: Summary top-level keys lifted into evidence metrics (present-only).
|
|
30
|
+
_SUMMARY_METRIC_KEYS = (
|
|
31
|
+
"case_pass_rate",
|
|
32
|
+
"quality_issue_count",
|
|
33
|
+
"delivered_files",
|
|
34
|
+
"evidence_signature",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def adapt_record(record: Mapping[str, Any], *, run_id: str = "intentforge") -> Event | None:
|
|
39
|
+
"""Map one IntentForge record to a canonical :class:`Event`, or ``None``.
|
|
40
|
+
|
|
41
|
+
Accepts a ``run_trace_event`` wrapper, a bare ``{sequence,name,payload}``, or
|
|
42
|
+
a ``{"type":"summary","summary":{...}}`` record. Never raises on missing keys.
|
|
43
|
+
"""
|
|
44
|
+
if record.get("type") == "summary":
|
|
45
|
+
return _adapt_summary(record.get("summary"), run_id=run_id)
|
|
46
|
+
|
|
47
|
+
inner = record.get("event") if record.get("type") == "run_trace_event" else record
|
|
48
|
+
if not isinstance(inner, Mapping):
|
|
49
|
+
return None
|
|
50
|
+
name = inner.get("name")
|
|
51
|
+
if not isinstance(name, str):
|
|
52
|
+
return None
|
|
53
|
+
sequence = inner.get("sequence")
|
|
54
|
+
sequence = sequence if isinstance(sequence, int) else 0
|
|
55
|
+
payload = inner.get("payload")
|
|
56
|
+
payload = payload if isinstance(payload, Mapping) else {}
|
|
57
|
+
|
|
58
|
+
builder = _MAPPING.get(name)
|
|
59
|
+
if builder is None:
|
|
60
|
+
return None
|
|
61
|
+
type_, scope, out_payload, status = builder(payload)
|
|
62
|
+
return Event(
|
|
63
|
+
version="1",
|
|
64
|
+
event_id=f"if-{sequence}",
|
|
65
|
+
run_id=run_id,
|
|
66
|
+
timestamp=_EPOCH + timedelta(seconds=sequence),
|
|
67
|
+
type=type_,
|
|
68
|
+
scope=scope,
|
|
69
|
+
status=status,
|
|
70
|
+
payload=out_payload,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --- Per-event builders ------------------------------------------------------
|
|
75
|
+
# Each returns (canonical_type, scope, payload, status).
|
|
76
|
+
|
|
77
|
+
_Built = tuple[str, Scope, dict[str, Any], str | None]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _item_id(payload: Mapping[str, Any]) -> str:
|
|
81
|
+
# Assembly items carry their id in case_id; file_diff sets work_item_id too.
|
|
82
|
+
wid = payload.get("work_item_id")
|
|
83
|
+
if isinstance(wid, str) and wid:
|
|
84
|
+
return wid
|
|
85
|
+
cid = payload.get("case_id")
|
|
86
|
+
return cid if isinstance(cid, str) else ""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _case_id(payload: Mapping[str, Any]) -> str:
|
|
90
|
+
cid = payload.get("case_id")
|
|
91
|
+
return cid if isinstance(cid, str) else ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _suite_id(payload: Mapping[str, Any]) -> str | None:
|
|
95
|
+
# IF 0.9.13+ carries the parent blueprint/suite as suite_id; absent in older
|
|
96
|
+
# streams (work items then fall back to the kit's "unassigned" group).
|
|
97
|
+
sid = payload.get("suite_id")
|
|
98
|
+
return sid if isinstance(sid, str) and sid else None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _status(payload: Mapping[str, Any]) -> str | None:
|
|
102
|
+
status = payload.get("status")
|
|
103
|
+
return status if isinstance(status, str) else None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _case_started(p: Mapping[str, Any]) -> _Built:
|
|
107
|
+
return "task_started", Scope(task_id=_case_id(p)), {}, None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _case_finished(p: Mapping[str, Any]) -> _Built:
|
|
111
|
+
return "task_completed", Scope(task_id=_case_id(p)), {}, _status(p)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _item_started(p: Mapping[str, Any]) -> _Built:
|
|
115
|
+
return "work_item_started", Scope(task_id=_suite_id(p), work_item_id=_item_id(p)), {}, None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _item_committed(p: Mapping[str, Any]) -> _Built:
|
|
119
|
+
return "work_item_completed", Scope(task_id=_suite_id(p), work_item_id=_item_id(p)), {}, None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _item_failed(p: Mapping[str, Any]) -> _Built:
|
|
123
|
+
return (
|
|
124
|
+
"work_item_completed",
|
|
125
|
+
Scope(task_id=_suite_id(p), work_item_id=_item_id(p)),
|
|
126
|
+
{},
|
|
127
|
+
_status(p) or "failed",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _plan_blocked(p: Mapping[str, Any]) -> _Built:
|
|
132
|
+
return "task_blocked", Scope(task_id=_case_id(p)), {}, _status(p)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _suite_started(p: Mapping[str, Any]) -> _Built:
|
|
136
|
+
return "run_started", Scope(), {}, None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _suite_finished(p: Mapping[str, Any]) -> _Built:
|
|
140
|
+
return "run_completed", Scope(), {}, _status(p)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _file_diff(p: Mapping[str, Any]) -> _Built:
|
|
144
|
+
diff = p.get("diff")
|
|
145
|
+
title = p.get("file")
|
|
146
|
+
return (
|
|
147
|
+
"diff_ready",
|
|
148
|
+
Scope(task_id=_suite_id(p), work_item_id=_item_id(p)),
|
|
149
|
+
{
|
|
150
|
+
"unified": diff if isinstance(diff, str) else "",
|
|
151
|
+
"title": title if isinstance(title, str) and title else "diff",
|
|
152
|
+
"public_safe": True,
|
|
153
|
+
},
|
|
154
|
+
None,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _repeat_started(p: Mapping[str, Any]) -> _Built:
|
|
159
|
+
text = f"repeat {p.get('repeat_count', '?')} started (run {p.get('run_index', '?')})"
|
|
160
|
+
return "message_added", Scope(), {"role": "system", "text": text}, None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _repeat_finished(p: Mapping[str, Any]) -> _Built:
|
|
164
|
+
text = f"repeat {p.get('repeat_count', '?')} finished: {p.get('status', '?')}"
|
|
165
|
+
return "message_added", Scope(), {"role": "system", "text": text}, None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_MAPPING: Mapping[str, Any] = {
|
|
169
|
+
"case_started": _case_started,
|
|
170
|
+
"case_finished": _case_finished,
|
|
171
|
+
"assembly_item_started": _item_started,
|
|
172
|
+
"assembly_item_committed": _item_committed,
|
|
173
|
+
"assembly_item_failed": _item_failed,
|
|
174
|
+
"assembly_plan_blocked": _plan_blocked,
|
|
175
|
+
"matrix_suite_started": _suite_started,
|
|
176
|
+
"matrix_suite_finished": _suite_finished,
|
|
177
|
+
"file_diff": _file_diff,
|
|
178
|
+
"repeat_started": _repeat_started,
|
|
179
|
+
"repeat_finished": _repeat_finished,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _adapt_summary(summary: Any, *, run_id: str) -> Event | None:
|
|
184
|
+
if not isinstance(summary, Mapping):
|
|
185
|
+
return None
|
|
186
|
+
metrics: list[dict[str, Any]] = []
|
|
187
|
+
for key in _SUMMARY_METRIC_KEYS:
|
|
188
|
+
if key in summary:
|
|
189
|
+
metrics.append({"key": key, "label": key.replace("_", " "), "value": summary[key]})
|
|
190
|
+
acb = summary.get("acb_score")
|
|
191
|
+
if isinstance(acb, Mapping) and "certified_level" in acb:
|
|
192
|
+
metrics.append(
|
|
193
|
+
{"key": "certified_level", "label": "certified level", "value": acb["certified_level"]}
|
|
194
|
+
)
|
|
195
|
+
return Event(
|
|
196
|
+
version="1",
|
|
197
|
+
event_id="if-summary",
|
|
198
|
+
run_id=run_id,
|
|
199
|
+
timestamp=_EPOCH + timedelta(days=1), # after any sequenced event
|
|
200
|
+
type="evidence_ready",
|
|
201
|
+
scope=Scope(),
|
|
202
|
+
payload={"title": "IntentForge summary", "metrics": metrics, "public_safe": True},
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class IntentForgeSource:
|
|
207
|
+
"""An :class:`EventSource` that adapts an IntentForge ndjson stream.
|
|
208
|
+
|
|
209
|
+
Reads raw IF ndjson (a file path, a line iterable, or — via
|
|
210
|
+
:meth:`from_command` — a spawned subprocess) and yields adapted canonical
|
|
211
|
+
envelope mappings, including the trailing summary. Malformed lines surface
|
|
212
|
+
through stream health; unmapped records are skipped.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
def __init__(
|
|
216
|
+
self,
|
|
217
|
+
source: Path | str | Iterable[str] | AsyncIterable[str],
|
|
218
|
+
*,
|
|
219
|
+
run_id: str = "intentforge",
|
|
220
|
+
) -> None:
|
|
221
|
+
self._source = source
|
|
222
|
+
self._run_id = run_id
|
|
223
|
+
self._cmd: tuple[str, ...] | None = None
|
|
224
|
+
|
|
225
|
+
@classmethod
|
|
226
|
+
def from_command(cls, cmd: Sequence[str], *, run_id: str = "intentforge") -> IntentForgeSource:
|
|
227
|
+
"""Spawn ``cmd`` and adapt its stdout ndjson live."""
|
|
228
|
+
self = cls([], run_id=run_id)
|
|
229
|
+
self._cmd = tuple(cmd)
|
|
230
|
+
return self
|
|
231
|
+
|
|
232
|
+
async def _lines(self) -> AsyncIterator[str]:
|
|
233
|
+
if self._cmd is not None:
|
|
234
|
+
async for line in _aiter_subprocess_lines(self._cmd):
|
|
235
|
+
yield line
|
|
236
|
+
else:
|
|
237
|
+
async for line in _aiter_text_lines(self._source):
|
|
238
|
+
yield line
|
|
239
|
+
|
|
240
|
+
async def __aiter__(self) -> AsyncIterator[Mapping[str, Any]]:
|
|
241
|
+
import json
|
|
242
|
+
|
|
243
|
+
line_number = 0
|
|
244
|
+
async for line in self._lines():
|
|
245
|
+
line_number += 1
|
|
246
|
+
stripped = line.strip()
|
|
247
|
+
if not stripped:
|
|
248
|
+
continue
|
|
249
|
+
try:
|
|
250
|
+
record: Any = json.loads(stripped)
|
|
251
|
+
except json.JSONDecodeError as exc:
|
|
252
|
+
yield {"__malformed__": f"invalid JSON: {exc}", "line_number": line_number}
|
|
253
|
+
continue
|
|
254
|
+
if not isinstance(record, dict):
|
|
255
|
+
yield {"__malformed__": "line is not a JSON object", "line_number": line_number}
|
|
256
|
+
continue
|
|
257
|
+
event = adapt_record(record, run_id=self._run_id)
|
|
258
|
+
if event is not None:
|
|
259
|
+
yield event.to_mapping()
|