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,180 @@
|
|
|
1
|
+
"""The frozen PR review feedback a Leader analyses, and the rules its reports must satisfy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum, auto
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
from pydantic import Field, StringConstraints, model_validator
|
|
9
|
+
|
|
10
|
+
from shikiru.domain.reports import (
|
|
11
|
+
DECISION_ACCEPT,
|
|
12
|
+
FeedbackAction,
|
|
13
|
+
FeedbackAnalysis,
|
|
14
|
+
FeedbackVerdict,
|
|
15
|
+
Report,
|
|
16
|
+
)
|
|
17
|
+
from shikiru.domain.tasks import DomainModel
|
|
18
|
+
|
|
19
|
+
_Text = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FeedbackContractError(ValueError):
|
|
23
|
+
"""A feedback report does not account for its snapshot or cannot be acted on."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FeedbackSourceKind(StrEnum):
|
|
27
|
+
"""Where a source item of feedback was written."""
|
|
28
|
+
|
|
29
|
+
REVIEW_BODY = auto()
|
|
30
|
+
DISCUSSION = auto()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FeedbackStep(StrEnum):
|
|
34
|
+
"""What the engine may do next with a checked analysis."""
|
|
35
|
+
|
|
36
|
+
ESCALATE = auto()
|
|
37
|
+
IMPLEMENT = auto()
|
|
38
|
+
VERIFY = auto()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FeedbackMessage(DomainModel):
|
|
42
|
+
"""One comment of a source item, in the order it was written.
|
|
43
|
+
|
|
44
|
+
`new` marks a comment this round must answer; the others were handled by earlier rounds
|
|
45
|
+
or are our own replies.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
author: str | None
|
|
49
|
+
body: str
|
|
50
|
+
url: str
|
|
51
|
+
new: bool = False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FeedbackSource(DomainModel):
|
|
55
|
+
"""A review body or an original inline discussion, with the replies that followed it."""
|
|
56
|
+
|
|
57
|
+
id: _Text
|
|
58
|
+
kind: FeedbackSourceKind
|
|
59
|
+
url: str
|
|
60
|
+
location: str = ""
|
|
61
|
+
outdated: bool = False
|
|
62
|
+
resolved: bool = False
|
|
63
|
+
messages: list[FeedbackMessage] = Field(min_length=1)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FeedbackSnapshot(DomainModel):
|
|
67
|
+
"""The feedback of one round, fixed when the round starts."""
|
|
68
|
+
|
|
69
|
+
pull_request_url: str
|
|
70
|
+
head_sha: str
|
|
71
|
+
sources: list[FeedbackSource] = Field(min_length=1)
|
|
72
|
+
|
|
73
|
+
@model_validator(mode="after")
|
|
74
|
+
def _source_ids_are_unique(self) -> FeedbackSnapshot:
|
|
75
|
+
ids = self.source_ids
|
|
76
|
+
if len(set(ids)) != len(ids):
|
|
77
|
+
message = "sources: duplicate ids"
|
|
78
|
+
raise ValueError(message)
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def source_ids(self) -> list[str]:
|
|
83
|
+
"""The identities of the sources, in snapshot order."""
|
|
84
|
+
return [source.id for source in self.sources]
|
|
85
|
+
|
|
86
|
+
def only(self, source_ids: set[str]) -> FeedbackSnapshot:
|
|
87
|
+
"""Return the snapshot restricted to `source_ids`."""
|
|
88
|
+
return self.model_copy(
|
|
89
|
+
update={"sources": [source for source in self.sources if source.id in source_ids]}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class FeedbackInput(DomainModel):
|
|
94
|
+
"""What a feedback turn is given: snapshot, analysis once made, round limit, tip verified."""
|
|
95
|
+
|
|
96
|
+
snapshot: FeedbackSnapshot
|
|
97
|
+
analysis: FeedbackAnalysis | None = None
|
|
98
|
+
final_round: bool = False
|
|
99
|
+
tip: str | None = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def check_analysis(snapshot: FeedbackSnapshot, analysis: FeedbackAnalysis) -> None:
|
|
103
|
+
"""Require one decision for each source of `snapshot` and no other."""
|
|
104
|
+
expected = set(snapshot.source_ids)
|
|
105
|
+
decided = {decision.source_id for decision in analysis.decisions}
|
|
106
|
+
unknown = decided - expected
|
|
107
|
+
if unknown:
|
|
108
|
+
message = f"decisions name unknown source ids {sorted(unknown)}"
|
|
109
|
+
raise FeedbackContractError(message)
|
|
110
|
+
missing = expected - decided
|
|
111
|
+
if missing:
|
|
112
|
+
message = f"decisions leave source ids {sorted(missing)} without a decision"
|
|
113
|
+
raise FeedbackContractError(message)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def next_feedback_step(analysis: FeedbackAnalysis) -> FeedbackStep:
|
|
117
|
+
"""Choose the step after `analysis`: an Operator question first, then work, then replies.
|
|
118
|
+
|
|
119
|
+
A decision that needs the Operator is never read as approval to carry out the work of
|
|
120
|
+
the others, and a round with no accepted work needs no Implementer turn.
|
|
121
|
+
"""
|
|
122
|
+
if operator_questions(analysis):
|
|
123
|
+
return FeedbackStep.ESCALATE
|
|
124
|
+
if analysis.work:
|
|
125
|
+
return FeedbackStep.IMPLEMENT
|
|
126
|
+
return FeedbackStep.VERIFY
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def operator_questions(analysis: FeedbackAnalysis) -> list[tuple[str, str]]:
|
|
130
|
+
"""Return each (source id, question) the Operator must answer."""
|
|
131
|
+
return [
|
|
132
|
+
(decision.source_id, decision.detail)
|
|
133
|
+
for decision in analysis.decisions
|
|
134
|
+
if decision.action is FeedbackAction.NEEDS_OPERATOR
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def check_verdict(
|
|
139
|
+
snapshot: FeedbackSnapshot, analysis: FeedbackAnalysis, verdict: FeedbackVerdict
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Require an accepting verdict to verify exactly the replies the analysis drafted."""
|
|
142
|
+
check_analysis(snapshot, analysis)
|
|
143
|
+
if verdict.decision != DECISION_ACCEPT:
|
|
144
|
+
return
|
|
145
|
+
if operator_questions(analysis):
|
|
146
|
+
message = "decision: 'accept' is not available while a decision needs the Operator"
|
|
147
|
+
raise FeedbackContractError(message)
|
|
148
|
+
expected = {
|
|
149
|
+
decision.source_id
|
|
150
|
+
for decision in analysis.decisions
|
|
151
|
+
if decision.action is not FeedbackAction.NEEDS_OPERATOR
|
|
152
|
+
}
|
|
153
|
+
verified = {reply.source_id for reply in verdict.replies}
|
|
154
|
+
if verified != expected:
|
|
155
|
+
message = (
|
|
156
|
+
f"replies must cover exactly source ids {sorted(expected)}, got {sorted(verified)}"
|
|
157
|
+
)
|
|
158
|
+
raise FeedbackContractError(message)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def check_feedback_report(feedback: FeedbackInput, report: Report) -> None:
|
|
162
|
+
"""Check `report` against the frozen `feedback` its turn was given."""
|
|
163
|
+
if isinstance(report, FeedbackAnalysis):
|
|
164
|
+
check_analysis(feedback.snapshot, report)
|
|
165
|
+
elif isinstance(report, FeedbackVerdict):
|
|
166
|
+
if feedback.analysis is None:
|
|
167
|
+
message = "a feedback verdict needs the analysis it verifies"
|
|
168
|
+
raise FeedbackContractError(message)
|
|
169
|
+
check_verdict(feedback.snapshot, feedback.analysis, report)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def publishable_replies(
|
|
173
|
+
snapshot: FeedbackSnapshot, analysis: FeedbackAnalysis, verdict: FeedbackVerdict
|
|
174
|
+
) -> dict[str, str]:
|
|
175
|
+
"""Return the verified reply of each source, or fail when the round is not accepted."""
|
|
176
|
+
if verdict.decision != DECISION_ACCEPT:
|
|
177
|
+
message = f"a '{verdict.decision}' verdict leaves no reply publishable"
|
|
178
|
+
raise FeedbackContractError(message)
|
|
179
|
+
check_verdict(snapshot, analysis, verdict)
|
|
180
|
+
return {reply.source_id: reply.reply for reply in verdict.replies}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""The durable progress of one PR feedback round and the pure rules that move it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum, auto
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from pydantic import Field
|
|
9
|
+
|
|
10
|
+
from shikiru.domain.feedback import FeedbackSnapshot, FeedbackSourceKind
|
|
11
|
+
from shikiru.domain.reports import FeedbackAction, FeedbackAnalysis
|
|
12
|
+
from shikiru.domain.tasks import DomainModel
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
|
|
17
|
+
from shikiru.domain.reports import TestRun
|
|
18
|
+
|
|
19
|
+
MARKER_PREFIX = "shikiru-feedback"
|
|
20
|
+
MARKER_SEPARATOR = ":"
|
|
21
|
+
REPLY_SEPARATOR = "\n\n"
|
|
22
|
+
REVIEW_LINK_PREFIX = "In reply to"
|
|
23
|
+
FIXED_PREFIX = "Fixed in"
|
|
24
|
+
CHECKS_PREFIX = "Verified by"
|
|
25
|
+
CHECKS_SEPARATOR = "; "
|
|
26
|
+
SHORT_SHA_LENGTH = 12
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FeedbackStage(StrEnum):
|
|
30
|
+
"""Where a feedback round stands; the first five stages are the unfinished ones."""
|
|
31
|
+
|
|
32
|
+
ANALYSIS = auto()
|
|
33
|
+
IMPLEMENTATION = auto()
|
|
34
|
+
VERIFICATION = auto()
|
|
35
|
+
PUBLICATION = auto()
|
|
36
|
+
REPLIES = auto()
|
|
37
|
+
HANDLED = auto()
|
|
38
|
+
ABANDONED = auto()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
UNFINISHED_STAGES = frozenset(
|
|
42
|
+
(
|
|
43
|
+
FeedbackStage.ANALYSIS,
|
|
44
|
+
FeedbackStage.IMPLEMENTATION,
|
|
45
|
+
FeedbackStage.VERIFICATION,
|
|
46
|
+
FeedbackStage.PUBLICATION,
|
|
47
|
+
FeedbackStage.REPLIES,
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ReplyState(StrEnum):
|
|
53
|
+
"""How far one reply got: stored, possibly sent (its outcome unknown), or confirmed sent."""
|
|
54
|
+
|
|
55
|
+
PLANNED = auto()
|
|
56
|
+
SENDING = auto()
|
|
57
|
+
SENT = auto()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PublicationAction(StrEnum):
|
|
61
|
+
"""What to do with the verified tip given where the remote branch is."""
|
|
62
|
+
|
|
63
|
+
PUSH = auto()
|
|
64
|
+
ALREADY_PUBLISHED = auto()
|
|
65
|
+
REMOTE_MOVED = auto()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Alignment(StrEnum):
|
|
69
|
+
"""How the task branch relates to the remote branch before a round edits it."""
|
|
70
|
+
|
|
71
|
+
ALIGNED = auto()
|
|
72
|
+
FAST_FORWARD = auto()
|
|
73
|
+
DIVERGED = auto()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class FeedbackRound(DomainModel):
|
|
77
|
+
"""One batch of review feedback and everything done about it so far."""
|
|
78
|
+
|
|
79
|
+
id: int
|
|
80
|
+
task_id: str
|
|
81
|
+
pull_request_url: str
|
|
82
|
+
review_ids: list[str]
|
|
83
|
+
comment_ids: list[str] = Field(default_factory=list)
|
|
84
|
+
snapshot: FeedbackSnapshot
|
|
85
|
+
stage: FeedbackStage
|
|
86
|
+
analysis: FeedbackAnalysis | None = None
|
|
87
|
+
attempts: int = 0
|
|
88
|
+
blocked: bool = False
|
|
89
|
+
expected_remote_sha: str
|
|
90
|
+
verified_sha: str | None = None
|
|
91
|
+
published_sha: str | None = None
|
|
92
|
+
checks: str = ""
|
|
93
|
+
verified_replies: dict[str, str] = Field(default_factory=dict)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class FeedbackReply(DomainModel):
|
|
97
|
+
"""The immutable reply to one source item, with the marker that finds it again."""
|
|
98
|
+
|
|
99
|
+
round_id: int
|
|
100
|
+
source_id: str
|
|
101
|
+
kind: FeedbackSourceKind
|
|
102
|
+
marker: str
|
|
103
|
+
body: str
|
|
104
|
+
state: ReplyState = ReplyState.PLANNED
|
|
105
|
+
posted_url: str | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def reply_marker(task_id: str, round_id: int, source_id: str) -> str:
|
|
109
|
+
"""Return the stable marker that identifies the reply to `source_id` in `round_id`."""
|
|
110
|
+
parts = (MARKER_PREFIX, task_id, str(round_id), source_id)
|
|
111
|
+
return f"<!-- {MARKER_SEPARATOR.join(parts)} -->"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def is_own_reply(body: str) -> bool:
|
|
115
|
+
"""Return whether a comment `body` carries the marker of a reply this engine posted."""
|
|
116
|
+
return MARKER_PREFIX in body
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def reply_body(
|
|
120
|
+
*,
|
|
121
|
+
reply: str,
|
|
122
|
+
marker: str,
|
|
123
|
+
review_url: str | None,
|
|
124
|
+
fixed_sha: str | None,
|
|
125
|
+
checks: str,
|
|
126
|
+
) -> str:
|
|
127
|
+
"""Compose a reply: the link of a review-body source, the verified text, the fix, the marker."""
|
|
128
|
+
parts: list[str] = []
|
|
129
|
+
if review_url is not None:
|
|
130
|
+
parts.append(f"{REVIEW_LINK_PREFIX} {review_url}")
|
|
131
|
+
parts.append(reply.strip())
|
|
132
|
+
if fixed_sha is not None:
|
|
133
|
+
fixed = f"{FIXED_PREFIX} {fixed_sha[:SHORT_SHA_LENGTH]}."
|
|
134
|
+
parts.append(f"{fixed} {CHECKS_PREFIX} {checks}." if checks else fixed)
|
|
135
|
+
parts.append(marker)
|
|
136
|
+
return REPLY_SEPARATOR.join(parts)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def describe_checks(tests: Sequence[TestRun]) -> str:
|
|
140
|
+
"""Name the checks an Implementer ran and how each ended."""
|
|
141
|
+
return CHECKS_SEPARATOR.join(f"{test.name} {test.result}" for test in tests)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def fixed_source_ids(analysis: FeedbackAnalysis) -> set[str]:
|
|
145
|
+
"""Return the sources whose decision changed code."""
|
|
146
|
+
return {
|
|
147
|
+
decision.source_id
|
|
148
|
+
for decision in analysis.decisions
|
|
149
|
+
if decision.action is FeedbackAction.ADDRESS
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def plan_publication(*, expected: str, remote_head: str, local_tip: str) -> PublicationAction:
|
|
154
|
+
"""Decide how to publish `local_tip` when the remote branch is at `remote_head`.
|
|
155
|
+
|
|
156
|
+
The remote only ever matches what the round captured (`expected`) or the tip itself; any
|
|
157
|
+
other head is somebody else's work that a push must not overwrite.
|
|
158
|
+
"""
|
|
159
|
+
if remote_head == local_tip:
|
|
160
|
+
return PublicationAction.ALREADY_PUBLISHED
|
|
161
|
+
if remote_head != expected:
|
|
162
|
+
return PublicationAction.REMOTE_MOVED
|
|
163
|
+
return PublicationAction.PUSH
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def align_with_remote(*, remote_in_local: bool, local_in_remote: bool) -> Alignment:
|
|
167
|
+
"""Relate the task branch to the remote branch from their ancestry."""
|
|
168
|
+
if remote_in_local:
|
|
169
|
+
return Alignment.ALIGNED
|
|
170
|
+
if local_in_remote:
|
|
171
|
+
return Alignment.FAST_FORWARD
|
|
172
|
+
return Alignment.DIVERGED
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Markdown code fencing that survives backticks already present in its content."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
_BACKTICK = "`"
|
|
6
|
+
_MIN_FENCE_LENGTH = 3
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def fence(content: str, info: str = "") -> str:
|
|
10
|
+
"""Wrap `content` in a backtick fence longer than any backtick run already inside it.
|
|
11
|
+
|
|
12
|
+
`info` is the fence's info string (e.g. `text` or `json`). Quoting untrusted content
|
|
13
|
+
(an agent's report, a request or a pane screen) this way keeps a `` ``` `` or `` ```` ``
|
|
14
|
+
the content already contains from closing the fence early and turning the rest of it
|
|
15
|
+
into instructions the agent reads as ours.
|
|
16
|
+
"""
|
|
17
|
+
length = max(_MIN_FENCE_LENGTH, _longest_backtick_run(content) + 1)
|
|
18
|
+
marker = _BACKTICK * length
|
|
19
|
+
return f"{marker}{info}\n{content}\n{marker}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _longest_backtick_run(content: str) -> int:
|
|
23
|
+
longest = 0
|
|
24
|
+
current = 0
|
|
25
|
+
for character in content:
|
|
26
|
+
if character == _BACKTICK:
|
|
27
|
+
current += 1
|
|
28
|
+
longest = max(longest, current)
|
|
29
|
+
else:
|
|
30
|
+
current = 0
|
|
31
|
+
return longest
|
shikiru/domain/ladder.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Rung selection, move-up rules and counter resets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from shikiru.domain.tasks import DEFAULT_COMPLEXITY, Counters, Role, Task
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from shikiru.agents.contracts import Rung
|
|
12
|
+
from shikiru.config.settings import Settings
|
|
13
|
+
|
|
14
|
+
REJECTIONS_FIELD = "rejections_on_rung"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _ladder_for(role: Role, task: Task, settings: Settings) -> list[Rung]:
|
|
18
|
+
if role is Role.LEADER:
|
|
19
|
+
return settings.roles.leader.ladder
|
|
20
|
+
if role is Role.IMPLEMENTER:
|
|
21
|
+
return settings.roles.implementer.ladder_for(task.complexity or DEFAULT_COMPLEXITY)
|
|
22
|
+
return settings.roles.helper.ladder
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def pick_rung(task: Task, role: Role, settings: Settings) -> Rung:
|
|
26
|
+
"""Resolve `role`'s rung: the operator override, else the ladder at `rung_index[role]`."""
|
|
27
|
+
override = task.overrides.get(role)
|
|
28
|
+
if override is not None:
|
|
29
|
+
return override
|
|
30
|
+
ladder = _ladder_for(role, task, settings)
|
|
31
|
+
return ladder[task.rung_index.get(role, 0)]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def reset_for_rung_change(counters: Counters) -> Counters:
|
|
35
|
+
"""`rejections_on_rung` resets whenever the Implementer's rung changes."""
|
|
36
|
+
return counters.model_copy(update={REJECTIONS_FIELD: 0})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def reset_for_new_iteration(counters: Counters) -> Counters:
|
|
40
|
+
"""`consults` resets at the start of every implement/verify iteration."""
|
|
41
|
+
return counters.model_copy(update={"consults": 0})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def reset_for_integration_attempt(counters: Counters) -> Counters:
|
|
45
|
+
"""`conflict_rounds` resets at the start of every integration attempt."""
|
|
46
|
+
return counters.model_copy(update={"conflict_rounds": 0})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class RungMove:
|
|
51
|
+
"""The new `rung_index`, the override to clear and `counters` after moving a role up."""
|
|
52
|
+
|
|
53
|
+
rung_index: dict[Role, int]
|
|
54
|
+
cleared_overrides: dict[Role, Rung]
|
|
55
|
+
counters: Counters
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def move_up(
|
|
59
|
+
task: Task,
|
|
60
|
+
role: Role,
|
|
61
|
+
settings: Settings,
|
|
62
|
+
*,
|
|
63
|
+
different_agent: bool = False,
|
|
64
|
+
blocked_agents: frozenset[str] = frozenset(),
|
|
65
|
+
) -> RungMove | None:
|
|
66
|
+
"""Advance `role` one rung up its ladder from the rung currently resolved by `pick_rung`.
|
|
67
|
+
|
|
68
|
+
Comparing against the resolved rung, not `ladder[rung_index]`, matters because that rung may
|
|
69
|
+
be an operator override; `different_agent=True` then skips ladder rungs on that override's
|
|
70
|
+
agent too. Moving up clears `role`'s override as observed in `task`: an automatic move replaces
|
|
71
|
+
a manual pin, since keeping it would make the move a no-op and could loop recovery on a
|
|
72
|
+
blocked agent. The store clears it only while it still equals that observed rung, so a pin the
|
|
73
|
+
operator set since survives.
|
|
74
|
+
For the Implementer, `rejections_on_rung` also resets, since it counts rework at one rung.
|
|
75
|
+
Agents in `blocked_agents` are skipped when selecting the destination rung.
|
|
76
|
+
|
|
77
|
+
With `different_agent=True` the move is a quota fallback: rungs above the current one come
|
|
78
|
+
first, then the rungs from the bottom of the ladder up, so a role parked on the last rung can
|
|
79
|
+
return to an earlier agent that is no longer blocked.
|
|
80
|
+
|
|
81
|
+
Returns `None` once no rung satisfies the constraints; the caller then keeps the task on its
|
|
82
|
+
current rung.
|
|
83
|
+
"""
|
|
84
|
+
ladder = _ladder_for(role, task, settings)
|
|
85
|
+
current_rung = pick_rung(task, role, settings)
|
|
86
|
+
current_index = task.rung_index.get(role, 0)
|
|
87
|
+
above = range(current_index + 1, len(ladder))
|
|
88
|
+
below = range(current_index + 1) if different_agent else range(0)
|
|
89
|
+
for index in (*above, *below):
|
|
90
|
+
candidate = ladder[index]
|
|
91
|
+
if candidate.agent in blocked_agents:
|
|
92
|
+
continue
|
|
93
|
+
if different_agent and candidate.agent == current_rung.agent:
|
|
94
|
+
continue
|
|
95
|
+
rung_index = dict(task.rung_index)
|
|
96
|
+
rung_index[role] = index
|
|
97
|
+
cleared = {role: task.overrides[role]} if role in task.overrides else {}
|
|
98
|
+
counters = (
|
|
99
|
+
reset_for_rung_change(task.counters) if role is Role.IMPLEMENTER else task.counters
|
|
100
|
+
)
|
|
101
|
+
return RungMove(rung_index=rung_index, cleared_overrides=cleared, counters=counters)
|
|
102
|
+
return None
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Pure parsing for the initializer's comma-separated ladder field."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from shikiru.agents.contracts import Rung
|
|
8
|
+
|
|
9
|
+
ENTRY_SEPARATOR = ","
|
|
10
|
+
PART_SEPARATOR = ":"
|
|
11
|
+
REQUIRED_PART_COUNT = 2
|
|
12
|
+
OPTIONAL_EFFORT_PART_COUNT = 3
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class LadderTextError(ValueError):
|
|
17
|
+
"""One malformed entry in an edited ladder field."""
|
|
18
|
+
|
|
19
|
+
entry_index: int
|
|
20
|
+
detail: str
|
|
21
|
+
|
|
22
|
+
def __str__(self) -> str:
|
|
23
|
+
return self.detail
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_ladder_text(value: str) -> tuple[Rung, ...]:
|
|
27
|
+
"""Parse ordered `agent:model[:effort]` entries without changing the source text."""
|
|
28
|
+
if not value.strip():
|
|
29
|
+
return ()
|
|
30
|
+
|
|
31
|
+
rungs: list[Rung] = []
|
|
32
|
+
for index, raw_entry in enumerate(value.split(ENTRY_SEPARATOR)):
|
|
33
|
+
entry = raw_entry.strip()
|
|
34
|
+
parts = tuple(part.strip() for part in entry.split(PART_SEPARATOR))
|
|
35
|
+
if len(parts) not in {REQUIRED_PART_COUNT, OPTIONAL_EFFORT_PART_COUNT}:
|
|
36
|
+
detail = "use agent:model or agent:model:effort"
|
|
37
|
+
raise LadderTextError(index, detail)
|
|
38
|
+
if any(not part for part in parts):
|
|
39
|
+
detail = "each agent, model and effort value must be non-empty"
|
|
40
|
+
raise LadderTextError(index, detail)
|
|
41
|
+
agent, model = parts[:REQUIRED_PART_COUNT]
|
|
42
|
+
effort = parts[-1] if len(parts) == OPTIONAL_EFFORT_PART_COUNT else None
|
|
43
|
+
rungs.append(Rung(agent=agent, model=model, effort=effort))
|
|
44
|
+
return tuple(rungs)
|
shikiru/domain/levels.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Usage levels: how full a share of a limit is against its warn and alert thresholds."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum, auto
|
|
6
|
+
|
|
7
|
+
WHOLE_PERCENT = 100
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UsageLevel(StrEnum):
|
|
11
|
+
"""Whether a used share is below the warn threshold, from it, or from the alert threshold."""
|
|
12
|
+
|
|
13
|
+
OK = auto()
|
|
14
|
+
WARN = auto()
|
|
15
|
+
ALERT = auto()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def displayed_pct(used_pct: float) -> int:
|
|
19
|
+
"""Return the whole percentage a surface shows for a used share (`41.6` shows `42`)."""
|
|
20
|
+
return round(used_pct)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def usage_level(used_pct: float, *, warn_pct: int, alert_pct: int) -> UsageLevel:
|
|
24
|
+
"""Classify `used_pct` by the whole percentage shown: `alert_pct` and above is ALERT."""
|
|
25
|
+
shown = displayed_pct(used_pct)
|
|
26
|
+
if shown >= alert_pct:
|
|
27
|
+
return UsageLevel.ALERT
|
|
28
|
+
if shown >= warn_pct:
|
|
29
|
+
return UsageLevel.WARN
|
|
30
|
+
return UsageLevel.OK
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def free_space_level(
|
|
34
|
+
free_bytes: int, total_bytes: int, *, warn_pct: int, alert_pct: int
|
|
35
|
+
) -> UsageLevel:
|
|
36
|
+
"""Classify free space below `alert_pct` percent as ALERT and below `warn_pct` as WARN.
|
|
37
|
+
|
|
38
|
+
The share is compared as exact byte counts, so it is never rounded into a level.
|
|
39
|
+
"""
|
|
40
|
+
if free_bytes * WHOLE_PERCENT < alert_pct * total_bytes:
|
|
41
|
+
return UsageLevel.ALERT
|
|
42
|
+
if free_bytes * WHOLE_PERCENT < warn_pct * total_bytes:
|
|
43
|
+
return UsageLevel.WARN
|
|
44
|
+
return UsageLevel.OK
|