hunter-harness 0.1.3 → 0.2.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 (149) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +11 -0
  3. package/dist/bin.js +1540 -491
  4. package/package.json +9 -7
  5. package/resources/harness/general/.harness-build.json +0 -5
  6. package/resources/harness/general/CONTEXT.md +0 -65
  7. package/resources/harness/general/README.md +0 -478
  8. package/resources/harness/general/agents/harness-evaluator.md +0 -96
  9. package/resources/harness/general/agents/harness-explorer.md +0 -72
  10. package/resources/harness/general/agents/harness-reviewer.md +0 -69
  11. package/resources/harness/general/harness-archive/SKILL.md +0 -215
  12. package/resources/harness/general/harness-archive/checklist.md +0 -52
  13. package/resources/harness/general/harness-archive/reference.md +0 -103
  14. package/resources/harness/general/harness-archive/scripts/gen-manifest.ps1 +0 -31
  15. package/resources/harness/general/harness-archive/templates/render-summary.mjs +0 -171
  16. package/resources/harness/general/harness-archive/templates/summary-data-template.json +0 -128
  17. package/resources/harness/general/harness-codebase-map/SKILL.md +0 -112
  18. package/resources/harness/general/harness-codebase-map/checklist.md +0 -126
  19. package/resources/harness/general/harness-codebase-map/reference.md +0 -364
  20. package/resources/harness/general/harness-codebase-map/templates/ARCHITECTURE.md +0 -57
  21. package/resources/harness/general/harness-codebase-map/templates/CONCERNS.md +0 -49
  22. package/resources/harness/general/harness-codebase-map/templates/CONVENTIONS.md +0 -57
  23. package/resources/harness/general/harness-codebase-map/templates/INTEGRATIONS.md +0 -52
  24. package/resources/harness/general/harness-codebase-map/templates/STACK.md +0 -55
  25. package/resources/harness/general/harness-codebase-map/templates/STRUCTURE.md +0 -69
  26. package/resources/harness/general/harness-codebase-map/templates/TESTING.md +0 -64
  27. package/resources/harness/general/harness-codebase-map/templates/map-manifest.schema.json +0 -73
  28. package/resources/harness/general/harness-codebase-map/templates/map-summary.md +0 -64
  29. package/resources/harness/general/harness-knowledge-ingest/SKILL.md +0 -246
  30. package/resources/harness/general/harness-knowledge-ingest/design.md +0 -842
  31. package/resources/harness/general/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
  32. package/resources/harness/general/harness-knowledge-ingest/mcp-config.example.json +0 -10
  33. package/resources/harness/general/harness-knowledge-ingest/reference.md +0 -309
  34. package/resources/harness/general/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -4082
  35. package/resources/harness/general/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -273
  36. package/resources/harness/general/harness-knowledge-query/SKILL.md +0 -164
  37. package/resources/harness/general/harness-plan/SKILL.md +0 -127
  38. package/resources/harness/general/harness-plan/checklist.md +0 -243
  39. package/resources/harness/general/harness-plan/protocols.md +0 -124
  40. package/resources/harness/general/harness-plan/reference.md +0 -376
  41. package/resources/harness/general/harness-review/SKILL.md +0 -156
  42. package/resources/harness/general/harness-review/checklist.md +0 -124
  43. package/resources/harness/general/harness-review/protocols.md +0 -68
  44. package/resources/harness/general/harness-review/reference.md +0 -86
  45. package/resources/harness/general/harness-run/SKILL.md +0 -132
  46. package/resources/harness/general/harness-run/checklist.md +0 -259
  47. package/resources/harness/general/harness-run/protocols.md +0 -78
  48. package/resources/harness/general/harness-run/reference.md +0 -834
  49. package/resources/harness/general/harness-submit/SKILL.md +0 -159
  50. package/resources/harness/general/harness-submit/checklist.md +0 -407
  51. package/resources/harness/general/harness-submit/reference.md +0 -152
  52. package/resources/harness/general/harness-sync/SKILL.md +0 -82
  53. package/resources/harness/general/harness-sync/reference.md +0 -153
  54. package/resources/harness/general/harness-test/SKILL.md +0 -180
  55. package/resources/harness/general/harness-test/checklist.md +0 -319
  56. package/resources/harness/general/harness-test/pitfalls.md +0 -260
  57. package/resources/harness/general/harness-test/reference.md +0 -791
  58. package/resources/harness/general/protocols/archive-report-protocol.md +0 -175
  59. package/resources/harness/general/protocols/evidence-based-reporting-protocol.md +0 -134
  60. package/resources/harness/general/protocols/ledger-protocol.md +0 -270
  61. package/resources/harness/general/protocols/powershell-protocol.md +0 -181
  62. package/resources/harness/general/protocols/report-pipeline-protocol.md +0 -150
  63. package/resources/harness/general/protocols/sensitive-info-protocol.md +0 -92
  64. package/resources/harness/general/protocols/state-layout-protocol.md +0 -131
  65. package/resources/harness/general/protocols/submit-protocol.md +0 -78
  66. package/resources/harness/general/scripts/harness_archive.py +0 -2319
  67. package/resources/harness/general/scripts/harness_deploy.py +0 -613
  68. package/resources/harness/general/scripts/harness_events.py +0 -680
  69. package/resources/harness/general/scripts/harness_ledger.py +0 -671
  70. package/resources/harness/general/scripts/harness_preflight.py +0 -780
  71. package/resources/harness/general/scripts/harness_service.py +0 -1331
  72. package/resources/harness/java/.harness-build.json +0 -5
  73. package/resources/harness/java/CONTEXT.md +0 -65
  74. package/resources/harness/java/README.md +0 -478
  75. package/resources/harness/java/agents/harness-evaluator.md +0 -96
  76. package/resources/harness/java/agents/harness-explorer.md +0 -72
  77. package/resources/harness/java/agents/harness-reviewer.md +0 -69
  78. package/resources/harness/java/harness-apidoc/SKILL.md +0 -86
  79. package/resources/harness/java/harness-apidoc/checklist.md +0 -142
  80. package/resources/harness/java/harness-apidoc/reference.md +0 -233
  81. package/resources/harness/java/harness-archive/SKILL.md +0 -215
  82. package/resources/harness/java/harness-archive/checklist.md +0 -52
  83. package/resources/harness/java/harness-archive/reference.md +0 -103
  84. package/resources/harness/java/harness-archive/scripts/gen-manifest.ps1 +0 -31
  85. package/resources/harness/java/harness-archive/templates/render-summary.mjs +0 -171
  86. package/resources/harness/java/harness-archive/templates/summary-data-template.json +0 -128
  87. package/resources/harness/java/harness-codebase-map/SKILL.md +0 -112
  88. package/resources/harness/java/harness-codebase-map/checklist.md +0 -126
  89. package/resources/harness/java/harness-codebase-map/reference.md +0 -364
  90. package/resources/harness/java/harness-codebase-map/templates/ARCHITECTURE.md +0 -57
  91. package/resources/harness/java/harness-codebase-map/templates/CONCERNS.md +0 -49
  92. package/resources/harness/java/harness-codebase-map/templates/CONVENTIONS.md +0 -57
  93. package/resources/harness/java/harness-codebase-map/templates/INTEGRATIONS.md +0 -52
  94. package/resources/harness/java/harness-codebase-map/templates/STACK.md +0 -55
  95. package/resources/harness/java/harness-codebase-map/templates/STRUCTURE.md +0 -69
  96. package/resources/harness/java/harness-codebase-map/templates/TESTING.md +0 -64
  97. package/resources/harness/java/harness-codebase-map/templates/map-manifest.schema.json +0 -73
  98. package/resources/harness/java/harness-codebase-map/templates/map-summary.md +0 -64
  99. package/resources/harness/java/harness-knowledge-ingest/SKILL.md +0 -246
  100. package/resources/harness/java/harness-knowledge-ingest/design.md +0 -842
  101. package/resources/harness/java/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
  102. package/resources/harness/java/harness-knowledge-ingest/mcp-config.example.json +0 -10
  103. package/resources/harness/java/harness-knowledge-ingest/reference.md +0 -309
  104. package/resources/harness/java/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -4082
  105. package/resources/harness/java/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -273
  106. package/resources/harness/java/harness-knowledge-query/SKILL.md +0 -164
  107. package/resources/harness/java/harness-package/SKILL.md +0 -87
  108. package/resources/harness/java/harness-package/checklist.md +0 -322
  109. package/resources/harness/java/harness-package/reference.md +0 -210
  110. package/resources/harness/java/harness-plan/SKILL.md +0 -127
  111. package/resources/harness/java/harness-plan/checklist.md +0 -243
  112. package/resources/harness/java/harness-plan/protocols.md +0 -124
  113. package/resources/harness/java/harness-plan/reference.md +0 -376
  114. package/resources/harness/java/harness-review/SKILL.md +0 -156
  115. package/resources/harness/java/harness-review/checklist.md +0 -124
  116. package/resources/harness/java/harness-review/protocols.md +0 -68
  117. package/resources/harness/java/harness-review/reference.md +0 -86
  118. package/resources/harness/java/harness-run/SKILL.md +0 -148
  119. package/resources/harness/java/harness-run/checklist.md +0 -250
  120. package/resources/harness/java/harness-run/protocols.md +0 -78
  121. package/resources/harness/java/harness-run/reference.md +0 -814
  122. package/resources/harness/java/harness-submit/SKILL.md +0 -166
  123. package/resources/harness/java/harness-submit/checklist.md +0 -407
  124. package/resources/harness/java/harness-submit/reference.md +0 -152
  125. package/resources/harness/java/harness-sync/SKILL.md +0 -82
  126. package/resources/harness/java/harness-sync/reference.md +0 -153
  127. package/resources/harness/java/harness-test/SKILL.md +0 -192
  128. package/resources/harness/java/harness-test/checklist.md +0 -317
  129. package/resources/harness/java/harness-test/pitfalls-java.md +0 -16
  130. package/resources/harness/java/harness-test/pitfalls.md +0 -260
  131. package/resources/harness/java/harness-test/reference.md +0 -788
  132. package/resources/harness/java/protocols/archive-report-protocol.md +0 -175
  133. package/resources/harness/java/protocols/evidence-based-reporting-protocol.md +0 -134
  134. package/resources/harness/java/protocols/ledger-protocol.md +0 -270
  135. package/resources/harness/java/protocols/powershell-protocol.md +0 -181
  136. package/resources/harness/java/protocols/report-pipeline-protocol.md +0 -150
  137. package/resources/harness/java/protocols/sensitive-info-protocol.md +0 -92
  138. package/resources/harness/java/protocols/state-layout-protocol.md +0 -131
  139. package/resources/harness/java/protocols/submit-protocol.md +0 -78
  140. package/resources/harness/java/scripts/harness_archive.py +0 -2319
  141. package/resources/harness/java/scripts/harness_deploy.py +0 -613
  142. package/resources/harness/java/scripts/harness_events.py +0 -680
  143. package/resources/harness/java/scripts/harness_ledger.py +0 -671
  144. package/resources/harness/java/scripts/harness_preflight.py +0 -780
  145. package/resources/harness/java/scripts/harness_service.py +0 -1331
  146. package/resources/harness/manifests/general.json +0 -276
  147. package/resources/harness/manifests/java.json +0 -304
  148. package/resources/harness/migrations/0.1.1/general.json +0 -358
  149. package/resources/harness/migrations/0.1.1/java.json +0 -398
