kd-cli 2.8.1__tar.gz → 2.9.0__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 (86) hide show
  1. {kd_cli-2.8.1/kd_cli.egg-info → kd_cli-2.9.0}/PKG-INFO +1 -1
  2. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/namespaces/tag/__init__.py +95 -24
  3. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/client.py +427 -27
  4. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/resources/CHANGELOG.md +32 -0
  5. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/resources/operating-guide.md +18 -0
  6. {kd_cli-2.8.1 → kd_cli-2.9.0/kd_cli.egg-info}/PKG-INFO +1 -1
  7. {kd_cli-2.8.1 → kd_cli-2.9.0}/pyproject.toml +1 -1
  8. {kd_cli-2.8.1 → kd_cli-2.9.0}/LICENSE +0 -0
  9. {kd_cli-2.8.1 → kd_cli-2.9.0}/MANIFEST.in +0 -0
  10. {kd_cli-2.8.1 → kd_cli-2.9.0}/README.md +0 -0
  11. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/__init__.py +0 -0
  12. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/api/__init__.py +0 -0
  13. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/api/articles.py +0 -0
  14. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/api/http.py +0 -0
  15. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/api/hub.py +0 -0
  16. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/api/saved_queries.py +0 -0
  17. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/api/tags.py +0 -0
  18. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/api/youtrack.py +0 -0
  19. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/article_marker.py +0 -0
  20. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/auth.py +0 -0
  21. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cache.py +0 -0
  22. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/__init__.py +0 -0
  23. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/core/__init__.py +0 -0
  24. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/core/command_base.py +0 -0
  25. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/core/command_parser.py +0 -0
  26. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/core/confirm.py +0 -0
  27. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/core/namespaces.py +0 -0
  28. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/core/option_registry.py +0 -0
  29. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/input.py +0 -0
  30. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/main.py +0 -0
  31. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/namespaces/__init__.py +0 -0
  32. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/namespaces/alias/__init__.py +0 -0
  33. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/namespaces/article/__init__.py +0 -0
  34. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/namespaces/cache/__init__.py +0 -0
  35. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/namespaces/init.py +0 -0
  36. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/namespaces/issue/__init__.py +0 -0
  37. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/namespaces/primer/__init__.py +0 -0
  38. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/namespaces/project/__init__.py +0 -0
  39. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/namespaces/query/__init__.py +0 -0
  40. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/namespaces/release_notes.py +0 -0
  41. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/namespaces/search/__init__.py +0 -0
  42. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/namespaces/system/__init__.py +0 -0
  43. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/namespaces/time/__init__.py +0 -0
  44. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/namespaces/user/__init__.py +0 -0
  45. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/output.py +0 -0
  46. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/cli/tree.py +0 -0
  47. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/config.py +0 -0
  48. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/config_read.py +0 -0
  49. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/config_schema.py +0 -0
  50. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/connection.py +0 -0
  51. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/debug.py +0 -0
  52. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/exceptions.py +0 -0
  53. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/history.py +0 -0
  54. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/locking.py +0 -0
  55. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/models/__init__.py +0 -0
  56. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/models/article.py +0 -0
  57. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/models/assist.py +0 -0
  58. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/models/attachment.py +0 -0
  59. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/models/comment.py +0 -0
  60. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/models/common.py +0 -0
  61. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/models/issue.py +0 -0
  62. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/models/project.py +0 -0
  63. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/models/saved_query.py +0 -0
  64. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/models/search.py +0 -0
  65. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/models/tag.py +0 -0
  66. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/models/tag_entities.py +0 -0
  67. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/models/user.py +0 -0
  68. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/models/work_item.py +0 -0
  69. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/operating_guide.py +0 -0
  70. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/primer.py +0 -0
  71. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/query_parser.py +0 -0
  72. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/release_notes.py +0 -0
  73. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/resources/__init__.py +0 -0
  74. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/resources/command-synonyms.yaml +0 -0
  75. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/resources/config-template.yaml +0 -0
  76. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/resources/error-messages.yaml +0 -0
  77. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/resources/primers/__init__.py +0 -0
  78. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/resources/primers/hitl.yaml +0 -0
  79. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/resources/sdlc-doctrine.md +0 -0
  80. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd/sdlc.py +0 -0
  81. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd_cli.egg-info/SOURCES.txt +0 -0
  82. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd_cli.egg-info/dependency_links.txt +0 -0
  83. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd_cli.egg-info/entry_points.txt +0 -0
  84. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd_cli.egg-info/requires.txt +0 -0
  85. {kd_cli-2.8.1 → kd_cli-2.9.0}/kd_cli.egg-info/top_level.txt +0 -0
  86. {kd_cli-2.8.1 → kd_cli-2.9.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.8.1
3
+ Version: 2.9.0
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -138,8 +138,18 @@ class TagShowCommand(Command):
138
138
  "show",
139
139
  "Show tag details",
140
140
  requires_client=True,
141
- usage="kd /tag show TAG_NAME",
142
- examples=["kd /tag show sprint-47"],
141
+ usage="kd /tag show TAG_NAME_OR_ID",
142
+ long_description=(
143
+ "Accepts either a display name or the tag-id form "
144
+ "(NN-NN); the id form skips name resolution and is the "
145
+ "disambiguator when two visible tags share a name. A "
146
+ "name that resolves to multiple visible tags refuses "
147
+ "with a candidate list — pass the tag id to pick."
148
+ ),
149
+ examples=[
150
+ "kd /tag show sprint-47",
151
+ "kd /tag show 10-178",
152
+ ],
143
153
  see_also=["/tag list", "/tag issues"],
144
154
  )
145
155
 
@@ -162,13 +172,16 @@ class TagCreateCommand(Command):
162
172
  requires_client=True,
163
173
  usage="kd /tag create TAG_NAME",
164
174
  long_description=(
165
- "Idempotent: if a tag with the requested name is already "
166
- "visible, the command returns ``action: noop`` and does "
167
- "not POST. Otherwise the tag is created with "
175
+ "Idempotent: if exactly one tag with the requested name "
176
+ "is already visible, the command returns ``action: noop`` "
177
+ "and does not POST. Otherwise the tag is created with "
168
178
  "``action: created``. Returns a parseable single-line ack: "
169
179
  "the last stdout line is ``ok: <NAME> created`` or "
170
180
  "``ok: <NAME> noop``. Name resolution is case-insensitive "
171
- "(same resolver /tag show uses). Pass --quiet to suppress "
181
+ "(same resolver /tag show uses). When the name collides "
182
+ "with multiple visible tags, create refuses with a "
183
+ "candidate list rather than picking one — inspect them "
184
+ "with ``kd /tag show <id>``. Pass --quiet to suppress "
172
185
  "the detail block above the ack."
173
186
  ),
