shellmate-cli 3.0.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.
Files changed (106) hide show
  1. shellmate/__init__.py +0 -0
  2. shellmate/adapters/__init__.py +67 -0
  3. shellmate/adapters/bash_adapter.sh +7 -0
  4. shellmate/adapters/ble_adapter.sh +350 -0
  5. shellmate/adapters/powershell_adapter.ps1 +417 -0
  6. shellmate/adapters/powershell_predictor.ps1 +97 -0
  7. shellmate/adapters/zsh_adapter.zsh +8 -0
  8. shellmate/agents/__init__.py +23 -0
  9. shellmate/agents/base.py +248 -0
  10. shellmate/agents/chain_patterns.yaml +98 -0
  11. shellmate/agents/command_chain.py +322 -0
  12. shellmate/agents/context_gatherer.py +175 -0
  13. shellmate/agents/environment.py +363 -0
  14. shellmate/agents/librarian.py +502 -0
  15. shellmate/agents/query.py +415 -0
  16. shellmate/agents/repomix_backend.py +171 -0
  17. shellmate/agents/watcher.py +266 -0
  18. shellmate/cache/__init__.py +0 -0
  19. shellmate/cache/semantic_cache.py +41 -0
  20. shellmate/cli/__init__.py +0 -0
  21. shellmate/cli/commands/__init__.py +27 -0
  22. shellmate/cli/commands/analyze.py +5 -0
  23. shellmate/cli/commands/config.py +319 -0
  24. shellmate/cli/commands/configure.py +118 -0
  25. shellmate/cli/commands/disable.py +11 -0
  26. shellmate/cli/commands/enable.py +49 -0
  27. shellmate/cli/commands/init.py +412 -0
  28. shellmate/cli/commands/install.py +242 -0
  29. shellmate/cli/commands/migrate.py +147 -0
  30. shellmate/cli/commands/status.py +31 -0
  31. shellmate/cli/commands/uninstall.py +78 -0
  32. shellmate/cli/main.py +114 -0
  33. shellmate/cli/parser.py +184 -0
  34. shellmate/cli/version.py +1 -0
  35. shellmate/completion/__init__.py +4 -0
  36. shellmate/completion/bash_adapter.py +145 -0
  37. shellmate/config/__init__.py +33 -0
  38. shellmate/config/credentials.py +120 -0
  39. shellmate/config/defaults.py +88 -0
  40. shellmate/config/manager.py +94 -0
  41. shellmate/config/paths.py +77 -0
  42. shellmate/config/secrets.py +66 -0
  43. shellmate/daemon/__init__.py +0 -0
  44. shellmate/daemon/lifecycle.py +308 -0
  45. shellmate/daemon/observability.py +82 -0
  46. shellmate/daemon/pipeline.py +119 -0
  47. shellmate/daemon/server.py +605 -0
  48. shellmate/daemon/session.py +170 -0
  49. shellmate/daemon/startup_ui.py +43 -0
  50. shellmate/environment/__init__.py +0 -0
  51. shellmate/environment/detector.py +130 -0
  52. shellmate/ipc/__init__.py +0 -0
  53. shellmate/ipc/client.py +210 -0
  54. shellmate/ipc/ps_client.py +190 -0
  55. shellmate/orchestration/__init__.py +3 -0
  56. shellmate/orchestration/analysis_graph.py +1274 -0
  57. shellmate/orchestration/state.py +35 -0
  58. shellmate/parser/__init__.py +0 -0
  59. shellmate/parser/command_parser.py +111 -0
  60. shellmate/plugins/__init__.py +0 -0
  61. shellmate/plugins/base.py +80 -0
  62. shellmate/plugins/loader.py +41 -0
  63. shellmate/providers/__init__.py +0 -0
  64. shellmate/providers/base.py +97 -0
  65. shellmate/providers/groq_command_guide.py +108 -0
  66. shellmate/providers/groq_provider.py +127 -0
  67. shellmate/providers/groq_supervisor.py +78 -0
  68. shellmate/providers/openrouter_provider.py +149 -0
  69. shellmate/providers/router.py +272 -0
  70. shellmate/providers/validate_keys.py +98 -0
  71. shellmate/providers/xai_provider.py +142 -0
  72. shellmate/rules/__init__.py +0 -0
  73. shellmate/rules/catastrophic.yaml +96 -0
  74. shellmate/rules/catastrophic_matcher.py +167 -0
  75. shellmate/rules/definitions/__init__.py +0 -0
  76. shellmate/rules/definitions/file_deletion.yaml +54 -0
  77. shellmate/rules/definitions/filesystem.yaml +43 -0
  78. shellmate/rules/definitions/network_execution.yaml +46 -0
  79. shellmate/rules/definitions/permissions.yaml +54 -0
  80. shellmate/rules/definitions/process_control.yaml +37 -0
  81. shellmate/rules/definitions/system_destruction.yaml +67 -0
  82. shellmate/rules/engine.py +200 -0
  83. shellmate/sanitizer/__init__.py +0 -0
  84. shellmate/sanitizer/typo_detector.py +114 -0
  85. shellmate/ui/__init__.py +10 -0
  86. shellmate/ui/analysis.py +164 -0
  87. shellmate/ui/annotations.py +232 -0
  88. shellmate/ui/banner.py +49 -0
  89. shellmate/ui/progress.py +126 -0
  90. shellmate/ui/runtime/__init__.py +15 -0
  91. shellmate/ui/runtime/console_factory.py +65 -0
  92. shellmate/ui/runtime/live_scene.py +116 -0
  93. shellmate/ui/runtime/motion_policy.py +69 -0
  94. shellmate/ui/runtime/tokens.py +47 -0
  95. shellmate/ui/scenes/__init__.py +24 -0
  96. shellmate/ui/scenes/enable.py +316 -0
  97. shellmate/ui/scenes/help.py +53 -0
  98. shellmate/ui/scenes/stages.py +128 -0
  99. shellmate/ui/scenes/status.py +181 -0
  100. shellmate/ui/status.py +3 -0
  101. shellmate_cli-3.0.0.dist-info/METADATA +352 -0
  102. shellmate_cli-3.0.0.dist-info/RECORD +106 -0
  103. shellmate_cli-3.0.0.dist-info/WHEEL +5 -0
  104. shellmate_cli-3.0.0.dist-info/entry_points.txt +3 -0
  105. shellmate_cli-3.0.0.dist-info/licenses/LICENSE +21 -0
  106. shellmate_cli-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,47 @@
