kd-cli 2.4.0__tar.gz → 2.4.1__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 (82) hide show
  1. {kd_cli-2.4.0/kd_cli.egg-info → kd_cli-2.4.1}/PKG-INFO +1 -1
  2. kd_cli-2.4.1/kd/locking.py +97 -0
  3. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/primer.py +74 -64
  4. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/resources/CHANGELOG.md +5 -0
  5. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/resources/operating-guide.md +2 -0
  6. {kd_cli-2.4.0 → kd_cli-2.4.1/kd_cli.egg-info}/PKG-INFO +1 -1
  7. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd_cli.egg-info/SOURCES.txt +1 -0
  8. {kd_cli-2.4.0 → kd_cli-2.4.1}/pyproject.toml +1 -1
  9. {kd_cli-2.4.0 → kd_cli-2.4.1}/LICENSE +0 -0
  10. {kd_cli-2.4.0 → kd_cli-2.4.1}/MANIFEST.in +0 -0
  11. {kd_cli-2.4.0 → kd_cli-2.4.1}/README.md +0 -0
  12. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/__init__.py +0 -0
  13. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/api/__init__.py +0 -0
  14. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/api/articles.py +0 -0
  15. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/api/http.py +0 -0
  16. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/api/hub.py +0 -0
  17. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/api/saved_queries.py +0 -0
  18. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/api/tags.py +0 -0
  19. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/api/youtrack.py +0 -0
  20. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/article_marker.py +0 -0
  21. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/auth.py +0 -0
  22. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/__init__.py +0 -0
  23. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/core/__init__.py +0 -0
  24. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/core/command_base.py +0 -0
  25. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/core/command_parser.py +0 -0
  26. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/core/confirm.py +0 -0
  27. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/core/namespaces.py +0 -0
  28. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/core/option_registry.py +0 -0
  29. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/input.py +0 -0
  30. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/main.py +0 -0
  31. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/__init__.py +0 -0
  32. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/alias/__init__.py +0 -0
  33. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/article/__init__.py +0 -0
  34. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/init.py +0 -0
  35. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/issue/__init__.py +0 -0
  36. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/primer/__init__.py +0 -0
  37. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/project/__init__.py +0 -0
  38. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/query/__init__.py +0 -0
  39. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/release_notes.py +0 -0
  40. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/search/__init__.py +0 -0
  41. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/system/__init__.py +0 -0
  42. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/tag/__init__.py +0 -0
  43. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/time/__init__.py +0 -0
  44. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/user/__init__.py +0 -0
  45. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/output.py +0 -0
  46. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/tree.py +0 -0
  47. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/client.py +0 -0
  48. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/config.py +0 -0
  49. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/config_read.py +0 -0
  50. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/config_schema.py +0 -0
  51. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/connection.py +0 -0
  52. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/debug.py +0 -0
  53. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/exceptions.py +0 -0
  54. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/history.py +0 -0
  55. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/__init__.py +0 -0
  56. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/article.py +0 -0
  57. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/assist.py +0 -0
  58. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/attachment.py +0 -0
  59. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/comment.py +0 -0
  60. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/common.py +0 -0
  61. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/issue.py +0 -0
  62. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/project.py +0 -0
  63. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/saved_query.py +0 -0
  64. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/search.py +0 -0
  65. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/tag.py +0 -0
  66. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/tag_entities.py +0 -0
  67. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/user.py +0 -0
  68. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/work_item.py +0 -0
  69. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/operating_guide.py +0 -0
  70. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/query_parser.py +0 -0
  71. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/release_notes.py +0 -0
  72. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/resources/__init__.py +0 -0
  73. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/resources/command-synonyms.yaml +0 -0
  74. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/resources/config-template.yaml +0 -0
  75. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/resources/error-messages.yaml +0 -0
  76. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/resources/primers/__init__.py +0 -0
  77. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/resources/primers/hitl.yaml +0 -0
  78. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd_cli.egg-info/dependency_links.txt +0 -0
  79. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd_cli.egg-info/entry_points.txt +0 -0
  80. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd_cli.egg-info/requires.txt +0 -0
  81. {kd_cli-2.4.0 → kd_cli-2.4.1}/kd_cli.egg-info/top_level.txt +0 -0
  82. {kd_cli-2.4.0 → kd_cli-2.4.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.4.0
3
+ Version: 2.4.1
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -0,0 +1,97 @@
1
+ """Advisory file locking for read-modify-write transactions on kd-managed files.
2
+
3
+ Provides :func:`file_transaction` — a context manager that acquires an
4
+ exclusive advisory lock on a sidecar lockfile (``<path>.lock``) for the
5
+ duration of a load → mutate → save cycle. Concurrent kd processes that
6
+ enter the same transaction serialize rather than clobber each other.
7
+
8
+ Design notes:
9
+
10
+ * **Sidecar lockfile, not the target file.** Atomic writes elsewhere in
11
+ kd use ``tempfile.mkstemp`` + ``os.replace``, which changes the target
12
+ file's inode on every write. Holding ``flock`` on the target inode
13
+ would not protect a second writer that opens the freshly-replaced file.
14
+ Locking a separate sidecar file that nobody rewrites keeps the lock
15
+ stable across atomic replaces of the data file.
16
+
17
+ * **No unlink on release.** Removing the lockfile after releasing the
18
+ lock is a classic race: process B can open the doomed inode while
19
+ process C creates a fresh one, and both end up holding "exclusive"
20
+ locks on different inodes. Empty sidecar files are cheap; leave them.
21
+
22
+ * **POSIX only.** ``fcntl`` is imported lazily inside the context
23
+ manager so that unrelated kd commands remain importable on Windows.
24
+ On non-POSIX platforms, :func:`file_transaction` raises ``ConfigError``
25
+ with a suggestion to run the command sequentially.
26
+
27
+ * **NFS caveat.** ``flock`` on NFS has been unreliable historically.
28
+ Modern Linux kernels implement it correctly, but operators using a
29
+ networked ``$HOME`` should be aware that concurrency guarantees weaken.
30
+ Local ``.kd/`` directories inside a repo are unaffected.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ import sys
37
+ import time
38
+ from collections.abc import Generator
39
+ from contextlib import contextmanager
40
+ from pathlib import Path
41
+
42
+ from kd.exceptions import ConfigError
43
+
44
+ LOCK_SUFFIX = ".lock"
45
+ _POLL_INTERVAL_SECONDS = 0.05
46
+
47
+
48
+ @contextmanager
49
+ def file_transaction(path: Path, *, timeout: float = 30.0) -> Generator[None, None, None]:
50
+ """Hold an exclusive advisory lock on ``<path>.lock`` for the ``with`` body.
51
+
52
+ The sidecar lockfile is created if missing (``O_RDWR | O_CREAT``,
53
+ never truncated). Acquisition polls non-blocking ``flock`` until
54
+ the lock is granted or *timeout* seconds elapse. On timeout, raises
55
+ :class:`ConfigError` naming the contested file. The lockfile is
56
+ intentionally left on disk after release.
57
+
58
+ Raises :class:`ConfigError` on non-POSIX platforms.
59
+ """
60
+ if sys.platform == "win32":
61
+ raise ConfigError(
62
+ "File locking is not available on this platform (POSIX only).",
63
+ suggestion="Run the kd command sequentially; concurrent mutations are not supported on Windows.",
64
+ )
65
+
66
+ import fcntl # noqa: PLC0415 — lazy import keeps kd importable on Windows
67
+
68
+ lock_path = path.with_name(path.name + LOCK_SUFFIX)
69
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
70
+
71
+ fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644)
72
+ try:
73
+ deadline = time.monotonic() + timeout
74
+ while True:
75
+ try:
76
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
77
+ break
78
+ except BlockingIOError:
79
+ if time.monotonic() >= deadline:
80
+ raise ConfigError(
81
+ f"Another kd process is writing to {path}; retry in a moment.",
82
+ suggestion=f"If no other kd process is running, remove the stale lockfile: {lock_path}",
83
+ ) from None
84
+ time.sleep(_POLL_INTERVAL_SECONDS)
85
+
86
+ try:
87
+ yield
88
+ finally:
89
+ try:
90
+ fcntl.flock(fd, fcntl.LOCK_UN)
91
+ except OSError:
92
+ pass
93
+ finally:
94
+ try:
95
+ os.close(fd)
96
+ except OSError:
97
+ pass
@@ -11,6 +11,8 @@ knowledge). The file is created lazily on first write operation.
11
11
  from __future__ import annotations
12
12
 
13
13
  import importlib.resources
14
+ from collections.abc import Generator
15
+ from contextlib import contextmanager
14
16
  from pathlib import Path
15
17
  from typing import Any
16
18
 
@@ -18,6 +20,7 @@ import yaml
18
20
  from pydantic import BaseModel
19
21
 
20
22
  from kd.exceptions import ConfigError, ValidationError
23
+ from kd.locking import file_transaction
21
24
 
22
25
 
23
26
  # ---------------------------------------------------------------------------
@@ -125,10 +128,15 @@ def load_primer(path: Path | None = None) -> PrimerData:
125
128
 
126
129
 
127
130
  def save_primer(data: PrimerData, path: Path | None = None) -> None:
128
- """Write primer to YAML.
131
+ """Write primer to YAML — low-level primitive.
129
132
 
130
133
  Snapshots the current file before writing (copy-on-write) and uses
131
- atomic writes to prevent partial-write corruption.
134
+ atomic writes to prevent partial-write corruption. Does **not**
135
+ serialize concurrent writers on its own; callers that do
136
+ ``load_primer → mutate → save_primer`` across processes risk lost
137
+ updates. Prefer the mutator functions below (:func:`add_note`,
138
+ :func:`update_note`, etc.), which wrap load/save in
139
+ :func:`primer_transaction` to guarantee per-file serialization.
132
140
  """
133
141
  from kd.history import atomic_write_text, snapshot_before_write
134
142
 
@@ -172,46 +180,56 @@ def _tag_dump(tag: dict[str, Any]) -> dict[str, Any]:
172
180
  # ---------------------------------------------------------------------------
173
181
 
174
182
 
183
+ @contextmanager
184
+ def _primer_transaction(path: Path) -> Generator[PrimerData, None, None]:
185
+ """Serialize a primer read-modify-write cycle under an advisory lock.
186
+
187
+ Holds an exclusive advisory lock on ``<path>.lock`` for the full
188
+ cycle: loads the primer, yields a ``PrimerData`` for the caller to
189
+ mutate in place, and saves on clean exit. If the caller raises,
190
+ the save is skipped (the file is not modified).
191
+ """
192
+ with file_transaction(path):
193
+ data = load_primer(path)
194
+ yield data
195
+ save_primer(data, path)
196
+
197
+
175
198
  def add_note(text: str, path: Path | None = None) -> int:
176
199
  """Append a note. Returns the 1-based index."""
177
200
  primer_file = path or _primer_path()
178
- data = load_primer(primer_file)
179
- data.notes.append(text)
180
- save_primer(data, primer_file)
181
- return len(data.notes)
201
+ with _primer_transaction(primer_file) as data:
202
+ data.notes.append(text)
203
+ return len(data.notes)
182
204
 
183
205
 
184
206
  def remove_note(index: int, path: Path | None = None) -> str:
185
207
  """Remove a note by 1-based index. Returns the removed text."""
186
208
  primer_file = path or _primer_path()
187
- data = load_primer(primer_file)
188
- if index < 1 or index > len(data.notes):
189
- raise ValidationError(f"Note index {index} out of range (1–{len(data.notes)})")
190
- removed = data.notes.pop(index - 1)
191
- save_primer(data, primer_file)
192
- return removed
209
+ with _primer_transaction(primer_file) as data:
210
+ if index < 1 or index > len(data.notes):
211
+ raise ValidationError(f"Note index {index} out of range (1–{len(data.notes)})")
212
+ return data.notes.pop(index - 1)
193
213
 
194
214
 
195
215
  def update_note(index: int, text: str, path: Path | None = None) -> str:
196
216
  """Replace a note by 1-based index. Returns the old text."""
197
217
  primer_file = path or _primer_path()
198
- data = load_primer(primer_file)
199
- if index < 1 or index > len(data.notes):
200
- raise ValidationError(f"Note index {index} out of range (1–{len(data.notes)})")
201
- old = data.notes[index - 1]
202
- data.notes[index - 1] = text
203
- save_primer(data, primer_file)
204
- return old
218
+ with _primer_transaction(primer_file) as data:
219
+ if index < 1 or index > len(data.notes):
220
+ raise ValidationError(f"Note index {index} out of range (1–{len(data.notes)})")
221
+ old = data.notes[index - 1]
222
+ data.notes[index - 1] = text
223
+ return old
205
224
 
206
225
 
207
226
  def clear_notes(path: Path | None = None) -> int:
208
227
  """Remove all notes. Returns the count removed."""
209
228
  primer_file = path or _primer_path()
210
- data = load_primer(primer_file)
211
- count = len(data.notes)
212
- data.notes.clear()
213
- save_primer(data, primer_file)
214
- return count
229
+ with _primer_transaction(primer_file) as data:
230
+ count = len(data.notes)
231
+ data.notes.clear()
232
+ return count
215
233
 
216
234
 
217
235
  def add_article(
@@ -219,71 +237,63 @@ def add_article(
219
237
  ) -> PrimerArticle:
220
238
  """Pin an article. Raises if already pinned."""
221
239
  primer_file = path or _primer_path()
222
- data = load_primer(primer_file)
223
- for existing in data.articles:
224
- if existing.id == article_id:
225
- raise ValidationError(f"Article '{article_id}' is already pinned")
226
- article = PrimerArticle(id=article_id, summary=summary)
227
- data.articles.append(article)
228
- save_primer(data, primer_file)
229
- return article
240
+ with _primer_transaction(primer_file) as data:
241
+ for existing in data.articles:
242
+ if existing.id == article_id:
243
+ raise ValidationError(f"Article '{article_id}' is already pinned")
244
+ article = PrimerArticle(id=article_id, summary=summary)
245
+ data.articles.append(article)
246
+ return article
230
247
 
231
248
 
232
249
  def remove_article(article_id: str, path: Path | None = None) -> PrimerArticle:
233
250
  """Unpin an article by ID. Returns the removed entry."""
234
251
  primer_file = path or _primer_path()
235
- data = load_primer(primer_file)
236
- for i, article in enumerate(data.articles):
237
- if article.id == article_id:
238
- removed = data.articles.pop(i)
239
- save_primer(data, primer_file)
240
- return removed
241
- raise ValidationError(f"Article '{article_id}' is not pinned")
252
+ with _primer_transaction(primer_file) as data:
253
+ for i, article in enumerate(data.articles):
254
+ if article.id == article_id:
255
+ return data.articles.pop(i)
256
+ raise ValidationError(f"Article '{article_id}' is not pinned")
242
257
 
243
258
 
244
259
  def clear_articles(path: Path | None = None) -> int:
245
260
  """Remove all pinned articles. Returns the count removed."""
246
261
  primer_file = path or _primer_path()
247
- data = load_primer(primer_file)
248
- count = len(data.articles)
249
- data.articles.clear()
250
- save_primer(data, primer_file)
251
- return count
262
+ with _primer_transaction(primer_file) as data:
263
+ count = len(data.articles)
264
+ data.articles.clear()
265
+ return count
252
266
 
253
267
 
254
268
  def add_tag(name: str, summary: str | None = None, path: Path | None = None) -> PrimerTag:
255
269
  """Pin a tag. Raises if already pinned."""
256
270
  primer_file = path or _primer_path()
257
- data = load_primer(primer_file)
258
- for existing in data.tags:
259
- if existing.name == name:
260
- raise ValidationError(f"Tag '{name}' is already pinned")
261
- tag = PrimerTag(name=name, summary=summary)
262
- data.tags.append(tag)
263
- save_primer(data, primer_file)
264
- return tag
271
+ with _primer_transaction(primer_file) as data:
272
+ for existing in data.tags:
273
+ if existing.name == name:
274
+ raise ValidationError(f"Tag '{name}' is already pinned")
275
+ tag = PrimerTag(name=name, summary=summary)
276
+ data.tags.append(tag)
277
+ return tag
265
278
 
266
279
 
267
280
  def remove_tag(name: str, path: Path | None = None) -> PrimerTag:
268
281
  """Unpin a tag by name. Returns the removed entry."""
269
282
  primer_file = path or _primer_path()
270
- data = load_primer(primer_file)
271
- for i, tag in enumerate(data.tags):
272
- if tag.name == name:
273
- removed = data.tags.pop(i)
274
- save_primer(data, primer_file)
275
- return removed
276
- raise ValidationError(f"Tag '{name}' is not pinned")
283
+ with _primer_transaction(primer_file) as data:
284
+ for i, tag in enumerate(data.tags):
285
+ if tag.name == name:
286
+ return data.tags.pop(i)
287
+ raise ValidationError(f"Tag '{name}' is not pinned")
277
288
 
278
289
 
279
290
  def clear_tags(path: Path | None = None) -> int:
280
291
  """Remove all pinned tags. Returns the count removed."""
281
292
  primer_file = path or _primer_path()
282
- data = load_primer(primer_file)
283
- count = len(data.tags)
284
- data.tags.clear()
285
- save_primer(data, primer_file)
286
- return count
293
+ with _primer_transaction(primer_file) as data:
294
+ count = len(data.tags)
295
+ data.tags.clear()
296
+ return count
287
297
 
288
298
 
289
299
  # ---------------------------------------------------------------------------
@@ -4,6 +4,11 @@ All notable changes to kd-cli are recorded here. The project follows [Semantic V
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.4.1] — 2026-04-19
8
+
9
+ ### Fixed
10
+ - Concurrent `kd /primer/note`, `/primer/tag`, and `/primer/article` commands against the same `.kd/primer.yaml` no longer silently lose writes. Before: running three `kd /primer/note update` calls in parallel (common in agent-driven sessions) all reported `ok: true` but only one update survived. Now: each command runs inside an advisory file lock on a sidecar `.kd/primer.yaml.lock`, so concurrent kd processes queue rather than clobber each other and every successful call is reflected in the final file. Concurrent acquirers wait up to 30 s before erroring with `Another kd process is writing to …; retry in a moment`; the error includes the path of the sidecar lockfile for stale-holder recovery. POSIX only; first lock attempt on Windows raises a clear error pointing at sequential invocation (KDCLI-98).
11
+
7
12
  ## [2.4.0] — 2026-04-19
8
13
 
9
14
  ### Added
@@ -146,6 +146,8 @@ When both pinned tags and pinned articles exist, read them in order: notes tell
146
146
 
147
147
  Keep the primer local and curated. If a note becomes generally true for kd, move it into docs or this guide. If it is workspace-specific, keep it in `.kd/primer.yaml`.
148
148
 
149
+ Primer mutations are serialized per file through an advisory lock on `.kd/primer.yaml.lock`; concurrent kd processes writing to the same primer queue rather than conflict, so agent-launched parallel `/primer/note`, `/primer/tag`, and `/primer/article` commands all converge without lost updates.
150
+
149
151
  ## Articles And Push
150
152
 
151
153
  Use `/article show` for metadata plus rendered content. Use `/article pull` when you need raw article content on stdout; `pull` is format-agnostic and ignores `--output`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.4.0
3
+ Version: 2.4.1
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -13,6 +13,7 @@ kd/connection.py
13
13
  kd/debug.py
14
14
  kd/exceptions.py
15
15
  kd/history.py
16
+ kd/locking.py
16
17
  kd/operating_guide.py
17
18
  kd/primer.py
18
19
  kd/query_parser.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kd-cli"
7
- version = "2.4.0"
7
+ version = "2.4.1"
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