174
187
  examples=['kd /tag create "sprint-47"'],
@@ -206,17 +219,25 @@ class TagRenameCommand(Command):
206
219
  "rename",
207
220
  "Rename a tag",
208
221
  requires_client=True,
209
- usage="kd /tag rename TAG_NAME NEW_NAME",
222
+ usage="kd /tag rename TAG_NAME_OR_ID NEW_NAME",
210
223
  long_description=(
211
224
  "Returns a parseable single-line ack: the last stdout line "
212
225
  "is ``ok: <NEW_NAME> renamed old_name=<TAG_NAME>``. The "
213
226
  "tail-line ID is the **new name**, since scripts that "
214
227
  "follow the rename refer to the tag by its new identity. "
215
228
  "The previous name is preserved in ``old_name=`` so "
216
- "audit/log consumers can correlate. Pass --quiet to "
217
- "suppress the detail block above the ack."
229
+ "audit/log consumers can correlate. The first argument "
230
+ "accepts either a display name or the tag-id form "
231
+ "(NN-NN); the id form skips name resolution and is the "
232
+ "disambiguator when two visible tags share a name. A "
233
+ "name that resolves to multiple visible tags refuses "
234
+ "with a candidate list — pass the tag id to pick. Pass "
235
+ "--quiet to suppress the detail block above the ack."
218
236
  ),
219
- examples=['kd /tag rename sprint-47 "sprint-48"'],
237
+ examples=[
238
+ 'kd /tag rename sprint-47 "sprint-48"',
239
+ 'kd /tag rename 10-178 "lifecycle:final"',
240
+ ],
220
241
  see_also=["/tag show"],
221
242
  )
222
243
  self.display_hint = "mutation"
@@ -244,15 +265,23 @@ class TagDeleteCommand(Command):
244
265
  "delete",
245
266
  "Delete a tag",
246
267
  requires_client=True,
247
- usage="kd /tag delete TAG_NAME",
268
+ usage="kd /tag delete TAG_NAME_OR_ID",
248
269
  long_description=(
249
270
  "Returns a parseable single-line ack: the last stdout line "
250
271
  "is ``ok: <NAME> deleted``. --confirm is required and is "
251
272
  "**not** softened by --quiet — the safety gate fires "
252
- "before any envelope is rendered. Pass --quiet to suppress "
253
- "the detail block above the ack."
273
+ "before any envelope is rendered. The argument accepts "
274
+ "either a display name or the tag-id form (NN-NN); the "
275
+ "id form skips name resolution and is the disambiguator "
276
+ "when two visible tags share a name. A name that "
277
+ "resolves to multiple visible tags refuses with a "
278
+ "candidate list — pass the tag id to pick. Pass "
279
+ "--quiet to suppress the detail block above the ack."
254
280
  ),
255
- examples=["kd /tag delete sprint-47 --confirm"],
281
+ examples=[
282
+ "kd /tag delete sprint-47 --confirm",
283
+ "kd /tag delete 10-178 --confirm",
284
+ ],
256
285
  see_also=["/tag list", "/tag create"],
257
286
  )
258
287
  self.display_hint = "mutation"
@@ -298,13 +327,20 @@ class TagAddCommand(Command):
298
327
  "add",
299
328
  "Tag an issue or article",
300
329
  requires_client=True,
301
- usage="kd /tag add ENTITY_ID TAG_NAME",
330
+ usage="kd /tag add ENTITY_ID TAG_NAME_OR_ID",
302
331
  long_description=(
303
- "Entity type is auto-detected from the ID format: FB-123 = issue, FB-A-1 = article."
332
+ "Entity type is auto-detected from the ID format: FB-123 = "
333
+ "issue, FB-A-1 = article. The tag argument accepts either a "
334
+ "display name or the tag-id form (NN-NN); the id form skips "
335
+ "name resolution and is the disambiguator when two tags share "
336
+ "a name. Adding a name already attached to the entity is an "
337
+ "idempotent re-tag; adding a name that has multiple visible "
338
+ "tags refuses with a candidate list — pass the tag id to pick."
304
339
  ),
305
340
  examples=[
306
341
  "kd /tag add FB-123 sprint-47",
307
342
  "kd /tag add FB-A-1 reviewed",
343
+ "kd /tag add FB-A-1 10-178",
308
344
  ],
309
345
  see_also=["/tag remove", "/tag issues"],
310
346
  )
@@ -327,10 +363,15 @@ class TagRemoveCommand(Command):
327
363
  "remove",
328
364
  "Untag an issue or article",
329
365
  requires_client=True,
330
- usage="kd /tag remove ENTITY_ID TAG_NAME",
366
+ usage="kd /tag remove ENTITY_ID TAG_NAME_OR_ID",
331
367
  long_description=(
332
368
  "Entity type is auto-detected from the ID format: FB-123 = "
333
- "issue, FB-A-1 = article. "
369
+ "issue, FB-A-1 = article. The tag argument is resolved "
370
+ "against the tags attached to the entity — names that are "
371
+ "not attached refuse loudly even when a same-name tag "
372
+ "exists globally. Pass the tag-id form (NN-NN) to skip name "
373
+ "resolution; this is the disambiguator when multiple "
374
+ "same-name tags are attached. "
334
375
  "Returns a parseable single-line ack: the last stdout line "
335
376
  "is ``ok: <ENTITY_ID> removed tag=<TAG_NAME>``. The "
336
377
  "entity ID is the primary tail-line ID (``$2``); the "
@@ -340,6 +381,7 @@ class TagRemoveCommand(Command):
340
381
  examples=[
341
382
  "kd /tag remove FB-123 sprint-47",
342
383
  "kd /tag remove FB-A-1 reviewed",
384
+ "kd /tag remove FB-A-1 10-178",
343
385
  ],
344
386
  see_also=["/tag add", "/tag issues"],
345
387
  )
@@ -363,16 +405,26 @@ class TagIssuesCommand(Command):
363
405
  "issues",
364
406
  "List issues with a tag",
365
407
  requires_client=True,
