hunter-harness 0.1.0 → 0.1.2

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 (71) hide show
  1. package/dist/bin.js +1033 -219
  2. package/package.json +2 -2
  3. package/resources/harness/general/.harness-build.json +1 -1
  4. package/resources/harness/general/harness-archive/SKILL.md +215 -215
  5. package/resources/harness/general/harness-codebase-map/SKILL.md +112 -112
  6. package/resources/harness/general/harness-codebase-map/templates/map-summary.md +3 -3
  7. package/resources/harness/general/harness-knowledge-ingest/SKILL.md +246 -246
  8. package/resources/harness/general/harness-knowledge-query/SKILL.md +164 -164
  9. package/resources/harness/general/harness-plan/SKILL.md +127 -127
  10. package/resources/harness/general/harness-review/SKILL.md +156 -156
  11. package/resources/harness/general/harness-run/SKILL.md +132 -132
  12. package/resources/harness/general/harness-submit/SKILL.md +159 -159
  13. package/resources/harness/general/harness-sync/SKILL.md +82 -82
  14. package/resources/harness/general/harness-test/SKILL.md +180 -180
  15. package/resources/harness/general/scripts/harness_deploy.py +580 -580
  16. package/resources/harness/java/.harness-build.json +1 -1
  17. package/resources/harness/java/harness-apidoc/SKILL.md +86 -86
  18. package/resources/harness/java/harness-archive/SKILL.md +215 -215
  19. package/resources/harness/java/harness-codebase-map/SKILL.md +112 -112
  20. package/resources/harness/java/harness-codebase-map/templates/map-summary.md +3 -3
  21. package/resources/harness/java/harness-knowledge-ingest/SKILL.md +246 -246
  22. package/resources/harness/java/harness-knowledge-query/SKILL.md +164 -164
  23. package/resources/harness/java/harness-package/SKILL.md +87 -87
  24. package/resources/harness/java/harness-plan/SKILL.md +127 -127
  25. package/resources/harness/java/harness-review/SKILL.md +156 -156
  26. package/resources/harness/java/harness-run/SKILL.md +148 -148
  27. package/resources/harness/java/harness-submit/SKILL.md +166 -166
  28. package/resources/harness/java/harness-sync/SKILL.md +82 -82
  29. package/resources/harness/java/harness-test/SKILL.md +192 -192
  30. package/resources/harness/java/scripts/harness_deploy.py +580 -580
  31. package/resources/harness/manifests/general.json +13 -33
  32. package/resources/harness/manifests/java.json +15 -35
  33. package/resources/harness/migrations/0.1.1/general.json +358 -0
  34. package/resources/harness/migrations/0.1.1/java.json +398 -0
  35. package/resources/bootstrap-ir/manifest.json +0 -19
  36. package/resources/bootstrap-ir/skills/harness-apidoc.yaml +0 -24
  37. package/resources/bootstrap-ir/skills/harness-archive.yaml +0 -24
  38. package/resources/bootstrap-ir/skills/harness-codebase-map.yaml +0 -24
  39. package/resources/bootstrap-ir/skills/harness-knowledge-ingest.yaml +0 -24
  40. package/resources/bootstrap-ir/skills/harness-package.yaml +0 -24
  41. package/resources/bootstrap-ir/skills/harness-plan.yaml +0 -24
  42. package/resources/bootstrap-ir/skills/harness-review.yaml +0 -24
  43. package/resources/bootstrap-ir/skills/harness-run.yaml +0 -24
  44. package/resources/bootstrap-ir/skills/harness-skill-optimizer.yaml +0 -28
  45. package/resources/bootstrap-ir/skills/harness-submit.yaml +0 -24
  46. package/resources/bootstrap-ir/skills/harness-sync.yaml +0 -24
  47. package/resources/bootstrap-ir/skills/harness-test.yaml +0 -24
  48. package/resources/bootstrap-ir/templates/claude-code-skill.md +0 -12
  49. package/resources/harness/general/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-01-10-ledger-reconciliation/reports/final/summary-data.json +0 -43
  50. package/resources/harness/general/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-02-14-ledger-snapshot-followup/reports/final/summary-data.json +0 -38
  51. package/resources/harness/general/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-03-05-webhook-contract/reports/final/summary-data.json +0 -36
  52. package/resources/harness/general/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/project.yaml +0 -1
  53. package/resources/harness/general/harness-knowledge-ingest/tests/test_harness_knowledge.py +0 -1792
  54. package/resources/harness/java/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-01-10-ledger-reconciliation/reports/final/summary-data.json +0 -43
  55. package/resources/harness/java/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-02-14-ledger-snapshot-followup/reports/final/summary-data.json +0 -38
  56. package/resources/harness/java/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-03-05-webhook-contract/reports/final/summary-data.json +0 -36
  57. package/resources/harness/java/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/project.yaml +0 -1
  58. package/resources/harness/java/harness-knowledge-ingest/tests/test_harness_knowledge.py +0 -1792
  59. package/resources/manifest.json +0 -19
  60. package/resources/skills/harness-apidoc/SKILL.md +0 -50
  61. package/resources/skills/harness-archive/SKILL.md +0 -48
  62. package/resources/skills/harness-codebase-map/SKILL.md +0 -53
  63. package/resources/skills/harness-knowledge-ingest/SKILL.md +0 -48
  64. package/resources/skills/harness-package/SKILL.md +0 -48
  65. package/resources/skills/harness-plan/SKILL.md +0 -51
  66. package/resources/skills/harness-review/SKILL.md +0 -50
  67. package/resources/skills/harness-run/SKILL.md +0 -48
  68. package/resources/skills/harness-skill-optimizer/SKILL.md +0 -54
  69. package/resources/skills/harness-submit/SKILL.md +0 -47
  70. package/resources/skills/harness-sync/SKILL.md +0 -48
  71. package/resources/skills/harness-test/SKILL.md +0 -50
