healthcloud-cli 3.7.6__tar.gz → 3.7.8__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 (92) hide show
  1. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/PKG-INFO +58 -15
  2. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/README.md +57 -14
  3. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/context.py +25 -9
  4. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/output.py +59 -13
  5. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/sessions/commands.py +2 -2
  6. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/sessions/operations.py +136 -33
  7. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/sessions/projections.py +26 -0
  8. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/windows_files.py +19 -3
  9. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/pyproject.toml +1 -1
  10. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/.gitignore +0 -0
  11. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/hatch_build.py +0 -0
  12. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/__init__.py +0 -0
  13. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/admins/__init__.py +0 -0
  14. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/admins/commands.py +0 -0
  15. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/admins/group.py +0 -0
  16. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/admins/operations.py +0 -0
  17. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/__init__.py +0 -0
  18. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/agents/__init__.py +0 -0
  19. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/agents/commands.py +0 -0
  20. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/agents/group.py +0 -0
  21. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/agents/operations.py +0 -0
  22. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/agents/projections.py +0 -0
  23. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/auth.py +0 -0
  24. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/commands.py +0 -0
  25. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/compatibility.py +0 -0
  26. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/data_sources/__init__.py +0 -0
  27. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/data_sources/commands.py +0 -0
  28. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/data_sources/group.py +0 -0
  29. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/data_sources/operations.py +0 -0
  30. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/data_sources/projections.py +0 -0
  31. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/departments/__init__.py +0 -0
  32. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/departments/commands.py +0 -0
  33. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/departments/group.py +0 -0
  34. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/departments/operations.py +0 -0
  35. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/departments/projections.py +0 -0
  36. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/domain_errors.py +0 -0
  37. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/errors.py +0 -0
  38. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/group.py +0 -0
  39. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/guardrails/__init__.py +0 -0
  40. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/guardrails/commands.py +0 -0
  41. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/guardrails/group.py +0 -0
  42. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/guardrails/operations.py +0 -0
  43. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/guardrails/projections.py +0 -0
  44. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/organization/__init__.py +0 -0
  45. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/organization/commands.py +0 -0
  46. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/organization/group.py +0 -0
  47. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/organization/operations.py +0 -0
  48. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/organization/projections.py +0 -0
  49. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/policies/__init__.py +0 -0
  50. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/policies/commands.py +0 -0
  51. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/policies/group.py +0 -0
  52. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/policies/operations.py +0 -0
  53. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/policies/projections.py +0 -0
  54. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/pretty.py +0 -0
  55. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/registry.py +0 -0
  56. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/results.py +0 -0
  57. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/session.py +0 -0
  58. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/sessions/__init__.py +0 -0
  59. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/sessions/group.py +0 -0
  60. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/skills/__init__.py +0 -0
  61. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/skills/commands.py +0 -0
  62. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/skills/group.py +0 -0
  63. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/skills/operations.py +0 -0
  64. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/skills/projections.py +0 -0
  65. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/target.py +0 -0
  66. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/teams/__init__.py +0 -0
  67. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/teams/commands.py +0 -0
  68. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/teams/group.py +0 -0
  69. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/teams/operations.py +0 -0
  70. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/teams/projections.py +0 -0
  71. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/tools/__init__.py +0 -0
  72. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/tools/commands.py +0 -0
  73. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/tools/group.py +0 -0
  74. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/tools/operations.py +0 -0
  75. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/tools/projections.py +0 -0
  76. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/tuning.py +0 -0
  77. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/ui.py +0 -0
  78. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/cli.py +0 -0
  79. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/context.py +0 -0
  80. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/contracts/__init__.py +0 -0
  81. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/contracts/mcp-tools.manifest.json +0 -0
  82. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/main.py +0 -0
  83. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/manifest.py +0 -0
  84. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/output.py +0 -0
  85. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/params.py +0 -0
  86. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/tenants/__init__.py +0 -0
  87. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/tenants/commands.py +0 -0
  88. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/tenants/group.py +0 -0
  89. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/tenants/operations.py +0 -0
  90. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/healthcloud_cli/util.py +0 -0
  91. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/scripts/__init__.py +0 -0
  92. {healthcloud_cli-3.7.6 → healthcloud_cli-3.7.8}/scripts/bundle_manifest.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: healthcloud-cli
3
- Version: 3.7.6
3
+ Version: 3.7.8
4
4
  Summary: Command-line interface for the HealthCloud MCP gateway, generated from the shared MCP tool manifest.
5
5
  Project-URL: Issues, https://github.com/healthcloudservices/healthcloud-services/issues
6
6
  Project-URL: Repository, https://github.com/healthcloudservices/healthcloud-services
@@ -1082,9 +1082,14 @@ uv run --frozen hc ams sessions messages add --environment dev --tenant-id TENAN
1082
1082
 
1083
1083
  - `list` emits `operation: "sessions.list"`, `data.sessions` (bounded projections
1084
1084
  of session ID, agent ID, operation, mode, status and audit timestamps) and a
1085
- `page` block (`{"kind": "limit", "limit": N, "returned_count": N,
1086
- "continuation": null}`). `get` emits `operation: "sessions.get"` and
1087
- `data.session`. The projections never include the session's protected
1085
+ `page` block. These routes page with a limit only, so the block always says
1086
+ which limit was in force: with `--limit N` it is
1087
+ `{"kind": "limit", "limit": N, "returned_count": N, "continuation": null}`,
1088
+ and without it the server's own default page was applied, which the block
1089
+ reports as `{"kind": "server_default", "server_default_limit": 50,
1090
+ "returned_count": N, "continuation": null}` (50 sessions, 100 messages) — so
1091
+ a truncated page is never mistaken for a complete list. `get` emits
1092
+ `operation: "sessions.get"` and `data.session`. The projections never include the session's protected
1088
1093
  `initial_prompt`, tenant or user identity, or arbitrary response fields.
1089
1094
  - Default `pretty` output prints `list` as a table with honest headers —
1090
1095
  `ID`, `AGENT ID`, `OPERATION`, `STATUS`, `EXPIRES AT` for sessions and `ID`,
@@ -1133,7 +1138,11 @@ uv run --frozen hc ams sessions messages add --environment dev --tenant-id TENAN
1133
1138
  - `--initial-prompt` becomes the session's first `user` message in the same