366
- usage="kd /tag issues TAG_NAME [--tag TAG ...] [--project P] [--limit N]",
408
+ usage="kd /tag issues TAG_NAME_OR_ID [--tag TAG ...] [--project P] [--limit N]",
367
409
  long_description=(
368
- "Lists issues tagged with the given tag. Single-tag uses the "
410
+ "Lists issues tagged with the given tag. Single-tag uses the "
369
411
  "fast server-side tag-issues endpoint; multi-tag composes a "
370
412
  "YouTrack query and uses /issues?query=... with AND semantics. "
371
413
  "Use --project to scope results to a single project (forces "
372
- "the composed-query path)."
414
+ "the composed-query path). The tag argument accepts either a "
415
+ "display name or the tag-id form (NN-NN) on the fast path "
416
+ "(single tag, no --project); the id form skips name "
417
+ "resolution and is the disambiguator when two visible tags "
418
+ "share a name. On the composed-query path (--project set or "
419
+ "multiple --tag arguments), the id form is rejected because "
420
+ "YouTrack's ``tag:`` predicate is name-keyed — use the name "
421
+ "form (which refuses on duplicate-name collision) or the "
422
+ "fast path. A name that resolves to multiple visible tags "
423
+ "refuses with a candidate list before the query is composed."
373
424
  ),
