vmware-debug 1.11.2__tar.gz → 1.11.3__tar.gz

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 (87) hide show
  1. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/.claude-plugin/plugin.json +1 -1
  2. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/.mcp.json +1 -1
  3. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/PKG-INFO +2 -2
  4. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/RELEASE_NOTES.md +36 -0
  5. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/pyproject.toml +2 -2
  6. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/server.json +2 -2
  7. vmware_debug-1.11.3/tests/__init__.py +9 -0
  8. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/capability/_scoring.py +2 -2
  9. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/regression/test_capability_grader.py +3 -3
  10. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/regression/test_environment_resolver_isolation.py +1 -1
  11. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/regression/test_investigation_metrics.py +1 -1
  12. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/test_case_conclusion.py +5 -5
  13. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/test_case_evidence.py +2 -2
  14. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/test_case_grading.py +5 -5
  15. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/test_case_hypotheses.py +1 -1
  16. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/test_case_readiness.py +1 -1
  17. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/test_case_store.py +5 -5
  18. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/test_case_timeline_close.py +2 -2
  19. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/test_knowledge.py +1 -1
  20. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/uv.lock +5 -5
  21. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/__init__.py +1 -1
  22. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/cli.py +25 -1
  23. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/ops/cases/conclusion.py +5 -5
  24. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/ops/cases/evidence.py +4 -4
  25. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/ops/cases/hypotheses.py +3 -3
  26. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/ops/cases/knowledge.py +1 -1
  27. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/ops/cases/store.py +4 -4
  28. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/ops/cases/timeline.py +4 -4
  29. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/.gitignore +0 -0
  30. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/README-CN.md +0 -0
  31. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/README.md +0 -0
  32. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/SECURITY.md +0 -0
  33. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/docs/plans/2026-08-28-investigation-layer-design.md +0 -0
  34. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/skills/vmware-debug/SKILL.md +0 -0
  35. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/skills/vmware-debug/references/agent-guardrails.md +0 -0
  36. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/skills/vmware-debug/references/capabilities.md +0 -0
  37. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/skills/vmware-debug/references/cli-reference.md +0 -0
  38. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/skills/vmware-debug/references/event-envelope.md +0 -0
  39. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/skills/vmware-debug/references/routing.md +0 -0
  40. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/skills/vmware-debug/references/setup-guide.md +0 -0
  41. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/__init__.py +0 -0
  42. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/capability/__init__.py +0 -0
  43. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/capability/_family.py +0 -0
  44. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/capability/_scores.json +0 -0
  45. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/capability/_skill.py +0 -0
  46. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/capability/conftest.py +0 -0
  47. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/capability/test_entity_reachability.py +0 -0
  48. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/capability/test_error_actionability.py +0 -0
  49. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/capability/test_tool_description_quality.py +0 -0
  50. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/capability/test_tool_manifest_budget.py +0 -0
  51. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/regression/__init__.py +0 -0
  52. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/regression/_vsphere_event_vocabulary.py +0 -0
  53. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/regression/test_case_id_non_ascii.py +0 -0
  54. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/regression/test_case_plan_max_steps.py +0 -0
  55. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/regression/test_classification_against_vsphere_vocabulary.py +0 -0
  56. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/regression/test_debug_regressions.py +0 -0
  57. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/regression/test_every_tool_parameter_is_documented.py +0 -0
  58. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/regression/test_evidence_payload_contract.py +0 -0
  59. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/regression/test_host_lifecycle_classification.py +0 -0
  60. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/regression/test_result_envelope.py +0 -0
  61. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/regression/test_skill_name_aliases.py +0 -0
  62. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/regression/test_spike_resolution.py +0 -0
  63. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/eval/regression/test_tool_annotations.py +0 -0
  64. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/test_case_ids.py +0 -0
  65. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/test_case_plan.py +0 -0
  66. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/test_case_tools.py +0 -0
  67. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/test_safe_error_passthrough.py +0 -0
  68. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/tests/test_timeline.py +0 -0
  69. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/envelope.py +0 -0
  70. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/mcp/__init__.py +0 -0
  71. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/mcp/tools.py +0 -0
  72. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/mcp_server/__init__.py +0 -0
  73. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/mcp_server/server.py +0 -0
  74. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/ops/__init__.py +0 -0
  75. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/ops/cases/__init__.py +0 -0
  76. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/ops/cases/api.py +0 -0
  77. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/ops/cases/grading.py +0 -0
  78. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/ops/cases/ids.py +0 -0
  79. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/ops/cases/model.py +0 -0
  80. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/ops/cases/payloads.py +0 -0
  81. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/ops/cases/plan.py +0 -0
  82. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/ops/cases/readiness.py +0 -0
  83. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/ops/cases/sources.py +0 -0
  84. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/ops/skill_names.py +0 -0
  85. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/ops/timeline.py +0 -0
  86. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/rules/evidence_sources.yaml +0 -0
  87. {vmware_debug-1.11.2 → vmware_debug-1.11.3}/vmware_debug/rules/grading_rules.yaml +0 -0
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "vmware-debug",
4
4
  "displayName": "VMware Debug",