@@ -1,613 +0,0 @@
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
- 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()
273
- files: list[Path] = []
274
- # Hash exactly the runtime source universe copied by copy_tree, including
275
- # references/templates/checklists rather than only SKILL.md files.
276
- for entry in iter_copy_entries(skills_root):
277
- if entry.is_file():
278
- if entry.name not in RUNTIME_SKIP_FILES and not entry.name.endswith(".pyc"):
279
- files.append(entry)
280
- elif entry.name == "scripts":
281
- files.extend(p for p in entry.glob("*.py") if p.name != "harness_acceptance.py")
282
- else:
283
- files.extend(
284
- p for p in entry.rglob("*")
285
- if p.is_file() and "__pycache__" not in p.parts and "tests" not in p.parts
286
- and not p.name.endswith(".pyc") and p.name not in RUNTIME_SKIP_FILES
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", 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
- 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()
501
- if not (build_out / BUILD_MARKER).is_file():
502
- raise ValueError(f"install source is not a marked harness build: {build_out}")
503
- backup: str | None = None
504
- staging = dest.parent / f".{dest.name}.staging-{uuid.uuid4().hex[:8]}"
505
- shutil.copytree(build_out, staging)
506
- if dest.exists() and any(dest.iterdir()):
507
- stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
508
- backup_path = dest.parent / f"skills-backup-{stamp}"
509
- os.replace(dest, backup_path)
510
- backup = str(backup_path)
511
- try:
512
- os.replace(staging, dest)
513
- except OSError:
514
- shutil.rmtree(staging, ignore_errors=True)
515
- if backup:
516
- os.replace(Path(backup), dest)
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())