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 @@
|
|
|
1
|
+
"""Pure domain rules: models, transitions and policies with no I/O."""
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Acceptance criteria a task request states under its acceptance-criteria heading."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
ACCEPTANCE_CRITERIA_HEADING: Final = "Acceptance criteria"
|
|
10
|
+
|
|
11
|
+
_PHRASE: Final = re.escape(ACCEPTANCE_CRITERIA_HEADING)
|
|
12
|
+
_HEADING: Final = re.compile(
|
|
13
|
+
rf"(?:#{{1,6}}\s+)?(?:\*\*{_PHRASE}(?::\*\*|\*\*:?)|{_PHRASE}:?)\s*", re.IGNORECASE
|
|
14
|
+
)
|
|
15
|
+
_ANY_HEADING: Final = re.compile(r"\s*#{1,6}\s")
|
|
16
|
+
_FENCE: Final = re.compile(r"\s{0,3}(?P<marker>`{3,}|~{3,})")
|
|
17
|
+
_CRITERION: Final = re.compile(r"\s*(?:[-*+]|\d+[.)])\s+(?P<text>\S.*?)\s*")
|
|
18
|
+
_INDENTED: Final = re.compile(r"\s+\S.*")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class _Section:
|
|
23
|
+
start: int
|
|
24
|
+
end: int
|
|
25
|
+
criteria: tuple[str, ...]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def acceptance_criteria_from_request(request: str) -> tuple[str, ...]:
|
|
29
|
+
"""Return the bullets under the request's acceptance-criteria heading, in order.
|
|
30
|
+
|
|
31
|
+
Fenced code is an example, not a requirement, so its lines are never read as a heading or
|
|
32
|
+
a criterion. The section starts after the first line that is only the heading
|
|
33
|
+
`ACCEPTANCE_CRITERIA_HEADING`, written with optional `#` marks, optional bold markers and an
|
|
34
|
+
optional trailing colon, in any letter case. An indented line continues the bullet above it.
|
|
35
|
+
The section ends at the first other non-blank line: prose, a heading or a code fence. A request
|
|
36
|
+
without that heading has no criteria.
|
|
37
|
+
"""
|
|
38
|
+
section = _section(request.splitlines(keepends=True))
|
|
39
|
+
return () if section is None else section.criteria
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def request_without_criteria_section(request: str) -> str:
|
|
43
|
+
"""Return the request without its acceptance-criteria section, keeping all other text.
|
|
44
|
+
|
|
45
|
+
The removed section is the one `acceptance_criteria_from_request` reads: its heading line,
|
|
46
|
+
its bullets and their continuations, and the blank lines among them. Every other character
|
|
47
|
+
and line ending is kept. Blank lines left at either end of the request by the removal are
|
|
48
|
+
dropped. A request without criteria is returned unchanged.
|
|
49
|
+
"""
|
|
50
|
+
lines = request.splitlines(keepends=True)
|
|
51
|
+
section = _section(lines)
|
|
52
|
+
if section is None:
|
|
53
|
+
return request
|
|
54
|
+
before = lines[: section.start]
|
|
55
|
+
after = lines[section.end :]
|
|
56
|
+
if not after:
|
|
57
|
+
before = _without_trailing_blank_lines(before)
|
|
58
|
+
if not before:
|
|
59
|
+
after = _without_leading_blank_lines(after)
|
|
60
|
+
return "".join([*before, *after])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _without_trailing_blank_lines(lines: list[str]) -> list[str]:
|
|
64
|
+
kept = list(lines)
|
|
65
|
+
while kept and not kept[-1].strip():
|
|
66
|
+
kept.pop()
|
|
67
|
+
return kept
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _without_leading_blank_lines(lines: list[str]) -> list[str]:
|
|
71
|
+
kept = list(lines)
|
|
72
|
+
while kept and not kept[0].strip():
|
|
73
|
+
kept.pop(0)
|
|
74
|
+
return kept
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _section(lines: list[str]) -> _Section | None:
|
|
78
|
+
visible = _blank_code_blocks(lines)
|
|
79
|
+
start = next(
|
|
80
|
+
(index for index, line in enumerate(visible) if _HEADING.fullmatch(line)),
|
|
81
|
+
None,
|
|
82
|
+
)
|
|
83
|
+
if start is None:
|
|
84
|
+
return None
|
|
85
|
+
criteria: list[str] = []
|
|
86
|
+
end = len(visible)
|
|
87
|
+
for index in range(start + 1, len(visible)):
|
|
88
|
+
line = visible[index]
|
|
89
|
+
if not line.strip():
|
|
90
|
+
continue
|
|
91
|
+
match = _CRITERION.fullmatch(line)
|
|
92
|
+
if match is not None:
|
|
93
|
+
criteria.append(match["text"])
|
|
94
|
+
elif criteria and _is_continuation(line):
|
|
95
|
+
criteria[-1] = f"{criteria[-1]} {line.strip()}"
|
|
96
|
+
else:
|
|
97
|
+
end = index
|
|
98
|
+
break
|
|
99
|
+
if not criteria:
|
|
100
|
+
return None
|
|
101
|
+
return _Section(start=start, end=end, criteria=tuple(criteria))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _blank_code_blocks(lines: list[str]) -> list[str]:
|
|
105
|
+
visible: list[str] = []
|
|
106
|
+
fence: str | None = None
|
|
107
|
+
for line in lines:
|
|
108
|
+
if fence is not None:
|
|
109
|
+
visible.append("")
|
|
110
|
+
if _closes_fence(line, fence):
|
|
111
|
+
fence = None
|
|
112
|
+
continue
|
|
113
|
+
visible.append(line.rstrip())
|
|
114
|
+
opening = _FENCE.match(line)
|
|
115
|
+
if opening is not None:
|
|
116
|
+
fence = opening["marker"]
|
|
117
|
+
return visible
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _closes_fence(line: str, fence: str) -> bool:
|
|
121
|
+
stripped = line.strip()
|
|
122
|
+
return len(stripped) >= len(fence) and stripped == fence[0] * len(stripped)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _is_continuation(line: str) -> bool:
|
|
126
|
+
return (
|
|
127
|
+
_INDENTED.fullmatch(line) is not None
|
|
128
|
+
and _ANY_HEADING.match(line) is None
|
|
129
|
+
and _FENCE.match(line) is None
|
|
130
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Stall detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
from shikiru.config.settings import TimeoutsConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def seconds_until_stall(
|
|
14
|
+
last_activity: datetime, tool_running: bool, now: datetime, timeouts: TimeoutsConfig
|
|
15
|
+
) -> float:
|
|
16
|
+
"""Return how long past `now` the activity last seen may stay silent.
|
|
17
|
+
|
|
18
|
+
A running tool gets the longer `timeouts.tool` budget; anything else uses `timeouts.stall`.
|
|
19
|
+
"""
|
|
20
|
+
threshold = timeouts.tool if tool_running else timeouts.stall
|
|
21
|
+
return threshold - (now - last_activity).total_seconds()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_stalled(
|
|
25
|
+
last_activity: datetime, tool_running: bool, now: datetime, timeouts: TimeoutsConfig
|
|
26
|
+
) -> bool:
|
|
27
|
+
"""Decide whether `now` is past the timeout for the kind of activity last seen."""
|
|
28
|
+
return seconds_until_stall(last_activity, tool_running, now, timeouts) <= 0
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""The capped digest of what a task did before it escalated."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import StrEnum, auto
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from shikiru.domain.briefing import one_line, parse_produced, reported_tests, verdict_counts
|
|
12
|
+
from shikiru.domain.events import EventKind
|
|
13
|
+
from shikiru.domain.reports import ImplementationReport, Verdict
|
|
14
|
+
from shikiru.domain.tasks import Step
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Mapping, Sequence
|
|
18
|
+
|
|
19
|
+
from shikiru.domain.events import Event
|
|
20
|
+
from shikiru.domain.reports import Report
|
|
21
|
+
from shikiru.domain.tasks import Task
|
|
22
|
+
|
|
23
|
+
TURN_FIELD = "turn"
|
|
24
|
+
ROLE_FIELD = "role"
|
|
25
|
+
STEP_FIELD = "step"
|
|
26
|
+
OUTCOME_FIELD = "outcome"
|
|
27
|
+
TIMES = "\N{MULTIPLICATION SIGN}"
|
|
28
|
+
PART_SEPARATOR = " \N{MIDDLE DOT} "
|
|
29
|
+
MINUS = "\N{MINUS SIGN}"
|
|
30
|
+
EARLIER_TEMPLATE = "+{count} earlier"
|
|
31
|
+
LABEL_SEPARATOR = ": "
|
|
32
|
+
VERDICT_SUFFIX = " verdict"
|
|
33
|
+
TURN_FAILED_PREFIXES = ("failed", "turn failed")
|
|
34
|
+
TRIAGE_TEXT = "triaged as"
|
|
35
|
+
PLAN_READY = "plan ready"
|
|
36
|
+
NO_COMMITS = "no commits"
|
|
37
|
+
COMPLETED = "completed"
|
|
38
|
+
NEEDS_DECISION = "needs_decision"
|
|
39
|
+
BLOCKED = "blocked"
|
|
40
|
+
LABEL_ROUNDS = "Rounds"
|
|
41
|
+
LABEL_RECOVERY = "Recovery"
|
|
42
|
+
LABEL_PRODUCED = "Produced"
|
|
43
|
+
LABEL_OPERATOR = "Operator"
|
|
44
|
+
FAILURE_MESSAGE_LIMIT = 80
|
|
45
|
+
_OUTCOME_WORDS = {
|
|
46
|
+
"answer": "answered",
|
|
47
|
+
"accept": "accepted",
|
|
48
|
+
"changes_requested": "changes requested",
|
|
49
|
+
"escalate": "escalated",
|
|
50
|
+
"supersede": "superseded",
|
|
51
|
+
NEEDS_DECISION: "needs a decision",
|
|
52
|
+
}
|
|
53
|
+
_RECOVERY_NOUNS = (
|
|
54
|
+
(EventKind.NUDGE, "nudge"),
|
|
55
|
+
(EventKind.HELPER_ACTION, "helper action"),
|
|
56
|
+
(EventKind.RUNG_CHANGED, "rung change"),
|
|
57
|
+
)
|
|
58
|
+
_OPERATOR_NOUNS = (
|
|
59
|
+
(EventKind.OPERATOR_MESSAGE, "message"),
|
|
60
|
+
(EventKind.TASK_RETRIED, "earlier retry"),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AttemptKind(StrEnum):
|
|
65
|
+
"""What one row of the escalation details shows."""
|
|
66
|
+
|
|
67
|
+
REASON = auto()
|
|
68
|
+
ROUNDS = auto()
|
|
69
|
+
TURN_OK = auto()
|
|
70
|
+
TURN_FAILED = auto()
|
|
71
|
+
VERDICT = auto()
|
|
72
|
+
RECOVERY = auto()
|
|
73
|
+
PRODUCED = auto()
|
|
74
|
+
OPERATOR = auto()
|
|
75
|
+
EARLIER = auto()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AttemptTone(StrEnum):
|
|
79
|
+
"""How a piece of a row reads: plain, a step name, success, failure or rework."""
|
|
80
|
+
|
|
81
|
+
PLAIN = auto()
|
|
82
|
+
STEP = auto()
|
|
83
|
+
SUCCESS = auto()
|
|
84
|
+
FAILURE = auto()
|
|
85
|
+
REWORK = auto()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class AttemptPart:
|
|
90
|
+
"""A run of row text with its tone."""
|
|
91
|
+
|
|
92
|
+
text: str
|
|
93
|
+
tone: AttemptTone
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class AttemptItem:
|
|
98
|
+
"""One row of the details: a kind, its label (a section or role name) and toned parts."""
|
|
99
|
+
|
|
100
|
+
kind: AttemptKind
|
|
101
|
+
label: str
|
|
102
|
+
parts: tuple[AttemptPart, ...]
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def text(self) -> str:
|
|
106
|
+
"""The row text without its label."""
|
|
107
|
+
return "".join(part.text for part in self.parts)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def line(self) -> str:
|
|
111
|
+
"""The `Label: text` packet line of this row."""
|
|
112
|
+
if self.kind in _UNLABELLED:
|
|
113
|
+
return self.text
|
|
114
|
+
label = self.label + VERDICT_SUFFIX if self.kind is AttemptKind.VERDICT else self.label
|
|
115
|
+
return label + LABEL_SEPARATOR + self.text
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_UNLABELLED = frozenset({AttemptKind.REASON, AttemptKind.EARLIER})
|
|
119
|
+
_SECTION_KINDS = {
|
|
120
|
+
LABEL_ROUNDS: AttemptKind.ROUNDS,
|
|
121
|
+
LABEL_RECOVERY: AttemptKind.RECOVERY,
|
|
122
|
+
LABEL_PRODUCED: AttemptKind.PRODUCED,
|
|
123
|
+
LABEL_OPERATOR: AttemptKind.OPERATOR,
|
|
124
|
+
}
|
|
125
|
+
_STEP_NAME = "|".join(step.value for step in Step)
|
|
126
|
+
_EARLIER = re.compile(r"\+\d+ earlier")
|
|
127
|
+
_TONES: dict[AttemptKind, tuple[tuple[re.Pattern[str], AttemptTone], ...]] = {
|
|
128
|
+
AttemptKind.ROUNDS: (
|
|
129
|
+
(re.compile(rf"\b(?:{_STEP_NAME})(?= {TIMES})"), AttemptTone.STEP),
|
|
130
|
+
(re.compile(r"\(?iteration \d+ of \d+\)?"), AttemptTone.REWORK),
|
|
131
|
+
),
|
|
132
|
+
AttemptKind.TURN_OK: (
|
|
133
|
+
(re.compile(r"\d+ commits?|\d+ tests? passed|\baccepted?\b"), AttemptTone.SUCCESS),
|
|
134
|
+
(re.compile(r"\d+ failed|no commits|\bblocked\b|\d+ blockers?"), AttemptTone.FAILURE),
|
|
135
|
+
(
|
|
136
|
+
re.compile(r"needs a decision|changes requested|\bescalated?\b|\bsuperseded\b"),
|
|
137
|
+
AttemptTone.REWORK,
|
|
138
|
+
),
|
|
139
|
+
(re.compile(rf"^(?:{_STEP_NAME})\b"), AttemptTone.STEP),
|
|
140
|
+
),
|
|
141
|
+
AttemptKind.TURN_FAILED: ((re.compile(r".+"), AttemptTone.FAILURE),),
|
|
142
|
+
AttemptKind.VERDICT: (
|
|
143
|
+
(re.compile(r"\baccept\b"), AttemptTone.SUCCESS),
|
|
144
|
+
(re.compile(r"changes requested"), AttemptTone.REWORK),
|
|
145
|
+
(re.compile(r"\d+ blockers?"), AttemptTone.FAILURE),
|
|
146
|
+
),
|
|
147
|
+
AttemptKind.RECOVERY: ((re.compile(r".+"), AttemptTone.REWORK),),
|
|
148
|
+
AttemptKind.PRODUCED: (
|
|
149
|
+
(re.compile(r"\+\d+"), AttemptTone.SUCCESS),
|
|
150
|
+
(re.compile(rf"[-{MINUS}]\d+"), AttemptTone.FAILURE),
|
|
151
|
+
),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _toned(kind: AttemptKind, text: str) -> tuple[AttemptPart, ...]:
|
|
156
|
+
marks = sorted(
|
|
157
|
+
(match.start(), match.end(), tone)
|
|
158
|
+
for pattern, tone in _TONES.get(kind, ())
|
|
159
|
+
for match in pattern.finditer(text)
|
|
160
|
+
)
|
|
161
|
+
parts: list[AttemptPart] = []
|
|
162
|
+
cursor = 0
|
|
163
|
+
for start, end, tone in marks:
|
|
164
|
+
if start < cursor:
|
|
165
|
+
continue
|
|
166
|
+
if start > cursor:
|
|
167
|
+
parts.append(AttemptPart(text[cursor:start], AttemptTone.PLAIN))
|
|
168
|
+
parts.append(AttemptPart(text[start:end], tone))
|
|
169
|
+
cursor = end
|
|
170
|
+
if cursor < len(text):
|
|
171
|
+
parts.append(AttemptPart(text[cursor:], AttemptTone.PLAIN))
|
|
172
|
+
return tuple(parts)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _item(kind: AttemptKind, label: str, text: str) -> AttemptItem:
|
|
176
|
+
return AttemptItem(kind, label, _toned(kind, text))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def parse_attempt_lines(content: str) -> tuple[AttemptItem, ...]:
|
|
180
|
+
"""Rebuild the details rows from a packet's `What was tried` text.
|
|
181
|
+
|
|
182
|
+
The first line is the reason; a canned one-sentence packet is a single reason row.
|
|
183
|
+
"""
|
|
184
|
+
lines = [line.strip() for line in content.splitlines() if line.strip()]
|
|
185
|
+
if not lines:
|
|
186
|
+
return ()
|
|
187
|
+
return (_item(AttemptKind.REASON, "", lines[0]), *map(_parse_line, lines[1:]))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _parse_line(line: str) -> AttemptItem:
|
|
191
|
+
if _EARLIER.fullmatch(line):
|
|
192
|
+
return _item(AttemptKind.EARLIER, "", line)
|
|
193
|
+
label, separator, text = line.partition(LABEL_SEPARATOR)
|
|
194
|
+
if not separator:
|
|
195
|
+
return _item(AttemptKind.REASON, "", line)
|
|
196
|
+
if label in _SECTION_KINDS:
|
|
197
|
+
return _item(_SECTION_KINDS[label], label, text)
|
|
198
|
+
if label.endswith(VERDICT_SUFFIX):
|
|
199
|
+
return _item(AttemptKind.VERDICT, label.removesuffix(VERDICT_SUFFIX), text)
|
|
200
|
+
failed = text.startswith(TURN_FAILED_PREFIXES)
|
|
201
|
+
return _item(AttemptKind.TURN_FAILED if failed else AttemptKind.TURN_OK, label, text)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _count(count: int, noun: str) -> str:
|
|
205
|
+
return f"{count} {noun}{'' if count == 1 else 's'}"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _counted(counts: Counter[EventKind], nouns: tuple[tuple[EventKind, str], ...]) -> str:
|
|
209
|
+
return ", ".join(_count(counts[kind], noun) for kind, noun in nouns if counts[kind])
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _rounds(events: Sequence[Event], task: Task, max_iterations: int) -> AttemptItem | None:
|
|
213
|
+
steps = Counter(
|
|
214
|
+
str(event.data.get(STEP_FIELD)) for event in events if event.kind is EventKind.STEP_STARTED
|
|
215
|
+
)
|
|
216
|
+
if not steps:
|
|
217
|
+
return None
|
|
218
|
+
text = PART_SEPARATOR.join(f"{step} {TIMES}{count}" for step, count in steps.items())
|
|
219
|
+
if task.iteration > 0:
|
|
220
|
+
text += f" (iteration {task.iteration} of {max_iterations})"
|
|
221
|
+
return _item(AttemptKind.ROUNDS, LABEL_ROUNDS, text)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _implementation_phrase(outcome: str, report: ImplementationReport) -> str:
|
|
225
|
+
tests = reported_tests(report.tests)
|
|
226
|
+
if outcome == BLOCKED:
|
|
227
|
+
return BLOCKED
|
|
228
|
+
commits = [_count(len(report.commits), "commit")] if report.commits else []
|
|
229
|
+
parts = list(commits)
|
|
230
|
+
if tests.passed:
|
|
231
|
+
parts.append(f"{_count(tests.passed, 'test')} passed")
|
|
232
|
+
if tests.failed:
|
|
233
|
+
parts.append(f"{tests.failed} failed")
|
|
234
|
+
if outcome == NEEDS_DECISION:
|
|
235
|
+
return ", ".join([_OUTCOME_WORDS[NEEDS_DECISION], *commits])
|
|
236
|
+
return ", ".join(parts) or NO_COMMITS
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _verdict_phrase(report: Verdict) -> str:
|
|
240
|
+
counts = verdict_counts(report)
|
|
241
|
+
text = _OUTCOME_WORDS.get(counts.decision, counts.decision)
|
|
242
|
+
if counts.blocker:
|
|
243
|
+
text += f" ({_count(counts.blocker, 'blocker')})"
|
|
244
|
+
return text
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _outcome_phrase(step: object, outcome: str, report: Report | None) -> str:
|
|
248
|
+
if isinstance(report, ImplementationReport):
|
|
249
|
+
return _implementation_phrase(outcome, report)
|
|
250
|
+
if isinstance(report, Verdict):
|
|
251
|
+
return _verdict_phrase(report)
|
|
252
|
+
if step == Step.TRIAGE:
|
|
253
|
+
return f"{TRIAGE_TEXT} {outcome}"
|
|
254
|
+
if step == Step.PLAN and outcome == COMPLETED:
|
|
255
|
+
return PLAN_READY
|
|
256
|
+
return _OUTCOME_WORDS.get(outcome, outcome)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _turn_item(event: Event, reports: Mapping[str, Report]) -> AttemptItem:
|
|
260
|
+
role = str(event.data.get(ROLE_FIELD, "agent")).capitalize()
|
|
261
|
+
if event.kind is EventKind.TURN_FAILED:
|
|
262
|
+
message = one_line(event.message, FAILURE_MESSAGE_LIMIT) or "no message"
|
|
263
|
+
return _item(AttemptKind.TURN_FAILED, role, f"{TURN_FAILED_PREFIXES[0]} ({message})")
|
|
264
|
+
step = event.data.get(STEP_FIELD)
|
|
265
|
+
report = reports.get(str(event.data.get(TURN_FIELD)))
|
|
266
|
+
phrase = _outcome_phrase(step, str(event.data.get(OUTCOME_FIELD)), report)
|
|
267
|
+
return _item(AttemptKind.TURN_OK, role, f"{step}{PART_SEPARATOR}{phrase}" if step else phrase)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _turn_items(
|
|
271
|
+
events: Sequence[Event], reports: Mapping[str, Report], room: int
|
|
272
|
+
) -> list[AttemptItem]:
|
|
273
|
+
items = [
|
|
274
|
+
_turn_item(event, reports)
|
|
275
|
+
for event in events
|
|
276
|
+
if event.kind in (EventKind.TURN_COMPLETED, EventKind.TURN_FAILED)
|
|
277
|
+
]
|
|
278
|
+
if len(items) <= room:
|
|
279
|
+
return items
|
|
280
|
+
kept = max(room - 1, 0)
|
|
281
|
+
earlier = _item(AttemptKind.EARLIER, "", EARLIER_TEMPLATE.format(count=len(items) - kept))
|
|
282
|
+
return [earlier, *items[len(items) - kept :]][:room]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _produced(facts: str) -> AttemptItem | None:
|
|
286
|
+
produced = parse_produced(facts)
|
|
287
|
+
if produced is None:
|
|
288
|
+
return None
|
|
289
|
+
text = (
|
|
290
|
+
f"{_count(produced.commits, 'commit')}, {_count(produced.files, 'file')}, "
|
|
291
|
+
f"+{produced.insertions} {MINUS}{produced.deletions}"
|
|
292
|
+
)
|
|
293
|
+
return _item(AttemptKind.PRODUCED, LABEL_PRODUCED, text)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _section(kind: AttemptKind, label: str, text: str) -> AttemptItem | None:
|
|
297
|
+
return _item(kind, label, text) if text else None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def attempt_lines(items: Sequence[AttemptItem]) -> str:
|
|
301
|
+
"""Return the packet text of `items`, one `Label: text` line per row."""
|
|
302
|
+
return "\n".join(item.line for item in items)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def summarise_attempts(
|
|
306
|
+
reason: str,
|
|
307
|
+
events: Sequence[Event],
|
|
308
|
+
reports: Mapping[str, Report],
|
|
309
|
+
task: Task,
|
|
310
|
+
facts: str,
|
|
311
|
+
*,
|
|
312
|
+
max_iterations: int,
|
|
313
|
+
limit: int,
|
|
314
|
+
) -> tuple[AttemptItem, ...]:
|
|
315
|
+
"""Return at most `limit` chronological rows of what `task` did, `reason` first.
|
|
316
|
+
|
|
317
|
+
Rounds, the latest turns with their outcome, recovery, produced numbers and Operator input
|
|
318
|
+
appear only when recorded; the oldest turns collapse into a `+N earlier` row.
|
|
319
|
+
"""
|
|
320
|
+
counts = Counter(event.kind for event in events)
|
|
321
|
+
head = [
|
|
322
|
+
_item(AttemptKind.REASON, "", reason),
|
|
323
|
+
_rounds(events, task, max_iterations),
|
|
324
|
+
]
|
|
325
|
+
tail = [
|
|
326
|
+
_section(AttemptKind.RECOVERY, LABEL_RECOVERY, _counted(counts, _RECOVERY_NOUNS)),
|
|
327
|
+
_produced(facts),
|
|
328
|
+
_section(AttemptKind.OPERATOR, LABEL_OPERATOR, _counted(counts, _OPERATOR_NOUNS)),
|
|
329
|
+
]
|
|
330
|
+
leading = [item for item in head if item is not None]
|
|
331
|
+
trailing = [item for item in tail if item is not None]
|
|
332
|
+
room = max(limit - len(leading) - len(trailing), 0)
|
|
333
|
+
return (*leading, *_turn_items(events, reports, room), *trailing)[:limit]
|