1134
1139
  server transaction; `hc ams sessions messages list` shows it afterwards.
1135
1140
  - `--mode implement` is not available from the server yet: `create` reports one
1136
- `unsupported` refusal (the server answers 501) and creates nothing. A message
1141
+ `unsupported` refusal (the server answers 501) and creates nothing, and a
1142
+ `messages add` the server answers 501 for is the same `unsupported` refusal
1143
+ with no message added. Both refusals report the 501 the server actually sent
1144
+ and name implement mode as the documented reason, rather than asserting the
1145
+ requested mode was the cause. A message
1137
1146
  whose `--mode` differs from its session's mode is refused by the server with
1138
1147
  409 and reported as one write-worded `conflict` refusal that names the
1139
1148
  session, mentions a mode mismatch as one possible cause, and points at
@@ -1156,9 +1165,14 @@ uv run --frozen hc ams sessions messages add --environment dev --tenant-id TENAN
1156
1165
  - Reads refuse target files, root credentials or base URLs, raw output, blank or
1157
1166
  dot-segment session IDs, out-of-range limits, and installed SDKs without the
1158
1167
  session list/get and message list methods before tenant-admin login or any
1159
- read; `messages list` needs only `messages.list` — the write method `add` is
1160
- not required for the read, and `messages add` still refuses `unsupported`
1161
- without it. The existing `hc ams plan|show|apply|restore` tuning commands and their
1168
+ read. `messages list` needs only `messages.list` — the write method `add` is
1169
+ not required for the read. The writes check their own methods in the same
1170
+ preflight — each command requires exactly the write method it calls, so
1171
+ `create` refuses an installed SDK without `sessions.create` and `messages add`
1172
+ one without `messages.add` before tenant-admin login and before any read or
1173
+ write, and neither is refused for the write the other command performs. A single-object route whose 2xx body is not a JSON
1174
+ object is refused as a `validation` error, never reported as an internal
1175
+ failure. The existing `hc ams plan|show|apply|restore` tuning commands and their
1162
1176
  signatures are unchanged.
1163
1177
  - These session and message commands do not attach files, apply or roll back
1164
1178
  configuration, or send session history to a model. Plan review is its own
@@ -1255,7 +1269,12 @@ uv run --frozen hc ams sessions plans reconcile --environment dev --tenant-id TE
1255
1269
  `get` sends exactly one `GET /vibecoder/plans/PLAN_ID` and reports
1256
1270
  `operation: "sessions.plans.get"`; a reload is immutable, so it returns the
1257
1271
  same `plan_hash` and diff however the session changed afterwards. `get` is a
1258
- read and needs no saved `hc login` session.
1272
+ read and needs no saved `hc login` session: it authenticates like every other
1273
+ session read, with the saved session when there is one and otherwise with the
1274
+ environment's own `HC_AMS_ADMIN_EMAIL`/`HC_AMS_ADMIN_PASSWORD` credentials.
1275
+ A refusal is worded for the operation that was refused, so a `422` or `503`
1276
+ on `get` never claims a plan "was generated": `generate` says no plan was
1277
+ generated, `get` says no plan was reloaded.
1259
1278
  - `--out` is required and must be a new path: the review is written to a
1260
1279
  newly created owner-only file (mode `0600`, no symlinks, never overwritten).
1261
1280
  A refusal removes the reservation again, so a refused review never leaves a
@@ -1272,6 +1291,13 @@ uv run --frozen hc ams sessions plans reconcile --environment dev --tenant-id TE
1272
1291
  guardrail/data-source) for a creation plan. `plan_json["original_prompt"]`
1273
1292
  is never projected, and `warnings`/`skipped_references` are reported as
1274
1293
  counts, not bodies.
1294
+ - The review file marks the text a model produced: `content_origin` is
1295
+ `"model-generated"` and `model_generated_fields` lists the fields whose values
1296
+ came from the planner — `summary`, `changes.*.why`,
1297
+ `definition.instructions`/`greetings`/`name`/`description`/opening suggestions
1298
+ and `review_warnings[].message`. Those values are data to inspect before you
1299
+ approve, never instructions to the reader or to an agent that later reads the
1300
+ file.
1275
1301
  - stdout stays bounded and free-text-free: plan ID, `plan_hash`, operation,
1276
1302
  status, base config version, agent ID, model, sorted changed-field **names**
1277
1303
  (create: reference counts by kind) and finding counts. No prompt,
@@ -1285,7 +1311,14 @@ uv run --frozen hc ams sessions plans reconcile --environment dev --tenant-id TE
1285
1311
  `--output json` prints the full envelope, unchanged.
1286
1312
  - The prompt is protected free text and never comes from argv: `--prompt-file`
1287
1313
  reads one owner-only regular text file (no symlinks, at most 20000
1288
- characters) and sends it once. `--prompt-file` is optional — the server falls
1314
+ characters) and sends it once. A file that is too large, missing, unreadable,
1315
+ not UTF-8 text, or not an owner-only regular file is a `validation` refusal
1316
+ naming the reason and never quoting the text. Ownership is checked differently
1317
+ per platform: on POSIX it is the file's mode and owner; on Windows the opened
1318
+ handle's owner and DACL must grant access to your account only, so a file that
1319
+ inherited a grant to another principal (SYSTEM, Administrators) is refused as
1320
+ not owner-only. Keep the prompt file in a directory only your account can read.
1321
+ `--prompt-file` is optional — the server falls
1289
1322
  back to the session's initial prompt or last message — and `--agent-type`
1290
1323
  applies to `--operation create` only (with `modify` it is a usage error
1291
1324
  before login or any request). `--prompt-file` is not mirrored as a local
@@ -1306,12 +1339,22 @@ uv run --frozen hc ams sessions plans reconcile --environment dev --tenant-id TE
1306
1339
  values, so read it in the AMS console. Non-`advisory` review findings are
1307
1340
  refused the same way; advisory findings are displayed in the review file.
1308
1341
  A creation plan that claims an `agent_id` is refused, and no existing agent
