shikiru-cli 0.2.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.
- shikiru/__init__.py +1 -0
- shikiru/__main__.py +8 -0
- shikiru/agents/__init__.py +3 -0
- shikiru/agents/contracts.py +590 -0
- shikiru/agents/registry.py +87 -0
- shikiru/agents/sdk/__init__.py +17 -0
- shikiru/agents/sdk/reset_time.py +137 -0
- shikiru/agents/sdk/turn.py +335 -0
- shikiru/agents/settings.py +151 -0
- shikiru/agents/testing/__init__.py +26 -0
- shikiru/agents/testing/contract_suite.py +653 -0
- shikiru/agents/testing/resume.py +15 -0
- shikiru/bootstrap.py +1277 -0
- shikiru/cli/__init__.py +1 -0
- shikiru/cli/app.py +770 -0
- shikiru/cli/context.py +63 -0
- shikiru/cli/render.py +1475 -0
- shikiru/cli/report.py +156 -0
- shikiru/cli/session_prompt.py +63 -0
- shikiru/cli/view.py +131 -0
- shikiru/clock.py +41 -0
- shikiru/config/__init__.py +8 -0
- shikiru/config/defaults.toml +131 -0
- shikiru/config/loader.py +178 -0
- shikiru/config/settings.py +392 -0
- shikiru/dashboard/__init__.py +3 -0
- shikiru/dashboard/actions_list.py +799 -0
- shikiru/dashboard/activity.py +150 -0
- shikiru/dashboard/app.py +1765 -0
- shikiru/dashboard/blocker_evidence.py +122 -0
- shikiru/dashboard/blocker_rows.py +84 -0
- shikiru/dashboard/briefing_view.py +871 -0
- shikiru/dashboard/contract.py +1086 -0
- shikiru/dashboard/covered.py +32 -0
- shikiru/dashboard/detail.py +1209 -0
- shikiru/dashboard/detail_fold.py +244 -0
- shikiru/dashboard/detail_hint.py +68 -0
- shikiru/dashboard/details_list_view.py +150 -0
- shikiru/dashboard/diagnostics.py +80 -0
- shikiru/dashboard/diagnostics_sections.py +186 -0
- shikiru/dashboard/evidence_table.py +123 -0
- shikiru/dashboard/extra_details.py +214 -0
- shikiru/dashboard/fault_guidance.py +216 -0
- shikiru/dashboard/fold_bodies.py +276 -0
- shikiru/dashboard/help_keys.py +166 -0
- shikiru/dashboard/help_screen.py +204 -0
- shikiru/dashboard/interaction.py +17 -0
- shikiru/dashboard/ladder_editor.py +544 -0
- shikiru/dashboard/live_settings.py +62 -0
- shikiru/dashboard/motion.py +368 -0
- shikiru/dashboard/orchestration_fields.py +201 -0
- shikiru/dashboard/palette.py +1606 -0
- shikiru/dashboard/palette_app.py +89 -0
- shikiru/dashboard/palette_drafts.py +33 -0
- shikiru/dashboard/resolution.py +573 -0
- shikiru/dashboard/resolution_view.py +302 -0
- shikiru/dashboard/row_list.py +256 -0
- shikiru/dashboard/section_rows.py +173 -0
- shikiru/dashboard/services.py +665 -0
- shikiru/dashboard/session_history.py +402 -0
- shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
- shikiru/dashboard/task_picker.py +358 -0
- shikiru/dashboard/tick.py +59 -0
- shikiru/dashboard/timeline_filter.py +129 -0
- shikiru/dashboard/timeline_section.py +385 -0
- shikiru/dashboard/usage_body.py +269 -0
- shikiru/dashboard/views.py +2314 -0
- shikiru/dashboard/wizard.py +1389 -0
- shikiru/domain/__init__.py +1 -0
- shikiru/domain/acceptance.py +130 -0
- shikiru/domain/activity.py +28 -0
- shikiru/domain/attempts.py +333 -0
- shikiru/domain/briefing.py +257 -0
- shikiru/domain/commands.py +219 -0
- shikiru/domain/decisions.py +231 -0
- shikiru/domain/docker.py +262 -0
- shikiru/domain/engine_uptime.py +87 -0
- shikiru/domain/escalation.py +194 -0
- shikiru/domain/escalation_evidence.py +186 -0
- shikiru/domain/escalation_panel.py +144 -0
- shikiru/domain/events.py +71 -0
- shikiru/domain/evidence_export.py +89 -0
- shikiru/domain/faults.py +86 -0
- shikiru/domain/feedback.py +180 -0
- shikiru/domain/feedback_rounds.py +172 -0
- shikiru/domain/fencing.py +31 -0
- shikiru/domain/ladder.py +102 -0
- shikiru/domain/ladder_text.py +44 -0
- shikiru/domain/levels.py +44 -0
- shikiru/domain/motion.py +199 -0
- shikiru/domain/packet_evidence.py +377 -0
- shikiru/domain/pushes.py +47 -0
- shikiru/domain/quota_blocks.py +44 -0
- shikiru/domain/quota_waits.py +219 -0
- shikiru/domain/recovery.py +194 -0
- shikiru/domain/reports.py +498 -0
- shikiru/domain/results.py +217 -0
- shikiru/domain/runs.py +85 -0
- shikiru/domain/scheduling.py +231 -0
- shikiru/domain/status_tasks.py +40 -0
- shikiru/domain/steps.py +325 -0
- shikiru/domain/tasks.py +174 -0
- shikiru/domain/timeline.py +208 -0
- shikiru/domain/timeline_phrases.py +271 -0
- shikiru/domain/versions.py +18 -0
- shikiru/domain/workflow.py +372 -0
- shikiru/engine/__init__.py +0 -0
- shikiru/engine/acceptance.py +90 -0
- shikiru/engine/agents.py +61 -0
- shikiru/engine/cosmetic_decorator.py +99 -0
- shikiru/engine/daemon.py +409 -0
- shikiru/engine/dependency.py +37 -0
- shikiru/engine/escalation_filing.py +110 -0
- shikiru/engine/escalations.py +401 -0
- shikiru/engine/events.py +285 -0
- shikiru/engine/evidence.py +81 -0
- shikiru/engine/fault_reporter.py +144 -0
- shikiru/engine/feedback.py +577 -0
- shikiru/engine/feedback_audit.py +80 -0
- shikiru/engine/feedback_intake.py +95 -0
- shikiru/engine/feedback_snapshot.py +148 -0
- shikiru/engine/flow.py +87 -0
- shikiru/engine/handlers/__init__.py +33 -0
- shikiru/engine/handlers/base.py +148 -0
- shikiru/engine/handlers/engine.py +24 -0
- shikiru/engine/handlers/escalations.py +174 -0
- shikiru/engine/handlers/operator.py +81 -0
- shikiru/engine/handlers/tasks.py +237 -0
- shikiru/engine/history.py +128 -0
- shikiru/engine/integration.py +1234 -0
- shikiru/engine/integration_turns.py +337 -0
- shikiru/engine/layout_guard.py +362 -0
- shikiru/engine/lifecycle.py +60 -0
- shikiru/engine/monitor.py +317 -0
- shikiru/engine/notification_delivery.py +124 -0
- shikiru/engine/notification_feed.py +57 -0
- shikiru/engine/notifier.py +91 -0
- shikiru/engine/pull_request_closing.py +117 -0
- shikiru/engine/push_service.py +177 -0
- shikiru/engine/quota_probe_threads.py +79 -0
- shikiru/engine/quota_reconciler.py +210 -0
- shikiru/engine/quota_schedule.py +24 -0
- shikiru/engine/quota_waits.py +102 -0
- shikiru/engine/reconcile.py +91 -0
- shikiru/engine/recovery.py +284 -0
- shikiru/engine/retention.py +102 -0
- shikiru/engine/root_preparation.py +86 -0
- shikiru/engine/scheduler.py +320 -0
- shikiru/engine/session_guard.py +46 -0
- shikiru/engine/sessions.py +95 -0
- shikiru/engine/signalled.py +75 -0
- shikiru/engine/step_runner.py +140 -0
- shikiru/engine/supervisor.py +101 -0
- shikiru/engine/takeover.py +223 -0
- shikiru/engine/task_facts.py +46 -0
- shikiru/engine/tick_guard.py +95 -0
- shikiru/engine/titles.py +297 -0
- shikiru/engine/transitions.py +79 -0
- shikiru/engine/turn_context.py +206 -0
- shikiru/engine/turn_execution.py +30 -0
- shikiru/engine/turn_loop.py +230 -0
- shikiru/engine/turn_processes.py +79 -0
- shikiru/engine/turns.py +457 -0
- shikiru/errors.py +144 -0
- shikiru/helper/__init__.py +3 -0
- shikiru/helper/helper.py +182 -0
- shikiru/helper/models.py +11 -0
- shikiru/mcp/__init__.py +1 -0
- shikiru/mcp/server.py +196 -0
- shikiru/operator/__init__.py +33 -0
- shikiru/operator/attachment.py +24 -0
- shikiru/operator/commands.py +319 -0
- shikiru/operator/detectors.py +374 -0
- shikiru/operator/doctor.py +351 -0
- shikiru/operator/events.py +57 -0
- shikiru/operator/evidence_export.py +498 -0
- shikiru/operator/evidence_writer.py +78 -0
- shikiru/operator/lifecycle.py +294 -0
- shikiru/operator/plugin_checks.py +194 -0
- shikiru/operator/probe.py +287 -0
- shikiru/operator/queries.py +1601 -0
- shikiru/operator/services.py +36 -0
- shikiru/operator/sessions.py +378 -0
- shikiru/operator/strip_views.py +162 -0
- shikiru/operator/timeline.py +416 -0
- shikiru/operator/toml_text.py +62 -0
- shikiru/palette.py +18 -0
- shikiru/paths.py +91 -0
- shikiru/plugins/__init__.py +0 -0
- shikiru/plugins/agents/__init__.py +1 -0
- shikiru/plugins/agents/claude/__init__.py +5 -0
- shikiru/plugins/agents/claude/agent.py +251 -0
- shikiru/plugins/agents/claude/context.py +116 -0
- shikiru/plugins/agents/claude/headless.py +92 -0
- shikiru/plugins/agents/claude/hook.py +57 -0
- shikiru/plugins/agents/claude/model_catalog.py +105 -0
- shikiru/plugins/agents/claude/options.py +62 -0
- shikiru/plugins/agents/claude/turn.py +1018 -0
- shikiru/plugins/agents/codex/__init__.py +5 -0
- shikiru/plugins/agents/codex/agent.py +486 -0
- shikiru/plugins/agents/codex/headless.py +166 -0
- shikiru/plugins/agents/codex/home.py +14 -0
- shikiru/plugins/agents/codex/model_catalog.py +128 -0
- shikiru/plugins/agents/codex/options.py +94 -0
- shikiru/plugins/agents/codex/quota.py +195 -0
- shikiru/plugins/agents/codex/sandbox.py +31 -0
- shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
- shikiru/plugins/agents/codex/turn.py +767 -0
- shikiru/plugins/agents/codex/usage.py +106 -0
- shikiru/plugins/agents/shared_settings.py +62 -0
- shikiru/plugins/widgets/__init__.py +3 -0
- shikiru/plugins/widgets/dialogue.py +144 -0
- shikiru/plugins/widgets/disk.py +123 -0
- shikiru/plugins/widgets/docker.py +225 -0
- shikiru/plugins/widgets/escalations.py +402 -0
- shikiru/plugins/widgets/faults.py +204 -0
- shikiru/plugins/widgets/header.py +321 -0
- shikiru/plugins/widgets/queue.py +1140 -0
- shikiru/plugins/widgets/queue_hint.py +93 -0
- shikiru/plugins/widgets/resources.py +105 -0
- shikiru/plugins/widgets/strips.py +82 -0
- shikiru/plugins/widgets/usage.py +176 -0
- shikiru/private.py +94 -0
- shikiru/process.py +535 -0
- shikiru/prompts/__init__.py +3 -0
- shikiru/prompts/renderer.py +85 -0
- shikiru/prompts/templates/_base.md.j2 +17 -0
- shikiru/prompts/templates/_macros.md.j2 +61 -0
- shikiru/prompts/templates/conflicts.md.j2 +22 -0
- shikiru/prompts/templates/consult.md.j2 +5 -0
- shikiru/prompts/templates/continue.md.j2 +4 -0
- shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
- shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
- shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
- shikiru/prompts/templates/handoff.md.j2 +23 -0
- shikiru/prompts/templates/implement.md.j2 +13 -0
- shikiru/prompts/templates/plan.md.j2 +30 -0
- shikiru/prompts/templates/rework.md.j2 +13 -0
- shikiru/prompts/templates/triage.md.j2 +20 -0
- shikiru/prompts/templates/verify.md.j2 +29 -0
- shikiru/store/__init__.py +1 -0
- shikiru/store/commands.py +242 -0
- shikiru/store/database.py +297 -0
- shikiru/store/escalations.py +240 -0
- shikiru/store/events.py +213 -0
- shikiru/store/feedback.py +266 -0
- shikiru/store/migrations/001_initial.sql +63 -0
- shikiru/store/migrations/002_headless_turns.sql +31 -0
- shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
- shikiru/store/migrations/004_repo_base_ref.sql +3 -0
- shikiru/store/migrations/005_complexity_explicit.sql +3 -0
- shikiru/store/migrations/006_events_kind_index.sql +1 -0
- shikiru/store/migrations/007_notification_cursor.sql +1 -0
- shikiru/store/migrations/008_reported_usage.sql +1 -0
- shikiru/store/migrations/009_billing_declarations.sql +2 -0
- shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
- shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
- shikiru/store/migrations/013_push_targets.sql +7 -0
- shikiru/store/migrations/014_agent_block_time.sql +6 -0
- shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
- shikiru/store/migrations/016_feedback_rounds.sql +29 -0
- shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
- shikiru/store/migrations/__init__.py +1 -0
- shikiru/store/notification_cursor.py +29 -0
- shikiru/store/pushes.py +117 -0
- shikiru/store/quotas.py +153 -0
- shikiru/store/reader.py +310 -0
- shikiru/store/retention.py +92 -0
- shikiru/store/runs.py +227 -0
- shikiru/store/tasks.py +949 -0
- shikiru/telemetry/__init__.py +1 -0
- shikiru/telemetry/disk.py +167 -0
- shikiru/telemetry/docker.py +164 -0
- shikiru/telemetry/resources.py +296 -0
- shikiru/tmux/__init__.py +3 -0
- shikiru/tmux/client.py +637 -0
- shikiru/tmux/decorator.py +225 -0
- shikiru/tmux/layout.py +200 -0
- shikiru/tmux/session.py +381 -0
- shikiru/ui/__init__.py +9 -0
- shikiru/ui/age.py +45 -0
- shikiru/ui/bubbles.py +41 -0
- shikiru/ui/compact.py +1209 -0
- shikiru/ui/console.py +49 -0
- shikiru/ui/fold.py +50 -0
- shikiru/ui/gated_static.py +117 -0
- shikiru/ui/gauge.py +40 -0
- shikiru/ui/glyphs.py +109 -0
- shikiru/ui/keys.py +57 -0
- shikiru/ui/live_interval.py +42 -0
- shikiru/ui/popup.py +311 -0
- shikiru/ui/quota_status.py +26 -0
- shikiru/ui/recent_window.py +78 -0
- shikiru/ui/screen.py +57 -0
- shikiru/ui/section.py +193 -0
- shikiru/ui/session.py +65 -0
- shikiru/ui/size.py +17 -0
- shikiru/ui/splash.py +42 -0
- shikiru/ui/status.py +47 -0
- shikiru/ui/status_strip.py +149 -0
- shikiru/ui/stream.py +152 -0
- shikiru/ui/strip.py +332 -0
- shikiru/ui/theme.py +83 -0
- shikiru/ui/tokens.py +51 -0
- shikiru/view_log.py +126 -0
- shikiru/workspace/__init__.py +3 -0
- shikiru/workspace/base_refs.py +160 -0
- shikiru/workspace/caches.py +136 -0
- shikiru/workspace/facts.py +118 -0
- shikiru/workspace/git.py +601 -0
- shikiru/workspace/github.py +744 -0
- shikiru/workspace/messages.py +107 -0
- shikiru/workspace/reports.py +145 -0
- shikiru/workspace/roots.py +180 -0
- shikiru_cli-0.2.0.dist-info/METADATA +428 -0
- shikiru_cli-0.2.0.dist-info/RECORD +319 -0
- shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
- shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
- shikiru_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
shikiru/domain/steps.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Step transitions for the task workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from shikiru.domain.ladder import (
|
|
10
|
+
REJECTIONS_FIELD,
|
|
11
|
+
move_up,
|
|
12
|
+
pick_rung,
|
|
13
|
+
reset_for_integration_attempt,
|
|
14
|
+
reset_for_new_iteration,
|
|
15
|
+
)
|
|
16
|
+
from shikiru.domain.reports import (
|
|
17
|
+
DECISION_ESCALATE,
|
|
18
|
+
STATUS_COMPLETED,
|
|
19
|
+
ConsultAnswer,
|
|
20
|
+
FeedbackAnalysis,
|
|
21
|
+
ImplementationReport,
|
|
22
|
+
PlanReport,
|
|
23
|
+
TriageReport,
|
|
24
|
+
Verdict,
|
|
25
|
+
)
|
|
26
|
+
from shikiru.domain.tasks import (
|
|
27
|
+
DEFAULT_COMPLEXITY,
|
|
28
|
+
Counters,
|
|
29
|
+
EscalationReason,
|
|
30
|
+
Mode,
|
|
31
|
+
Role,
|
|
32
|
+
Status,
|
|
33
|
+
StatusReason,
|
|
34
|
+
Step,
|
|
35
|
+
Task,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from shikiru.agents.contracts import Rung
|
|
40
|
+
from shikiru.config.settings import ComplexityName, Settings
|
|
41
|
+
|
|
42
|
+
CONSULT_LIMIT = 3
|
|
43
|
+
CONFLICT_ROUNDS = 2
|
|
44
|
+
|
|
45
|
+
Outcome = (
|
|
46
|
+
TriageReport | PlanReport | ImplementationReport | ConsultAnswer | Verdict | FeedbackAnalysis
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
ACTING_ROLE: dict[Step, Role] = {
|
|
50
|
+
Step.TRIAGE: Role.LEADER,
|
|
51
|
+
Step.PLAN: Role.LEADER,
|
|
52
|
+
Step.IMPLEMENT: Role.IMPLEMENTER,
|
|
53
|
+
Step.CONSULT: Role.LEADER,
|
|
54
|
+
Step.VERIFY: Role.LEADER,
|
|
55
|
+
Step.INTEGRATE: Role.IMPLEMENTER,
|
|
56
|
+
}
|
|
57
|
+
"""Which role acts at each step: the Implementer only acts in `integrate` on conflicts."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class Transition:
|
|
62
|
+
"""The task's new step/status/iteration/counters/rungs, plus side effects as data.
|
|
63
|
+
|
|
64
|
+
`base` is the task snapshot the decision was computed from; `rebased_onto` reconciles the
|
|
65
|
+
decision with the row as it is when the decision is written.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
base: Task
|
|
69
|
+
step: Step | None
|
|
70
|
+
status: Status
|
|
71
|
+
iteration: int
|
|
72
|
+
counters: Counters
|
|
73
|
+
rung_index: dict[Role, int]
|
|
74
|
+
set_overrides: dict[Role, Rung] = field(default_factory=dict)
|
|
75
|
+
cleared_overrides: dict[Role, Rung] = field(default_factory=dict)
|
|
76
|
+
complexity: ComplexityName | None = None
|
|
77
|
+
mode: Mode | None = None
|
|
78
|
+
title: str | None = None
|
|
79
|
+
status_reason: StatusReason | None = None
|
|
80
|
+
escalation_reason: EscalationReason | None = None
|
|
81
|
+
repoint_dependents: bool = False
|
|
82
|
+
enqueue_new_tasks: bool = False
|
|
83
|
+
|
|
84
|
+
def rebased_onto(self, current: Task) -> Transition:
|
|
85
|
+
"""Keep what changed since `base` and apply only what this decision itself changed.
|
|
86
|
+
|
|
87
|
+
A role whose override differs between `base` and `current` was repinned by the operator
|
|
88
|
+
while the turn ran: its rung index and, for the Implementer, `rejections_on_rung` stay as
|
|
89
|
+
stored, so neither the turn's rejection nor an automatic move undoes the pin. Every other
|
|
90
|
+
iteration, counter and rung index takes the decision's value only where the decision
|
|
91
|
+
changed it relative to `base`.
|
|
92
|
+
"""
|
|
93
|
+
repinned = {
|
|
94
|
+
role for role in Role if current.overrides.get(role) != self.base.overrides.get(role)
|
|
95
|
+
}
|
|
96
|
+
changed = {
|
|
97
|
+
name: value
|
|
98
|
+
for name, value in self.counters
|
|
99
|
+
if value != getattr(self.base.counters, name)
|
|
100
|
+
and not (name == REJECTIONS_FIELD and Role.IMPLEMENTER in repinned)
|
|
101
|
+
}
|
|
102
|
+
rung_index = dict(current.rung_index)
|
|
103
|
+
rung_index.update(
|
|
104
|
+
{
|
|
105
|
+
role: index
|
|
106
|
+
for role, index in self.rung_index.items()
|
|
107
|
+
if role not in repinned and index != self.base.rung_index.get(role)
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
return dataclasses.replace(
|
|
111
|
+
self,
|
|
112
|
+
iteration=self.iteration
|
|
113
|
+
if self.iteration != self.base.iteration
|
|
114
|
+
else current.iteration,
|
|
115
|
+
counters=current.counters.model_copy(update=changed),
|
|
116
|
+
rung_index=rung_index,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def stay(task: Task, *, step: Step | None, status: Status = Status.RUNNING) -> Transition:
|
|
121
|
+
"""Build a transition that keeps every field of `task` except `step` and `status`."""
|
|
122
|
+
return Transition(
|
|
123
|
+
base=task,
|
|
124
|
+
step=step,
|
|
125
|
+
status=status,
|
|
126
|
+
iteration=task.iteration,
|
|
127
|
+
counters=task.counters,
|
|
128
|
+
rung_index=dict(task.rung_index),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def escalate(task: Task, reason: EscalationReason, *, step: Step) -> Transition:
|
|
133
|
+
"""Build an escalation transition while preserving the task's other state."""
|
|
134
|
+
base = Transition.stay(task, step=step, status=Status.ESCALATED)
|
|
135
|
+
return dataclasses.replace(base, escalation_reason=reason)
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def escalate_current(task: Task, reason: EscalationReason) -> Transition:
|
|
139
|
+
"""Escalate at the current step, preserving a missing step for an unstarted task."""
|
|
140
|
+
if task.step is None:
|
|
141
|
+
base = Transition.stay(task, step=None, status=Status.ESCALATED)
|
|
142
|
+
return dataclasses.replace(base, escalation_reason=reason)
|
|
143
|
+
return Transition.escalate(task, reason, step=task.step)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def first_step(task: Task) -> Transition:
|
|
147
|
+
"""Compute the transition for a task's very first turn (submit flags).
|
|
148
|
+
|
|
149
|
+
A task with no `mode` starts at `triage`. `submit --mode implement|plan` skips triage
|
|
150
|
+
straight into that step; `implement` also starts numbering from iteration 1, matching the
|
|
151
|
+
turn dirs (`NN-<step>-i<iteration>`) and the `implement` iteration-1 convention.
|
|
152
|
+
"""
|
|
153
|
+
if task.mode is None:
|
|
154
|
+
return Transition.stay(task, step=Step.TRIAGE)
|
|
155
|
+
if task.mode is Mode.PLAN:
|
|
156
|
+
return Transition.stay(task, step=Step.PLAN)
|
|
157
|
+
return dataclasses.replace(Transition.stay(task, step=Step.IMPLEMENT), iteration=1)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def task_acting_role(task: Task) -> Role:
|
|
161
|
+
"""Return the role for the current step or the task's first scheduled step."""
|
|
162
|
+
step = task.step if task.step is not None else first_step(task).step
|
|
163
|
+
return Role.LEADER if step is None else ACTING_ROLE[step]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _triaged_complexity(task: Task, outcome: TriageReport) -> ComplexityName | None:
|
|
167
|
+
return outcome.complexity if task.complexity is None else None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _from_triage(task: Task, outcome: TriageReport, settings: Settings) -> Transition:
|
|
171
|
+
if outcome.mode is Mode.IMPLEMENT:
|
|
172
|
+
base = dataclasses.replace(Transition.stay(task, step=Step.IMPLEMENT), iteration=1)
|
|
173
|
+
else:
|
|
174
|
+
base = Transition.stay(task, step=Step.PLAN)
|
|
175
|
+
complexity = _triaged_complexity(task, outcome)
|
|
176
|
+
implementer = settings.roles.implementer
|
|
177
|
+
if complexity is not None and implementer.ladder_for(complexity) != implementer.ladder_for(
|
|
178
|
+
task.complexity or DEFAULT_COMPLEXITY
|
|
179
|
+
):
|
|
180
|
+
index = 0
|
|
181
|
+
if task.rung_index.get(Role.IMPLEMENTER, 0) > 0:
|
|
182
|
+
agent = pick_rung(task, Role.IMPLEMENTER, settings).agent
|
|
183
|
+
index = next(
|
|
184
|
+
(
|
|
185
|
+
index
|
|
186
|
+
for index, rung in enumerate(implementer.ladder_for(complexity))
|
|
187
|
+
if rung.agent == agent
|
|
188
|
+
),
|
|
189
|
+
0,
|
|
190
|
+
)
|
|
191
|
+
base = dataclasses.replace(base, rung_index={**base.rung_index, Role.IMPLEMENTER: index})
|
|
192
|
+
return dataclasses.replace(
|
|
193
|
+
base,
|
|
194
|
+
complexity=complexity,
|
|
195
|
+
mode=outcome.mode,
|
|
196
|
+
title=outcome.title,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _from_plan(task: Task) -> Transition:
|
|
201
|
+
base = Transition.stay(task, step=Step.INTEGRATE, status=Status.INTEGRATING)
|
|
202
|
+
return dataclasses.replace(
|
|
203
|
+
base, counters=reset_for_integration_attempt(task.counters), enqueue_new_tasks=True
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _from_implement(task: Task, outcome: ImplementationReport) -> Transition:
|
|
208
|
+
if outcome.status == STATUS_COMPLETED:
|
|
209
|
+
return Transition.stay(task, step=Step.VERIFY)
|
|
210
|
+
return _consult_or_escalate(task, Status.RUNNING)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _consult_or_escalate(task: Task, status: Status) -> Transition:
|
|
214
|
+
if task.counters.consults >= CONSULT_LIMIT:
|
|
215
|
+
return Transition.escalate(task, EscalationReason.DECISION_REQUIRED, step=Step.CONSULT)
|
|
216
|
+
counters = task.counters.model_copy(update={"consults": task.counters.consults + 1})
|
|
217
|
+
base = Transition.stay(task, step=Step.CONSULT, status=status)
|
|
218
|
+
return dataclasses.replace(base, counters=counters)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _from_consult(task: Task, outcome: ConsultAnswer) -> Transition:
|
|
222
|
+
if outcome.decision == DECISION_ESCALATE:
|
|
223
|
+
return Transition.escalate(task, EscalationReason.DECISION_REQUIRED, step=Step.CONSULT)
|
|
224
|
+
if task.status is Status.INTEGRATING:
|
|
225
|
+
return Transition.stay(task, step=Step.INTEGRATE, status=Status.INTEGRATING)
|
|
226
|
+
return Transition.stay(task, step=Step.IMPLEMENT)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _changes_requested(task: Task, settings: Settings) -> Transition:
|
|
230
|
+
if task.iteration >= settings.orchestration.max_iterations:
|
|
231
|
+
return Transition.escalate(task, EscalationReason.ITERATIONS_EXHAUSTED, step=Step.VERIFY)
|
|
232
|
+
rejections = task.counters.rejections_on_rung + 1
|
|
233
|
+
rung_index = dict(task.rung_index)
|
|
234
|
+
cleared_overrides: dict[Role, Rung] = {}
|
|
235
|
+
counters = task.counters.model_copy(update={"rejections_on_rung": rejections})
|
|
236
|
+
if rejections >= settings.roles.implementer.escalate_after:
|
|
237
|
+
move = move_up(task, Role.IMPLEMENTER, settings)
|
|
238
|
+
if move is not None:
|
|
239
|
+
rung_index = move.rung_index
|
|
240
|
+
cleared_overrides = move.cleared_overrides
|
|
241
|
+
counters = move.counters
|
|
242
|
+
counters = reset_for_new_iteration(counters)
|
|
243
|
+
return Transition(
|
|
244
|
+
base=task,
|
|
245
|
+
step=Step.IMPLEMENT,
|
|
246
|
+
status=Status.RUNNING,
|
|
247
|
+
iteration=task.iteration + 1,
|
|
248
|
+
counters=counters,
|
|
249
|
+
rung_index=rung_index,
|
|
250
|
+
cleared_overrides=cleared_overrides,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _conflict_round(task: Task) -> Transition:
|
|
255
|
+
rounds = task.counters.conflict_rounds + 1
|
|
256
|
+
counters = task.counters.model_copy(update={"conflict_rounds": rounds})
|
|
257
|
+
base = dataclasses.replace(Transition.stay(task, step=Step.INTEGRATE), counters=counters)
|
|
258
|
+
if rounds >= CONFLICT_ROUNDS:
|
|
259
|
+
return dataclasses.replace(
|
|
260
|
+
Transition.escalate(task, EscalationReason.INTEGRATION_CONFLICT, step=Step.INTEGRATE),
|
|
261
|
+
counters=counters,
|
|
262
|
+
)
|
|
263
|
+
return dataclasses.replace(base, status=Status.INTEGRATING)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _from_verify(task: Task, outcome: Verdict, settings: Settings) -> Transition:
|
|
267
|
+
match outcome.decision:
|
|
268
|
+
case "accept":
|
|
269
|
+
base = Transition.stay(task, step=Step.INTEGRATE, status=Status.INTEGRATING)
|
|
270
|
+
return dataclasses.replace(base, counters=reset_for_integration_attempt(task.counters))
|
|
271
|
+
case "changes_requested":
|
|
272
|
+
return _changes_requested(task, settings)
|
|
273
|
+
case "escalate":
|
|
274
|
+
return Transition.escalate(task, EscalationReason.DECISION_REQUIRED, step=Step.VERIFY)
|
|
275
|
+
case "supersede":
|
|
276
|
+
base = Transition.stay(task, step=None, status=Status.CANCELLED)
|
|
277
|
+
return dataclasses.replace(
|
|
278
|
+
base,
|
|
279
|
+
status_reason=StatusReason.SUPERSEDED,
|
|
280
|
+
repoint_dependents=True,
|
|
281
|
+
enqueue_new_tasks=True,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _from_integrate(task: Task, outcome: Verdict) -> Transition:
|
|
286
|
+
match outcome.decision:
|
|
287
|
+
case "accept":
|
|
288
|
+
return Transition.stay(task, step=Step.INTEGRATE, status=Status.INTEGRATING)
|
|
289
|
+
case "changes_requested":
|
|
290
|
+
return _conflict_round(task)
|
|
291
|
+
case "escalate":
|
|
292
|
+
return Transition.escalate(
|
|
293
|
+
task, EscalationReason.DECISION_REQUIRED, step=Step.INTEGRATE
|
|
294
|
+
)
|
|
295
|
+
case "supersede":
|
|
296
|
+
message = "a post-rebase conflicts verify cannot supersede the task"
|
|
297
|
+
raise ValueError(message)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _from_integrate_conflicts(task: Task, outcome: ImplementationReport) -> Transition:
|
|
301
|
+
if outcome.status == STATUS_COMPLETED:
|
|
302
|
+
return Transition.stay(task, step=Step.INTEGRATE, status=Status.INTEGRATING)
|
|
303
|
+
return _consult_or_escalate(task, Status.INTEGRATING)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def next_step(task: Task, outcome: Outcome, settings: Settings) -> Transition:
|
|
307
|
+
"""Apply `outcome` to `task`'s current step and return the resulting transition."""
|
|
308
|
+
match task.step, outcome:
|
|
309
|
+
case Step.TRIAGE, TriageReport():
|
|
310
|
+
return _from_triage(task, outcome, settings)
|
|
311
|
+
case Step.PLAN, PlanReport():
|
|
312
|
+
return _from_plan(task)
|
|
313
|
+
case Step.IMPLEMENT, ImplementationReport():
|
|
314
|
+
return _from_implement(task, outcome)
|
|
315
|
+
case Step.CONSULT, ConsultAnswer():
|
|
316
|
+
return _from_consult(task, outcome)
|
|
317
|
+
case Step.VERIFY, Verdict():
|
|
318
|
+
return _from_verify(task, outcome, settings)
|
|
319
|
+
case Step.INTEGRATE, Verdict():
|
|
320
|
+
return _from_integrate(task, outcome)
|
|
321
|
+
case Step.INTEGRATE, ImplementationReport():
|
|
322
|
+
return _from_integrate_conflicts(task, outcome)
|
|
323
|
+
case _:
|
|
324
|
+
message = f"no transition for step {task.step!r} with outcome {outcome!r}"
|
|
325
|
+
raise ValueError(message)
|
shikiru/domain/tasks.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Core task model and its enums."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import StrEnum, auto
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
from shikiru.agents.contracts import Rung
|
|
11
|
+
from shikiru.config.settings import ComplexityName
|
|
12
|
+
|
|
13
|
+
TASK_TITLE_MAX_LENGTH = 60
|
|
14
|
+
TASK_TITLE_ELLIPSIS = "…"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TaskOrigin(StrEnum):
|
|
18
|
+
"""Where a task came from."""
|
|
19
|
+
|
|
20
|
+
OPERATOR = auto()
|
|
21
|
+
LEADER = auto()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Status(StrEnum):
|
|
25
|
+
"""A task's lifecycle status."""
|
|
26
|
+
|
|
27
|
+
QUEUED = auto()
|
|
28
|
+
RUNNING = auto()
|
|
29
|
+
INTEGRATING = auto()
|
|
30
|
+
ESCALATED = auto()
|
|
31
|
+
DONE = auto()
|
|
32
|
+
CANCELLED = auto()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
ACTIVE_STATUSES = (Status.RUNNING, Status.INTEGRATING, Status.ESCALATED)
|
|
36
|
+
"""The statuses that remain active in a task window."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class StatusReason(StrEnum):
|
|
40
|
+
"""Why a terminal `status` was reached outside the ordinary happy path."""
|
|
41
|
+
|
|
42
|
+
SUPERSEDED = auto()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Step(StrEnum):
|
|
46
|
+
"""The current step of a running task."""
|
|
47
|
+
|
|
48
|
+
TRIAGE = auto()
|
|
49
|
+
PLAN = auto()
|
|
50
|
+
IMPLEMENT = auto()
|
|
51
|
+
CONSULT = auto()
|
|
52
|
+
VERIFY = auto()
|
|
53
|
+
INTEGRATE = auto()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Mode(StrEnum):
|
|
57
|
+
"""What triage decided the request needs."""
|
|
58
|
+
|
|
59
|
+
IMPLEMENT = auto()
|
|
60
|
+
PLAN = auto()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Hold(StrEnum):
|
|
64
|
+
"""A task-scoped hold."""
|
|
65
|
+
|
|
66
|
+
PAUSED = auto()
|
|
67
|
+
INTERVENTION = auto()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Role(StrEnum):
|
|
71
|
+
"""The three agent roles."""
|
|
72
|
+
|
|
73
|
+
LEADER = auto()
|
|
74
|
+
IMPLEMENTER = auto()
|
|
75
|
+
HELPER = auto()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class EscalationReason(StrEnum):
|
|
79
|
+
"""Why a task was escalated."""
|
|
80
|
+
|
|
81
|
+
DECISION_REQUIRED = auto()
|
|
82
|
+
ITERATIONS_EXHAUSTED = auto()
|
|
83
|
+
INTEGRATION_CONFLICT = auto()
|
|
84
|
+
INTEGRATION_BLOCKED = auto()
|
|
85
|
+
UNCOMMITTED_WORK = auto()
|
|
86
|
+
OFF_TASK_BRANCH = auto()
|
|
87
|
+
PR_CLOSED = auto()
|
|
88
|
+
AGENT_AUTH = auto()
|
|
89
|
+
AGENT_ERROR = auto()
|
|
90
|
+
AGENT_STUCK = auto()
|
|
91
|
+
AGENT_PROTOCOL = auto()
|
|
92
|
+
LADDER_EXHAUSTED = auto()
|
|
93
|
+
QUOTA_EXHAUSTED = auto()
|
|
94
|
+
DEPENDENCY_CANCELLED = auto()
|
|
95
|
+
ENGINE_ERROR = auto()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class DomainModel(BaseModel):
|
|
99
|
+
"""Base for domain models: unknown keys are rejected, fields can't be reassigned.
|
|
100
|
+
|
|
101
|
+
`frozen=True` only blocks assigning a new value to a field; a mutable field's own `list` or
|
|
102
|
+
`dict` can still be mutated in place, which is why callers that need to change one (e.g.
|
|
103
|
+
`Transition.stay`) copy it first rather than relying on the model to be deeply immutable.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Counters(DomainModel):
|
|
110
|
+
"""Persisted per-task counters and their reset rules."""
|
|
111
|
+
|
|
112
|
+
rejections_on_rung: int = 0
|
|
113
|
+
consults: int = 0
|
|
114
|
+
conflict_rounds: int = 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class RepoState(DomainModel):
|
|
118
|
+
"""The task's checkout state (mirrors `tasks.repo`)."""
|
|
119
|
+
|
|
120
|
+
base: str
|
|
121
|
+
base_ref: str
|
|
122
|
+
base_sha: str
|
|
123
|
+
branch: str
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
DEFAULT_COMPLEXITY: ComplexityName = "normal"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Task(DomainModel):
|
|
130
|
+
"""The task fields the domain rules need (mirrors the `tasks` table)."""
|
|
131
|
+
|
|
132
|
+
id: str
|
|
133
|
+
seq: int
|
|
134
|
+
parent_id: str | None = None
|
|
135
|
+
origin: TaskOrigin
|
|
136
|
+
title: str
|
|
137
|
+
request: str
|
|
138
|
+
mode: Mode | None = None
|
|
139
|
+
complexity: ComplexityName | None = None
|
|
140
|
+
priority: int = 0
|
|
141
|
+
depends_on: list[str] = Field(default_factory=list)
|
|
142
|
+
status: Status
|
|
143
|
+
step: Step | None = None
|
|
144
|
+
status_reason: StatusReason | None = None
|
|
145
|
+
holds: frozenset[Hold] = Field(default_factory=frozenset)
|
|
146
|
+
iteration: int = 0
|
|
147
|
+
counters: Counters = Field(default_factory=Counters)
|
|
148
|
+
rung_index: dict[Role, int] = Field(default_factory=dict)
|
|
149
|
+
overrides: dict[Role, Rung] = Field(default_factory=dict)
|
|
150
|
+
repo: RepoState | None = None
|
|
151
|
+
slot: int | None = None
|
|
152
|
+
window_id: str | None = None
|
|
153
|
+
panes: dict[Role, str] = Field(default_factory=dict)
|
|
154
|
+
wait_reason: str | None = None
|
|
155
|
+
requeued_at: datetime | None = None
|
|
156
|
+
summary: str | None = None
|
|
157
|
+
created_at: datetime
|
|
158
|
+
started_at: datetime | None = None
|
|
159
|
+
finished_at: datetime | None = None
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def held(self) -> bool:
|
|
163
|
+
"""Whether a task-scoped hold forbids starting the task's next external effect."""
|
|
164
|
+
return bool(self.holds)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def title_from_request(request: str) -> str:
|
|
168
|
+
"""Derive a title from the request's first line, cut at a word boundary within the limit."""
|
|
169
|
+
lines = request.splitlines()
|
|
170
|
+
first_line = lines[0] if lines else request
|
|
171
|
+
if len(first_line) <= TASK_TITLE_MAX_LENGTH:
|
|
172
|
+
return first_line
|
|
173
|
+
kept = first_line[: TASK_TITLE_MAX_LENGTH - len(TASK_TITLE_ELLIPSIS)]
|
|
174
|
+
return kept.rsplit(" ", 1)[0] + TASK_TITLE_ELLIPSIS
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Pure parsing and classification for the engine's normalized task evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
from shikiru.domain.timeline_phrases import GLYPH_ATTENTION, Phrase, Tone, signal_phrase
|
|
11
|
+
|
|
12
|
+
TIMELINE_ACTIVITY_CATEGORY = "activity"
|
|
13
|
+
TIMELINE_ALL_CATEGORY = "all"
|
|
14
|
+
TIMELINE_ENGINE_CATEGORY = "engine"
|
|
15
|
+
TIMELINE_SIGNAL_CATEGORY = "signal"
|
|
16
|
+
TIMELINE_AGENT_CATEGORY = "agent"
|
|
17
|
+
TIMELINE_DECISION_CATEGORY = "decision"
|
|
18
|
+
TIMELINE_TRANSITION_CATEGORY = "transition"
|
|
19
|
+
TIMELINE_TOOL_CATEGORY = "tool"
|
|
20
|
+
TIMELINE_MESSAGE_CATEGORY = "message"
|
|
21
|
+
TIMELINE_USAGE_CATEGORY = "usage"
|
|
22
|
+
TIMELINE_EVIDENCE_CATEGORY = "evidence"
|
|
23
|
+
TIMELINE_DEFAULT_CATEGORY = TIMELINE_ACTIVITY_CATEGORY
|
|
24
|
+
TIMELINE_CATEGORIES = frozenset(
|
|
25
|
+
{
|
|
26
|
+
TIMELINE_ACTIVITY_CATEGORY,
|
|
27
|
+
TIMELINE_ALL_CATEGORY,
|
|
28
|
+
TIMELINE_ENGINE_CATEGORY,
|
|
29
|
+
TIMELINE_SIGNAL_CATEGORY,
|
|
30
|
+
TIMELINE_AGENT_CATEGORY,
|
|
31
|
+
TIMELINE_DECISION_CATEGORY,
|
|
32
|
+
TIMELINE_TRANSITION_CATEGORY,
|
|
33
|
+
TIMELINE_TOOL_CATEGORY,
|
|
34
|
+
TIMELINE_MESSAGE_CATEGORY,
|
|
35
|
+
TIMELINE_USAGE_CATEGORY,
|
|
36
|
+
TIMELINE_EVIDENCE_CATEGORY,
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
SIGNAL_TOOL_NAMES = frozenset({"ToolStarted", "ToolFinished"})
|
|
40
|
+
SIGNAL_MESSAGE_NAMES = frozenset({"Message"})
|
|
41
|
+
SIGNAL_USAGE_NAMES = frozenset({"UsageReported", "QuotaReported"})
|
|
42
|
+
SIGNAL_LIFECYCLE_NAMES = frozenset({"SessionStarted", "TurnEnded", "TurnFailed", "Compacted"})
|
|
43
|
+
TIMELINE_DECISION_KINDS = frozenset(
|
|
44
|
+
{
|
|
45
|
+
"verdict",
|
|
46
|
+
"scope_change",
|
|
47
|
+
"escalation",
|
|
48
|
+
"escalation_resolved",
|
|
49
|
+
"task_done",
|
|
50
|
+
"task_cancelled",
|
|
51
|
+
"pr_opened",
|
|
52
|
+
"pr_withdrawn",
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
TIMELINE_SOURCE_ENGINE = "engine"
|
|
56
|
+
TIMELINE_SOURCE_AGENT = "agent"
|
|
57
|
+
INCOMPLETE_SIGNAL_SUMMARY = "Incomplete live JSONL row"
|
|
58
|
+
MALFORMED_SIGNAL_SUMMARY = "Malformed normalized signal evidence"
|
|
59
|
+
MISSING_SIGNAL_SUMMARY = "Normalized signal evidence is unavailable"
|
|
60
|
+
MISSING_TIMESTAMP = "recorded timestamp is missing"
|
|
61
|
+
TIMELINE_JSON_INDENT = 2
|
|
62
|
+
_TOOL_FINISHED_SIGNAL = "ToolFinished"
|
|
63
|
+
_OUTCOME_FIELD = "outcome"
|
|
64
|
+
_EXIT_CODE_FIELD = "exit_code"
|
|
65
|
+
_SUCCESS_OUTCOME = "success"
|
|
66
|
+
_FAILURE_OUTCOME = "failure"
|
|
67
|
+
_SUMMARY_SEPARATOR = " · "
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class ParsedSignal:
|
|
72
|
+
"""One parsed normalized signal row and its recorded evidence identity."""
|
|
73
|
+
|
|
74
|
+
role: str
|
|
75
|
+
turn: str | None
|
|
76
|
+
signal: str | None
|
|
77
|
+
category: str
|
|
78
|
+
timestamp: datetime | None
|
|
79
|
+
timestamp_error: str | None
|
|
80
|
+
summary: str
|
|
81
|
+
phrase: Phrase
|
|
82
|
+
listed: bool
|
|
83
|
+
detail: str
|
|
84
|
+
start_offset: int
|
|
85
|
+
end_offset: int
|
|
86
|
+
incomplete: bool
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_signal_row(
|
|
90
|
+
raw: bytes,
|
|
91
|
+
*,
|
|
92
|
+
role: str,
|
|
93
|
+
start_offset: int,
|
|
94
|
+
end_offset: int,
|
|
95
|
+
incomplete: bool,
|
|
96
|
+
) -> ParsedSignal:
|
|
97
|
+
"""Parse one normalized `view.jsonl` row without interpreting provider formats."""
|
|
98
|
+
text = raw.decode("utf-8", errors="replace")
|
|
99
|
+
try:
|
|
100
|
+
raw_data: object = json.loads(text)
|
|
101
|
+
except json.JSONDecodeError:
|
|
102
|
+
summary = INCOMPLETE_SIGNAL_SUMMARY if incomplete else MALFORMED_SIGNAL_SUMMARY
|
|
103
|
+
return ParsedSignal(
|
|
104
|
+
role=role,
|
|
105
|
+
turn=None,
|
|
106
|
+
signal=None,
|
|
107
|
+
category=TIMELINE_EVIDENCE_CATEGORY,
|
|
108
|
+
timestamp=None,
|
|
109
|
+
timestamp_error=MISSING_TIMESTAMP,
|
|
110
|
+
summary=summary,
|
|
111
|
+
phrase=_problem_phrase(summary),
|
|
112
|
+
listed=True,
|
|
113
|
+
detail=text,
|
|
114
|
+
start_offset=start_offset,
|
|
115
|
+
end_offset=end_offset,
|
|
116
|
+
incomplete=incomplete,
|
|
117
|
+
)
|
|
118
|
+
if not isinstance(raw_data, dict):
|
|
119
|
+
return ParsedSignal(
|
|
120
|
+
role=role,
|
|
121
|
+
turn=None,
|
|
122
|
+
signal=None,
|
|
123
|
+
category=TIMELINE_EVIDENCE_CATEGORY,
|
|
124
|
+
timestamp=None,
|
|
125
|
+
timestamp_error=MISSING_TIMESTAMP,
|
|
126
|
+
summary=MALFORMED_SIGNAL_SUMMARY,
|
|
127
|
+
phrase=_problem_phrase(MALFORMED_SIGNAL_SUMMARY),
|
|
128
|
+
listed=True,
|
|
129
|
+
detail=text,
|
|
130
|
+
start_offset=start_offset,
|
|
131
|
+
end_offset=end_offset,
|
|
132
|
+
incomplete=incomplete,
|
|
133
|
+
)
|
|
134
|
+
data = cast("dict[str, object]", raw_data)
|
|
135
|
+
turn = data.get("turn")
|
|
136
|
+
signal = data.get("signal")
|
|
137
|
+
timestamp, timestamp_error = _timestamp(data.get("ts"))
|
|
138
|
+
signal_name = signal if isinstance(signal, str) else None
|
|
139
|
+
summary = signal_name or MISSING_SIGNAL_SUMMARY
|
|
140
|
+
if signal_name == _TOOL_FINISHED_SIGNAL:
|
|
141
|
+
summary = _tool_finished_summary(data)
|
|
142
|
+
phrase = signal_phrase(signal_name, data) if signal_name else _problem_phrase(summary)
|
|
143
|
+
return ParsedSignal(
|
|
144
|
+
role=role,
|
|
145
|
+
turn=turn if isinstance(turn, str) else None,
|
|
146
|
+
signal=signal_name,
|
|
147
|
+
category=signal_category(signal_name),
|
|
148
|
+
timestamp=timestamp,
|
|
149
|
+
timestamp_error=timestamp_error,
|
|
150
|
+
summary=summary,
|
|
151
|
+
phrase=phrase or Phrase(summary),
|
|
152
|
+
listed=phrase is not None,
|
|
153
|
+
detail=json.dumps(data, ensure_ascii=False, sort_keys=True, indent=TIMELINE_JSON_INDENT),
|
|
154
|
+
start_offset=start_offset,
|
|
155
|
+
end_offset=end_offset,
|
|
156
|
+
incomplete=incomplete,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _problem_phrase(summary: str) -> Phrase:
|
|
161
|
+
return Phrase(summary, GLYPH_ATTENTION, Tone.WARNING)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _tool_finished_summary(data: dict[str, object]) -> str:
|
|
165
|
+
values = [_TOOL_FINISHED_SIGNAL]
|
|
166
|
+
outcome = data.get(_OUTCOME_FIELD)
|
|
167
|
+
if isinstance(outcome, str) and outcome in (_SUCCESS_OUTCOME, _FAILURE_OUTCOME):
|
|
168
|
+
values.append(outcome)
|
|
169
|
+
exit_code = data.get(_EXIT_CODE_FIELD)
|
|
170
|
+
if type(exit_code) is int:
|
|
171
|
+
values.append(f"exit {exit_code}")
|
|
172
|
+
return _SUMMARY_SEPARATOR.join(values)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def signal_category(signal: str | None) -> str:
|
|
176
|
+
"""Classify a normalized signal name for evidence filtering."""
|
|
177
|
+
if signal is None:
|
|
178
|
+
return TIMELINE_EVIDENCE_CATEGORY
|
|
179
|
+
if signal in SIGNAL_TOOL_NAMES:
|
|
180
|
+
return TIMELINE_TOOL_CATEGORY
|
|
181
|
+
if signal in SIGNAL_MESSAGE_NAMES:
|
|
182
|
+
return TIMELINE_MESSAGE_CATEGORY
|
|
183
|
+
if signal in SIGNAL_USAGE_NAMES:
|
|
184
|
+
return TIMELINE_USAGE_CATEGORY
|
|
185
|
+
if signal in SIGNAL_LIFECYCLE_NAMES:
|
|
186
|
+
return TIMELINE_TRANSITION_CATEGORY
|
|
187
|
+
return TIMELINE_SIGNAL_CATEGORY
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def event_category(kind: str) -> str:
|
|
191
|
+
"""Return the operator-facing category for one persisted engine event."""
|
|
192
|
+
return (
|
|
193
|
+
TIMELINE_DECISION_CATEGORY
|
|
194
|
+
if kind in TIMELINE_DECISION_KINDS
|
|
195
|
+
else TIMELINE_TRANSITION_CATEGORY
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _timestamp(value: object) -> tuple[datetime | None, str | None]:
|
|
200
|
+
if not isinstance(value, str):
|
|
201
|
+
return None, MISSING_TIMESTAMP
|
|
202
|
+
try:
|
|
203
|
+
parsed = datetime.fromisoformat(value)
|
|
204
|
+
except ValueError:
|
|
205
|
+
return None, "recorded timestamp is invalid"
|
|
206
|
+
if parsed.tzinfo is None:
|
|
207
|
+
return None, "recorded timestamp has no UTC offset"
|
|
208
|
+
return parsed.astimezone(UTC), None
|