@christang/keel 5.1.1
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.
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/README.zh-CN.md +295 -0
- package/assets/bootstrap/AGENTS.md +9 -0
- package/assets/openspec/schemas/keel-spec-driven/schema.yaml +166 -0
- package/assets/openspec/schemas/keel-spec-driven/templates/design.md +52 -0
- package/assets/openspec/schemas/keel-spec-driven/templates/proposal.md +21 -0
- package/assets/openspec/schemas/keel-spec-driven/templates/spec.md +8 -0
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +68 -0
- package/bin/keel.js +1490 -0
- package/package.json +35 -0
- package/plugins/keel/.claude-plugin/plugin.json +17 -0
- package/plugins/keel/.codex-plugin/plugin.json +29 -0
- package/plugins/keel/agents/keel-single-task-goal-claude.md +16 -0
- package/plugins/keel/agents/keel-single-task-goal-codex.md +16 -0
- package/plugins/keel/hooks/hooks.json +30 -0
- package/plugins/keel/scripts/pretooluse-guard.js +156 -0
- package/plugins/keel/scripts/session-start.js +182 -0
- package/plugins/keel/skills/keel-align-expectations/SKILL.md +53 -0
- package/plugins/keel/skills/keel-align-expectations/references/hardware-dsl.md +21 -0
- package/plugins/keel/skills/keel-align-expectations/references/hardware.md +21 -0
- package/plugins/keel/skills/keel-align-expectations/references/web.md +21 -0
- package/plugins/keel/skills/keel-debug-failure/SKILL.md +41 -0
- package/plugins/keel/skills/keel-handoff/SKILL.md +45 -0
- package/plugins/keel/skills/keel-review-checklist/SKILL.md +73 -0
- package/plugins/keel/skills/keel-run-single-task-goal/SKILL.md +68 -0
- package/plugins/keel/skills/keel-tdd-or-test-first/SKILL.md +45 -0
- package/scripts/install_to_repo.py +1122 -0
- package/scripts/run_python.js +63 -0
- package/scripts/validate_plugin.py +9869 -0
- package/src/core/capabilities.js +291 -0
- package/src/core/context.js +514 -0
- package/src/core/gates.js +643 -0
- package/src/core/goal.js +230 -0
- package/src/core/guard.js +295 -0
- package/src/core/helper.js +319 -0
- package/src/core/projection.js +195 -0
- package/src/core/task-contract.js +736 -0
- package/src/core/tasksview.js +123 -0
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Install the thin Keel host surface: OpenSpec schema, overlays, and bootstrap."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
|
|
16
|
+
MANAGED_START_RE = re.compile(r"<!--\s*keel:start(?:\s+[^>]*)?\s*-->")
|
|
17
|
+
MANAGED_END = "<!-- keel:end -->"
|
|
18
|
+
TEMPLATE_CHECKSUM_PREFIX = "<!-- keel:content-sha256 "
|
|
19
|
+
TEMPLATE_CHECKSUM_SUFFIX = " -->"
|
|
20
|
+
KEEL_ROOT = Path("keel")
|
|
21
|
+
HANDOFF_PATH = KEEL_ROOT / "HANDOFF.md"
|
|
22
|
+
OPENSPEC_ROOT = Path("openspec")
|
|
23
|
+
OPENSPEC_CONFIG_PATH = OPENSPEC_ROOT / "config.yaml"
|
|
24
|
+
OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
|
|
25
|
+
OPENSPEC_SCHEMA_ROOT = OPENSPEC_ROOT / "schemas" / OPENSPEC_SCHEMA_NAME
|
|
26
|
+
BOOTSTRAP_ASSET = Path("assets") / "bootstrap" / "AGENTS.md"
|
|
27
|
+
OPENSPEC_ASSET_ROOT = Path("assets") / "openspec"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _bootstrap_marker() -> str:
|
|
31
|
+
# The shipped bootstrap asset is the single canonical source of the
|
|
32
|
+
# managed-block marker (including its version); install code derives the
|
|
33
|
+
# marker instead of restating the literal, so the two can never drift.
|
|
34
|
+
first_line = (
|
|
35
|
+
(PACKAGE_ROOT / BOOTSTRAP_ASSET)
|
|
36
|
+
.read_text(encoding="utf-8")
|
|
37
|
+
.splitlines()[0]
|
|
38
|
+
.strip()
|
|
39
|
+
)
|
|
40
|
+
if not MANAGED_START_RE.fullmatch(first_line):
|
|
41
|
+
raise SystemExit(
|
|
42
|
+
"bootstrap asset must begin with a keel:start marker; found: "
|
|
43
|
+
+ first_line
|
|
44
|
+
)
|
|
45
|
+
return first_line
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
MANAGED_START = _bootstrap_marker()
|
|
49
|
+
CLAUDE_IMPORT_BLOCK = f"{MANAGED_START}\n@AGENTS.md\n{MANAGED_END}\n"
|
|
50
|
+
KEEL_HOOK_NAME = "keel-gate"
|
|
51
|
+
KEEL_HOOK_ROOT = Path(".claude") / "hooks" / KEEL_HOOK_NAME
|
|
52
|
+
SUPPORTED_TARGETS = ("claude", "codex", "opencode")
|
|
53
|
+
AGENT_PROTOCOL_TARGETS = {"codex", "opencode"}
|
|
54
|
+
TARGET_SKILL_ROOTS = {
|
|
55
|
+
"claude": Path(".claude") / "skills",
|
|
56
|
+
"codex": Path(".agents") / "skills",
|
|
57
|
+
"opencode": Path(".opencode") / "skills",
|
|
58
|
+
}
|
|
59
|
+
TARGET_ADAPTER_PATHS = {
|
|
60
|
+
"claude": Path(".claude") / "keel" / "keel-adapter.js",
|
|
61
|
+
"codex": Path(".agents") / "keel" / "keel-adapter.js",
|
|
62
|
+
"opencode": Path(".opencode") / "keel" / "keel-adapter.js",
|
|
63
|
+
}
|
|
64
|
+
CORE_KEEL_SKILLS = {
|
|
65
|
+
"keel-align-expectations",
|
|
66
|
+
"keel-debug-failure",
|
|
67
|
+
"keel-handoff",
|
|
68
|
+
"keel-review-checklist",
|
|
69
|
+
"keel-tdd-or-test-first",
|
|
70
|
+
}
|
|
71
|
+
HANDOFF_FIELDS = {"schema", "owner", "action", "reason"}
|
|
72
|
+
HANDOFF_ACTIONS = {
|
|
73
|
+
"discuss",
|
|
74
|
+
"author",
|
|
75
|
+
"task-start",
|
|
76
|
+
"task-complete",
|
|
77
|
+
"change-close",
|
|
78
|
+
}
|
|
79
|
+
HANDOFF_OWNER_RE = re.compile(
|
|
80
|
+
r"^openspec/changes/[A-Za-z0-9][A-Za-z0-9._-]*/"
|
|
81
|
+
r"(?:proposal|design|tasks)\.md(?:#.+)?$"
|
|
82
|
+
)
|
|
83
|
+
TASKS_LEGACY_HEADING_PATTERNS = (
|
|
84
|
+
(
|
|
85
|
+
re.compile(r"(?im)^##\s+Execution Status\s*$"),
|
|
86
|
+
"replace legacy ## Execution Status with ## Workflow Notes; tasks.md is not an execution or commit ledger",
|
|
87
|
+
),
|
|
88
|
+
(
|
|
89
|
+
re.compile(r"(?im)^##\s+Current Completion\s*$"),
|
|
90
|
+
"remove legacy ## Current Completion; derive progress from checklist [x]/[ ] state",
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
TASKS_COMMIT_STATUS_PATTERNS = (
|
|
94
|
+
(
|
|
95
|
+
re.compile(r"(?i)\bcommit[-\s]?hash\b"),
|
|
96
|
+
"remove commit hash wording from tasks.md; git log is the source of truth",
|
|
97
|
+
),
|
|
98
|
+
(
|
|
99
|
+
re.compile(r"(?i)\b(?:dirty|uncommitted|not\s+committed|pending\s+commit)\b"),
|
|
100
|
+
"remove dirty/uncommitted state from tasks.md; keep durable work state in OpenSpec and use HANDOFF only as an explicit pointer override",
|
|
101
|
+
),
|
|
102
|
+
(
|
|
103
|
+
re.compile(r"(?:未提交|待提交|已提交|尚未提交|未合入|待合入|已合入|合入\s*(?:master|main))"),
|
|
104
|
+
"remove commit or merge state from tasks.md; git log is the source of truth",
|
|
105
|
+
),
|
|
106
|
+
(
|
|
107
|
+
re.compile(r"(?i)\b(?:merged|merge[d]?)\s+(?:to|into)\s+(?:master|main)\b"),
|
|
108
|
+
"remove branch merge state from tasks.md; git log is the source of truth",
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
TASKS_CONTEXTUAL_HASH_RE = re.compile(
|
|
112
|
+
r"(?i)(?:commit|提交|合入|master|main|HEAD|hash|哈希).*\b[0-9a-f]{7,40}\b|"
|
|
113
|
+
r"\b[0-9a-f]{7,40}\b.*(?:commit|提交|合入|master|main|HEAD|hash|哈希)"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class InstallAction:
|
|
119
|
+
relative_path: Path
|
|
120
|
+
source_path: Path | None = None
|
|
121
|
+
content: str | None = None
|
|
122
|
+
strategy: str = "copy"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class PlannedAction:
|
|
127
|
+
kind: str
|
|
128
|
+
relative_path: Path
|
|
129
|
+
content: str | None = None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def is_relative_to(path: Path, parent: Path) -> bool:
|
|
133
|
+
try:
|
|
134
|
+
path.relative_to(parent)
|
|
135
|
+
except ValueError:
|
|
136
|
+
return False
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def require_inside_repo(repo: Path, relative_path: Path) -> Path:
|
|
141
|
+
destination = (repo / relative_path).resolve()
|
|
142
|
+
if not is_relative_to(destination, repo):
|
|
143
|
+
raise ValueError(f"refusing to write outside target repo: {relative_path}")
|
|
144
|
+
return destination
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def dist_asset(*parts: str) -> Path:
|
|
148
|
+
return PACKAGE_ROOT.joinpath("dist", *parts)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def target_names(target: str) -> list[str]:
|
|
152
|
+
if target == "both":
|
|
153
|
+
return ["claude"]
|
|
154
|
+
return [target]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def target_set(target: str) -> set[str]:
|
|
158
|
+
return set(target_names(target))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def core_skill_names() -> set[str]:
|
|
162
|
+
return set(CORE_KEEL_SKILLS)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def file_action(relative_path: str, source_path: Path) -> InstallAction:
|
|
166
|
+
if not source_path.is_file():
|
|
167
|
+
raise ValueError(f"missing packaged asset: {source_path}")
|
|
168
|
+
return InstallAction(
|
|
169
|
+
relative_path=Path(relative_path),
|
|
170
|
+
source_path=source_path,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def managed_file_action(relative_path: str, source_path: Path) -> InstallAction:
|
|
175
|
+
if not source_path.is_file():
|
|
176
|
+
raise ValueError(f"missing packaged asset: {source_path}")
|
|
177
|
+
return InstallAction(
|
|
178
|
+
relative_path=Path(relative_path),
|
|
179
|
+
source_path=source_path,
|
|
180
|
+
strategy="managed-block",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def managed_content_action(relative_path: str, content: str) -> InstallAction:
|
|
185
|
+
return InstallAction(
|
|
186
|
+
relative_path=Path(relative_path),
|
|
187
|
+
content=content,
|
|
188
|
+
strategy="managed-block",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def template_file_action(relative_path: str, source_path: Path) -> InstallAction:
|
|
193
|
+
if not source_path.is_file():
|
|
194
|
+
raise ValueError(f"missing packaged asset: {source_path}")
|
|
195
|
+
return InstallAction(
|
|
196
|
+
relative_path=Path(relative_path),
|
|
197
|
+
source_path=source_path,
|
|
198
|
+
strategy="template",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def gitkeep_action(relative_path: str) -> InstallAction:
|
|
203
|
+
return InstallAction(
|
|
204
|
+
relative_path=Path(relative_path),
|
|
205
|
+
content="",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def openspec_config_action() -> InstallAction:
|
|
210
|
+
return InstallAction(
|
|
211
|
+
relative_path=OPENSPEC_CONFIG_PATH,
|
|
212
|
+
content=f"schema: {OPENSPEC_SCHEMA_NAME}\n",
|
|
213
|
+
strategy="openspec-config",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def openspec_schema_actions() -> list[InstallAction]:
|
|
218
|
+
schema_root = PACKAGE_ROOT / OPENSPEC_ASSET_ROOT / "schemas" / OPENSPEC_SCHEMA_NAME
|
|
219
|
+
if not schema_root.is_dir():
|
|
220
|
+
raise ValueError(f"missing packaged OpenSpec schema: {schema_root}")
|
|
221
|
+
|
|
222
|
+
actions: list[InstallAction] = []
|
|
223
|
+
for schema_file in sorted(schema_root.rglob("*")):
|
|
224
|
+
if not schema_file.is_file():
|
|
225
|
+
continue
|
|
226
|
+
relative_file = schema_file.relative_to(schema_root).as_posix()
|
|
227
|
+
actions.append(
|
|
228
|
+
file_action(
|
|
229
|
+
(OPENSPEC_SCHEMA_ROOT / relative_file).as_posix(),
|
|
230
|
+
schema_file,
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
return actions
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def skill_actions(target: str) -> list[InstallAction]:
|
|
237
|
+
actions: list[InstallAction] = []
|
|
238
|
+
selected_skill_names = core_skill_names()
|
|
239
|
+
for target_name in target_names(target):
|
|
240
|
+
skill_destination_root = TARGET_SKILL_ROOTS[target_name]
|
|
241
|
+
skills_root = dist_asset(target_name, "skills")
|
|
242
|
+
if skills_root.is_dir():
|
|
243
|
+
for skill in sorted(skills_root.iterdir()):
|
|
244
|
+
if not skill.is_dir():
|
|
245
|
+
continue
|
|
246
|
+
if skill.name not in selected_skill_names:
|
|
247
|
+
continue
|
|
248
|
+
for skill_file in sorted(skill.rglob("*")):
|
|
249
|
+
if not skill_file.is_file():
|
|
250
|
+
continue
|
|
251
|
+
relative_file = skill_file.relative_to(skill).as_posix()
|
|
252
|
+
actions.append(
|
|
253
|
+
file_action(
|
|
254
|
+
(skill_destination_root / skill.name / relative_file).as_posix(),
|
|
255
|
+
skill_file,
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
return actions
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def agent_actions(target: str) -> list[InstallAction]:
|
|
262
|
+
actions: list[InstallAction] = []
|
|
263
|
+
if "claude" not in target_set(target):
|
|
264
|
+
return actions
|
|
265
|
+
agents_root = dist_asset("claude", "agents")
|
|
266
|
+
if not agents_root.is_dir():
|
|
267
|
+
return actions
|
|
268
|
+
for agent in sorted(agents_root.iterdir()):
|
|
269
|
+
if not agent.is_dir():
|
|
270
|
+
continue
|
|
271
|
+
for agent_file in sorted(agent.rglob("*")):
|
|
272
|
+
if not agent_file.is_file():
|
|
273
|
+
continue
|
|
274
|
+
relative_file = agent_file.relative_to(agent).as_posix()
|
|
275
|
+
actions.append(
|
|
276
|
+
file_action(
|
|
277
|
+
f".claude/agents/{agent.name}/{relative_file}",
|
|
278
|
+
agent_file,
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
return actions
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def adapter_actions(target: str) -> list[InstallAction]:
|
|
285
|
+
actions: list[InstallAction] = []
|
|
286
|
+
for target_name in target_names(target):
|
|
287
|
+
source = dist_asset(target_name, "adapters", "keel-adapter.js")
|
|
288
|
+
actions.append(
|
|
289
|
+
file_action(TARGET_ADAPTER_PATHS[target_name].as_posix(), source)
|
|
290
|
+
)
|
|
291
|
+
return actions
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def keel_hook_source_root() -> Path:
|
|
295
|
+
source = dist_asset("claude", "hooks", KEEL_HOOK_NAME)
|
|
296
|
+
if not source.is_dir():
|
|
297
|
+
raise ValueError(f"missing packaged Keel hook: {source}")
|
|
298
|
+
if not (source / "hooks.json").is_file():
|
|
299
|
+
raise ValueError(f"missing packaged Keel hook config: {source / 'hooks.json'}")
|
|
300
|
+
return source
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def keel_hook_actions(target: str) -> list[InstallAction]:
|
|
304
|
+
if "claude" not in target_set(target):
|
|
305
|
+
return []
|
|
306
|
+
source_root = keel_hook_source_root()
|
|
307
|
+
actions = [
|
|
308
|
+
file_action(
|
|
309
|
+
(KEEL_HOOK_ROOT / source.relative_to(source_root)).as_posix(),
|
|
310
|
+
source,
|
|
311
|
+
)
|
|
312
|
+
for source in sorted(source_root.rglob("*"))
|
|
313
|
+
if source.is_file()
|
|
314
|
+
]
|
|
315
|
+
actions.append(
|
|
316
|
+
InstallAction(
|
|
317
|
+
relative_path=Path(".claude/settings.json"),
|
|
318
|
+
source_path=source_root / "hooks.json",
|
|
319
|
+
strategy="keel-hook-settings",
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
return actions
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def unmanaged_keel_content_warning(repo: Path, relative_path: str) -> bool:
|
|
326
|
+
path = repo / relative_path
|
|
327
|
+
if not path.is_file():
|
|
328
|
+
return False
|
|
329
|
+
content = path.read_text(encoding="utf-8")
|
|
330
|
+
if MANAGED_START_RE.search(content):
|
|
331
|
+
return False
|
|
332
|
+
if "keel gate task-start" in content or "keel context" in content:
|
|
333
|
+
print(
|
|
334
|
+
f"preserve {relative_path}: Keel-looking resident content has no "
|
|
335
|
+
"managed markers and cannot be matched to a known managed version; "
|
|
336
|
+
"resolve it manually before Keel merges the v4 bootstrap"
|
|
337
|
+
)
|
|
338
|
+
return True
|
|
339
|
+
return False
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def collect_actions(repo: Path, target: str) -> list[InstallAction]:
|
|
343
|
+
actions: list[InstallAction] = []
|
|
344
|
+
targets = target_set(target)
|
|
345
|
+
|
|
346
|
+
if not unmanaged_keel_content_warning(repo, "AGENTS.md"):
|
|
347
|
+
actions.append(
|
|
348
|
+
managed_file_action("AGENTS.md", PACKAGE_ROOT / BOOTSTRAP_ASSET)
|
|
349
|
+
)
|
|
350
|
+
if "claude" in targets and not unmanaged_keel_content_warning(repo, "CLAUDE.md"):
|
|
351
|
+
actions.append(managed_content_action("CLAUDE.md", CLAUDE_IMPORT_BLOCK))
|
|
352
|
+
|
|
353
|
+
actions.append(openspec_config_action())
|
|
354
|
+
actions.extend(openspec_schema_actions())
|
|
355
|
+
return actions
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def has_managed_block(path: Path) -> bool:
|
|
359
|
+
if not path.is_file():
|
|
360
|
+
return False
|
|
361
|
+
return extract_managed_block(path.read_text(encoding="utf-8")) is not None
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def has_openspec_schema_config(repo: Path) -> bool:
|
|
365
|
+
config_path = repo / OPENSPEC_CONFIG_PATH
|
|
366
|
+
if not config_path.is_file():
|
|
367
|
+
return False
|
|
368
|
+
config = config_path.read_text(encoding="utf-8")
|
|
369
|
+
return re.search(rf"(?m)^\s*schema\s*:\s*{re.escape(OPENSPEC_SCHEMA_NAME)}\s*$", config) is not None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def is_keel_hook_handler(handler: object) -> bool:
|
|
373
|
+
if not isinstance(handler, dict) or handler.get("command") != "node":
|
|
374
|
+
return False
|
|
375
|
+
args = handler.get("args")
|
|
376
|
+
return isinstance(args, list) and any(
|
|
377
|
+
isinstance(arg, str) and ".claude/hooks/keel-gate/keel-gate.js" in arg
|
|
378
|
+
for arg in args
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def has_keel_hook_settings(repo: Path) -> bool:
|
|
383
|
+
settings_path = repo / ".claude/settings.json"
|
|
384
|
+
if not settings_path.is_file():
|
|
385
|
+
return False
|
|
386
|
+
try:
|
|
387
|
+
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
388
|
+
except json.JSONDecodeError:
|
|
389
|
+
return False
|
|
390
|
+
hooks = settings.get("hooks")
|
|
391
|
+
if not isinstance(hooks, dict):
|
|
392
|
+
return False
|
|
393
|
+
groups = hooks.get("UserPromptExpansion")
|
|
394
|
+
return isinstance(groups, list) and any(
|
|
395
|
+
isinstance(group, dict)
|
|
396
|
+
and isinstance(group.get("hooks"), list)
|
|
397
|
+
and any(is_keel_hook_handler(handler) for handler in group["hooks"])
|
|
398
|
+
for group in groups
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def check_status_items(repo: Path, target: str) -> list[tuple[str, bool]]:
|
|
403
|
+
items: list[tuple[str, bool]] = []
|
|
404
|
+
targets = target_set(target)
|
|
405
|
+
items.append(("AGENTS.md bootstrap", has_managed_block(repo / "AGENTS.md")))
|
|
406
|
+
if "claude" in targets:
|
|
407
|
+
claude_path = repo / "CLAUDE.md"
|
|
408
|
+
items.append(
|
|
409
|
+
(
|
|
410
|
+
"CLAUDE.md @AGENTS.md import",
|
|
411
|
+
claude_path.is_file()
|
|
412
|
+
and "@AGENTS.md" in claude_path.read_text(encoding="utf-8"),
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
items.append(
|
|
417
|
+
(
|
|
418
|
+
f"{OPENSPEC_CONFIG_PATH.as_posix()} schema {OPENSPEC_SCHEMA_NAME}",
|
|
419
|
+
has_openspec_schema_config(repo),
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
for action in openspec_schema_actions():
|
|
423
|
+
items.append((action.relative_path.as_posix(), (repo / action.relative_path).is_file()))
|
|
424
|
+
return items
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def line_number_for_offset(content: str, offset: int) -> int:
|
|
428
|
+
return content.count("\n", 0, offset) + 1
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def inspect_handoff(repo: Path) -> tuple[str, str, list[str]]:
|
|
432
|
+
handoff_path = repo / HANDOFF_PATH
|
|
433
|
+
if not handoff_path.is_file():
|
|
434
|
+
return (
|
|
435
|
+
"absent",
|
|
436
|
+
"normal; keel context will infer from OpenSpec",
|
|
437
|
+
[],
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
content = handoff_path.read_bytes().decode("utf-8")
|
|
442
|
+
except UnicodeDecodeError:
|
|
443
|
+
message = "keel/HANDOFF.md is not valid UTF-8"
|
|
444
|
+
return ("invalid", message, [message])
|
|
445
|
+
if not content.startswith(("---\n", "---\r\n")):
|
|
446
|
+
return (
|
|
447
|
+
"legacy",
|
|
448
|
+
"preserved byte-for-byte; migrate to keel-handoff/v1 or clear with "
|
|
449
|
+
"keel context --clear-handoff",
|
|
450
|
+
[],
|
|
451
|
+
)
|
|
452
|
+
match = re.fullmatch(
|
|
453
|
+
r"---\r?\n(?P<front>[\s\S]*?)\r?\n---(?:\r?\n)?",
|
|
454
|
+
content,
|
|
455
|
+
)
|
|
456
|
+
if match is None:
|
|
457
|
+
message = "keel/HANDOFF.md has invalid or non-pointer v1 content"
|
|
458
|
+
return ("invalid", message, [message])
|
|
459
|
+
|
|
460
|
+
fields: dict[str, str] = {}
|
|
461
|
+
for line in match.group("front").splitlines():
|
|
462
|
+
if not line.strip():
|
|
463
|
+
continue
|
|
464
|
+
field = re.fullmatch(r"([A-Za-z][A-Za-z0-9_-]*):\s*(.*?)\s*", line)
|
|
465
|
+
if field is None or field.group(1) in fields:
|
|
466
|
+
message = "keel/HANDOFF.md has invalid v1 front matter"
|
|
467
|
+
return ("invalid", message, [message])
|
|
468
|
+
fields[field.group(1)] = field.group(2).strip("\"'")
|
|
469
|
+
|
|
470
|
+
if fields.get("schema") != "keel-handoff/v1":
|
|
471
|
+
return (
|
|
472
|
+
"legacy",
|
|
473
|
+
"preserved byte-for-byte; migrate to keel-handoff/v1 or clear with "
|
|
474
|
+
"keel context --clear-handoff",
|
|
475
|
+
[],
|
|
476
|
+
)
|
|
477
|
+
if set(fields) != HANDOFF_FIELDS or not all(fields.values()):
|
|
478
|
+
message = "keel/HANDOFF.md v1 must contain only schema, owner, action, and reason"
|
|
479
|
+
return ("invalid", message, [message])
|
|
480
|
+
if fields["action"] not in HANDOFF_ACTIONS:
|
|
481
|
+
message = f"keel/HANDOFF.md has unsupported action: {fields['action']}"
|
|
482
|
+
return ("invalid", message, [message])
|
|
483
|
+
if HANDOFF_OWNER_RE.fullmatch(fields["owner"]) is None:
|
|
484
|
+
message = f"keel/HANDOFF.md has unsupported owner: {fields['owner']}"
|
|
485
|
+
return ("invalid", message, [message])
|
|
486
|
+
owner_path = fields["owner"].split("#", 1)[0]
|
|
487
|
+
if not (repo / owner_path).is_file():
|
|
488
|
+
message = f"keel/HANDOFF.md owner is missing: {fields['owner']}"
|
|
489
|
+
return ("invalid", message, [message])
|
|
490
|
+
return ("v1", f"validated override -> {fields['owner']}", [])
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def report_handoff_status(repo: Path) -> list[str]:
|
|
494
|
+
state, detail, errors = inspect_handoff(repo)
|
|
495
|
+
print(f"handoff: {state} - {detail}")
|
|
496
|
+
return errors
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def is_tasks_rule_line(line: str) -> bool:
|
|
500
|
+
normalized = line.strip().lstrip("> ").lower()
|
|
501
|
+
return any(
|
|
502
|
+
phrase in normalized
|
|
503
|
+
for phrase in (
|
|
504
|
+
"do not record",
|
|
505
|
+
"must not record",
|
|
506
|
+
"not record",
|
|
507
|
+
"source of truth",
|
|
508
|
+
"belongs in keel/handoff.md",
|
|
509
|
+
"不在此记录",
|
|
510
|
+
"不写入",
|
|
511
|
+
"不要记录",
|
|
512
|
+
"唯一真相源",
|
|
513
|
+
)
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def check_tasks_semantics(repo: Path) -> list[str]:
|
|
518
|
+
changes_root = repo / OPENSPEC_ROOT / "changes"
|
|
519
|
+
if not changes_root.is_dir():
|
|
520
|
+
return []
|
|
521
|
+
|
|
522
|
+
errors: list[str] = []
|
|
523
|
+
for tasks_path in sorted(changes_root.rglob("tasks.md")):
|
|
524
|
+
if not tasks_path.is_file():
|
|
525
|
+
continue
|
|
526
|
+
if tasks_path.relative_to(changes_root).parts[0] == "archive":
|
|
527
|
+
continue
|
|
528
|
+
relative = tasks_path.relative_to(repo).as_posix()
|
|
529
|
+
content = tasks_path.read_text(encoding="utf-8")
|
|
530
|
+
|
|
531
|
+
for pattern, message in TASKS_LEGACY_HEADING_PATTERNS:
|
|
532
|
+
match = pattern.search(content)
|
|
533
|
+
if match is not None:
|
|
534
|
+
line = line_number_for_offset(content, match.start())
|
|
535
|
+
errors.append(f"{relative}:{line}: {message}")
|
|
536
|
+
|
|
537
|
+
for line_number, line in enumerate(content.splitlines(), start=1):
|
|
538
|
+
if is_tasks_rule_line(line):
|
|
539
|
+
continue
|
|
540
|
+
for pattern, message in TASKS_COMMIT_STATUS_PATTERNS:
|
|
541
|
+
if pattern.search(line):
|
|
542
|
+
errors.append(f"{relative}:{line_number}: {message}")
|
|
543
|
+
if TASKS_CONTEXTUAL_HASH_RE.search(line):
|
|
544
|
+
errors.append(
|
|
545
|
+
f"{relative}:{line_number}: remove contextual commit hash from tasks.md; git log is the source of truth"
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
return errors
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def report_check_status(repo: Path, target: str) -> int:
|
|
552
|
+
items = check_status_items(repo, target)
|
|
553
|
+
missing = [name for name, present in items if not present]
|
|
554
|
+
handoff_errors = report_handoff_status(repo)
|
|
555
|
+
|
|
556
|
+
if not missing:
|
|
557
|
+
print("status: installed")
|
|
558
|
+
semantic_errors = [
|
|
559
|
+
*handoff_errors,
|
|
560
|
+
*check_tasks_semantics(repo),
|
|
561
|
+
]
|
|
562
|
+
if semantic_errors:
|
|
563
|
+
print("keel state: failed")
|
|
564
|
+
for error in semantic_errors:
|
|
565
|
+
print(f"state-error {error}")
|
|
566
|
+
return 1
|
|
567
|
+
print("keel state: ok")
|
|
568
|
+
return 0
|
|
569
|
+
if len(missing) == len(items):
|
|
570
|
+
print("status: missing")
|
|
571
|
+
else:
|
|
572
|
+
print("status: partial")
|
|
573
|
+
|
|
574
|
+
for name in missing:
|
|
575
|
+
print(f"missing {name}")
|
|
576
|
+
return 0
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def extract_managed_block(content: str) -> str | None:
|
|
580
|
+
start_match = MANAGED_START_RE.search(content)
|
|
581
|
+
if start_match is None:
|
|
582
|
+
return None
|
|
583
|
+
end = content.find(MANAGED_END, start_match.end())
|
|
584
|
+
if end == -1:
|
|
585
|
+
return None
|
|
586
|
+
return content[start_match.start() : end + len(MANAGED_END)]
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def remove_managed_block(content: str) -> tuple[str, bool]:
|
|
590
|
+
block = extract_managed_block(content)
|
|
591
|
+
if block is None:
|
|
592
|
+
return content, False
|
|
593
|
+
return content.replace(block, "", 1), True
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def merge_managed_block(existing: str, source: str) -> tuple[str, str]:
|
|
597
|
+
source_block = extract_managed_block(source)
|
|
598
|
+
if source_block is None:
|
|
599
|
+
raise ValueError("packaged managed-block file is missing keel managed markers")
|
|
600
|
+
|
|
601
|
+
existing_block = extract_managed_block(existing)
|
|
602
|
+
if existing_block is None:
|
|
603
|
+
separator = "" if existing.endswith("\n") or not existing else "\n"
|
|
604
|
+
return existing + separator + source_block + "\n", "append"
|
|
605
|
+
|
|
606
|
+
if existing_block == source_block:
|
|
607
|
+
return existing, "skip"
|
|
608
|
+
|
|
609
|
+
return existing.replace(existing_block, source_block, 1), "update"
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def merge_openspec_config(existing: str) -> tuple[str, str]:
|
|
613
|
+
desired_line = f"schema: {OPENSPEC_SCHEMA_NAME}"
|
|
614
|
+
schema_re = re.compile(r"(?m)^(\s*schema\s*:\s*).*$")
|
|
615
|
+
match = schema_re.search(existing)
|
|
616
|
+
if match is None:
|
|
617
|
+
separator = "" if existing.startswith("\n") or not existing else "\n"
|
|
618
|
+
updated = desired_line + "\n" + separator + existing
|
|
619
|
+
return updated, "update"
|
|
620
|
+
|
|
621
|
+
current_line = match.group(0).strip()
|
|
622
|
+
if current_line == desired_line:
|
|
623
|
+
return existing, "skip"
|
|
624
|
+
|
|
625
|
+
updated = existing[: match.start()] + desired_line + existing[match.end() :]
|
|
626
|
+
return updated, "update"
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def template_checksum(content: str) -> str:
|
|
630
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def template_payload(content: str) -> str:
|
|
634
|
+
return (
|
|
635
|
+
content
|
|
636
|
+
+ f"{TEMPLATE_CHECKSUM_PREFIX}{template_checksum(content)}"
|
|
637
|
+
+ TEMPLATE_CHECKSUM_SUFFIX
|
|
638
|
+
+ "\n"
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def strip_template_checksum(content: str) -> tuple[str, str | None]:
|
|
643
|
+
lines = content.splitlines(keepends=True)
|
|
644
|
+
checksum: str | None = None
|
|
645
|
+
kept: list[str] = []
|
|
646
|
+
for line in lines:
|
|
647
|
+
stripped = line.strip()
|
|
648
|
+
if stripped.startswith(TEMPLATE_CHECKSUM_PREFIX) and stripped.endswith(
|
|
649
|
+
TEMPLATE_CHECKSUM_SUFFIX
|
|
650
|
+
):
|
|
651
|
+
checksum = stripped[
|
|
652
|
+
len(TEMPLATE_CHECKSUM_PREFIX) : -len(TEMPLATE_CHECKSUM_SUFFIX)
|
|
653
|
+
]
|
|
654
|
+
continue
|
|
655
|
+
kept.append(line)
|
|
656
|
+
return "".join(kept), checksum
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def action_source_content(action: InstallAction) -> str:
|
|
660
|
+
if action.source_path is not None:
|
|
661
|
+
return action.source_path.read_text(encoding="utf-8")
|
|
662
|
+
return action.content or ""
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def load_hook_config(content: str) -> dict:
|
|
666
|
+
try:
|
|
667
|
+
config = json.loads(content)
|
|
668
|
+
except json.JSONDecodeError as exc:
|
|
669
|
+
raise ValueError(f"Keel hook config is not valid JSON: {exc}") from exc
|
|
670
|
+
if not isinstance(config, dict) or not isinstance(config.get("hooks"), dict):
|
|
671
|
+
raise ValueError("Keel hook config must contain an object field 'hooks'")
|
|
672
|
+
return config
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def remove_keel_hook_handlers(groups: object) -> list[object]:
|
|
676
|
+
if not isinstance(groups, list):
|
|
677
|
+
raise ValueError("existing Claude settings hooks event must be an array")
|
|
678
|
+
kept_groups: list[object] = []
|
|
679
|
+
for group in groups:
|
|
680
|
+
if not isinstance(group, dict) or not isinstance(group.get("hooks"), list):
|
|
681
|
+
kept_groups.append(group)
|
|
682
|
+
continue
|
|
683
|
+
kept_handlers = [
|
|
684
|
+
handler for handler in group["hooks"] if not is_keel_hook_handler(handler)
|
|
685
|
+
]
|
|
686
|
+
if kept_handlers:
|
|
687
|
+
updated = dict(group)
|
|
688
|
+
updated["hooks"] = kept_handlers
|
|
689
|
+
kept_groups.append(updated)
|
|
690
|
+
return kept_groups
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def merge_keel_hook_settings(existing: str, source: str) -> tuple[str, str]:
|
|
694
|
+
config = load_hook_config(source)
|
|
695
|
+
if not existing.strip():
|
|
696
|
+
settings: dict = {}
|
|
697
|
+
else:
|
|
698
|
+
try:
|
|
699
|
+
settings = json.loads(existing)
|
|
700
|
+
except json.JSONDecodeError as exc:
|
|
701
|
+
raise ValueError(
|
|
702
|
+
f"cannot merge Keel Hook into .claude/settings.json because it is not valid JSON: {exc}"
|
|
703
|
+
) from exc
|
|
704
|
+
if not isinstance(settings, dict):
|
|
705
|
+
raise ValueError("cannot merge Keel Hook into .claude/settings.json because it is not a JSON object")
|
|
706
|
+
|
|
707
|
+
hooks = settings.get("hooks", {})
|
|
708
|
+
if not isinstance(hooks, dict):
|
|
709
|
+
raise ValueError("cannot merge Keel Hook because .claude/settings.json field 'hooks' is not an object")
|
|
710
|
+
hooks = dict(hooks)
|
|
711
|
+
for event, keel_groups in config["hooks"].items():
|
|
712
|
+
if not isinstance(keel_groups, list):
|
|
713
|
+
raise ValueError(f"Keel hook config event {event!r} must be an array")
|
|
714
|
+
existing_groups = remove_keel_hook_handlers(hooks.get(event, [])) if event in hooks else []
|
|
715
|
+
hooks[event] = [*existing_groups, *keel_groups]
|
|
716
|
+
settings = dict(settings)
|
|
717
|
+
settings["hooks"] = hooks
|
|
718
|
+
merged = json.dumps(settings, indent=2, ensure_ascii=False) + "\n"
|
|
719
|
+
existing_normalized = json.dumps(json.loads(existing), indent=2, ensure_ascii=False) + "\n" if existing.strip() else ""
|
|
720
|
+
return merged, "skip" if existing_normalized == merged else "update"
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def remove_keel_hook_settings(existing: str) -> tuple[str, str]:
|
|
724
|
+
try:
|
|
725
|
+
settings = json.loads(existing)
|
|
726
|
+
except json.JSONDecodeError as exc:
|
|
727
|
+
raise ValueError(
|
|
728
|
+
f"cannot remove Keel Hook from .claude/settings.json because it is not valid JSON: {exc}"
|
|
729
|
+
) from exc
|
|
730
|
+
if not isinstance(settings, dict):
|
|
731
|
+
raise ValueError("cannot remove Keel Hook because .claude/settings.json is not a JSON object")
|
|
732
|
+
hooks = settings.get("hooks")
|
|
733
|
+
if not isinstance(hooks, dict):
|
|
734
|
+
return existing, "skip"
|
|
735
|
+
updated_hooks = dict(hooks)
|
|
736
|
+
changed = False
|
|
737
|
+
for event, groups in list(updated_hooks.items()):
|
|
738
|
+
if not isinstance(groups, list):
|
|
739
|
+
continue
|
|
740
|
+
filtered = remove_keel_hook_handlers(groups)
|
|
741
|
+
if filtered != groups:
|
|
742
|
+
changed = True
|
|
743
|
+
if filtered:
|
|
744
|
+
updated_hooks[event] = filtered
|
|
745
|
+
else:
|
|
746
|
+
del updated_hooks[event]
|
|
747
|
+
if not changed:
|
|
748
|
+
return existing, "skip"
|
|
749
|
+
updated_settings = dict(settings)
|
|
750
|
+
if updated_hooks:
|
|
751
|
+
updated_settings["hooks"] = updated_hooks
|
|
752
|
+
else:
|
|
753
|
+
updated_settings.pop("hooks", None)
|
|
754
|
+
return json.dumps(updated_settings, indent=2, ensure_ascii=False) + "\n", "update"
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def plan_template_action(
|
|
758
|
+
destination: Path,
|
|
759
|
+
action: InstallAction,
|
|
760
|
+
source_content: str,
|
|
761
|
+
force_template_update: bool,
|
|
762
|
+
) -> PlannedAction:
|
|
763
|
+
payload = template_payload(source_content)
|
|
764
|
+
if not destination.exists():
|
|
765
|
+
return PlannedAction("create", action.relative_path, payload)
|
|
766
|
+
|
|
767
|
+
existing = destination.read_text(encoding="utf-8")
|
|
768
|
+
existing_base, existing_checksum = strip_template_checksum(existing)
|
|
769
|
+
current_checksum = template_checksum(source_content)
|
|
770
|
+
|
|
771
|
+
if existing_base == source_content and existing_checksum == current_checksum:
|
|
772
|
+
return PlannedAction("skip", action.relative_path)
|
|
773
|
+
if force_template_update:
|
|
774
|
+
return PlannedAction("update", action.relative_path, payload)
|
|
775
|
+
if existing_checksum == template_checksum(existing_base):
|
|
776
|
+
return PlannedAction("update", action.relative_path, payload)
|
|
777
|
+
|
|
778
|
+
return PlannedAction("skip", action.relative_path)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def plan_action(
|
|
782
|
+
repo: Path,
|
|
783
|
+
action: InstallAction,
|
|
784
|
+
force_template_update: bool = False,
|
|
785
|
+
) -> PlannedAction:
|
|
786
|
+
destination = require_inside_repo(repo, action.relative_path)
|
|
787
|
+
source_content = action_source_content(action)
|
|
788
|
+
|
|
789
|
+
if action.strategy == "keel-hook-settings":
|
|
790
|
+
existing = destination.read_text(encoding="utf-8") if destination.exists() else ""
|
|
791
|
+
merged, kind = merge_keel_hook_settings(existing, source_content)
|
|
792
|
+
return PlannedAction("create" if not destination.exists() else kind, action.relative_path, merged if kind != "skip" else None)
|
|
793
|
+
|
|
794
|
+
if action.strategy == "template":
|
|
795
|
+
return plan_template_action(
|
|
796
|
+
destination,
|
|
797
|
+
action,
|
|
798
|
+
source_content,
|
|
799
|
+
force_template_update,
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
if not destination.exists():
|
|
803
|
+
return PlannedAction("create", action.relative_path, source_content)
|
|
804
|
+
|
|
805
|
+
existing = destination.read_text(encoding="utf-8")
|
|
806
|
+
if action.strategy == "managed-block":
|
|
807
|
+
merged, kind = merge_managed_block(existing, source_content)
|
|
808
|
+
return PlannedAction(kind, action.relative_path, None if kind == "skip" else merged)
|
|
809
|
+
if action.strategy == "openspec-config":
|
|
810
|
+
merged, kind = merge_openspec_config(existing)
|
|
811
|
+
return PlannedAction(kind, action.relative_path, None if kind == "skip" else merged)
|
|
812
|
+
|
|
813
|
+
if existing == source_content:
|
|
814
|
+
return PlannedAction("skip", action.relative_path)
|
|
815
|
+
return PlannedAction("update", action.relative_path, source_content)
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def plan_actions(
|
|
819
|
+
repo: Path,
|
|
820
|
+
actions: list[InstallAction],
|
|
821
|
+
force_template_update: bool = False,
|
|
822
|
+
) -> list[PlannedAction]:
|
|
823
|
+
return [
|
|
824
|
+
plan_action(repo, action, force_template_update=force_template_update)
|
|
825
|
+
for action in actions
|
|
826
|
+
]
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def template_matches_packaged(path: Path, source_content: str) -> bool:
|
|
830
|
+
if not path.is_file():
|
|
831
|
+
return False
|
|
832
|
+
existing_base, existing_checksum = strip_template_checksum(
|
|
833
|
+
path.read_text(encoding="utf-8")
|
|
834
|
+
)
|
|
835
|
+
current_checksum = template_checksum(source_content)
|
|
836
|
+
return existing_base == source_content and existing_checksum in {
|
|
837
|
+
None,
|
|
838
|
+
current_checksum,
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def plan_uninstall_managed(repo: Path, relative_path: str) -> PlannedAction:
|
|
843
|
+
path = require_inside_repo(repo, Path(relative_path))
|
|
844
|
+
if not path.is_file():
|
|
845
|
+
return PlannedAction("skip", Path(relative_path))
|
|
846
|
+
updated, removed = remove_managed_block(path.read_text(encoding="utf-8"))
|
|
847
|
+
if not removed:
|
|
848
|
+
return PlannedAction("skip", Path(relative_path))
|
|
849
|
+
return PlannedAction("remove-managed", Path(relative_path), updated)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def plan_uninstall_template(
|
|
853
|
+
repo: Path,
|
|
854
|
+
relative_path: str,
|
|
855
|
+
source_path: Path,
|
|
856
|
+
) -> PlannedAction:
|
|
857
|
+
path = require_inside_repo(repo, Path(relative_path))
|
|
858
|
+
if not path.exists():
|
|
859
|
+
return PlannedAction("skip", Path(relative_path))
|
|
860
|
+
source_content = source_path.read_text(encoding="utf-8")
|
|
861
|
+
if template_matches_packaged(path, source_content):
|
|
862
|
+
return PlannedAction("remove", Path(relative_path))
|
|
863
|
+
return PlannedAction("skip", Path(relative_path))
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def directory_has_only_gitkeep(path: Path) -> bool:
|
|
867
|
+
if not path.is_dir():
|
|
868
|
+
return False
|
|
869
|
+
entries = list(path.iterdir())
|
|
870
|
+
return len(entries) == 1 and entries[0].name == ".gitkeep"
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def plan_uninstall_gitkeep(repo: Path, relative_dir: str) -> list[PlannedAction]:
|
|
874
|
+
directory = require_inside_repo(repo, Path(relative_dir))
|
|
875
|
+
gitkeep = directory / ".gitkeep"
|
|
876
|
+
if not gitkeep.is_file() or not directory_has_only_gitkeep(directory):
|
|
877
|
+
return []
|
|
878
|
+
return [
|
|
879
|
+
PlannedAction("remove", Path(relative_dir) / ".gitkeep"),
|
|
880
|
+
PlannedAction("rmdir", Path(relative_dir)),
|
|
881
|
+
]
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def plan_uninstall_packaged_file(
|
|
885
|
+
repo: Path,
|
|
886
|
+
relative_path: str,
|
|
887
|
+
source_path: Path,
|
|
888
|
+
) -> PlannedAction:
|
|
889
|
+
path = require_inside_repo(repo, Path(relative_path))
|
|
890
|
+
if not path.exists():
|
|
891
|
+
return PlannedAction("skip", Path(relative_path))
|
|
892
|
+
if not path.is_file():
|
|
893
|
+
return PlannedAction("skip", Path(relative_path))
|
|
894
|
+
source_content = source_path.read_text(encoding="utf-8")
|
|
895
|
+
if path.read_text(encoding="utf-8") == source_content:
|
|
896
|
+
return PlannedAction("remove", Path(relative_path))
|
|
897
|
+
return PlannedAction("skip", Path(relative_path))
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def plan_uninstall_keel_hook_settings(repo: Path) -> PlannedAction:
|
|
901
|
+
relative_path = Path(".claude/settings.json")
|
|
902
|
+
path = require_inside_repo(repo, relative_path)
|
|
903
|
+
if not path.is_file():
|
|
904
|
+
return PlannedAction("skip", relative_path)
|
|
905
|
+
updated, kind = remove_keel_hook_settings(path.read_text(encoding="utf-8"))
|
|
906
|
+
if kind == "update" and json.loads(updated) == {}:
|
|
907
|
+
return PlannedAction("remove", relative_path)
|
|
908
|
+
return PlannedAction(kind, relative_path, updated if kind != "skip" else None)
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def plan_uninstall_keel_hook_actions(repo: Path, target: str) -> list[PlannedAction]:
|
|
912
|
+
if "claude" not in target_set(target):
|
|
913
|
+
return []
|
|
914
|
+
actions: list[PlannedAction] = []
|
|
915
|
+
for action in keel_hook_actions("claude"):
|
|
916
|
+
if action.strategy == "keel-hook-settings":
|
|
917
|
+
actions.append(plan_uninstall_keel_hook_settings(repo))
|
|
918
|
+
elif action.source_path is not None:
|
|
919
|
+
actions.append(
|
|
920
|
+
plan_uninstall_packaged_file(
|
|
921
|
+
repo,
|
|
922
|
+
action.relative_path.as_posix(),
|
|
923
|
+
action.source_path,
|
|
924
|
+
)
|
|
925
|
+
)
|
|
926
|
+
actions.extend(
|
|
927
|
+
[
|
|
928
|
+
rmdir_if_empty_action(KEEL_HOOK_ROOT.as_posix()),
|
|
929
|
+
rmdir_if_empty_action(".claude/hooks"),
|
|
930
|
+
]
|
|
931
|
+
)
|
|
932
|
+
return actions
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def plan_uninstall_empty_dir(repo: Path, relative_dir: str) -> PlannedAction:
|
|
936
|
+
directory = require_inside_repo(repo, Path(relative_dir))
|
|
937
|
+
if not directory.is_dir():
|
|
938
|
+
return PlannedAction("skip", Path(relative_dir))
|
|
939
|
+
if any(directory.iterdir()):
|
|
940
|
+
return PlannedAction("skip", Path(relative_dir))
|
|
941
|
+
return PlannedAction("rmdir", Path(relative_dir))
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def rmdir_if_empty_action(relative_dir: str) -> PlannedAction:
|
|
945
|
+
return PlannedAction("rmdir", Path(relative_dir))
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def plan_uninstall_skill_actions(repo: Path, target: str) -> list[PlannedAction]:
|
|
949
|
+
actions: list[PlannedAction] = []
|
|
950
|
+
for target_name in target_names(target):
|
|
951
|
+
skill_root = TARGET_SKILL_ROOTS[target_name]
|
|
952
|
+
target_actions = skill_actions(target_name)
|
|
953
|
+
for action in target_actions:
|
|
954
|
+
if action.source_path is None:
|
|
955
|
+
continue
|
|
956
|
+
actions.append(
|
|
957
|
+
plan_uninstall_packaged_file(
|
|
958
|
+
repo,
|
|
959
|
+
action.relative_path.as_posix(),
|
|
960
|
+
action.source_path,
|
|
961
|
+
)
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
skill_dirs: set[Path] = set()
|
|
965
|
+
for action in target_actions:
|
|
966
|
+
parent = action.relative_path.parent
|
|
967
|
+
while parent != skill_root:
|
|
968
|
+
skill_dirs.add(parent)
|
|
969
|
+
parent = parent.parent
|
|
970
|
+
for skill_dir in sorted(skill_dirs, key=lambda path: len(path.parts), reverse=True):
|
|
971
|
+
actions.append(rmdir_if_empty_action(skill_dir.as_posix()))
|
|
972
|
+
actions.append(rmdir_if_empty_action(skill_root.as_posix()))
|
|
973
|
+
actions.append(rmdir_if_empty_action(skill_root.parent.as_posix()))
|
|
974
|
+
return actions
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def plan_uninstall_agent_actions(repo: Path, target: str) -> list[PlannedAction]:
|
|
978
|
+
actions: list[PlannedAction] = []
|
|
979
|
+
if "claude" not in target_set(target):
|
|
980
|
+
return actions
|
|
981
|
+
for action in agent_actions(target):
|
|
982
|
+
if action.source_path is None:
|
|
983
|
+
continue
|
|
984
|
+
actions.append(
|
|
985
|
+
plan_uninstall_packaged_file(
|
|
986
|
+
repo,
|
|
987
|
+
action.relative_path.as_posix(),
|
|
988
|
+
action.source_path,
|
|
989
|
+
)
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
agent_dirs: set[Path] = set()
|
|
993
|
+
for action in agent_actions(target):
|
|
994
|
+
parent = action.relative_path.parent
|
|
995
|
+
while parent != Path(".claude/agents"):
|
|
996
|
+
agent_dirs.add(parent)
|
|
997
|
+
parent = parent.parent
|
|
998
|
+
for agent_dir in sorted(agent_dirs, key=lambda path: len(path.parts), reverse=True):
|
|
999
|
+
actions.append(rmdir_if_empty_action(agent_dir.as_posix()))
|
|
1000
|
+
actions.append(rmdir_if_empty_action(".claude/agents"))
|
|
1001
|
+
return actions
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def plan_uninstall_actions(repo: Path, target: str) -> list[PlannedAction]:
|
|
1005
|
+
actions: list[PlannedAction] = []
|
|
1006
|
+
targets = target_set(target)
|
|
1007
|
+
actions.append(plan_uninstall_managed(repo, "AGENTS.md"))
|
|
1008
|
+
if "claude" in targets:
|
|
1009
|
+
actions.append(plan_uninstall_managed(repo, "CLAUDE.md"))
|
|
1010
|
+
|
|
1011
|
+
for action in openspec_schema_actions():
|
|
1012
|
+
if action.source_path is not None:
|
|
1013
|
+
actions.append(
|
|
1014
|
+
plan_uninstall_packaged_file(
|
|
1015
|
+
repo,
|
|
1016
|
+
action.relative_path.as_posix(),
|
|
1017
|
+
action.source_path,
|
|
1018
|
+
)
|
|
1019
|
+
)
|
|
1020
|
+
actions.append(rmdir_if_empty_action((OPENSPEC_SCHEMA_ROOT / "templates").as_posix()))
|
|
1021
|
+
actions.append(rmdir_if_empty_action(OPENSPEC_SCHEMA_ROOT.as_posix()))
|
|
1022
|
+
actions.append(rmdir_if_empty_action((OPENSPEC_ROOT / "schemas").as_posix()))
|
|
1023
|
+
actions.append(rmdir_if_empty_action((KEEL_ROOT / "backlog").as_posix()))
|
|
1024
|
+
actions.append(rmdir_if_empty_action((KEEL_ROOT / "templates").as_posix()))
|
|
1025
|
+
actions.append(rmdir_if_empty_action(KEEL_ROOT.as_posix()))
|
|
1026
|
+
return actions
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def describe_actions(actions: list[PlannedAction]) -> None:
|
|
1030
|
+
for action in actions:
|
|
1031
|
+
print(f"{action.kind} {action.relative_path.as_posix()}")
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def apply_actions(repo: Path, actions: list[PlannedAction]) -> None:
|
|
1035
|
+
for action in actions:
|
|
1036
|
+
if action.kind == "skip":
|
|
1037
|
+
continue
|
|
1038
|
+
destination = require_inside_repo(repo, action.relative_path)
|
|
1039
|
+
if action.kind == "remove":
|
|
1040
|
+
if destination.is_file():
|
|
1041
|
+
destination.unlink()
|
|
1042
|
+
continue
|
|
1043
|
+
if action.kind == "rmdir":
|
|
1044
|
+
if destination.is_dir() and not any(destination.iterdir()):
|
|
1045
|
+
destination.rmdir()
|
|
1046
|
+
continue
|
|
1047
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
1048
|
+
destination.write_text(action.content or "", encoding="utf-8")
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def main() -> int:
|
|
1052
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
1053
|
+
parser.add_argument("repo", help="Target repository path.")
|
|
1054
|
+
parser.add_argument(
|
|
1055
|
+
"--target",
|
|
1056
|
+
choices=["both", "claude", "codex", "opencode"],
|
|
1057
|
+
default="claude",
|
|
1058
|
+
help="Protocol target to install. Defaults to claude; both is a legacy alias for claude.",
|
|
1059
|
+
)
|
|
1060
|
+
parser.add_argument(
|
|
1061
|
+
"--dry-run",
|
|
1062
|
+
action="store_true",
|
|
1063
|
+
help="Report planned actions without modifying files.",
|
|
1064
|
+
)
|
|
1065
|
+
parser.add_argument(
|
|
1066
|
+
"--check",
|
|
1067
|
+
action="store_true",
|
|
1068
|
+
help="Report installed, partial, or missing status without modifying files.",
|
|
1069
|
+
)
|
|
1070
|
+
parser.add_argument(
|
|
1071
|
+
"--force-template-update",
|
|
1072
|
+
action="store_true",
|
|
1073
|
+
help="Overwrite user-edited keel templates during install or project refresh.",
|
|
1074
|
+
)
|
|
1075
|
+
parser.add_argument(
|
|
1076
|
+
"--uninstall",
|
|
1077
|
+
action="store_true",
|
|
1078
|
+
help="Remove managed protocol blocks and safe generated skeleton files.",
|
|
1079
|
+
)
|
|
1080
|
+
parser.add_argument(
|
|
1081
|
+
"--profile",
|
|
1082
|
+
action="append",
|
|
1083
|
+
help="Obsolete in v4; domain references are bundled with keel-align-expectations.",
|
|
1084
|
+
)
|
|
1085
|
+
args = parser.parse_args()
|
|
1086
|
+
|
|
1087
|
+
try:
|
|
1088
|
+
repo = Path(args.repo).resolve()
|
|
1089
|
+
if args.profile:
|
|
1090
|
+
print(
|
|
1091
|
+
"Install failed: --profile is no longer supported; web, hardware, "
|
|
1092
|
+
"and hardware-dsl guidance is bundled with the "
|
|
1093
|
+
"keel-align-expectations skill as on-demand references",
|
|
1094
|
+
file=sys.stderr,
|
|
1095
|
+
)
|
|
1096
|
+
return 1
|
|
1097
|
+
if args.check:
|
|
1098
|
+
return report_check_status(repo, args.target)
|
|
1099
|
+
if args.uninstall:
|
|
1100
|
+
actions = plan_uninstall_actions(repo, args.target)
|
|
1101
|
+
describe_actions(actions)
|
|
1102
|
+
if not args.dry_run:
|
|
1103
|
+
apply_actions(repo, actions)
|
|
1104
|
+
return 0
|
|
1105
|
+
repo.mkdir(parents=True, exist_ok=True)
|
|
1106
|
+
actions = plan_actions(
|
|
1107
|
+
repo,
|
|
1108
|
+
collect_actions(repo, args.target),
|
|
1109
|
+
force_template_update=args.force_template_update,
|
|
1110
|
+
)
|
|
1111
|
+
describe_actions(actions)
|
|
1112
|
+
report_handoff_status(repo)
|
|
1113
|
+
if not args.dry_run:
|
|
1114
|
+
apply_actions(repo, actions)
|
|
1115
|
+
return 0
|
|
1116
|
+
except ValueError as exc:
|
|
1117
|
+
print(f"Install failed: {exc}", file=sys.stderr)
|
|
1118
|
+
return 1
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
if __name__ == "__main__":
|
|
1122
|
+
sys.exit(main())
|