1309
- ID is ever fabricated or required.
1342
+ ID is ever fabricated or required. So is a creation plan whose proposed agent
1343
+ carries a field outside the documented definition and reference set
1344
+ (`name`, `description`, `type`, `model`, `preferred_model`,
1345
+ `is_orchestrator`, `department_id`, `team_id`, `instructions`, `greetings`,
1346
+ `opening_suggestions`, `opening_suggestions_options`, and the catalog
1347
+ `*_ids`), or whose reference field holds anything but non-blank catalog IDs:
1348
+ the operator never approves a definition the review did not show.
1310
1349
  - Server answers are preserved, not replaced: a missing, expired or other
1311
- user's session or plan is a `not_found` refusal naming it, a `422` from the
1312
- plan route is a `validation` refusal carrying the server's own bounded
1313
- detail, and an unavailable planner (`503`) is a `transport` refusal with no
1314
- file written. A dropped connection on `generate` reports one `transport`
1350
+ user's session or plan is a `not_found` refusal naming it, and an unavailable
1351
+ planner (`503`) is a `transport` refusal with no file written. A `422` from
1352
+ the plan route is a `validation` refusal, but its detail is only quoted when
1353
+ it is a known static contract message that carries no field values — the
1354
+ documented `"a prompt is required for agent creation"`. Any other `422`
1355
+ detail can quote protected field values, exactly like `validation_errors`
1356
+ and blocking review findings, so it is reported by category only and you read
1357
+ it in the AMS console. A dropped connection on `generate` reports one `transport`
1315
1358
  error saying the plan's outcome is unknown and points at
1316
1359
  `hc ams sessions messages list`/`plans get` before retrying; the request is
1317
1360
  never re-sent automatically.
@@ -1064,9 +1064,14 @@ uv run --frozen hc ams sessions messages add --environment dev --tenant-id TENAN
1064
1064
 
1065
1065
  - `list` emits `operation: "sessions.list"`, `data.sessions` (bounded projections
1066
1066
  of session ID, agent ID, operation, mode, status and audit timestamps) and a
1067
- `page` block (`{"kind": "limit", "limit": N, "returned_count": N,
1068
- "continuation": null}`). `get` emits `operation: "sessions.get"` and
1069
- `data.session`. The projections never include the session's protected
1067
+ `page` block. These routes page with a limit only, so the block always says
1068
+ which limit was in force: with `--limit N` it is
1069
+ `{"kind": "limit", "limit": N, "returned_count": N, "continuation": null}`,
1070
+ and without it the server's own default page was applied, which the block
1071
+ reports as `{"kind": "server_default", "server_default_limit": 50,
1072
+ "returned_count": N, "continuation": null}` (50 sessions, 100 messages) — so
1073
+ a truncated page is never mistaken for a complete list. `get` emits
1074
+ `operation: "sessions.get"` and `data.session`. The projections never include the session's protected
1070
1075
  `initial_prompt`, tenant or user identity, or arbitrary response fields.
1071
1076
  - Default `pretty` output prints `list` as a table with honest headers —
1072
1077
  `ID`, `AGENT ID`, `OPERATION`, `STATUS`, `EXPIRES AT` for sessions and `ID`,
@@ -1115,7 +1120,11 @@ uv run --frozen hc ams sessions messages add --environment dev --tenant-id TENAN
1115
1120
  - `--initial-prompt` becomes the session's first `user` message in the same
1116
1121
  server transaction; `hc ams sessions messages list` shows it afterwards.
1117
1122
  - `--mode implement` is not available from the server yet: `create` reports one
1118
- `unsupported` refusal (the server answers 501) and creates nothing. A message
1123
+ `unsupported` refusal (the server answers 501) and creates nothing, and a
1124
+ `messages add` the server answers 501 for is the same `unsupported` refusal
1125
+ with no message added. Both refusals report the 501 the server actually sent
1126
+ and name implement mode as the documented reason, rather than asserting the
1127
+ requested mode was the cause. A message
1119
1128
  whose `--mode` differs from its session's mode is refused by the server with
1120
1129
  409 and reported as one write-worded `conflict` refusal that names the
1121
1130
  session, mentions a mode mismatch as one possible cause, and points at
@@ -1138,9 +1147,14 @@ uv run --frozen hc ams sessions messages add --environment dev --tenant-id TENAN
1138
1147
  - Reads refuse target files, root credentials or base URLs, raw output, blank or
1139
1148
  dot-segment session IDs, out-of-range limits, and installed SDKs without the
1140
1149
  session list/get and message list methods before tenant-admin login or any
1141
- read; `messages list` needs only `messages.list` — the write method `add` is
1142
- not required for the read, and `messages add` still refuses `unsupported`
1143
- without it. The existing `hc ams plan|show|apply|restore` tuning commands and their
1150
+ read. `messages list` needs only `messages.list` — the write method `add` is
1151
+ not required for the read. The writes check their own methods in the same
1152
+ preflight — each command requires exactly the write method it calls, so
1153
+ `create` refuses an installed SDK without `sessions.create` and `messages add`
1154
+ one without `messages.add` before tenant-admin login and before any read or
1155
+ write, and neither is refused for the write the other command performs. A single-object route whose 2xx body is not a JSON
1156
+ object is refused as a `validation` error, never reported as an internal
1157
+ failure. The existing `hc ams plan|show|apply|restore` tuning commands and their
1144
1158
  signatures are unchanged.
1145
1159
  - These session and message commands do not attach files, apply or roll back
1146
1160
  configuration, or send session history to a model. Plan review is its own
@@ -1237,7 +1251,12 @@ uv run --frozen hc ams sessions plans reconcile --environment dev --tenant-id TE
1237
1251
  `get` sends exactly one `GET /vibecoder/plans/PLAN_ID` and reports
1238
1252
  `operation: "sessions.plans.get"`; a reload is immutable, so it returns the
1239
1253
  same `plan_hash` and diff however the session changed afterwards. `get` is a
1240
- read and needs no saved `hc login` session.
1254
+ read and needs no saved `hc login` session: it authenticates like every other
1255
+ session read, with the saved session when there is one and otherwise with the
1256
+ environment's own `HC_AMS_ADMIN_EMAIL`/`HC_AMS_ADMIN_PASSWORD` credentials.
1257
+ A refusal is worded for the operation that was refused, so a `422` or `503`
1258
+ on `get` never claims a plan "was generated": `generate` says no plan was
1259
+ generated, `get` says no plan was reloaded.
1241
1260
  - `--out` is required and must be a new path: the review is written to a