5
- "version": "1.11.2",
5
+ "version": "1.11.3",
6
6
  "description": "Read-only VMware incident correlation: timeline, spikes, root-cause routing. 2 MCP tools.",
7
7
  "author": {
8
8
  "name": "Wei Zhou",
@@ -5,7 +5,7 @@
5
5
  "command": "uvx",
6
6
  "args": [
7
7
  "--from",
8
- "vmware-debug==1.11.2",
8
+ "vmware-debug==1.11.3",
9
9
  "vmware-debug-mcp"
10
10
  ]
11
11
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vmware-debug
3
- Version: 1.11.2
3
+ Version: 1.11.3
4
4
  Summary: VMware diagnostic brain — read-only incident triage, log/event correlation, and root-cause routing across the VMware skill family
5
5
  Project-URL: Homepage, https://github.com/vmware-skills/VMware-Debug
6
6
  Project-URL: Repository, https://github.com/vmware-skills/VMware-Debug
@@ -18,7 +18,7 @@ Requires-Dist: mcp[cli]<2.0,>=1.10
18
18
  Requires-Dist: pyyaml<7.0,>=6.0
19
19
  Requires-Dist: rich<15.0,>=13.0
20
20
  Requires-Dist: typer<1.0,>=0.12
21
- Requires-Dist: vmware-policy<2.0,>=1.11.0
21
+ Requires-Dist: vmware-policy<2.0,>=1.12.0
22
22
  Description-Content-Type: text/markdown
23
23
 
24
24
  <!-- mcp-name: io.github.vmware-skills/vmware-debug -->
@@ -1,3 +1,39 @@
1
+ ## v1.11.3 — 398 tests never ran
2
+
3
+
4
+ **`tests/__init__.py` was missing, and the whole suite -- 398 tests -- failed at
5
+ collection.** `tests/eval/` and `tests/eval/regression/` were already packages,
6
+ so `from tests.eval.regression._vsphere_event_vocabulary import ...` resolved
7
+ only where the namespace-package fallback happened to work. It did not on
8
+ Windows. One file.
9
+
10
+ **The suite now runs on a cp936 machine.** Round 3 of the VCF 9 field testing ran
11
+ on Windows Server 2025 with locale cp936. Across the family four repos' suites --
12
+ 1687 tests -- never executed at all, dying at collection reading our own UTF-8
13
+ sources, and 101 more failed the same way. Most of those were the tests that
14
+ verify the destructive-operation guardrails: the guardrails were fine, the tests
15
+ that check them could not open a file. On the UTF-8 CI every one of them was
16
+ green. A security test that cannot run is not a security test.
17
+
18
+ Every text read and write here names its encoding now, `tests/` included -- the
19
+ previous round fixed only the package, which is why this came back. A gate in
20
+ `family_smoke` scans both trees by AST, and the whole family's suites were re-run
21
+ under an ASCII locale to confirm: 15 of 15 green, from 1 of 15.
22
+
23
+ **`--help` no longer dies on a console that cannot encode it.** On any console
24
+ whose encoding cannot carry the characters in our own help text, `--help` exited
25
+ with a `UnicodeEncodeError` traceback -- unavailable exactly on the machines
26
+ where it is most needed. Four repos were affected; the handler is now relaxed in
27
+ all fifteen so a glyph degrades instead of killing the command.
28
+
29
+ **Unknown tool arguments are refused instead of dropped.** The schema declared
30
+ `additionalProperties: false` and the runtime accepted them anyway, so a filter
31
+ argument whose name a model guessed wrong returned the *unfiltered* result with
32
+ nothing to indicate anything had been discarded. Fixed in vmware-policy 1.12.0
33
+ and in force here.
34
+
35
+ Requires vmware-policy 1.12.0.
36
+
1
37
  ## v1.11.2 — the resolver hijack, the discarded event identifier, and a parameter that did nothing
2
38
 
3
39
  Three findings from the 2026-08-30 real-hardware re-test.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "vmware-debug"
7
- version = "1.11.2"
7
+ version = "1.11.3"
8
8
  description = "VMware diagnostic brain — read-only incident triage, log/event correlation, and root-cause routing across the VMware skill family"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -21,7 +21,7 @@ dependencies = [
21
21
  "typer>=0.12,<1.0",
22
22
  "rich>=13.0,<15.0",
23
23
  "mcp[cli]>=1.10,<2.0",
24
- "vmware-policy>=1.11.0,<2.0",
24
+ "vmware-policy>=1.12.0,<2.0",
25
25
  # Imported directly by ops/cases/grading.py for the rules file. It has
26
26
  # always arrived transitively via vmware-policy; a package should
27
27
  # declare what it imports rather than rely on a dependency's dependency.
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/vmware-skills/VMware-Debug",
8
8
  "source": "github"
9
9
  },
10
- "version": "1.11.2",
10
+ "version": "1.11.3",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "vmware-debug",
15
- "version": "1.11.2",
15
+ "version": "1.11.3",
16
16
  "runtimeHint": "uvx",
17
17
  "packageArguments": [
18
18
  {
@@ -0,0 +1,9 @@
1
+ """Test package.
2
+
3
+ ``tests/eval/`` and ``tests/eval/regression/`` were already packages while this
4
+ file was missing, so ``from tests.eval.regression._vsphere_event_vocabulary
5
+ import ...`` resolved only where the namespace-package fallback happened to
6
+ work. It did not on Windows, and the whole suite -- 398 tests -- failed at
7
+ collection. The other repos that use cross-module test imports all carry this
8
+ file; this one is the half-built package.
9
+ """
@@ -132,13 +132,13 @@ class ScoreBoard:
132
132
  ),
133
133
  "scores": merged,
134
134
  }
135
- path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
135
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
136
136
 
137
137
 
138
138
  def previous_scores(path: Path = SCORES_PATH) -> dict[str, Any]:
139
139
  """Load the last recorded run, or ``{}`` on a first run / unreadable file."""
140
140
  try:
141
- return json.loads(path.read_text()).get("scores", {})
141
+ return json.loads(path.read_text(encoding="utf-8")).get("scores", {})
142
142
  except (OSError, ValueError):
143
143
  return {}
144
144
 
@@ -184,7 +184,7 @@ def test_an_error_returned_via_a_local_is_still_seen(tmp_path):
184
184
  " except Exception as exc:\n"
185
185
  " payload = {'error': str(exc), 'hint': _HINT}\n"
186
186
  " return payload\n"
187
- )
187
+ , encoding="utf-8")
188
188
  sites = list(_error_returns_in_server(server_dir=tmp_path, module=None))
