shikiru-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shikiru/__init__.py +1 -0
- shikiru/__main__.py +8 -0
- shikiru/agents/__init__.py +3 -0
- shikiru/agents/contracts.py +590 -0
- shikiru/agents/registry.py +87 -0
- shikiru/agents/sdk/__init__.py +17 -0
- shikiru/agents/sdk/reset_time.py +137 -0
- shikiru/agents/sdk/turn.py +335 -0
- shikiru/agents/settings.py +151 -0
- shikiru/agents/testing/__init__.py +26 -0
- shikiru/agents/testing/contract_suite.py +653 -0
- shikiru/agents/testing/resume.py +15 -0
- shikiru/bootstrap.py +1277 -0
- shikiru/cli/__init__.py +1 -0
- shikiru/cli/app.py +770 -0
- shikiru/cli/context.py +63 -0
- shikiru/cli/render.py +1475 -0
- shikiru/cli/report.py +156 -0
- shikiru/cli/session_prompt.py +63 -0
- shikiru/cli/view.py +131 -0
- shikiru/clock.py +41 -0
- shikiru/config/__init__.py +8 -0
- shikiru/config/defaults.toml +131 -0
- shikiru/config/loader.py +178 -0
- shikiru/config/settings.py +392 -0
- shikiru/dashboard/__init__.py +3 -0
- shikiru/dashboard/actions_list.py +799 -0
- shikiru/dashboard/activity.py +150 -0
- shikiru/dashboard/app.py +1765 -0
- shikiru/dashboard/blocker_evidence.py +122 -0
- shikiru/dashboard/blocker_rows.py +84 -0
- shikiru/dashboard/briefing_view.py +871 -0
- shikiru/dashboard/contract.py +1086 -0
- shikiru/dashboard/covered.py +32 -0
- shikiru/dashboard/detail.py +1209 -0
- shikiru/dashboard/detail_fold.py +244 -0
- shikiru/dashboard/detail_hint.py +68 -0
- shikiru/dashboard/details_list_view.py +150 -0
- shikiru/dashboard/diagnostics.py +80 -0
- shikiru/dashboard/diagnostics_sections.py +186 -0
- shikiru/dashboard/evidence_table.py +123 -0
- shikiru/dashboard/extra_details.py +214 -0
- shikiru/dashboard/fault_guidance.py +216 -0
- shikiru/dashboard/fold_bodies.py +276 -0
- shikiru/dashboard/help_keys.py +166 -0
- shikiru/dashboard/help_screen.py +204 -0
- shikiru/dashboard/interaction.py +17 -0
- shikiru/dashboard/ladder_editor.py +544 -0
- shikiru/dashboard/live_settings.py +62 -0
- shikiru/dashboard/motion.py +368 -0
- shikiru/dashboard/orchestration_fields.py +201 -0
- shikiru/dashboard/palette.py +1606 -0
- shikiru/dashboard/palette_app.py +89 -0
- shikiru/dashboard/palette_drafts.py +33 -0
- shikiru/dashboard/resolution.py +573 -0
- shikiru/dashboard/resolution_view.py +302 -0
- shikiru/dashboard/row_list.py +256 -0
- shikiru/dashboard/section_rows.py +173 -0
- shikiru/dashboard/services.py +665 -0
- shikiru/dashboard/session_history.py +402 -0
- shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
- shikiru/dashboard/task_picker.py +358 -0
- shikiru/dashboard/tick.py +59 -0
- shikiru/dashboard/timeline_filter.py +129 -0
- shikiru/dashboard/timeline_section.py +385 -0
- shikiru/dashboard/usage_body.py +269 -0
- shikiru/dashboard/views.py +2314 -0
- shikiru/dashboard/wizard.py +1389 -0
- shikiru/domain/__init__.py +1 -0
- shikiru/domain/acceptance.py +130 -0
- shikiru/domain/activity.py +28 -0
- shikiru/domain/attempts.py +333 -0
- shikiru/domain/briefing.py +257 -0
- shikiru/domain/commands.py +219 -0
- shikiru/domain/decisions.py +231 -0
- shikiru/domain/docker.py +262 -0
- shikiru/domain/engine_uptime.py +87 -0
- shikiru/domain/escalation.py +194 -0
- shikiru/domain/escalation_evidence.py +186 -0
- shikiru/domain/escalation_panel.py +144 -0
- shikiru/domain/events.py +71 -0
- shikiru/domain/evidence_export.py +89 -0
- shikiru/domain/faults.py +86 -0
- shikiru/domain/feedback.py +180 -0
- shikiru/domain/feedback_rounds.py +172 -0
- shikiru/domain/fencing.py +31 -0
- shikiru/domain/ladder.py +102 -0
- shikiru/domain/ladder_text.py +44 -0
- shikiru/domain/levels.py +44 -0
- shikiru/domain/motion.py +199 -0
- shikiru/domain/packet_evidence.py +377 -0
- shikiru/domain/pushes.py +47 -0
- shikiru/domain/quota_blocks.py +44 -0
- shikiru/domain/quota_waits.py +219 -0
- shikiru/domain/recovery.py +194 -0
- shikiru/domain/reports.py +498 -0
- shikiru/domain/results.py +217 -0
- shikiru/domain/runs.py +85 -0
- shikiru/domain/scheduling.py +231 -0
- shikiru/domain/status_tasks.py +40 -0
- shikiru/domain/steps.py +325 -0
- shikiru/domain/tasks.py +174 -0
- shikiru/domain/timeline.py +208 -0
- shikiru/domain/timeline_phrases.py +271 -0
- shikiru/domain/versions.py +18 -0
- shikiru/domain/workflow.py +372 -0
- shikiru/engine/__init__.py +0 -0
- shikiru/engine/acceptance.py +90 -0
- shikiru/engine/agents.py +61 -0
- shikiru/engine/cosmetic_decorator.py +99 -0
- shikiru/engine/daemon.py +409 -0
- shikiru/engine/dependency.py +37 -0
- shikiru/engine/escalation_filing.py +110 -0
- shikiru/engine/escalations.py +401 -0
- shikiru/engine/events.py +285 -0
- shikiru/engine/evidence.py +81 -0
- shikiru/engine/fault_reporter.py +144 -0
- shikiru/engine/feedback.py +577 -0
- shikiru/engine/feedback_audit.py +80 -0
- shikiru/engine/feedback_intake.py +95 -0
- shikiru/engine/feedback_snapshot.py +148 -0
- shikiru/engine/flow.py +87 -0
- shikiru/engine/handlers/__init__.py +33 -0
- shikiru/engine/handlers/base.py +148 -0
- shikiru/engine/handlers/engine.py +24 -0
- shikiru/engine/handlers/escalations.py +174 -0
- shikiru/engine/handlers/operator.py +81 -0
- shikiru/engine/handlers/tasks.py +237 -0
- shikiru/engine/history.py +128 -0
- shikiru/engine/integration.py +1234 -0
- shikiru/engine/integration_turns.py +337 -0
- shikiru/engine/layout_guard.py +362 -0
- shikiru/engine/lifecycle.py +60 -0
- shikiru/engine/monitor.py +317 -0
- shikiru/engine/notification_delivery.py +124 -0
- shikiru/engine/notification_feed.py +57 -0
- shikiru/engine/notifier.py +91 -0
- shikiru/engine/pull_request_closing.py +117 -0
- shikiru/engine/push_service.py +177 -0
- shikiru/engine/quota_probe_threads.py +79 -0
- shikiru/engine/quota_reconciler.py +210 -0
- shikiru/engine/quota_schedule.py +24 -0
- shikiru/engine/quota_waits.py +102 -0
- shikiru/engine/reconcile.py +91 -0
- shikiru/engine/recovery.py +284 -0
- shikiru/engine/retention.py +102 -0
- shikiru/engine/root_preparation.py +86 -0
- shikiru/engine/scheduler.py +320 -0
- shikiru/engine/session_guard.py +46 -0
- shikiru/engine/sessions.py +95 -0
- shikiru/engine/signalled.py +75 -0
- shikiru/engine/step_runner.py +140 -0
- shikiru/engine/supervisor.py +101 -0
- shikiru/engine/takeover.py +223 -0
- shikiru/engine/task_facts.py +46 -0
- shikiru/engine/tick_guard.py +95 -0
- shikiru/engine/titles.py +297 -0
- shikiru/engine/transitions.py +79 -0
- shikiru/engine/turn_context.py +206 -0
- shikiru/engine/turn_execution.py +30 -0
- shikiru/engine/turn_loop.py +230 -0
- shikiru/engine/turn_processes.py +79 -0
- shikiru/engine/turns.py +457 -0
- shikiru/errors.py +144 -0
- shikiru/helper/__init__.py +3 -0
- shikiru/helper/helper.py +182 -0
- shikiru/helper/models.py +11 -0
- shikiru/mcp/__init__.py +1 -0
- shikiru/mcp/server.py +196 -0
- shikiru/operator/__init__.py +33 -0
- shikiru/operator/attachment.py +24 -0
- shikiru/operator/commands.py +319 -0
- shikiru/operator/detectors.py +374 -0
- shikiru/operator/doctor.py +351 -0
- shikiru/operator/events.py +57 -0
- shikiru/operator/evidence_export.py +498 -0
- shikiru/operator/evidence_writer.py +78 -0
- shikiru/operator/lifecycle.py +294 -0
- shikiru/operator/plugin_checks.py +194 -0
- shikiru/operator/probe.py +287 -0
- shikiru/operator/queries.py +1601 -0
- shikiru/operator/services.py +36 -0
- shikiru/operator/sessions.py +378 -0
- shikiru/operator/strip_views.py +162 -0
- shikiru/operator/timeline.py +416 -0
- shikiru/operator/toml_text.py +62 -0
- shikiru/palette.py +18 -0
- shikiru/paths.py +91 -0
- shikiru/plugins/__init__.py +0 -0
- shikiru/plugins/agents/__init__.py +1 -0
- shikiru/plugins/agents/claude/__init__.py +5 -0
- shikiru/plugins/agents/claude/agent.py +251 -0
- shikiru/plugins/agents/claude/context.py +116 -0
- shikiru/plugins/agents/claude/headless.py +92 -0
- shikiru/plugins/agents/claude/hook.py +57 -0
- shikiru/plugins/agents/claude/model_catalog.py +105 -0
- shikiru/plugins/agents/claude/options.py +62 -0
- shikiru/plugins/agents/claude/turn.py +1018 -0
- shikiru/plugins/agents/codex/__init__.py +5 -0
- shikiru/plugins/agents/codex/agent.py +486 -0
- shikiru/plugins/agents/codex/headless.py +166 -0
- shikiru/plugins/agents/codex/home.py +14 -0
- shikiru/plugins/agents/codex/model_catalog.py +128 -0
- shikiru/plugins/agents/codex/options.py +94 -0
- shikiru/plugins/agents/codex/quota.py +195 -0
- shikiru/plugins/agents/codex/sandbox.py +31 -0
- shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
- shikiru/plugins/agents/codex/turn.py +767 -0
- shikiru/plugins/agents/codex/usage.py +106 -0
- shikiru/plugins/agents/shared_settings.py +62 -0
- shikiru/plugins/widgets/__init__.py +3 -0
- shikiru/plugins/widgets/dialogue.py +144 -0
- shikiru/plugins/widgets/disk.py +123 -0
- shikiru/plugins/widgets/docker.py +225 -0
- shikiru/plugins/widgets/escalations.py +402 -0
- shikiru/plugins/widgets/faults.py +204 -0
- shikiru/plugins/widgets/header.py +321 -0
- shikiru/plugins/widgets/queue.py +1140 -0
- shikiru/plugins/widgets/queue_hint.py +93 -0
- shikiru/plugins/widgets/resources.py +105 -0
- shikiru/plugins/widgets/strips.py +82 -0
- shikiru/plugins/widgets/usage.py +176 -0
- shikiru/private.py +94 -0
- shikiru/process.py +535 -0
- shikiru/prompts/__init__.py +3 -0
- shikiru/prompts/renderer.py +85 -0
- shikiru/prompts/templates/_base.md.j2 +17 -0
- shikiru/prompts/templates/_macros.md.j2 +61 -0
- shikiru/prompts/templates/conflicts.md.j2 +22 -0
- shikiru/prompts/templates/consult.md.j2 +5 -0
- shikiru/prompts/templates/continue.md.j2 +4 -0
- shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
- shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
- shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
- shikiru/prompts/templates/handoff.md.j2 +23 -0
- shikiru/prompts/templates/implement.md.j2 +13 -0
- shikiru/prompts/templates/plan.md.j2 +30 -0
- shikiru/prompts/templates/rework.md.j2 +13 -0
- shikiru/prompts/templates/triage.md.j2 +20 -0
- shikiru/prompts/templates/verify.md.j2 +29 -0
- shikiru/store/__init__.py +1 -0
- shikiru/store/commands.py +242 -0
- shikiru/store/database.py +297 -0
- shikiru/store/escalations.py +240 -0
- shikiru/store/events.py +213 -0
- shikiru/store/feedback.py +266 -0
- shikiru/store/migrations/001_initial.sql +63 -0
- shikiru/store/migrations/002_headless_turns.sql +31 -0
- shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
- shikiru/store/migrations/004_repo_base_ref.sql +3 -0
- shikiru/store/migrations/005_complexity_explicit.sql +3 -0
- shikiru/store/migrations/006_events_kind_index.sql +1 -0
- shikiru/store/migrations/007_notification_cursor.sql +1 -0
- shikiru/store/migrations/008_reported_usage.sql +1 -0
- shikiru/store/migrations/009_billing_declarations.sql +2 -0
- shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
- shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
- shikiru/store/migrations/013_push_targets.sql +7 -0
- shikiru/store/migrations/014_agent_block_time.sql +6 -0
- shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
- shikiru/store/migrations/016_feedback_rounds.sql +29 -0
- shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
- shikiru/store/migrations/__init__.py +1 -0
- shikiru/store/notification_cursor.py +29 -0
- shikiru/store/pushes.py +117 -0
- shikiru/store/quotas.py +153 -0
- shikiru/store/reader.py +310 -0
- shikiru/store/retention.py +92 -0
- shikiru/store/runs.py +227 -0
- shikiru/store/tasks.py +949 -0
- shikiru/telemetry/__init__.py +1 -0
- shikiru/telemetry/disk.py +167 -0
- shikiru/telemetry/docker.py +164 -0
- shikiru/telemetry/resources.py +296 -0
- shikiru/tmux/__init__.py +3 -0
- shikiru/tmux/client.py +637 -0
- shikiru/tmux/decorator.py +225 -0
- shikiru/tmux/layout.py +200 -0
- shikiru/tmux/session.py +381 -0
- shikiru/ui/__init__.py +9 -0
- shikiru/ui/age.py +45 -0
- shikiru/ui/bubbles.py +41 -0
- shikiru/ui/compact.py +1209 -0
- shikiru/ui/console.py +49 -0
- shikiru/ui/fold.py +50 -0
- shikiru/ui/gated_static.py +117 -0
- shikiru/ui/gauge.py +40 -0
- shikiru/ui/glyphs.py +109 -0
- shikiru/ui/keys.py +57 -0
- shikiru/ui/live_interval.py +42 -0
- shikiru/ui/popup.py +311 -0
- shikiru/ui/quota_status.py +26 -0
- shikiru/ui/recent_window.py +78 -0
- shikiru/ui/screen.py +57 -0
- shikiru/ui/section.py +193 -0
- shikiru/ui/session.py +65 -0
- shikiru/ui/size.py +17 -0
- shikiru/ui/splash.py +42 -0
- shikiru/ui/status.py +47 -0
- shikiru/ui/status_strip.py +149 -0
- shikiru/ui/stream.py +152 -0
- shikiru/ui/strip.py +332 -0
- shikiru/ui/theme.py +83 -0
- shikiru/ui/tokens.py +51 -0
- shikiru/view_log.py +126 -0
- shikiru/workspace/__init__.py +3 -0
- shikiru/workspace/base_refs.py +160 -0
- shikiru/workspace/caches.py +136 -0
- shikiru/workspace/facts.py +118 -0
- shikiru/workspace/git.py +601 -0
- shikiru/workspace/github.py +744 -0
- shikiru/workspace/messages.py +107 -0
- shikiru/workspace/reports.py +145 -0
- shikiru/workspace/roots.py +180 -0
- shikiru_cli-0.2.0.dist-info/METADATA +428 -0
- shikiru_cli-0.2.0.dist-info/RECORD +319 -0
- shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
- shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
- shikiru_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
shikiru/process.py
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""The only subprocess gateway in Shikiru: argv lists, timeouts, typed errors, async."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import errno
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import signal
|
|
10
|
+
import subprocess
|
|
11
|
+
import threading
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING, Any, NoReturn, cast
|
|
15
|
+
|
|
16
|
+
import psutil
|
|
17
|
+
|
|
18
|
+
from shikiru.errors import (
|
|
19
|
+
CommandFailed,
|
|
20
|
+
CommandTimeoutError,
|
|
21
|
+
DetachedProcessError,
|
|
22
|
+
ProcessReplacementError,
|
|
23
|
+
)
|
|
24
|
+
from shikiru.private import PRIVATE_FILE_MODE
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
TURN_STOP_GRACE = 10.0
|
|
31
|
+
STDERR_DRAIN_TIMEOUT = 2.0
|
|
32
|
+
STDOUT_DRAIN_TIMEOUT = 2.0
|
|
33
|
+
_STDERR_READ_SIZE = 65536
|
|
34
|
+
_STDOUT_READ_SIZE = 65536
|
|
35
|
+
_TERMINAL_READ_SIZE = 65536
|
|
36
|
+
_STDERR_TAIL_CHARS = 4000
|
|
37
|
+
STDERR_KEPT_BYTES = 1024 * 1024
|
|
38
|
+
START_TIME_TOLERANCE_SECONDS = 10.0
|
|
39
|
+
_NO_SIGNAL = cast("signal.Signals", 0)
|
|
40
|
+
_SHELL = "/bin/sh"
|
|
41
|
+
_PATH_VARIABLE = "PATH"
|
|
42
|
+
_GATE_SCRIPT = 'IFS= read -r _ || exit 1; exec "$@" </dev/null'
|
|
43
|
+
_GATE_ARG0 = "shikiru-gate"
|
|
44
|
+
_GATE_RELEASE = b"\n"
|
|
45
|
+
_ABORT_GRACE = 0.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def ignore_started(_pid: int) -> None:
|
|
49
|
+
"""Accept the id of a just-spawned process and do nothing with it."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class ProcessResult:
|
|
54
|
+
"""The outcome of one process run through `SubprocessRunner`."""
|
|
55
|
+
|
|
56
|
+
argv: tuple[str, ...]
|
|
57
|
+
returncode: int
|
|
58
|
+
stdout: str
|
|
59
|
+
stderr: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _killpg(pgid: int, signum: signal.Signals) -> bool:
|
|
63
|
+
"""Signal process group `pgid`; return whether it was sent.
|
|
64
|
+
|
|
65
|
+
A missing group, or on macOS a group of only zombies (a permission error), has nothing left
|
|
66
|
+
to signal.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
os.killpg(pgid, signum)
|
|
70
|
+
except ProcessLookupError, PermissionError:
|
|
71
|
+
return False
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def _end_process(process: asyncio.subprocess.Process, *, grace: float | None) -> None:
|
|
76
|
+
"""End the process group `process` leads: SIGTERM and `grace` seconds, then SIGKILL.
|
|
77
|
+
|
|
78
|
+
Without a `grace` the group is SIGKILLed at once. It is always SIGKILLed once its leader
|
|
79
|
+
has exited, so no descendant outlives it.
|
|
80
|
+
"""
|
|
81
|
+
if process.returncode is None and grace is not None:
|
|
82
|
+
_killpg(process.pid, signal.SIGTERM)
|
|
83
|
+
with suppress(TimeoutError):
|
|
84
|
+
await asyncio.wait_for(process.wait(), timeout=grace)
|
|
85
|
+
if process.returncode is None:
|
|
86
|
+
_killpg(process.pid, signal.SIGKILL)
|
|
87
|
+
await process.wait()
|
|
88
|
+
_killpg(process.pid, signal.SIGKILL)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True, slots=True)
|
|
92
|
+
class ProcessIdentity:
|
|
93
|
+
"""A process id with its start time, which together never match a reused pid.
|
|
94
|
+
|
|
95
|
+
A process group whose leader had already exited when it was recorded has no start time.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
pid: int
|
|
99
|
+
started_at: float | None
|
|
100
|
+
|
|
101
|
+
def matches(self, live: ProcessIdentity | None) -> bool:
|
|
102
|
+
"""Return whether `live` is this very process, allowing for a shifted start time.
|
|
103
|
+
|
|
104
|
+
Start times derive from the boot time on Linux and move when the clock is stepped, so
|
|
105
|
+
they match within `START_TIME_TOLERANCE_SECONDS`. A reused pid starts a process later
|
|
106
|
+
than that, since no pid is reissued so quickly.
|
|
107
|
+
"""
|
|
108
|
+
if live is None or live.pid != self.pid:
|
|
109
|
+
return False
|
|
110
|
+
if self.started_at is None or live.started_at is None:
|
|
111
|
+
return self.started_at is live.started_at
|
|
112
|
+
return abs(live.started_at - self.started_at) <= START_TIME_TOLERANCE_SECONDS
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def identify_pid(pid: int) -> ProcessIdentity | None:
|
|
116
|
+
"""Return the identity of process `pid`, or `None` when no inspectable process runs.
|
|
117
|
+
|
|
118
|
+
An exited (or zombie) process has no identity, and neither has one the caller may not
|
|
119
|
+
inspect, since every process this engine starts is its own.
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
process = psutil.Process(pid)
|
|
123
|
+
if process.status() == psutil.STATUS_ZOMBIE:
|
|
124
|
+
return None
|
|
125
|
+
return ProcessIdentity(pid, process.create_time())
|
|
126
|
+
except psutil.NoSuchProcess, psutil.AccessDenied:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class SpawnedProcess:
|
|
131
|
+
"""A long-running child process with closed stdin and streamed stdout."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, process: asyncio.subprocess.Process) -> None:
|
|
134
|
+
self._process = process
|
|
135
|
+
self._stderr_tail = bytearray()
|
|
136
|
+
self._stderr_task = asyncio.create_task(self._read_stderr())
|
|
137
|
+
self._stdout_read = False
|
|
138
|
+
self._group_swept = False
|
|
139
|
+
self._stop_lock = asyncio.Lock()
|
|
140
|
+
self._stderr = ""
|
|
141
|
+
self._stderr_bytes = b""
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def pid(self) -> int:
|
|
145
|
+
"""Return this child process's operating system id."""
|
|
146
|
+
return self._process.pid
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def returncode(self) -> int | None:
|
|
150
|
+
"""Return the exit status when this process has exited."""
|
|
151
|
+
return self._process.returncode
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def stderr(self) -> str:
|
|
155
|
+
"""Return the last `STDERR_KEPT_BYTES` of stderr captured after `wait()` completes."""
|
|
156
|
+
return self._stderr
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def stderr_bytes(self) -> bytes:
|
|
160
|
+
"""Return the exact last `STDERR_KEPT_BYTES` of stderr after `wait()` completes."""
|
|
161
|
+
return self._stderr_bytes
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def stderr_tail(self) -> str:
|
|
165
|
+
"""Return a bounded stderr tail for failure messages."""
|
|
166
|
+
return self._stderr[-_STDERR_TAIL_CHARS:]
|
|
167
|
+
|
|
168
|
+
async def stdout_lines(self) -> AsyncIterator[bytes]:
|
|
169
|
+
"""Yield stdout lines as original bytes, including line endings, exactly once.
|
|
170
|
+
|
|
171
|
+
Once the leader has exited, `STDOUT_DRAIN_TIMEOUT` seconds later its process group is
|
|
172
|
+
SIGKILLed, so a descendant holding the pipe cannot keep the stream open.
|
|
173
|
+
"""
|
|
174
|
+
if self._stdout_read:
|
|
175
|
+
message = "stdout_lines() can only be consumed once"
|
|
176
|
+
raise RuntimeError(message)
|
|
177
|
+
self._stdout_read = True
|
|
178
|
+
if self._process.stdout is None:
|
|
179
|
+
message = "spawned process has no stdout pipe"
|
|
180
|
+
raise RuntimeError(message)
|
|
181
|
+
pending = bytearray()
|
|
182
|
+
sweeper = asyncio.create_task(self._sweep_group_after_exit())
|
|
183
|
+
try:
|
|
184
|
+
while chunk := await self._process.stdout.read(_STDOUT_READ_SIZE):
|
|
185
|
+
pending.extend(chunk)
|
|
186
|
+
while (newline_index := pending.find(b"\n")) >= 0:
|
|
187
|
+
line_end = newline_index + 1
|
|
188
|
+
yield bytes(pending[:line_end])
|
|
189
|
+
del pending[:line_end]
|
|
190
|
+
finally:
|
|
191
|
+
sweeper.cancel()
|
|
192
|
+
if pending:
|
|
193
|
+
yield bytes(pending)
|
|
194
|
+
|
|
195
|
+
async def wait(self) -> int:
|
|
196
|
+
"""Wait for process exit, end its process group, and capture stderr within a bound."""
|
|
197
|
+
returncode = await self._process.wait()
|
|
198
|
+
self._sweep_group()
|
|
199
|
+
_, pending = await asyncio.wait({self._stderr_task}, timeout=STDERR_DRAIN_TIMEOUT)
|
|
200
|
+
for task in pending:
|
|
201
|
+
task.cancel()
|
|
202
|
+
self._stderr_bytes = bytes(self._stderr_tail)
|
|
203
|
+
self._stderr = self._stderr_bytes.decode(errors="replace")
|
|
204
|
+
return returncode
|
|
205
|
+
|
|
206
|
+
def _sweep_group(self) -> None:
|
|
207
|
+
if not self._group_swept:
|
|
208
|
+
self._group_swept = True
|
|
209
|
+
_killpg(self._process.pid, signal.SIGKILL)
|
|
210
|
+
|
|
211
|
+
async def _sweep_group_after_exit(self) -> None:
|
|
212
|
+
await self._process.wait()
|
|
213
|
+
await asyncio.sleep(STDOUT_DRAIN_TIMEOUT)
|
|
214
|
+
self._sweep_group()
|
|
215
|
+
|
|
216
|
+
async def stop(self, *, grace: float = TURN_STOP_GRACE) -> None:
|
|
217
|
+
"""Signal the process group with SIGTERM, then SIGKILL after `grace`, and reap it."""
|
|
218
|
+
async with self._stop_lock:
|
|
219
|
+
await _end_process(self._process, grace=grace)
|
|
220
|
+
await self.wait()
|
|
221
|
+
|
|
222
|
+
async def _read_stderr(self) -> None:
|
|
223
|
+
if self._process.stderr is None:
|
|
224
|
+
message = "spawned process has no stderr pipe"
|
|
225
|
+
raise RuntimeError(message)
|
|
226
|
+
while chunk := await self._process.stderr.read(_STDERR_READ_SIZE):
|
|
227
|
+
self._stderr_tail.extend(chunk)
|
|
228
|
+
del self._stderr_tail[:-STDERR_KEPT_BYTES]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class SpawnedTerminalProcess:
|
|
232
|
+
"""A process attached to a private pseudo-terminal."""
|
|
233
|
+
|
|
234
|
+
def __init__(self, process: asyncio.subprocess.Process, master_fd: int) -> None:
|
|
235
|
+
self._process = process
|
|
236
|
+
self._master_fd = master_fd
|
|
237
|
+
os.set_blocking(master_fd, False)
|
|
238
|
+
self._loop = asyncio.get_running_loop()
|
|
239
|
+
self._loop.add_reader(master_fd, self._drain_terminal)
|
|
240
|
+
self._stop_lock = asyncio.Lock()
|
|
241
|
+
self._terminal_closed = False
|
|
242
|
+
self._output = bytearray()
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def returncode(self) -> int | None:
|
|
246
|
+
"""Return the terminal process's exit code when it has finished."""
|
|
247
|
+
return self._process.returncode
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def output(self) -> bytes:
|
|
251
|
+
"""Return every byte the terminal has produced so far."""
|
|
252
|
+
return bytes(self._output)
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def pid(self) -> int:
|
|
256
|
+
"""Return the terminal process's PID."""
|
|
257
|
+
return self._process.pid
|
|
258
|
+
|
|
259
|
+
def write_input(self, value: bytes) -> None:
|
|
260
|
+
"""Write input bytes to the terminal attached to this process."""
|
|
261
|
+
os.write(self._master_fd, value)
|
|
262
|
+
|
|
263
|
+
def send_interrupt(self) -> None:
|
|
264
|
+
"""Send SIGINT to the terminal process."""
|
|
265
|
+
self._process.send_signal(signal.SIGINT)
|
|
266
|
+
|
|
267
|
+
async def stop(self, *, grace: float = TURN_STOP_GRACE) -> None:
|
|
268
|
+
"""Stop the process group and close the private pseudo-terminal."""
|
|
269
|
+
async with self._stop_lock:
|
|
270
|
+
try:
|
|
271
|
+
await _end_process(self._process, grace=grace)
|
|
272
|
+
finally:
|
|
273
|
+
if not self._terminal_closed:
|
|
274
|
+
self._terminal_closed = True
|
|
275
|
+
self._loop.remove_reader(self._master_fd)
|
|
276
|
+
os.close(self._master_fd)
|
|
277
|
+
|
|
278
|
+
def _drain_terminal(self) -> None:
|
|
279
|
+
while True:
|
|
280
|
+
try:
|
|
281
|
+
chunk = os.read(self._master_fd, _TERMINAL_READ_SIZE)
|
|
282
|
+
if not chunk:
|
|
283
|
+
self._loop.remove_reader(self._master_fd)
|
|
284
|
+
return
|
|
285
|
+
self._output.extend(chunk)
|
|
286
|
+
except BlockingIOError:
|
|
287
|
+
return
|
|
288
|
+
except OSError as error:
|
|
289
|
+
if error.errno == errno.EIO:
|
|
290
|
+
self._loop.remove_reader(self._master_fd)
|
|
291
|
+
return
|
|
292
|
+
raise
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class SubprocessRunner:
|
|
296
|
+
"""Runs argv lists as subprocesses, never through a shell."""
|
|
297
|
+
|
|
298
|
+
async def run(
|
|
299
|
+
self,
|
|
300
|
+
argv: Sequence[str],
|
|
301
|
+
*,
|
|
302
|
+
timeout: float,
|
|
303
|
+
cwd: Path | None = None,
|
|
304
|
+
env: Mapping[str, str] | None = None,
|
|
305
|
+
input_text: str | None = None,
|
|
306
|
+
check: bool = False,
|
|
307
|
+
stop_grace: float | None = None,
|
|
308
|
+
) -> ProcessResult:
|
|
309
|
+
"""Run `argv` and return its result, honouring `timeout`.
|
|
310
|
+
|
|
311
|
+
On expiry or cancellation the process is SIGKILLed, or with a `stop_grace` given SIGTERMed
|
|
312
|
+
and killed only when it outlasts that many seconds. The process leads its own process
|
|
313
|
+
group, which is ended with it so no descendant (a hook, an MCP server) outlives the call.
|
|
314
|
+
"""
|
|
315
|
+
argv_tuple = tuple(argv)
|
|
316
|
+
process = await self._start(
|
|
317
|
+
argv_tuple,
|
|
318
|
+
cwd=cwd,
|
|
319
|
+
env=env,
|
|
320
|
+
stdin=asyncio.subprocess.PIPE if input_text is not None else None,
|
|
321
|
+
)
|
|
322
|
+
input_bytes = input_text.encode() if input_text is not None else None
|
|
323
|
+
try:
|
|
324
|
+
try:
|
|
325
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
326
|
+
process.communicate(input_bytes), timeout=timeout
|
|
327
|
+
)
|
|
328
|
+
except TimeoutError:
|
|
329
|
+
await self._end_and_wait(process, grace=stop_grace)
|
|
330
|
+
raise CommandTimeoutError(argv_tuple, timeout) from None
|
|
331
|
+
except asyncio.CancelledError:
|
|
332
|
+
await self._end_and_wait(process, grace=stop_grace)
|
|
333
|
+
raise
|
|
334
|
+
finally:
|
|
335
|
+
self._close_transport(process)
|
|
336
|
+
|
|
337
|
+
result = ProcessResult(
|
|
338
|
+
argv=argv_tuple,
|
|
339
|
+
returncode=cast("int", process.returncode),
|
|
340
|
+
stdout=stdout_bytes.decode(),
|
|
341
|
+
stderr=stderr_bytes.decode(),
|
|
342
|
+
)
|
|
343
|
+
if check and result.returncode != 0:
|
|
344
|
+
raise CommandFailed(argv_tuple, result.returncode, result.stderr)
|
|
345
|
+
return result
|
|
346
|
+
|
|
347
|
+
async def spawn(
|
|
348
|
+
self,
|
|
349
|
+
argv: Sequence[str],
|
|
350
|
+
*,
|
|
351
|
+
cwd: Path | None = None,
|
|
352
|
+
env: Mapping[str, str] | None = None,
|
|
353
|
+
on_started: Callable[[int], None] = ignore_started,
|
|
354
|
+
) -> SpawnedProcess:
|
|
355
|
+
"""Start `argv` with stdin closed and stdout and stderr captured as streams.
|
|
356
|
+
|
|
357
|
+
The process leads its own session and does not run `argv` until `on_started` has
|
|
358
|
+
returned, so the caller can durably record its id first. When the caller dies or
|
|
359
|
+
`on_started` raises before that, `argv` never runs and the process exits or is killed.
|
|
360
|
+
"""
|
|
361
|
+
argv_tuple = tuple(argv)
|
|
362
|
+
search_path = (env if env is not None else os.environ).get(_PATH_VARIABLE)
|
|
363
|
+
if shutil.which(argv_tuple[0], path=search_path) is None:
|
|
364
|
+
raise FileNotFoundError(argv_tuple[0])
|
|
365
|
+
gate_read, gate_write = os.pipe()
|
|
366
|
+
try:
|
|
367
|
+
try:
|
|
368
|
+
process = await self._start(
|
|
369
|
+
(_SHELL, "-c", _GATE_SCRIPT, _GATE_ARG0, *argv_tuple),
|
|
370
|
+
cwd=cwd,
|
|
371
|
+
env=env,
|
|
372
|
+
stdin=gate_read,
|
|
373
|
+
)
|
|
374
|
+
finally:
|
|
375
|
+
os.close(gate_read)
|
|
376
|
+
spawned = SpawnedProcess(process)
|
|
377
|
+
try:
|
|
378
|
+
on_started(process.pid)
|
|
379
|
+
os.write(gate_write, _GATE_RELEASE)
|
|
380
|
+
except BaseException:
|
|
381
|
+
await spawned.stop(grace=_ABORT_GRACE)
|
|
382
|
+
raise
|
|
383
|
+
finally:
|
|
384
|
+
os.close(gate_write)
|
|
385
|
+
return spawned
|
|
386
|
+
|
|
387
|
+
async def spawn_terminal(
|
|
388
|
+
self,
|
|
389
|
+
argv: Sequence[str],
|
|
390
|
+
*,
|
|
391
|
+
cwd: Path | None = None,
|
|
392
|
+
env: Mapping[str, str] | None = None,
|
|
393
|
+
) -> SpawnedTerminalProcess:
|
|
394
|
+
"""Start `argv` with a private terminal connected to standard input and output."""
|
|
395
|
+
argv_tuple = tuple(argv)
|
|
396
|
+
master_fd, slave_fd = os.openpty()
|
|
397
|
+
starting = asyncio.ensure_future(
|
|
398
|
+
asyncio.create_subprocess_exec(
|
|
399
|
+
*argv_tuple,
|
|
400
|
+
cwd=cwd,
|
|
401
|
+
env=env,
|
|
402
|
+
stdin=slave_fd,
|
|
403
|
+
stdout=slave_fd,
|
|
404
|
+
stderr=slave_fd,
|
|
405
|
+
start_new_session=True,
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
try:
|
|
409
|
+
try:
|
|
410
|
+
process = await asyncio.shield(starting)
|
|
411
|
+
except asyncio.CancelledError:
|
|
412
|
+
process = await starting
|
|
413
|
+
await self._end_and_wait(process, grace=None)
|
|
414
|
+
raise
|
|
415
|
+
except BaseException:
|
|
416
|
+
os.close(master_fd)
|
|
417
|
+
raise
|
|
418
|
+
finally:
|
|
419
|
+
os.close(slave_fd)
|
|
420
|
+
return SpawnedTerminalProcess(process, master_fd)
|
|
421
|
+
|
|
422
|
+
def spawn_detached(
|
|
423
|
+
self,
|
|
424
|
+
argv: Sequence[str],
|
|
425
|
+
*,
|
|
426
|
+
output: Path,
|
|
427
|
+
env: Mapping[str, str] | None = None,
|
|
428
|
+
) -> int:
|
|
429
|
+
"""Start `argv` in a new session and return its process id without waiting.
|
|
430
|
+
|
|
431
|
+
Its stdout and stderr go to `output`, an owner-only file truncated by this start, so a
|
|
432
|
+
process that dies at once leaves its error behind.
|
|
433
|
+
"""
|
|
434
|
+
argv_tuple = tuple(argv)
|
|
435
|
+
output_fd = os.open(output, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, PRIVATE_FILE_MODE)
|
|
436
|
+
try:
|
|
437
|
+
try:
|
|
438
|
+
process = subprocess.Popen( # noqa: S603 # keep: gateway executes argv without a shell
|
|
439
|
+
argv_tuple,
|
|
440
|
+
env=dict(env) if env is not None else dict(os.environ),
|
|
441
|
+
stdout=output_fd,
|
|
442
|
+
stderr=output_fd,
|
|
443
|
+
close_fds=False,
|
|
444
|
+
restore_signals=False,
|
|
445
|
+
start_new_session=True,
|
|
446
|
+
)
|
|
447
|
+
except OSError as error:
|
|
448
|
+
raise DetachedProcessError(argv_tuple, error) from error
|
|
449
|
+
threading.Thread(target=process.wait, daemon=True).start()
|
|
450
|
+
return process.pid
|
|
451
|
+
finally:
|
|
452
|
+
os.close(output_fd)
|
|
453
|
+
|
|
454
|
+
def identify(self, pid: int) -> ProcessIdentity | None:
|
|
455
|
+
"""Return the identity of process `pid`, or `None` when no inspectable process runs."""
|
|
456
|
+
return identify_pid(pid)
|
|
457
|
+
|
|
458
|
+
def is_running(self, identity: ProcessIdentity) -> bool:
|
|
459
|
+
"""Return whether the very process `identity` names still runs."""
|
|
460
|
+
return identity.matches(self.identify(identity.pid))
|
|
461
|
+
|
|
462
|
+
def group_is_running(self, pgid: int) -> bool:
|
|
463
|
+
"""Return whether any process of group `pgid` still runs."""
|
|
464
|
+
return _killpg(pgid, _NO_SIGNAL)
|
|
465
|
+
|
|
466
|
+
def signal_group(self, identity: ProcessIdentity, signum: signal.Signals) -> bool:
|
|
467
|
+
"""Send `signum` to the process group `identity` leads; return whether it was sent.
|
|
468
|
+
|
|
469
|
+
A group outlives its leader while any member runs, and no pid equal to a live group id is
|
|
470
|
+
reused, so only a pid now held by a different process is refused.
|
|
471
|
+
"""
|
|
472
|
+
live = self.identify(identity.pid)
|
|
473
|
+
if live is not None and not identity.matches(live):
|
|
474
|
+
return False
|
|
475
|
+
return _killpg(identity.pid, signum)
|
|
476
|
+
|
|
477
|
+
def replace(self, argv: Sequence[str], *, env: Mapping[str, str] | None = None) -> NoReturn:
|
|
478
|
+
"""Replace this process with `argv`, resolving its executable on `PATH`."""
|
|
479
|
+
argv_tuple = tuple(argv)
|
|
480
|
+
executable = shutil.which(argv_tuple[0])
|
|
481
|
+
if executable is None:
|
|
482
|
+
error = FileNotFoundError(argv_tuple[0])
|
|
483
|
+
raise ProcessReplacementError(argv_tuple, error) from error
|
|
484
|
+
try:
|
|
485
|
+
os.execve( # noqa: S606
|
|
486
|
+
executable,
|
|
487
|
+
argv_tuple,
|
|
488
|
+
dict(env) if env is not None else dict(os.environ),
|
|
489
|
+
)
|
|
490
|
+
except OSError as error:
|
|
491
|
+
raise ProcessReplacementError(argv_tuple, error) from error
|
|
492
|
+
|
|
493
|
+
@classmethod
|
|
494
|
+
async def _start(
|
|
495
|
+
cls,
|
|
496
|
+
argv: tuple[str, ...],
|
|
497
|
+
*,
|
|
498
|
+
cwd: Path | None,
|
|
499
|
+
env: Mapping[str, str] | None,
|
|
500
|
+
stdin: int | None,
|
|
501
|
+
) -> asyncio.subprocess.Process:
|
|
502
|
+
starting = asyncio.ensure_future(
|
|
503
|
+
asyncio.create_subprocess_exec(
|
|
504
|
+
*argv,
|
|
505
|
+
cwd=cwd,
|
|
506
|
+
env=env,
|
|
507
|
+
stdin=stdin,
|
|
508
|
+
stdout=asyncio.subprocess.PIPE,
|
|
509
|
+
stderr=asyncio.subprocess.PIPE,
|
|
510
|
+
start_new_session=True,
|
|
511
|
+
)
|
|
512
|
+
)
|
|
513
|
+
try:
|
|
514
|
+
return await asyncio.shield(starting)
|
|
515
|
+
except asyncio.CancelledError:
|
|
516
|
+
process = await starting
|
|
517
|
+
await cls._end_and_wait(process, grace=None)
|
|
518
|
+
cls._close_transport(process)
|
|
519
|
+
raise
|
|
520
|
+
|
|
521
|
+
@staticmethod
|
|
522
|
+
async def _end_and_wait(process: asyncio.subprocess.Process, *, grace: float | None) -> None:
|
|
523
|
+
cleanup = asyncio.create_task(_end_process(process, grace=grace))
|
|
524
|
+
try:
|
|
525
|
+
await asyncio.shield(cleanup)
|
|
526
|
+
except asyncio.CancelledError:
|
|
527
|
+
await cleanup
|
|
528
|
+
|
|
529
|
+
@staticmethod
|
|
530
|
+
def _close_transport(process: asyncio.subprocess.Process) -> None:
|
|
531
|
+
transport = cast(
|
|
532
|
+
"Any",
|
|
533
|
+
process._transport, # noqa: SLF001 # pyright: ignore[reportAttributeAccessIssue]
|
|
534
|
+
)
|
|
535
|
+
transport.close()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Renders turn prompts from packaged templates, overridable per project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum, auto
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from jinja2 import (
|
|
9
|
+
BaseLoader,
|
|
10
|
+
ChoiceLoader,
|
|
11
|
+
Environment,
|
|
12
|
+
FileSystemLoader,
|
|
13
|
+
PackageLoader,
|
|
14
|
+
StrictUndefined,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from shikiru.agents.contracts import shikiru_command
|
|
18
|
+
from shikiru.domain.acceptance import ACCEPTANCE_CRITERIA_HEADING
|
|
19
|
+
from shikiru.domain.fencing import fence
|
|
20
|
+
from shikiru.paths import PROMPT_OVERRIDES_RELATIVE_PATH
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Mapping
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
TEMPLATES_PACKAGE = "shikiru.prompts"
|
|
27
|
+
TEMPLATES_PACKAGE_PATH = "templates"
|
|
28
|
+
TEMPLATE_FILENAME_SUFFIX = ".md.j2"
|
|
29
|
+
|
|
30
|
+
REPORT_DRAFT_FILENAME = "report.draft.json"
|
|
31
|
+
REPORT_INSTRUCTION = (
|
|
32
|
+
f"Write the report JSON matching `report.schema.json` to `{REPORT_DRAFT_FILENAME}` in this "
|
|
33
|
+
"prompt's directory, never inside the workspace, and run "
|
|
34
|
+
f"`{shikiru_command('report', '--file')} <that file>`. If it exits non-zero, fix it and run "
|
|
35
|
+
"it again. Then end your turn."
|
|
36
|
+
)
|
|
37
|
+
OPERATOR_MESSAGES_KEY = "operator_messages"
|
|
38
|
+
ALREADY_DONE_SENTENCE = "If this work is already done in the workspace, verify it and report."
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PromptName(StrEnum):
|
|
42
|
+
"""One packaged template name, also its filename stem."""
|
|
43
|
+
|
|
44
|
+
TRIAGE = auto()
|
|
45
|
+
PLAN = auto()
|
|
46
|
+
IMPLEMENT = auto()
|
|
47
|
+
REWORK = auto()
|
|
48
|
+
CONSULT = auto()
|
|
49
|
+
VERIFY = auto()
|
|
50
|
+
CONFLICTS = auto()
|
|
51
|
+
CONTINUE = auto()
|
|
52
|
+
HANDOFF = auto()
|
|
53
|
+
FEEDBACK_ANALYSIS = auto()
|
|
54
|
+
FEEDBACK_REWORK = auto()
|
|
55
|
+
FEEDBACK_VERIFY = auto()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PromptRenderer:
|
|
59
|
+
"""Renders a `PromptName` template, preferring a project's `.shikiru/prompts/` override."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, project_dir: Path) -> None:
|
|
62
|
+
"""Build the Jinja2 environment: project overrides first, packaged templates beneath."""
|
|
63
|
+
loaders: list[BaseLoader] = []
|
|
64
|
+
overrides_dir = project_dir / PROMPT_OVERRIDES_RELATIVE_PATH
|
|
65
|
+
if overrides_dir.is_dir():
|
|
66
|
+
loaders.append(FileSystemLoader(overrides_dir))
|
|
67
|
+
loaders.append(PackageLoader(TEMPLATES_PACKAGE, TEMPLATES_PACKAGE_PATH))
|
|
68
|
+
self._env = Environment( # noqa: S701 -- Markdown prompts, not HTML; autoescape doesn't apply
|
|
69
|
+
loader=ChoiceLoader(loaders),
|
|
70
|
+
undefined=StrictUndefined,
|
|
71
|
+
trim_blocks=True,
|
|
72
|
+
lstrip_blocks=True,
|
|
73
|
+
)
|
|
74
|
+
self._env.filters["fenced"] = fence
|
|
75
|
+
|
|
76
|
+
def render(self, name: PromptName, context: Mapping[str, object]) -> str:
|
|
77
|
+
"""Render `name`'s template against `context`, raising on any undefined variable."""
|
|
78
|
+
template = self._env.get_template(f"{name.value}{TEMPLATE_FILENAME_SUFFIX}")
|
|
79
|
+
merged = {
|
|
80
|
+
"report_instruction": REPORT_INSTRUCTION,
|
|
81
|
+
"already_done_sentence": ALREADY_DONE_SENTENCE,
|
|
82
|
+
"acceptance_criteria_heading": ACCEPTANCE_CRITERIA_HEADING,
|
|
83
|
+
**context,
|
|
84
|
+
}
|
|
85
|
+
return template.render(**merged)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{% import "_macros.md.j2" as macros %}
|
|
2
|
+
{{ macros.operator_messages_section(operator_messages) }}
|
|
3
|
+
{{ macros.request_section(request) }}
|
|
4
|
+
|
|
5
|
+
{{ macros.trackers_line(trackers) }}
|
|
6
|
+
|
|
7
|
+
Your workspace is `{{ workspace }}`, the current directory: read and change files only there.
|
|
8
|
+
The project checkout it came from is off limits. Stay on the branch checked out there:
|
|
9
|
+
never switch branches or detach `HEAD`, or your commits are not integrated.
|
|
10
|
+
|
|
11
|
+
{{ macros.reports_section(reports) }}
|
|
12
|
+
|
|
13
|
+
{% block body %}{% endblock %}
|
|
14
|
+
|
|
15
|
+
{{ already_done_sentence }}
|
|
16
|
+
|
|
17
|
+
{{ report_instruction }}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{% macro request_section(request) -%}
|
|
2
|
+
## Request
|
|
3
|
+
|
|
4
|
+
{{ request | fenced("text") }}
|
|
5
|
+
{%- endmacro %}
|
|
6
|
+
|
|
7
|
+
{% macro trackers_line(trackers) -%}
|
|
8
|
+
Trackers: {% for path in trackers %}`{{ path }}`{% if not loop.last %}, {% endif %}{% endfor %}
|
|
9
|
+
{%- endmacro %}
|
|
10
|
+
|
|
11
|
+
{% macro reports_section(reports) -%}
|
|
12
|
+
{% if reports %}
|
|
13
|
+
## Earlier reports
|
|
14
|
+
{% for report in reports %}
|
|
15
|
+
### {{ report.label }}
|
|
16
|
+
|
|
17
|
+
{{ report.json | fenced("json") }}
|
|
18
|
+
{% endfor %}
|
|
19
|
+
{% endif -%}
|
|
20
|
+
{%- endmacro %}
|
|
21
|
+
|
|
22
|
+
{% macro facts_section(facts) -%}
|
|
23
|
+
## Facts
|
|
24
|
+
|
|
25
|
+
{{ facts | fenced("text") }}
|
|
26
|
+
{%- endmacro %}
|
|
27
|
+
|
|
28
|
+
{% macro operator_messages_section(paths) -%}
|
|
29
|
+
{% if paths %}
|
|
30
|
+
## Operator messages — read these first
|
|
31
|
+
|
|
32
|
+
{% for path in paths %}
|
|
33
|
+
- `{{ path }}`
|
|
34
|
+
{% endfor %}
|
|
35
|
+
{% endif -%}
|
|
36
|
+
{%- endmacro %}
|
|
37
|
+
|
|
38
|
+
{% macro consult_body(question, operator_decision) -%}
|
|
39
|
+
You are the Leader, consulted by the Implementer during this task.
|
|
40
|
+
{% if operator_decision is not none %}
|
|
41
|
+
|
|
42
|
+
## Decision from the Operator
|
|
43
|
+
|
|
44
|
+
{{ operator_decision | fenced("text") }}
|
|
45
|
+
|
|
46
|
+
This is the product owner's decision in answer to an escalation, and it settles the question that
|
|
47
|
+
escalation asked. Use it as the basis of your `answer`, adapted into guidance for the Implementer.
|
|
48
|
+
Do not escalate the same question again unless the decision is unclear or contradicts the request.
|
|
49
|
+
{% endif %}
|
|
50
|
+
{% if question or operator_decision is none %}
|
|
51
|
+
|
|
52
|
+
## Implementer's question
|
|
53
|
+
|
|
54
|
+
{{ question | fenced("text") }}
|
|
55
|
+
{% endif %}
|
|
56
|
+
|
|
57
|
+
Decide `answer`, with the guidance the Implementer needs to continue, or `escalate` with a
|
|
58
|
+
question and options for the Operator, exactly one of them `recommended`.
|
|
59
|
+
Set `needs_input` on an option that asks the Operator to provide something (a file, a result, access,
|
|
60
|
+
a value): choosing it lets the Operator type the details.
|
|
61
|
+
{%- endmacro %}
|