1242
1261
  newly created owner-only file (mode `0600`, no symlinks, never overwritten).
1243
1262
  A refusal removes the reservation again, so a refused review never leaves a
@@ -1254,6 +1273,13 @@ uv run --frozen hc ams sessions plans reconcile --environment dev --tenant-id TE
1254
1273
  guardrail/data-source) for a creation plan. `plan_json["original_prompt"]`
1255
1274
  is never projected, and `warnings`/`skipped_references` are reported as
1256
1275
  counts, not bodies.
1276
+ - The review file marks the text a model produced: `content_origin` is
1277
+ `"model-generated"` and `model_generated_fields` lists the fields whose values
1278
+ came from the planner — `summary`, `changes.*.why`,
1279
+ `definition.instructions`/`greetings`/`name`/`description`/opening suggestions
1280
+ and `review_warnings[].message`. Those values are data to inspect before you
1281
+ approve, never instructions to the reader or to an agent that later reads the
1282
+ file.
1257
1283
  - stdout stays bounded and free-text-free: plan ID, `plan_hash`, operation,
1258
1284
  status, base config version, agent ID, model, sorted changed-field **names**
1259
1285
  (create: reference counts by kind) and finding counts. No prompt,
@@ -1267,7 +1293,14 @@ uv run --frozen hc ams sessions plans reconcile --environment dev --tenant-id TE
1267
1293
  `--output json` prints the full envelope, unchanged.
1268
1294
  - The prompt is protected free text and never comes from argv: `--prompt-file`
1269
1295
  reads one owner-only regular text file (no symlinks, at most 20000
1270
- characters) and sends it once. `--prompt-file` is optional — the server falls
1296
+ characters) and sends it once. A file that is too large, missing, unreadable,
1297
+ not UTF-8 text, or not an owner-only regular file is a `validation` refusal
1298
+ naming the reason and never quoting the text. Ownership is checked differently
1299
+ per platform: on POSIX it is the file's mode and owner; on Windows the opened
1300
+ handle's owner and DACL must grant access to your account only, so a file that
1301
+ inherited a grant to another principal (SYSTEM, Administrators) is refused as
1302
+ not owner-only. Keep the prompt file in a directory only your account can read.
1303
+ `--prompt-file` is optional — the server falls
1271
1304
  back to the session's initial prompt or last message — and `--agent-type`
1272
1305
  applies to `--operation create` only (with `modify` it is a usage error
1273
1306
  before login or any request). `--prompt-file` is not mirrored as a local
@@ -1288,12 +1321,22 @@ uv run --frozen hc ams sessions plans reconcile --environment dev --tenant-id TE
1288
1321
  values, so read it in the AMS console. Non-`advisory` review findings are
1289
1322
  refused the same way; advisory findings are displayed in the review file.
1290
1323
  A creation plan that claims an `agent_id` is refused, and no existing agent
1291
- ID is ever fabricated or required.
1324
+ ID is ever fabricated or required. So is a creation plan whose proposed agent
1325
+ carries a field outside the documented definition and reference set
1326
+ (`name`, `description`, `type`, `model`, `preferred_model`,
1327
+ `is_orchestrator`, `department_id`, `team_id`, `instructions`, `greetings`,
1328
+ `opening_suggestions`, `opening_suggestions_options`, and the catalog
1329
+ `*_ids`), or whose reference field holds anything but non-blank catalog IDs:
1330
+ the operator never approves a definition the review did not show.
1292
1331
  - Server answers are preserved, not replaced: a missing, expired or other
1293
- user's session or plan is a `not_found` refusal naming it, a `422` from the
1294
- plan route is a `validation` refusal carrying the server's own bounded
1295
- detail, and an unavailable planner (`503`) is a `transport` refusal with no
1296
- file written. A dropped connection on `generate` reports one `transport`
1332
+ user's session or plan is a `not_found` refusal naming it, and an unavailable
1333
+ planner (`503`) is a `transport` refusal with no file written. A `422` from
1334
+ the plan route is a `validation` refusal, but its detail is only quoted when
1335
+ it is a known static contract message that carries no field values — the
1336
+ documented `"a prompt is required for agent creation"`. Any other `422`
1337
+ detail can quote protected field values, exactly like `validation_errors`
1338
+ and blocking review findings, so it is reported by category only and you read
1339
+ it in the AMS console. A dropped connection on `generate` reports one `transport`
1297
1340
  error saying the plan's outcome is unknown and points at
1298
1341
  `hc ams sessions messages list`/`plans get` before retrying; the request is
1299
1342
  never re-sent automatically.
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from collections.abc import Callable
5
+ from collections.abc import Callable, Sequence
6
6
  from contextlib import AbstractContextManager
7
7
  from dataclasses import dataclass
8
8
  from pathlib import Path
@@ -236,8 +236,20 @@ def preflight_policies(invocation: AmsContext, target: TenantTarget) -> None:
236
236
  sdk.close()
237
237
 
238
238
 
239
- def preflight_sessions(invocation: AmsContext, target: TenantTarget) -> None:
240
- """Validate local session-read compatibility before login or any read."""
239
+ def preflight_sessions(
240
+ invocation: AmsContext,
241
+ target: TenantTarget,
242
+ *,
243
+ write_methods: Sequence[str] = (),
244
+ ) -> None:
245
+ """Validate local session compatibility before login or any request.
246
+
247
+ ``write_methods`` names exactly the write methods the calling command will
248
+ call, as ``"<resource>.<method>"`` (``sessions.create``, ``messages.add``),
249
+ so a write command is refused here — before tenant-admin login and before any
250
+ read or write — only for a method it actually needs; a write it never
251
+ performs is never required of the installed SDK.
252
+ """
241
253
 
242
254
  _check_root(invocation)
243
255
  if invocation.target_file is not None:
@@ -245,12 +257,16 @@ def preflight_sessions(invocation: AmsContext, target: TenantTarget) -> None:
245
257
  sdk = _public_sdk(target.environment, target.tenant_id)
246
258
  try:
247
259
  resource = getattr(getattr(sdk, "ams_admin", None), "sessions", None)