189
189
 
190
190
  assert len(sites) == 2, f"a returned local was not seen: {sites}"
@@ -219,7 +219,7 @@ def test_a_partial_run_does_not_erase_metrics_it_did_not_measure(tmp_path):
219
219
  partial.add(Score(name="alpha", value=2, maximum=2))
220
220
  partial.write(path)
221
221
 
222
- scores = json.loads(path.read_text())["scores"]
222
+ scores = json.loads(path.read_text(encoding="utf-8"))["scores"]
223
223
  assert set(scores) == {"alpha", "beta"}, "a partial run deleted a metric"
224
224
  assert scores["alpha"]["value"] == 2, "the fresh measurement must win"
225
225
  assert not scores["alpha"].get("stale"), "a freshly measured metric is not stale"
@@ -238,7 +238,7 @@ def test_an_empty_run_leaves_the_baseline_alone(tmp_path):
238
238
  seeded.write(path)
239
239
 
240
240
  ScoreBoard().write(path)
241
- assert set(json.loads(path.read_text())["scores"]) == {"alpha"}
241
+ assert set(json.loads(path.read_text(encoding="utf-8"))["scores"]) == {"alpha"}
242
242
 
243
243
 
244
244
  def test_credits_a_real_subcommand_under_a_group():
@@ -51,7 +51,7 @@ def _sibling_resolver(target: str) -> str | None:
51
51
  @pytest.fixture
52
52
  def engine(tmp_path):
53
53
  path = tmp_path / "rules.yaml"
54
- path.write_text(_RULES)
54
+ path.write_text(_RULES, encoding="utf-8")
55
55
  return PolicyEngine(path)
56
56
 
57
57
 
@@ -233,7 +233,7 @@ def test_every_unreachable_source_carries_a_remedy():
233
233
  def _kb(tmp_path, name, applies_block):
234
234
  p = tmp_path / "vmware" / "knowledge" / "kb" / name
235
235
  p.parent.mkdir(parents=True, exist_ok=True)
236
- p.write_text(f"---\nid: {name}\n{applies_block}---\nbody\n")
236
+ p.write_text(f"---\nid: {name}\n{applies_block}---\nbody\n", encoding="utf-8")
237
237
 
238
238
 
239
239
  def _scoped_case(versions):
@@ -54,18 +54,18 @@ class TestRecording:
54
54
 
55
55
  def test_conclusion_md_gains_an_entry(self, case):
56
56
  record_grade(case, grade_case(case), at="2026-08-28T09:30:00Z")
57
- body = (case_dir(case) / "conclusion.md").read_text()
57
+ body = (case_dir(case) / "conclusion.md").read_text(encoding="utf-8")
58
58
  assert "candidate" in body.lower()
59
59
  assert "2026-08-28T09:30:00Z" in body
60
60
 
61
61
  def test_the_entry_names_the_rules_that_produced_it(self, case):
62
62
  record_grade(case, grade_case(case), at="2026-08-28T09:30:00Z")
63
- assert "grading_rules.yaml" in (case_dir(case) / "conclusion.md").read_text()
63
+ assert "grading_rules.yaml" in (case_dir(case) / "conclusion.md").read_text(encoding="utf-8")
64
64
 
65
65
  def test_the_placeholder_is_gone_once_a_grade_exists(self, case):