1
+ """Shared visual tokens — spacing, styles, icons with ASCII fallbacks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ CONTENT_MAX_WIDTH = 96
6
+ NARROW_WIDTH = 80
7
+ MIN_WIDTH = 40
8
+
9
+ ACCENT = "cyan"
10
+ MUTED = "dim"
11
+ SUCCESS = "green"
12
+ WARNING = "yellow"
13
+ ERROR = "red"
14
+ TITLE = "bold"
15
+
16
+ # Unicode icons (interactive modern terminals)
17
+ ICONS_UNICODE = {
18
+ "pending": "○",
19
+ "running": "●",
20
+ "done": "✓",
21
+ "error": "✗",
22
+ "skip": "−",
23
+ }
24
+
25
+ # ASCII-safe (Windows PS 5.1 / constrained / --no-color plain)
26
+ ICONS_ASCII = {
27
+ "pending": "o",
28
+ "running": "*",
29
+ "done": "+",
30
+ "error": "x",
31
+ "skip": "-",
32
+ }
33
+
34
+
35
+ def icons(ascii_safe: bool = False) -> dict[str, str]:
36
+ return ICONS_ASCII if ascii_safe else ICONS_UNICODE
37
+
38
+
39
+ def content_width(console_width: int) -> int:
40
+ """Usable content width: track terminal, clamp to max, never invent fixed 110."""
41
+ w = max(int(console_width or MIN_WIDTH), MIN_WIDTH)
42
+ return min(w, CONTENT_MAX_WIDTH)
43
+
44
+
45
+ def show_logo(console_width: int) -> bool:
46
+ """Bold logo only when there is room; narrow terminals get wordmark only."""
47
+ return int(console_width or 0) >= NARROW_WIDTH
@@ -0,0 +1,24 @@
1
+ """CLI scene composers."""
2
+
3
+ from shellmate.ui.scenes.enable import render_enable_frame, run_enable_scene
4
+ from shellmate.ui.scenes.help import print_help, render_help
5
+ from shellmate.ui.scenes.status import render_status, build_status_renderable
6
+ from shellmate.ui.scenes.stages import (
7
+ CANONICAL_STAGES,
8
+ reduce_enable_state,
9
+ normalize_completed,
10
+ next_reveal_target,
11
+ )
12
+
13
+ __all__ = [
14
+ "render_enable_frame",
15
+ "run_enable_scene",
16
+ "print_help",
17
+ "render_help",
18
+ "render_status",
19
+ "build_status_renderable",
20
+ "CANONICAL_STAGES",
21
+ "reduce_enable_state",
22
+ "normalize_completed",
23
+ "next_reveal_target",
24
+ ]
@@ -0,0 +1,316 @@
1
+ """Enable scene — bold logo moment + live stage morph + quiet usage settle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Callable, Iterable, Optional
7
+
8
+ from rich.console import Console, Group, RenderableType
9
+ from rich.panel import Panel
10
+ from rich.text import Text
11
+
12
+ from shellmate.cli.version import VERSION
13
+ from shellmate.ui.runtime.console_factory import ConsoleFactory
14
+ from shellmate.ui.runtime.live_scene import LiveScene
15
+ from shellmate.ui.runtime.motion_policy import MotionPolicy, motion_from_env
16
+ from shellmate.ui.runtime import tokens
17
+ from shellmate.ui.scenes.stages import (
18
+ CANONICAL_STAGES,
19
+ EnableViewState,
20
+ StageStatus,
21
+ next_reveal_target,
22
+ normalize_completed,
23
+ reduce_enable_state,
24
+ )
25
+
26
+ # Compact logo (fits content max-width); dropped on narrow terminals.
27
+ _LOGO_LINES = [
28
+ r" ____ _ _ _ __ __ _ ",
29
+ r" / ___|| |__ ___| | | \/ | __ _| |_ ___ ",
30
+ r" \___ \| '_ \ / _ \ | | |\/| |/ _` | __/ _ \\",
31
+ r" ___) | | | | __/ | | | | | (_| | || __/",
32
+ r" |____/|_| |_|\___|_|_|_| |_|\__,_|\__\___|",
33
+ ]
34
+
35
+
36
+ def render_enable_frame(
37
+ state: EnableViewState,
38
+ *,
39
+ console_width: int,
40
+ ascii_safe: bool = False,
41
+ settled: bool = False,
42
+ ) -> RenderableType:
43
+ """Build one enable-scene frame. Width-aware; no fixed 110 panels."""
44
+ width = tokens.content_width(console_width)
45
+ icon = tokens.icons(ascii_safe=ascii_safe)
46
+ lines: list[Text | str] = []
47
+
48
+ if tokens.show_logo(console_width):
49
+ for row in _LOGO_LINES:
50
+ lines.append(Text(row, style=tokens.ACCENT))
51
+ lines.append("")
52
+ lines.append(Text(f"ShellMate v{VERSION}", style=tokens.MUTED))
53
+ else:
54
+ lines.append(Text(f"ShellMate v{VERSION}", style=f"bold {tokens.ACCENT}"))
55
+
56
+ lines.append("")
57
+ lines.append(Text("Startup", style=tokens.TITLE))
58
+
59
+ for stage in state.stages:
60
+ if stage.status == StageStatus.DONE:
61
+ mark = Text(f" {icon['done']} ", style=tokens.SUCCESS)
62
+ label = Text(stage.label, style=tokens.SUCCESS)
63
+ elif stage.status == StageStatus.RUNNING:
64
+ mark = Text(f" {icon['running']} ", style=tokens.ACCENT)
65
+ label = Text(stage.label, style=tokens.ACCENT)
66
+ elif stage.status == StageStatus.INCOMPLETE:
67
+ mark = Text(f" {icon['pending']} ", style=tokens.MUTED)
68
+ label = Text(stage.label, style=tokens.MUTED)
69
+ else:
70
+ mark = Text(f" {icon['pending']} ", style=tokens.MUTED)
71
+ label = Text(stage.label, style=tokens.MUTED)
72
+ lines.append(Text.assemble(mark, label))
73
+
74
+ if settled:
75
+ lines.append("")
76
+ if state.timed_out:
77
+ lines.append(Text("Startup timed out. Daemon may not be ready.", style=tokens.ERROR))
78
+ lines.append(Text("Run sm status to check daemon state.", style=tokens.MUTED))
79
+ else:
80
+ lines.append(Text("Active features", style=tokens.TITLE))
81
+ lines.append(Text.assemble(
82
+ Text(f" {icon['done']} ", style=tokens.SUCCESS),
83
+ Text("Autocomplete: active", style=tokens.SUCCESS),
84
+ ))
85
+ lines.append(Text.assemble(
86
+ Text(f" {icon['done']} ", style=tokens.SUCCESS),
87
+ Text("Consequence preview: active", style=tokens.SUCCESS),
88
+ ))
89
+ lines.append("")
90
+ lines.append(Text("Usage", style=tokens.TITLE))
91
+ from rich.table import Table
92
+ usage = Table(show_header=False, box=None, padding=(0, 2), collapse_padding=True)
93
+ usage.add_column(style=tokens.ACCENT, no_wrap=True)
94
+ usage.add_column(overflow="fold")
95
+ usage.add_row("sm status", "Show daemon status")
96
+ usage.add_row("sm disable", "Stop ShellMate")
97
+ lines.append(usage)
98
+
99
+ body = Group(*lines)
100
+ return Panel(
101
+ body,
102
+ title=f"[bold {tokens.ACCENT}]ShellMate[/] [dim]v{VERSION}[/]",
103
+ border_style=tokens.ACCENT,
104
+ padding=(1, 2),
105
+ width=width,
106
+ expand=False,
107
+ )
108
+
109
+
110
+ def _hold_last_completed(
111
+ prev: list[str],
112
+ incoming: Optional[Iterable[str]],
113
+ current: Optional[str],
114
+ ) -> list[str]:
115
+ """IPC blip (both None) keeps previous completed set — no reset flicker."""
116
+ if incoming is None and current is None:
117
+ return list(prev)
118
+ # incoming None but current set → legacy expand via normalize
119
+ return normalize_completed(incoming, current)
120
+
121
+
122
+ StagePoll = Callable[[], tuple[Optional[list[str]], Optional[str]]]
123
+ # Returns (startup_stages_completed | None on blip, startup_stage | None)
124
+
125
+
126
+ def run_enable_scene(
127
+ *,
128
+ poll: StagePoll,
129
+ console: Optional[Console] = None,
130
+ plain: bool = False,
131
+ ascii_safe: bool = False,
132
+ timeout: float = 30.0,
133
+ policy: Optional[MotionPolicy] = None,
134
+ sleep_fn: Callable[[float], None] = time.sleep,
135
+ poll_interval: float = 0.15,
136
+ ) -> bool:
137
+ """Live enable scene. Returns True if ready, False on timeout.
138
+
139
+ Animation failure never raises — falls back to static settle.
140
+ """
141
+ factory_plain = plain
142
+ if console is None:
143
+ cf = ConsoleFactory(no_color=plain, force_terminal=False if plain else None)
144
+ console = cf.create()
145
+ factory_plain = cf.is_plain() or plain
146
+
147
+ policy = policy or motion_from_env(plain=factory_plain)
148
+ ascii_safe = ascii_safe or factory_plain
149
+
150
+ scene = LiveScene(console, plain=factory_plain)
151
+ last_completed: list[str] = []
152
+ revealed = 0
153
+ ready = False
154
+ timed_out = False
155
+ artificial_spent_ms = 0.0
156
+ saw_ready_at: Optional[float] = None
157
+
158
+ initial = reduce_enable_state(completed=[], revealed_through=1)
159
+ try:
160
+ scene.start(render_enable_frame(initial, console_width=console.width, ascii_safe=ascii_safe))
161
+ except Exception:
162
+ pass
163
+
164
+ # Ensure at least the first stage shows as running while IPC is not up yet
165
+ revealed = 1
166
+ scene.update(
167
+ render_enable_frame(
168
+ reduce_enable_state(completed=[], revealed_through=1),
169
+ console_width=console.width,
170
+ ascii_safe=ascii_safe,
171
+ )
172
+ )
173
+
174
+ start = time.monotonic()
175
+ try:
176
+ while time.monotonic() - start < timeout:
177
+ try:
178
+ completed_raw, current = poll()
179
+ except Exception:
180
+ completed_raw, current = None, None
181
+
182
+ last_completed = _hold_last_completed(last_completed, completed_raw, current)
183
+ target = next_reveal_target(last_completed, current)
184
+ # Keep a live tip visible before the daemon answers
185
+ if target == 0:
186
+ target = 1
187
+
188
+ if "ready" in last_completed and saw_ready_at is None:
189
+ saw_ready_at = time.monotonic()
190
+
191
+ # Ordered reveal toward target
192
+ while revealed < target:
193
+ catching_up = target - revealed
194
+ remaining_budget_s = (
195
+ policy.clamp_artificial_budget(artificial_spent_ms)
196
+ if saw_ready_at is not None
197
+ else None
198
+ )
199
+ if saw_ready_at is not None and remaining_budget_s is not None and remaining_budget_s <= 0:
200
+ revealed = target
201
+ break
202
+
203
+ if saw_ready_at is not None:
204
+ # Daemon already ready — paced ordered replay, clamped by budget
205
+ step_delay = policy.dwell_seconds() + policy.stagger_seconds()
206
+ step_delay = min(step_delay, remaining_budget_s / catching_up)
207
+ elif catching_up > 1:
208
+ # Skipped ahead while polling — brief stagger between lines
209
+ step_delay = policy.stagger_seconds()
210
+ else:
211
+ # Live follow of real progress — tiny pause so the frame paints
212
+ step_delay = min(0.08, policy.stagger_seconds()) if not factory_plain else 0.0
213
+
214
+ revealed += 1
215
+ state = reduce_enable_state(
216
+ completed=last_completed,
217
+ current=current,
218
+ revealed_through=revealed,
219
+ )
220
+ frame = render_enable_frame(
221
+ state,
222
+ console_width=console.width,
223
+ ascii_safe=ascii_safe,
224
+ )
225
+ scene.update(frame)
226
+
227
+ if step_delay > 0:
228
+ sleep_fn(step_delay)
229
+ if saw_ready_at is not None:
230
+ artificial_spent_ms += step_delay * 1000.0
231
+
232
+ # Refresh current tip even when target unchanged (running spinner feel)
233
+ if not ("ready" in last_completed and revealed >= target):
234
+ state = reduce_enable_state(
235
+ completed=last_completed,
236
+ current=current,
237
+ revealed_through=max(revealed, 1),
238
+ )
239
+ scene.update(
240
+ render_enable_frame(
241
+ state,
242
+ console_width=console.width,
243
+ ascii_safe=ascii_safe,
244
+ )
245
+ )
246
+
247
+ if "ready" in last_completed:
248
+ if revealed >= target:
249
+ ready = True
250
+ break
251
+ sleep_fn(0.0 if factory_plain else poll_interval)
252
+
253
+ if not ready:
254
+ timed_out = True
255
+ except Exception:
256
+ timed_out = not ready
257
+
258
+ # Never invent success: only keys in last_completed are DONE.
259
+ final_state = reduce_enable_state(
260
+ completed=last_completed,
261
+ timed_out=timed_out,
262
+ revealed_through=len(CANONICAL_STAGES) if (timed_out or ready) else max(revealed, 1),
263
+ )
264
+
265
+ try:
266
+ scene.settle(
267
+ render_enable_frame(
268
+ final_state,
269
+ console_width=console.width,
270
+ ascii_safe=ascii_safe,
271
+ settled=True,
272
+ )
273
+ )
274
+ except Exception:
275
+ try:
276
+ console.print("[red]Startup finished with UI fallback.[/]")
277
+ except Exception:
278
+ pass
279
+
280
+ return ready and not timed_out
281
+
282
+
283
+ def demo_enable_animation(*, slow: bool = True) -> bool:
284
+ """Watch the enable morph without a real daemon (for manual testing).
285
+
286
+ Run::
287
+ python -c "from shellmate.ui.scenes.enable import demo_enable_animation; demo_enable_animation()"
288
+
289
+ Or slow real enable::
290
+ $env:SM_UI_SLOW=1; sm disable; sm enable
291
+ """
292
+ import os
293
+
294
+ keys = [k for k, _ in CANONICAL_STAGES]
295
+ state = {"i": 0, "t0": time.monotonic()}
296
+
297
+ def poll():
298
+ # Reveal one new stage every ~0.4s
299
+ elapsed = time.monotonic() - state["t0"]
300
+ n = min(len(keys), 1 + int(elapsed / 0.4))
301
+ state["i"] = n
302
+ completed = keys[:n]
303
+ return completed, completed[-1]
304
+
305
+ if slow:
306
+ os.environ.setdefault("SM_UI_SLOW", "1")
307
+
308
+ return run_enable_scene(
309
+ poll=poll,
310
+ timeout=20.0,
311
+ poll_interval=0.05,
312
+ )
313
+
314
+
315
+ if __name__ == "__main__":
316
+ demo_enable_animation()
@@ -0,0 +1,53 @@
1
+ """Help / usage scene — static, minimal (no logo)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console, Group
6
+ from rich.table import Table
7
+ from rich.text import Text
8
+
9
+ from shellmate.cli.version import VERSION
10
+ from shellmate.ui.runtime import tokens
11
+
12
+
13
+ def render_help(*, console_width: int | None = None) -> Group:
14
+ width_hint = console_width or tokens.CONTENT_MAX_WIDTH
15
+ _ = tokens.content_width(width_hint)
16
+
17
+ usage = Table(show_header=False, box=None, padding=(0, 2))
18
+ usage.add_column(style=tokens.ACCENT, no_wrap=True)
19
+ usage.add_column(overflow="fold")
20
+ rows = [
21
+ ("sm install", "Install shell hooks"),
22
+ ("sm config", "Configure providers and settings"),
23
+ ("sm enable", "Start the daemon for current directory"),
24
+ ("sm disable", "Stop the daemon and remove session files"),
25
+ ("sm status", "Show daemon, agent, and provider status"),
26
+ ("sm uninstall", "Remove shell hooks"),
27
+ ("sm --help", "Show this help message"),
28
+ ("sm --version", "Show version"),
29
+ ("sm --no-color <cmd>", "Plain text output (no animation)"),
30
+ ]
31
+ for cmd, desc in rows:
32
+ usage.add_row(cmd, desc)
33
+
34
+ return Group(
35
+ Text(f"ShellMate v{VERSION}", style=f"bold {tokens.ACCENT}"),
36
+ Text("Intelligent command safety layer", style=tokens.MUTED),
37
+ Text(""),
38
+ Text("Usage", style=tokens.TITLE),
39
+ usage,
40
+ Text(""),
41
+ Text("Examples", style=tokens.TITLE),
42
+ Text(" $ sm enable", style=tokens.MUTED),
43
+ Text(" $ sm status", style=tokens.MUTED),
44
+ Text(""),
45
+ Text("Run sm enable to start the daemon.", style=tokens.MUTED),
46
+ )
47
+
48
+
49
+ def print_help(console: Console | None = None) -> None:
50
+ from shellmate.ui.runtime.console_factory import ConsoleFactory
51
+
52
+ c = console or ConsoleFactory().create()
53
+ c.print(render_help(console_width=c.width))
@@ -0,0 +1,128 @@
1
+ """Canonical startup stages and pure reducer for enable-scene UI state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Iterable, Optional
8
+
9
+
10
+ # Canonical display order (UI labels). Daemon keys must match.
11
+ CANONICAL_STAGES: list[tuple[str, str]] = [
12
+ ("binding_ipc", "Binding IPC"),
13
+ ("watcher_starting", "Starting Watcher"),
14
+ ("watcher_ready", "Watcher ready"),
15
+ ("librarian_scanning", "Scanning project files"),
16
+ ("librarian_ready", "Librarian ready"),
17
+ ("providers_checked", "Checking providers"),
18
+ ("ready", "Ready"),
19
+ ]
20
+
21
+ STAGE_KEYS = [k for k, _ in CANONICAL_STAGES]
22
+ STAGE_LABELS = {k: label for k, label in CANONICAL_STAGES}
23
+
24
+
25
+ class StageStatus(str, Enum):
26
+ PENDING = "pending"
27
+ RUNNING = "running"
28
+ DONE = "done"
29
+ INCOMPLETE = "incomplete" # timeout — never paint false success
30
+
31
+
32
+ @dataclass
33
+ class StageView:
34
+ key: str
35
+ label: str
36
+ status: StageStatus
37
+
38
+
39
+ @dataclass
40
+ class EnableViewState:
41
+ stages: list[StageView] = field(default_factory=list)
42
+ ready: bool = False
43
+ timed_out: bool = False
44
+ revealed_count: int = 0
45
+
46
+
47
+ def expand_from_current(current: Optional[str]) -> list[str]:
48
+ """Legacy single-pointer protocol: current stage implies all prior milestones reached."""
49
+ if not current or current not in STAGE_LABELS:
50
+ return []
51
+ idx = STAGE_KEYS.index(current)
52
+ return list(STAGE_KEYS[: idx + 1])
53
+
54
+
55
+ def normalize_completed(completed: Iterable[str] | None, current: Optional[str] = None) -> list[str]:
56
+ """Merge completed list + current into canonical-ordered unique keys.
57
+
58
+ If ``completed`` is empty/None but ``current`` is set, expand legacy pointer.
59
+ """
60
+ raw_list = list(completed) if completed is not None else []
61
+ if not raw_list and current:
62
+ raw_list = expand_from_current(current)
63
+ seen: set[str] = set()
64
+ raw: list[str] = []
65
+ for key in raw_list + ([current] if current else []):
66
+ if key in STAGE_LABELS and key not in seen:
67
+ seen.add(key)
68
+ raw.append(key)
69
+ raw.sort(key=lambda k: STAGE_KEYS.index(k))
70
+ return raw
71
+
72
+
73
+ def next_reveal_target(completed: Iterable[str] | None, current: Optional[str] = None) -> int:
74
+ """Exclusive index into CANONICAL_STAGES the UI should reveal up to."""
75
+ ordered = normalize_completed(completed, current)
76
+ if not ordered:
77
+ return 0
78
+ return STAGE_KEYS.index(ordered[-1]) + 1
79
+
80
+
81
+ def reduce_enable_state(
82
+ *,
83
+ completed: Iterable[str] | None = None,
84
+ current: Optional[str] = None,
85
+ timed_out: bool = False,
86
+ revealed_through: Optional[int] = None,
87
+ ) -> EnableViewState:
88
+ """Pure reducer: daemon facts → UI stage list. Never invents success.
89
+
90
+ ``revealed_through`` caps how many canonical rows are visible (ordered animation).
91
+ If None, reveal through the latest known completed/current stage (or all on timeout).
92
+ """
93
+ completed_ordered = normalize_completed(completed, current)
94
+ completed_set = set(completed_ordered)
95
+ ready = "ready" in completed_set and not timed_out
96
+
97
+ if revealed_through is None:
98
+ if timed_out:
99
+ reveal_idx = len(CANONICAL_STAGES)
100
+ else:
101
+ reveal_idx = next_reveal_target(completed, current)
102
+ if reveal_idx == 0 and current:
103
+ reveal_idx = 1
104
+ else:
105
+ reveal_idx = max(0, min(int(revealed_through), len(CANONICAL_STAGES)))
106
+
107
+ stages: list[StageView] = []
108
+ for i, (key, label) in enumerate(CANONICAL_STAGES):
109
+ if i >= reveal_idx:
110
+ break
111
+
112
+ if key in completed_set:
113
+ status = StageStatus.DONE
114
+ elif timed_out:
115
+ status = StageStatus.INCOMPLETE
116
+ elif i == reveal_idx - 1:
117
+ status = StageStatus.RUNNING
118
+ else:
119
+ status = StageStatus.PENDING
120
+
121
+ stages.append(StageView(key=key, label=label, status=status))
122
+
123
+ return EnableViewState(
124
+ stages=stages,
125
+ ready=ready,
126
+ timed_out=timed_out,
127
+ revealed_count=reveal_idx,
128
+ )