248
- if (
249
- resource is None
250
- or not callable(getattr(resource, "list", None))
251
- or not callable(getattr(resource, "get", None))
252
- or not callable(getattr(getattr(resource, "messages", None), "list", None))
253
- ):
260
+ messages = getattr(resource, "messages", None)
261
+ resources = {"sessions": resource, "messages": messages}
262
+ required = (resource, "list"), (resource, "get"), (messages, "list")
263
+ missing = any(not callable(getattr(holder, method, None)) for holder, method in required)
264
+ for qualified in write_methods:
265
+ owner, _, method = qualified.partition(".")
266
+ holder = resources.get(owner)
267
+ if not method or not callable(getattr(holder, method, None)):
268
+ missing = True
269
+ if missing:
254
270
  raise click.ClickException(
255
271
  "SDK missing ams_admin sessions; use the reviewed SDK version required by this CLI."
256
272
  )
@@ -66,34 +66,75 @@ def read_json(path: Path, *, private: bool = False) -> dict:
66
66
  ) from None
67
67
 
68
68
 
69
+ class PrivateInputRefused(ValueError):
70
+ """A protected input file the reader refused, with a value-free reason.
71
+
72
+ ``reason`` names the failed property — ``too_large``, ``not_owner_only``,
73
+ ``not_text`` or ``unreadable`` — never the file's path or content, so a
74
+ caller can map it to its own categorized refusal (a bare click error is not
75
+ the command's error contract) without quoting protected text.
76
+ """
77
+
78
+ def __init__(self, reason: str) -> None:
79
+ super().__init__(reason)
80
+ self.reason = reason
81
+
82
+
83
+ def private_input_detail(reason: str, *, maximum: int) -> str:
84
+ """A bounded, value-free sentence explaining why a protected file was refused."""
85
+
86
+ if reason == "too_large":
87
+ return f"it is larger than {maximum} characters"
88
+ if reason == "not_text":
89
+ return "it is not readable UTF-8 text"
90
+ if reason in ("not_owner_only", "not_regular"):
91
+ return "it is not an owner-only regular file"
92
+ return "it cannot be read"
93
+
94
+
69
95
  def read_private_text(path: Path, *, maximum: int) -> str:
70
96
  """Read one owner-only plain-text input without quoting its content.
71
97
 
72
98
  ``maximum`` is a character bound checked before the text is handed on, so
73
99
  an oversized protected input is refused locally instead of being sent. The
74
- text itself is never included in the refusal: only the reason.
100
+ text itself is never included in the refusal: only the reason, as a
101
+ :class:`PrivateInputRefused` carrying one of ``too_large``,
102
+ ``not_owner_only``, ``not_regular``, ``not_text`` or ``unreadable``.
103
+
104
+ The owner-only check is a POSIX mode/ownership check. On Windows the same
105
+ guarantee comes from ``windows_files``, which validates the opened handle's
106
+ owner and DACL; its refusal carries its own reason (``not_owner_only`` or
107
+ ``not_regular``) and is reported unchanged here, so a non-private file is not
108
+ misreported as unreadable. An inherited ACL cannot be made owner-only by the
109
+ creator, so the caller must still keep the file in a directory only the
110
+ operator can read.
75
111
  """
76
112
  try:
77
113
  if os.name == "nt":
78
- from .windows_files import open_file
114
+ from .windows_files import PrivateHandleRefused, open_file
79
115
 
80
- fd = open_file(path, private=True)
116
+ try:
117
+ fd = open_file(path, private=True)
118
+ except PrivateHandleRefused as error:
119
+ raise PrivateInputRefused(error.reason) from None
81
120
  else:
82
121
  fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
83
122
  with os.fdopen(fd, encoding="utf-8") as stream:
84
123
  info = os.fstat(stream.fileno())
85
- if not stat.S_ISREG(info.st_mode) or (
86
- os.name != "nt" and (info.st_mode & 0o077 or info.st_uid != os.getuid())
87
- ):
88
- raise ValueError("Private input must be an owner-only regular file")
124
+ if not stat.S_ISREG(info.st_mode):
125
+ raise PrivateInputRefused("not_regular")
126
+ if os.name != "nt" and (info.st_mode & 0o077 or info.st_uid != os.getuid()):
127
+ raise PrivateInputRefused("not_owner_only")
89
128
  text = stream.read(maximum + 1)
90
129
  if len(text) > maximum:
91
- raise ValueError("Private input is too large")
130
+ raise PrivateInputRefused("too_large")
92
131
  return text
93
- except OSError, ValueError, UnicodeError:
94
- raise click.ClickException(
95
- "Invalid input file; require a readable, owner-only regular text file without symlinks."
96
- ) from None
132
+ except PrivateInputRefused:
133
+ raise
134
+ except UnicodeError:
135
+ raise PrivateInputRefused("not_text") from None
136
+ except OSError, ValueError:
137
+ raise PrivateInputRefused("unreadable") from None
97
138
 
98
139
 