66
- assert "Not graded yet" in (case_dir(case) / "conclusion.md").read_text()
66
+ assert "Not graded yet" in (case_dir(case) / "conclusion.md").read_text(encoding="utf-8")
67
67
  record_grade(case, grade_case(case), at="2026-08-28T09:30:00Z")
68
- assert "Not graded yet" not in (case_dir(case) / "conclusion.md").read_text()
68
+ assert "Not graded yet" not in (case_dir(case) / "conclusion.md").read_text(encoding="utf-8")
69
69
 
70
70
 
71
71
  class TestHistoryIsAppendOnly:
@@ -108,7 +108,7 @@ class TestHistoryIsAppendOnly:
108
108
  def test_every_entry_survives_in_the_file(self, case):
109
109
  for i, at in enumerate(["09:30", "09:40", "09:50"]):
110
110
  record_grade(case, grade_case(case), at=f"2026-08-28T{at}:00Z")
111
- body = (case_dir(case) / "conclusion.md").read_text()
111
+ body = (case_dir(case) / "conclusion.md").read_text(encoding="utf-8")
112
112
  for at in ["09:30", "09:40", "09:50"]:
113
113
  assert f"2026-08-28T{at}:00Z" in body
114
114
 
@@ -73,7 +73,7 @@ class TestRecording:
73
73
 
74
74
  def test_the_payload_is_stored_beside_the_record(self, case):
75
75
  e = record_evidence(case, an_evidence(), payload={"rows": [1, 2, 3]})
76
- body = json.loads((case_dir(case) / "evidence" / f"{e.evidence_id}.json").read_text())
76
+ body = json.loads((case_dir(case) / "evidence" / f"{e.evidence_id}.json").read_text(encoding="utf-8"))
77
77
  assert body["payload"] == {"rows": [1, 2, 3]}
78
78
 
79
79
  def test_recording_against_a_case_that_does_not_exist_is_an_error(self):
@@ -95,7 +95,7 @@ class TestTheTimeBasisContract:
95
95
 
96
96
  def test_an_unknown_time_source_is_explicit_not_missing(self, case):
97
97
  e = record_evidence(case, an_evidence(time_source=None, clock_skew_s=None))
98
- body = json.loads((case_dir(case) / "evidence" / f"{e.evidence_id}.json").read_text())
98
+ body = json.loads((case_dir(case) / "evidence" / f"{e.evidence_id}.json").read_text(encoding="utf-8"))
99
99
  assert "time_source" in body and body["time_source"] is None
100
100
  assert "clock_skew_s" in body and body["clock_skew_s"] is None
101
101
 
@@ -42,7 +42,7 @@ def mounted_kb(tmp_path):
42
42
  """
43
43
  p = tmp_path / "vmware" / "knowledge" / "kb" / "KB-fixture.md"
44
44
  p.parent.mkdir(parents=True, exist_ok=True)
45
- p.write_text("---\nid: KB-fixture\napplies_to:\n product: vsphere\n---\nbody\n")
45
+ p.write_text("---\nid: KB-fixture\napplies_to:\n product: vsphere\n---\nbody\n", encoding="utf-8")
46
46
  return p
47
47
 
48
48
 
@@ -229,7 +229,7 @@ class TestRules:
229
229
  site.parent.mkdir(parents=True)
230
230
  site.write_text(
231
231
  "grades:\n probable:\n min_independent_sources: 1\n blocked_by_gaps: false\n"
232
- )
232
+ , encoding="utf-8")
233
233
  record_evidence(case, evidence(skill="vmware-monitor"))
234
234
  r = grade_case(case)
235
235
  assert r.grade == "probable"
@@ -242,7 +242,7 @@ class TestRules:
242
242
  def test_a_broken_site_file_is_an_error_not_a_silent_fallback(self, tmp_path):
243
243
  site = tmp_path / "vmware" / "investigation" / "grading_rules.yaml"
244
244
  site.parent.mkdir(parents=True)
245
- site.write_text("grades: [this is a list not a mapping")
245
+ site.write_text("grades: [this is a list not a mapping", encoding="utf-8")
246
246
  with pytest.raises(ValueError, match="grading_rules.yaml"):
247
247
  load_rules()
248
248
 
@@ -263,7 +263,7 @@ class TestTheCeilingIsMeasuredNotAsserted:
263
263
  def test_a_populated_knowledge_library_raises_the_ceiling(self, case, tmp_path):
264
264
  kb = tmp_path / "vmware" / "knowledge" / "kb"
265
265
  kb.mkdir(parents=True)
266
- (kb / "KB-2026-0417.md").write_text("---\nid: KB-2026-0417\n---\nbody\n")
266
+ (kb / "KB-2026-0417.md").write_text("---\nid: KB-2026-0417\n---\nbody\n", encoding="utf-8")
267
267
  r = grade_case(case)
268
268
  assert r.ceiling == "confirmed"
269
269
 
@@ -288,7 +288,7 @@ class TestTheRulesFileHasNoDecorativeKnobs:
288
288
  def _site(self, tmp_path, body):
289
289
  p = tmp_path / "vmware" / "investigation" / "grading_rules.yaml"
290
290
  p.parent.mkdir(parents=True, exist_ok=True)
291
- p.write_text(body)
291
+ p.write_text(body, encoding="utf-8")
292
292
 
293
293
  def test_relaxing_the_prerequisite_actually_relaxes_it(self, case, tmp_path, mounted_kb):
294
294
  """One decisive source, no corroboration: Confirmed under the relaxed
