@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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +250 -0
  3. package/README.zh-CN.md +295 -0
  4. package/assets/bootstrap/AGENTS.md +9 -0
  5. package/assets/openspec/schemas/keel-spec-driven/schema.yaml +166 -0
  6. package/assets/openspec/schemas/keel-spec-driven/templates/design.md +52 -0
  7. package/assets/openspec/schemas/keel-spec-driven/templates/proposal.md +21 -0
  8. package/assets/openspec/schemas/keel-spec-driven/templates/spec.md +8 -0
  9. package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +68 -0
  10. package/bin/keel.js +1490 -0
  11. package/package.json +35 -0
  12. package/plugins/keel/.claude-plugin/plugin.json +17 -0
  13. package/plugins/keel/.codex-plugin/plugin.json +29 -0
  14. package/plugins/keel/agents/keel-single-task-goal-claude.md +16 -0
  15. package/plugins/keel/agents/keel-single-task-goal-codex.md +16 -0
  16. package/plugins/keel/hooks/hooks.json +30 -0
  17. package/plugins/keel/scripts/pretooluse-guard.js +156 -0
  18. package/plugins/keel/scripts/session-start.js +182 -0
  19. package/plugins/keel/skills/keel-align-expectations/SKILL.md +53 -0
  20. package/plugins/keel/skills/keel-align-expectations/references/hardware-dsl.md +21 -0
  21. package/plugins/keel/skills/keel-align-expectations/references/hardware.md +21 -0
  22. package/plugins/keel/skills/keel-align-expectations/references/web.md +21 -0
  23. package/plugins/keel/skills/keel-debug-failure/SKILL.md +41 -0
  24. package/plugins/keel/skills/keel-handoff/SKILL.md +45 -0
  25. package/plugins/keel/skills/keel-review-checklist/SKILL.md +73 -0
  26. package/plugins/keel/skills/keel-run-single-task-goal/SKILL.md +68 -0
  27. package/plugins/keel/skills/keel-tdd-or-test-first/SKILL.md +45 -0
  28. package/scripts/install_to_repo.py +1122 -0
  29. package/scripts/run_python.js +63 -0
  30. package/scripts/validate_plugin.py +9869 -0
  31. package/src/core/capabilities.js +291 -0
  32. package/src/core/context.js +514 -0
  33. package/src/core/gates.js +643 -0
  34. package/src/core/goal.js +230 -0
  35. package/src/core/guard.js +295 -0
  36. package/src/core/helper.js +319 -0
  37. package/src/core/projection.js +195 -0
  38. package/src/core/task-contract.js +736 -0
  39. 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())