hunter-harness 0.1.0

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 (184) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/dist/bin.js +3356 -0
  4. package/package.json +34 -0
  5. package/resources/bootstrap-ir/manifest.json +19 -0
  6. package/resources/bootstrap-ir/skills/harness-apidoc.yaml +24 -0
  7. package/resources/bootstrap-ir/skills/harness-archive.yaml +24 -0
  8. package/resources/bootstrap-ir/skills/harness-codebase-map.yaml +24 -0
  9. package/resources/bootstrap-ir/skills/harness-knowledge-ingest.yaml +24 -0
  10. package/resources/bootstrap-ir/skills/harness-package.yaml +24 -0
  11. package/resources/bootstrap-ir/skills/harness-plan.yaml +24 -0
  12. package/resources/bootstrap-ir/skills/harness-review.yaml +24 -0
  13. package/resources/bootstrap-ir/skills/harness-run.yaml +24 -0
  14. package/resources/bootstrap-ir/skills/harness-skill-optimizer.yaml +28 -0
  15. package/resources/bootstrap-ir/skills/harness-submit.yaml +24 -0
  16. package/resources/bootstrap-ir/skills/harness-sync.yaml +24 -0
  17. package/resources/bootstrap-ir/skills/harness-test.yaml +24 -0
  18. package/resources/bootstrap-ir/templates/claude-code-skill.md +12 -0
  19. package/resources/harness/general/.harness-build.json +5 -0
  20. package/resources/harness/general/CONTEXT.md +65 -0
  21. package/resources/harness/general/README.md +478 -0
  22. package/resources/harness/general/agents/harness-evaluator.md +96 -0
  23. package/resources/harness/general/agents/harness-explorer.md +72 -0
  24. package/resources/harness/general/agents/harness-reviewer.md +69 -0
  25. package/resources/harness/general/harness-archive/SKILL.md +215 -0
  26. package/resources/harness/general/harness-archive/checklist.md +52 -0
  27. package/resources/harness/general/harness-archive/reference.md +103 -0
  28. package/resources/harness/general/harness-archive/scripts/gen-manifest.ps1 +31 -0
  29. package/resources/harness/general/harness-archive/templates/render-summary.mjs +171 -0
  30. package/resources/harness/general/harness-archive/templates/summary-data-template.json +128 -0
  31. package/resources/harness/general/harness-codebase-map/SKILL.md +112 -0
  32. package/resources/harness/general/harness-codebase-map/checklist.md +126 -0
  33. package/resources/harness/general/harness-codebase-map/reference.md +364 -0
  34. package/resources/harness/general/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
  35. package/resources/harness/general/harness-codebase-map/templates/CONCERNS.md +49 -0
  36. package/resources/harness/general/harness-codebase-map/templates/CONVENTIONS.md +57 -0
  37. package/resources/harness/general/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
  38. package/resources/harness/general/harness-codebase-map/templates/STACK.md +55 -0
  39. package/resources/harness/general/harness-codebase-map/templates/STRUCTURE.md +69 -0
  40. package/resources/harness/general/harness-codebase-map/templates/TESTING.md +64 -0
  41. package/resources/harness/general/harness-codebase-map/templates/map-manifest.schema.json +73 -0
  42. package/resources/harness/general/harness-codebase-map/templates/map-summary.md +64 -0
  43. package/resources/harness/general/harness-knowledge-ingest/SKILL.md +246 -0
  44. package/resources/harness/general/harness-knowledge-ingest/design.md +842 -0
  45. package/resources/harness/general/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +42 -0
  46. package/resources/harness/general/harness-knowledge-ingest/mcp-config.example.json +10 -0
  47. package/resources/harness/general/harness-knowledge-ingest/reference.md +309 -0
  48. package/resources/harness/general/harness-knowledge-ingest/scripts/harness_knowledge.py +4082 -0
  49. package/resources/harness/general/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +273 -0
  50. package/resources/harness/general/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-01-10-ledger-reconciliation/reports/final/summary-data.json +43 -0
  51. 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 +38 -0
  52. package/resources/harness/general/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-03-05-webhook-contract/reports/final/summary-data.json +36 -0
  53. package/resources/harness/general/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/project.yaml +1 -0
  54. package/resources/harness/general/harness-knowledge-ingest/tests/test_harness_knowledge.py +1792 -0
  55. package/resources/harness/general/harness-knowledge-query/SKILL.md +164 -0
  56. package/resources/harness/general/harness-plan/SKILL.md +127 -0
  57. package/resources/harness/general/harness-plan/checklist.md +243 -0
  58. package/resources/harness/general/harness-plan/protocols.md +124 -0
  59. package/resources/harness/general/harness-plan/reference.md +376 -0
  60. package/resources/harness/general/harness-review/SKILL.md +156 -0
  61. package/resources/harness/general/harness-review/checklist.md +124 -0
  62. package/resources/harness/general/harness-review/protocols.md +68 -0
  63. package/resources/harness/general/harness-review/reference.md +86 -0
  64. package/resources/harness/general/harness-run/SKILL.md +132 -0
  65. package/resources/harness/general/harness-run/checklist.md +259 -0
  66. package/resources/harness/general/harness-run/protocols.md +78 -0
  67. package/resources/harness/general/harness-run/reference.md +834 -0
  68. package/resources/harness/general/harness-submit/SKILL.md +159 -0
  69. package/resources/harness/general/harness-submit/checklist.md +407 -0
  70. package/resources/harness/general/harness-submit/reference.md +152 -0
  71. package/resources/harness/general/harness-sync/SKILL.md +82 -0
  72. package/resources/harness/general/harness-sync/reference.md +153 -0
  73. package/resources/harness/general/harness-test/SKILL.md +180 -0
  74. package/resources/harness/general/harness-test/checklist.md +319 -0
  75. package/resources/harness/general/harness-test/pitfalls.md +260 -0
  76. package/resources/harness/general/harness-test/reference.md +791 -0
  77. package/resources/harness/general/protocols/archive-report-protocol.md +175 -0
  78. package/resources/harness/general/protocols/evidence-based-reporting-protocol.md +134 -0
  79. package/resources/harness/general/protocols/ledger-protocol.md +270 -0
  80. package/resources/harness/general/protocols/powershell-protocol.md +181 -0
  81. package/resources/harness/general/protocols/report-pipeline-protocol.md +150 -0
  82. package/resources/harness/general/protocols/sensitive-info-protocol.md +92 -0
  83. package/resources/harness/general/protocols/state-layout-protocol.md +131 -0
  84. package/resources/harness/general/protocols/submit-protocol.md +78 -0
  85. package/resources/harness/general/scripts/harness_archive.py +2319 -0
  86. package/resources/harness/general/scripts/harness_deploy.py +613 -0
  87. package/resources/harness/general/scripts/harness_events.py +680 -0
  88. package/resources/harness/general/scripts/harness_ledger.py +671 -0
  89. package/resources/harness/general/scripts/harness_preflight.py +780 -0
  90. package/resources/harness/general/scripts/harness_service.py +1331 -0
  91. package/resources/harness/java/.harness-build.json +5 -0
  92. package/resources/harness/java/CONTEXT.md +65 -0
  93. package/resources/harness/java/README.md +478 -0
  94. package/resources/harness/java/agents/harness-evaluator.md +96 -0
  95. package/resources/harness/java/agents/harness-explorer.md +72 -0
  96. package/resources/harness/java/agents/harness-reviewer.md +69 -0
  97. package/resources/harness/java/harness-apidoc/SKILL.md +86 -0
  98. package/resources/harness/java/harness-apidoc/checklist.md +142 -0
  99. package/resources/harness/java/harness-apidoc/reference.md +233 -0
  100. package/resources/harness/java/harness-archive/SKILL.md +215 -0
  101. package/resources/harness/java/harness-archive/checklist.md +52 -0
  102. package/resources/harness/java/harness-archive/reference.md +103 -0
  103. package/resources/harness/java/harness-archive/scripts/gen-manifest.ps1 +31 -0
  104. package/resources/harness/java/harness-archive/templates/render-summary.mjs +171 -0
  105. package/resources/harness/java/harness-archive/templates/summary-data-template.json +128 -0
  106. package/resources/harness/java/harness-codebase-map/SKILL.md +112 -0
  107. package/resources/harness/java/harness-codebase-map/checklist.md +126 -0
  108. package/resources/harness/java/harness-codebase-map/reference.md +364 -0
  109. package/resources/harness/java/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
  110. package/resources/harness/java/harness-codebase-map/templates/CONCERNS.md +49 -0
  111. package/resources/harness/java/harness-codebase-map/templates/CONVENTIONS.md +57 -0
  112. package/resources/harness/java/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
  113. package/resources/harness/java/harness-codebase-map/templates/STACK.md +55 -0
  114. package/resources/harness/java/harness-codebase-map/templates/STRUCTURE.md +69 -0
  115. package/resources/harness/java/harness-codebase-map/templates/TESTING.md +64 -0
  116. package/resources/harness/java/harness-codebase-map/templates/map-manifest.schema.json +73 -0
  117. package/resources/harness/java/harness-codebase-map/templates/map-summary.md +64 -0
  118. package/resources/harness/java/harness-knowledge-ingest/SKILL.md +246 -0
  119. package/resources/harness/java/harness-knowledge-ingest/design.md +842 -0
  120. package/resources/harness/java/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +42 -0
  121. package/resources/harness/java/harness-knowledge-ingest/mcp-config.example.json +10 -0
  122. package/resources/harness/java/harness-knowledge-ingest/reference.md +309 -0
  123. package/resources/harness/java/harness-knowledge-ingest/scripts/harness_knowledge.py +4082 -0
  124. package/resources/harness/java/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +273 -0
  125. package/resources/harness/java/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-01-10-ledger-reconciliation/reports/final/summary-data.json +43 -0
  126. 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 +38 -0
  127. package/resources/harness/java/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-03-05-webhook-contract/reports/final/summary-data.json +36 -0
  128. package/resources/harness/java/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/project.yaml +1 -0
  129. package/resources/harness/java/harness-knowledge-ingest/tests/test_harness_knowledge.py +1792 -0
  130. package/resources/harness/java/harness-knowledge-query/SKILL.md +164 -0
  131. package/resources/harness/java/harness-package/SKILL.md +87 -0
  132. package/resources/harness/java/harness-package/checklist.md +322 -0
  133. package/resources/harness/java/harness-package/reference.md +210 -0
  134. package/resources/harness/java/harness-plan/SKILL.md +127 -0
  135. package/resources/harness/java/harness-plan/checklist.md +243 -0
  136. package/resources/harness/java/harness-plan/protocols.md +124 -0
  137. package/resources/harness/java/harness-plan/reference.md +376 -0
  138. package/resources/harness/java/harness-review/SKILL.md +156 -0
  139. package/resources/harness/java/harness-review/checklist.md +124 -0
  140. package/resources/harness/java/harness-review/protocols.md +68 -0
  141. package/resources/harness/java/harness-review/reference.md +86 -0
  142. package/resources/harness/java/harness-run/SKILL.md +148 -0
  143. package/resources/harness/java/harness-run/checklist.md +250 -0
  144. package/resources/harness/java/harness-run/protocols.md +78 -0
  145. package/resources/harness/java/harness-run/reference.md +814 -0
  146. package/resources/harness/java/harness-submit/SKILL.md +166 -0
  147. package/resources/harness/java/harness-submit/checklist.md +407 -0
  148. package/resources/harness/java/harness-submit/reference.md +152 -0
  149. package/resources/harness/java/harness-sync/SKILL.md +82 -0
  150. package/resources/harness/java/harness-sync/reference.md +153 -0
  151. package/resources/harness/java/harness-test/SKILL.md +192 -0
  152. package/resources/harness/java/harness-test/checklist.md +317 -0
  153. package/resources/harness/java/harness-test/pitfalls-java.md +16 -0
  154. package/resources/harness/java/harness-test/pitfalls.md +260 -0
  155. package/resources/harness/java/harness-test/reference.md +788 -0
  156. package/resources/harness/java/protocols/archive-report-protocol.md +175 -0
  157. package/resources/harness/java/protocols/evidence-based-reporting-protocol.md +134 -0
  158. package/resources/harness/java/protocols/ledger-protocol.md +270 -0
  159. package/resources/harness/java/protocols/powershell-protocol.md +181 -0
  160. package/resources/harness/java/protocols/report-pipeline-protocol.md +150 -0
  161. package/resources/harness/java/protocols/sensitive-info-protocol.md +92 -0
  162. package/resources/harness/java/protocols/state-layout-protocol.md +131 -0
  163. package/resources/harness/java/protocols/submit-protocol.md +78 -0
  164. package/resources/harness/java/scripts/harness_archive.py +2319 -0
  165. package/resources/harness/java/scripts/harness_deploy.py +613 -0
  166. package/resources/harness/java/scripts/harness_events.py +680 -0
  167. package/resources/harness/java/scripts/harness_ledger.py +671 -0
  168. package/resources/harness/java/scripts/harness_preflight.py +780 -0
  169. package/resources/harness/java/scripts/harness_service.py +1331 -0
  170. package/resources/harness/manifests/general.json +296 -0
  171. package/resources/harness/manifests/java.json +324 -0
  172. package/resources/manifest.json +19 -0
  173. package/resources/skills/harness-apidoc/SKILL.md +50 -0
  174. package/resources/skills/harness-archive/SKILL.md +48 -0
  175. package/resources/skills/harness-codebase-map/SKILL.md +53 -0
  176. package/resources/skills/harness-knowledge-ingest/SKILL.md +48 -0
  177. package/resources/skills/harness-package/SKILL.md +48 -0
  178. package/resources/skills/harness-plan/SKILL.md +51 -0
  179. package/resources/skills/harness-review/SKILL.md +50 -0
  180. package/resources/skills/harness-run/SKILL.md +48 -0
  181. package/resources/skills/harness-skill-optimizer/SKILL.md +54 -0
  182. package/resources/skills/harness-submit/SKILL.md +47 -0
  183. package/resources/skills/harness-sync/SKILL.md +48 -0
  184. package/resources/skills/harness-test/SKILL.md +50 -0
