@opengsd/gsd-pi 1.4.0-dev.5fc22c6f → 1.4.0-dev.c18009cd

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 (135) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +58 -4
  3. package/dist/resources/extensions/gsd/artifact-verification.js +17 -5
  4. package/dist/resources/extensions/gsd/auto/closeout.js +45 -42
  5. package/dist/resources/extensions/gsd/auto/finalize.js +9 -0
  6. package/dist/resources/extensions/gsd/auto/orchestrator.js +1 -1
  7. package/dist/resources/extensions/gsd/auto/run-unit.js +5 -2
  8. package/dist/resources/extensions/gsd/auto/schedule-wakeup.js +3 -0
  9. package/dist/resources/extensions/gsd/auto.js +10 -3
  10. package/dist/resources/extensions/gsd/bootstrap/schedule-wakeup-tool.js +58 -9
  11. package/dist/resources/extensions/gsd/doctor-engine-checks.js +174 -16
  12. package/dist/resources/extensions/gsd/gsd-db.js +52 -0
  13. package/dist/resources/extensions/gsd/migrate-external.js +13 -4
  14. package/dist/resources/extensions/gsd/milestone-merge-transaction.js +3 -3
  15. package/dist/resources/extensions/gsd/repo-identity.js +16 -0
  16. package/dist/resources/extensions/gsd/root-write-leak-guard.js +4 -2
  17. package/dist/resources/extensions/gsd/unit-registry.js +27 -2
  18. package/dist/resources/extensions/gsd/worktree-lifecycle.js +67 -5
  19. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  20. package/dist/web/standalone/.next/BUILD_ID +1 -1
  21. package/dist/web/standalone/.next/app-path-routes-manifest.json +6 -6
  22. package/dist/web/standalone/.next/build-manifest.json +2 -2
  23. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  24. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.html +1 -1
  41. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app-paths-manifest.json +6 -6
  48. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  49. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  50. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  51. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  52. package/integrations/hermes/CONTEXT.md +47 -0
  53. package/integrations/hermes/README.md +21 -0
  54. package/integrations/hermes/docs/issue-1162-grilling.md +197 -0
  55. package/integrations/hermes/docs/setup.md +4 -0
  56. package/integrations/hermes/open_gsd_hermes/commands.py +141 -0
  57. package/integrations/hermes/open_gsd_hermes/config.py +2 -0
  58. package/integrations/hermes/open_gsd_hermes/gsd_client.py +594 -31
  59. package/integrations/hermes/open_gsd_hermes/notifications.py +10 -1
  60. package/integrations/hermes/plugin.yaml +4 -0
  61. package/integrations/hermes/tests/test_config.py +6 -0
  62. package/integrations/hermes/tests/test_gsd_client_cache.py +3 -0
  63. package/integrations/hermes/tests/test_gsd_client_execute.py +35 -0
  64. package/integrations/hermes/tests/test_gsd_client_process_cwd.py +22 -0
  65. package/integrations/hermes/tests/test_gsd_client_timeout.py +98 -0
  66. package/integrations/hermes/tests/test_milestone_client.py +447 -0
  67. package/integrations/hermes/tests/test_new_milestone_command.py +313 -0
  68. package/integrations/hermes/tests/test_supervisor_fsm.py +40 -0
  69. package/package.json +1 -1
  70. package/packages/cloud-mcp-gateway/package.json +2 -2
  71. package/packages/contracts/package.json +1 -1
  72. package/packages/daemon/dist/session-manager.js +1 -1
  73. package/packages/daemon/dist/session-manager.js.map +1 -1
  74. package/packages/daemon/package.json +4 -4
  75. package/packages/gsd-agent-core/dist/session/agent-session-navigation.d.ts.map +1 -1
  76. package/packages/gsd-agent-core/dist/session/agent-session-navigation.js +4 -1
  77. package/packages/gsd-agent-core/dist/session/agent-session-navigation.js.map +1 -1
  78. package/packages/gsd-agent-core/package.json +5 -5
  79. package/packages/gsd-agent-modes/package.json +7 -7
  80. package/packages/mcp-server/dist/server.d.ts +18 -5
  81. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  82. package/packages/mcp-server/dist/server.js +46 -5
  83. package/packages/mcp-server/dist/server.js.map +1 -1
  84. package/packages/mcp-server/dist/session-manager.js +1 -1
  85. package/packages/mcp-server/dist/session-manager.js.map +1 -1
  86. package/packages/mcp-server/package.json +4 -4
  87. package/packages/native/package.json +1 -1
  88. package/packages/pi-agent-core/package.json +1 -1
  89. package/packages/pi-ai/package.json +1 -1
  90. package/packages/pi-coding-agent/dist/core/session-manager-types.d.ts +1 -0
  91. package/packages/pi-coding-agent/dist/core/session-manager-types.d.ts.map +1 -1
  92. package/packages/pi-coding-agent/dist/core/session-manager-types.js.map +1 -1
  93. package/packages/pi-coding-agent/dist/core/session-manager.d.ts.map +1 -1
  94. package/packages/pi-coding-agent/dist/core/session-manager.js +3 -0
  95. package/packages/pi-coding-agent/dist/core/session-manager.js.map +1 -1
  96. package/packages/pi-coding-agent/package.json +7 -7
  97. package/packages/pi-tui/package.json +2 -2
  98. package/packages/rpc-client/package.json +2 -2
  99. package/pkg/package.json +1 -1
  100. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +64 -3
  101. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +212 -39
  102. package/src/resources/extensions/gsd/artifact-verification.ts +16 -4
  103. package/src/resources/extensions/gsd/auto/closeout.ts +54 -56
  104. package/src/resources/extensions/gsd/auto/finalize.ts +8 -0
  105. package/src/resources/extensions/gsd/auto/loop-deps.ts +1 -0
  106. package/src/resources/extensions/gsd/auto/orchestrator.ts +1 -1
  107. package/src/resources/extensions/gsd/auto/run-unit.ts +5 -2
  108. package/src/resources/extensions/gsd/auto/schedule-wakeup.ts +8 -0
  109. package/src/resources/extensions/gsd/auto.ts +9 -3
  110. package/src/resources/extensions/gsd/bootstrap/schedule-wakeup-tool.ts +80 -12
  111. package/src/resources/extensions/gsd/doctor-engine-checks.ts +230 -21
  112. package/src/resources/extensions/gsd/gsd-db.ts +72 -0
  113. package/src/resources/extensions/gsd/migrate-external.ts +14 -4
  114. package/src/resources/extensions/gsd/milestone-merge-transaction.ts +4 -4
  115. package/src/resources/extensions/gsd/repo-identity.ts +17 -0
  116. package/src/resources/extensions/gsd/root-write-leak-guard.ts +3 -2
  117. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +131 -5
  118. package/src/resources/extensions/gsd/tests/auto-phases-lifecycle.test.ts +43 -1
  119. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +54 -0
  120. package/src/resources/extensions/gsd/tests/doctor-scope-db-unavailable.test.ts +335 -0
  121. package/src/resources/extensions/gsd/tests/merge-conflict-stops-loop.test.ts +4 -4
  122. package/src/resources/extensions/gsd/tests/migrate-external-worktree.test.ts +116 -0
  123. package/src/resources/extensions/gsd/tests/milestone-merge-stash-restore.test.ts +152 -22
  124. package/src/resources/extensions/gsd/tests/milestone-merge-transaction.test.ts +1 -1
  125. package/src/resources/extensions/gsd/tests/phases-merge-error-stops-auto.test.ts +50 -6
  126. package/src/resources/extensions/gsd/tests/root-write-leak-guard.test.ts +31 -9
  127. package/src/resources/extensions/gsd/tests/schedule-wakeup-tool.test.ts +167 -0
  128. package/src/resources/extensions/gsd/tests/state-corruption-2945.test.ts +1 -1
  129. package/src/resources/extensions/gsd/tests/unit-registry.test.ts +56 -0
  130. package/src/resources/extensions/gsd/tests/worktree-journal-events.test.ts +4 -4
  131. package/src/resources/extensions/gsd/tests/worktree-lifecycle.test.ts +2 -2
  132. package/src/resources/extensions/gsd/unit-registry.ts +34 -2
  133. package/src/resources/extensions/gsd/worktree-lifecycle.ts +135 -11
  134. /package/dist/web/standalone/.next/static/{kIEFsCryyGy6RzifULbJl → L2UMqXELiDH3oz-7yguXp}/_buildManifest.js +0 -0
  135. /package/dist/web/standalone/.next/static/{kIEFsCryyGy6RzifULbJl → L2UMqXELiDH3oz-7yguXp}/_ssgManifest.js +0 -0
