kd-cli 2.3.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.
- {kd_cli-2.3.0/kd_cli.egg-info → kd_cli-2.4.1}/PKG-INFO +1 -1
- kd_cli-2.4.1/kd/cli/core/confirm.py +43 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/core/option_registry.py +1 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/main.py +2 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/namespaces/primer/__init__.py +49 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/namespaces/project/__init__.py +25 -1
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/namespaces/tag/__init__.py +25 -1
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/namespaces/user/__init__.py +34 -4
- kd_cli-2.4.1/kd/locking.py +97 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/primer.py +121 -64
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/resources/CHANGELOG.md +11 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/resources/operating-guide.md +13 -1
- kd_cli-2.4.1/kd/resources/primers/__init__.py +0 -0
- kd_cli-2.4.1/kd/resources/primers/hitl.yaml +41 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1/kd_cli.egg-info}/PKG-INFO +1 -1
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd_cli.egg-info/SOURCES.txt +4 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/pyproject.toml +2 -2
- {kd_cli-2.3.0 → kd_cli-2.4.1}/LICENSE +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/MANIFEST.in +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/README.md +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/__init__.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/api/__init__.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/api/articles.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/api/http.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/api/hub.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/api/saved_queries.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/api/tags.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/api/youtrack.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/article_marker.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/auth.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/__init__.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/core/__init__.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/core/command_base.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/core/command_parser.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/core/namespaces.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/input.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/namespaces/__init__.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/namespaces/alias/__init__.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/namespaces/article/__init__.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/namespaces/init.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/namespaces/issue/__init__.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/namespaces/query/__init__.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/namespaces/release_notes.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/namespaces/search/__init__.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/namespaces/system/__init__.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/namespaces/time/__init__.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/output.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/cli/tree.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/client.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/config.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/config_read.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/config_schema.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/connection.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/debug.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/exceptions.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/history.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/models/__init__.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/models/article.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/models/assist.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/models/attachment.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/models/comment.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/models/common.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/models/issue.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/models/project.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/models/saved_query.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/models/search.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/models/tag.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/models/tag_entities.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/models/user.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/models/work_item.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/operating_guide.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/query_parser.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/release_notes.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/resources/__init__.py +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/resources/command-synonyms.yaml +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/resources/config-template.yaml +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd/resources/error-messages.yaml +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd_cli.egg-info/dependency_links.txt +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd_cli.egg-info/entry_points.txt +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd_cli.egg-info/requires.txt +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/kd_cli.egg-info/top_level.txt +0 -0
- {kd_cli-2.3.0 → kd_cli-2.4.1}/setup.cfg +0 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Confirmation guard for irreversible destructive commands.
|
|
2
|
+
|
|
3
|
+
Some destructive commands bypass YouTrack's Deleted Items safety net —
|
|
4
|
+
project, tag, and user deletes in particular. This helper enforces an
|
|
5
|
+
opt-in ``--confirm`` flag: without it the command aborts with an
|
|
6
|
+
actionable multi-line error; with it the delete proceeds.
|
|
7
|
+
|
|
8
|
+
The error follows the ``_missing_tag_resolution`` pattern from
|
|
9
|
+
``kd.client``: a human-readable explanation plus a ``suggestion`` line
|
|
10
|
+
that spells out the literal retry command so an LLM reading the
|
|
11
|
+
stderr can proceed without extra reasoning.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import shlex
|
|
17
|
+
|
|
18
|
+
from kd.exceptions import ValidationError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def require_confirm(
|
|
22
|
+
*,
|
|
23
|
+
confirmed: bool,
|
|
24
|
+
command: str,
|
|
25
|
+
entity_id: str,
|
|
26
|
+
caveat: str,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Abort unless ``--confirm`` was passed.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
confirmed: Whether the caller passed ``--confirm``.
|
|
32
|
+
command: Full command path, e.g. ``/project delete``.
|
|
33
|
+
entity_id: The target identifier as given on the command line.
|
|
34
|
+
caveat: One or two sentences explaining why this particular
|
|
35
|
+
delete is unsafe — the per-entity failure mode (issues
|
|
36
|
+
vanishing, tag membership gone, identity refs detaching).
|
|
37
|
+
"""
|
|
38
|
+
if confirmed:
|
|
39
|
+
return
|
|
40
|
+
raise ValidationError(
|
|
41
|
+
f"kd {command} {entity_id} is irreversible. {caveat}",
|
|
42
|
+
suggestion=(f"Retry with: kd {command} {shlex.quote(entity_id)} --confirm"),
|
|
43
|
+
)
|
|
@@ -547,6 +547,8 @@ def _print_namespace_reference(ns: Namespace, depth: int = 0) -> None:
|
|
|
547
547
|
|
|
548
548
|
# Options
|
|
549
549
|
for opt in cmd.get_options():
|
|
550
|
+
if opt.hidden_from_reference:
|
|
551
|
+
continue
|
|
550
552
|
flag = f"--{opt.name}"
|
|
551
553
|
if opt.short_name:
|
|
552
554
|
flag += f"/-{opt.short_name}"
|
|
@@ -26,6 +26,7 @@ from kd.primer import (
|
|
|
26
26
|
clear_articles,
|
|
27
27
|
clear_notes,
|
|
28
28
|
clear_tags,
|
|
29
|
+
init_primer,
|
|
29
30
|
load_primer,
|
|
30
31
|
remove_article,
|
|
31
32
|
remove_note,
|
|
@@ -107,6 +108,53 @@ class PrimerShowCommand(Command):
|
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# /primer init
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class PrimerInitCommand(Command):
|
|
117
|
+
"""Scaffold ``.kd/primer.yaml`` from a packaged template."""
|
|
118
|
+
|
|
119
|
+
def __init__(self) -> None:
|
|
120
|
+
super().__init__(
|
|
121
|
+
"init",
|
|
122
|
+
"Scaffold .kd/primer.yaml from a template",
|
|
123
|
+
usage="kd /primer init [TEMPLATE]",
|
|
124
|
+
long_description=(
|
|
125
|
+
"Creates .kd/primer.yaml from a packaged template. Default "
|
|
126
|
+
"template is `hitl` — a human-in-the-loop session workflow "
|
|
127
|
+
"scaffold with placeholder prose in the scope-guard, session-"
|
|
128
|
+
"workflow, and canonical-reference slots. The template is "
|
|
129
|
+
"copied through unchanged so its banner and example-shape "
|
|
130
|
+
"comments survive. Refuses if a primer already exists; "
|
|
131
|
+
"there is no --force (deletion is explicit operator work)."
|
|
132
|
+
),
|
|
133
|
+
examples=[
|
|
134
|
+
"kd /primer init",
|
|
135
|
+
"kd /primer init hitl",
|
|
136
|
+
],
|
|
137
|
+
see_also=["/primer show", "/primer/note list"],
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def _register_options(self) -> None:
|
|
141
|
+
self.register_option(project_option(help_text="Assert which project's primer this edits"))
|
|
142
|
+
|
|
143
|
+
def execute(
|
|
144
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
145
|
+
) -> CommandResult:
|
|
146
|
+
_assert_workspace_project(self, options)
|
|
147
|
+
template = args[0] if args else "hitl"
|
|
148
|
+
created = init_primer(template)
|
|
149
|
+
return {
|
|
150
|
+
"ok": True,
|
|
151
|
+
"action": "init",
|
|
152
|
+
"template": template,
|
|
153
|
+
"created": str(created),
|
|
154
|
+
"next": "kd /primer show -o yaml",
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
110
158
|
# ---------------------------------------------------------------------------
|
|
111
159
|
# /primer/note
|
|
112
160
|
# ---------------------------------------------------------------------------
|
|
@@ -534,6 +582,7 @@ def create_primer_namespace() -> Namespace:
|
|
|
534
582
|
path="primer",
|
|
535
583
|
)
|
|
536
584
|
ns.register_command("show", PrimerShowCommand())
|
|
585
|
+
ns.register_command("init", PrimerInitCommand())
|
|
537
586
|
|
|
538
587
|
# /primer/note
|
|
539
588
|
note_ns = Namespace("note", "Primer notes and conventions", path="primer/note")
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
11
|
from kd.cli.core.command_base import Command, CommandResult
|
|
12
|
+
from kd.cli.core.confirm import require_confirm
|
|
12
13
|
from kd.cli.core.namespaces import Namespace
|
|
13
14
|
from kd.cli.core.option_registry import (
|
|
14
15
|
OptionDeclaration,
|
|
@@ -153,9 +154,22 @@ class ProjectDeleteCommand(Command):
|
|
|
153
154
|
"Delete a project",
|
|
154
155
|
requires_client=True,
|
|
155
156
|
usage="kd /project delete KEY",
|
|
156
|
-
examples=["kd /project delete TST"],
|
|
157
|
+
examples=["kd /project delete TST --confirm"],
|
|
157
158
|
see_also=["/project list"],
|
|
158
159
|
)
|
|
160
|
+
self.register_option(
|
|
161
|
+
OptionDeclaration(
|
|
162
|
+
name="confirm",
|
|
163
|
+
layer=OptionLayer.COMMAND,
|
|
164
|
+
type=bool,
|
|
165
|
+
default=False,
|
|
166
|
+
help_text=(
|
|
167
|
+
"Required — project delete is not recoverable from "
|
|
168
|
+
"Deleted Items (issues and articles are permanently removed)"
|
|
169
|
+
),
|
|
170
|
+
hidden_from_reference=True,
|
|
171
|
+
)
|
|
172
|
+
)
|
|
159
173
|
|
|
160
174
|
def execute(
|
|
161
175
|
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
@@ -163,6 +177,16 @@ class ProjectDeleteCommand(Command):
|
|
|
163
177
|
if not args:
|
|
164
178
|
raise ValidationError("Usage: kd /project delete KEY")
|
|
165
179
|
key = args[0]
|
|
180
|
+
require_confirm(
|
|
181
|
+
confirmed=bool(options.command.get("confirm")),
|
|
182
|
+
command="/project delete",
|
|
183
|
+
entity_id=key,
|
|
184
|
+
caveat=(
|
|
185
|
+
"All issues and articles in the project are permanently "
|
|
186
|
+
"deleted (they are not moved to Deleted Items) — recovery "
|
|
187
|
+
"requires a database backup restore."
|
|
188
|
+
),
|
|
189
|
+
)
|
|
166
190
|
client.projects.delete(key)
|
|
167
191
|
return {"ok": True, "action": "delete", "project": key}
|
|
168
192
|
|
|
@@ -10,6 +10,7 @@ import sys
|
|
|
10
10
|
from typing import Any, Literal
|
|
11
11
|
|
|
12
12
|
from kd.cli.core.command_base import Command, CommandResult
|
|
13
|
+
from kd.cli.core.confirm import require_confirm
|
|
13
14
|
from kd.cli.core.namespaces import Namespace
|
|
14
15
|
from kd.cli.core.option_registry import (
|
|
15
16
|
OptionDeclaration,
|
|
@@ -203,9 +204,22 @@ class TagDeleteCommand(Command):
|
|
|
203
204
|
"Delete a tag",
|
|
204
205
|
requires_client=True,
|
|
205
206
|
usage="kd /tag delete TAG_NAME",
|
|
206
|
-
examples=["kd /tag delete sprint-47"],
|
|
207
|
+
examples=["kd /tag delete sprint-47 --confirm"],
|
|
207
208
|
see_also=["/tag list", "/tag create"],
|
|
208
209
|
)
|
|
210
|
+
self.register_option(
|
|
211
|
+
OptionDeclaration(
|
|
212
|
+
name="confirm",
|
|
213
|
+
layer=OptionLayer.COMMAND,
|
|
214
|
+
type=bool,
|
|
215
|
+
default=False,
|
|
216
|
+
help_text=(
|
|
217
|
+
"Required — tag delete is not recoverable from Deleted "
|
|
218
|
+
"Items (tag membership is lost across all entities)"
|
|
219
|
+
),
|
|
220
|
+
hidden_from_reference=True,
|
|
221
|
+
)
|
|
222
|
+
)
|
|
209
223
|
|
|
210
224
|
def execute(
|
|
211
225
|
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
@@ -213,6 +227,16 @@ class TagDeleteCommand(Command):
|
|
|
213
227
|
if not args:
|
|
214
228
|
raise ValidationError("Usage: kd /tag delete TAG_NAME")
|
|
215
229
|
name = args[0]
|
|
230
|
+
require_confirm(
|
|
231
|
+
confirmed=bool(options.command.get("confirm")),
|
|
232
|
+
command="/tag delete",
|
|
233
|
+
entity_id=name,
|
|
234
|
+
caveat=(
|
|
235
|
+
"Tag membership is removed from every tagged issue and "
|
|
236
|
+
"article; recreating the tag does not restore assignments. "
|
|
237
|
+
"Full recovery requires a database backup restore."
|
|
238
|
+
),
|
|
239
|
+
)
|
|
216
240
|
client.tags.delete(name)
|
|
217
241
|
return {"ok": True, "action": "delete", "tag": name}
|
|
218
242
|
|
|
@@ -9,8 +9,13 @@ from __future__ import annotations
|
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
11
|
from kd.cli.core.command_base import Command, CommandResult
|
|
12
|
+
from kd.cli.core.confirm import require_confirm
|
|
12
13
|
from kd.cli.core.namespaces import Namespace
|
|
13
|
-
from kd.cli.core.option_registry import
|
|
14
|
+
from kd.cli.core.option_registry import (
|
|
15
|
+
OptionDeclaration,
|
|
16
|
+
OptionLayer,
|
|
17
|
+
ResolvedOptions,
|
|
18
|
+
)
|
|
14
19
|
from kd.exceptions import ValidationError
|
|
15
20
|
|
|
16
21
|
|
|
@@ -77,17 +82,42 @@ class UserDeleteCommand(Command):
|
|
|
77
82
|
"Delete a user",
|
|
78
83
|
requires_client=True,
|
|
79
84
|
usage="kd /user delete HUB_USER_ID",
|
|
80
|
-
examples=["kd /user delete abc-123"],
|
|
85
|
+
examples=["kd /user delete abc-123 --confirm"],
|
|
81
86
|
see_also=["/user list"],
|
|
82
87
|
)
|
|
88
|
+
self.register_option(
|
|
89
|
+
OptionDeclaration(
|
|
90
|
+
name="confirm",
|
|
91
|
+
layer=OptionLayer.COMMAND,
|
|
92
|
+
type=bool,
|
|
93
|
+
default=False,
|
|
94
|
+
help_text=(
|
|
95
|
+
"Required — user delete is not recoverable from Deleted "
|
|
96
|
+
"Items (consider banning via the Hub UI instead)"
|
|
97
|
+
),
|
|
98
|
+
hidden_from_reference=True,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
83
101
|
|
|
84
102
|
def execute(
|
|
85
103
|
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
86
104
|
) -> CommandResult:
|
|
87
105
|
if not args:
|
|
88
106
|
raise ValidationError("Usage: kd /user delete HUB_USER_ID")
|
|
89
|
-
|
|
90
|
-
|
|
107
|
+
user_id = args[0]
|
|
108
|
+
require_confirm(
|
|
109
|
+
confirmed=bool(options.command.get("confirm")),
|
|
110
|
+
command="/user delete",
|
|
111
|
+
entity_id=user_id,
|
|
112
|
+
caveat=(
|
|
113
|
+
"Identity references in issue history become detached, and "
|
|
114
|
+
"the account is removed from the Hub. Consider banning the "
|
|
115
|
+
"user in the Hub UI instead; full recovery requires a "
|
|
116
|
+
"database backup restore."
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
client.users.delete(user_id)
|
|
120
|
+
return {"ok": True, "action": "delete", "user": user_id}
|
|
91
121
|
|
|
92
122
|
|
|
93
123
|
def create_user_namespace() -> Namespace:
|
|
@@ -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
|
|
@@ -10,6 +10,9 @@ knowledge). The file is created lazily on first write operation.
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import importlib.resources
|
|
14
|
+
from collections.abc import Generator
|
|
15
|
+
from contextlib import contextmanager
|
|
13
16
|
from pathlib import Path
|
|
14
17
|
from typing import Any
|
|
15
18
|
|
|
@@ -17,6 +20,7 @@ import yaml
|
|
|
17
20
|
from pydantic import BaseModel
|
|
18
21
|
|
|
19
22
|
from kd.exceptions import ConfigError, ValidationError
|
|
23
|
+
from kd.locking import file_transaction
|
|
20
24
|
|
|
21
25
|
|
|
22
26
|
# ---------------------------------------------------------------------------
|
|
@@ -124,10 +128,15 @@ def load_primer(path: Path | None = None) -> PrimerData:
|
|
|
124
128
|
|
|
125
129
|
|
|
126
130
|
def save_primer(data: PrimerData, path: Path | None = None) -> None:
|
|
127
|
-
"""Write primer to YAML.
|
|
131
|
+
"""Write primer to YAML — low-level primitive.
|
|
128
132
|
|
|
129
133
|
Snapshots the current file before writing (copy-on-write) and uses
|
|
130
|
-
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.
|
|
131
140
|
"""
|
|
132
141
|
from kd.history import atomic_write_text, snapshot_before_write
|
|
133
142
|
|
|
@@ -171,46 +180,56 @@ def _tag_dump(tag: dict[str, Any]) -> dict[str, Any]:
|
|
|
171
180
|
# ---------------------------------------------------------------------------
|
|
172
181
|
|
|
173
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
|
+
|
|
174
198
|
def add_note(text: str, path: Path | None = None) -> int:
|
|
175
199
|
"""Append a note. Returns the 1-based index."""
|
|
176
200
|
primer_file = path or _primer_path()
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
return len(data.notes)
|
|
201
|
+
with _primer_transaction(primer_file) as data:
|
|
202
|
+
data.notes.append(text)
|
|
203
|
+
return len(data.notes)
|
|
181
204
|
|
|
182
205
|
|
|
183
206
|
def remove_note(index: int, path: Path | None = None) -> str:
|
|
184
207
|
"""Remove a note by 1-based index. Returns the removed text."""
|
|
185
208
|
primer_file = path or _primer_path()
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
save_primer(data, primer_file)
|
|
191
|
-
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)
|
|
192
213
|
|
|
193
214
|
|
|
194
215
|
def update_note(index: int, text: str, path: Path | None = None) -> str:
|
|
195
216
|
"""Replace a note by 1-based index. Returns the old text."""
|
|
196
217
|
primer_file = path or _primer_path()
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
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
|
|
204
224
|
|
|
205
225
|
|
|
206
226
|
def clear_notes(path: Path | None = None) -> int:
|
|
207
227
|
"""Remove all notes. Returns the count removed."""
|
|
208
228
|
primer_file = path or _primer_path()
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
return count
|
|
229
|
+
with _primer_transaction(primer_file) as data:
|
|
230
|
+
count = len(data.notes)
|
|
231
|
+
data.notes.clear()
|
|
232
|
+
return count
|
|
214
233
|
|
|
215
234
|
|
|
216
235
|
def add_article(
|
|
@@ -218,68 +237,106 @@ def add_article(
|
|
|
218
237
|
) -> PrimerArticle:
|
|
219
238
|
"""Pin an article. Raises if already pinned."""
|
|
220
239
|
primer_file = path or _primer_path()
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
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
|
|
229
247
|
|
|
230
248
|
|
|
231
249
|
def remove_article(article_id: str, path: Path | None = None) -> PrimerArticle:
|
|
232
250
|
"""Unpin an article by ID. Returns the removed entry."""
|
|
233
251
|
primer_file = path or _primer_path()
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
return removed
|
|
240
|
-
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")
|
|
241
257
|
|
|
242
258
|
|
|
243
259
|
def clear_articles(path: Path | None = None) -> int:
|
|
244
260
|
"""Remove all pinned articles. Returns the count removed."""
|
|
245
261
|
primer_file = path or _primer_path()
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
return count
|
|
262
|
+
with _primer_transaction(primer_file) as data:
|
|
263
|
+
count = len(data.articles)
|
|
264
|
+
data.articles.clear()
|
|
265
|
+
return count
|
|
251
266
|
|
|
252
267
|
|
|
253
268
|
def add_tag(name: str, summary: str | None = None, path: Path | None = None) -> PrimerTag:
|
|
254
269
|
"""Pin a tag. Raises if already pinned."""
|
|
255
270
|
primer_file = path or _primer_path()
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
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
|
|
264
278
|
|
|
265
279
|
|
|
266
280
|
def remove_tag(name: str, path: Path | None = None) -> PrimerTag:
|
|
267
281
|
"""Unpin a tag by name. Returns the removed entry."""
|
|
268
282
|
primer_file = path or _primer_path()
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
return removed
|
|
275
|
-
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")
|
|
276
288
|
|
|
277
289
|
|
|
278
290
|
def clear_tags(path: Path | None = None) -> int:
|
|
279
291
|
"""Remove all pinned tags. Returns the count removed."""
|
|
280
292
|
primer_file = path or _primer_path()
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
293
|
+
with _primer_transaction(primer_file) as data:
|
|
294
|
+
count = len(data.tags)
|
|
295
|
+
data.tags.clear()
|
|
296
|
+
return count
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# ---------------------------------------------------------------------------
|
|
300
|
+
# Scaffolding
|
|
301
|
+
# ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
_PRIMER_TEMPLATE_PACKAGE = "kd.resources.primers"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def list_primer_templates() -> list[str]:
|
|
307
|
+
"""Return sorted names of shipped primer templates (without ``.yaml``)."""
|
|
308
|
+
root = importlib.resources.files(_PRIMER_TEMPLATE_PACKAGE)
|
|
309
|
+
return sorted(p.name[: -len(".yaml")] for p in root.iterdir() if p.name.endswith(".yaml"))
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def init_primer(template: str = "hitl", path: Path | None = None) -> Path:
|
|
313
|
+
"""Scaffold ``.kd/primer.yaml`` from a packaged template.
|
|
314
|
+
|
|
315
|
+
Copies the template file through unchanged so its banner and inline
|
|
316
|
+
comments survive; no YAML load/dump round-trip. Refuses if a primer
|
|
317
|
+
already exists — deletion is explicit operator work and there is no
|
|
318
|
+
``--force``.
|
|
319
|
+
"""
|
|
320
|
+
from kd.history import atomic_write_text
|
|
321
|
+
|
|
322
|
+
available = list_primer_templates()
|
|
323
|
+
if template not in available:
|
|
324
|
+
raise ValidationError(
|
|
325
|
+
f"Unknown primer template '{template}'. Available: {', '.join(available)}"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
primer_file = path or _primer_path()
|
|
329
|
+
if primer_file.exists():
|
|
330
|
+
raise ValidationError(
|
|
331
|
+
f"Primer already exists at {primer_file}. "
|
|
332
|
+
f"Run `kd /primer show` to inspect it, or delete the file to start over."
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
content = (
|
|
336
|
+
importlib.resources.files(_PRIMER_TEMPLATE_PACKAGE)
|
|
337
|
+
.joinpath(f"{template}.yaml")
|
|
338
|
+
.read_text(encoding="utf-8")
|
|
339
|
+
)
|
|
340
|
+
primer_file.parent.mkdir(parents=True, exist_ok=True)
|
|
341
|
+
atomic_write_text(primer_file, content)
|
|
342
|
+
return primer_file
|
|
@@ -4,6 +4,17 @@ 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
|
+
|
|
12
|
+
## [2.4.0] — 2026-04-19
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- `kd /primer init [TEMPLATE]` scaffolds `.kd/primer.yaml` from a packaged template so fresh workspaces don't start from a blank page. The default `hitl` template ships placeholder prose in three slots (scope guard, session workflow, canonical reference); the banner and example-shape comments are preserved verbatim. Refuses if a primer already exists; unknown template names reject with the available-templates list (KDCLI-97).
|
|
16
|
+
- `--confirm` flag on `/project delete`, `/tag delete`, and `/user delete` — these three deletes bypass YouTrack's Deleted Items and require a backup restore to undo, so kd now rejects them without `--confirm` and prints the literal retry command in the error. The flag is hidden from `kd --reference` but visible in per-command `--help` (KDCLI-96).
|
|
17
|
+
|
|
7
18
|
## [2.3.0] — 2026-04-18
|
|
8
19
|
|
|
9
20
|
### Added
|
|
@@ -82,6 +82,14 @@ kd /issue update KDCLI-42 --state Fixed -o yaml
|
|
|
82
82
|
For article push, use `--dry-run` before a risky write and inspect conflicts with `/article pull`.
|
|
83
83
|
`/article push --tag` rejects missing tags by default and prints the concrete recovery commands. Use `--create-tags` only for scripted flows that already own the tag namespace.
|
|
84
84
|
|
|
85
|
+
Three delete commands bypass YouTrack's Deleted Items safety net and require an explicit `--confirm` flag:
|
|
86
|
+
|
|
87
|
+
- `/project delete` — all issues and articles in the project are permanently removed, not moved to Deleted Items.
|
|
88
|
+
- `/tag delete` — tag membership is lost across every tagged issue and article; recreating the tag does not restore assignments.
|
|
89
|
+
- `/user delete` — identity references in issue history become detached; consider banning in the Hub UI instead.
|
|
90
|
+
|
|
91
|
+
Without `--confirm` these commands exit non-zero with an error that names the failure mode and prints the literal retry command. Recovery for any of the three requires a database backup restore. `/issue delete`, `/article delete`, and attachment deletes do not require `--confirm` — they go to Deleted Items and are admin-restorable for roughly 14 days.
|
|
92
|
+
|
|
85
93
|
## YouTrack Gotchas
|
|
86
94
|
|
|
87
95
|
YouTrack articles are not queryable like issues. `/api/articles` has no issue-style `query` parameter, and there is no server-side article-by-tag endpoint. kd article tag lookup scans article pages that include `tags(id,name)` and filters locally.
|
|
@@ -106,6 +114,8 @@ Tag existence, tag visibility, and tag sharing rights are different concerns. A
|
|
|
106
114
|
|
|
107
115
|
## Primer Practices
|
|
108
116
|
|
|
117
|
+
For a fresh workspace, scaffold the primer with `kd /primer init`. The default `hitl` template drops placeholder prose into three slots — scope guard, session workflow, canonical reference — that the operator fills in an editor. The command refuses if a primer already exists and has no `--force`; deletion is explicit operator work.
|
|
118
|
+
|
|
109
119
|
Primer notes should capture durable project rules, not temporary status. Good notes explain naming conventions, required workflows, dangerous assumptions, and pointers to canonical references.
|
|
110
120
|
|
|
111
121
|
Do not use the primer as a session log. Avoid recording current focus, completed session lists, release-cadence details copied from a plan, or other progress facts that can become false by the next agent session. If the primer needs to point at active work, stop at the current master plan or equivalent planning document; the agent should open that document and derive the next step from there.
|
|
@@ -136,6 +146,8 @@ When both pinned tags and pinned articles exist, read them in order: notes tell
|
|
|
136
146
|
|
|
137
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`.
|
|
138
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
|
+
|
|
139
151
|
## Articles And Push
|
|
140
152
|
|
|
141
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`.
|
|
@@ -211,4 +223,4 @@ Use these sources together:
|
|
|
211
223
|
- `docs/design/architecture.md` and ADRs for architectural constraints.
|
|
212
224
|
|
|
213
225
|
---
|
|
214
|
-
Updated: 2026-04-
|
|
226
|
+
Updated: 2026-04-19
|
|
File without changes
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# DO NOT EDIT THIS FILE DIRECTLY. Use `kd /primer --help` to manage it.
|
|
2
|
+
#
|
|
3
|
+
# This is the `hitl` template — human-in-the-loop session workflow. Edit the
|
|
4
|
+
# placeholders below to fit this workspace. Three slots:
|
|
5
|
+
# 1. Scope guard — what this workspace is / is not; how to reject drift.
|
|
6
|
+
# 2. Session workflow — the repeatable loop and local conventions.
|
|
7
|
+
# 3. Canonical reference — pinned article(s) with durable background.
|
|
8
|
+
|
|
9
|
+
notes:
|
|
10
|
+
- '<WORKSPACE> is <one-sentence identity: what kind of tool, codebase, or knowledge
|
|
11
|
+
base this workspace represents>. Primary loop: <describe the repeatable action —
|
|
12
|
+
for example, pull context → reason → push focused updates back>. <State what this
|
|
13
|
+
workspace is NOT — adjacent shapes it should not drift toward, such as migration
|
|
14
|
+
platform, data warehouse, silent local mirror>. Before adding features that look
|
|
15
|
+
like the adjacent shape, re-read this guard. Test question: does this keep the
|
|
16
|
+
workspace <identity>-shaped, or does it drag it toward <adjacent shape>? Reject
|
|
17
|
+
drift toward the latter.'
|
|
18
|
+
- 'Active planning entry point: <path to master plan, roadmap doc, or tracking
|
|
19
|
+
project>. The primer must not record session-level progress, current focus,
|
|
20
|
+
completed-session lists, or release-cadence details copied from the plan; those
|
|
21
|
+
stale quickly. A fresh agent reads this pointer, checks the active plan or
|
|
22
|
+
assigned ticket for scope, then follows the default session workflow recorded
|
|
23
|
+
in this primer.'
|
|
24
|
+
- 'Default session workflow: 1) Load primer with `kd /primer show -o yaml`. 2) Read
|
|
25
|
+
the assigned ticket or active planning source; if tracked work lacks a ticket,
|
|
26
|
+
create one with `kd /issue create ... --project <PROJECT> --type <Feature|Bug|Task>`
|
|
27
|
+
and use the returned ID. 3) Enter plan mode for a full tracked session and write
|
|
28
|
+
the session doc from the local template, naming the file with the ticket ID when
|
|
29
|
+
present. 4) Execute code, tests, and docs together; run the project''s test, lint,
|
|
30
|
+
and type checks before commit. 5) Commit with `<TICKET-PREFIX>-NN: <subject>` for
|
|
31
|
+
tracked work. 6) Install the built artifact, run applicable HITL against a real
|
|
32
|
+
target, append the session conclusion, comment on the ticket, transition it to
|
|
33
|
+
<done-state>, and update the relevant plan and docs. If no ticket or user-visible
|
|
34
|
+
HITL surface exists, record that explicitly in the session doc.'
|
|
35
|
+
|
|
36
|
+
tags: []
|
|
37
|
+
|
|
38
|
+
# Pin canonical reference articles after `kd /primer init`. Example shape:
|
|
39
|
+
# - id: <ARTICLE-ID>
|
|
40
|
+
# summary: <one-line hook — what this pinned article gives the operator>
|
|
41
|
+
articles: []
|
|
@@ -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
|
|
@@ -32,6 +33,7 @@ kd/cli/tree.py
|
|
|
32
33
|
kd/cli/core/__init__.py
|
|
33
34
|
kd/cli/core/command_base.py
|
|
34
35
|
kd/cli/core/command_parser.py
|
|
36
|
+
kd/cli/core/confirm.py
|
|
35
37
|
kd/cli/core/namespaces.py
|
|
36
38
|
kd/cli/core/option_registry.py
|
|
37
39
|
kd/cli/namespaces/__init__.py
|
|
@@ -68,6 +70,8 @@ kd/resources/command-synonyms.yaml
|
|
|
68
70
|
kd/resources/config-template.yaml
|
|
69
71
|
kd/resources/error-messages.yaml
|
|
70
72
|
kd/resources/operating-guide.md
|
|
73
|
+
kd/resources/primers/__init__.py
|
|
74
|
+
kd/resources/primers/hitl.yaml
|
|
71
75
|
kd_cli.egg-info/PKG-INFO
|
|
72
76
|
kd_cli.egg-info/SOURCES.txt
|
|
73
77
|
kd_cli.egg-info/dependency_links.txt
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "kd-cli"
|
|
7
|
-
version = "2.
|
|
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"
|
|
@@ -57,7 +57,7 @@ kd = "kd.cli.main:main"
|
|
|
57
57
|
include = ["kd*"]
|
|
58
58
|
|
|
59
59
|
[tool.setuptools.package-data]
|
|
60
|
-
kd = ["resources/*.yaml", "resources/*.md"]
|
|
60
|
+
kd = ["resources/*.yaml", "resources/*.md", "resources/primers/*.yaml"]
|
|
61
61
|
|
|
62
62
|
[tool.ruff]
|
|
63
63
|
exclude = ["docs"]
|
|
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
|
|
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
|