@@ -67,7 +67,7 @@ class TestRegistering:
67
67
 
68
68
  def test_it_lands_in_hypotheses_md_as_readable_text(self, case):
69
69
  add_hypothesis(case, "failing NVMe device")
70
- body = (case_dir(case) / "hypotheses.md").read_text()
70
+ body = (case_dir(case) / "hypotheses.md").read_text(encoding="utf-8")
71
71
  assert "H1" in body and "failing NVMe device" in body
72
72
  assert "_Empty." not in body
73
73
 
@@ -122,7 +122,7 @@ class TestReadiness:
122
122
  assert readiness()["classes"]["knowledge"]["available"] is False
123
123
  kb = tmp_path / "vmware" / "knowledge" / "kb"
124
124
  kb.mkdir(parents=True)
125
- (kb / "KB-1.md").write_text("---\napplies_to: {}\n---\nbody\n")
125
+ (kb / "KB-1.md").write_text("---\napplies_to: {}\n---\nbody\n", encoding="utf-8")
126
126
  assert readiness()["classes"]["knowledge"]["available"] is True
127
127
 
128
128
  def test_hardware_stays_unavailable_no_matter_what_is_installed(self):
@@ -82,18 +82,18 @@ class TestCreate:
82
82
  differently to whoever opens the folder."""
83
83
  d = case_dir(create_case(a_scope(), at=AT).case_id)
84
84
  for name in ("timeline.md", "hypotheses.md", "conclusion.md"):
85
- body = (d / name).read_text()
85
+ body = (d / name).read_text(encoding="utf-8")
86
86
  assert body.strip(), f"{name} is blank"
87
87
  assert body.lstrip().startswith("#"), f"{name} has no heading"
88
88
 
89
89
  def test_jsonl_and_json_start_valid_not_blank(self):
90
90
  d = case_dir(create_case(a_scope(), at=AT).case_id)
91
- assert (d / "plan.jsonl").read_text() == ""
92
- assert json.loads((d / "gaps.json").read_text()) == {"gaps": []}
91
+ assert (d / "plan.jsonl").read_text(encoding="utf-8") == ""
92
+ assert json.loads((d / "gaps.json").read_text(encoding="utf-8")) == {"gaps": []}
93
93
 
94
94
  def test_refuses_to_overwrite_an_existing_case(self):
95
95
  case = create_case(a_scope(), at=AT)
96
- (case_dir(case.case_id) / "evidence" / "E001.json").write_text("{}")
96
+ (case_dir(case.case_id) / "evidence" / "E001.json").write_text("{}", encoding="utf-8")
97
97
  with pytest.raises(CaseExists) as e:
98
98
  create_case(a_scope(), at=AT)
99
99
  assert case.case_id in str(e.value)
@@ -133,7 +133,7 @@ class TestLoad:
133
133
 
134
134
  def test_a_case_whose_scope_is_unreadable_is_an_error_not_an_empty_scope(self):
135
135
  case = create_case(a_scope(), at=AT)
136
- (case_dir(case.case_id) / "scope.json").write_text("{ this is not json")
136
+ (case_dir(case.case_id) / "scope.json").write_text("{ this is not json", encoding="utf-8")
137
137
  with pytest.raises(ValueError, match="scope.json"):
138
138
  load_case(case.case_id)
139
139
 
@@ -97,7 +97,7 @@ class TestTimeline:
97
97
  def test_it_writes_timeline_md(self, case):
98
98
  submit(case, payload=EVENTS)
99
99
  build_case_timeline(case)
100
- body = (case_dir(case) / "timeline.md").read_text()
100
+ body = (case_dir(case) / "timeline.md").read_text(encoding="utf-8")
101
101
  assert "esxi-03" in body
102
102
  assert "_Empty." not in body
103
103
 
@@ -132,7 +132,7 @@ class TestClose:
132
132
  def test_the_grade_is_recorded_at_close_not_assumed(self, case):
133
133
  out = close_case(case, at=AT)
134
134
  assert out["grade"] == "candidate"
135
- assert "candidate" in (case_dir(case) / "conclusion.md").read_text().lower()
135
+ assert "candidate" in (case_dir(case) / "conclusion.md").read_text(encoding="utf-8").lower()
136
136
 
137
137
  def test_closing_twice_is_refused_rather_than_rewriting_the_record(self, case):
138
138
  close_case(case, at=AT)
@@ -38,7 +38,7 @@ def home(tmp_path, monkeypatch):
38
38
  def write(home, rel, body):
39
39
  p = home / rel
40
40
  p.parent.mkdir(parents=True, exist_ok=True)
41
- p.write_text(body)
41
+ p.write_text(body, encoding="utf-8")
42
42
  return p
43
43
 
44
44
 
@@ -1293,7 +1293,7 @@ wheels = [
1293
1293
 
1294
1294
  [[package]]
1295
1295
  name = "vmware-debug"
1296
- version = "1.11.2"
1296
+ version = "1.11.3"
1297
1297
  source = { editable = "." }
1298
1298
  dependencies = [
1299
1299
  { name = "mcp", extra = ["cli"] },
@@ -1316,7 +1316,7 @@ requires-dist = [
1316
1316
  { name = "pyyaml", specifier = ">=6.0,<7.0" },
1317
1317
  { name = "rich", specifier = ">=13.0,<15.0" },
1318
1318
  { name = "typer", specifier = ">=0.12,<1.0" },
1319
- { name = "vmware-policy", specifier = ">=1.11.0,<2.0" },
1319
+ { name = "vmware-policy", specifier = ">=1.12.0,<2.0" },
1320
1320
  ]
1321
1321
 
1322
1322
  [package.metadata.requires-dev]
@@ -1328,14 +1328,14 @@ dev = [
1328
1328
 
1329
1329
  [[package]]
1330
1330
  name = "vmware-policy"
1331
- version = "1.11.0"
1331
+ version = "1.12.0"
1332
1332
  source = { registry = "https://pypi.org/simple" }
1333
1333
  dependencies = [
1334
1334
  { name = "pyyaml" },
1335
1335
  { name = "rich" },
1336
1336
  { name = "typer" },
1337
1337
  ]
1338
- sdist = { url = "https://files.pythonhosted.org/packages/3e/8a/9be3e8f6605e740f067e69d0dceb34a888b89559f35510090459d004c66f/vmware_policy-1.11.0.tar.gz", hash = "sha256:f497be95b4558bf714980ccf05219a9a40b48173de845de4d1562e3679b2ce91", size = 149532, upload-time = "2026-08-30T14:40:05.844Z" }
1338
+ sdist = { url = "https://files.pythonhosted.org/packages/24/ce/6d3eda9c10648e34facba0eaeff5a58991246a54e6b0f203a499440e3939/vmware_policy-1.12.0.tar.gz", hash = "sha256:4f9e7b80020eec628d65955ba0d61d8080bde730a257e940912917fd3d5ba36d", size = 156796, upload-time = "2026-08-31T00:20:49.206Z" }
1339
1339
  wheels = [
1340
- { url = "https://files.pythonhosted.org/packages/a8/89/8ea2b904057b6a3b344237763a1821079e896fcc6adccd699455586a01e8/vmware_policy-1.11.0-py3-none-any.whl", hash = "sha256:de7d434e1c8113acfd91aacac0a76c05d20b3fc0c147ab02b29782413ec8905b", size = 56005, upload-time = "2026-08-30T14:40:04.205Z" },
1340
+ { url = "https://files.pythonhosted.org/packages/f7/58/c13af514586e3d6ba18144305b421e0e3830cc33e58e87b447c4821509e7/vmware_policy-1.12.0-py3-none-any.whl", hash = "sha256:cbd4dbc454678604fedf4c82e3e6bcecb696e3a1ce6c0ba795bb4dac134c1d17", size = 60569, upload-time = "2026-08-31T00:20:47.595Z" },
1341
1341
  ]
@@ -5,4 +5,4 @@ into a unified timeline, detect spikes, rank root-cause hypotheses, and route
5
5
  remediation to vmware-aiops / vmware-pilot. Never writes; never executes fixes.
6
6
  """