@@ -52,12 +52,21 @@ class NotificationService:
52
52
 
53
53
  def notify_blocker(self, status: SessionStatus) -> None:
54
54
  blocker = status.pending_blocker or {}
55
- q = blocker.get("question") or blocker.get("prompt") or "Action required"
55
+ q = (
56
+ blocker.get("question")
57
+ or blocker.get("prompt")
58
+ or blocker.get("title")
59
+ or blocker.get("message")
60
+ or "Action required"
61
+ )
56
62
  self.send(f"🚧 GSD blocker: {q}\nReply with `/gsd reply <your answer>`", kind="blocker")
57
63
 
58
64
  def notify_transition(self, message: str) -> None:
59
65
  self.send(f"📋 GSD: {message}", kind="transition")
60
66
 
67
+ def notify_milestone_complete(self, message: str) -> None:
68
+ self.send(message, kind="complete")
69
+
61
70
  def notify_terminal(self, status: str, error: str | None = None) -> None:
62
71
  normalized_status = status.lower()
63
72
  if normalized_status in ("complete", "completed", "done"):
@@ -32,6 +32,10 @@ config_schema:
32
32
  cache_ttl_seconds:
33
33
  type: integer
34
34
  default: 45
35
+ mcp_read_timeout_seconds:
36
+ type: number
37
+ default: 60
38
+ description: Seconds to wait for one gsd-mcp-server response before resetting the MCP sidecar
35
39
  notification_level:
36
40
  type: string
37
41
  enum: [quiet, normal, verbose]
@@ -21,6 +21,12 @@ def test_dict_fallback_gsd_version_range_matches_plugin_release_train() -> None:
21
21
  assert config.gsd_version_max == "3.0"
22
22
 
23
23
 
24
+ def test_mcp_read_timeout_can_be_configured() -> None:
25
+ config = GsdConfig.from_dict({"gsd": {"mcp_read_timeout_seconds": 12.5}})
26
+
27
+ assert config.mcp_read_timeout_seconds == 12.5
28
+
29
+
24
30
  def test_config_path_respects_hermes_home(tmp_path: Path, monkeypatch) -> None:
25
31
  monkeypatch.delenv("HERMES_GSD_CONFIG", raising=False)
26
32
  monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home"))
@@ -70,6 +70,7 @@ def test_progress_falls_back_to_mcp_when_cli_binary_is_missing() -> None:
70
70
  "gsd_progress",
71
71
  {"projectDir": "/tmp/p"},
72
72
  check_version=False,
73
+ project_dir="/tmp/p",
73
74
  )
74
75
 
75
76
 
@@ -90,6 +91,7 @@ def test_progress_falls_back_to_mcp_when_cli_version_is_unsupported() -> None:
90
91
  "gsd_progress",
91
92
  {"projectDir": "/tmp/p"},
92
93
  check_version=False,
94
+ project_dir="/tmp/p",
93
95
  )
94
96
 
95
97
 
@@ -117,4 +119,5 @@ def test_progress_falls_back_to_mcp_when_cli_json_has_unexpected_shape() -> None
117
119
  "gsd_progress",
118
120
  {"projectDir": "/tmp/p"},
119
121
  check_version=False,
122
+ project_dir="/tmp/p",
120
123
  )
