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.
- package/LICENSE +21 -21
- package/README.md +11 -0
- package/dist/bin.js +1540 -491
- package/package.json +9 -7
- package/resources/harness/general/.harness-build.json +0 -5
- package/resources/harness/general/CONTEXT.md +0 -65
- package/resources/harness/general/README.md +0 -478
- package/resources/harness/general/agents/harness-evaluator.md +0 -96
- package/resources/harness/general/agents/harness-explorer.md +0 -72
- package/resources/harness/general/agents/harness-reviewer.md +0 -69
- package/resources/harness/general/harness-archive/SKILL.md +0 -215
- package/resources/harness/general/harness-archive/checklist.md +0 -52
- package/resources/harness/general/harness-archive/reference.md +0 -103
- package/resources/harness/general/harness-archive/scripts/gen-manifest.ps1 +0 -31
- package/resources/harness/general/harness-archive/templates/render-summary.mjs +0 -171
- package/resources/harness/general/harness-archive/templates/summary-data-template.json +0 -128
- package/resources/harness/general/harness-codebase-map/SKILL.md +0 -112
- package/resources/harness/general/harness-codebase-map/checklist.md +0 -126
- package/resources/harness/general/harness-codebase-map/reference.md +0 -364
- package/resources/harness/general/harness-codebase-map/templates/ARCHITECTURE.md +0 -57
- package/resources/harness/general/harness-codebase-map/templates/CONCERNS.md +0 -49
- package/resources/harness/general/harness-codebase-map/templates/CONVENTIONS.md +0 -57
- package/resources/harness/general/harness-codebase-map/templates/INTEGRATIONS.md +0 -52
- package/resources/harness/general/harness-codebase-map/templates/STACK.md +0 -55
- package/resources/harness/general/harness-codebase-map/templates/STRUCTURE.md +0 -69
- package/resources/harness/general/harness-codebase-map/templates/TESTING.md +0 -64
- package/resources/harness/general/harness-codebase-map/templates/map-manifest.schema.json +0 -73
- package/resources/harness/general/harness-codebase-map/templates/map-summary.md +0 -64
- package/resources/harness/general/harness-knowledge-ingest/SKILL.md +0 -246
- package/resources/harness/general/harness-knowledge-ingest/design.md +0 -842
- package/resources/harness/general/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
- package/resources/harness/general/harness-knowledge-ingest/mcp-config.example.json +0 -10
- package/resources/harness/general/harness-knowledge-ingest/reference.md +0 -309
- package/resources/harness/general/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -4082
- package/resources/harness/general/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -273
- package/resources/harness/general/harness-knowledge-query/SKILL.md +0 -164
- package/resources/harness/general/harness-plan/SKILL.md +0 -127
- package/resources/harness/general/harness-plan/checklist.md +0 -243
- package/resources/harness/general/harness-plan/protocols.md +0 -124
- package/resources/harness/general/harness-plan/reference.md +0 -376
- package/resources/harness/general/harness-review/SKILL.md +0 -156
- package/resources/harness/general/harness-review/checklist.md +0 -124
- package/resources/harness/general/harness-review/protocols.md +0 -68
- package/resources/harness/general/harness-review/reference.md +0 -86
- package/resources/harness/general/harness-run/SKILL.md +0 -132
- package/resources/harness/general/harness-run/checklist.md +0 -259
- package/resources/harness/general/harness-run/protocols.md +0 -78
- package/resources/harness/general/harness-run/reference.md +0 -834
- package/resources/harness/general/harness-submit/SKILL.md +0 -159
- package/resources/harness/general/harness-submit/checklist.md +0 -407
- package/resources/harness/general/harness-submit/reference.md +0 -152
- package/resources/harness/general/harness-sync/SKILL.md +0 -82
- package/resources/harness/general/harness-sync/reference.md +0 -153
- package/resources/harness/general/harness-test/SKILL.md +0 -180
- package/resources/harness/general/harness-test/checklist.md +0 -319
- package/resources/harness/general/harness-test/pitfalls.md +0 -260
- package/resources/harness/general/harness-test/reference.md +0 -791
- package/resources/harness/general/protocols/archive-report-protocol.md +0 -175
- package/resources/harness/general/protocols/evidence-based-reporting-protocol.md +0 -134
- package/resources/harness/general/protocols/ledger-protocol.md +0 -270
- package/resources/harness/general/protocols/powershell-protocol.md +0 -181
- package/resources/harness/general/protocols/report-pipeline-protocol.md +0 -150
- package/resources/harness/general/protocols/sensitive-info-protocol.md +0 -92
- package/resources/harness/general/protocols/state-layout-protocol.md +0 -131
- package/resources/harness/general/protocols/submit-protocol.md +0 -78
- package/resources/harness/general/scripts/harness_archive.py +0 -2319
- package/resources/harness/general/scripts/harness_deploy.py +0 -613
- package/resources/harness/general/scripts/harness_events.py +0 -680
- package/resources/harness/general/scripts/harness_ledger.py +0 -671
- package/resources/harness/general/scripts/harness_preflight.py +0 -780
- package/resources/harness/general/scripts/harness_service.py +0 -1331
- package/resources/harness/java/.harness-build.json +0 -5
- package/resources/harness/java/CONTEXT.md +0 -65
- package/resources/harness/java/README.md +0 -478
- package/resources/harness/java/agents/harness-evaluator.md +0 -96
- package/resources/harness/java/agents/harness-explorer.md +0 -72
- package/resources/harness/java/agents/harness-reviewer.md +0 -69
- package/resources/harness/java/harness-apidoc/SKILL.md +0 -86
- package/resources/harness/java/harness-apidoc/checklist.md +0 -142
- package/resources/harness/java/harness-apidoc/reference.md +0 -233
- package/resources/harness/java/harness-archive/SKILL.md +0 -215
- package/resources/harness/java/harness-archive/checklist.md +0 -52
- package/resources/harness/java/harness-archive/reference.md +0 -103
- package/resources/harness/java/harness-archive/scripts/gen-manifest.ps1 +0 -31
- package/resources/harness/java/harness-archive/templates/render-summary.mjs +0 -171
- package/resources/harness/java/harness-archive/templates/summary-data-template.json +0 -128
- package/resources/harness/java/harness-codebase-map/SKILL.md +0 -112
- package/resources/harness/java/harness-codebase-map/checklist.md +0 -126
- package/resources/harness/java/harness-codebase-map/reference.md +0 -364
- package/resources/harness/java/harness-codebase-map/templates/ARCHITECTURE.md +0 -57
- package/resources/harness/java/harness-codebase-map/templates/CONCERNS.md +0 -49
- package/resources/harness/java/harness-codebase-map/templates/CONVENTIONS.md +0 -57
- package/resources/harness/java/harness-codebase-map/templates/INTEGRATIONS.md +0 -52
- package/resources/harness/java/harness-codebase-map/templates/STACK.md +0 -55
- package/resources/harness/java/harness-codebase-map/templates/STRUCTURE.md +0 -69
- package/resources/harness/java/harness-codebase-map/templates/TESTING.md +0 -64
- package/resources/harness/java/harness-codebase-map/templates/map-manifest.schema.json +0 -73
- package/resources/harness/java/harness-codebase-map/templates/map-summary.md +0 -64
- package/resources/harness/java/harness-knowledge-ingest/SKILL.md +0 -246
- package/resources/harness/java/harness-knowledge-ingest/design.md +0 -842
- package/resources/harness/java/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
- package/resources/harness/java/harness-knowledge-ingest/mcp-config.example.json +0 -10
- package/resources/harness/java/harness-knowledge-ingest/reference.md +0 -309
- package/resources/harness/java/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -4082
- package/resources/harness/java/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -273
- package/resources/harness/java/harness-knowledge-query/SKILL.md +0 -164
- package/resources/harness/java/harness-package/SKILL.md +0 -87
- package/resources/harness/java/harness-package/checklist.md +0 -322
- package/resources/harness/java/harness-package/reference.md +0 -210
- package/resources/harness/java/harness-plan/SKILL.md +0 -127
- package/resources/harness/java/harness-plan/checklist.md +0 -243
- package/resources/harness/java/harness-plan/protocols.md +0 -124
- package/resources/harness/java/harness-plan/reference.md +0 -376
- package/resources/harness/java/harness-review/SKILL.md +0 -156
- package/resources/harness/java/harness-review/checklist.md +0 -124
- package/resources/harness/java/harness-review/protocols.md +0 -68
- package/resources/harness/java/harness-review/reference.md +0 -86
- package/resources/harness/java/harness-run/SKILL.md +0 -148
- package/resources/harness/java/harness-run/checklist.md +0 -250
- package/resources/harness/java/harness-run/protocols.md +0 -78
- package/resources/harness/java/harness-run/reference.md +0 -814
- package/resources/harness/java/harness-submit/SKILL.md +0 -166
- package/resources/harness/java/harness-submit/checklist.md +0 -407
- package/resources/harness/java/harness-submit/reference.md +0 -152
- package/resources/harness/java/harness-sync/SKILL.md +0 -82
- package/resources/harness/java/harness-sync/reference.md +0 -153
- package/resources/harness/java/harness-test/SKILL.md +0 -192
- package/resources/harness/java/harness-test/checklist.md +0 -317
- package/resources/harness/java/harness-test/pitfalls-java.md +0 -16
- package/resources/harness/java/harness-test/pitfalls.md +0 -260
- package/resources/harness/java/harness-test/reference.md +0 -788
- package/resources/harness/java/protocols/archive-report-protocol.md +0 -175
- package/resources/harness/java/protocols/evidence-based-reporting-protocol.md +0 -134
- package/resources/harness/java/protocols/ledger-protocol.md +0 -270
- package/resources/harness/java/protocols/powershell-protocol.md +0 -181
- package/resources/harness/java/protocols/report-pipeline-protocol.md +0 -150
- package/resources/harness/java/protocols/sensitive-info-protocol.md +0 -92
- package/resources/harness/java/protocols/state-layout-protocol.md +0 -131
- package/resources/harness/java/protocols/submit-protocol.md +0 -78
- package/resources/harness/java/scripts/harness_archive.py +0 -2319
- package/resources/harness/java/scripts/harness_deploy.py +0 -613
- package/resources/harness/java/scripts/harness_events.py +0 -680
- package/resources/harness/java/scripts/harness_ledger.py +0 -671
- package/resources/harness/java/scripts/harness_preflight.py +0 -780
- package/resources/harness/java/scripts/harness_service.py +0 -1331
- package/resources/harness/manifests/general.json +0 -276
- package/resources/harness/manifests/java.json +0 -304
- package/resources/harness/migrations/0.1.1/general.json +0 -358
- 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())
|