@@ -1,275 +1,275 @@
1
- #!/usr/bin/env python3
2
- """Harness deploy: build core + overlay into self-contained skills tree (D9/D12).
3
-
4
- Python 3.10+ stdlib only. UTF-8 without BOM.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import argparse
10
- import datetime as dt
11
- import filecmp
12
- import hashlib
13
- import json
14
- import os
15
- import re
16
- import shutil
17
- import sys
18
- import uuid
19
- from pathlib import Path
20
- from typing import Any, Iterable
21
-
22
- if hasattr(sys.stdout, "reconfigure"):
23
- sys.stdout.reconfigure(encoding="utf-8")
24
- if hasattr(sys.stderr, "reconfigure"):
25
- sys.stderr.reconfigure(encoding="utf-8")
26
-
27
- INCLUDE_RE = re.compile(r"<!--\s*@include\s+shared/([^\s]+)\s*-->")
28
- # §7.5: overlay anchors support both new section-id and legacy section:"title".
29
- OVERRIDE_RE = re.compile(r'<!--\s*@override\s+(?:section-id:"([^"]+)"|section:"([^"]+)")\s*-->')
30
- APPEND_RE = re.compile(r'<!--\s*@append-after\s+(?:section-id:"([^"]+)"|section:"([^"]+)")\s*-->')
31
- SECTION_ID_RE = re.compile(r"<!--\s*@section-id\s+([\w.-]+)\s*-->")
32
- HEADING_RE = re.compile(r"^(#{2,6})\s+(.+?)\s*$")
33
- FRAGMENT_HINT_RE = re.compile(r"^>\s*片段:\[\[shared/[^\]]+\]\]\s*.*$")
34
-
35
- BUILD_MARKER = ".harness-build.json"
36
-
37
- SKIP_DIR_NAMES = {
38
- "__pycache__",
39
- ".pytest_cache",
40
- "redesign",
41
- "shared",
42
- "overlays",
43
- ".git",
44
- }
45
- SKIP_TOP_NAMES = {"harness-merge", "harness-report"}
46
-
47
-
48
- def now_iso() -> str:
49
- return dt.datetime.now().astimezone().isoformat(timespec="seconds")
50
-
51
-
52
- def emit_json(payload: dict[str, Any], *, ok: bool = True) -> int:
53
- print(json.dumps(payload, ensure_ascii=False, indent=2))
54
- return 0 if ok else 1
55
-
56
-
57
- def normalize_section_title(title: str) -> str:
58
- t = title.strip()
59
- t = re.sub(r"\s*⚠️.*$", "", t)
60
- t = re.sub(r"\s*([^)]*)\s*$", "", t)
61
- return t.strip()
62
-
63
-
64
- def sha256_file(path: Path) -> str:
65
- h = hashlib.sha256()
66
- with path.open("rb") as fh:
67
- for chunk in iter(lambda: fh.read(65536), b""):
68
- h.update(chunk)
69
- return h.hexdigest()
70
-
71
-
72
- def iter_copy_entries(skills_root: Path) -> Iterable[Path]:
73
- for entry in sorted(skills_root.iterdir()):
74
- if entry.name in SKIP_TOP_NAMES:
75
- continue
76
- if entry.is_dir() and entry.name in SKIP_DIR_NAMES:
77
- continue
78
- if entry.is_file() and entry.name.startswith("."):
79
- continue
80
- yield entry
81
-
82
-
83
- RUNTIME_IGNORE = shutil.ignore_patterns(
84
- "__pycache__", "*.pyc", ".pytest_cache", "_last_run.txt", "_same_proc_*"
85
- )
86
- RUNTIME_SKIP_FILES = {"_last_run.txt", "_same_proc_pid.txt", "_same_proc_tmp.txt"}
87
-
88
-
89
- def copy_tree(skills_root: Path, out_dir: Path) -> list[str]:
90
- copied: list[str] = []
91
- out_dir.mkdir(parents=True, exist_ok=True)
92
- for entry in iter_copy_entries(skills_root):
93
- dest = out_dir / entry.name
94
- if entry.is_dir():
95
- if entry.name == "scripts":
96
- # §7.4: runtime keeps only scripts/*.py; scripts/tests/ excluded.
97
- # harness_acceptance.py is an acceptance meta-tool (not a runtime
98
- # skill script) and is excluded from the deployed tree.
99
- dest.mkdir(parents=True, exist_ok=True)
100
- for py in sorted(entry.glob("*.py")):
101
- if py.name == "harness_acceptance.py":
102
- continue
103
- shutil.copy2(py, dest / py.name)
104
- copied.append(f"scripts/{py.name}")
105
- else:
106
- shutil.copytree(entry, dest, ignore=RUNTIME_IGNORE)
107
- copied.append(entry.name + "/")
108
- else:
109
- if entry.name in RUNTIME_SKIP_FILES or entry.name.endswith(".pyc"):
110
- continue
111
- shutil.copy2(entry, dest)
112
- copied.append(entry.name)
113
- return copied
114
-
115
-
116
- def expand_includes(text: str, shared_dir: Path) -> str:
117
- def repl(match: re.Match[str]) -> str:
118
- rel = match.group(1)
119
- inc_path = shared_dir / rel
120
- if not inc_path.is_file():
121
- raise FileNotFoundError(f"shared include not found: {rel}")
122
- body = inc_path.read_text(encoding="utf-8").rstrip()
123
- return body
124
-
125
- expanded = INCLUDE_RE.sub(repl, text)
126
- lines = [ln for ln in expanded.splitlines() if not FRAGMENT_HINT_RE.match(ln)]
127
- return "\n".join(lines) + ("\n" if text.endswith("\n") else "")
128
-
129
-
130
- def find_section_span(lines: list[str], section: str) -> tuple[int, int, int]:
131
- target = normalize_section_title(section)
132
- start = end = level = -1
133
- for i, line in enumerate(lines):
134
- m = HEADING_RE.match(line)
135
- if not m:
136
- continue
137
- title = normalize_section_title(m.group(2))
138
- if title == target or title.startswith(target):
139
- start = i
140
- level = len(m.group(1))
141
- break
142
- if start < 0:
143
- raise KeyError(section)
144
- end = len(lines)
145
- for j in range(start + 1, len(lines)):
146
- m = HEADING_RE.match(lines[j])
147
- if m and len(m.group(1)) <= level:
148
- end = j
149
- break
150
- return start, end, level
151
-
152
-
153
- def parse_section_ids(text: str) -> dict[str, tuple[int, int, int]]:
154
- """Parse ``<!-- @section-id name -->`` markers; map id -> (start, end, level)
155
- of the heading that follows. Raise ValueError on duplicate ids (§7.5)."""
156
- lines = text.splitlines()
157
- ids: dict[str, tuple[int, int, int]] = {}
158
- pending: str | None = None
159
- for i, line in enumerate(lines):
160
- m = SECTION_ID_RE.search(line)
161
- if m:
162
- sid = m.group(1)
163
- if sid in ids:
164
- raise ValueError(f"duplicate section-id: {sid}")
165
- pending = sid
166
- continue
167
- hm = HEADING_RE.match(line)
168
- if hm and pending is not None:
169
- level = len(hm.group(1))
170
- start = i
171
- end = len(lines)
172
- for j in range(i + 1, len(lines)):
173
- m2 = HEADING_RE.match(lines[j])
174
- if m2 and len(m2.group(1)) <= level:
175
- end = j
176
- break
177
- ids[pending] = (start, end, level)
178
- pending = None
179
- return ids
180
-
181
-
182
- def apply_overlay_blocks(
183
- core_text: str, overlay_text: str, section_ids: dict[str, tuple[int, int, int]] | None = None
184
- ) -> str:
185
- lines = core_text.splitlines()
186
- pos = 0
187
- while pos < len(overlay_text):
188
- override = OVERRIDE_RE.search(overlay_text, pos)
189
- append = APPEND_RE.search(overlay_text, pos)
190
- candidates = [(m.start(), "override", m) for m in [override] if m]
191
- candidates += [(m.start(), "append", m) for m in [append] if m]
192
- if not candidates:
193
- break
194
- candidates.sort(key=lambda x: x[0])
195
- _, kind, match = candidates[0]
196
- section_id = match.group(1) # new style, may be None
197
- section_title = match.group(2) # legacy style, may be None
198
- content_start = match.end()
199
- next_marker = min(
200
- (
201
- m.start()
202
- for m in (
203
- OVERRIDE_RE.search(overlay_text, content_start),
204
- APPEND_RE.search(overlay_text, content_start),
205
- )
206
- if m
207
- ),
208
- default=len(overlay_text),
209
- )
210
- block = overlay_text[content_start:next_marker].strip("\n")
211
- if section_id:
212
- if not section_ids or section_id not in section_ids:
213
- raise KeyError(f"section-id:{section_id}")
214
- start, end, _ = section_ids[section_id]
215
- else:
216
- start, end, _ = find_section_span(lines, section_title)
217
- if kind == "override":
218
- replacement = block.splitlines()
219
- lines = lines[:start] + replacement + lines[end:]
220
- else:
221
- insertion = block.splitlines()
222
- lines = lines[:end] + insertion + lines[end:]
223
- pos = next_marker
224
- return "\n".join(lines) + ("\n" if core_text.endswith("\n") else "")
225
-
226
-
227
- def _is_relative_to(child: Path, parent: Path) -> bool:
228
- try:
229
- child.relative_to(parent)
230
- return True
231
- except ValueError:
232
- return False
233
-
234
-
235
- def validate_build_paths(skills_root: Path, out_dir: Path) -> tuple[Path, Path]:
236
- """Return resolved safe paths; raise ValueError for every forbidden relation.
237
-
238
- §7.1: refuse out==source, out as ancestor of source, out inside source,
239
- an existing non-empty out without the build marker, and obvious danger
240
- dirs (user home).
241
- """
242
- skills_root = skills_root.resolve()
243
- out_dir = out_dir.resolve()
244
-
245
- if out_dir == skills_root:
246
- raise ValueError(f"out_dir must not equal skills_root: {out_dir}")
247
- if _is_relative_to(skills_root, out_dir):
248
- raise ValueError(f"out_dir must not be an ancestor of skills_root: {out_dir}")
249
- if _is_relative_to(out_dir, skills_root):
250
- raise ValueError(f"out_dir must not be inside skills_root: {out_dir}")
251
- try:
252
- home = Path.home().resolve()
253
- except OSError:
254
- home = None
255
- if home is not None and out_dir == home:
256
- raise ValueError(f"out_dir must not be the user home: {out_dir}")
257
-
258
- if out_dir.exists() and out_dir.is_dir():
259
- has_marker = (out_dir / BUILD_MARKER).is_file()
260
- has_user_files = any(p.name != BUILD_MARKER for p in out_dir.iterdir())
261
- if has_user_files and not has_marker:
262
- raise ValueError(
263
- f"out_dir exists with user files and no {BUILD_MARKER} marker: {out_dir}"
264
- )
265
- return skills_root, out_dir
266
-
267
-
1
+ #!/usr/bin/env python3
2
+ """Harness deploy: build core + overlay into self-contained skills tree (D9/D12).
3
+
4
+ Python 3.10+ stdlib only. UTF-8 without BOM.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import datetime as dt
11
+ import filecmp
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import shutil
17
+ import sys
18
+ import uuid
19
+ from pathlib import Path
20
+ from typing import Any, Iterable
21
+
22
+ if hasattr(sys.stdout, "reconfigure"):
23
+ sys.stdout.reconfigure(encoding="utf-8")
24
+ if hasattr(sys.stderr, "reconfigure"):
25
+ sys.stderr.reconfigure(encoding="utf-8")
26
+
27
+ INCLUDE_RE = re.compile(r"<!--\s*@include\s+shared/([^\s]+)\s*-->")
28
+ # §7.5: overlay anchors support both new section-id and legacy section:"title".
29
+ OVERRIDE_RE = re.compile(r'<!--\s*@override\s+(?:section-id:"([^"]+)"|section:"([^"]+)")\s*-->')
30
+ APPEND_RE = re.compile(r'<!--\s*@append-after\s+(?:section-id:"([^"]+)"|section:"([^"]+)")\s*-->')
31
+ SECTION_ID_RE = re.compile(r"<!--\s*@section-id\s+([\w.-]+)\s*-->")
32
+ HEADING_RE = re.compile(r"^(#{2,6})\s+(.+?)\s*$")
33
+ FRAGMENT_HINT_RE = re.compile(r"^>\s*片段:\[\[shared/[^\]]+\]\]\s*.*$")
34
+
35
+ BUILD_MARKER = ".harness-build.json"
36
+
37
+ SKIP_DIR_NAMES = {
38
+ "__pycache__",
39
+ ".pytest_cache",
40
+ "redesign",
41
+ "shared",
42
+ "overlays",
43
+ ".git",
44
+ }
45
+ SKIP_TOP_NAMES = {"harness-merge", "harness-report"}
46
+
47
+
48
+ def now_iso() -> str:
49
+ return dt.datetime.now().astimezone().isoformat(timespec="seconds")
50
+
51
+
52
+ def emit_json(payload: dict[str, Any], *, ok: bool = True) -> int:
53
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
54
+ return 0 if ok else 1
55
+
56
+
57
+ def normalize_section_title(title: str) -> str:
58
+ t = title.strip()
59
+ t = re.sub(r"\s*⚠️.*$", "", t)
60
+ t = re.sub(r"\s*([^)]*)\s*$", "", t)
61
+ return t.strip()
62
+
63
+
64
+ def sha256_file(path: Path) -> str:
65
+ h = hashlib.sha256()
66
+ with path.open("rb") as fh:
67
+ for chunk in iter(lambda: fh.read(65536), b""):
68
+ h.update(chunk)
69
+ return h.hexdigest()
70
+
71
+
72
+ def iter_copy_entries(skills_root: Path) -> Iterable[Path]:
73
+ for entry in sorted(skills_root.iterdir()):
74
+ if entry.name in SKIP_TOP_NAMES:
75
+ continue
76
+ if entry.is_dir() and entry.name in SKIP_DIR_NAMES:
77
+ continue
78
+ if entry.is_file() and entry.name.startswith("."):
79
+ continue
80
+ yield entry
81
+
82
+
83
+ RUNTIME_IGNORE = shutil.ignore_patterns(
84
+ "tests", "__pycache__", "*.pyc", ".pytest_cache", "_last_run.txt", "_same_proc_*"
85
+ )
86
+ RUNTIME_SKIP_FILES = {"_last_run.txt", "_same_proc_pid.txt", "_same_proc_tmp.txt"}
87
+
88
+
89
+ def copy_tree(skills_root: Path, out_dir: Path) -> list[str]:
90
+ copied: list[str] = []
91
+ out_dir.mkdir(parents=True, exist_ok=True)
92
+ for entry in iter_copy_entries(skills_root):
93
+ dest = out_dir / entry.name
94
+ if entry.is_dir():
95
+ if entry.name == "scripts":
96
+ # §7.4: runtime keeps only scripts/*.py; scripts/tests/ excluded.
97
+ # harness_acceptance.py is an acceptance meta-tool (not a runtime
98
+ # skill script) and is excluded from the deployed tree.
99
+ dest.mkdir(parents=True, exist_ok=True)
100
+ for py in sorted(entry.glob("*.py")):
101
+ if py.name == "harness_acceptance.py":
102
+ continue
103
+ shutil.copy2(py, dest / py.name)
104
+ copied.append(f"scripts/{py.name}")
105
+ else:
106
+ shutil.copytree(entry, dest, ignore=RUNTIME_IGNORE)
107
+ copied.append(entry.name + "/")
108
+ else:
109
+ if entry.name in RUNTIME_SKIP_FILES or entry.name.endswith(".pyc"):
110
+ continue
111
+ shutil.copy2(entry, dest)
112
+ copied.append(entry.name)
113
+ return copied
114
+
115
+
116
+ def expand_includes(text: str, shared_dir: Path) -> str:
117
+ def repl(match: re.Match[str]) -> str:
118
+ rel = match.group(1)
119
+ inc_path = shared_dir / rel
120
+ if not inc_path.is_file():
121
+ raise FileNotFoundError(f"shared include not found: {rel}")
122
+ body = inc_path.read_text(encoding="utf-8").rstrip()
123
+ return body
124
+
125
+ expanded = INCLUDE_RE.sub(repl, text)
126
+ lines = [ln for ln in expanded.splitlines() if not FRAGMENT_HINT_RE.match(ln)]
127
+ return "\n".join(lines) + ("\n" if text.endswith("\n") else "")
128
+
129
+
130
+ def find_section_span(lines: list[str], section: str) -> tuple[int, int, int]:
131
+ target = normalize_section_title(section)
132
+ start = end = level = -1
133
+ for i, line in enumerate(lines):
134
+ m = HEADING_RE.match(line)
135
+ if not m:
136
+ continue
137
+ title = normalize_section_title(m.group(2))
138
+ if title == target or title.startswith(target):
139
+ start = i
140
+ level = len(m.group(1))
141
+ break
142
+ if start < 0:
143
+ raise KeyError(section)
144
+ end = len(lines)
145
+ for j in range(start + 1, len(lines)):
146
+ m = HEADING_RE.match(lines[j])
147
+ if m and len(m.group(1)) <= level:
148
+ end = j
149
+ break
150
+ return start, end, level
151
+
152
+
153
+ def parse_section_ids(text: str) -> dict[str, tuple[int, int, int]]:
154
+ """Parse ``<!-- @section-id name -->`` markers; map id -> (start, end, level)
155
+ of the heading that follows. Raise ValueError on duplicate ids (§7.5)."""
156
+ lines = text.splitlines()
157
+ ids: dict[str, tuple[int, int, int]] = {}
158
+ pending: str | None = None
159
+ for i, line in enumerate(lines):
160
+ m = SECTION_ID_RE.search(line)
161
+ if m:
162
+ sid = m.group(1)
163
+ if sid in ids:
164
+ raise ValueError(f"duplicate section-id: {sid}")
165
+ pending = sid
166
+ continue
167
+ hm = HEADING_RE.match(line)
168
+ if hm and pending is not None:
169
+ level = len(hm.group(1))
170
+ start = i
171
+ end = len(lines)
172
+ for j in range(i + 1, len(lines)):
173
+ m2 = HEADING_RE.match(lines[j])
174
+ if m2 and len(m2.group(1)) <= level:
175
+ end = j
176
+ break
177
+ ids[pending] = (start, end, level)
178
+ pending = None
179
+ return ids
180
+
181
+
182
+ def apply_overlay_blocks(
183
+ core_text: str, overlay_text: str, section_ids: dict[str, tuple[int, int, int]] | None = None
184
+ ) -> str:
185
+ lines = core_text.splitlines()
186
+ pos = 0
187
+ while pos < len(overlay_text):
188
+ override = OVERRIDE_RE.search(overlay_text, pos)
189
+ append = APPEND_RE.search(overlay_text, pos)
190
+ candidates = [(m.start(), "override", m) for m in [override] if m]
191
+ candidates += [(m.start(), "append", m) for m in [append] if m]
192
+ if not candidates:
193
+ break
194
+ candidates.sort(key=lambda x: x[0])
195
+ _, kind, match = candidates[0]
196
+ section_id = match.group(1) # new style, may be None
197
+ section_title = match.group(2) # legacy style, may be None
198
+ content_start = match.end()
199
+ next_marker = min(
200
+ (
201
+ m.start()
202
+ for m in (
203
+ OVERRIDE_RE.search(overlay_text, content_start),
204
+ APPEND_RE.search(overlay_text, content_start),
205
+ )
206
+ if m
207
+ ),
208
+ default=len(overlay_text),
209
+ )
210
+ block = overlay_text[content_start:next_marker].strip("\n")
211
+ if section_id:
212
+ if not section_ids or section_id not in section_ids:
213
+ raise KeyError(f"section-id:{section_id}")
214
+ start, end, _ = section_ids[section_id]
215
+ else:
216
+ start, end, _ = find_section_span(lines, section_title)
217
+ if kind == "override":
218
+ replacement = block.splitlines()
219
+ lines = lines[:start] + replacement + lines[end:]
220
+ else:
221
+ insertion = block.splitlines()
222
+ lines = lines[:end] + insertion + lines[end:]
223
+ pos = next_marker
224
+ return "\n".join(lines) + ("\n" if core_text.endswith("\n") else "")
225
+
226
+
227
+ def _is_relative_to(child: Path, parent: Path) -> bool:
228
+ try:
229
+ child.relative_to(parent)
230
+ return True
231
+ except ValueError:
232
+ return False
233
+
234
+
235
+ def validate_build_paths(skills_root: Path, out_dir: Path) -> tuple[Path, Path]:
236
+ """Return resolved safe paths; raise ValueError for every forbidden relation.
237
+
238
+ §7.1: refuse out==source, out as ancestor of source, out inside source,
239
+ an existing non-empty out without the build marker, and obvious danger
240
+ dirs (user home).
241
+ """
242
+ skills_root = skills_root.resolve()
243
+ out_dir = out_dir.resolve()
244
+
245
+ if out_dir == skills_root:
246
+ raise ValueError(f"out_dir must not equal skills_root: {out_dir}")
247
+ if _is_relative_to(skills_root, out_dir):
248
+ raise ValueError(f"out_dir must not be an ancestor of skills_root: {out_dir}")
249
+ if _is_relative_to(out_dir, skills_root):
250
+ raise ValueError(f"out_dir must not be inside skills_root: {out_dir}")
251
+ try:
252
+ home = Path.home().resolve()
253
+ except OSError:
254
+ home = None
255
+ if home is not None and out_dir == home:
256
+ raise ValueError(f"out_dir must not be the user home: {out_dir}")
257
+
258
+ if out_dir.exists() and out_dir.is_dir():
259
+ has_marker = (out_dir / BUILD_MARKER).is_file()
260
+ has_user_files = any(p.name != BUILD_MARKER for p in out_dir.iterdir())
261
+ if has_user_files and not has_marker:
262
+ raise ValueError(
263
+ f"out_dir exists with user files and no {BUILD_MARKER} marker: {out_dir}"
264
+ )
265
+ return skills_root, out_dir
266
+
267
+
268
268
  def core_content_hash(skills_root: Path, overlay_dir: Path | None) -> str:
269
- """SHA-256 over the actual core/shared/protocol/script/overlay files that
270
- participate in the build (path-relative + content). Deterministic across
271
- time and machine paths (§7.3)."""
272
- h = hashlib.sha256()
269
+ """SHA-256 over the actual core/shared/protocol/script/overlay files that
270
+ participate in the build (path-relative + content). Deterministic across
271
+ time and machine paths (§7.3)."""
272
+ h = hashlib.sha256()
273
273
  files: list[Path] = []
274
274
  # Hash exactly the runtime source universe copied by copy_tree, including
275
275
  # references/templates/checklists rather than only SKILL.md files.
@@ -282,222 +282,222 @@ def core_content_hash(skills_root: Path, overlay_dir: Path | None) -> str:
282
282
  else:
283
283
  files.extend(
284
284
  p for p in entry.rglob("*")
285
- if p.is_file() and "__pycache__" not in p.parts
285
+ if p.is_file() and "__pycache__" not in p.parts and "tests" not in p.parts
286
286
  and not p.name.endswith(".pyc") and p.name not in RUNTIME_SKIP_FILES
287
287
  )
288
- if overlay_dir and overlay_dir.is_dir():
289
- files.extend(p for p in overlay_dir.rglob("*") if p.is_file())
290
- for f in sorted(set(files)):
291
- rel = f.resolve().relative_to(skills_root.resolve()).as_posix()
292
- h.update(rel.encode("utf-8"))
293
- h.update(b"\0")
294
- h.update(sha256_file(f).encode("ascii"))
295
- h.update(b"\0")
296
- return h.hexdigest()[:16]
297
-
298
-
299
- def synthesis_header(skills_root: Path, overlay_dir: Path | None) -> str:
300
- """Deterministic synth header: no absolute path, no timestamp (§7.3)."""
301
- core_hash = core_content_hash(skills_root, overlay_dir)
302
- overlay_name = overlay_dir.name if overlay_dir else "none"
303
- return (
304
- f"<!-- generated by harness_deploy.py; core={core_hash}; "
305
- f"overlay={overlay_name}; do not edit -->\n"
306
- )
307
-
308
-
309
- def inject_header(text: str, header: str) -> str:
310
- if text.startswith("---"):
311
- parts = text.split("---", 2)
312
- if len(parts) >= 3:
313
- body = parts[2].lstrip("\n")
314
- return f"---{parts[1]}---\n{header}{body}"
315
- return header + text
316
-
317
-
318
- def process_skill_md(
319
- path: Path,
320
- shared_dir: Path,
321
- overlay_path: Path | None,
322
- header: str,
323
- ) -> None:
324
- text = path.read_text(encoding="utf-8")
325
- text = expand_includes(text, shared_dir)
326
- section_ids = parse_section_ids(text) # §7.5: raises on duplicate section-id
327
- if overlay_path and overlay_path.is_file():
328
- overlay = overlay_path.read_text(encoding="utf-8")
329
- text = apply_overlay_blocks(text, overlay, section_ids)
330
- text = inject_header(text, header)
331
- if INCLUDE_RE.search(text):
332
- raise RuntimeError(f"unexpanded include placeholder remains in {path}")
333
- path.write_text(text, encoding="utf-8")
334
-
335
-
336
- def overlay_for_skill(overlay_dir: Path, skill_name: str) -> Path | None:
337
- candidate = overlay_dir / f"{skill_name}.overlay.md"
338
- return candidate if candidate.is_file() else None
339
-
340
-
341
- def copy_overlay_unique_skills(overlay_dir: Path, out_dir: Path) -> list[str]:
342
- added: list[str] = []
343
- for entry in sorted(overlay_dir.iterdir()):
344
- if not entry.is_dir():
345
- continue
346
- if not entry.name.startswith("harness-"):
347
- continue
348
- if not (entry / "SKILL.md").is_file():
349
- continue
350
- if (out_dir / entry.name).exists():
351
- continue
352
- shutil.copytree(
353
- entry,
354
- out_dir / entry.name,
355
- ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
356
- )
357
- added.append(entry.name + "/")
358
- return added
359
-
360
-
361
- def merge_overlay_skill_extras(overlay_dir: Path, out_dir: Path) -> list[str]:
362
- """Copy overlays/java/harness-<name>/* into existing out/harness-<name>/ (reference, etc.)."""
363
- merged: list[str] = []
364
- unique = {
365
- p.name
366
- for p in overlay_dir.iterdir()
367
- if p.is_dir() and (p / "SKILL.md").is_file()
368
- }
369
- for entry in sorted(overlay_dir.iterdir()):
370
- if not entry.is_dir() or not entry.name.startswith("harness-"):
371
- continue
372
- if entry.name in unique:
373
- continue
374
- dest_skill = out_dir / entry.name
375
- if not dest_skill.is_dir():
376
- continue
377
- for src in entry.rglob("*"):
378
- if src.is_dir() or "__pycache__" in src.parts:
379
- continue
380
- rel = src.relative_to(entry)
381
- dest = dest_skill / rel
382
- dest.parent.mkdir(parents=True, exist_ok=True)
383
- shutil.copy2(src, dest)
384
- merged.append(f"{entry.name}/{rel.as_posix()}")
385
- return merged
386
-
387
-
388
- def copy_overlay_extras(overlay_dir: Path, out_dir: Path) -> list[str]:
389
- extras: list[str] = []
390
- # §7.4: PROJECT-PROFILE-EXAMPLE.md stays a Vault doc (UDP real values);
391
- # it must NOT be installed to runtime. Only pitfalls-java.md is runtime.
392
- pitfalls = overlay_dir / "pitfalls-java.md"
393
- if pitfalls.is_file():
394
- test_pit = out_dir / "harness-test" / "pitfalls-java.md"
395
- test_pit.parent.mkdir(parents=True, exist_ok=True)
396
- shutil.copy2(pitfalls, test_pit)
397
- extras.append("harness-test/pitfalls-java.md")
398
- return extras
399
-
400
-
401
- def cmd_build(
402
- skills_root: Path,
403
- out_dir: Path,
404
- overlay: str | None,
405
- ) -> dict[str, Any]:
406
- skills_root, out_dir = validate_build_paths(skills_root, out_dir)
407
- shared_dir = skills_root / "shared"
408
- if not shared_dir.is_dir():
409
- raise FileNotFoundError(f"shared/ missing under {skills_root}")
410
- overlay_dir = skills_root / "overlays" / overlay if overlay else None
411
- if overlay and (not overlay_dir or not overlay_dir.is_dir()):
412
- raise FileNotFoundError(f"overlay not found: overlays/{overlay}")
413
-
414
- # §7.2: build entirely in a staging dir; out_dir is untouched until the
415
- # atomic swap. Never `shutil.rmtree(out_dir)` at build start.
416
- staging = out_dir.parent / f".{out_dir.name}.staging-{uuid.uuid4().hex[:8]}"
417
- if staging.exists():
418
- shutil.rmtree(staging)
419
- try:
420
- copied = copy_tree(skills_root, staging)
421
- if overlay:
422
- copied.extend(copy_overlay_unique_skills(overlay_dir, staging))
423
- copied.extend(copy_overlay_extras(overlay_dir, staging))
424
- copied.extend(merge_overlay_skill_extras(overlay_dir, staging))
425
-
426
- header = synthesis_header(skills_root, overlay_dir)
427
- processed: list[str] = []
428
- for skill_md in sorted(staging.glob("harness-*/SKILL.md")):
429
- ov = overlay_for_skill(overlay_dir, skill_md.parent.name) if overlay_dir else None
430
- try:
431
- process_skill_md(skill_md, shared_dir, ov, header)
432
- except KeyError as exc:
433
- raise KeyError(
434
- f"overlay anchor not found: {exc.args[0]} ({skill_md})"
435
- ) from exc
436
- processed.append(str(skill_md.relative_to(staging)))
437
-
438
- for skill_md in sorted(staging.glob("harness-*/SKILL.md")):
439
- if INCLUDE_RE.search(skill_md.read_text(encoding="utf-8")):
440
- raise RuntimeError(f"include placeholder remains: {skill_md}")
441
-
442
- # deterministic build marker (no timestamp -> byte-identical builds)
443
- core_hash = core_content_hash(skills_root, overlay_dir)
444
- (staging / BUILD_MARKER).write_text(
445
- json.dumps(
446
- {"schemaVersion": 1, "overlay": overlay or "none", "coreHash": core_hash},
447
- ensure_ascii=False,
448
- indent=2,
449
- )
450
- + "\n",
451
- encoding="utf-8",
452
- newline="\n",
453
- )
454
- except Exception:
455
- # build failed: clean staging, leave out_dir completely untouched
456
- shutil.rmtree(staging, ignore_errors=True)
457
- raise
458
-
459
- # atomic swap: move old out aside (if a valid build), rename staging -> out
460
- old_backup = None
461
- if out_dir.exists():
462
- if (out_dir / BUILD_MARKER).is_file():
463
- old_backup = out_dir.parent / f".{out_dir.name}.old-{uuid.uuid4().hex[:8]}"
464
- os.replace(out_dir, old_backup)
465
- else:
466
- shutil.rmtree(staging, ignore_errors=True)
467
- raise ValueError(f"refusing to overwrite unmarked out_dir: {out_dir}")
468
- try:
469
- os.replace(staging, out_dir)
470
- except OSError:
471
- if old_backup is not None and old_backup.exists():
472
- os.replace(old_backup, out_dir)
473
- raise
474
- if old_backup is not None:
475
- shutil.rmtree(old_backup, ignore_errors=True)
476
-
477
- return {
478
- "ok": True,
479
- "action": "build",
480
- "skillsRoot": str(skills_root),
481
- "outDir": str(out_dir),
482
- "overlay": overlay,
483
- "copied": copied,
484
- "processedSkills": processed,
485
- }
486
-
487
-
488
- def collect_files(root: Path) -> dict[str, str]:
489
- files: dict[str, str] = {}
490
- for path in sorted(root.rglob("*")):
491
- if path.is_file() and "__pycache__" not in path.parts:
492
- rel = path.relative_to(root).as_posix()
493
- files[rel] = sha256_file(path)
494
- return files
495
-
496
-
288
+ if overlay_dir and overlay_dir.is_dir():
289
+ files.extend(p for p in overlay_dir.rglob("*") if p.is_file())
290
+ for f in sorted(set(files)):
291
+ rel = f.resolve().relative_to(skills_root.resolve()).as_posix()
292
+ h.update(rel.encode("utf-8"))
293
+ h.update(b"\0")
294
+ h.update(sha256_file(f).encode("ascii"))
295
+ h.update(b"\0")
296
+ return h.hexdigest()[:16]
297
+
298
+
299
+ def synthesis_header(skills_root: Path, overlay_dir: Path | None) -> str:
300
+ """Deterministic synth header: no absolute path, no timestamp (§7.3)."""
301
+ core_hash = core_content_hash(skills_root, overlay_dir)
302
+ overlay_name = overlay_dir.name if overlay_dir else "none"
303
+ return (
304
+ f"<!-- generated by harness_deploy.py; core={core_hash}; "
305
+ f"overlay={overlay_name}; do not edit -->\n"
306
+ )
307
+
308
+
309
+ def inject_header(text: str, header: str) -> str:
310
+ if text.startswith("---"):
311
+ parts = text.split("---", 2)
312
+ if len(parts) >= 3:
313
+ body = parts[2].lstrip("\n")
314
+ return f"---{parts[1]}---\n{header}{body}"
315
+ return header + text
316
+
317
+
318
+ def process_skill_md(
319
+ path: Path,
320
+ shared_dir: Path,
321
+ overlay_path: Path | None,
322
+ header: str,
323
+ ) -> None:
324
+ text = path.read_text(encoding="utf-8")
325
+ text = expand_includes(text, shared_dir)
326
+ section_ids = parse_section_ids(text) # §7.5: raises on duplicate section-id
327
+ if overlay_path and overlay_path.is_file():
328
+ overlay = overlay_path.read_text(encoding="utf-8")
329
+ text = apply_overlay_blocks(text, overlay, section_ids)
330
+ text = inject_header(text, header)
331
+ if INCLUDE_RE.search(text):
332
+ raise RuntimeError(f"unexpanded include placeholder remains in {path}")
333
+ path.write_text(text, encoding="utf-8", newline="\n")
334
+
335
+
336
+ def overlay_for_skill(overlay_dir: Path, skill_name: str) -> Path | None:
337
+ candidate = overlay_dir / f"{skill_name}.overlay.md"
338
+ return candidate if candidate.is_file() else None
339
+
340
+
341
+ def copy_overlay_unique_skills(overlay_dir: Path, out_dir: Path) -> list[str]:
342
+ added: list[str] = []
343
+ for entry in sorted(overlay_dir.iterdir()):
344
+ if not entry.is_dir():
345
+ continue
346
+ if not entry.name.startswith("harness-"):
347
+ continue
348
+ if not (entry / "SKILL.md").is_file():
349
+ continue
350
+ if (out_dir / entry.name).exists():
351
+ continue
352
+ shutil.copytree(
353
+ entry,
354
+ out_dir / entry.name,
355
+ ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
356
+ )
357
+ added.append(entry.name + "/")
358
+ return added
359
+
360
+
361
+ def merge_overlay_skill_extras(overlay_dir: Path, out_dir: Path) -> list[str]:
362
+ """Copy overlays/java/harness-<name>/* into existing out/harness-<name>/ (reference, etc.)."""
363
+ merged: list[str] = []
364
+ unique = {
365
+ p.name
366
+ for p in overlay_dir.iterdir()
367
+ if p.is_dir() and (p / "SKILL.md").is_file()
368
+ }
369
+ for entry in sorted(overlay_dir.iterdir()):
370
+ if not entry.is_dir() or not entry.name.startswith("harness-"):
371
+ continue
372
+ if entry.name in unique:
373
+ continue
374
+ dest_skill = out_dir / entry.name
375
+ if not dest_skill.is_dir():
376
+ continue
377
+ for src in entry.rglob("*"):
378
+ if src.is_dir() or "__pycache__" in src.parts:
379
+ continue
380
+ rel = src.relative_to(entry)
381
+ dest = dest_skill / rel
382
+ dest.parent.mkdir(parents=True, exist_ok=True)
383
+ shutil.copy2(src, dest)
384
+ merged.append(f"{entry.name}/{rel.as_posix()}")
385
+ return merged
386
+
387
+
388
+ def copy_overlay_extras(overlay_dir: Path, out_dir: Path) -> list[str]:
389
+ extras: list[str] = []
390
+ # §7.4: PROJECT-PROFILE-EXAMPLE.md stays a Vault doc (UDP real values);
391
+ # it must NOT be installed to runtime. Only pitfalls-java.md is runtime.
392
+ pitfalls = overlay_dir / "pitfalls-java.md"
393
+ if pitfalls.is_file():
394
+ test_pit = out_dir / "harness-test" / "pitfalls-java.md"
395
+ test_pit.parent.mkdir(parents=True, exist_ok=True)
396
+ shutil.copy2(pitfalls, test_pit)
397
+ extras.append("harness-test/pitfalls-java.md")
398
+ return extras
399
+
400
+
401
+ def cmd_build(
402
+ skills_root: Path,
403
+ out_dir: Path,
404
+ overlay: str | None,
405
+ ) -> dict[str, Any]:
406
+ skills_root, out_dir = validate_build_paths(skills_root, out_dir)
407
+ shared_dir = skills_root / "shared"
408
+ if not shared_dir.is_dir():
409
+ raise FileNotFoundError(f"shared/ missing under {skills_root}")
410
+ overlay_dir = skills_root / "overlays" / overlay if overlay else None
411
+ if overlay and (not overlay_dir or not overlay_dir.is_dir()):
412
+ raise FileNotFoundError(f"overlay not found: overlays/{overlay}")
413
+
414
+ # §7.2: build entirely in a staging dir; out_dir is untouched until the
415
+ # atomic swap. Never `shutil.rmtree(out_dir)` at build start.
416
+ staging = out_dir.parent / f".{out_dir.name}.staging-{uuid.uuid4().hex[:8]}"
417
+ if staging.exists():
418
+ shutil.rmtree(staging)
419
+ try:
420
+ copied = copy_tree(skills_root, staging)
421
+ if overlay:
422
+ copied.extend(copy_overlay_unique_skills(overlay_dir, staging))
423
+ copied.extend(copy_overlay_extras(overlay_dir, staging))
424
+ copied.extend(merge_overlay_skill_extras(overlay_dir, staging))
425
+
426
+ header = synthesis_header(skills_root, overlay_dir)
427
+ processed: list[str] = []
428
+ for skill_md in sorted(staging.glob("harness-*/SKILL.md")):
429
+ ov = overlay_for_skill(overlay_dir, skill_md.parent.name) if overlay_dir else None
430
+ try:
431
+ process_skill_md(skill_md, shared_dir, ov, header)
432
+ except KeyError as exc:
433
+ raise KeyError(
434
+ f"overlay anchor not found: {exc.args[0]} ({skill_md})"
435
+ ) from exc
436
+ processed.append(str(skill_md.relative_to(staging)))
437
+
438
+ for skill_md in sorted(staging.glob("harness-*/SKILL.md")):
439
+ if INCLUDE_RE.search(skill_md.read_text(encoding="utf-8")):
440
+ raise RuntimeError(f"include placeholder remains: {skill_md}")
441
+
442
+ # deterministic build marker (no timestamp -> byte-identical builds)
443
+ core_hash = core_content_hash(skills_root, overlay_dir)
444
+ (staging / BUILD_MARKER).write_text(
445
+ json.dumps(
446
+ {"schemaVersion": 1, "overlay": overlay or "none", "coreHash": core_hash},
447
+ ensure_ascii=False,
448
+ indent=2,
449
+ )
450
+ + "\n",
451
+ encoding="utf-8",
452
+ newline="\n",
453
+ )
454
+ except Exception:
455
+ # build failed: clean staging, leave out_dir completely untouched
456
+ shutil.rmtree(staging, ignore_errors=True)
457
+ raise
458
+
459
+ # atomic swap: move old out aside (if a valid build), rename staging -> out
460
+ old_backup = None
461
+ if out_dir.exists():
462
+ if (out_dir / BUILD_MARKER).is_file():
463
+ old_backup = out_dir.parent / f".{out_dir.name}.old-{uuid.uuid4().hex[:8]}"
464
+ os.replace(out_dir, old_backup)
465
+ else:
466
+ shutil.rmtree(staging, ignore_errors=True)
467
+ raise ValueError(f"refusing to overwrite unmarked out_dir: {out_dir}")
468
+ try:
469
+ os.replace(staging, out_dir)
470
+ except OSError:
471
+ if old_backup is not None and old_backup.exists():
472
+ os.replace(old_backup, out_dir)
473
+ raise
474
+ if old_backup is not None:
475
+ shutil.rmtree(old_backup, ignore_errors=True)
476
+
477
+ return {
478
+ "ok": True,
479
+ "action": "build",
480
+ "skillsRoot": str(skills_root),
481
+ "outDir": str(out_dir),
482
+ "overlay": overlay,
483
+ "copied": copied,
484
+ "processedSkills": processed,
485
+ }
486
+
487
+
488
+ def collect_files(root: Path) -> dict[str, str]:
489
+ files: dict[str, str] = {}
490
+ for path in sorted(root.rglob("*")):
491
+ if path.is_file() and "__pycache__" not in path.parts:
492
+ rel = path.relative_to(root).as_posix()
493
+ files[rel] = sha256_file(path)
494
+ return files
495
+
496
+
497
497
  def cmd_install(build_out: Path, project: Path, target: Path | None) -> dict[str, Any]:
498
- build_out = build_out.resolve()
499
- project = project.resolve()
500
- dest = (target or project / ".claude" / "skills").resolve()
498
+ build_out = build_out.resolve()
499
+ project = project.resolve()
500
+ dest = (target or project / ".claude" / "skills").resolve()
501
501
  if not (build_out / BUILD_MARKER).is_file():
502
502
  raise ValueError(f"install source is not a marked harness build: {build_out}")
503
503
  backup: str | None = None
@@ -515,99 +515,99 @@ def cmd_install(build_out: Path, project: Path, target: Path | None) -> dict[str
515
515
  if backup:
516
516
  os.replace(Path(backup), dest)
517
517
  raise
518
-
519
- agents_src = build_out / "agents"
520
- agents_dest = project / ".claude" / "agents"
521
- if agents_src.is_dir():
522
- agents_dest.mkdir(parents=True, exist_ok=True)
523
- for agent in agents_src.glob("*.md"):
524
- shutil.copy2(agent, agents_dest / agent.name)
525
-
526
- return {
527
- "ok": True,
528
- "action": "install",
529
- "from": str(build_out),
530
- "project": str(project),
531
- "target": str(dest),
532
- "backup": backup,
533
- "installedAt": now_iso(),
534
- }
535
-
536
-
537
- def cmd_diff(build_out: Path, project: Path, target: Path | None) -> dict[str, Any]:
538
- build_out = build_out.resolve()
539
- project = project.resolve()
540
- installed = (target or project / ".claude" / "skills").resolve()
541
- if not installed.is_dir():
542
- return {
543
- "ok": True,
544
- "action": "diff",
545
- "stale": True,
546
- "missingInstall": True,
547
- "outdated": [],
548
- "missing": [],
549
- "extra": [],
550
- }
551
-
552
- built = collect_files(build_out)
553
- current = collect_files(installed)
554
- outdated = [p for p, h in built.items() if p in current and current[p] != h]
555
- missing = [p for p in built if p not in current]
556
- extra = [p for p in current if p not in built]
557
- return {
558
- "ok": True,
559
- "action": "diff",
560
- "stale": bool(outdated or missing),
561
- "outdated": sorted(outdated),
562
- "missing": sorted(missing),
563
- "extra": sorted(extra),
564
- "comparedAt": now_iso(),
565
- }
566
-
567
-
568
- def build_parser() -> argparse.ArgumentParser:
569
- p = argparse.ArgumentParser(description="Harness skills deploy (build/install/diff)")
570
- sub = p.add_subparsers(dest="command", required=True)
571
-
572
- b = sub.add_parser("build", help="Synthesize core + overlay into output dir")
573
- b.add_argument("--skills-root", type=Path, required=True)
574
- b.add_argument("--out", type=Path, required=True)
575
- b.add_argument("--overlay")
576
- b.add_argument("--json", action="store_true")
577
-
578
- i = sub.add_parser("install", help="Install build output to project .claude/skills")
579
- i.add_argument("--from", dest="from_dir", type=Path, required=True)
580
- i.add_argument("--project", type=Path, required=True)
581
- i.add_argument("--target", type=Path)
582
- i.add_argument("--json", action="store_true")
583
-
584
- d = sub.add_parser("diff", help="Compare installed skills vs build output")
585
- d.add_argument("--from", dest="from_dir", type=Path, required=True)
586
- d.add_argument("--project", type=Path, required=True)
587
- d.add_argument("--target", type=Path)
588
- d.add_argument("--json", action="store_true")
589
- return p
590
-
591
-
592
- def main(argv: list[str] | None = None) -> int:
593
- args = build_parser().parse_args(argv)
594
- try:
595
- if args.command == "build":
596
- result = cmd_build(args.skills_root, args.out, args.overlay)
597
- return emit_json(result) if args.json else 0
598
- if args.command == "install":
599
- result = cmd_install(args.from_dir, args.project, args.target)
600
- return emit_json(result) if args.json else 0
601
- if args.command == "diff":
602
- result = cmd_diff(args.from_dir, args.project, args.target)
603
- return emit_json(result) if args.json else 0
604
- except (FileNotFoundError, KeyError, RuntimeError, ValueError) as exc:
605
- if getattr(args, "json", False):
606
- return emit_json({"ok": False, "error": str(exc)}, ok=False)
607
- print(f"ERROR: {exc}", file=sys.stderr)
608
- return 1
609
- return 1
610
-
611
-
612
- if __name__ == "__main__":
613
- raise SystemExit(main())
518
+
519
+ agents_src = build_out / "agents"
520
+ agents_dest = project / ".claude" / "agents"
521
+ if agents_src.is_dir():
522
+ agents_dest.mkdir(parents=True, exist_ok=True)
523
+ for agent in agents_src.glob("*.md"):
524
+ shutil.copy2(agent, agents_dest / agent.name)
525
+
526
+ return {
527
+ "ok": True,
528
+ "action": "install",
529
+ "from": str(build_out),
530
+ "project": str(project),
531
+ "target": str(dest),
532
+ "backup": backup,
533
+ "installedAt": now_iso(),
534
+ }
535
+
536
+
537
+ def cmd_diff(build_out: Path, project: Path, target: Path | None) -> dict[str, Any]:
538
+ build_out = build_out.resolve()
539
+ project = project.resolve()
540
+ installed = (target or project / ".claude" / "skills").resolve()
541
+ if not installed.is_dir():
542
+ return {
543
+ "ok": True,
544
+ "action": "diff",
545
+ "stale": True,
546
+ "missingInstall": True,
547
+ "outdated": [],
548
+ "missing": [],
549
+ "extra": [],
550
+ }
551
+
552
+ built = collect_files(build_out)
553
+ current = collect_files(installed)
554
+ outdated = [p for p, h in built.items() if p in current and current[p] != h]
555
+ missing = [p for p in built if p not in current]
556
+ extra = [p for p in current if p not in built]
557
+ return {
558
+ "ok": True,
559
+ "action": "diff",
560
+ "stale": bool(outdated or missing),
561
+ "outdated": sorted(outdated),
562
+ "missing": sorted(missing),
563
+ "extra": sorted(extra),
564
+ "comparedAt": now_iso(),
565
+ }
566
+
567
+
568
+ def build_parser() -> argparse.ArgumentParser:
569
+ p = argparse.ArgumentParser(description="Harness skills deploy (build/install/diff)")
570
+ sub = p.add_subparsers(dest="command", required=True)
571
+
572
+ b = sub.add_parser("build", help="Synthesize core + overlay into output dir")
573
+ b.add_argument("--skills-root", type=Path, required=True)
574
+ b.add_argument("--out", type=Path, required=True)
575
+ b.add_argument("--overlay")
576
+ b.add_argument("--json", action="store_true")
577
+
578
+ i = sub.add_parser("install", help="Install build output to project .claude/skills")
579
+ i.add_argument("--from", dest="from_dir", type=Path, required=True)
580
+ i.add_argument("--project", type=Path, required=True)
581
+ i.add_argument("--target", type=Path)
582
+ i.add_argument("--json", action="store_true")
583
+
584
+ d = sub.add_parser("diff", help="Compare installed skills vs build output")
585
+ d.add_argument("--from", dest="from_dir", type=Path, required=True)
586
+ d.add_argument("--project", type=Path, required=True)
587
+ d.add_argument("--target", type=Path)
588
+ d.add_argument("--json", action="store_true")
589
+ return p
590
+
591
+
592
+ def main(argv: list[str] | None = None) -> int:
593
+ args = build_parser().parse_args(argv)
594
+ try:
595
+ if args.command == "build":
596
+ result = cmd_build(args.skills_root, args.out, args.overlay)
597
+ return emit_json(result) if args.json else 0
598
+ if args.command == "install":
599
+ result = cmd_install(args.from_dir, args.project, args.target)
600
+ return emit_json(result) if args.json else 0
601
+ if args.command == "diff":
602
+ result = cmd_diff(args.from_dir, args.project, args.target)
603
+ return emit_json(result) if args.json else 0
604
+ except (FileNotFoundError, KeyError, RuntimeError, ValueError) as exc:
605
+ if getattr(args, "json", False):
606
+ return emit_json({"ok": False, "error": str(exc)}, ok=False)
607
+ print(f"ERROR: {exc}", file=sys.stderr)
608
+ return 1
609
+ return 1
610
+
611
+
612
+ if __name__ == "__main__":
613
+ raise SystemExit(main())