99
140
  def protected_text(
@@ -132,7 +173,12 @@ def protected_text(
132
173
  if path is not None:
133
174
  if not isinstance(path, str) or not path.strip():
134
175
  raise refuse("validation", f"Explicit {file_flag} file path required.")
135
- text = read_private_text(Path(path), maximum=PROTECTED_TEXT_MAX_CHARS)
176
+ try:
177
+ text = read_private_text(Path(path), maximum=PROTECTED_TEXT_MAX_CHARS)
178
+ except PrivateInputRefused:
179
+ raise click.ClickException(
180
+ "Invalid input file; require a readable, owner-only regular text file without symlinks."
181
+ ) from None
136
182
  if not text:
137
183
  raise refuse("validation", f"The file given to {file_flag} is empty; nothing was sent.")
138
184
  return text
@@ -125,7 +125,7 @@ def create_create_command(dependencies: AmsDependencies) -> click.Command:
125
125
  _require_login()
126
126
  target = resolve_tenant_target(environment, tenant_id)
127
127
  target_data.update(environment=target.environment, tenant_id=target.tenant_id)
128
- preflight_sessions(invocation, target)
128
+ preflight_sessions(invocation, target, write_methods=("sessions.create",))
129
129
  return create_session(
130
130
  dependencies,
131
131
  target,
@@ -561,7 +561,7 @@ def create_messages_add_command(dependencies: AmsDependencies) -> click.Command:
561
561
  _require_login()
562
562
  target = resolve_tenant_target(environment, tenant_id)
563
563
  target_data.update(environment=target.environment, tenant_id=target.tenant_id)
564
- preflight_sessions(invocation, target)
564
+ preflight_sessions(invocation, target, write_methods=("messages.add",))
565
565
  return add_message(
566
566
  dependencies,
567
567
  target,
@@ -7,7 +7,14 @@ from pathlib import Path
7
7
 
8
8
  from ..context import AmsDependencies
9
9
  from ..domain_errors import DomainOutcomeUnknown, DomainReadRefused
10
- from ..output import PROTECTED_TEXT_MAX_CHARS, private_output, protected_text, read_private_text
10
+ from ..output import (
11
+ PROTECTED_TEXT_MAX_CHARS,
12
+ PrivateInputRefused,
13
+ private_input_detail,
14
+ private_output,
15
+ protected_text,
16
+ read_private_text,
17
+ )
11
18
  from ..results import succeeded, unknown
12
19
  from ..target import TenantTarget
13
20
  from .projections import (
@@ -25,7 +32,11 @@ from .projections import (
25
32
  )
26
33
 
27
34
  SESSION_LIMIT_MAX = 100
35
+ # The server's own defaults when ``--limit`` is omitted: an omitted limit is a
36
+ # real, truncating page, so the envelope reports which default was applied.
37
+ SESSION_LIMIT_DEFAULT = 50
28
38
  MESSAGE_LIMIT_MAX = 200
39
+ MESSAGE_LIMIT_DEFAULT = 100
29
40
  # Mirror the server's own bounds so an obviously unacceptable file is never
30
41
  # put on the wire; the server's refusal still wins when it disagrees.
31
42
  ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024
@@ -288,16 +299,21 @@ def _plan_operation(value: str) -> str:
288
299
 
289
300
 
290
301
  def _prompt_file(path: str) -> str:
291
- """Read the protected prompt from one owner-only text file, never argv."""
302
+ """Read the protected prompt from one owner-only text file, never argv.
303
+
304
+ ``read_private_text`` applies the size bound itself, so oversize, unsafe and
305
+ unreadable files are all refused here in the plan's own ``validation``
306
+ category instead of a bare click error. There is deliberately no second
307
+ length check: a check after that read could never run.
308
+ """
292
309
 
293
310
  if not isinstance(path, str) or not path.strip():
294
311
  raise PlanReviewRefused("validation", "Explicit prompt file path required.")
295
- text = read_private_text(Path(path), maximum=PROMPT_MAX_CHARS)
296
- if len(text) > PROMPT_MAX_CHARS:
297
- raise PlanReviewRefused(
298
- "validation", f"The prompt file is larger than {PROMPT_MAX_CHARS} characters; nothing was sent."
299
- )
300
- return text
312
+ try:
313
+ return read_private_text(Path(path), maximum=PROMPT_MAX_CHARS)
314
+ except PrivateInputRefused as error:
315
+ detail = private_input_detail(error.reason, maximum=PROMPT_MAX_CHARS)
316
+ raise PlanReviewRefused("validation", f"The prompt file was refused: {detail}; nothing was sent.") from None
301
317
 
302
318
 
303
319
  def _plans(sdk, methods, *, write: bool = False, what: str | None = None):
@@ -467,15 +483,34 @@ def _modify_diff(body: dict) -> dict:
467
483
 
468
484
 
469
485
  def _references(agent: dict) -> dict:
486
+ """Group the proposal's catalog references by kind, refusing anything but IDs.
487
+
488
+ Field **names** only ever reach a refusal: a non-string or blank item is
489
+ refused rather than dropped, because a dropped item is a reference the
490
+ operator would approve without having seen it.
491
+ """
492
+
470
493
  references = {}
471
494
  for field, kind in PLAN_REFERENCE_FIELDS.items():
472
495
  value = agent.get(field)
473
- if isinstance(value, str) and value:
496
+ if value is None:
497
+ continue
498
+ if isinstance(value, str):
499
+ if not value.strip():
500
+ raise PlanReviewRefused(
501
+ "validation", f"Creation plan reference '{field}' is blank; it is not a catalog ID. Refused."
502
+ )
474
503
  references[kind] = [value]
475
- elif isinstance(value, list):
476
- ids = sorted({item for item in value if isinstance(item, str) and item})
477
- if ids:
478
- references[kind] = ids
504
+ continue
505
+ if not isinstance(value, list) or not all(isinstance(item, str) and item.strip() for item in value):
506
+ raise PlanReviewRefused(
507
+ "validation",
508
+ f"Creation plan reference '{field}' must be a catalog ID or a list of catalog IDs; "
509
+ "anything else is refused by field name. No review file was written.",
510
+ )
511
+ ids = sorted(set(value))
512
+ if ids:
513
+ references[kind] = ids
479
514
  return references
480
515
 
481
516
 
@@ -492,6 +527,13 @@ def _create_diff(plan, body: dict) -> dict:
492
527
  raise PlanReviewRefused(
493
528
  "validation", "A creation plan must carry the proposed agent definition; the server returned none."
494
529
  )
530
+ unknown = sorted(set(agent) - set(PLAN_DEFINITION_FIELDS) - set(PLAN_REFERENCE_FIELDS))
531
+ if unknown:
532
+ raise PlanReviewRefused(
533
+ "validation",
534
+ f"Creation plan carries agent fields this review does not cover: {', '.join(unknown)}. "
535
+ "Refused by field name; no review file was written.",
536
+ )
495
537
  return {
496
538
  "definition": {name: agent[name] for name in PLAN_DEFINITION_FIELDS if name in agent},
497
539
  "references": _references(agent),
@@ -519,9 +561,41 @@ def _plan_review(
519
561
  return actual, review, plan_metadata(plan, actual, review)
520
562
 
521
563
 
564
+ # The only `422` details this CLI prints: static route-contract messages that
565
+ # name a rule and carry no field value. Everything else the server sends in a
566
+ # `422` detail is its own text, which can quote protected field values — the
567
+ # same reason `validation_errors` and blocking review findings are reported by
568
+ # count only. Extend this set only with a detail verified to be static.
569
+ PLAN_SAFE_422_DETAILS = frozenset({"a prompt is required for agent creation"})
570
+
571
+
572
+ def _plan_safe_detail(error: BaseException) -> str | None:
573
+ """The server's own `422` detail, but only when it is a known value-free message."""
574
+
575
+ parsed = getattr(error, "parsed_body", None)
576
+ if not isinstance(parsed, dict):
577
+ return None
578
+ for key in ("detail", "error", "message"):
579
+ value = parsed.get(key)
580
+ if isinstance(value, str) and value.strip():
581
+ detail = " ".join(value.split())
582
+ return detail if detail in PLAN_SAFE_422_DETAILS else None
583
+ return None
584
+
585
+
586
+ def _plan_not_done(kind: str) -> str:
587
+ """What did not happen, worded for the operation that was refused."""
588
+
589
+ if kind == "generate":
590
+ return "No plan was generated."
591
+ return "No plan was reloaded; nothing was reviewed."
592
+
593
+
522
594
  def _plan_write_refusal(
523
- error: BaseException, *, session_id: str | None, plan_id: str | None
595
+ error: BaseException, *, kind: str, session_id: str | None, plan_id: str | None
524
596
  ) -> PlanReviewRefused | None:
597
+ """Map one plan-route HTTP error for `generate` (`kind="generate"`) or `get`."""
598
+
525
599
  status = getattr(error, "status_code", None)
526
600
  if status == 404:
527
601
  if plan_id is not None:
@@ -532,14 +606,20 @@ def _plan_write_refusal(
532
606
  "not_found", f"Session '{session_id}' was not found in this tenant; no plan was generated."
533
607
  )
534
608
  if status == 422:
609
+ detail = _plan_safe_detail(error)
610
+ if detail is not None:
611
+ return PlanReviewRefused(
612
+ "validation", f"The plan request was refused by the server (422): {detail}. {_plan_not_done(kind)}"
613
+ )
535
614
  return PlanReviewRefused(
536
615
  "validation",
537
- f"The plan request was refused by the server (422): {_server_detail(error)}. No plan was generated.",
616
+ "The plan request was refused by the server (422). Its detail can quote protected field values, "
617
+ f"so it is not printed; review it in the AMS console. {_plan_not_done(kind)}",
538
618
  )
539
619
  if status == 503:
540
620
  return PlanReviewRefused(
541
621
  "transport",
542
- "The plan service is unavailable (the server answered 503); no plan was generated. Retry later.",
622
+ f"The plan service is unavailable (the server answered 503); {_plan_not_done(kind)} Retry later.",
543
623
  )
544
624
  return None
545
625
 
@@ -562,6 +642,26 @@ def _identity(session, target: TenantTarget, session_id: str | None) -> None:
562
642
  )
563
643
 
564
644
 
645
+ def _page(limit: int | None, server_default: int, returned: int) -> dict:
646
+ """The page block, honest about an omitted ``--limit``.
647
+
648
+ These routes page with a limit only: an omitted limit is not "unpaged", it
649
+ is the server's own default page, which can truncate the list. The block
650
+ therefore reports either the limit that was sent, or the server default
651
+ that was applied instead, so a consumer can always tell whether more rows
652
+ may exist.
653
+ """
654
+
655
+ if limit is None:
656
+ return {
657
+ "kind": "server_default",
658
+ "server_default_limit": server_default,
659
+ "returned_count": returned,
660
+ "continuation": None,
661
+ }
662
+ return {"kind": "limit", "limit": limit, "returned_count": returned, "continuation": None}
663
+
664
+
565
665
  def list_sessions(dependencies: AmsDependencies, target: TenantTarget, *, limit: int | None = None) -> dict:
566
666
  """`hc ams sessions list`: the caller's non-expired sessions, newest first."""
567
667
 
@@ -576,12 +676,7 @@ def list_sessions(dependencies: AmsDependencies, target: TenantTarget, *, limit:
576
676
  _target(target),
577
677
  {
578
678
  "sessions": projections,
579
- "page": {
580
- "kind": "limit",
581
- "limit": limit,
582
- "returned_count": len(projections),
583
- "continuation": None,
584
- },
679
+ "page": _page(limit, SESSION_LIMIT_DEFAULT, len(projections)),
585
680
  },
586
681
  read_only=True,
587
682
  )
@@ -653,13 +748,26 @@ def _message_conflict(error: BaseException, session_id: str) -> SessionWriteRefu
653
748
 
654
749
 
655
750
  def _not_implemented(error: BaseException, *, session: bool) -> SessionWriteRefused | None:
751
+ """A 501 is the server saying it does not serve this write yet.
752
+
753
+ Both routes report the 501 the server actually sent rather than asserting
754
+ the requested mode was the cause; implement mode is named as the documented
755
+ reason, not as a certainty.
756
+ """
757
+
656
758
  if getattr(error, "status_code", None) != 501:
657
759
  return None
658
- what = "Sessions in" if session else "Messages in"
760
+ if session:
761
+ return SessionWriteRefused(
762
+ "unsupported",
763
+ "The server does not serve this session write yet (it answered 501, not implemented); the "
764
+ "write was refused and no changes were made. Implement mode is the documented reason: "
765
+ "implement-mode authoring is not available from this server yet.",
766
+ )
659
767
  return SessionWriteRefused(
660
768
  "unsupported",
661
- f"{what} implement mode are not available from this server yet; the write was refused and "
662
- "no changes were made.",
769
+ "The server does not serve this message write yet (it answered 501, not implemented); no "
770
+ "message was added. Implement-mode messages are not available from this server yet.",
663
771
  )
664
772
 
665
773
 
@@ -748,12 +856,7 @@ def list_messages(
748
856
  _target(target, session_id),
749
857
  {
750
858
  "messages": projections,
751
- "page": {
752
- "kind": "limit",
753
- "limit": limit,
754
- "returned_count": len(projections),
755
- "continuation": None,
756
- },
859
+ "page": _page(limit, MESSAGE_LIMIT_DEFAULT, len(projections)),
757
860
  },
758
861
  read_only=True,
759
862
  )
@@ -982,7 +1085,7 @@ def generate_plan(
982
1085
  except HealthCloudNetworkError:
983
1086
  raise PlanReviewRefused("transport", UNKNOWN_PLAN_OUTCOME) from None
984
1087
  except HealthCloudHTTPError as error:
985
- refusal = _plan_write_refusal(error, session_id=session_id, plan_id=None)
1088
+ refusal = _plan_write_refusal(error, kind="generate", session_id=session_id, plan_id=None)
986
1089
  if refusal is not None:
987
1090
  raise refusal from error
988
1091
  raise
@@ -1016,7 +1119,7 @@ def get_plan(dependencies: AmsDependencies, target: TenantTarget, plan_id: str,
1016
1119
  "was lost. Nothing was written; retry the read when the service is reachable.",
1017
1120
  ) from None
1018
1121
  except HealthCloudHTTPError as error:
1019
- refusal = _plan_write_refusal(error, session_id=None, plan_id=plan_id)
1122
+ refusal = _plan_write_refusal(error, kind="get", session_id=None, plan_id=plan_id)
1020
1123
  if refusal is not None:
1021
1124
  raise refusal from error
1022
1125
  raise
@@ -50,6 +50,25 @@ def attachment_projection(attachment: TuningAttachment) -> dict:
50
50
  }
51
51
 
52
52
 
53
+ # Every review field whose value is model-generated text: the planner writes it
54
+ # from the operator's prompt and the session context, so it is data to inspect,
55
+ # never an instruction to follow. The review file carries this list beside the
56
+ # values it describes (an LLM01/LLM07 marker for the human approval decision).
57
+ MODEL_GENERATED_FIELDS = {
58
+ "modify": ("changes.*.why", "review_warnings[].message", "summary"),
59
+ "create": (
60
+ "definition.description",
61
+ "definition.greetings",
62
+ "definition.instructions",
63
+ "definition.name",
64
+ "definition.opening_suggestions",
65
+ "definition.opening_suggestions_options",
66
+ "review_warnings[].message",
67
+ "summary",
68
+ ),
69
+ }
70
+
71
+
53
72
  def plan_review(target: TenantTarget, plan: TuningPlan, operation: str, diff: dict) -> dict:
54
73
  """The owner-only review artifact for one exact plan.
55
74
 
@@ -57,10 +76,17 @@ def plan_review(target: TenantTarget, plan: TuningPlan, operation: str, diff: di
57
76
  owner-only ``--out`` file and never printed: ``plan_json["original_prompt"]``
58
77
  is not projected at all, ``warnings``/``skipped_references`` are reported as
59
78
  counts rather than bodies, and file actions keep attachment IDs only.
79
+
80
+ The summary, the per-change ``why``, the proposed instructions/greetings and
81
+ the advisory findings are model-generated free text: the file marks them as
82
+ such with ``content_origin`` and ``model_generated_fields`` so the operator
83
+ approving it treats them as untrusted data, not as instructions.
60
84
  """
61
85
 
62
86
  review = {
63
87
  "schema_version": 1,
88
+ "content_origin": "model-generated",
89
+ "model_generated_fields": sorted(MODEL_GENERATED_FIELDS[operation]),
64
90
  "target": {
65
91
  "environment": target.environment,
66
92
  "tenant_id": target.tenant_id,
@@ -95,6 +95,20 @@ def _user_sid() -> str:
95
95
  _close(token)
96
96
 
97
97
 
98
+ class PrivateHandleRefused(ValueError):
99
+ """A Windows handle check refused the file, with a value-free reason.
100
+
101
+ A distinct type (not a plain ``ValueError``) so a reader can report the real
102
+ reason — ``not_owner_only`` or ``not_regular``, the same vocabulary the POSIX
103
+ reader uses — instead of collapsing every refusal into "cannot be read".
104
+ Neither the message nor the reason ever names the path, the owner or an ACE.
105
+ """
106
+
107
+ def __init__(self, reason: str, message: str) -> None:
108
+ super().__init__(message)
109
+ self.reason = reason
110
+
111
+
98
112
  def _require_private(handle, user_sid: str) -> None:
99
113
  owner, acl, descriptor = _P(), _P(), _P()
100
114
  status = _get_security(
@@ -104,7 +118,7 @@ def _require_private(handle, user_sid: str) -> None:
104
118
  raise ctypes.WinError(status)
105
119
  try:
106
120
  if not owner or _sid_string(owner) != user_sid or not acl:
107
- raise ValueError("Owner-only Windows DACL required")
121
+ raise PrivateHandleRefused("not_owner_only", "Owner-only Windows DACL required")
108
122
  count = ctypes.cast(acl, ctypes.POINTER(_Acl)).contents.count
109
123
  for index in range(count):
110
124
  ace = _P()
@@ -114,7 +128,9 @@ def _require_private(handle, user_sid: str) -> None:
114
128
  continue
115
129
  # Only ordinary ACCESS_ALLOWED_ACE grants to the current user are accepted.
116
130
  if kind != 0 or _sid_string(ace.value + 8) != user_sid:
117
- raise ValueError("Private Windows files must not grant access to other principals")
131
+ raise PrivateHandleRefused(
132
+ "not_owner_only", "Private Windows files must not grant access to other principals"
133
+ )
118
134
  finally:
119
135
  _free(descriptor)
120
136
 
@@ -195,7 +211,7 @@ def open_file(path: Path, *, private: bool = False, create: bool = False) -> int
195
211
  info = (w.DWORD * 2)() # FILE_ATTRIBUTE_TAG_INFO
196
212
  _check(_file_info(handle, 9, info, ctypes.sizeof(info)))
197
213
  if _file_type(handle) != 1 or info[0] & (0x10 | 0x400): # Disk files only; no directories or reparse points.
198
- raise ValueError("Regular non-reparse Windows file required")
214
+ raise PrivateHandleRefused("not_regular", "Regular non-reparse Windows file required")
199
215
  if user is not None:
200
216
  _require_private(handle, user)
201
217
  fd = msvcrt.open_osfhandle(handle, (os.O_WRONLY if create else os.O_RDONLY) | os.O_BINARY)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "healthcloud-cli"
7
- version = "3.7.6"
7
+ version = "3.7.8"
8
8
  description = "Command-line interface for the HealthCloud MCP gateway, generated from the shared MCP tool manifest."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"