374
425
  examples=[
375
426
  "kd /tag issues sprint-47",
427
+ "kd /tag issues 10-178",
376
428
  "kd /tag issues blocked --limit 100",
377
429
  "kd /tag issues kind:note --tag component:authservice",
378
430
  "kd /tag issues kind:note --tag component:authservice --project KDCLI",
@@ -414,16 +466,22 @@ class TagArticlesCommand(Command):
414
466
  "articles",
415
467
  "List articles with a tag",
416
468
  requires_client=True,
417
- usage="kd /tag articles TAG_NAME [--tag TAG ...] [--project P] [--limit N]",
469
+ usage="kd /tag articles TAG_NAME_OR_ID [--tag TAG ...] [--project P] [--limit N]",
418
470
  long_description=(
419
471
  "Lists articles tagged with the given tag. Uses client-side "
420
472
  "filtering because the YouTrack API has no server-side "
421
473
  "article-by-tag endpoint. Use --project to scope the scan "
422
474
  "to a single project. Repeat --tag to require multiple tags "
423
- "(AND semantics)."
475
+ "(AND semantics). Every tag argument accepts either a "
476
+ "display name or the tag-id form (NN-NN); the id form "
477
+ "filters precisely by id and is the disambiguator when "
478
+ "two visible tags share a name. A name that resolves to "
479
+ "multiple visible tags refuses with a candidate list — "
480
+ "pass the tag id to pick."
424
481
  ),
425
482
  examples=[
426
483
  "kd /tag articles reviewed",
484
+ "kd /tag articles 10-178",
427
485
  'kd /tag articles "type:guide" --project FB',
428
486
  "kd /tag articles type:case --tag component:authservice -p CMCSA",
429
487
  "kd /tag articles blocked --limit 100",
@@ -484,10 +542,23 @@ class TagEntitiesCommand(Command):
484
542
  "--limit-issues and --limit-articles override per kind. "
485
543
  "--primer-scope substitutes the primer's pinned tags as an "
486
544
  "OR scope (match=any); mutually exclusive with the positional "
487
- "TAG and --tag arguments."
545
+ "TAG and --tag arguments. "
546
+ "The tag argument accepts either a display name or the "
547
+ "tag-id form (NN-NN); the id form skips name resolution "
548
+ "and is the disambiguator when two visible tags share a "
549
+ "name. Id-form scope: allowed when the issue half routes "
550
+ "through the fast path (single tag, no --project) or when "
551
+ "only --kind article is requested (article filtering is "
552
+ "client-side by id). With --project or multiple tags AND "
553
+ "the issue kind included, id-form is rejected because "
554
+ "YouTrack's ``tag:`` predicate is name-keyed. Names are "
555
+ "pre-validated before the issue/article fanout — a "
556
+ "duplicate-name refusal is the whole-command outcome, not "
557
+ "a partial-success row."
488
558
  ),
489
559
  examples=[
490
560
  "kd /tag entities kind:note",
561
+ "kd /tag entities 10-178",
491
562
  "kd /tag entities kind:note --tag component:authservice --project KDCLI",
492
563
  "kd /tag entities kind:note --kind article --limit-articles 5",
493
564
  "kd /tag entities kind:note --limit 20 -o json",
@@ -23,7 +23,7 @@ import difflib
23
23
  import re
24
24
  import shlex
25
25
  from collections import OrderedDict
26
- from collections.abc import Callable
26
+ from collections.abc import Callable, Sequence
27
27
  from dataclasses import dataclass, field as dataclass_field
28
28
  from datetime import datetime, timezone
29
29
  from pathlib import Path
@@ -2184,6 +2184,52 @@ def _detect_entity_type(entity_id: str) -> str:
2184
2184
  return "article" if "-A-" in entity_id else "issue"
2185
2185
 
2186
2186
 
2187
+ _TAG_ID_RE = re.compile(r"\d+-\d+")
2188
+
2189
+
2190
+ def _is_tag_id(s: str) -> bool:
2191
+ """True if *s* matches YouTrack's tag-id shape (``\\d+-\\d+``).
2192
+
2193
+ Names following kd's namespace convention (``lifecycle:draft``)
2194
+ contain ``:`` and never match.
2195
+ """
2196
+ return bool(_TAG_ID_RE.fullmatch(s))
2197
+
2198
+
2199
+ # KDCLI-200: retry-line templates for the global tag resolver. Keyed by
2200
+ # command verb. ``{id}`` substitutes the candidate's tag id; ``rename``
2201
+ # carries a literal ``<new-name>`` placeholder because the operator
2202
+ # supplies the new name on retry. ``create`` has no valid retry shape
2203
+ # (creating with an existing id is meaningless), so it is absent from
2204
+ # this table and uses ``_TAG_INSPECT_HINTS`` instead.
2205
+ _TAG_RETRY_TEMPLATES: dict[str, str] = {
2206
+ "show": "kd /tag show {id}",
2207
+ "delete": "kd /tag delete {id}",
2208
+ "rename": "kd /tag rename {id} <new-name>",
2209
+ "issues": "kd /tag issues {id}",
2210
+ "articles": "kd /tag articles {id}",
2211
+ "entities": "kd /tag entities {id}",
2212
+ }
2213
+
2214
+ # KDCLI-200: trailer-only hint when no valid retry shape exists. Used
2215
+ # by ``/tag create`` so an operator who hits a collision can inspect the
2216
+ # existing tags without inventing a ``create <id>`` shape.
2217
+ _TAG_INSPECT_HINTS: dict[str, str] = {
2218
+ "create": "kd /tag show <id>",
2219
+ }
2220
+
2221
+
2222
+ def _format_owner(tag: Tag) -> str:
2223
+ """Render the ``owner=`` field for a tag in ambiguity diagnostics.
2224
+
2225
+ ``Tag.owner`` is ``NamedEntity | None`` and ``NamedEntity.name`` is
2226
+ itself ``str | None``, so both branches collapse to ``<unknown>``.
2227
+ """
2228
+ if tag.owner is None:
2229
+ return "<unknown>"
2230
+ return tag.owner.name or "<unknown>"
2231
+
2232
+
2187
2233
  class TagOperations:
2188
2234
  """High-level tag operations with name resolution and entity detection."""
2189
2235
 
@@ -2198,7 +2244,9 @@ class TagOperations:
2198
2244
  self._articles = articles_api
2199
2245
  self._yt = yt
2200
2246
  self._debug = debug_log or DebugLog()
2201
- self._name_cache: dict[str, str] = {}
2247
+ # KDCLI-200: list-valued cache so display-name collisions can
2248
+ # be detected and refused instead of silently last-one-wins.
2249
+ self._name_cache: dict[str, builtins.list[Tag]] = {}
2202
2250
 
2203
2251
  def list(
2204
2252
  self,
@@ -2261,7 +2309,7 @@ class TagOperations:
2261
2309
  return (matched[:top], cap_hit)
2262
2310
 
2263
2311
  def get(self, name: str) -> Tag:
2264
- tag_id = self._resolve_tag(name)
2312
+ tag_id = name if _is_tag_id(name) else self._resolve_tag(name, command="show")
2265
2313
  return self._tags.get_tag(tag_id)
2266
2314
 
2267
2315
  def create(self, name: str) -> TagCreateResult:
@@ -2276,11 +2324,11 @@ class TagOperations:
2276
2324
  naming sharing/visibility and name-acceptance as plausible causes.
2277
2325
  """
2278
2326
  try:
2279
- tag_id = self._resolve_tag(name)
2327
+ tag_id = self._resolve_tag(name, command="create")
2280
2328
  except NotFoundError:
2281
2329
  self._debug.info("api", f"creating tag '{name}'")
2282
2330
  created = self._tags.create_tag(name)
2283
- self._name_cache[name.lower()] = created.id
2331
+ self._name_cache.setdefault(name.lower(), []).append(created)
2284
2332
  self._debug.info("api", f"created tag '{name}' ({created.id})")
2285
2333
  return TagCreateResult(action="create", tag=created)
2286
2334
  self._debug.info("api", f"tag '{name}' already exists ({tag_id})")
@@ -2288,22 +2336,28 @@ class TagOperations:
2288
2336
  return TagCreateResult(action="exists", tag=existing)
2289
2337
 
2290
2338
  def rename(self, name: str, new_name: str) -> Tag:
2291
- tag_id = self._resolve_tag(name)
2339
+ tag_id = name if _is_tag_id(name) else self._resolve_tag(name, command="rename")
2292
2340
  self._debug.info("api", f"renaming tag '{name}' → '{new_name}'")
2293
2341
  tag = self._tags.update_tag(tag_id, new_name)
2294
- del self._name_cache[name.lower()]
2295
- self._name_cache[new_name.lower()] = tag.id
2342
+ self._invalidate_tag(tag.id)
2343
+ self._name_cache.setdefault(new_name.lower(), []).append(tag)
2296
2344
  return tag
2297
2345
 
2298
2346
  def delete(self, name: str) -> None:
2299
- tag_id = self._resolve_tag(name)
2347
+ tag_id = name if _is_tag_id(name) else self._resolve_tag(name, command="delete")
2300
2348
  self._debug.info("api", f"deleting tag '{name}' ({tag_id})")
2301
2349
  self._tags.delete_tag(tag_id)
2302
- self._name_cache.pop(name.lower(), None)
2350
+ self._invalidate_tag(tag_id)
2303
2351
 
2304
2352
  def add(self, entity_id: str, tag: str) -> None:
2305
- """Add a tag to an issue or article. Entity type is auto-detected."""
2306
- tag_id = self._resolve_tag(tag)
2353
+ """Add a tag to an issue or article. Entity type is auto-detected.
2354
+
2355
+ Tag resolution is entity-anchored (KDCLI-198 S1): if a same-name
2356
+ tag is already attached, it is reused (idempotent re-tag);
2357
+ otherwise exactly one visible global match is required. The
2358
+ tag-id form (``\\d+-\\d+``) is a passthrough.
2359
+ """
2360
+ tag_id = self._resolve_tag_to_attach(tag, entity_id)
2307
2361
  entity_type = _detect_entity_type(entity_id)
2308
2362
  self._debug.info("api", f"tagging {entity_type} {entity_id} with '{tag}'")
2309
2363
  if entity_type == "article":
@@ -2312,8 +2366,13 @@ class TagOperations:
2312
2366
  self._tags.add_tag_to_issue(entity_id, tag_id)
2313
2367
 
2314
2368
  def remove(self, entity_id: str, tag: str) -> None:
2315
- """Remove a tag from an issue or article. Entity type is auto-detected."""
2316
- tag_id = self._resolve_tag(tag)
2369
+ """Remove a tag from an issue or article. Entity type is auto-detected.
2370
+
2371
+ Tag resolution is attached-anchored (KDCLI-198 S1): the name
2372
+ resolves only against the tags attached to the entity. The
2373
+ tag-id form (``\\d+-\\d+``) is a passthrough.
2374
+ """
2375
+ tag_id = self._resolve_attached_tag(tag, entity_id)
2317
2376
  entity_type = _detect_entity_type(entity_id)
2318
2377
  self._debug.info("api", f"untagging {entity_type} {entity_id} from '{tag}'")
2319
2378
  if entity_type == "article":
@@ -2359,8 +2418,35 @@ class TagOperations:
2359
2418
  if not tag_names:
2360
2419
  raise ValidationError("At least one tag is required")
2361
2420
  if len(tag_names) == 1 and project is None:
2362
- tag_id = self._resolve_tag(tag_names[0])
2421
+ arg = tag_names[0]
2422
+ tag_id = arg if _is_tag_id(arg) else self._resolve_tag(arg, command="issues")
2363
2423
  return self._tags.list_tag_issues(tag_id, top=top, skip=skip)
2424
+ # Multi-tag / project path composes a YouTrack query rather than
2425
+ # hitting ``/tags/{id}/issues``. YouTrack's ``tag:`` predicate is
2426
+ # name-keyed (see docs/specs/001-youtrack-api-reference.md §574
2427
+ # and kd/query_parser.py); there is no id-keyed tag predicate.
2428
+ # Accepting an id-form arg here would emit ``tag: 10-178`` which
2429
+ # YouTrack parses as the literal tag *name* "10-178" — wrong
2430
+ # rows, silently. Refuse before composition; the operator can
2431
+ # use the fast path (single tag, no --project) for id-keyed
2432
+ # precision or pass the name form (which refuses on collision).
2433
+ id_args = [a for a in tag_names if _is_tag_id(a)]
2434
+ if id_args:
2435
+ raise ValidationError(
2436
+ "Tag-id form ("
2437
+ + ", ".join(id_args)
2438
+ + ") is not supported with --project or multiple --tag arguments. "
2439
+ "YouTrack's query DSL identifies tags by name. Use the fast path "
2440
+ "(single tag, no --project) for id-keyed lookup, or pass the name "
2441
+ "form (which refuses on duplicate-name collision)."
2442
+ )
2443
+ # Every name arg is pre-validated via ``_resolve_tag`` so
2444
+ # duplicate visible names never reach the YouTrack query
2445
+ # composition. The resolver result is discarded — the composed
2446
+ # query uses the names directly, with YouTrack handling
2447
+ # server-side matching.
2448
+ for arg in tag_names:
2449
+ self._resolve_tag(arg, command="issues")
2364
2450
  tag_clauses = [f"tag: {youtrack_query_literal(name)}" for name in tag_names]
2365
2451
  joiner = " or " if match == "any" else " and "
2366
2452
  query = joiner.join(tag_clauses)
@@ -2386,7 +2472,10 @@ class TagOperations:
2386
2472
  *top* matches are collected or the article source is exhausted.
2387
2473
  """
2388
2474
  tag_names = [tags] if isinstance(tags, str) else tags
2389
- tag_ids = frozenset(self._resolve_tag(name) for name in tag_names)
2475
+ tag_ids = frozenset(
2476
+ arg if _is_tag_id(arg) else self._resolve_tag(arg, command="articles")
2477
+ for arg in tag_names
2478
+ )
2390
2479
  self._debug.info("api", f"scanning articles for tags {tag_names} ({tag_ids}) match={match}")
2391
2480
 
2392
2481
  project_id: str | None = None
@@ -2480,6 +2569,38 @@ class TagOperations:
2480
2569
  if not kinds:
2481
2570
  raise ValidationError("At least one kind is required")
2482
2571
 
2572
+ # KDCLI-200: refuse id-form when the issue half would route
2573
+ # through the composed-query path (--project set or multiple
2574
+ # tags) because YouTrack's ``tag:`` predicate is name-keyed —
2575
+ # accepting an id literal there would silently emit wrong
2576
+ # rows. The article half is client-side-filtered by id and
2577
+ # works fine with id-form, so the refusal is scoped to runs
2578
+ # that actually dispatch through ``issues()`` composed query.
2579
+ id_args = [a for a in tag_names if _is_tag_id(a)]
2580
+ issue_dispatched = "issue" in kinds
2581
+ issue_would_compose = project is not None or len(tag_names) > 1
2582
+ if id_args and issue_dispatched and issue_would_compose:
2583
+ raise ValidationError(
2584
+ "Tag-id form ("
2585
+ + ", ".join(id_args)
2586
+ + ") is not supported on /tag entities when the issue half "
2587
+ "would route through a composed query (--project set or "
2588
+ "multiple tags). YouTrack's query DSL identifies tags by "
2589
+ "name. Drop --project and pass a single tag for id-keyed "
2590
+ "issue lookup, restrict to --kind article (which filters "
2591
+ "by id client-side), or use the name form (which refuses "
2592
+ "on duplicate-name collision)."
2593
+ )
2594
+
2595
+ # Pre-validate every name argument before fanout so an
2596
+ # ambiguity diagnostic is the whole-command outcome rather than
2597
+ # a partial-success row. The per-kind ``except KdError`` below
2598
+ # (which converts transport-flavoured failures into a scanned=
2599
+ # False meta entry) MUST NOT swallow resolver refusal.
2600
+ for arg in tag_names:
2601
+ if not _is_tag_id(arg):
2602
+ self._resolve_tag(arg, command="entities")
2603
+
2483
2604
  defaults = limit_per_kind or {}
2484
2605
  limits = {
2485
2606
  "issue": defaults.get("issue", 50),
@@ -2551,22 +2672,301 @@ class TagOperations:
2551
2672
  )
2552
2673
  return TagEntitiesResult(results=results, meta=meta)
2553
2674
 
2554
- def _resolve_tag(self, name: str) -> str:
2555
- """Resolve tag name to ID. Cached for client lifetime."""
2675
+ def _invalidate_tag(self, tag_id: str) -> None:
2676
+ """Remove a single tag from every name-cache bucket containing it.
2677
+
2678
+ Called by :meth:`rename` and :meth:`delete` after a successful
2679
+ mutation. Sweeping every bucket (rather than relying on a
2680
+ caller-supplied key) keeps invalidation correct when the
2681
+ caller addressed the tag by id form and so does not know the
2682
+ original cached name. Buckets that empty out are dropped; other
2683
+ tags sharing the same case-folded name remain intact.
2684
+ """
2685
+ for key in list(self._name_cache.keys()):
2686
+ bucket = self._name_cache[key]
2687
+ bucket[:] = [t for t in bucket if t.id != tag_id]
2688
+ if not bucket:
2689
+ del self._name_cache[key]
2690
+
2691
+ def _resolve_tag(self, name: str, *, command: str) -> str:
2692
+ """Resolve a tag name to a single tag id.
2693
+
2694
+ Lazily builds a list-valued name cache on first call (one
2695
+ ``list_tags(top=500)`` round-trip) and reuses it for the
2696
+ client's lifetime, with surgical invalidation on
2697
+ create/rename/delete.
2698
+
2699
+ Three outcomes (KDCLI-200):
2700
+
2701
+ - exactly one visible tag with the given case-folded name →
2702
+ returns that id.
2703
+ - multiple visible tags share the name → refuses via
2704
+ :meth:`_format_tag_ambiguity` with the supplied ``command``
2705
+ so the caller's retry template renders correctly. The
2706
+ legacy last-one-wins behaviour is gone.
2707
+ - no visible match → :class:`NotFoundError` with the
2708
+ close-match hint, same shape as before.
2709
+
2710
+ ``command`` threads through to the ambiguity diagnostic; each
2711
+ call site is expected to pass its own verb. Tag-id form
2712
+ (``\\d+-\\d+``) is handled at the call sites (not here): names
2713
+ following kd's namespace convention never collide with the id
2714
+ shape, and the passthrough belongs alongside other input-shape
2715
+ decisions.
2716
+ """
2556
2717
  key = name.lower()
2557
- if key in self._name_cache:
2558
- return self._name_cache[key]
2559
- self._debug.info("api", f"resolving tag '{name}'")
2560
- all_tags = self._tags.list_tags(top=500)
2561
- for t in all_tags:
2562
- self._name_cache[t.name.lower()] = t.id
2563
- if key in self._name_cache:
2564
- return self._name_cache[key]
2565
- available = [t.name for t in all_tags]
2718
+ if not self._name_cache:
2719
+ self._debug.info("api", f"resolving tag '{name}'")
2720
+ for t in self._tags.list_tags(top=500):
2721
+ self._name_cache.setdefault(t.name.lower(), []).append(t)
2722
+ bucket = self._name_cache.get(key, [])
2723
+ if len(bucket) == 1:
2724
+ return bucket[0].id
2725
+ if len(bucket) > 1:
2726
+ raise self._format_tag_ambiguity(
2727
+ name=name,
2728
+ command=command,
2729
+ global_candidates=list(bucket),
2730
+ retry_template=_TAG_RETRY_TEMPLATES.get(command),
2731
+ inspect_hint=_TAG_INSPECT_HINTS.get(command),
2732
+ )
2733
+ available = [t.name for ts in self._name_cache.values() for t in ts]
2566
2734
  close = difflib.get_close_matches(name, available, n=3, cutoff=0.5)
2567
2735
  hint = f" Did you mean: {', '.join(close)}?" if close else ""
2568
2736
  raise NotFoundError(f"Tag '{name}' not found.{hint}")
2569
2737
 
2738
+ # --- Entity-anchored resolvers (KDCLI-198 S1) ---
2739
+ #
2740
+ # These bypass ``_resolve_tag`` / ``_name_cache`` deliberately: a
2741
+ # display name can map to multiple tag ids, and the global cache
2742
+ # silently picks one. ``/tag add`` and ``/tag remove`` both take an
2743
+ # entity argument, so the attached tag list disambiguates by
2744
+ # construction. S2 (KDCLI-200) replaces the global cache for the
2745
+ # remaining tag commands.
2746
+
2747
+ def _fetch_attached_tags(self, entity_id: str) -> builtins.list[Tag]:
2748
+ """Return the tags currently attached to ``entity_id``.
2749
+
2750
+ Uses the existing rich detail field set on both sides
2751
+ (``_ARTICLE_DETAIL_FIELDS``, ``_ISSUE_RICH_FIELDS``) which
2752
+ already projects ``tags(id,name)``. No additional request shape
2753
+ is required.
2754
+ """
2755
+ if _detect_entity_type(entity_id) == "article":
2756
+ return list(self._articles.get_article(entity_id).tags)
2757
+ return list(self._yt.get_issue(entity_id, rich=True).tags)
2758
+
2759
+ def _resolve_attached_tag(self, name_or_id: str, entity_id: str) -> str:
2760
+ """Resolve ``name_or_id`` to a tag id attached to ``entity_id``.
2761
+
2762
+ Used by ``/tag remove``. Refuses to fall through to a global
2763
+ lookup so the operator cannot accidentally address a same-name
2764
+ tag that is not on the entity.
2765
+
2766
+ - id-form (``\\d+-\\d+``) → passthrough; existence checked by
2767
+ the downstream HTTP call.
2768
+ - exactly one same-name attached → return that id.
2769
+ - none attached → :class:`ValidationError` ("not attached").
2770
+ When a same-name tag exists globally, the diagnostic lists it
2771
+ (without an ``[attached]`` marker) so the operator can see
2772
+ the tag is real but not on this entity.
2773
+ - multiple attached → :class:`ValidationError` with a candidate
2774
+ diagnostic; require the id form.
2775
+ """
2776
+ if _is_tag_id(name_or_id):
2777
+ return name_or_id
2778
+ attached = self._fetch_attached_tags(entity_id)
2779
+ key = name_or_id.lower()
2780
+ matches = [t for t in attached if t.name.lower() == key]
2781
+ if len(matches) == 1:
2782
+ return matches[0].id
2783
+ if not matches:
2784
+ global_candidates = [t for t in self._tags.list_tags(top=500) if t.name.lower() == key]
2785
+ raise self._format_tag_ambiguity(
2786
+ name=name_or_id,
2787
+ entity_id=entity_id,
2788
+ attached=attached,
2789
+ command="remove",
2790
+ global_candidates=global_candidates,
2791
+ reason="not_attached",
2792
+ )
2793
+ raise self._format_tag_ambiguity(
2794
+ name=name_or_id,
2795
+ entity_id=entity_id,
2796
+ attached=attached,
2797
+ command="remove",
2798
+ retry_template="kd /tag {command} {entity_id} {id}",
2799
+ )
2800
+
2801
+ def _resolve_tag_to_attach(self, name_or_id: str, entity_id: str) -> str:
2802
+ """Resolve ``name_or_id`` to a tag id suitable for ``/tag add``.
2803
+
2804
+ - id-form (``\\d+-\\d+``) → passthrough.
2805
+ - one same-name tag already attached → return that id
2806
+ (idempotent re-tag).
2807
+ - multiple same-name tags attached → :class:`ValidationError`
2808
+ with a candidate diagnostic; require the id form.
2809
+ - none attached, exactly one visible global match → return that
2810
+ id.
2811
+ - none attached, multiple visible global matches →
2812
+ :class:`ValidationError` with a candidate diagnostic.
2813
+ - none attached, no visible global match → :class:`NotFoundError`
2814
+ with a close-match hint (same shape as :meth:`_resolve_tag`).
2815
+ """
2816
+ if _is_tag_id(name_or_id):
2817
+ return name_or_id
2818
+ attached = self._fetch_attached_tags(entity_id)
2819
+ key = name_or_id.lower()
2820
+ attached_matches = [t for t in attached if t.name.lower() == key]
2821
+ if len(attached_matches) == 1:
2822
+ return attached_matches[0].id
2823
+ if len(attached_matches) > 1:
2824
+ raise self._format_tag_ambiguity(
2825
+ name=name_or_id,
2826
+ entity_id=entity_id,
2827
+ attached=attached,
2828
+ command="add",
2829
+ retry_template="kd /tag {command} {entity_id} {id}",
2830
+ )
2831
+ all_tags = self._tags.list_tags(top=500)
2832
+ global_matches = [t for t in all_tags if t.name.lower() == key]
2833
+ if len(global_matches) == 1:
2834
+ return global_matches[0].id
2835
+ if len(global_matches) > 1:
2836
+ raise self._format_tag_ambiguity(
2837
+ name=name_or_id,
2838
+ entity_id=entity_id,
2839
+ attached=attached,
2840
+ command="add",
2841
+ global_candidates=global_matches,
2842
+ retry_template="kd /tag {command} {entity_id} {id}",
2843
+ )
2844
+ available = [t.name for t in all_tags]
2845
+ close = difflib.get_close_matches(name_or_id, available, n=3, cutoff=0.5)
2846
+ hint = f" Did you mean: {', '.join(close)}?" if close else ""
2847
+ raise NotFoundError(f"Tag '{name_or_id}' not found.{hint}")
2848
+
2849
+ def _format_tag_ambiguity(
2850
+ self,
2851
+ *,
2852
+ name: str,
2853
+ command: str,
2854
+ entity_id: str | None = None,
2855
+ attached: Sequence[Tag] = (),
2856
+ global_candidates: builtins.list[Tag] | None = None,
2857
+ reason: Literal["ambiguous", "not_attached"] = "ambiguous",
2858
+ retry_template: str | None = None,
2859
+ inspect_hint: str | None = None,
2860
+ ) -> ValidationError:
2861
+ """Build a :class:`ValidationError` carrying the shared tag
2862
+ diagnostic.
2863
+
2864
+ Candidates are the union of (same-name tags attached to the
2865
+ entity) and (same-name tags visible globally), de-duplicated by
2866
+ id. ``global_candidates`` is the pre-fetched global slice when
2867
+ the caller already paid for ``list_tags`` — otherwise this
2868
+ helper fetches it so attached-only ambiguity still gets enriched
2869
+ with ``owner=`` metadata.
2870
+
2871
+ ``entity_id`` is omitted by S2 (global-command) callers; the
2872
+ header drops the "on <entity>" clause and ``[attached]`` markers
2873
+ do not appear because ``attached`` defaults to empty.
2874
+
2875
+ ``retry_template`` is rendered once per candidate via
2876
+ ``.format(id=..., entity_id=..., command=...)``. When ``None``,
2877
+ the retry block is omitted entirely (``/tag create`` collision
2878
+ path, where ``create <id>`` is not a valid retry). The
2879
+ ``inspect_hint`` parameter appends a single trailer line for the
2880
+ no-retry case (e.g. ``kd /tag show <id>``).
2881
+
2882
+ ``reason="ambiguous"`` (default): the name resolves to multiple
2883
+ candidates that the resolver cannot disambiguate. The diagnostic
2884
+ lists candidates and the retry block proposes id-form retries.
2885
+ When exactly one candidate is attached the retry block points to
2886
+ that id (the operator clearly meant the attached one); otherwise
2887
+ every candidate gets its own retry line so the emitter never
2888
+ picks for the operator.
2889
+
2890
+ ``reason="not_attached"``: the name is not on the entity. When
2891
+ same-name globals exist they are listed (no ``[attached]``
2892
+ markers, since none are) so the operator can see the tag is
2893
+ real but not here; there is no retry block. When no globals
2894
+ match, the message is the bare "not attached" sentence.
2895
+ ``reason="not_attached"`` is an S1 (entity-anchored) shape and
2896
+ requires ``entity_id``.
2897
+ """
2898
+ key = name.lower()
2899
+ if global_candidates is None:
2900
+ global_candidates = [t for t in self._tags.list_tags(top=500) if t.name.lower() == key]
2901
+ attached_ids = {t.id for t in attached if t.name.lower() == key}
2902
+ # Prefer the global slice for each id (it carries owner metadata
2903
+ # that the attached tags list, projected as ``tags(id,name)``,
2904
+ # does not). Fall back to the attached entry only when the tag
2905
+ # is invisible to the running principal in ``list_tags``.
2906
+ by_id: dict[str, Tag] = {t.id: t for t in global_candidates}
2907
+ for t in attached:
2908
+ if t.name.lower() == key:
2909
+ by_id.setdefault(t.id, t)
2910
+ candidates = sorted(by_id.values(), key=lambda t: t.id)
2911
+
2912
+ if reason == "not_attached":
2913
+ lines = [f"Tag '{name}' is not attached to {entity_id}."]
2914
+ if candidates:
2915
+ lines.extend(
2916
+ [
2917
+ "",
2918
+ "Same-name tags exist globally but are not on this entity:",
2919
+ ]
2920
+ )
2921
+ for t in candidates:
2922
+ lines.append(f" {t.id} {t.name} owner={_format_owner(t)}")
2923
+ return ValidationError("\n".join(lines))
2924
+
2925
+ header = (
2926
+ f"Tag '{name}' is ambiguous on {entity_id}."
2927
+ if entity_id is not None
2928
+ else f"Tag '{name}' is ambiguous."
2929
+ )
2930
+ lines = [header, "", "Candidates:"]
2931
+ for t in candidates:
2932
+ mark = " [attached]" if t.id in attached_ids else ""
2933
+ lines.append(f" {t.id} {t.name} owner={_format_owner(t)}{mark}")
2934
+ attached_candidates = [t for t in candidates if t.id in attached_ids]
2935
+ if retry_template is not None:
2936
+ lines.append("")
2937
+ if len(attached_candidates) == 1:
2938
+ # Exactly one attached among many candidates: the
2939
+ # operator clearly meant the attached one — emit a
2940
+ # single retry.
2941
+ only = attached_candidates[0]
2942
+ lines.extend(
2943
+ [
2944
+ "Retry with the tag id:",
2945
+ " "
2946
+ + retry_template.format(
2947
+ id=only.id,
2948
+ entity_id=entity_id,
2949
+ command=command,
2950
+ ),
2951
+ ]
2952
+ )
2953
+ else:
2954
+ # Zero or multiple attached: do not pick. List every
2955
+ # candidate's retry so the operator chooses.
2956
+ lines.append("Retry with one of:")
2957
+ for t in candidates:
2958
+ lines.append(
2959
+ " "
2960
+ + retry_template.format(
2961
+ id=t.id,
2962
+ entity_id=entity_id,
2963
+ command=command,
2964
+ )
2965
+ )
2966
+ elif inspect_hint is not None:
2967
+ lines.extend(["", "Inspect a candidate with:", f" {inspect_hint}"])
2968
+ return ValidationError("\n".join(lines))
2969
+
2570
2970
 
2571
2971
  class SavedQueryOperations:
2572
2972
  """High-level saved-query operations with name-or-ID resolution."""
@@ -4,6 +4,38 @@ All notable changes to kd-cli are recorded here. The project follows [Semantic V
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.9.0] — 2026-05-12
8
+
9
+ ### Changed
10
+ - Tag-name resolution now refuses on visible-global name collisions
11
+ instead of silently picking one tag id. The legacy last-one-wins
12
+ behaviour is gone across the entire `/tag` namespace: `/tag show`,
13
+ `/tag delete`, `/tag rename`, `/tag create`, `/tag add`, `/tag remove`,
14
+ `/tag issues`, `/tag articles`, and `/tag entities` all surface a
15
+ structured candidate list (id, name, owner) when two visible tags
16
+ share a display name. Each candidate is rendered with a retry shape
17
+ appropriate to the command (`kd /tag show <id>`, `kd /tag rename
18
+ <id> <new-name>`, etc.); `/tag create` lists candidates without a
19
+ retry block and suggests `kd /tag show <id>` for inspection. The
20
+ tag-id form (`NN-NN`) is a passthrough on every command that accepts
21
+ a tag identifier — use it to disambiguate (KDCLI-198: KDCLI-199 +
22
+ KDCLI-200).
23
+ - `/tag issues` and `/tag entities` pre-validate every tag argument
24
+ before composing the YouTrack query or fanning out across kinds, so
25
+ ambiguity refusal is a hard precondition rather than a partial-
26
+ success row. The multi-tag / project slow path on `/tag issues`
27
+ previously bypassed the resolver entirely; that gap is closed.
28
+ - Tag-id form scope is narrower on `/tag issues` and `/tag entities`
29
+ than on the other tag commands because YouTrack's `tag:` query DSL
30
+ predicate is name-keyed (no id-keyed predicate exists). `/tag issues`
31
+ accepts id form on the fast path only (single tag, no `--project`);
32
+ any combination with `--project` or multiple `--tag` arguments
33
+ refuses with guidance. `/tag entities` accepts id form when the
34
+ issue half stays on the fast path or when only `--kind article` is
35
+ requested; combining id form with `--project` or multiple tags AND
36
+ the issue kind refuses. The other tag commands (`show`, `delete`,
37
+ `rename`, `add`, `remove`, `articles`) are unaffected.
38
+
7
39
  ## [2.8.1] — 2026-05-08
8
40
 
9
41
  ### Fixed
@@ -257,6 +257,24 @@ Do not treat `--primer-scope` as implicit global state. It is a deliberate flag
257
257
 
258
258
  `/tag create TAG_NAME` is idempotent. It resolves the name first (same case-insensitive resolver `/tag show` uses) and returns `{ok, action, tag}` with `action: exists` when the tag is already visible, or `action: create` after a successful POST. Taxonomy bootstrapping and fixture setup can re-run the command safely without decoding `Tag.name-is-invalid`. When the POST still fails — either a genuinely unacceptable name or an existing tag hidden by sharing/visibility — kd surfaces a factual diagnostic naming both plausible causes rather than the raw upstream error.
259
259
 
260
+ ### Tag Name Resolution And Duplicates
261
+
262
+ Tags are a **global** YouTrack entity. Two different users can independently create tags with the same display name (e.g., one automation account owns a private `lifecycle:draft` taxonomy and a user re-creates `lifecycle:draft` they cannot see) — both tags are real and both can be visible to a third principal. The case-folded name is therefore not a unique identifier; the tag id (`NN-NN`) is.
263
+
264
+ `kd` exposes this distinction directly:
265
+
266
+ - **Name form (`sprint-47`, `lifecycle:draft`).** Used by every tag command. Resolves to exactly one visible tag, or refuses with a structured candidate list listing each id and owner. The resolver never silently picks among duplicates.
267
+ - **Id form (`NN-NN`, e.g. `10-178`).** Skips name resolution. Accepted by `/tag show`, `/tag delete`, `/tag rename` (first arg), `/tag add`, `/tag remove`, `/tag articles`, and — with scope limits — `/tag issues` and `/tag entities`. Use the id form to disambiguate when a name collides.
268
+
269
+ The id-form scope for `/tag issues` and `/tag entities` is narrower because YouTrack's query DSL `tag:` predicate is name-keyed; there is no id-keyed tag predicate. Concretely:
270
+
271
+ - `/tag issues TAG_NAME_OR_ID` — id form works on the fast path (single tag, no `--project`). Add `--project` or another `--tag`, and the id form is refused with guidance to use the fast path or the name form.
272
+ - `/tag entities TAG_NAME_OR_ID` — id form works when the issue half stays on the fast path (single tag, no `--project`) or when the run is restricted to `--kind article` (article filtering is client-side by id). With `--project` or multiple tags AND the issue kind included, id form is refused.
273
+
274
+ `/tag create TAG_NAME` is the one exception: it refuses on visible-global collision rather than silently returning "the first existing tag", and does not accept an id form (creating with an existing id is meaningless). Inspect each candidate with `kd /tag show <id>` to decide whether to reuse or rename one.
275
+
276
+ `/tag entities` pre-validates every tag argument before fanout. Ambiguity refusal is therefore the whole-command outcome — duplicate-name refusal is never reported as a per-kind partial-success row.
277
+
260
278
  ## Output And Debugging
261
279
 
262
280
  For deterministic machine consumption, always pass an explicit output format:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.8.1
3
+ Version: 2.9.0
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kd-cli"
7
- version = "2.8.1"
7
+ version = "2.9.0"
8
8
  description = "Knowledge Dispatcher for issue trackers and knowledge bases"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes