hunter-harness 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +11 -0
  3. package/dist/bin.js +1540 -491
  4. package/package.json +9 -7
  5. package/resources/harness/general/.harness-build.json +0 -5
  6. package/resources/harness/general/CONTEXT.md +0 -65
  7. package/resources/harness/general/README.md +0 -478
  8. package/resources/harness/general/agents/harness-evaluator.md +0 -96
  9. package/resources/harness/general/agents/harness-explorer.md +0 -72
  10. package/resources/harness/general/agents/harness-reviewer.md +0 -69
  11. package/resources/harness/general/harness-archive/SKILL.md +0 -215
  12. package/resources/harness/general/harness-archive/checklist.md +0 -52
  13. package/resources/harness/general/harness-archive/reference.md +0 -103
  14. package/resources/harness/general/harness-archive/scripts/gen-manifest.ps1 +0 -31
  15. package/resources/harness/general/harness-archive/templates/render-summary.mjs +0 -171
  16. package/resources/harness/general/harness-archive/templates/summary-data-template.json +0 -128
  17. package/resources/harness/general/harness-codebase-map/SKILL.md +0 -112
  18. package/resources/harness/general/harness-codebase-map/checklist.md +0 -126
  19. package/resources/harness/general/harness-codebase-map/reference.md +0 -364
  20. package/resources/harness/general/harness-codebase-map/templates/ARCHITECTURE.md +0 -57
  21. package/resources/harness/general/harness-codebase-map/templates/CONCERNS.md +0 -49
  22. package/resources/harness/general/harness-codebase-map/templates/CONVENTIONS.md +0 -57
  23. package/resources/harness/general/harness-codebase-map/templates/INTEGRATIONS.md +0 -52
  24. package/resources/harness/general/harness-codebase-map/templates/STACK.md +0 -55
  25. package/resources/harness/general/harness-codebase-map/templates/STRUCTURE.md +0 -69
  26. package/resources/harness/general/harness-codebase-map/templates/TESTING.md +0 -64
  27. package/resources/harness/general/harness-codebase-map/templates/map-manifest.schema.json +0 -73
  28. package/resources/harness/general/harness-codebase-map/templates/map-summary.md +0 -64
  29. package/resources/harness/general/harness-knowledge-ingest/SKILL.md +0 -246
  30. package/resources/harness/general/harness-knowledge-ingest/design.md +0 -842
  31. package/resources/harness/general/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
  32. package/resources/harness/general/harness-knowledge-ingest/mcp-config.example.json +0 -10
  33. package/resources/harness/general/harness-knowledge-ingest/reference.md +0 -309
  34. package/resources/harness/general/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -4082
  35. package/resources/harness/general/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -273
  36. package/resources/harness/general/harness-knowledge-query/SKILL.md +0 -164
  37. package/resources/harness/general/harness-plan/SKILL.md +0 -127
  38. package/resources/harness/general/harness-plan/checklist.md +0 -243
  39. package/resources/harness/general/harness-plan/protocols.md +0 -124
  40. package/resources/harness/general/harness-plan/reference.md +0 -376
  41. package/resources/harness/general/harness-review/SKILL.md +0 -156
  42. package/resources/harness/general/harness-review/checklist.md +0 -124
  43. package/resources/harness/general/harness-review/protocols.md +0 -68
  44. package/resources/harness/general/harness-review/reference.md +0 -86
  45. package/resources/harness/general/harness-run/SKILL.md +0 -132
  46. package/resources/harness/general/harness-run/checklist.md +0 -259
  47. package/resources/harness/general/harness-run/protocols.md +0 -78
  48. package/resources/harness/general/harness-run/reference.md +0 -834
  49. package/resources/harness/general/harness-submit/SKILL.md +0 -159
  50. package/resources/harness/general/harness-submit/checklist.md +0 -407
  51. package/resources/harness/general/harness-submit/reference.md +0 -152
  52. package/resources/harness/general/harness-sync/SKILL.md +0 -82
  53. package/resources/harness/general/harness-sync/reference.md +0 -153
  54. package/resources/harness/general/harness-test/SKILL.md +0 -180
  55. package/resources/harness/general/harness-test/checklist.md +0 -319
  56. package/resources/harness/general/harness-test/pitfalls.md +0 -260
  57. package/resources/harness/general/harness-test/reference.md +0 -791
  58. package/resources/harness/general/protocols/archive-report-protocol.md +0 -175
  59. package/resources/harness/general/protocols/evidence-based-reporting-protocol.md +0 -134
  60. package/resources/harness/general/protocols/ledger-protocol.md +0 -270
  61. package/resources/harness/general/protocols/powershell-protocol.md +0 -181
  62. package/resources/harness/general/protocols/report-pipeline-protocol.md +0 -150
  63. package/resources/harness/general/protocols/sensitive-info-protocol.md +0 -92
  64. package/resources/harness/general/protocols/state-layout-protocol.md +0 -131
  65. package/resources/harness/general/protocols/submit-protocol.md +0 -78
  66. package/resources/harness/general/scripts/harness_archive.py +0 -2319
  67. package/resources/harness/general/scripts/harness_deploy.py +0 -613
  68. package/resources/harness/general/scripts/harness_events.py +0 -680
  69. package/resources/harness/general/scripts/harness_ledger.py +0 -671
  70. package/resources/harness/general/scripts/harness_preflight.py +0 -780
  71. package/resources/harness/general/scripts/harness_service.py +0 -1331
  72. package/resources/harness/java/.harness-build.json +0 -5
  73. package/resources/harness/java/CONTEXT.md +0 -65
  74. package/resources/harness/java/README.md +0 -478
  75. package/resources/harness/java/agents/harness-evaluator.md +0 -96
  76. package/resources/harness/java/agents/harness-explorer.md +0 -72
  77. package/resources/harness/java/agents/harness-reviewer.md +0 -69
  78. package/resources/harness/java/harness-apidoc/SKILL.md +0 -86
  79. package/resources/harness/java/harness-apidoc/checklist.md +0 -142
  80. package/resources/harness/java/harness-apidoc/reference.md +0 -233
  81. package/resources/harness/java/harness-archive/SKILL.md +0 -215
  82. package/resources/harness/java/harness-archive/checklist.md +0 -52
  83. package/resources/harness/java/harness-archive/reference.md +0 -103
  84. package/resources/harness/java/harness-archive/scripts/gen-manifest.ps1 +0 -31
  85. package/resources/harness/java/harness-archive/templates/render-summary.mjs +0 -171
  86. package/resources/harness/java/harness-archive/templates/summary-data-template.json +0 -128
  87. package/resources/harness/java/harness-codebase-map/SKILL.md +0 -112
  88. package/resources/harness/java/harness-codebase-map/checklist.md +0 -126
  89. package/resources/harness/java/harness-codebase-map/reference.md +0 -364
  90. package/resources/harness/java/harness-codebase-map/templates/ARCHITECTURE.md +0 -57
  91. package/resources/harness/java/harness-codebase-map/templates/CONCERNS.md +0 -49
  92. package/resources/harness/java/harness-codebase-map/templates/CONVENTIONS.md +0 -57
  93. package/resources/harness/java/harness-codebase-map/templates/INTEGRATIONS.md +0 -52
  94. package/resources/harness/java/harness-codebase-map/templates/STACK.md +0 -55
  95. package/resources/harness/java/harness-codebase-map/templates/STRUCTURE.md +0 -69
  96. package/resources/harness/java/harness-codebase-map/templates/TESTING.md +0 -64
  97. package/resources/harness/java/harness-codebase-map/templates/map-manifest.schema.json +0 -73
  98. package/resources/harness/java/harness-codebase-map/templates/map-summary.md +0 -64
  99. package/resources/harness/java/harness-knowledge-ingest/SKILL.md +0 -246
  100. package/resources/harness/java/harness-knowledge-ingest/design.md +0 -842
  101. package/resources/harness/java/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
  102. package/resources/harness/java/harness-knowledge-ingest/mcp-config.example.json +0 -10
  103. package/resources/harness/java/harness-knowledge-ingest/reference.md +0 -309
  104. package/resources/harness/java/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -4082
  105. package/resources/harness/java/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -273
  106. package/resources/harness/java/harness-knowledge-query/SKILL.md +0 -164
  107. package/resources/harness/java/harness-package/SKILL.md +0 -87
  108. package/resources/harness/java/harness-package/checklist.md +0 -322
  109. package/resources/harness/java/harness-package/reference.md +0 -210
  110. package/resources/harness/java/harness-plan/SKILL.md +0 -127
  111. package/resources/harness/java/harness-plan/checklist.md +0 -243
  112. package/resources/harness/java/harness-plan/protocols.md +0 -124
  113. package/resources/harness/java/harness-plan/reference.md +0 -376
  114. package/resources/harness/java/harness-review/SKILL.md +0 -156
  115. package/resources/harness/java/harness-review/checklist.md +0 -124
  116. package/resources/harness/java/harness-review/protocols.md +0 -68
  117. package/resources/harness/java/harness-review/reference.md +0 -86
  118. package/resources/harness/java/harness-run/SKILL.md +0 -148
  119. package/resources/harness/java/harness-run/checklist.md +0 -250
  120. package/resources/harness/java/harness-run/protocols.md +0 -78
  121. package/resources/harness/java/harness-run/reference.md +0 -814
  122. package/resources/harness/java/harness-submit/SKILL.md +0 -166
  123. package/resources/harness/java/harness-submit/checklist.md +0 -407
  124. package/resources/harness/java/harness-submit/reference.md +0 -152
  125. package/resources/harness/java/harness-sync/SKILL.md +0 -82
  126. package/resources/harness/java/harness-sync/reference.md +0 -153
  127. package/resources/harness/java/harness-test/SKILL.md +0 -192
  128. package/resources/harness/java/harness-test/checklist.md +0 -317
  129. package/resources/harness/java/harness-test/pitfalls-java.md +0 -16
  130. package/resources/harness/java/harness-test/pitfalls.md +0 -260
  131. package/resources/harness/java/harness-test/reference.md +0 -788
  132. package/resources/harness/java/protocols/archive-report-protocol.md +0 -175
  133. package/resources/harness/java/protocols/evidence-based-reporting-protocol.md +0 -134
  134. package/resources/harness/java/protocols/ledger-protocol.md +0 -270
  135. package/resources/harness/java/protocols/powershell-protocol.md +0 -181
  136. package/resources/harness/java/protocols/report-pipeline-protocol.md +0 -150
  137. package/resources/harness/java/protocols/sensitive-info-protocol.md +0 -92
  138. package/resources/harness/java/protocols/state-layout-protocol.md +0 -131
  139. package/resources/harness/java/protocols/submit-protocol.md +0 -78
  140. package/resources/harness/java/scripts/harness_archive.py +0 -2319
  141. package/resources/harness/java/scripts/harness_deploy.py +0 -613
  142. package/resources/harness/java/scripts/harness_events.py +0 -680
  143. package/resources/harness/java/scripts/harness_ledger.py +0 -671
  144. package/resources/harness/java/scripts/harness_preflight.py +0 -780
  145. package/resources/harness/java/scripts/harness_service.py +0 -1331
  146. package/resources/harness/manifests/general.json +0 -276
  147. package/resources/harness/manifests/java.json +0 -304
  148. package/resources/harness/migrations/0.1.1/general.json +0 -358
  149. package/resources/harness/migrations/0.1.1/java.json +0 -398
@@ -1,680 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Harness events.ndjson writer and execution-log renderer (D2).
3
-
4
- Subcommands:
5
- append — append one schema_version 2 event, then auto-render execution-log.md
6
- render — full re-render of logs/execution-log.md from events.ndjson
7
- summary — phase durations, event counts, and issue list (JSON)
8
-
9
- Python 3.10+, stdlib only. UTF-8 without BOM. Windows path safe.
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import argparse
15
- import contextlib
16
- import datetime as dt
17
- import json
18
- import os
19
- import sys
20
- import tempfile
21
- import time
22
- import uuid
23
- from pathlib import Path
24
- from typing import Any
25
-
26
-
27
- if hasattr(sys.stdout, "reconfigure"):
28
- sys.stdout.reconfigure(encoding="utf-8")
29
- if hasattr(sys.stderr, "reconfigure"):
30
- sys.stderr.reconfigure(encoding="utf-8")
31
-
32
-
33
- EVENT_TYPES = frozenset(
34
- {
35
- "phase.start",
36
- "phase.end",
37
- "command",
38
- "verification",
39
- "artifact",
40
- "issue",
41
- "decision",
42
- }
43
- )
44
-
45
- SCHEMA_VERSION = 2
46
- HEADER_LINE = (
47
- "本文件由 harness_events.py 自动渲染,请勿手工编辑;事实源为 events.ndjson"
48
- )
49
-
50
- # Optional fields accepted on append; only non-None values are written.
51
- OPTIONAL_FIELDS = (
52
- "command",
53
- "exit_code",
54
- "duration_ms",
55
- "note",
56
- "name",
57
- "status",
58
- "path",
59
- "kind",
60
- "code",
61
- "severity",
62
- "message",
63
- "decision",
64
- "reason",
65
- )
66
-
67
-
68
- def now_iso() -> str:
69
- return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
70
-
71
-
72
- def emit_json(payload: dict[str, Any], *, as_json: bool) -> None:
73
- if as_json:
74
- sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
75
- else:
76
- ok = payload.get("ok", True)
77
- msg = payload.get("message") or payload.get("path") or ("ok" if ok else "error")
78
- sys.stdout.write(f"{msg}\n")
79
-
80
-
81
- def emit_error(message: str, *, as_json: bool, code: int = 1) -> int:
82
- payload = {"ok": False, "error": message}
83
- if as_json:
84
- sys.stderr.write(json.dumps(payload, ensure_ascii=False) + "\n")
85
- else:
86
- sys.stderr.write(f"error: {message}\n")
87
- return code
88
-
89
-
90
- def resolve_change_dir(raw: str) -> Path:
91
- return Path(raw).expanduser().resolve()
92
-
93
-
94
- def events_path(change_dir: Path) -> Path:
95
- return change_dir / "events.ndjson"
96
-
97
-
98
- def execution_log_path(change_dir: Path) -> Path:
99
- return change_dir / "logs" / "execution-log.md"
100
-
101
-
102
- def parse_timestamp(value: Any) -> dt.datetime | None:
103
- if not isinstance(value, str) or not value.strip():
104
- return None
105
- text = value.strip()
106
- if text.endswith("Z"):
107
- text = text[:-1] + "+00:00"
108
- try:
109
- return dt.datetime.fromisoformat(text)
110
- except ValueError:
111
- return None
112
-
113
-
114
- def duration_ms_between(start: Any, end: Any) -> int | None:
115
- start_dt = parse_timestamp(start)
116
- end_dt = parse_timestamp(end)
117
- if start_dt is None or end_dt is None:
118
- return None
119
- return max(0, int((end_dt - start_dt).total_seconds() * 1000))
120
-
121
-
122
- def normalize_event(raw: dict[str, Any]) -> dict[str, Any]:
123
- """Normalize schema_version 1/2 events for rendering and summary."""
124
- event = dict(raw)
125
- version = event.get("schema_version", 1)
126
- try:
127
- version_int = int(version)
128
- except (TypeError, ValueError):
129
- version_int = 1
130
- event["schema_version"] = version_int
131
- if "note" not in event or event["note"] is None:
132
- event["note"] = ""
133
- return event
134
-
135
-
136
- def load_events(path: Path) -> list[dict[str, Any]]:
137
- if not path.exists():
138
- return []
139
- events: list[dict[str, Any]] = []
140
- text = path.read_text(encoding="utf-8-sig")
141
- for line_no, line in enumerate(text.splitlines(), start=1):
142
- stripped = line.strip()
143
- if not stripped:
144
- continue
145
- try:
146
- obj = json.loads(stripped)
147
- except json.JSONDecodeError as exc:
148
- raise ValueError(f"invalid JSON at {path} line {line_no}: {exc}") from exc
149
- if not isinstance(obj, dict):
150
- raise ValueError(f"event at {path} line {line_no} is not an object")
151
- events.append(normalize_event(obj))
152
- return events
153
-
154
-
155
- def atomic_append_line(path: Path, line: str) -> None:
156
- """Write line to a temp file first, then append to the target (append-only)."""
157
- path.parent.mkdir(parents=True, exist_ok=True)
158
- payload = line if line.endswith("\n") else line + "\n"
159
- # Ensure UTF-8 without BOM for the temp payload.
160
- data = payload.encode("utf-8")
161
- fd, tmp_name = tempfile.mkstemp(
162
- prefix=f".{path.name}.",
163
- suffix=".tmp",
164
- dir=str(path.parent),
165
- )
166
- tmp_path = Path(tmp_name)
167
- try:
168
- with os.fdopen(fd, "wb") as tmp_f:
169
- tmp_f.write(data)
170
- tmp_f.flush()
171
- os.fsync(tmp_f.fileno())
172
- with path.open("ab") as out_f:
173
- out_f.write(data)
174
- out_f.flush()
175
- os.fsync(out_f.fileno())
176
- finally:
177
- try:
178
- tmp_path.unlink(missing_ok=True)
179
- except OSError:
180
- pass
181
-
182
-
183
- @contextlib.contextmanager
184
- def event_file_lock(lock_path: Path, timeout_seconds: float = 10.0):
185
- """Acquire a cross-process exclusive lock or raise TimeoutError.
186
-
187
- §6.2: Windows uses msvcrt.locking; POSIX uses fcntl.flock. The lock file is
188
- ``<change-dir>/events.ndjson.lock``. On timeout the caller must fail non-zero
189
- -- never continue without a lock. ``finally`` always unlocks and closes.
190
- """
191
- lock_path.parent.mkdir(parents=True, exist_ok=True)
192
- # Ensure at least 1 byte exists; msvcrt.locking on an empty file can misbehave
193
- # on some Windows versions.
194
- if not lock_path.exists() or lock_path.stat().st_size == 0:
195
- with open(lock_path, "ab") as seed_f:
196
- seed_f.write(b"\0")
197
- handle = open(lock_path, "r+b")
198
- try:
199
- handle.seek(0)
200
- _acquire_file_lock(handle, lock_path, timeout_seconds)
201
- try:
202
- yield
203
- finally:
204
- _release_file_lock(handle)
205
- finally:
206
- handle.close()
207
-
208
-
209
- def _acquire_file_lock(handle, lock_path: Path, timeout_seconds: float) -> None:
210
- deadline = time.monotonic() + timeout_seconds
211
- if os.name == "nt":
212
- import msvcrt
213
-
214
- while True:
215
- try:
216
- msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
217
- return
218
- except OSError:
219
- if time.monotonic() >= deadline:
220
- raise TimeoutError(
221
- f"timeout acquiring event lock {lock_path} after {timeout_seconds}s"
222
- )
223
- time.sleep(0.02)
224
- else:
225
- import fcntl
226
-
227
- while True:
228
- try:
229
- fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
230
- return
231
- except OSError:
232
- if time.monotonic() >= deadline:
233
- raise TimeoutError(
234
- f"timeout acquiring event lock {lock_path} after {timeout_seconds}s"
235
- )
236
- time.sleep(0.02)
237
-
238
-
239
- def _release_file_lock(handle) -> None:
240
- try:
241
- if os.name == "nt":
242
- import msvcrt
243
-
244
- handle.seek(0)
245
- msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
246
- else:
247
- import fcntl
248
-
249
- fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
250
- except OSError:
251
- pass
252
-
253
-
254
- def new_event_id(existing: list[dict[str, Any]] | None = None) -> str:
255
- """Return a full-entropy event id.
256
-
257
- §6.2: UUID 不需要扫描历史去重;直接使用完整 ``uuid.uuid4().hex``。
258
- ``existing`` is accepted for backward-compat callers but intentionally unused.
259
- """
260
- return f"evt-{uuid.uuid4().hex}"
261
-
262
-
263
- def build_event(args: argparse.Namespace, existing: list[dict[str, Any]]) -> dict[str, Any]:
264
- event: dict[str, Any] = {
265
- "schema_version": SCHEMA_VERSION,
266
- "id": new_event_id(existing),
267
- "timestamp": now_iso(),
268
- "phase": args.phase,
269
- "type": args.type,
270
- }
271
- for field in OPTIONAL_FIELDS:
272
- value = getattr(args, field, None)
273
- if value is None:
274
- continue
275
- event[field] = value
276
- if "note" not in event:
277
- event["note"] = ""
278
- return event
279
-
280
-
281
- def status_symbol(status: Any, reason: Any = None) -> str:
282
- text = str(status or "").strip().lower()
283
- reason_text = str(reason or "").strip()
284
- ok_set = {"ok", "passed", "pass", "success", "green", "✅", "✅ok"}
285
- warn_set = {"warn", "warning", "yellow", "skipped", "skip", "🟡", "🟡warn"}
286
- fail_set = {"fail", "failed", "error", "red", "blocked", "❌", "❌fail"}
287
- if text in ok_set or text.startswith("ok") or "✅" in text:
288
- return "✅OK"
289
- if text in warn_set or "warn" in text or "🟡" in text:
290
- return f"🟡WARN({reason_text})" if reason_text else "🟡WARN"
291
- if text in fail_set or "fail" in text or "error" in text or "❌" in text:
292
- return f"❌FAIL({reason_text})" if reason_text else "❌FAIL"
293
- if reason_text:
294
- return f"{status}({reason_text})"
295
- return str(status or "unknown")
296
-
297
-
298
- def severity_symbol(severity: Any, message: Any = None) -> str:
299
- text = str(severity or "").strip().lower()
300
- msg = str(message or "").strip()
301
- if text in {"error", "fail", "failed", "critical"}:
302
- return f"❌FAIL({msg})" if msg else "❌FAIL"
303
- if text in {"warn", "warning"}:
304
- return f"🟡WARN({msg})" if msg else "🟡WARN"
305
- if text in {"info", "ok", "note"}:
306
- return f"✅OK({msg})" if msg else "✅OK"
307
- return f"{severity}: {msg}" if msg else str(severity or "issue")
308
-
309
-
310
- def format_duration(ms: int | None) -> str:
311
- if ms is None:
312
- return "—"
313
- if ms < 1000:
314
- return f"{ms}ms"
315
- seconds = ms / 1000
316
- if seconds < 60:
317
- return f"{seconds:.1f}s"
318
- minutes = int(seconds // 60)
319
- rem = seconds - minutes * 60
320
- return f"{minutes}m{rem:04.1f}s"
321
-
322
-
323
- def group_events_by_phase(events: list[dict[str, Any]]) -> list[tuple[str, list[dict[str, Any]]]]:
324
- """Preserve first-seen phase order; keep events in file order within each phase."""
325
- order: list[str] = []
326
- buckets: dict[str, list[dict[str, Any]]] = {}
327
- for event in events:
328
- phase = str(event.get("phase") or "unknown")
329
- if phase not in buckets:
330
- order.append(phase)
331
- buckets[phase] = []
332
- buckets[phase].append(event)
333
- return [(phase, buckets[phase]) for phase in order]
334
-
335
-
336
- def phase_start_time(phase_events: list[dict[str, Any]]) -> str:
337
- for event in phase_events:
338
- if event.get("type") == "phase.start":
339
- return str(event.get("timestamp") or "")
340
- if phase_events:
341
- return str(phase_events[0].get("timestamp") or "")
342
- return ""
343
-
344
-
345
- def phase_duration_ms(phase_events: list[dict[str, Any]]) -> int | None:
346
- start_ts = None
347
- end_ts = None
348
- for event in phase_events:
349
- etype = event.get("type")
350
- if etype == "phase.start" and start_ts is None:
351
- start_ts = event.get("timestamp")
352
- elif etype == "phase.end":
353
- end_ts = event.get("timestamp")
354
- if start_ts and end_ts:
355
- return duration_ms_between(start_ts, end_ts)
356
- # Fallback: first to last timestamp in the phase bucket.
357
- stamps = [e.get("timestamp") for e in phase_events if e.get("timestamp")]
358
- if len(stamps) >= 2:
359
- return duration_ms_between(stamps[0], stamps[-1])
360
- return None
361
-
362
-
363
- def render_command_block(commands: list[dict[str, Any]]) -> list[str]:
364
- if not commands:
365
- return []
366
- if len(commands) == 1:
367
- event = commands[0]
368
- cmd = event.get("command") or ""
369
- exit_code = event.get("exit_code")
370
- exit_text = "?" if exit_code is None else str(exit_code)
371
- duration = format_duration(
372
- int(event["duration_ms"]) if isinstance(event.get("duration_ms"), int) else None
373
- )
374
- note = str(event.get("note") or "").strip()
375
- parts = [f"- command: `{cmd}`", f"exit={exit_text}", f"duration={duration}"]
376
- if note:
377
- parts.append(f"note={note}")
378
- return [" · ".join(parts)]
379
-
380
- lines = [
381
- "",
382
- "| 命令 | exit | duration | note |",
383
- "| --- | ---: | ---: | --- |",
384
- ]
385
- for event in commands:
386
- cmd = str(event.get("command") or "").replace("|", "\\|")
387
- exit_code = event.get("exit_code")
388
- exit_text = "?" if exit_code is None else str(exit_code)
389
- duration = format_duration(
390
- int(event["duration_ms"]) if isinstance(event.get("duration_ms"), int) else None
391
- )
392
- note = str(event.get("note") or "").replace("|", "\\|")
393
- lines.append(f"| `{cmd}` | {exit_text} | {duration} | {note} |")
394
- lines.append("")
395
- return lines
396
-
397
-
398
- def render_event_line(event: dict[str, Any]) -> list[str]:
399
- etype = event.get("type")
400
- if etype == "phase.start":
401
- note = str(event.get("note") or "").strip()
402
- suffix = f" — {note}" if note else ""
403
- return [f"- phase.start @ {event.get('timestamp', '')}{suffix}"]
404
- if etype == "phase.end":
405
- note = str(event.get("note") or "").strip()
406
- suffix = f" — {note}" if note else ""
407
- return [f"- phase.end @ {event.get('timestamp', '')}{suffix}"]
408
- if etype == "verification":
409
- name = event.get("name") or "(unnamed)"
410
- symbol = status_symbol(event.get("status"), event.get("reason") or event.get("note"))
411
- return [f"- verification: {name} → {symbol}"]
412
- if etype == "decision":
413
- decision = event.get("decision") or ""
414
- reason = event.get("reason") or event.get("note") or ""
415
- if reason:
416
- return [f"- decision: {decision} — {reason}"]
417
- return [f"- decision: {decision}"]
418
- if etype == "issue":
419
- symbol = severity_symbol(event.get("severity"), event.get("message"))
420
- code = event.get("code")
421
- prefix = f"[{code}] " if code else ""
422
- return [f"- issue: {prefix}{symbol}"]
423
- if etype == "artifact":
424
- path = event.get("path") or ""
425
- kind = event.get("kind")
426
- if kind:
427
- return [f"- artifact: `{path}` ({kind})"]
428
- return [f"- artifact: `{path}`"]
429
- if etype == "command":
430
- return render_command_block([event])
431
- # Unknown types: skip from human log to keep size down.
432
- return []
433
-
434
-
435
- def render_execution_log(events: list[dict[str, Any]]) -> str:
436
- lines: list[str] = [
437
- f"> [!warning] {HEADER_LINE}",
438
- "",
439
- "# Execution Log",
440
- "",
441
- ]
442
- if not events:
443
- lines.append("_(暂无事件)_")
444
- lines.append("")
445
- return "\n".join(lines)
446
-
447
- for phase, phase_events in group_events_by_phase(events):
448
- start = phase_start_time(phase_events) or "—"
449
- duration = phase_duration_ms(phase_events)
450
- lines.append(f"## {phase} — {start}")
451
- lines.append("")
452
- if duration is not None:
453
- lines.append(f"- 阶段耗时: {format_duration(duration)}")
454
- # Collapse consecutive commands into one table.
455
- buffer: list[dict[str, Any]] = []
456
-
457
- def flush_commands() -> None:
458
- nonlocal buffer
459
- if buffer:
460
- lines.extend(render_command_block(buffer))
461
- buffer = []
462
-
463
- for event in phase_events:
464
- if event.get("type") == "command":
465
- buffer.append(event)
466
- continue
467
- flush_commands()
468
- lines.extend(render_event_line(event))
469
- flush_commands()
470
- lines.append("")
471
-
472
- return "\n".join(lines).rstrip() + "\n"
473
-
474
-
475
- def write_execution_log(change_dir: Path, content: str) -> Path:
476
- path = execution_log_path(change_dir)
477
- path.parent.mkdir(parents=True, exist_ok=True)
478
- # UTF-8 without BOM
479
- path.write_text(content, encoding="utf-8", newline="\n")
480
- return path
481
-
482
-
483
- def build_summary(change_dir: Path, events: list[dict[str, Any]]) -> dict[str, Any]:
484
- phases: dict[str, Any] = {}
485
- issues: list[dict[str, Any]] = []
486
-
487
- for phase, phase_events in group_events_by_phase(events):
488
- start_ts = None
489
- end_ts = None
490
- for event in phase_events:
491
- if event.get("type") == "phase.start" and start_ts is None:
492
- start_ts = event.get("timestamp")
493
- elif event.get("type") == "phase.end":
494
- end_ts = event.get("timestamp")
495
- duration = None
496
- if start_ts and end_ts:
497
- duration = duration_ms_between(start_ts, end_ts)
498
- phases[phase] = {
499
- "event_count": len(phase_events),
500
- "started_at": start_ts,
501
- "ended_at": end_ts,
502
- "duration_ms": duration,
503
- }
504
-
505
- for event in events:
506
- if event.get("type") != "issue":
507
- continue
508
- issues.append(
509
- {
510
- "id": event.get("id"),
511
- "timestamp": event.get("timestamp"),
512
- "phase": event.get("phase"),
513
- "code": event.get("code"),
514
- "severity": event.get("severity"),
515
- "message": event.get("message"),
516
- }
517
- )
518
-
519
- return {
520
- "ok": True,
521
- "change_dir": str(change_dir),
522
- "event_count": len(events),
523
- "phases": phases,
524
- "issues": issues,
525
- }
526
-
527
-
528
- def cmd_append(args: argparse.Namespace) -> int:
529
- as_json = bool(args.json)
530
- if args.type not in EVENT_TYPES:
531
- return emit_error(
532
- f"unsupported type: {args.type}; expected one of {sorted(EVENT_TYPES)}",
533
- as_json=as_json,
534
- )
535
- change_dir = resolve_change_dir(args.change_dir)
536
- path = events_path(change_dir)
537
- lock_path = change_dir / "events.ndjson.lock"
538
-
539
- # §6.1/§6.2: 普通 append = 加锁 -> 追加一行 -> fsync -> 解锁,不 load 历史、不渲染。
540
- # new_event_id 用完整 uuid,无需扫描去重。锁覆盖 atomic_append_line 的
541
- # open/write/flush/fsync 全过程。
542
- event = build_event(args, [])
543
- line = json.dumps(event, ensure_ascii=False, separators=(",", ":"))
544
- try:
545
- with event_file_lock(lock_path):
546
- atomic_append_line(path, line)
547
- except (OSError, TimeoutError) as exc:
548
- return emit_error(f"append failed: {exc}", as_json=as_json)
549
-
550
- # §6.1: phase.end append -> 追加成功后执行一次 render(从完整 events 重建 log)。
551
- # 普通 command/issue 等 append 不渲染(O(1));显式 `render` 子命令随时重建。
552
- rendered = False
553
- log_path = None
554
- log_lines = None
555
- if args.type == "phase.end":
556
- try:
557
- events = load_events(path)
558
- content = render_execution_log(events)
559
- log_path = write_execution_log(change_dir, content)
560
- log_lines = len(content.splitlines())
561
- rendered = True
562
- except (OSError, ValueError) as exc:
563
- return emit_error(f"phase.end render failed: {exc}", as_json=as_json)
564
-
565
- payload: dict[str, Any] = {
566
- "ok": True,
567
- "action": "append",
568
- "event": event,
569
- "events_path": str(path),
570
- "rendered": rendered,
571
- }
572
- if log_path is not None:
573
- payload["execution_log_path"] = str(log_path)
574
- payload["execution_log_lines"] = log_lines
575
- emit_json(payload, as_json=as_json)
576
- return 0
577
-
578
-
579
- def cmd_render(args: argparse.Namespace) -> int:
580
- as_json = bool(args.json)
581
- change_dir = resolve_change_dir(args.change_dir)
582
- path = events_path(change_dir)
583
- try:
584
- events = load_events(path)
585
- content = render_execution_log(events)
586
- log_path = write_execution_log(change_dir, content)
587
- except (OSError, ValueError) as exc:
588
- return emit_error(f"render failed: {exc}", as_json=as_json)
589
-
590
- payload = {
591
- "ok": True,
592
- "action": "render",
593
- "events_path": str(path),
594
- "execution_log_path": str(log_path),
595
- "event_count": len(events),
596
- "execution_log_lines": len(content.splitlines()),
597
- }
598
- emit_json(payload, as_json=as_json)
599
- return 0
600
-
601
-
602
- def cmd_summary(args: argparse.Namespace) -> int:
603
- # Structured summary is always JSON (task card: summary --json).
604
- change_dir = resolve_change_dir(args.change_dir)
605
- path = events_path(change_dir)
606
- try:
607
- events = load_events(path)
608
- payload = build_summary(change_dir, events)
609
- except (OSError, ValueError) as exc:
610
- return emit_error(f"summary failed: {exc}", as_json=True)
611
-
612
- emit_json(payload, as_json=True)
613
- return 0
614
-
615
-
616
- def build_parser() -> argparse.ArgumentParser:
617
- common = argparse.ArgumentParser(add_help=False)
618
- common.add_argument(
619
- "--json",
620
- action="store_true",
621
- help="emit machine-readable JSON on stdout",
622
- )
623
-
624
- parser = argparse.ArgumentParser(
625
- prog="harness_events.py",
626
- description="Append/render/summarize harness change events.ndjson",
627
- parents=[common],
628
- )
629
- sub = parser.add_subparsers(dest="command_name", required=True)
630
-
631
- p_append = sub.add_parser(
632
- "append",
633
- parents=[common],
634
- help="append one event and auto-render execution-log",
635
- )
636
- p_append.add_argument("--change-dir", required=True)
637
- p_append.add_argument("--phase", required=True)
638
- p_append.add_argument("--type", required=True)
639
- p_append.add_argument("--command", default=None)
640
- p_append.add_argument("--exit-code", type=int, default=None)
641
- p_append.add_argument("--duration-ms", type=int, default=None)
642
- p_append.add_argument("--note", default=None)
643
- p_append.add_argument("--name", default=None)
644
- p_append.add_argument("--status", default=None)
645
- p_append.add_argument("--path", default=None)
646
- p_append.add_argument("--kind", default=None)
647
- p_append.add_argument("--code", default=None)
648
- p_append.add_argument("--severity", default=None)
649
- p_append.add_argument("--message", default=None)
650
- p_append.add_argument("--decision", default=None)
651
- p_append.add_argument("--reason", default=None)
652
- p_append.set_defaults(func=cmd_append)
653
-
654
- p_render = sub.add_parser(
655
- "render",
656
- parents=[common],
657
- help="re-render execution-log.md from events.ndjson",
658
- )
659
- p_render.add_argument("--change-dir", required=True)
660
- p_render.set_defaults(func=cmd_render)
661
-
662
- p_summary = sub.add_parser(
663
- "summary",
664
- parents=[common],
665
- help="summarize phases/issues from events.ndjson",
666
- )
667
- p_summary.add_argument("--change-dir", required=True)
668
- p_summary.set_defaults(func=cmd_summary)
669
-
670
- return parser
671
-
672
-
673
- def main(argv: list[str] | None = None) -> int:
674
- parser = build_parser()
675
- args = parser.parse_args(argv)
676
- return int(args.func(args))
677
-
678
-
679
- if __name__ == "__main__":
680
- raise SystemExit(main())