hunter-harness 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +127 -106
- package/package.json +2 -2
- package/resources/harness/general/.harness-build.json +1 -1
- package/resources/harness/general/harness-archive/SKILL.md +215 -215
- package/resources/harness/general/harness-codebase-map/SKILL.md +112 -112
- package/resources/harness/general/harness-codebase-map/templates/map-summary.md +3 -3
- package/resources/harness/general/harness-knowledge-ingest/SKILL.md +246 -246
- package/resources/harness/general/harness-knowledge-query/SKILL.md +164 -164
- package/resources/harness/general/harness-plan/SKILL.md +127 -127
- package/resources/harness/general/harness-review/SKILL.md +156 -156
- package/resources/harness/general/harness-run/SKILL.md +132 -132
- package/resources/harness/general/harness-submit/SKILL.md +159 -159
- package/resources/harness/general/harness-sync/SKILL.md +82 -82
- package/resources/harness/general/harness-test/SKILL.md +180 -180
- package/resources/harness/general/scripts/harness_deploy.py +580 -580
- package/resources/harness/java/.harness-build.json +1 -1
- package/resources/harness/java/harness-apidoc/SKILL.md +86 -86
- package/resources/harness/java/harness-archive/SKILL.md +215 -215
- package/resources/harness/java/harness-codebase-map/SKILL.md +112 -112
- package/resources/harness/java/harness-codebase-map/templates/map-summary.md +3 -3
- package/resources/harness/java/harness-knowledge-ingest/SKILL.md +246 -246
- package/resources/harness/java/harness-knowledge-query/SKILL.md +164 -164
- package/resources/harness/java/harness-package/SKILL.md +87 -87
- package/resources/harness/java/harness-plan/SKILL.md +127 -127
- package/resources/harness/java/harness-review/SKILL.md +156 -156
- package/resources/harness/java/harness-run/SKILL.md +148 -148
- package/resources/harness/java/harness-submit/SKILL.md +166 -166
- package/resources/harness/java/harness-sync/SKILL.md +82 -82
- package/resources/harness/java/harness-test/SKILL.md +192 -192
- package/resources/harness/java/scripts/harness_deploy.py +580 -580
- package/resources/harness/manifests/general.json +13 -33
- package/resources/harness/manifests/java.json +15 -35
- package/resources/bootstrap-ir/manifest.json +0 -19
- package/resources/bootstrap-ir/skills/harness-apidoc.yaml +0 -24
- package/resources/bootstrap-ir/skills/harness-archive.yaml +0 -24
- package/resources/bootstrap-ir/skills/harness-codebase-map.yaml +0 -24
- package/resources/bootstrap-ir/skills/harness-knowledge-ingest.yaml +0 -24
- package/resources/bootstrap-ir/skills/harness-package.yaml +0 -24
- package/resources/bootstrap-ir/skills/harness-plan.yaml +0 -24
- package/resources/bootstrap-ir/skills/harness-review.yaml +0 -24
- package/resources/bootstrap-ir/skills/harness-run.yaml +0 -24
- package/resources/bootstrap-ir/skills/harness-skill-optimizer.yaml +0 -28
- package/resources/bootstrap-ir/skills/harness-submit.yaml +0 -24
- package/resources/bootstrap-ir/skills/harness-sync.yaml +0 -24
- package/resources/bootstrap-ir/skills/harness-test.yaml +0 -24
- package/resources/bootstrap-ir/templates/claude-code-skill.md +0 -12
- 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
- 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
- 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
- package/resources/harness/general/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/project.yaml +0 -1
- package/resources/harness/general/harness-knowledge-ingest/tests/test_harness_knowledge.py +0 -1792
- 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
- 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
- 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
- package/resources/harness/java/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/project.yaml +0 -1
- package/resources/harness/java/harness-knowledge-ingest/tests/test_harness_knowledge.py +0 -1792
- package/resources/manifest.json +0 -19
- package/resources/skills/harness-apidoc/SKILL.md +0 -50
- package/resources/skills/harness-archive/SKILL.md +0 -48
- package/resources/skills/harness-codebase-map/SKILL.md +0 -53
- package/resources/skills/harness-knowledge-ingest/SKILL.md +0 -48
- package/resources/skills/harness-package/SKILL.md +0 -48
- package/resources/skills/harness-plan/SKILL.md +0 -51
- package/resources/skills/harness-review/SKILL.md +0 -50
- package/resources/skills/harness-run/SKILL.md +0 -48
- package/resources/skills/harness-skill-optimizer/SKILL.md +0 -54
- package/resources/skills/harness-submit/SKILL.md +0 -47
- package/resources/skills/harness-sync/SKILL.md +0 -48
- 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())
|