@@ -0,0 +1,780 @@
1
+ #!/usr/bin/env python3
2
+ """Harness preflight: build-profile detect/check + quirk recording + agent precheck.
3
+
4
+ Implements DESIGN.md D5 (build-profile) and D8 (subagent precheck).
5
+ Python 3.10+ stdlib only. UTF-8 without BOM. Windows path friendly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import datetime as dt
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+
23
+ if hasattr(sys.stdout, "reconfigure"):
24
+ sys.stdout.reconfigure(encoding="utf-8")
25
+ if hasattr(sys.stderr, "reconfigure"):
26
+ sys.stderr.reconfigure(encoding="utf-8")
27
+
28
+
29
+ SCHEMA_VERSION = 1
30
+ PROFILE_REL = Path(".harness") / "config" / "build-profile.json"
31
+ PITFALLS_REL = Path(".harness") / "pitfalls.md"
32
+ PITFALLS_APPENDIX_HEADER = "## Preflight 附录(自动追加)"
33
+
34
+ DEFAULT_BUILD_COMMANDS: dict[str, str] = {
35
+ "compile": "",
36
+ "unitTest": "",
37
+ "unitTestFull": "",
38
+ "install": "",
39
+ "package": "",
40
+ }
41
+
42
+ VALID_QUIRK_ACTIONS = {"skip-not-block", "fix-command"}
43
+
44
+
45
+ def now_iso() -> str:
46
+ return dt.datetime.now().astimezone().isoformat(timespec="seconds")
47
+
48
+
49
+ def emit_json(payload: dict[str, Any], *, ok: bool = True) -> int:
50
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
51
+ return 0 if ok else 1
52
+
53
+
54
+ def read_json(path: Path) -> Any:
55
+ return json.loads(path.read_text(encoding="utf-8"))
56
+
57
+
58
+ def write_json(path: Path, data: Any) -> None:
59
+ path.parent.mkdir(parents=True, exist_ok=True)
60
+ text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
61
+ path.write_text(text, encoding="utf-8")
62
+
63
+
64
+ def sha256_file(path: Path) -> str | None:
65
+ if not path.is_file():
66
+ return None
67
+ h = hashlib.sha256()
68
+ with path.open("rb") as fh:
69
+ for chunk in iter(lambda: fh.read(65536), b""):
70
+ h.update(chunk)
71
+ return h.hexdigest()
72
+
73
+
74
+ def find_root_pom(project: Path) -> Path | None:
75
+ candidates = [project / "pom.xml", project / "pom.xml.example"]
76
+ for c in candidates:
77
+ if c.is_file():
78
+ return c
79
+ return None
80
+
81
+
82
+ def which_tool(name: str) -> str | None:
83
+ found = shutil.which(name)
84
+ if found:
85
+ return str(Path(found).resolve())
86
+ # Windows: try .cmd / .bat / .exe explicitly
87
+ if os.name == "nt":
88
+ for ext in (".cmd", ".bat", ".exe"):
89
+ found = shutil.which(name + ext)
90
+ if found:
91
+ return str(Path(found).resolve())
92
+ return None
93
+
94
+
95
+ def run_version(tool_path: str | None, args: list[str], timeout: float = 8.0) -> str:
96
+ """Best-effort version probe. Empty string if unavailable."""
97
+ if not tool_path:
98
+ return ""
99
+ try:
100
+ completed = subprocess.run(
101
+ [tool_path, *args],
102
+ capture_output=True,
103
+ text=True,
104
+ encoding="utf-8",
105
+ errors="replace",
106
+ timeout=timeout,
107
+ check=False,
108
+ )
109
+ except (OSError, subprocess.TimeoutExpired):
110
+ return ""
111
+ text = (completed.stdout or "") + "\n" + (completed.stderr or "")
112
+ return _extract_version(text)
113
+
114
+
115
+ def _extract_version(text: str) -> str:
116
+ # node: v20.11.0 / Apache Maven 3.9.6
117
+ m = re.search(r"\bv?(\d+\.\d+\.\d+(?:[-+][\w.]+)?)\b", text)
118
+ if m:
119
+ return m.group(1)
120
+ m = re.search(r"version\s+([^\s,]+)", text, re.IGNORECASE)
121
+ if m:
122
+ return m.group(1).strip()
123
+ line = next((ln.strip() for ln in text.splitlines() if ln.strip()), "")
124
+ return line[:120]
125
+
126
+
127
+ def is_executable_path(path_str: str | None) -> bool:
128
+ if not path_str:
129
+ return False
130
+ p = Path(path_str)
131
+ if not p.exists():
132
+ return False
133
+ if p.is_file():
134
+ if os.name == "nt":
135
+ return True
136
+ return os.access(p, os.X_OK)
137
+ # On Windows, shutil.which may return .cmd wrappers that exist
138
+ return p.exists()
139
+
140
+
141
+ def load_existing_profile(project: Path) -> dict[str, Any] | None:
142
+ path = project / PROFILE_REL
143
+ if not path.is_file():
144
+ return None
145
+ try:
146
+ data = read_json(path)
147
+ except (OSError, json.JSONDecodeError):
148
+ return None
149
+ return data if isinstance(data, dict) else None
150
+
151
+
152
+ def parse_claude_hints(project: Path) -> dict[str, str]:
153
+ """Extract optional build command hints from CLAUDE.md / AGENTS.md."""
154
+ hints: dict[str, str] = {}
155
+ for name in ("CLAUDE.md", "AGENTS.md", ".claude/CLAUDE.md"):
156
+ path = project / name
157
+ if not path.is_file():
158
+ continue
159
+ try:
160
+ text = path.read_text(encoding="utf-8")
161
+ except OSError:
162
+ continue
163
+ # Look for fenced or inline mvn/npm command examples
164
+ for key, pattern in (
165
+ ("compile", r"(?:compile|编译)[^\n`]*`([^`]+)`"),
166
+ ("unitTest", r"(?:unit.?test|单元测试)[^\n`]*`([^`]+)`"),
167
+ ("package", r"(?:package|打包)[^\n`]*`([^`]+)`"),
168
+ ("install", r"(?:install|安装依赖)[^\n`]*`([^`]+)`"),
169
+ ):
170
+ m = re.search(pattern, text, re.IGNORECASE)
171
+ if m and key not in hints:
172
+ hints[key] = m.group(1).strip()
173
+ # Direct mvn lines
174
+ for m in re.finditer(r"`(mvn[^`]+)`", text):
175
+ cmd = m.group(1).strip()
176
+ if "compile" in cmd and "compile" not in hints:
177
+ hints["compile"] = cmd
178
+ elif re.search(r"\btest\b", cmd) and "unitTest" not in hints:
179
+ hints["unitTest"] = cmd
180
+ elif "package" in cmd and "package" not in hints:
181
+ hints["package"] = cmd
182
+ elif "install" in cmd and "install" not in hints:
183
+ hints["install"] = cmd
184
+ return hints
185
+
186
+
187
+ def empty_profile_skeleton() -> dict[str, Any]:
188
+ return {
189
+ "schemaVersion": SCHEMA_VERSION,
190
+ "detectedAt": "",
191
+ "toolPaths": {"node": "", "mvn": ""},
192
+ "buildCommands": dict(DEFAULT_BUILD_COMMANDS),
193
+ "verificationInputs": {},
194
+ "serviceStart": {
195
+ "command": "",
196
+ "healthUrl": "",
197
+ "startTimeoutSec": 120,
198
+ "inputFiles": [],
199
+ "profile": "",
200
+ "overlayPath": "",
201
+ },
202
+ "knownPreexistingErrors": [],
203
+ "shellQuirks": [],
204
+ "fingerprint": {"mvnVersion": "", "nodeVersion": "", "pomHash": ""},
205
+ }
206
+
207
+
208
+ def merge_preserve_quirks(
209
+ base: dict[str, Any],
210
+ existing: dict[str, Any] | None,
211
+ ) -> dict[str, Any]:
212
+ """Keep human-curated quirks/errors/commands when re-detecting."""
213
+ if not existing:
214
+ return base
215
+ # Preserve knownPreexistingErrors / shellQuirks / filled buildCommands / serviceStart
216
+ if isinstance(existing.get("knownPreexistingErrors"), list):
217
+ base["knownPreexistingErrors"] = list(existing["knownPreexistingErrors"])
218
+ if isinstance(existing.get("shellQuirks"), list):
219
+ base["shellQuirks"] = list(existing["shellQuirks"])
220
+ existing_cmds = existing.get("buildCommands")
221
+ if isinstance(existing_cmds, dict):
222
+ for k, v in existing_cmds.items():
223
+ if isinstance(v, str) and v.strip():
224
+ base["buildCommands"][k] = v
225
+ existing_svc = existing.get("serviceStart")
226
+ if isinstance(existing_svc, dict):
227
+ merged_svc = dict(base.get("serviceStart") or {})
228
+ for k, v in existing_svc.items():
229
+ if v not in (None, ""):
230
+ merged_svc[k] = v
231
+ base["serviceStart"] = merged_svc
232
+ # 保留用户已配置的 verificationInputs(可能含 module 专属 glob),不覆盖。
233
+ if isinstance(existing.get("verificationInputs"), dict):
234
+ base["verificationInputs"] = dict(existing["verificationInputs"])
235
+ return base
236
+
237
+
238
+ def build_fingerprint(project: Path, node_path: str | None, mvn_path: str | None) -> dict[str, str]:
239
+ pom = find_root_pom(project)
240
+ pom_hash = sha256_file(pom) if pom else ""
241
+ return {
242
+ "mvnVersion": run_version(mvn_path, ["--version"]) if mvn_path else "",
243
+ "nodeVersion": run_version(node_path, ["--version"]) if node_path else "",
244
+ "pomHash": pom_hash or "",
245
+ }
246
+
247
+
248
+ def current_pom_hash(project: Path) -> str:
249
+ pom = find_root_pom(project)
250
+ return (sha256_file(pom) if pom else "") or ""
251
+
252
+
253
+ def cmd_detect(project: Path) -> dict[str, Any]:
254
+ project = project.resolve()
255
+ existing = load_existing_profile(project)
256
+ profile = empty_profile_skeleton()
257
+
258
+ node_path = which_tool("node")
259
+ mvn_path = which_tool("mvn")
260
+ profile["toolPaths"] = {
261
+ "node": node_path or "",
262
+ "mvn": mvn_path or "",
263
+ }
264
+ profile["fingerprint"] = build_fingerprint(project, node_path, mvn_path)
265
+ profile["detectedAt"] = now_iso()
266
+
267
+ hints = parse_claude_hints(project)
268
+ cmds = dict(DEFAULT_BUILD_COMMANDS)
269
+ cmds.update({k: v for k, v in hints.items() if k in cmds})
270
+ # Sensible Java placeholders when pom exists but no hints
271
+ if find_root_pom(project) and not any(cmds.values()):
272
+ cmds["compile"] = "mvn -f pom.xml compile -o -q"
273
+ cmds["unitTest"] = "mvn -f pom.xml test -Dtest={testClasses} -o"
274
+ cmds["unitTestFull"] = "mvn -f pom.xml test -o"
275
+ cmds["install"] = "mvn install -pl {modules} -am -DskipTests -nsu"
276
+ cmds["package"] = "mvn -f pom.xml package '-Dmaven.test.skip=true'"
277
+ profile["buildCommands"] = cmds
278
+
279
+ # Java 项目给 verificationInputs.unitTestFull 一个根级默认闭包
280
+ # (多 module 项目用户可改为 module/pom.xml + module/src/**)。通用/无 pom
281
+ # 项目保持空 {} —— can-reuse --profile-input unitTestFull 会返回
282
+ # insufficient-evidence,执行全量测试但不允许缓存复用,直到 profile 配置好。
283
+ if find_root_pom(project):
284
+ # A reactor-level Maven test can run every module, so its reuse
285
+ # fingerprint must include every module's pom and source roots.
286
+ module_poms = sorted(
287
+ pom.relative_to(project).as_posix()
288
+ for pom in project.rglob("pom.xml")
289
+ if pom.is_file() and ".harness" not in pom.parts and ".git" not in pom.parts
290
+ )
291
+ full_inputs: list[str] = []
292
+ for pom in module_poms:
293
+ full_inputs.append(pom)
294
+ parent = Path(pom).parent
295
+ prefix = "" if str(parent) == "." else f"{parent.as_posix()}/"
296
+ full_inputs.extend([f"{prefix}src/main/**", f"{prefix}src/test/**"])
297
+ profile["verificationInputs"] = {"unitTestFull": full_inputs}
298
+
299
+ profile = merge_preserve_quirks(profile, existing)
300
+
301
+ # Idempotent fingerprint/toolPaths overwrite from fresh detect;
302
+ # detectedAt updates each run (tests compare ignoring it).
303
+ out_path = project / PROFILE_REL
304
+ write_json(out_path, profile)
305
+
306
+ return {
307
+ "ok": True,
308
+ "action": "detect",
309
+ "project": str(project),
310
+ "profilePath": str(out_path),
311
+ "profile": profile,
312
+ "created": existing is None,
313
+ "updated": True,
314
+ }
315
+
316
+
317
+ def cmd_check(project: Path) -> dict[str, Any]:
318
+ """Second-run fast check: existence + executability + fingerprint compare.
319
+
320
+ Does NOT run mvn/node full version probes (≤5s target).
321
+ """
322
+ project = project.resolve()
323
+ profile_path = project / PROFILE_REL
324
+ issues: list[str] = []
325
+ stale = False
326
+
327
+ if not profile_path.is_file():
328
+ return {
329
+ "ok": False,
330
+ "hardFailure": True,
331
+ "action": "check",
332
+ "project": str(project),
333
+ "stale": True,
334
+ "issues": ["build-profile.json missing; run detect"],
335
+ "hint": "python harness_preflight.py detect --project <root> --json",
336
+ }
337
+
338
+ try:
339
+ profile = read_json(profile_path)
340
+ except (OSError, json.JSONDecodeError) as exc:
341
+ return {
342
+ "ok": False,
343
+ "hardFailure": True,
344
+ "action": "check",
345
+ "project": str(project),
346
+ "stale": True,
347
+ "issues": [f"build-profile.json unreadable: {exc}"],
348
+ "hint": "python harness_preflight.py detect --project <root> --json",
349
+ }
350
+
351
+ if not isinstance(profile, dict):
352
+ return {
353
+ "ok": False,
354
+ "hardFailure": True,
355
+ "action": "check",
356
+ "project": str(project),
357
+ "stale": True,
358
+ "issues": ["build-profile.json is not an object"],
359
+ "hint": "python harness_preflight.py detect --project <root> --json",
360
+ }
361
+
362
+ tool_paths = profile.get("toolPaths") or {}
363
+ if not isinstance(tool_paths, dict):
364
+ tool_paths = {}
365
+
366
+ for tool_name in ("node", "mvn"):
367
+ path_str = tool_paths.get(tool_name) or ""
368
+ if not path_str:
369
+ # Empty is allowed (tool not present at detect time)
370
+ continue
371
+ if not is_executable_path(path_str):
372
+ stale = True
373
+ issues.append(f"toolPaths.{tool_name} missing or not executable: {path_str}")
374
+
375
+ fp = profile.get("fingerprint") or {}
376
+ if not isinstance(fp, dict):
377
+ fp = {}
378
+ stored_pom = fp.get("pomHash") or ""
379
+ current_pom = current_pom_hash(project)
380
+ if stored_pom != current_pom:
381
+ stale = True
382
+ issues.append(
383
+ f"fingerprint.pomHash changed: stored={stored_pom or '(empty)'} "
384
+ f"current={current_pom or '(empty)'}"
385
+ )
386
+
387
+ # If profile recorded a tool version but path is now gone → already covered.
388
+ # If pom appeared/disappeared relative to empty hash → covered above.
389
+
390
+ result = {
391
+ "ok": not stale,
392
+ "action": "check",
393
+ "project": str(project),
394
+ "stale": stale,
395
+ "issues": issues,
396
+ "fingerprint": {
397
+ "stored": {"pomHash": stored_pom, "mvnVersion": fp.get("mvnVersion", ""), "nodeVersion": fp.get("nodeVersion", "")},
398
+ "current": {"pomHash": current_pom},
399
+ },
400
+ }
401
+ if stale:
402
+ result["hint"] = "python harness_preflight.py detect --project <root> --json"
403
+ return result
404
+
405
+
406
+ def ensure_pitfalls_appendix(project: Path) -> Path:
407
+ path = project / PITFALLS_REL
408
+ path.parent.mkdir(parents=True, exist_ok=True)
409
+ if not path.exists():
410
+ content = (
411
+ "# 项目避坑记录\n\n"
412
+ "> 由 harness_preflight.py record-quirk 自动维护附录。\n\n"
413
+ f"{PITFALLS_APPENDIX_HEADER}\n\n"
414
+ )
415
+ path.write_text(content, encoding="utf-8")
416
+ return path
417
+ text = path.read_text(encoding="utf-8")
418
+ if PITFALLS_APPENDIX_HEADER not in text:
419
+ if not text.endswith("\n"):
420
+ text += "\n"
421
+ text += f"\n{PITFALLS_APPENDIX_HEADER}\n\n"
422
+ path.write_text(text, encoding="utf-8")
423
+ return path
424
+
425
+
426
+ def append_pitfalls_line(project: Path, line: str) -> Path:
427
+ path = ensure_pitfalls_appendix(project)
428
+ text = path.read_text(encoding="utf-8")
429
+ if not text.endswith("\n"):
430
+ text += "\n"
431
+ # Append after appendix header block (end of file is fine for append-only)
432
+ text += line.rstrip() + "\n"
433
+ path.write_text(text, encoding="utf-8")
434
+ return path
435
+
436
+
437
+ def cmd_record_quirk(
438
+ project: Path,
439
+ pattern: str,
440
+ reason: str,
441
+ action: str,
442
+ fixed_command: str | None = None,
443
+ ) -> dict[str, Any]:
444
+ project = project.resolve()
445
+ if action not in VALID_QUIRK_ACTIONS:
446
+ return {
447
+ "ok": False,
448
+ "action": "record-quirk",
449
+ "issues": [f"invalid action: {action}; expected one of {sorted(VALID_QUIRK_ACTIONS)}"],
450
+ }
451
+
452
+ profile_path = project / PROFILE_REL
453
+ if profile_path.is_file():
454
+ try:
455
+ profile = read_json(profile_path)
456
+ if not isinstance(profile, dict):
457
+ profile = empty_profile_skeleton()
458
+ except (OSError, json.JSONDecodeError):
459
+ profile = empty_profile_skeleton()
460
+ else:
461
+ profile = empty_profile_skeleton()
462
+ profile["detectedAt"] = now_iso()
463
+
464
+ profile.setdefault("knownPreexistingErrors", [])
465
+ profile.setdefault("shellQuirks", [])
466
+ profile.setdefault("buildCommands", dict(DEFAULT_BUILD_COMMANDS))
467
+
468
+ if not isinstance(profile["knownPreexistingErrors"], list):
469
+ profile["knownPreexistingErrors"] = []
470
+ if not isinstance(profile["shellQuirks"], list):
471
+ profile["shellQuirks"] = []
472
+ if not isinstance(profile["buildCommands"], dict):
473
+ profile["buildCommands"] = dict(DEFAULT_BUILD_COMMANDS)
474
+
475
+ changed: list[str] = []
476
+
477
+ if action == "skip-not-block":
478
+ entry = {"pattern": pattern, "reason": reason, "action": "skip-not-block"}
479
+ existing_patterns = {
480
+ e.get("pattern")
481
+ for e in profile["knownPreexistingErrors"]
482
+ if isinstance(e, dict)
483
+ }
484
+ if pattern not in existing_patterns:
485
+ profile["knownPreexistingErrors"].append(entry)
486
+ changed.append("knownPreexistingErrors")
487
+ else:
488
+ # Append-only: do not overwrite existing entry; still sync pitfalls
489
+ changed.append("knownPreexistingErrors(already-present)")
490
+ else: # fix-command
491
+ if pattern not in profile["shellQuirks"]:
492
+ profile["shellQuirks"].append(pattern)
493
+ changed.append("shellQuirks")
494
+ else:
495
+ changed.append("shellQuirks(already-present)")
496
+ if fixed_command:
497
+ # Known buildCommands key → update it; otherwise store under a
498
+ # namespaced custom key to avoid polluting the standard key space.
499
+ if pattern in DEFAULT_BUILD_COMMANDS:
500
+ key = pattern
501
+ else:
502
+ key = f"custom:{pattern}"
503
+ profile["buildCommands"][key] = fixed_command
504
+ changed.append(f"buildCommands.{key}")
505
+
506
+ write_json(profile_path, profile)
507
+
508
+ date_str = dt.date.today().isoformat()
509
+ pitfalls_line = f"- {date_str}: `{pattern}` — {reason} (action={action}"
510
+ if fixed_command:
511
+ pitfalls_line += f"; fixed-command=`{fixed_command}`"
512
+ pitfalls_line += ")"
513
+ pitfalls_path = append_pitfalls_line(project, pitfalls_line)
514
+
515
+ return {
516
+ "ok": True,
517
+ "action": "record-quirk",
518
+ "project": str(project),
519
+ "pattern": pattern,
520
+ "reason": reason,
521
+ "quirkAction": action,
522
+ "fixedCommand": fixed_command,
523
+ "changed": changed,
524
+ "profilePath": str(profile_path),
525
+ "pitfallsPath": str(pitfalls_path),
526
+ "pitfallsLine": pitfalls_line,
527
+ "profile": profile,
528
+ }
529
+
530
+
531
+ def parse_frontmatter(text: str) -> tuple[dict[str, Any] | None, str | None]:
532
+ """Parse YAML-ish frontmatter between leading --- fences.
533
+
534
+ Returns (meta, error_reason). Supports simple scalars, lists, and
535
+ nested list items used by harness agent definitions.
536
+ """
537
+ if not text.startswith("---"):
538
+ return None, "missing frontmatter start ---"
539
+ lines = text.splitlines()
540
+ if not lines or lines[0].strip() != "---":
541
+ return None, "missing frontmatter start ---"
542
+ end_idx = None
543
+ for i in range(1, len(lines)):
544
+ if lines[i].strip() == "---":
545
+ end_idx = i
546
+ break
547
+ if end_idx is None:
548
+ return None, "missing frontmatter end ---"
549
+
550
+ body = "\n".join(lines[1:end_idx])
551
+ meta: dict[str, Any] = {}
552
+ current_list_key: str | None = None
553
+
554
+ for raw in body.splitlines():
555
+ if not raw.strip():
556
+ continue
557
+ # list item under previous key
558
+ list_item = re.match(r"^-\s+(.*)$", raw)
559
+ if list_item and current_list_key:
560
+ meta.setdefault(current_list_key, [])
561
+ if not isinstance(meta[current_list_key], list):
562
+ meta[current_list_key] = []
563
+ meta[current_list_key].append(_parse_scalar(list_item.group(1).strip()))
564
+ continue
565
+
566
+ m = re.match(r"^([A-Za-z0-9_]+):\s*(.*)$", raw)
567
+ if not m:
568
+ current_list_key = None
569
+ continue
570
+ key, val = m.group(1), m.group(2).strip()
571
+ if val == "":
572
+ current_list_key = key
573
+ meta[key] = []
574
+ continue
575
+ current_list_key = None
576
+ if val.startswith("[") and val.endswith("]"):
577
+ inner = val[1:-1].strip()
578
+ if not inner:
579
+ meta[key] = []
580
+ else:
581
+ parts = _split_flow_list(inner)
582
+ meta[key] = [_parse_scalar(p.strip()) for p in parts]
583
+ else:
584
+ meta[key] = _parse_scalar(val)
585
+ return meta, None
586
+
587
+
588
+ def _split_flow_list(inner: str) -> list[str]:
589
+ """Split YAML flow list respecting nested brackets like Bash(powershell.exe:*)."""
590
+ parts: list[str] = []
591
+ buf: list[str] = []
592
+ depth = 0
593
+ for ch in inner:
594
+ if ch == "[":
595
+ depth += 1
596
+ buf.append(ch)
597
+ elif ch == "]":
598
+ depth = max(0, depth - 1)
599
+ buf.append(ch)
600
+ elif ch == "(":
601
+ depth += 1
602
+ buf.append(ch)
603
+ elif ch == ")":
604
+ depth = max(0, depth - 1)
605
+ buf.append(ch)
606
+ elif ch == "," and depth == 0:
607
+ parts.append("".join(buf))
608
+ buf = []
609
+ else:
610
+ buf.append(ch)
611
+ if buf:
612
+ parts.append("".join(buf))
613
+ return parts
614
+
615
+
616
+ def _parse_scalar(val: str) -> Any:
617
+ if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
618
+ return val[1:-1]
619
+ if val.lower() in ("true", "false"):
620
+ return val.lower() == "true"
621
+ if re.fullmatch(r"-?\d+", val):
622
+ return int(val)
623
+ return val
624
+
625
+
626
+ def cmd_check_agents(skills_root: Path, agent: str) -> dict[str, Any]:
627
+ skills_root = skills_root.resolve()
628
+ agent_name = agent.strip()
629
+ if not agent_name:
630
+ return {
631
+ "ok": False,
632
+ "action": "check-agents",
633
+ "agent": agent,
634
+ "usable": False,
635
+ "reason": "agent name is empty",
636
+ }
637
+
638
+ # Accept bare name or with .md
639
+ filename = agent_name if agent_name.endswith(".md") else f"{agent_name}.md"
640
+ agent_path = skills_root / "agents" / filename
641
+
642
+ if not agent_path.is_file():
643
+ return {
644
+ "ok": False,
645
+ "action": "check-agents",
646
+ "agent": agent_name,
647
+ "path": str(agent_path),
648
+ "usable": False,
649
+ "reason": f"agent file not found: {agent_path}",
650
+ }
651
+
652
+ try:
653
+ text = agent_path.read_text(encoding="utf-8")
654
+ except OSError as exc:
655
+ return {
656
+ "ok": False,
657
+ "action": "check-agents",
658
+ "agent": agent_name,
659
+ "path": str(agent_path),
660
+ "usable": False,
661
+ "reason": f"cannot read agent file: {exc}",
662
+ }
663
+
664
+ meta, err = parse_frontmatter(text)
665
+ if err or meta is None:
666
+ return {
667
+ "ok": False,
668
+ "action": "check-agents",
669
+ "agent": agent_name,
670
+ "path": str(agent_path),
671
+ "usable": False,
672
+ "reason": f"frontmatter parse failed: {err or 'unknown'}",
673
+ }
674
+
675
+ tools = meta.get("tools")
676
+ if tools is None:
677
+ return {
678
+ "ok": False,
679
+ "action": "check-agents",
680
+ "agent": agent_name,
681
+ "path": str(agent_path),
682
+ "usable": False,
683
+ "reason": "tools declaration missing",
684
+ "frontmatter": meta,
685
+ }
686
+ if not isinstance(tools, list) or len(tools) == 0:
687
+ return {
688
+ "ok": False,
689
+ "action": "check-agents",
690
+ "agent": agent_name,
691
+ "path": str(agent_path),
692
+ "usable": False,
693
+ "reason": "tools declaration empty or invalid",
694
+ "frontmatter": meta,
695
+ }
696
+
697
+ name_field = meta.get("name")
698
+ return {
699
+ "ok": True,
700
+ "action": "check-agents",
701
+ "agent": agent_name,
702
+ "path": str(agent_path),
703
+ "usable": True,
704
+ "reason": "agent file exists; frontmatter parsed; tools declared",
705
+ "name": name_field,
706
+ "tools": tools,
707
+ "frontmatterKeys": sorted(meta.keys()),
708
+ }
709
+
710
+
711
+ def build_parser() -> argparse.ArgumentParser:
712
+ parser = argparse.ArgumentParser(
713
+ description="Harness preflight: build-profile + agent availability checks.",
714
+ )
715
+ parser.add_argument(
716
+ "--json",
717
+ action="store_true",
718
+ help="Emit machine-readable JSON (always on for subcommands; kept for contract).",
719
+ )
720
+ sub = parser.add_subparsers(dest="command", required=True)
721
+
722
+ p_detect = sub.add_parser("detect", help="Full probe and write build-profile.json")
723
+ p_detect.add_argument("--project", required=True, type=Path)
724
+ p_detect.add_argument("--json", action="store_true")
725
+
726
+ p_check = sub.add_parser("check", help="Fast stale check of build-profile")
727
+ p_check.add_argument("--project", required=True, type=Path)
728
+ p_check.add_argument("--json", action="store_true")
729
+
730
+ p_quirk = sub.add_parser("record-quirk", help="Append quirk without overwriting peers")
731
+ p_quirk.add_argument("--project", required=True, type=Path)
732
+ p_quirk.add_argument("--pattern", required=True)
733
+ p_quirk.add_argument("--reason", required=True)
734
+ p_quirk.add_argument(
735
+ "--action",
736
+ required=True,
737
+ choices=sorted(VALID_QUIRK_ACTIONS),
738
+ )
739
+ p_quirk.add_argument("--fixed-command", default=None)
740
+ p_quirk.add_argument("--json", action="store_true")
741
+
742
+ p_agents = sub.add_parser("check-agents", help="Validate agent definition usability")
743
+ p_agents.add_argument("--skills-root", required=True, type=Path)
744
+ p_agents.add_argument("--agent", required=True)
745
+ p_agents.add_argument("--json", action="store_true")
746
+
747
+ return parser
748
+
749
+
750
+ def main(argv: list[str] | None = None) -> int:
751
+ parser = build_parser()
752
+ args = parser.parse_args(argv)
753
+
754
+ if args.command == "detect":
755
+ result = cmd_detect(args.project)
756
+ return emit_json(result, ok=bool(result.get("ok", True)))
757
+ if args.command == "check":
758
+ result = cmd_check(args.project)
759
+ # stale=true with a readable profile still exits 0 (callers branch on JSON);
760
+ # hard failures (profile missing / unreadable / not an object) exit 1.
761
+ return emit_json(result, ok=not result.get("hardFailure", False))
762
+ if args.command == "record-quirk":
763
+ result = cmd_record_quirk(
764
+ args.project,
765
+ pattern=args.pattern,
766
+ reason=args.reason,
767
+ action=args.action,
768
+ fixed_command=args.fixed_command,
769
+ )
770
+ return emit_json(result, ok=bool(result.get("ok", False)))
771
+ if args.command == "check-agents":
772
+ result = cmd_check_agents(args.skills_root, args.agent)
773
+ return emit_json(result, ok=True)
774
+
775
+ parser.error(f"unknown command: {args.command}")
776
+ return 2
777
+
778
+
779
+ if __name__ == "__main__":
780
+ raise SystemExit(main())