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
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Pure derivations behind the task briefing: rework, verdict, produced work, tests and PR."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from shikiru.domain.reports import DECISION_ACCEPT
|
|
11
|
+
from shikiru.domain.tasks import Hold, Status
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Mapping, Sequence
|
|
15
|
+
|
|
16
|
+
from shikiru.domain.reports import TestRun, Verdict
|
|
17
|
+
|
|
18
|
+
FACTS_COMMITS_HEADING = "### Commits (base..HEAD)"
|
|
19
|
+
FACTS_DIFF_STAT_HEADING = "### Diff stat (base...HEAD)"
|
|
20
|
+
FACTS_NONE_MARKER = "(none)"
|
|
21
|
+
PR_OPEN_STATE = "OPEN"
|
|
22
|
+
PR_MERGED_STATE = "MERGED"
|
|
23
|
+
PR_URL_KEY = "url"
|
|
24
|
+
PR_STATE_KEY = "state"
|
|
25
|
+
PR_CHECKS_PENDING_KEY = "checks_pending"
|
|
26
|
+
PR_CHECKS_FAILING_KEY = "checks_failing"
|
|
27
|
+
MAX_BRIEFING_ACTIONS = 4
|
|
28
|
+
FIRST_PASS_ITERATION = 1
|
|
29
|
+
LAST_WORDS_ELLIPSIS = "…"
|
|
30
|
+
_FACTS_SECTION_BREAK = "\n\n"
|
|
31
|
+
_FACTS_PARTS = 4
|
|
32
|
+
_DIFF_SUMMARY = re.compile(
|
|
33
|
+
r"(?P<files>\d+) files? changed"
|
|
34
|
+
r"(?:, (?P<insertions>\d+) insertions?\(\+\))?"
|
|
35
|
+
r"(?:, (?P<deletions>\d+) deletions?\(-\))?"
|
|
36
|
+
)
|
|
37
|
+
_PR_NUMBER = re.compile(r"/pull/(?P<number>\d+)/?$")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BriefingAction(StrEnum):
|
|
41
|
+
"""A briefing verb, declared in offer order; each value is the palette action it opens."""
|
|
42
|
+
|
|
43
|
+
ANSWER = "resolve"
|
|
44
|
+
TAKE_OVER = "take-over"
|
|
45
|
+
HAND_BACK = "hand-back"
|
|
46
|
+
MESSAGE = "message"
|
|
47
|
+
PAUSE = "pause"
|
|
48
|
+
RESUME = "resume"
|
|
49
|
+
CANCEL = "cancel"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class VerdictCounts:
|
|
54
|
+
"""A verdict's decision and its issues counted by severity."""
|
|
55
|
+
|
|
56
|
+
decision: str
|
|
57
|
+
blocker: int
|
|
58
|
+
major: int
|
|
59
|
+
minor: int
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def has_problems(self) -> bool:
|
|
63
|
+
"""Whether the verdict is anything but a clean accept."""
|
|
64
|
+
return self.decision != DECISION_ACCEPT or bool(self.blocker or self.major or self.minor)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class ProducedNumbers:
|
|
69
|
+
"""Commits, files and lines recorded in the facts snapshot."""
|
|
70
|
+
|
|
71
|
+
commits: int
|
|
72
|
+
files: int
|
|
73
|
+
insertions: int
|
|
74
|
+
deletions: int
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class ReportedTests:
|
|
79
|
+
"""Agent-reported test results by outcome."""
|
|
80
|
+
|
|
81
|
+
passed: int
|
|
82
|
+
failed: int
|
|
83
|
+
skipped: int
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class PullRequestLine:
|
|
88
|
+
"""The recorded pull request; checks are set only when the engine recorded them."""
|
|
89
|
+
|
|
90
|
+
state: str
|
|
91
|
+
url: str | None
|
|
92
|
+
number: int | None
|
|
93
|
+
checks_pending: bool | None
|
|
94
|
+
checks_failing: bool | None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def escalation_remaining(
|
|
98
|
+
rejections_on_rung: int, escalate_after: int, *, can_escalate: bool
|
|
99
|
+
) -> int | None:
|
|
100
|
+
"""Return the rejections left before the Implementer's rung escalates automatically.
|
|
101
|
+
|
|
102
|
+
The counter counts rework at one rung; the rejection that makes it reach `escalate_after`
|
|
103
|
+
moves the rung up, so the remainder is only meaningful once a rejection happened and the
|
|
104
|
+
ladder still has a higher rung.
|
|
105
|
+
"""
|
|
106
|
+
remaining = escalate_after - rejections_on_rung
|
|
107
|
+
if rejections_on_rung < 1 or not can_escalate or remaining < 1:
|
|
108
|
+
return None
|
|
109
|
+
return remaining
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def verdict_counts(verdict: Verdict) -> VerdictCounts:
|
|
113
|
+
"""Count `verdict`'s issues by severity beside its decision."""
|
|
114
|
+
return severity_counts(verdict.decision, [issue.severity for issue in verdict.issues])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def severity_counts(decision: str, severities: Sequence[str]) -> VerdictCounts:
|
|
118
|
+
"""Count `severities` by level beside `decision`."""
|
|
119
|
+
return VerdictCounts(
|
|
120
|
+
decision,
|
|
121
|
+
severities.count("blocker"),
|
|
122
|
+
severities.count("major"),
|
|
123
|
+
severities.count("minor"),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def reported_tests(tests: Sequence[TestRun]) -> ReportedTests:
|
|
128
|
+
"""Count `tests` by their reported result."""
|
|
129
|
+
results = [test.result for test in tests]
|
|
130
|
+
return ReportedTests(results.count("passed"), results.count("failed"), results.count("skipped"))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _facts_blocks(facts: str | None) -> tuple[str, str] | None:
|
|
134
|
+
if facts is None:
|
|
135
|
+
return None
|
|
136
|
+
parts = facts.split(_FACTS_SECTION_BREAK)
|
|
137
|
+
if len(parts) < _FACTS_PARTS:
|
|
138
|
+
return None
|
|
139
|
+
commits_heading, commits_block, diff_heading, diff_block, *_ = parts
|
|
140
|
+
if commits_heading != FACTS_COMMITS_HEADING or diff_heading != FACTS_DIFF_STAT_HEADING:
|
|
141
|
+
return None
|
|
142
|
+
return commits_block, diff_block
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def facts_commit_lines(facts: str | None) -> tuple[str, ...]:
|
|
146
|
+
"""Return the one-line commits of rendered facts, empty when none or unreadable."""
|
|
147
|
+
blocks = _facts_blocks(facts)
|
|
148
|
+
if blocks is None or blocks[0] == FACTS_NONE_MARKER:
|
|
149
|
+
return ()
|
|
150
|
+
return tuple(blocks[0].splitlines())
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def parse_produced(facts: str | None) -> ProducedNumbers | None:
|
|
154
|
+
"""Read the commit count and diff summary from rendered facts, or None when unreadable.
|
|
155
|
+
|
|
156
|
+
An empty diff prints no summary line and renders as `(none)`, which is zero change.
|
|
157
|
+
"""
|
|
158
|
+
blocks = _facts_blocks(facts)
|
|
159
|
+
if blocks is None:
|
|
160
|
+
return None
|
|
161
|
+
commits = len(facts_commit_lines(facts))
|
|
162
|
+
diff_block = blocks[1]
|
|
163
|
+
if diff_block == FACTS_NONE_MARKER:
|
|
164
|
+
return ProducedNumbers(commits, 0, 0, 0)
|
|
165
|
+
summary = _DIFF_SUMMARY.search(diff_block.strip().splitlines()[-1])
|
|
166
|
+
if summary is None:
|
|
167
|
+
return None
|
|
168
|
+
return ProducedNumbers(
|
|
169
|
+
commits,
|
|
170
|
+
int(summary["files"]),
|
|
171
|
+
int(summary["insertions"] or 0),
|
|
172
|
+
int(summary["deletions"] or 0),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def pull_request_line(data: Mapping[str, object], *, task_done: bool = False) -> PullRequestLine:
|
|
177
|
+
"""Describe the latest recorded pull request event; an open event records no state yet.
|
|
178
|
+
|
|
179
|
+
A Done task has its pull request merged, whatever the last recorded event said.
|
|
180
|
+
"""
|
|
181
|
+
url = data.get(PR_URL_KEY)
|
|
182
|
+
state = data.get(PR_STATE_KEY)
|
|
183
|
+
link = url if isinstance(url, str) else None
|
|
184
|
+
match = _PR_NUMBER.search(link) if link is not None else None
|
|
185
|
+
pending = data.get(PR_CHECKS_PENDING_KEY)
|
|
186
|
+
failing = data.get(PR_CHECKS_FAILING_KEY)
|
|
187
|
+
return PullRequestLine(
|
|
188
|
+
state=PR_MERGED_STATE
|
|
189
|
+
if task_done
|
|
190
|
+
else (state if isinstance(state, str) else PR_OPEN_STATE),
|
|
191
|
+
url=link,
|
|
192
|
+
number=int(match["number"]) if match is not None else None,
|
|
193
|
+
checks_pending=pending if isinstance(pending, bool) else None,
|
|
194
|
+
checks_failing=failing if isinstance(failing, bool) else None,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def squashed(text: str) -> str | None:
|
|
199
|
+
"""Collapse the whitespace runs of `text` to single spaces, or None when it is blank."""
|
|
200
|
+
return " ".join(text.split()) or None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def one_line(text: str, limit: int) -> str | None:
|
|
204
|
+
"""Collapse `text` to one line of at most `limit` characters, or None when it is blank."""
|
|
205
|
+
collapsed = squashed(text)
|
|
206
|
+
if collapsed is None:
|
|
207
|
+
return None
|
|
208
|
+
if len(collapsed) <= limit:
|
|
209
|
+
return collapsed
|
|
210
|
+
return collapsed[: limit - len(LAST_WORDS_ELLIPSIS)].rstrip() + LAST_WORDS_ELLIPSIS
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def briefing_actions(
|
|
214
|
+
status: Status,
|
|
215
|
+
holds: Sequence[str],
|
|
216
|
+
*,
|
|
217
|
+
operator_owned: bool,
|
|
218
|
+
can_take_over: bool = False,
|
|
219
|
+
waiting: bool = False,
|
|
220
|
+
) -> tuple[BriefingAction, ...]:
|
|
221
|
+
"""Return the at most four verbs the briefing offers for a task in this state.
|
|
222
|
+
|
|
223
|
+
A decision comes first, then the ownership hand-over, a message, and the pause hold's
|
|
224
|
+
opposite; Cancel always closes the row of an unfinished task and a finished task offers
|
|
225
|
+
none. Take over is offered whenever the operator action can work (`can_take_over`, the
|
|
226
|
+
palette's own availability for the acting role). A waiting task has no agent to read a
|
|
227
|
+
message, so it offers none. An intervention hold is cleared by handing back, so it never
|
|
228
|
+
offers Resume.
|
|
229
|
+
"""
|
|
230
|
+
if status in (Status.DONE, Status.CANCELLED):
|
|
231
|
+
return ()
|
|
232
|
+
verbs: list[BriefingAction] = []
|
|
233
|
+
if status is Status.ESCALATED:
|
|
234
|
+
verbs.append(BriefingAction.ANSWER)
|
|
235
|
+
if operator_owned:
|
|
236
|
+
verbs.append(BriefingAction.HAND_BACK)
|
|
237
|
+
elif can_take_over:
|
|
238
|
+
verbs.append(BriefingAction.TAKE_OVER)
|
|
239
|
+
if not waiting:
|
|
240
|
+
verbs.append(BriefingAction.MESSAGE)
|
|
241
|
+
if Hold.PAUSED.value in holds:
|
|
242
|
+
verbs.append(BriefingAction.RESUME)
|
|
243
|
+
elif not holds:
|
|
244
|
+
verbs.append(BriefingAction.PAUSE)
|
|
245
|
+
return (*verbs[: MAX_BRIEFING_ACTIONS - 1], BriefingAction.CANCEL)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def rework_progress(iteration: int, max_iterations: int) -> tuple[int, int] | None:
|
|
249
|
+
"""Return `(reworks so far, reworks allowed)`, or None until the first rework.
|
|
250
|
+
|
|
251
|
+
`iteration` counts implement/verify rounds from 1, so the first pass is not a rework, and
|
|
252
|
+
the round that reaches `max_iterations` is the final one: `max_iterations - 1` reworks fit.
|
|
253
|
+
"""
|
|
254
|
+
allowed = max_iterations - FIRST_PASS_ITERATION
|
|
255
|
+
if iteration <= FIRST_PASS_ITERATION or allowed < 1:
|
|
256
|
+
return None
|
|
257
|
+
return iteration - FIRST_PASS_ITERATION, allowed
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Operator command models, discriminated by `kind` for storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum, auto
|
|
6
|
+
from typing import Annotated, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import Field
|
|
9
|
+
|
|
10
|
+
from shikiru.agents.contracts import Rung
|
|
11
|
+
from shikiru.config.settings import ComplexityName
|
|
12
|
+
from shikiru.domain.tasks import DomainModel, Mode, Role
|
|
13
|
+
|
|
14
|
+
COMMAND_ID_KEY = "command_id"
|
|
15
|
+
EVENT_TEXT_KEY = "text"
|
|
16
|
+
COMMAND_POLL_SECONDS = 0.25
|
|
17
|
+
COMMAND_RESULT_ERROR_KEY = "error"
|
|
18
|
+
COMMAND_REJECTED_FALLBACK = "engine rejected the command"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CommandStatus(StrEnum):
|
|
22
|
+
"""The `commands.status` lifecycle."""
|
|
23
|
+
|
|
24
|
+
PENDING = auto()
|
|
25
|
+
DONE = auto()
|
|
26
|
+
REJECTED = auto()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CommandKind(StrEnum):
|
|
30
|
+
"""The `commands.kind` discriminator."""
|
|
31
|
+
|
|
32
|
+
SUBMIT = auto()
|
|
33
|
+
PRIORITIZE = auto()
|
|
34
|
+
PAUSE = auto()
|
|
35
|
+
RESUME = auto()
|
|
36
|
+
CANCEL = auto()
|
|
37
|
+
RETRY = auto()
|
|
38
|
+
SET_RUNG = auto()
|
|
39
|
+
SAY = auto()
|
|
40
|
+
TAKE_OVER = auto()
|
|
41
|
+
HAND_BACK = auto()
|
|
42
|
+
RESOLVE = auto()
|
|
43
|
+
STOP = auto()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class IdentifiedCommand(DomainModel):
|
|
47
|
+
"""A command whose effects are recognised on replay by the queue row id it is read with."""
|
|
48
|
+
|
|
49
|
+
command_id: int | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Submit(DomainModel):
|
|
53
|
+
"""`submit "<request>" [flags]`: the queue allocates the task id when appending it."""
|
|
54
|
+
|
|
55
|
+
kind: Literal[CommandKind.SUBMIT] = CommandKind.SUBMIT
|
|
56
|
+
task_id: str | None = None
|
|
57
|
+
title: str
|
|
58
|
+
request: str
|
|
59
|
+
mode: Mode | None = None
|
|
60
|
+
complexity: ComplexityName | None = None
|
|
61
|
+
priority: int = 0
|
|
62
|
+
depends_on: list[str] = Field(default_factory=list)
|
|
63
|
+
leader: Rung | None = None
|
|
64
|
+
implementer: Rung | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Prioritize(DomainModel):
|
|
68
|
+
"""`prioritize T-n N`: sets a task's dispatch priority."""
|
|
69
|
+
|
|
70
|
+
kind: Literal[CommandKind.PRIORITIZE] = CommandKind.PRIORITIZE
|
|
71
|
+
task_id: str
|
|
72
|
+
priority: int
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Pause(DomainModel):
|
|
76
|
+
"""`pause T-n [--now]`: holds a task; `now` also interrupts and cancels the current step."""
|
|
77
|
+
|
|
78
|
+
kind: Literal[CommandKind.PAUSE] = CommandKind.PAUSE
|
|
79
|
+
task_id: str
|
|
80
|
+
now: bool = False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Resume(DomainModel):
|
|
84
|
+
"""`resume T-n`: clears the `paused` hold, or injects `continue` when it was `intervention`."""
|
|
85
|
+
|
|
86
|
+
kind: Literal[CommandKind.RESUME] = CommandKind.RESUME
|
|
87
|
+
task_id: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Cancel(IdentifiedCommand):
|
|
91
|
+
"""`cancel T-n`: cancels a task and keeps its branch."""
|
|
92
|
+
|
|
93
|
+
kind: Literal[CommandKind.CANCEL] = CommandKind.CANCEL
|
|
94
|
+
task_id: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Retry(IdentifiedCommand):
|
|
98
|
+
"""`retry T-n`: cancels and re-runs the task's current step."""
|
|
99
|
+
|
|
100
|
+
kind: Literal[CommandKind.RETRY] = CommandKind.RETRY
|
|
101
|
+
task_id: str
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class SetRung(DomainModel):
|
|
105
|
+
"""`rung T-n <role> <agent:model:effort>`: overrides a role's rung from the next turn."""
|
|
106
|
+
|
|
107
|
+
kind: Literal[CommandKind.SET_RUNG] = CommandKind.SET_RUNG
|
|
108
|
+
task_id: str
|
|
109
|
+
role: Role
|
|
110
|
+
rung: Rung
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Say(IdentifiedCommand):
|
|
114
|
+
"""`say T-n <role> "<text>"`: queues a message for the role's next turn.
|
|
115
|
+
|
|
116
|
+
The queue fills `command_id` so a replayed delivery is recognised.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
kind: Literal[CommandKind.SAY] = CommandKind.SAY
|
|
120
|
+
task_id: str
|
|
121
|
+
role: Role
|
|
122
|
+
text: str
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TakeOver(IdentifiedCommand):
|
|
126
|
+
"""`take-over T-n <role>`: stops the turn and opens the role's session in its pane."""
|
|
127
|
+
|
|
128
|
+
kind: Literal[CommandKind.TAKE_OVER] = CommandKind.TAKE_OVER
|
|
129
|
+
task_id: str
|
|
130
|
+
role: Role
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class HandBack(IdentifiedCommand):
|
|
134
|
+
"""`hand-back T-n <role>`: returns a taken-over session to the engine.
|
|
135
|
+
|
|
136
|
+
The queue fills `command_id` so a replayed delivery is recognised.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
kind: Literal[CommandKind.HAND_BACK] = CommandKind.HAND_BACK
|
|
140
|
+
task_id: str
|
|
141
|
+
role: Role
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ResolutionKind(StrEnum):
|
|
145
|
+
"""The `resolve` answer's discriminator."""
|
|
146
|
+
|
|
147
|
+
ANSWER = auto()
|
|
148
|
+
RETRY = auto()
|
|
149
|
+
ACCEPT = auto()
|
|
150
|
+
CANCEL = auto()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class AnswerResolution(DomainModel):
|
|
154
|
+
"""`resolve E-n <answer TEXT>`: runs a consult turn with the Operator's text."""
|
|
155
|
+
|
|
156
|
+
kind: Literal[ResolutionKind.ANSWER] = ResolutionKind.ANSWER
|
|
157
|
+
text: str
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class RetryResolution(DomainModel):
|
|
161
|
+
"""`resolve E-n retry [--rung a:m:e]`: re-runs the failed step, optionally on a new rung."""
|
|
162
|
+
|
|
163
|
+
kind: Literal[ResolutionKind.RETRY] = ResolutionKind.RETRY
|
|
164
|
+
rung: Rung | None = None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class AcceptResolution(DomainModel):
|
|
168
|
+
"""`resolve E-n accept`: integrates the task as-is."""
|
|
169
|
+
|
|
170
|
+
kind: Literal[ResolutionKind.ACCEPT] = ResolutionKind.ACCEPT
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class CancelResolution(DomainModel):
|
|
174
|
+
"""`resolve E-n cancel`: cancels the task and keeps its branch."""
|
|
175
|
+
|
|
176
|
+
kind: Literal[ResolutionKind.CANCEL] = ResolutionKind.CANCEL
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
Resolution = Annotated[
|
|
180
|
+
AnswerResolution | RetryResolution | AcceptResolution | CancelResolution,
|
|
181
|
+
Field(discriminator="kind"),
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class Resolve(IdentifiedCommand):
|
|
186
|
+
"""`resolve E-n <...>`: resolves an open escalation."""
|
|
187
|
+
|
|
188
|
+
kind: Literal[CommandKind.RESOLVE] = CommandKind.RESOLVE
|
|
189
|
+
escalation_id: str
|
|
190
|
+
resolution: Resolution
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class Stop(DomainModel):
|
|
194
|
+
"""`stop`: stops the engine; panes and their turns keep running."""
|
|
195
|
+
|
|
196
|
+
kind: Literal[CommandKind.STOP] = CommandKind.STOP
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
Command = Annotated[
|
|
200
|
+
Submit
|
|
201
|
+
| Prioritize
|
|
202
|
+
| Pause
|
|
203
|
+
| Resume
|
|
204
|
+
| Cancel
|
|
205
|
+
| Retry
|
|
206
|
+
| SetRung
|
|
207
|
+
| Say
|
|
208
|
+
| TakeOver
|
|
209
|
+
| HandBack
|
|
210
|
+
| Resolve
|
|
211
|
+
| Stop,
|
|
212
|
+
Field(discriminator="kind"),
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def command_rejection_cause(result: dict[str, object] | None) -> str:
|
|
217
|
+
"""Return the engine's `error` text from a rejected command's result, else a fixed phrase."""
|
|
218
|
+
error = None if result is None else result.get(COMMAND_RESULT_ERROR_KEY)
|
|
219
|
+
return error if isinstance(error, str) else COMMAND_REJECTED_FALLBACK
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Pure derivation of a task's Decisions: a chronological log of who decided what."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import TYPE_CHECKING, cast
|
|
8
|
+
|
|
9
|
+
from shikiru.domain.briefing import pull_request_line, squashed
|
|
10
|
+
from shikiru.domain.commands import EVENT_TEXT_KEY, ResolutionKind
|
|
11
|
+
from shikiru.domain.events import EventKind
|
|
12
|
+
from shikiru.domain.reports import (
|
|
13
|
+
DECISION_ACCEPT,
|
|
14
|
+
DECISION_ESCALATE,
|
|
15
|
+
DECISION_SUPERSEDE,
|
|
16
|
+
SEVERITY_BLOCKER,
|
|
17
|
+
)
|
|
18
|
+
from shikiru.domain.tasks import Hold
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Callable, Sequence
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
|
|
24
|
+
from shikiru.domain.events import Event
|
|
25
|
+
|
|
26
|
+
MAX_DETAIL_LINES = 3
|
|
27
|
+
DECISION_KEY = "decision"
|
|
28
|
+
SUMMARY_KEY = "summary"
|
|
29
|
+
ISSUES_KEY = "issues"
|
|
30
|
+
SEVERITY_KEY = "severity"
|
|
31
|
+
DESCRIPTION_KEY = "description"
|
|
32
|
+
LOCATION_KEY = "location"
|
|
33
|
+
RESOLUTION_KEY = "resolution"
|
|
34
|
+
ROLE_KEY = "role"
|
|
35
|
+
HOLD_KEY = "hold"
|
|
36
|
+
NEW_TASKS_KEY = "new_task_ids"
|
|
37
|
+
SCOPE_CHANGES_KEY = "scope_changes"
|
|
38
|
+
RUNG_FROM_KEY = "from"
|
|
39
|
+
RUNG_TO_KEY = "to"
|
|
40
|
+
OPERATOR_KEY = "operator"
|
|
41
|
+
_SEVERITY_ORDER = {SEVERITY_BLOCKER: 0, "major": 1, "minor": 2}
|
|
42
|
+
_UNRANKED = len(_SEVERITY_ORDER)
|
|
43
|
+
_VERDICT_WORDS = {DECISION_ACCEPT: "accepted", DECISION_SUPERSEDE: "superseded"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Decider(StrEnum):
|
|
47
|
+
"""Who made a decision, as the Decisions rows name them."""
|
|
48
|
+
|
|
49
|
+
LEADER = "Leader"
|
|
50
|
+
YOU = "You"
|
|
51
|
+
ENGINE = "Engine"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class DecisionEntry:
|
|
56
|
+
"""One decision: when, by whom, a what/why headline and its capped detail lines."""
|
|
57
|
+
|
|
58
|
+
at: datetime
|
|
59
|
+
decider: Decider
|
|
60
|
+
headline: str
|
|
61
|
+
details: tuple[str, ...]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class TaskDecisions:
|
|
66
|
+
"""A task's decisions, oldest first."""
|
|
67
|
+
|
|
68
|
+
entries: tuple[DecisionEntry, ...]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _text(data: dict[str, object], key: str) -> str | None:
|
|
72
|
+
value = data.get(key)
|
|
73
|
+
return squashed(value) if isinstance(value, str) else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _items(data: dict[str, object], key: str) -> list[object]:
|
|
77
|
+
value = data.get(key)
|
|
78
|
+
return cast("list[object]", value) if isinstance(value, list) else []
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _entry(event: Event, decider: Decider, headline: str, *details: str) -> DecisionEntry:
|
|
82
|
+
return DecisionEntry(event.ts, decider, headline, details[:MAX_DETAIL_LINES])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _issue_line(issue: dict[str, object]) -> str | None:
|
|
86
|
+
description = _text(issue, DESCRIPTION_KEY)
|
|
87
|
+
location = _text(issue, LOCATION_KEY)
|
|
88
|
+
if description is None:
|
|
89
|
+
return None
|
|
90
|
+
return f"{description} ({location})" if location else description
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _issue_lines(data: dict[str, object]) -> list[str]:
|
|
94
|
+
issues = cast(
|
|
95
|
+
"list[dict[str, object]]",
|
|
96
|
+
[issue for issue in _items(data, ISSUES_KEY) if isinstance(issue, dict)],
|
|
97
|
+
)
|
|
98
|
+
ranked = sorted(
|
|
99
|
+
issues,
|
|
100
|
+
key=lambda issue: _SEVERITY_ORDER.get(str(issue.get(SEVERITY_KEY)), _UNRANKED),
|
|
101
|
+
)
|
|
102
|
+
return [line for line in map(_issue_line, ranked) if line is not None]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _verdict(event: Event, _: str | None) -> DecisionEntry | None:
|
|
106
|
+
decision = str(event.data.get(DECISION_KEY))
|
|
107
|
+
if decision == DECISION_ESCALATE:
|
|
108
|
+
return None
|
|
109
|
+
summary = _text(event.data, SUMMARY_KEY)
|
|
110
|
+
headline = _VERDICT_WORDS.get(decision, decision.replace("_", " "))
|
|
111
|
+
return _entry(
|
|
112
|
+
event, Decider.LEADER, headline, *([summary] if summary else []), *_issue_lines(event.data)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _escalation(event: Event, _: str | None) -> DecisionEntry:
|
|
117
|
+
question = squashed(event.message)
|
|
118
|
+
return _entry(event, Decider.LEADER, f"escalated: {question}" if question else "escalated")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _resolution(event: Event, _: str | None) -> DecisionEntry | None:
|
|
122
|
+
answer = _text(event.data, EVENT_TEXT_KEY)
|
|
123
|
+
headlines = {
|
|
124
|
+
ResolutionKind.ANSWER.value: f'answered "{answer}"' if answer else "answered",
|
|
125
|
+
ResolutionKind.RETRY.value: "retried",
|
|
126
|
+
ResolutionKind.ACCEPT.value: "accepted as is",
|
|
127
|
+
}
|
|
128
|
+
headline = headlines.get(str(event.data.get(RESOLUTION_KEY)))
|
|
129
|
+
return _entry(event, Decider.YOU, headline) if headline is not None else None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _scope_change(event: Event, _: str | None) -> DecisionEntry:
|
|
133
|
+
added = [str(task) for task in _items(event.data, NEW_TASKS_KEY)]
|
|
134
|
+
scope = [
|
|
135
|
+
text for text in map(squashed, map(str, _items(event.data, SCOPE_CHANGES_KEY))) if text
|
|
136
|
+
]
|
|
137
|
+
return _entry(
|
|
138
|
+
event, Decider.LEADER, f"added {', '.join(added)}" if added else "scope changed", *scope
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _role(event: Event) -> str:
|
|
143
|
+
return str(event.data.get(ROLE_KEY)).capitalize()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _rung_change(event: Event, _: str | None) -> DecisionEntry:
|
|
147
|
+
decider = Decider.YOU if event.data.get(OPERATOR_KEY) else Decider.ENGINE
|
|
148
|
+
old = event.data.get(RUNG_FROM_KEY)
|
|
149
|
+
lead = (
|
|
150
|
+
f"{_role(event)}: {old} \N{RIGHTWARDS ARROW}"
|
|
151
|
+
if old
|
|
152
|
+
else f"{_role(event)} \N{RIGHTWARDS ARROW}"
|
|
153
|
+
)
|
|
154
|
+
return _entry(event, decider, f"{lead} {event.data.get(RUNG_TO_KEY)}")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _operator_message(event: Event, _: str | None) -> DecisionEntry:
|
|
158
|
+
text = _text(event.data, EVENT_TEXT_KEY)
|
|
159
|
+
return _entry(
|
|
160
|
+
event,
|
|
161
|
+
Decider.YOU,
|
|
162
|
+
f'messaged {_role(event)} "{text}"' if text else f"messaged {_role(event)}",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _you(headline: str) -> Callable[[Event, str | None], DecisionEntry]:
|
|
167
|
+
return lambda event, _: _entry(event, Decider.YOU, headline)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _role_action(verb: str) -> Callable[[Event, str | None], DecisionEntry]:
|
|
171
|
+
return lambda event, _: _entry(event, Decider.YOU, f"{verb} {_role(event)}")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _paused(headline: str) -> Callable[[Event, str | None], DecisionEntry | None]:
|
|
175
|
+
def build(event: Event, _: str | None) -> DecisionEntry | None:
|
|
176
|
+
return (
|
|
177
|
+
_entry(event, Decider.YOU, headline)
|
|
178
|
+
if event.data.get(HOLD_KEY) == Hold.PAUSED.value
|
|
179
|
+
else None
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return build
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _pull_request(verb: str) -> Callable[[Event, str | None], DecisionEntry]:
|
|
186
|
+
def build(event: Event, _: str | None) -> DecisionEntry:
|
|
187
|
+
number = pull_request_line(event.data).number
|
|
188
|
+
return _entry(event, Decider.ENGINE, f"PR #{number} {verb}" if number else f"PR {verb}")
|
|
189
|
+
|
|
190
|
+
return build
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _done(event: Event, pull_request: str | None) -> DecisionEntry:
|
|
194
|
+
return _entry(event, Decider.ENGINE, f"{pull_request} merged" if pull_request else "merged")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
_BUILDERS: dict[EventKind, Callable[[Event, str | None], DecisionEntry | None]] = {
|
|
198
|
+
EventKind.VERDICT: _verdict,
|
|
199
|
+
EventKind.ESCALATION: _escalation,
|
|
200
|
+
EventKind.ESCALATION_RESOLVED: _resolution,
|
|
201
|
+
EventKind.SCOPE_CHANGE: _scope_change,
|
|
202
|
+
EventKind.RUNG_CHANGED: _rung_change,
|
|
203
|
+
EventKind.OPERATOR_MESSAGE: _operator_message,
|
|
204
|
+
EventKind.TAKEN_OVER: _role_action("took over"),
|
|
205
|
+
EventKind.HANDED_BACK: _role_action("handed back"),
|
|
206
|
+
EventKind.HOLD_SET: _paused("paused"),
|
|
207
|
+
EventKind.HOLD_CLEARED: _paused("resumed"),
|
|
208
|
+
EventKind.TASK_RETRIED: _you("retried"),
|
|
209
|
+
EventKind.TASK_CANCELLED: _you("cancelled"),
|
|
210
|
+
EventKind.PR_OPENED: _pull_request("opened"),
|
|
211
|
+
EventKind.PR_WITHDRAWN: _pull_request("withdrawn"),
|
|
212
|
+
EventKind.TASK_DONE: _done,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def derive_decisions(events: Sequence[Event]) -> TaskDecisions:
|
|
217
|
+
"""Derive a task's decision log from its recorded events, oldest first.
|
|
218
|
+
|
|
219
|
+
Events of other kinds are ignored; a cancelling resolution is shown by the cancel event.
|
|
220
|
+
"""
|
|
221
|
+
entries: list[DecisionEntry] = []
|
|
222
|
+
pull_request: str | None = None
|
|
223
|
+
for event in sorted(events, key=lambda recorded: recorded.id):
|
|
224
|
+
if event.kind is EventKind.PR_OPENED:
|
|
225
|
+
number = pull_request_line(event.data).number
|
|
226
|
+
pull_request = f"PR #{number}" if number else "PR"
|
|
227
|
+
builder = _BUILDERS.get(event.kind)
|
|
228
|
+
entry = builder(event, pull_request) if builder is not None else None
|
|
229
|
+
if entry is not None:
|
|
230
|
+
entries.append(entry)
|
|
231
|
+
return TaskDecisions(tuple(entries))
|