@@ -0,0 +1,35 @@
1
+ """Unit tests for MCP-backed GSD execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import patch
6
+
7
+ from open_gsd_hermes.config import GsdConfig
8
+ from open_gsd_hermes.gsd_client import GsdMcpClient, GsdVersionError
9
+
10
+
11
+ def test_execute_falls_back_to_mcp_when_cli_version_is_unsupported() -> None:
12
+ client = GsdMcpClient(GsdConfig())
13
+
14
+ with patch.object(client, "_call_tool") as call_tool:
15
+ call_tool.side_effect = [
16
+ GsdVersionError("old gsd"),
17
+ {"sessionId": "session-1"},
18
+ ]
19
+
20
+ result = client.execute("/tmp/p")
21
+
22
+ assert result == {"sessionId": "session-1"}
23
+ assert call_tool.call_args_list[0].args == (
24
+ "gsd_execute",
25
+ {"projectDir": "/tmp/p", "command": "/gsd auto"},
26
+ )
27
+ assert call_tool.call_args_list[0].kwargs == {"project_dir": "/tmp/p"}
28
+ assert call_tool.call_args_list[1].args == (
29
+ "gsd_execute",
30
+ {"projectDir": "/tmp/p", "command": "/gsd auto"},
31
+ )
32
+ assert call_tool.call_args_list[1].kwargs == {
33
+ "check_version": False,
34
+ "project_dir": "/tmp/p",
35
+ }
@@ -0,0 +1,22 @@
1
+ """Unit tests for MCP server process cwd selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ from open_gsd_hermes.config import GsdConfig
8
+ from open_gsd_hermes.gsd_client import GsdMcpClient
9
+
10
+
11
+ def test_ensure_process_starts_server_in_project_cwd(tmp_path) -> None:
12
+ client = GsdMcpClient(GsdConfig())
13
+ fake_proc = MagicMock()
14
+ fake_proc.poll.return_value = None
15
+
16
+ with patch.object(client, "_initialize") as initialize:
17
+ with patch("subprocess.Popen", return_value=fake_proc) as popen:
18
+ proc = client._ensure_process(str(tmp_path))
19
+
20
+ assert proc is fake_proc
21
+ initialize.assert_called_once_with()
22
+ assert popen.call_args.kwargs["cwd"] == str(tmp_path)
@@ -0,0 +1,98 @@
1
+ """Unit tests for MCP stdio read timeouts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ from unittest.mock import patch
8
+
9
+ import pytest
10
+
11
+ from open_gsd_hermes.config import GsdConfig
12
+ from open_gsd_hermes.gsd_client import GsdMcpClient, McpProtocolError
13
+
14
+
15
+ def test_read_message_times_out_waiting_for_headers() -> None:
16
+ client = GsdMcpClient(GsdConfig(mcp_read_timeout_seconds=0.05))
17
+ proc = subprocess.Popen(
18
+ [sys.executable, "-c", "import time; time.sleep(10)"],
19
+ stdin=subprocess.PIPE,
20
+ stdout=subprocess.PIPE,
21
+ stderr=subprocess.PIPE,
22
+ )
23
+ client._proc = proc
24
+
25
+ try:
26
+ with pytest.raises(McpProtocolError, match="timed out"):
27
+ client._read_message()
28
+ assert client._proc is None
29
+ assert proc.poll() is not None
30
+ finally:
31
+ proc.kill()
32
+
33
+
34
+ def test_read_message_times_out_waiting_for_body() -> None:
35
+ client = GsdMcpClient(GsdConfig(mcp_read_timeout_seconds=0.05))
36
+ proc = subprocess.Popen(
37
+ [
38
+ sys.executable,
39
+ "-c",
40
+ (
41
+ "import sys, time; "
42
+ "sys.stdout.buffer.write(b'Content-Length: 2\\r\\n\\r\\n{'); "
43
+ "sys.stdout.flush(); "
44
+ "time.sleep(10)"
45
+ ),
46
+ ],
47
+ stdin=subprocess.PIPE,
48
+ stdout=subprocess.PIPE,
49
+ stderr=subprocess.PIPE,
50
+ )
51
+ client._proc = proc
52
+
53
+ try:
54
+ with pytest.raises(McpProtocolError, match="timed out"):
55
+ client._read_message()
56
+ assert client._proc is None
57
+ assert proc.poll() is not None
58
+ finally:
59
+ proc.kill()
60
+
61
+
62
+ def test_read_message_fails_without_respawning_when_stdout_closes_mid_body(
63
+ tmp_path,
64
+ ) -> None:
65
+ client = GsdMcpClient(GsdConfig(mcp_read_timeout_seconds=1))
66
+ proc = subprocess.Popen(
67
+ [
68
+ sys.executable,
69
+ "-c",
70
+ (
71
+ "import sys; "
72
+ "sys.stdout.buffer.write(b'Content-Length: 20\\r\\n\\r\\n{'); "
73
+ "sys.stdout.flush()"
74
+ ),
75
+ ],
76
+ stdin=subprocess.PIPE,
77
+ stdout=subprocess.PIPE,
78
+ stderr=subprocess.PIPE,
79
+ )
80
+ client._proc = proc
81
+ client._proc_cwd = str(tmp_path)
82
+
83
+ try:
84
+ with patch.object(
85
+ client,
86
+ "_ensure_process",
87
+ side_effect=AssertionError("read must not respawn MCP server"),
88
+ ) as ensure_process:
89
+ with pytest.raises(McpProtocolError, match="closed stdout"):
90
+ client._read_message()
91
+
92
+ ensure_process.assert_not_called()
93
+ assert client._proc is None
94
+ assert client._proc_cwd is None
95
+ finally:
96
+ if proc.poll() is None:
97
+ proc.kill()
98
+ proc.wait(timeout=1)
@@ -0,0 +1,447 @@
1
+ """Tests for GsdMcpClient milestone creation (subprocess-owned, stream-driven).
2
+
3
+ These tests pin the A1 design from issue #1162's grilling:
4
+ - create_milestone spawns `gsd headless --supervised new-milestone`
5
+ - a background stream-reader drives NotificationService on blocker/terminal events
6
+ - cancel_milestone SIGTERMs the held subprocess
7
+ - respond_to_milestone_blocker writes an extension_ui_response to stdin
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from io import BytesIO
13
+ import json
14
+ import subprocess
15
+ from unittest.mock import MagicMock, patch
16
+
17
+ import pytest
18
+
19
+ from open_gsd_hermes.config import GsdConfig
20
+ from open_gsd_hermes.gsd_client import GsdMcpClient
21
+ from open_gsd_hermes.types import ProgressSnapshot
22
+
23
+
24
+ def _config() -> GsdConfig:
25
+ return GsdConfig(cli_path="/usr/local/bin/gsd")
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # create_milestone — argument construction
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ class _FakeProc:
34
+ """Minimal stand-in for subprocess.Popen used by create_milestone."""
35
+
36
+ def __init__(self) -> None:
37
+ self.pid = 4242
38
+ self.stdin = MagicMock()
39
+ self.stdout = MagicMock()
40
+ self.stderr = BytesIO()
41
+ self.poll = MagicMock(return_value=None)
42
+ self.terminate = MagicMock()
43
+ self.wait = MagicMock(return_value=0)
44
+ self.kill = MagicMock()
45
+
46
+
47
+ def test_create_milestone_builds_supervised_new_milestone_command_with_context_text() -> None:
48
+ """create_milestone must invoke `gsd headless --supervised new-milestone --context-text <spec>`."""
49
+ client = GsdMcpClient(_config())
50
+ fake_proc = _FakeProc()
51
+
52
+ captured: dict[str, object] = {}
53
+
54
+ def fake_popen(args, **kwargs): # type: ignore[no-untyped-def]
55
+ captured["args"] = args
56
+ captured["cwd"] = kwargs.get("cwd")
57
+ captured["env"] = kwargs.get("env")
58
+ return fake_proc
59
+
60
+ with patch.object(client, "ensure_version"):
61
+ with patch("subprocess.Popen", side_effect=fake_popen):
62
+ with patch.object(client, "_milestone_stream_loop"):
63
+ client.create_milestone("/proj", context_text="Build a REST API")
64
+
65
+ args = captured["args"]
66
+ assert args[0] == "/usr/local/bin/gsd"
67
+ assert "headless" in args
68
+ assert "--supervised" in args
69
+ assert "--response-timeout" in args
70
+ timeout_idx = args.index("--response-timeout")
71
+ assert args[timeout_idx + 1] == str(GsdMcpClient._SUPERVISED_RESPONSE_TIMEOUT_MS)
72
+ assert "new-milestone" in args
73
+ assert "--context-text" in args
74
+ text_idx = args.index("--context-text")
75
+ assert args[text_idx + 1] == "Build a REST API"
76
+ assert captured["cwd"] == "/proj"
77
+
78
+
79
+ def test_create_milestone_builds_command_with_context_file() -> None:
80
+ """--file form passes --context <path> instead of --context-text."""
81
+ client = GsdMcpClient(_config())
82
+ fake_proc = _FakeProc()
83
+ captured: dict[str, object] = {}
84
+
85
+ def fake_popen(args, **kwargs): # type: ignore[no-untyped-def]
86
+ captured["args"] = args
87
+ return fake_proc
88
+
89
+ with patch.object(client, "ensure_version"):
90
+ with patch("subprocess.Popen", side_effect=fake_popen):
91
+ with patch.object(client, "_milestone_stream_loop"):
92
+ client.create_milestone("/proj", context_file="/path/to/spec.md")
93
+
94
+ args = captured["args"]
95
+ assert "--context" in args
96
+ ctx_idx = args.index("--context")
97
+ assert args[ctx_idx + 1] == "/path/to/spec.md"
98
+ assert "--context-text" not in args
99
+
100
+
101
+ def test_create_milestone_requires_either_context_text_or_file() -> None:
102
+ """No spec at all is a usage error, raised before any subprocess spawns."""
103
+ client = GsdMcpClient(_config())
104
+ with pytest.raises(ValueError, match="spec"):
105
+ client.create_milestone("/proj")
106
+
107
+
108
+ def test_create_milestone_rejects_both_context_text_and_file() -> None:
109
+ """Ambiguous input (both forms) is a usage error."""
110
+ client = GsdMcpClient(_config())
111
+ with pytest.raises(ValueError, match="spec"):
112
+ client.create_milestone(
113
+ "/proj", context_text="x", context_file="/p.md"
114
+ )
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Stream reader — sessionId capture, blocker, terminal
119
+ # ---------------------------------------------------------------------------
120
+
121
+
122
+ def test_create_milestone_returns_local_session_id_from_pid() -> None:
123
+ """create_milestone returns a local session id without waiting for init_result."""
124
+ client = GsdMcpClient(_config())
125
+ fake_proc = _FakeProc()
126
+
127
+ with patch.object(client, "ensure_version"):
128
+ with patch("subprocess.Popen", return_value=fake_proc):
129
+ with patch.object(client, "_milestone_stream_loop"):
130
+ session_id = client.create_milestone("/proj", context_text="spec")
131
+
132
+ assert session_id == "milestone-4242"
133
+
134
+
135
+ def test_stream_loop_captures_session_id_from_init_result() -> None:
136
+ """The reader must pull sessionId out of the init_result stream event."""
137
+ client = GsdMcpClient(_config())
138
+ client._handle_milestone_event({"type": "init_result", "sessionId": "S-789"})
139
+
140
+ assert client.milestone_session_id() == "S-789"
141
+
142
+
143
+ def test_stream_loop_notifies_blocker_on_supervised_select_request() -> None:
144
+ """Supervised planning prompts (select/input/confirm) must fire notify_blocker."""
145
+ client = GsdMcpClient(_config())
146
+ notifications = MagicMock()
147
+ blocker_event = {
148
+ "type": "extension_ui_request",
149
+ "method": "select",
150
+ "id": "sel-1",
151
+ "title": "Choose milestone scope",
152
+ "options": ["A", "B"],
153
+ }
154
+ with patch.object(client, "_milestone_notifications", notifications):
155
+ client._handle_milestone_event(blocker_event)
156
+
157
+ notifications.notify_blocker.assert_called_once()
158
+ assert client.milestone_pending_blocker_id() == "sel-1"
159
+
160
+
161
+ def test_stream_loop_notifies_blocker_on_extension_ui_request() -> None:
162
+ """Blocked-notice notify events are fire-and-forget; they must not prompt /gsd reply."""
163
+ client = GsdMcpClient(_config())
164
+ notifications = MagicMock()
165
+ # Realistic blocked-notice message — headless auto-acks notify in supervised mode.
166
+ blocker_event = {
167
+ "type": "extension_ui_request",
168
+ "method": "notify",
169
+ "id": "blk-1",
170
+ "message": "Auto-mode paused — blocked: choose an option",
171
+ }
172
+ with patch.object(client, "_milestone_notifications", notifications):
173
+ client._handle_milestone_event(blocker_event)
174
+
175
+ notifications.notify_blocker.assert_not_called()
176
+ assert client.milestone_pending_blocker_id() is None
177
+
178
+
179
+ def test_build_milestone_completion_message_uses_query_and_progress() -> None:
180
+ client = GsdMcpClient(_config())
181
+ with patch.object(
182
+ client,
183
+ "project_query",
184
+ return_value={"milestones": [{"id": "M002", "hasRoadmap": True}]},
185
+ ):
186
+ with patch.object(
187
+ client,
188
+ "progress",
189
+ return_value=ProgressSnapshot(
190
+ active_milestone={"id": "M002"},
191
+ slices={"total": 3},
192
+ tasks={"total": 9},
193
+ ),
194
+ ):
195
+ msg = client._build_milestone_completion_message("/proj")
196
+
197
+ assert msg == "✅ Milestone M002 ready — 3 slices, 9 tasks. Run `/gsd auto` to start."
198
+
199
+
200
+ def test_build_milestone_completion_message_falls_back_when_query_empty() -> None:
201
+ client = GsdMcpClient(_config())
202
+ with patch.object(client, "project_query", return_value={"milestones": []}):
203
+ msg = client._build_milestone_completion_message("/proj")
204
+
205
+ assert msg == "✅ Milestone created. Run `/gsd status` for details."
206
+
207
+
208
+ def test_stream_loop_notifies_terminal_on_process_exit() -> None:
209
+ """When the stream ends (process exited), a milestone completion notification fires."""
210
+ client = GsdMcpClient(_config())
211
+ notifications = MagicMock()
212
+ fake_proc = _FakeProc()
213
+ fake_proc.poll.return_value = 0
214
+ fake_proc.stdout.readline.side_effect = [b""]
215
+ client._milestone_session_id = "milestone-4242"
216
+ client._milestone_pending_blocker_id = "old-blocker"
217
+ client._milestone_pending_blocker_method = "input"
218
+
219
+ with patch.object(client, "_milestone_proc", fake_proc):
220
+ with patch.object(client, "_milestone_project_dir", "/proj"):
221
+ with patch.object(
222
+ client,
223
+ "_build_milestone_completion_message",
224
+ return_value="✅ Milestone M001 ready — 2 slices, 5 tasks. Run `/gsd auto` to start.",
225
+ ):
226
+ with patch.object(client, "_milestone_notifications", notifications):
227
+ client._milestone_stream_loop(fake_proc)
228
+
229
+ notifications.notify_milestone_complete.assert_called_once_with(
230
+ "✅ Milestone M001 ready — 2 slices, 5 tasks. Run `/gsd auto` to start."
231
+ )
232
+ notifications.notify_terminal.assert_not_called()
233
+ assert client.milestone_active() is False
234
+ assert client.milestone_session_id() is None
235
+ assert client.milestone_pending_blocker_id() is None
236
+ assert client._milestone_pending_blocker_method is None
237
+
238
+
239
+ def test_stream_loop_failure_reports_drained_stderr() -> None:
240
+ """Failure notifications must use stderr retained by the drain thread."""
241
+ client = GsdMcpClient(_config())
242
+ notifications = MagicMock()
243
+ fake_proc = _FakeProc()
244
+ fake_proc.poll.return_value = 2
245
+ fake_proc.stderr = BytesIO(b"fatal diagnostics\n")
246
+ fake_proc.stdout.readline.side_effect = [b""]
247
+
248
+ client._drain_milestone_stderr(fake_proc)
249
+ with patch.object(client, "_milestone_proc", fake_proc):
250
+ with patch.object(client, "_milestone_notifications", notifications):
251
+ client._milestone_stream_loop(fake_proc)
252
+
253
+ notifications.notify_terminal.assert_called_once_with(
254
+ "failed", "fatal diagnostics"
255
+ )
256
+
257
+
258
+ def test_stream_loop_releases_blocked_exit_with_pending_blocker() -> None:
259
+ """Exit 10 after stdout EOF is terminal even with a pending blocker id."""
260
+ client = GsdMcpClient(_config())
261
+ notifications = MagicMock()
262
+ fake_proc = _FakeProc()
263
+ fake_proc.poll.return_value = 10
264
+ fake_proc.stdout.readline.side_effect = [b""]
265
+ client._milestone_session_id = "milestone-4242"
266
+
267
+ with patch.object(client, "_milestone_proc", fake_proc):
268
+ with patch.object(client, "_milestone_pending_blocker_id", "sel-1"):
269
+ with patch.object(client, "_milestone_notifications", notifications):
270
+ client._milestone_stream_loop(fake_proc)
271
+
272
+ notifications.notify_terminal.assert_called_once_with("failed", "Planning blocked")
273
+ assert client.milestone_active() is False
274
+ assert client.milestone_session_id() is None
275
+
276
+
277
+ def test_stream_loop_releases_noninteractive_blocked_exit() -> None:
278
+ """Exit 10 without a pending blocker must not leave milestone ownership stuck."""
279
+ client = GsdMcpClient(_config())
280
+ notifications = MagicMock()
281
+ fake_proc = _FakeProc()
282
+ fake_proc.poll.return_value = 10
283
+ fake_proc.stdout.readline.side_effect = [b""]
284
+ client._milestone_session_id = "milestone-4242"
285
+
286
+ with patch.object(client, "_milestone_proc", fake_proc):
287
+ with patch.object(client, "_milestone_notifications", notifications):
288
+ client._milestone_stream_loop(fake_proc)
289
+
290
+ notifications.notify_terminal.assert_called_once_with("failed", "Planning blocked")
291
+ assert client.milestone_active() is False
292
+ assert client.milestone_session_id() is None
293
+
294
+
295
+ def test_stream_loop_retains_running_proc_on_stream_loss() -> None:
296
+ """If stdout closes while the child is alive, /gsd cancel must still work."""
297
+ client = GsdMcpClient(_config())
298
+ notifications = MagicMock()
299
+ on_terminal = MagicMock()
300
+ fake_proc = _FakeProc()
301
+ fake_proc.poll.return_value = None
302
+ fake_proc.wait.side_effect = subprocess.TimeoutExpired(cmd="gsd", timeout=5)
303
+ fake_proc.stdout.readline.side_effect = [b""]
304
+ client._milestone_session_id = "milestone-4242"
305
+
306
+ with patch.object(client, "_milestone_proc", fake_proc):
307
+ with patch.object(client, "_milestone_notifications", notifications):
308
+ with patch.object(client, "_milestone_on_terminal", on_terminal):
309
+ client._milestone_stream_loop(fake_proc)
310
+
311
+ notifications.notify_terminal.assert_called_once_with(
312
+ "failed", "stream lost — run /gsd cancel"
313
+ )
314
+ on_terminal.assert_called_once_with("failed")
315
+ assert client.milestone_active() is True
316
+ assert client.milestone_session_id() == "milestone-4242"
317
+
318
+ client.cancel_milestone()
319
+
320
+ fake_proc.terminate.assert_called_once()
321
+ assert client.milestone_active() is False
322
+
323
+
324
+ def test_stream_loop_handles_blocking_command_block() -> None:
325
+ """gsd-command-block failures must not be reported as milestone complete."""
326
+ client = GsdMcpClient(_config())
327
+ notifications = MagicMock()
328
+ fake_proc = _FakeProc()
329
+ client._milestone_session_id = "milestone-4242"
330
+ command_block = {
331
+ "type": "message_start",
332
+ "message": {
333
+ "role": "custom",
334
+ "customType": "gsd-command-block",
335
+ "content": (
336
+ "/gsd auto cannot run because the active milestone "
337
+ "is blocked by validation."
338
+ ),
339
+ },
340
+ }
341
+ fake_proc.poll.return_value = 0
342
+ fake_proc.stdout.readline.side_effect = [
343
+ (json.dumps(command_block) + "\n").encode(),
344
+ b"",
345
+ ]
346
+
347
+ with patch.object(client, "_milestone_proc", fake_proc):
348
+ with patch.object(client, "_milestone_notifications", notifications):
349
+ client._milestone_stream_loop(fake_proc)
350
+
351
+ notifications.notify_terminal.assert_called_with(
352
+ "failed",
353
+ "/gsd auto cannot run because the active milestone is blocked by validation.",
354
+ )
355
+ notifications.notify_milestone_complete.assert_not_called()
356
+ assert client.milestone_active() is False
357
+ assert client.milestone_session_id() is None
358
+
359
+
360
+ # ---------------------------------------------------------------------------
361
+ # cancel_milestone / respond_to_milestone_blocker / milestone_active
362
+ # ---------------------------------------------------------------------------
363
+
364
+
365
+ def test_cancel_milestone_terminates_held_subprocess() -> None:
366
+ client = GsdMcpClient(_config())
367
+ fake_proc = _FakeProc()
368
+
369
+ with patch.object(client, "_milestone_proc", fake_proc):
370
+ client.cancel_milestone()
371
+
372
+ fake_proc.terminate.assert_called_once()
373
+
374
+
375
+ def test_cancel_milestone_when_no_proc_is_a_noop() -> None:
376
+ """Calling cancel with no active milestone proc must not raise."""
377
+ client = GsdMcpClient(_config())
378
+ # should not raise
379
+ client.cancel_milestone()
380
+
381
+
382
+ def test_close_terminates_active_milestone_subprocess() -> None:
383
+ """close() must tear down the owned milestone subprocess as well as MCP."""
384
+ client = GsdMcpClient(_config())
385
+ fake_proc = _FakeProc()
386
+
387
+ with (
388
+ patch.object(client, "_terminate_process"),
389
+ patch.object(client, "_milestone_proc", fake_proc),
390
+ ):
391
+ client.close()
392
+
393
+ fake_proc.terminate.assert_called_once()
394
+ assert client.milestone_active() is False
395
+
396
+
397
+ def test_respond_to_milestone_blocker_writes_extension_ui_response_to_stdin() -> None:
398
+ """The reply must write the supervised-mode stdin JSONL protocol."""
399
+ client = GsdMcpClient(_config())
400
+ fake_proc = _FakeProc()
401
+
402
+ with patch.object(client, "_milestone_proc", fake_proc):
403
+ with patch.object(client, "_milestone_pending_blocker_id", "blk-7"):
404
+ with patch.object(client, "_milestone_pending_blocker_method", "input"):
405
+ client.respond_to_milestone_blocker("use option B")
406
+
407
+ assert fake_proc.stdin.write.called
408
+ written = fake_proc.stdin.write.call_args[0][0]
409
+ payload = json.loads(written.decode() if isinstance(written, bytes) else written)
410
+ assert payload["type"] == "extension_ui_response"
411
+ assert payload["id"] == "blk-7"
412
+ assert payload["value"] == "use option B"
413
+
414
+
415
+ def test_respond_to_milestone_blocker_writes_confirmed_for_confirm_prompt() -> None:
416
+ """Confirm blockers must answer with a confirmed boolean, not free text."""
417
+ client = GsdMcpClient(_config())
418
+ fake_proc = _FakeProc()
419
+
420
+ with patch.object(client, "_milestone_proc", fake_proc):
421
+ with patch.object(client, "_milestone_pending_blocker_id", "cfm-1"):
422
+ with patch.object(client, "_milestone_pending_blocker_method", "confirm"):
423
+ client.respond_to_milestone_blocker("yes")
424
+
425
+ written = fake_proc.stdin.write.call_args[0][0]
426
+ payload = json.loads(written.decode() if isinstance(written, bytes) else written)
427
+ assert payload["type"] == "extension_ui_response"
428
+ assert payload["id"] == "cfm-1"
429
+ assert payload["confirmed"] is True
430
+ assert "value" not in payload
431
+
432
+
433
+ def test_respond_to_milestone_blocker_without_pending_blocker_raises() -> None:
434
+ """No pending blocker id means nothing to reply to — fail loud, don't pretend."""
435
+ client = GsdMcpClient(_config())
436
+ fake_proc = _FakeProc()
437
+ with patch.object(client, "_milestone_proc", fake_proc):
438
+ with pytest.raises(RuntimeError, match="No pending blocker"):
439
+ client.respond_to_milestone_blocker("answer")
440
+
441
+
442
+ def test_milestone_active_reflects_held_proc() -> None:
443
+ client = GsdMcpClient(_config())
444
+ assert client.milestone_active() is False
445
+ with patch.object(client, "_milestone_proc", _FakeProc()):
446
+ assert client.milestone_active() is True
447
+ assert client.milestone_active() is False