7
7
 
8
- __version__ = "1.11.2"
8
+ __version__ = "1.11.3"
@@ -20,6 +20,30 @@ from rich.table import Table
20
20
  from vmware_debug import __version__
21
21
  from vmware_debug.mcp.tools import incident_timeline, list_symptom_categories
22
22
 
23
+
24
+ def _harden_console_encoding() -> None:
25
+ """Never let one unrepresentable glyph kill a command.
26
+
27
+ On a console whose encoding cannot carry the characters we print -- cp936 on
28
+ the Chinese Windows boxes this family is tested on, or any ASCII locale --
29
+ ``print`` raises ``UnicodeEncodeError`` and the whole command dies with a
30
+ traceback. ``--help`` died that way in four repos. A mangled dash is a
31
+ cosmetic loss; a dead ``--help`` is an outage, so the error handler is
32
+ relaxed rather than the vocabulary narrowed.
33
+
34
+ Best effort: ``reconfigure`` is absent when stdout has been replaced by a
35
+ plain object (pytest capture, some MCP hosts), and losing the hardening
36
+ there is not worth an exception at import.
37
+ """
38
+ for stream in (sys.stdout, sys.stderr):
39
+ try:
40
+ stream.reconfigure(errors="backslashreplace")
41
+ except (AttributeError, ValueError, OSError):
42
+ pass
43
+
44
+
45
+ _harden_console_encoding()
46
+
23
47
  app = typer.Typer(
24
48
  add_completion=False,
25
49
  help="VMware diagnostic brain — read-only incident triage and root-cause routing.",
@@ -54,7 +78,7 @@ def triage(
54
78
  top_n: int = typer.Option(5, help="Max hypotheses to return."),
55
79
  ) -> None:
56
80
  """Correlate a set of pre-collected events into a ranked incident timeline."""
57
- raw = events_file.read_text() if events_file else sys.stdin.read()
81
+ raw = events_file.read_text(encoding="utf-8") if events_file else sys.stdin.read()
58
82
  try:
59
83
  events = json.loads(raw)
60
84
  except json.JSONDecodeError as exc:
@@ -76,7 +76,7 @@ def record_grade(case_id: str, result: GradeResult, at: str) -> GradeEntry:
76
76
  )
77
77
 
78
78
  try:
79
- body = path.read_text()
79
+ body = path.read_text(encoding="utf-8")
80
80
  except OSError as exc:
81
81
  raise CaseError(
82
82
  f"Cannot read conclusion.md for case {case_id}: {exc}. The case "
@@ -84,13 +84,13 @@ def record_grade(case_id: str, result: GradeResult, at: str) -> GradeEntry:
84
84
  ) from exc
85
85
 
86
86
  body = body.replace(_PLACEHOLDER + "\n\n", "").replace(_PLACEHOLDER + "\n", "")
87
- path.write_text(body.rstrip("\n") + "\n\n" + _render(entry))
87
+ path.write_text(body.rstrip("\n") + "\n\n" + _render(entry), encoding="utf-8")
88
88
 
89
89
  index_path = d / "case.json"
90
- index = json.loads(index_path.read_text())
90
+ index = json.loads(index_path.read_text(encoding="utf-8"))
91
91
  index["grade"] = result.grade
92
92
  index["graded_at"] = at
93
- index_path.write_text(json.dumps(index, indent=2, ensure_ascii=False) + "\n")
93
+ index_path.write_text(json.dumps(index, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
94
94
 
95
95
  return entry
96
96
 
@@ -137,7 +137,7 @@ def grade_history(case_id: str) -> tuple[GradeEntry, ...]:
137
137
  f"incomplete; restore it from your copy rather than recreating it."
138
138
  )
139
139
  out = []
140
- for m in _ENTRY_RE.finditer(path.read_text()):
140
+ for m in _ENTRY_RE.finditer(path.read_text(encoding="utf-8")):
141
141
  try:
142
142
  d = json.loads(m.group("json"))
143
143
  except json.JSONDecodeError as exc:
@@ -215,7 +215,7 @@ def record_evidence(case_id: str, evidence: Evidence, payload: Any = None) -> Ev
215
215
  body = stamped.to_json()
216
216
  if payload is not None:
217
217
  body["payload"] = payload
218
- path.write_text(json.dumps(body, indent=2, ensure_ascii=False) + "\n")
218
+ path.write_text(json.dumps(body, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
219
219
  return stamped
220
220
 
221
221
 
@@ -243,7 +243,7 @@ def load_evidence(case_id: str) -> tuple[Evidence, ...]:
243
243
  out = []
244
244
  for path in sorted(d.glob("E*.json")):
245
245
  try:
246
- out.append(Evidence.from_json(json.loads(path.read_text())))
246
+ out.append(Evidence.from_json(json.loads(path.read_text(encoding="utf-8"))))
247
247
  except (OSError, json.JSONDecodeError) as exc:
248
248
  raise ValueError(
249
249
  f"Evidence file {path.name} in case {case_id} is unreadable: "
@@ -271,7 +271,7 @@ def record_gap(case_id: str, gap: Gap) -> Gap:
271
271
  gap_id=_next_id([g.gap_id for g in existing], "G"),
272
272
  )
273
273
  payload = {"gaps": [g.to_json() for g in existing] + [stamped.to_json()]}
274
- (d / _GAPS).write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
274
+ (d / _GAPS).write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
275
275
  return stamped
276
276
 
277
277
 
@@ -284,7 +284,7 @@ def load_gaps(case_id: str) -> tuple[Gap, ...]:
284
284
  """
285
285
  path = _case_dir_or_raise(case_id) / _GAPS
286
286
  try:
287
- body = json.loads(path.read_text())
287
+ body = json.loads(path.read_text(encoding="utf-8"))
288
288
  except OSError as exc:
289
289
  raise ValueError(f"Cannot read {_GAPS} for case {case_id}: {exc}.") from exc
290
290
  except json.JSONDecodeError as exc:
@@ -55,7 +55,7 @@ def load_hypotheses(case_id: str) -> tuple[Hypothesis, ...]:
55
55
  """Every registered hypothesis, in registration order."""
56
56
  path = _path(case_id)
57
57
  try:
58
- body = path.read_text()
58
+ body = path.read_text(encoding="utf-8")
59
59
  except OSError as exc:
60
60
  raise CaseError(f"Cannot read {_FILE} for case {case_id}: {exc}") from exc
61
61
  out = []
@@ -97,12 +97,12 @@ def add_hypothesis(case_id: str, statement: str, at: str = "") -> Hypothesis:
97
97
  statement=statement.strip(),
98
98
  at=at,
99
99
  )
100
- body = path.read_text().replace(_PLACEHOLDER_MARK, "").rstrip("\n")
100
+ body = path.read_text(encoding="utf-8").replace(_PLACEHOLDER_MARK, "").rstrip("\n")
101
101
  machine = json.dumps(
102
102
  {"id": h.hypothesis_id, "statement": h.statement, "at": h.at},
103
103
  ensure_ascii=False,
104
104
  )
105
- path.write_text(f"{body}\n\n## {h.hypothesis_id} — {h.statement}\n\n{_ENTRY.format(machine)}\n")
105
+ path.write_text(f"{body}\n\n## {h.hypothesis_id} — {h.statement}\n\n{_ENTRY.format(machine)}\n", encoding="utf-8")
106
106
  return h
107
107
 
108
108
 
@@ -298,7 +298,7 @@ def _parse(path: Path, section: str) -> tuple[list[KnowledgeEntry], str | None]:
298
298
  return [one(row) for row in csv.DictReader(raw.splitlines(), delimiter=delim)], None
299
299
  if ext in (".txt", ".log"):
300
300
  sidecar = path.with_suffix(".yaml")
301
- d = yaml.safe_load(sidecar.read_text()) if sidecar.is_file() else {}
301
+ d = yaml.safe_load(sidecar.read_text(encoding="utf-8")) if sidecar.is_file() else {}
302
302
  return [one(d or {}, raw.strip())], None
303
303
  except Exception as exc:
304
304
  return [], f"{path.name}: {type(exc).__name__}: {exc}"
@@ -65,7 +65,7 @@ def case_dir(case_id: str) -> Path:
65
65
 
66
66
 
67
67
  def _write_json(path: Path, payload: object) -> None:
68
- path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
68
+ path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
69
69
 
70
70
 
71
71
  def _read_json(path: Path, what: str) -> dict:
@@ -76,7 +76,7 @@ def _read_json(path: Path, what: str) -> dict:
76
76
  it would be at its most costly here.
77
77
  """
78
78
  try:
79
- raw = path.read_text()
79
+ raw = path.read_text(encoding="utf-8")
80
80
  except OSError as exc:
81
81
  raise CaseError(
82
82
  f"Cannot read {what} at {path}: {exc}. The case directory may have "
@@ -125,10 +125,10 @@ def create_case(scope: Scope, at: str) -> Case:
125
125
  (d / "evidence").mkdir()
126
126
 
127
127
  _write_json(d / "scope.json", scope.to_json())
128
- (d / "plan.jsonl").write_text("")
128
+ (d / "plan.jsonl").write_text("", encoding="utf-8")
129
129
  _write_json(d / "gaps.json", {"gaps": []})
130
130
  for name, body in _SKELETON_MD.items():
131
- (d / name).write_text(body)
131
+ (d / name).write_text(body, encoding="utf-8")
132
132
  _write_json(
133
133
  d / "case.json",
134
134
  {"case_id": case_id, "state": "open", "opened_at": at, "grade": None},
@@ -52,7 +52,7 @@ def build_case_timeline(
52
52
  for item in evidence:
53
53
  path = d / f"{item.evidence_id}.json"
54
54
  try:
55
- body = json.loads(path.read_text())
55
+ body = json.loads(path.read_text(encoding="utf-8"))
56
56
  except (OSError, json.JSONDecodeError):
57
57
  rejected.append(f"{item.evidence_id}: payload unreadable")
58
58
  continue
@@ -125,7 +125,7 @@ def _write_timeline_md(case_id: str, result: dict, rows: list) -> None:
125
125
  if result.get("rejected"):
126
126
  lines += ["", "## Could not be read", ""]
127
127
  lines += [f"- {r}" for r in result["rejected"]]
128
- (case_dir(case_id) / "timeline.md").write_text("\n".join(lines) + "\n")
128
+ (case_dir(case_id) / "timeline.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
129
129
 
130
130
 
131
131
  def close_case(case_id: str, at: str) -> dict[str, Any]:
@@ -146,11 +146,11 @@ def close_case(case_id: str, at: str) -> dict[str, Any]:
146
146
  open_gaps = [g.gap_id for g in load_gaps(case_id) if g.blocks]
147
147
  index_path = case_dir(case_id) / "case.json"
148
148
  try:
149
- index = json.loads(index_path.read_text())
149
+ index = json.loads(index_path.read_text(encoding="utf-8"))
150
150
  except (OSError, json.JSONDecodeError) as exc:
151
151
  raise CaseError(f"Cannot read case.json for {case_id}: {exc}") from exc
152
152
  index.update({"state": "closed", "closed_at": at, "grade": result.grade})
153
- index_path.write_text(json.dumps(index, indent=2, ensure_ascii=False) + "\n")
153
+ index_path.write_text(json.dumps(index, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
154
154
 
155
155
  note = f"Closed at {result.grade}."
156
156
  if open_gaps:
File without changes
File without changes
File without changes