opencode-thrum-pro 0.1.5 → 0.1.6
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/assets/commands/thrum-_compact-runtime-commands.md +61 -0
- package/assets/commands/thrum-_snapshot-protocol.md +17 -6
- package/assets/commands/thrum-_stamp-protocol.md +9 -2
- package/assets/commands/thrum-compact-extended.md +352 -0
- package/assets/commands/thrum-compact.md +300 -0
- package/assets/commands/thrum-inbox.md +1 -1
- package/assets/commands/thrum-restart-extended.md +84 -11
- package/assets/commands/thrum-restart.md +105 -13
- package/assets/commands/thrum-send.md +4 -1
- package/assets/commands/thrum-sleep-extended.md +67 -46
- package/assets/commands/thrum-sleep.md +67 -58
- package/assets/skills/adversarial-critique/SKILL.md +5 -8
- package/assets/skills/brainstorm-steward/SKILL.md +65 -11
- package/assets/skills/brainstormer-staying-current/SKILL.md +1 -4
- package/assets/skills/choosing-subagent-models/SKILL.md +14 -0
- package/assets/skills/conversation-only-context/SKILL.md +11 -11
- package/assets/skills/conversation-only-context/resources/driver-subagent-pattern.md +1 -1
- package/assets/skills/conversation-only-context/resources/remote-environment-scenarios.md +1 -1
- package/assets/skills/coordinator-assessing-agent-completion/SKILL.md +98 -6
- package/assets/skills/coordinator-context-monitoring/SKILL.md +29 -19
- package/assets/skills/coordinator-deploying-a-node/SKILL.md +124 -0
- package/assets/skills/coordinator-dispatching-work/SKILL.md +24 -11
- package/assets/skills/coordinator-hotpath-merge-gate/SKILL.md +62 -20
- package/assets/skills/coordinator-managing-state-and-lifecycle/SKILL.md +6 -6
- package/assets/skills/coordinator-merging-code/SKILL.md +25 -11
- package/assets/skills/coordinator-philosophy-merge-gate/SKILL.md +28 -7
- package/assets/skills/coordinator-plan-reconcile-gate/SKILL.md +3 -3
- package/assets/skills/coordinator-rolling-out-to-nodes/SKILL.md +92 -0
- package/assets/skills/coordinator-running-autonomous-feature-development/SKILL.md +3 -3
- package/assets/skills/coordinator-running-brainstorm-cycles/SKILL.md +21 -20
- package/assets/skills/coordinator-running-review-cycles/SKILL.md +15 -4
- package/assets/skills/diagnosing-fleet-and-daemon/SKILL.md +0 -2
- package/assets/skills/efficient-multi-agent-research/SKILL.md +3 -3
- package/assets/skills/implementer-orchestrating-execution/SKILL.md +11 -14
- package/assets/skills/orchestrate/SKILL.md +47 -35
- package/assets/skills/persistent-watcher-archetype/SKILL.md +172 -25
- package/assets/skills/persistent-watcher-archetype/resources/capture_archive.py +618 -0
- package/assets/skills/persistent-watcher-archetype/resources/jev_watcher_filter.py +837 -0
- package/assets/skills/persistent-watcher-archetype/resources/roster-watch.sh +1140 -0
- package/assets/skills/persistent-watcher-archetype/resources/roster-watch_test.sh +291 -0
- package/assets/skills/persistent-watcher-archetype/resources/thrum-capture-fallback.sh +2 -2
- package/assets/skills/persistent-watcher-archetype/resources/thrum-watch-pane-capture.sh +20 -8
- package/assets/skills/prime-agent/SKILL.md +10 -0
- package/assets/skills/project-setup/SKILL.md +22 -16
- package/assets/skills/project-setup/resources/implementation-agent.md +56 -35
- package/assets/skills/researcher-investigating/SKILL.md +2 -5
- package/assets/skills/thrum/SKILL.md +14 -7
- package/assets/skills/thrum/resources/ANTI_PATTERNS.md +16 -18
- package/assets/skills/thrum/resources/CLI_REFERENCE.md +17 -11
- package/assets/skills/thrum/resources/LISTENER_PATTERN.md +9 -3
- package/assets/skills/thrum/resources/MESSAGING.md +18 -11
- package/assets/skills/thrum/resources/WORKTREES.md +1 -1
- package/assets/skills/thrum-memory/SKILL.md +1 -1
- package/assets/skills/trace-spec-to-plan/resources/sweep-agent-prompt.md +1 -1
- package/assets/skills/update-queue/SKILL.md +6 -8
- package/assets/skills/using-the-dashboard/SKILL.md +30 -0
- package/assets/skills/using-the-queue/SKILL.md +76 -5
- package/assets/skills/using-thrum-state/SKILL.md +83 -0
- package/assets/skills/verify-against-plan/SKILL.md +120 -5
- package/assets/skills/watcher-archetype/SKILL.md +18 -0
- package/assets/skills/writing-durable-markdown/SKILL.md +5 -0
- package/dist/installer.js +4 -4
- package/dist/permission-fixture.integration.test.js +4 -4
- package/package.json +1 -1
- package/assets/skills/coordinator-deploying-the-fleet/SKILL.md +0 -396
- package/dist/allowlist-sync.test.d.ts +0 -1
- package/dist/allowlist-sync.test.js +0 -44
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Archive roster watcher captures as private, byte-preserving records."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import stat
|
|
12
|
+
import sys
|
|
13
|
+
import uuid
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Iterable
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
SCHEMA_VERSION = "roster-capture-archive-v2"
|
|
20
|
+
TRAINING_VIEW_SCHEMA_VERSION = "roster-capture-training-view-v2"
|
|
21
|
+
HEADER_RE = re.compile(r"^=== ([^=\n]+) ===\s*$")
|
|
22
|
+
FILENAME_TS_RE = re.compile(r"roster-capture-(\d{8}-\d{6})(?:-(\d{6}))?\.txt$")
|
|
23
|
+
DEFAULT_REQUESTED_LINES = 30
|
|
24
|
+
LINE_SEMANTICS = (
|
|
25
|
+
"tmux capture text from existing watcher behavior and joined logical lines; direct records preserve "
|
|
26
|
+
"raw bytes, legacy assembled import preserves full source bytes but only "
|
|
27
|
+
"derives per-agent text from column-1 delimiters"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ArchiveError(RuntimeError):
|
|
32
|
+
"""Archive operation failed after preserving whatever source bytes existed."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ArchiveWriteConflictError(ArchiveError):
|
|
36
|
+
"""An immutable archive path already exists with different content."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AmbiguousHeaderError(ArchiveError):
|
|
40
|
+
"""A legacy assembled import saw a roster-looking header it cannot trust."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class RosterSection:
|
|
45
|
+
index: int
|
|
46
|
+
agent: str
|
|
47
|
+
line_start: int
|
|
48
|
+
line_end: int
|
|
49
|
+
raw_response: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ArchiveWrite:
|
|
54
|
+
path: Path
|
|
55
|
+
status: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def sha256_bytes(data: bytes) -> str:
|
|
59
|
+
return hashlib.sha256(data).hexdigest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def sha256_text(text: str) -> str:
|
|
63
|
+
return sha256_bytes(text.encode("utf-8"))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def stable_json(value: Any) -> str:
|
|
67
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def chmod_private_dir(path: Path) -> None:
|
|
71
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
os.chmod(path, stat.S_IRWXU)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def fsync_dir(path: Path) -> None:
|
|
76
|
+
try:
|
|
77
|
+
fd = os.open(path, os.O_RDONLY)
|
|
78
|
+
except OSError:
|
|
79
|
+
return
|
|
80
|
+
try:
|
|
81
|
+
os.fsync(fd)
|
|
82
|
+
except OSError:
|
|
83
|
+
pass
|
|
84
|
+
finally:
|
|
85
|
+
os.close(fd)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def private_subdir(root: Path, *parts: str) -> Path:
|
|
89
|
+
path = root.joinpath(*parts)
|
|
90
|
+
chmod_private_dir(path)
|
|
91
|
+
return path
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def write_idempotent_bytes(path: Path, data: bytes) -> ArchiveWrite:
|
|
95
|
+
"""Create a private file, or accept an exact same-content retry."""
|
|
96
|
+
|
|
97
|
+
chmod_private_dir(path.parent)
|
|
98
|
+
tmp = path.parent / f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp"
|
|
99
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
100
|
+
try:
|
|
101
|
+
with os.fdopen(fd, "wb") as fh:
|
|
102
|
+
fh.write(data)
|
|
103
|
+
fh.flush()
|
|
104
|
+
os.fsync(fh.fileno())
|
|
105
|
+
try:
|
|
106
|
+
os.link(tmp, path)
|
|
107
|
+
except FileExistsError:
|
|
108
|
+
if path.read_bytes() == data:
|
|
109
|
+
return ArchiveWrite(path=path, status="existing")
|
|
110
|
+
existing_sha = sha256_bytes(path.read_bytes())
|
|
111
|
+
incoming_sha = sha256_bytes(data)
|
|
112
|
+
raise ArchiveWriteConflictError(
|
|
113
|
+
stable_json(
|
|
114
|
+
{
|
|
115
|
+
"reason": "archive_write_conflict",
|
|
116
|
+
"path": str(path),
|
|
117
|
+
"existing_sha256": existing_sha,
|
|
118
|
+
"incoming_sha256": incoming_sha,
|
|
119
|
+
"retry_backfill_strategy": "preserve both inputs; move conflicting incoming capture to a new capture_id/source path and rerun archive-agent/archive-source, or backfill from raw-ticks after resolving the duplicate filename source",
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
) from None
|
|
123
|
+
os.chmod(path, 0o600)
|
|
124
|
+
fsync_dir(path.parent)
|
|
125
|
+
return ArchiveWrite(path=path, status="written")
|
|
126
|
+
except Exception:
|
|
127
|
+
raise
|
|
128
|
+
finally:
|
|
129
|
+
try:
|
|
130
|
+
tmp.unlink()
|
|
131
|
+
except FileNotFoundError:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def write_idempotent_json(path: Path, value: dict[str, Any]) -> ArchiveWrite:
|
|
136
|
+
return write_idempotent_bytes(path, (stable_json(value) + "\n").encode("utf-8"))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def write_immutable_json(path: Path, value: dict[str, Any]) -> None:
|
|
140
|
+
"""Compatibility wrapper retained for existing tests/callers."""
|
|
141
|
+
|
|
142
|
+
write_idempotent_json(path, value)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def store_blob(root: Path, family: str, digest: str, suffix: str, data: bytes) -> ArchiveWrite:
|
|
146
|
+
return write_idempotent_bytes(private_subdir(root, family) / f"{digest}{suffix}", data)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def parse_roster_sections(
|
|
150
|
+
text: str,
|
|
151
|
+
*,
|
|
152
|
+
known_agents: Iterable[str] | None = None,
|
|
153
|
+
fail_unknown_headers: bool = False,
|
|
154
|
+
) -> list[RosterSection]:
|
|
155
|
+
known = set(known_agents or [])
|
|
156
|
+
sections: list[RosterSection] = []
|
|
157
|
+
current_agent: str | None = None
|
|
158
|
+
current_start = 0
|
|
159
|
+
current_lines: list[str] = []
|
|
160
|
+
lines = text.splitlines()
|
|
161
|
+
for lineno, line in enumerate(lines, start=1):
|
|
162
|
+
match = HEADER_RE.match(line)
|
|
163
|
+
if match:
|
|
164
|
+
name = match.group(1)
|
|
165
|
+
if known and name not in known and fail_unknown_headers:
|
|
166
|
+
raise AmbiguousHeaderError(
|
|
167
|
+
f"legacy roster header at line {lineno} is not a known agent; it may be pane content"
|
|
168
|
+
)
|
|
169
|
+
if current_agent is not None:
|
|
170
|
+
sections.append(
|
|
171
|
+
RosterSection(
|
|
172
|
+
index=len(sections) + 1,
|
|
173
|
+
agent=current_agent,
|
|
174
|
+
line_start=current_start,
|
|
175
|
+
line_end=lineno - 1,
|
|
176
|
+
raw_response="\n".join(current_lines),
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
current_agent = name
|
|
180
|
+
current_start = lineno
|
|
181
|
+
current_lines = []
|
|
182
|
+
continue
|
|
183
|
+
if current_agent is not None:
|
|
184
|
+
current_lines.append(line)
|
|
185
|
+
if current_agent is not None:
|
|
186
|
+
sections.append(
|
|
187
|
+
RosterSection(
|
|
188
|
+
index=len(sections) + 1,
|
|
189
|
+
agent=current_agent,
|
|
190
|
+
line_start=current_start,
|
|
191
|
+
line_end=len(lines),
|
|
192
|
+
raw_response="\n".join(current_lines),
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
return sections
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def capture_timestamp_from_name(path: Path) -> str | None:
|
|
199
|
+
match = FILENAME_TS_RE.match(path.name)
|
|
200
|
+
if not match:
|
|
201
|
+
return None
|
|
202
|
+
stamp = match.group(1)
|
|
203
|
+
micros = match.group(2)
|
|
204
|
+
base = f"{stamp[0:4]}-{stamp[4:6]}-{stamp[6:8]}T{stamp[9:11]}:{stamp[11:13]}:{stamp[13:15]}"
|
|
205
|
+
if micros:
|
|
206
|
+
return f"{base}.{micros}Z"
|
|
207
|
+
return f"{base}Z"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def classify_failure_state(raw_response: str, capture_rc: int = 0) -> tuple[str, str | None]:
|
|
211
|
+
lower = raw_response.lower()
|
|
212
|
+
if capture_rc != 0:
|
|
213
|
+
return "capture_failed", first_nonempty(raw_response)
|
|
214
|
+
if "error: tmux.capture" in lower or "failed to connect to daemon" in lower:
|
|
215
|
+
return "error_text", first_nonempty(raw_response)
|
|
216
|
+
if "degraded-known" in lower:
|
|
217
|
+
return "degraded_known", first_marker(raw_response)
|
|
218
|
+
if "fallback skipped" in lower:
|
|
219
|
+
return "fallback_skipped", first_marker(raw_response)
|
|
220
|
+
if "fallback deferred" in lower:
|
|
221
|
+
return "fallback_deferred", first_marker(raw_response)
|
|
222
|
+
if "capture failed" in lower:
|
|
223
|
+
return "capture_failed", first_marker(raw_response)
|
|
224
|
+
return "success", None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def first_marker(text: str) -> str | None:
|
|
228
|
+
for line in text.splitlines():
|
|
229
|
+
if line.startswith("---"):
|
|
230
|
+
return line[:240]
|
|
231
|
+
return first_nonempty(text)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def first_nonempty(text: str) -> str | None:
|
|
235
|
+
for line in text.splitlines():
|
|
236
|
+
if line.strip():
|
|
237
|
+
return line[:240]
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def parse_geometry_json(value: str | None) -> tuple[dict[str, Any] | None, str]:
|
|
242
|
+
if not value:
|
|
243
|
+
return None, "unmeasured"
|
|
244
|
+
parsed = json.loads(value)
|
|
245
|
+
if not isinstance(parsed, dict):
|
|
246
|
+
raise ValueError("geometry JSON must be an object")
|
|
247
|
+
allowed = {"pane_width", "pane_height", "cursor_x", "cursor_y", "pane_in_mode", "source", "target", "measured_at_unix"}
|
|
248
|
+
extra = set(parsed) - allowed
|
|
249
|
+
if extra:
|
|
250
|
+
raise ValueError(f"unexpected geometry keys: {sorted(extra)}")
|
|
251
|
+
return parsed, "measured"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def record_filename(record: dict[str, Any]) -> str:
|
|
255
|
+
ts = record.get("capture_timestamp_utc") or "unknown-time"
|
|
256
|
+
safe_agent = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(record["agent"]))[:80]
|
|
257
|
+
source_digest = str(record.get("source_file_sha256") or "direct")[:12]
|
|
258
|
+
raw_digest = record["raw_response_sha256"][:16]
|
|
259
|
+
index = int(record.get("section_index") or 0)
|
|
260
|
+
return f"{ts.replace(':', '').replace('-', '')}_{index:04d}_{safe_agent}_{source_digest}_{raw_digest}.json"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def build_direct_record(
|
|
264
|
+
*,
|
|
265
|
+
archive_dir: Path,
|
|
266
|
+
agent: str,
|
|
267
|
+
raw_response_bytes: bytes,
|
|
268
|
+
requested_lines: int = DEFAULT_REQUESTED_LINES,
|
|
269
|
+
capture_rc: int = 0,
|
|
270
|
+
capture_timestamp_utc: str | None = None,
|
|
271
|
+
capture_id: str | None = None,
|
|
272
|
+
source_file: Path | None = None,
|
|
273
|
+
source_bytes: bytes | None = None,
|
|
274
|
+
source_blob: Path | None = None,
|
|
275
|
+
source_sha256: str | None = None,
|
|
276
|
+
section_index: int | None = None,
|
|
277
|
+
line_start: int | None = None,
|
|
278
|
+
line_end: int | None = None,
|
|
279
|
+
runtime: str | None = None,
|
|
280
|
+
box: str | None = None,
|
|
281
|
+
geometry: dict[str, Any] | None = None,
|
|
282
|
+
geometry_provenance: str = "unmeasured",
|
|
283
|
+
capture_route: str | None = None,
|
|
284
|
+
primary_rc: int | None = None,
|
|
285
|
+
ssh_rc: int | None = None,
|
|
286
|
+
final_rc: int | None = None,
|
|
287
|
+
split_provenance: str = "direct_per_agent_capture_time",
|
|
288
|
+
split_reliable: bool = True,
|
|
289
|
+
) -> dict[str, Any]:
|
|
290
|
+
raw_sha = sha256_bytes(raw_response_bytes)
|
|
291
|
+
raw_blob = store_blob(archive_dir, "raw", raw_sha, ".bin", raw_response_bytes)
|
|
292
|
+
raw_text = raw_response_bytes.decode("utf-8", errors="replace")
|
|
293
|
+
failure_state, error_excerpt = classify_failure_state(raw_text, capture_rc)
|
|
294
|
+
if source_bytes is not None and source_sha256 is None:
|
|
295
|
+
source_sha256 = sha256_bytes(source_bytes)
|
|
296
|
+
return {
|
|
297
|
+
"schema_version": SCHEMA_VERSION,
|
|
298
|
+
"capture_id": capture_id,
|
|
299
|
+
"capture_timestamp_utc": capture_timestamp_utc,
|
|
300
|
+
"agent": agent,
|
|
301
|
+
"runtime": runtime,
|
|
302
|
+
"box": box,
|
|
303
|
+
"requested_lines": requested_lines,
|
|
304
|
+
"line_semantics": LINE_SEMANTICS,
|
|
305
|
+
"geometry": geometry,
|
|
306
|
+
"geometry_provenance": geometry_provenance,
|
|
307
|
+
"capture_rc": capture_rc,
|
|
308
|
+
"final_rc": final_rc if final_rc is not None else capture_rc,
|
|
309
|
+
"primary_rc": primary_rc,
|
|
310
|
+
"ssh_rc": ssh_rc,
|
|
311
|
+
"capture_route": capture_route,
|
|
312
|
+
"failure_state": failure_state,
|
|
313
|
+
"error_excerpt": error_excerpt,
|
|
314
|
+
"section_index": section_index,
|
|
315
|
+
"line_start": line_start,
|
|
316
|
+
"line_end": line_end,
|
|
317
|
+
"split_provenance": split_provenance,
|
|
318
|
+
"split_reliable": split_reliable,
|
|
319
|
+
"source_file": str(source_file) if source_file is not None else None,
|
|
320
|
+
"source_file_name": source_file.name if source_file is not None else None,
|
|
321
|
+
"source_file_sha256": source_sha256,
|
|
322
|
+
"source_blob": str(source_blob) if source_blob is not None else None,
|
|
323
|
+
"raw_response_sha256": raw_sha,
|
|
324
|
+
"raw_response_bytes": len(raw_response_bytes),
|
|
325
|
+
"raw_response_blob": str(raw_blob.path),
|
|
326
|
+
"joined_logical_line_count": len(raw_text.splitlines()),
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def archive_agent_capture(
|
|
331
|
+
*,
|
|
332
|
+
archive_dir: Path,
|
|
333
|
+
agent: str,
|
|
334
|
+
raw_response_bytes: bytes,
|
|
335
|
+
requested_lines: int = DEFAULT_REQUESTED_LINES,
|
|
336
|
+
capture_rc: int = 0,
|
|
337
|
+
capture_timestamp_utc: str | None = None,
|
|
338
|
+
capture_id: str | None = None,
|
|
339
|
+
source_file: Path | None = None,
|
|
340
|
+
source_bytes: bytes | None = None,
|
|
341
|
+
source_blob: Path | None = None,
|
|
342
|
+
source_sha256: str | None = None,
|
|
343
|
+
section_index: int | None = None,
|
|
344
|
+
line_start: int | None = None,
|
|
345
|
+
line_end: int | None = None,
|
|
346
|
+
runtime: str | None = None,
|
|
347
|
+
box: str | None = None,
|
|
348
|
+
geometry: dict[str, Any] | None = None,
|
|
349
|
+
geometry_provenance: str = "unmeasured",
|
|
350
|
+
capture_route: str | None = None,
|
|
351
|
+
primary_rc: int | None = None,
|
|
352
|
+
ssh_rc: int | None = None,
|
|
353
|
+
final_rc: int | None = None,
|
|
354
|
+
split_provenance: str = "direct_per_agent_capture_time",
|
|
355
|
+
split_reliable: bool = True,
|
|
356
|
+
) -> ArchiveWrite:
|
|
357
|
+
chmod_private_dir(archive_dir)
|
|
358
|
+
record = build_direct_record(
|
|
359
|
+
archive_dir=archive_dir,
|
|
360
|
+
agent=agent,
|
|
361
|
+
raw_response_bytes=raw_response_bytes,
|
|
362
|
+
requested_lines=requested_lines,
|
|
363
|
+
capture_rc=capture_rc,
|
|
364
|
+
capture_timestamp_utc=capture_timestamp_utc,
|
|
365
|
+
capture_id=capture_id,
|
|
366
|
+
source_file=source_file,
|
|
367
|
+
source_bytes=source_bytes,
|
|
368
|
+
source_blob=source_blob,
|
|
369
|
+
source_sha256=source_sha256,
|
|
370
|
+
section_index=section_index,
|
|
371
|
+
line_start=line_start,
|
|
372
|
+
line_end=line_end,
|
|
373
|
+
runtime=runtime,
|
|
374
|
+
box=box,
|
|
375
|
+
geometry=geometry,
|
|
376
|
+
geometry_provenance=geometry_provenance,
|
|
377
|
+
capture_route=capture_route,
|
|
378
|
+
primary_rc=primary_rc,
|
|
379
|
+
ssh_rc=ssh_rc,
|
|
380
|
+
final_rc=final_rc,
|
|
381
|
+
split_provenance=split_provenance,
|
|
382
|
+
split_reliable=split_reliable,
|
|
383
|
+
)
|
|
384
|
+
out = private_subdir(archive_dir, "records") / record_filename(record)
|
|
385
|
+
return write_idempotent_json(out, record)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def write_import_error(archive_dir: Path, *, source_path: Path, source_sha: str, source_blob: Path, error: str, reason: str) -> Path:
|
|
389
|
+
payload = {
|
|
390
|
+
"schema_version": "roster-capture-import-error-v1",
|
|
391
|
+
"source_file": str(source_path),
|
|
392
|
+
"source_file_name": source_path.name,
|
|
393
|
+
"source_file_sha256": source_sha,
|
|
394
|
+
"source_blob": str(source_blob),
|
|
395
|
+
"error": error,
|
|
396
|
+
"reason": reason,
|
|
397
|
+
}
|
|
398
|
+
out = private_subdir(archive_dir, "errors") / f"{source_sha}_{reason}.json"
|
|
399
|
+
write_idempotent_json(out, payload)
|
|
400
|
+
return out
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def archive_roster_file(
|
|
404
|
+
source_path: Path,
|
|
405
|
+
archive_dir: Path,
|
|
406
|
+
requested_lines: int = DEFAULT_REQUESTED_LINES,
|
|
407
|
+
*,
|
|
408
|
+
known_agents: Iterable[str] | None = None,
|
|
409
|
+
fail_unknown_headers: bool = False,
|
|
410
|
+
) -> list[Path]:
|
|
411
|
+
source_bytes = source_path.read_bytes()
|
|
412
|
+
source_sha = sha256_bytes(source_bytes)
|
|
413
|
+
chmod_private_dir(archive_dir)
|
|
414
|
+
source_blob = store_blob(archive_dir, "sources", source_sha, ".txt", source_bytes)
|
|
415
|
+
source_text = source_bytes.decode("utf-8", errors="replace")
|
|
416
|
+
try:
|
|
417
|
+
sections = parse_roster_sections(source_text, known_agents=known_agents, fail_unknown_headers=fail_unknown_headers)
|
|
418
|
+
except AmbiguousHeaderError as exc:
|
|
419
|
+
write_import_error(
|
|
420
|
+
archive_dir,
|
|
421
|
+
source_path=source_path,
|
|
422
|
+
source_sha=source_sha,
|
|
423
|
+
source_blob=source_blob.path,
|
|
424
|
+
error=str(exc),
|
|
425
|
+
reason="ambiguous_header",
|
|
426
|
+
)
|
|
427
|
+
raise
|
|
428
|
+
if not sections:
|
|
429
|
+
write_import_error(
|
|
430
|
+
archive_dir,
|
|
431
|
+
source_path=source_path,
|
|
432
|
+
source_sha=source_sha,
|
|
433
|
+
source_blob=source_blob.path,
|
|
434
|
+
error="legacy assembled roster file contained zero parseable column-1 headers",
|
|
435
|
+
reason="zero_headers",
|
|
436
|
+
)
|
|
437
|
+
raise ArchiveError("zero parseable roster headers; source bytes preserved")
|
|
438
|
+
written: list[Path] = []
|
|
439
|
+
for section in sections:
|
|
440
|
+
result = archive_agent_capture(
|
|
441
|
+
archive_dir=archive_dir,
|
|
442
|
+
agent=section.agent,
|
|
443
|
+
raw_response_bytes=section.raw_response.encode("utf-8"),
|
|
444
|
+
requested_lines=requested_lines,
|
|
445
|
+
capture_timestamp_utc=capture_timestamp_from_name(source_path),
|
|
446
|
+
capture_id=source_path.stem,
|
|
447
|
+
source_file=source_path,
|
|
448
|
+
source_bytes=source_bytes,
|
|
449
|
+
source_blob=source_blob.path,
|
|
450
|
+
source_sha256=source_sha,
|
|
451
|
+
section_index=section.index,
|
|
452
|
+
line_start=section.line_start,
|
|
453
|
+
line_end=section.line_end,
|
|
454
|
+
capture_route="legacy_assembled_import",
|
|
455
|
+
split_provenance="legacy_assembled_column1_headers",
|
|
456
|
+
split_reliable=False,
|
|
457
|
+
)
|
|
458
|
+
written.append(result.path)
|
|
459
|
+
return written
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def load_record_text(record: dict[str, Any]) -> str:
|
|
463
|
+
blob_path = record.get("raw_response_blob")
|
|
464
|
+
if blob_path:
|
|
465
|
+
return Path(blob_path).read_bytes().decode("utf-8", errors="replace")
|
|
466
|
+
return str(record.get("raw_response") or "")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def derive_training_view(records_dir: Path, out_path: Path) -> int:
|
|
470
|
+
rows: list[dict[str, Any]] = []
|
|
471
|
+
for path in sorted(records_dir.glob("*.json")):
|
|
472
|
+
record = json.loads(path.read_text(encoding="utf-8"))
|
|
473
|
+
text = load_record_text(record)
|
|
474
|
+
rows.append(
|
|
475
|
+
{
|
|
476
|
+
"schema_version": TRAINING_VIEW_SCHEMA_VERSION,
|
|
477
|
+
"record_path": str(path),
|
|
478
|
+
"source_file": record.get("source_file"),
|
|
479
|
+
"source_file_sha256": record.get("source_file_sha256"),
|
|
480
|
+
"source_blob": record.get("source_blob"),
|
|
481
|
+
"agent": record["agent"],
|
|
482
|
+
"capture_timestamp_utc": record.get("capture_timestamp_utc"),
|
|
483
|
+
"requested_lines": record["requested_lines"],
|
|
484
|
+
"line_semantics": record["line_semantics"],
|
|
485
|
+
"geometry": record.get("geometry"),
|
|
486
|
+
"geometry_provenance": record.get("geometry_provenance"),
|
|
487
|
+
"failure_state": record["failure_state"],
|
|
488
|
+
"split_provenance": record.get("split_provenance"),
|
|
489
|
+
"split_reliable": record.get("split_reliable"),
|
|
490
|
+
"text": f"=== {record['agent']} ===\n{text}",
|
|
491
|
+
}
|
|
492
|
+
)
|
|
493
|
+
chmod_private_dir(out_path.parent)
|
|
494
|
+
tmp = out_path.with_name(f".{out_path.name}.{os.getpid()}.tmp")
|
|
495
|
+
with tmp.open("w", encoding="utf-8") as fh:
|
|
496
|
+
for row in rows:
|
|
497
|
+
fh.write(stable_json(row) + "\n")
|
|
498
|
+
fh.flush()
|
|
499
|
+
os.fsync(fh.fileno())
|
|
500
|
+
os.chmod(tmp, 0o600)
|
|
501
|
+
os.replace(tmp, out_path)
|
|
502
|
+
fsync_dir(out_path.parent)
|
|
503
|
+
return len(rows)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def cmd_archive_agent(args: argparse.Namespace) -> int:
|
|
507
|
+
geometry, geometry_provenance = parse_geometry_json(args.geometry_json)
|
|
508
|
+
source_bytes = args.source_file.read_bytes() if args.source_file and args.source_file.exists() else None
|
|
509
|
+
source_sha = sha256_bytes(source_bytes) if source_bytes is not None else None
|
|
510
|
+
source_blob = None
|
|
511
|
+
if source_bytes is not None and source_sha is not None:
|
|
512
|
+
source_blob = store_blob(args.archive_dir, "sources", source_sha, ".txt", source_bytes).path
|
|
513
|
+
result = archive_agent_capture(
|
|
514
|
+
archive_dir=args.archive_dir,
|
|
515
|
+
agent=args.agent,
|
|
516
|
+
raw_response_bytes=args.raw_file.read_bytes(),
|
|
517
|
+
requested_lines=args.requested_lines,
|
|
518
|
+
capture_rc=args.capture_rc,
|
|
519
|
+
capture_timestamp_utc=args.capture_timestamp_utc,
|
|
520
|
+
capture_id=args.capture_id,
|
|
521
|
+
source_file=args.source_file,
|
|
522
|
+
source_bytes=source_bytes,
|
|
523
|
+
source_blob=source_blob,
|
|
524
|
+
source_sha256=source_sha,
|
|
525
|
+
runtime=args.runtime,
|
|
526
|
+
box=args.box,
|
|
527
|
+
geometry=geometry,
|
|
528
|
+
geometry_provenance=geometry_provenance,
|
|
529
|
+
capture_route=args.capture_route,
|
|
530
|
+
primary_rc=args.primary_rc,
|
|
531
|
+
ssh_rc=args.ssh_rc,
|
|
532
|
+
final_rc=args.final_rc,
|
|
533
|
+
)
|
|
534
|
+
print(json.dumps({"record": str(result.path), "status": result.status}, sort_keys=True))
|
|
535
|
+
return 0
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def cmd_archive_source(args: argparse.Namespace) -> int:
|
|
539
|
+
source_bytes = args.source.read_bytes()
|
|
540
|
+
source_sha = sha256_bytes(source_bytes)
|
|
541
|
+
result = store_blob(args.archive_dir, "sources", source_sha, ".txt", source_bytes)
|
|
542
|
+
manifest = {
|
|
543
|
+
"schema_version": "roster-capture-source-v1",
|
|
544
|
+
"capture_id": args.capture_id,
|
|
545
|
+
"capture_timestamp_utc": args.capture_timestamp_utc,
|
|
546
|
+
"source_file": str(args.source),
|
|
547
|
+
"source_file_name": args.source.name,
|
|
548
|
+
"source_file_sha256": source_sha,
|
|
549
|
+
"source_blob": str(result.path),
|
|
550
|
+
}
|
|
551
|
+
safe_capture = re.sub(r"[^A-Za-z0-9_.-]+", "_", args.capture_id or source_sha)[:120]
|
|
552
|
+
out = private_subdir(args.archive_dir, "source-records") / f"{safe_capture}_{source_sha}.json"
|
|
553
|
+
written = write_idempotent_json(out, manifest)
|
|
554
|
+
print(json.dumps({"source": str(result.path), "source_status": result.status, "record": str(written.path)}, sort_keys=True))
|
|
555
|
+
return 0
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def main(argv: list[str] | None = None) -> int:
|
|
559
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
560
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
561
|
+
archive = sub.add_parser("archive-roster-file")
|
|
562
|
+
archive.add_argument("--source", required=True, type=Path)
|
|
563
|
+
archive.add_argument("--archive-dir", required=True, type=Path)
|
|
564
|
+
archive.add_argument("--requested-lines", type=int, default=DEFAULT_REQUESTED_LINES)
|
|
565
|
+
archive.add_argument("--known-agent", action="append", default=[])
|
|
566
|
+
archive.add_argument("--fail-unknown-headers", action="store_true")
|
|
567
|
+
agent = sub.add_parser("archive-agent")
|
|
568
|
+
agent.add_argument("--archive-dir", required=True, type=Path)
|
|
569
|
+
agent.add_argument("--agent", required=True)
|
|
570
|
+
agent.add_argument("--raw-file", required=True, type=Path)
|
|
571
|
+
agent.add_argument("--source-file", type=Path)
|
|
572
|
+
agent.add_argument("--requested-lines", type=int, default=DEFAULT_REQUESTED_LINES)
|
|
573
|
+
agent.add_argument("--capture-rc", type=int, default=0)
|
|
574
|
+
agent.add_argument("--capture-timestamp-utc")
|
|
575
|
+
agent.add_argument("--capture-id")
|
|
576
|
+
agent.add_argument("--runtime")
|
|
577
|
+
agent.add_argument("--box")
|
|
578
|
+
agent.add_argument("--geometry-json")
|
|
579
|
+
agent.add_argument("--capture-route")
|
|
580
|
+
agent.add_argument("--primary-rc", type=int)
|
|
581
|
+
agent.add_argument("--ssh-rc", type=int)
|
|
582
|
+
agent.add_argument("--final-rc", type=int)
|
|
583
|
+
source = sub.add_parser("archive-source")
|
|
584
|
+
source.add_argument("--archive-dir", required=True, type=Path)
|
|
585
|
+
source.add_argument("--source", required=True, type=Path)
|
|
586
|
+
source.add_argument("--capture-timestamp-utc")
|
|
587
|
+
source.add_argument("--capture-id")
|
|
588
|
+
view = sub.add_parser("derive-training-view")
|
|
589
|
+
view.add_argument("--records-dir", required=True, type=Path)
|
|
590
|
+
view.add_argument("--out", required=True, type=Path)
|
|
591
|
+
args = parser.parse_args(argv)
|
|
592
|
+
try:
|
|
593
|
+
if args.cmd == "archive-roster-file":
|
|
594
|
+
paths = archive_roster_file(
|
|
595
|
+
args.source,
|
|
596
|
+
args.archive_dir,
|
|
597
|
+
args.requested_lines,
|
|
598
|
+
known_agents=args.known_agent,
|
|
599
|
+
fail_unknown_headers=args.fail_unknown_headers,
|
|
600
|
+
)
|
|
601
|
+
print(json.dumps({"records_written": len(paths), "archive_dir": str(args.archive_dir)}, sort_keys=True))
|
|
602
|
+
return 0
|
|
603
|
+
if args.cmd == "archive-agent":
|
|
604
|
+
return cmd_archive_agent(args)
|
|
605
|
+
if args.cmd == "archive-source":
|
|
606
|
+
return cmd_archive_source(args)
|
|
607
|
+
if args.cmd == "derive-training-view":
|
|
608
|
+
count = derive_training_view(args.records_dir, args.out)
|
|
609
|
+
print(json.dumps({"rows_written": count, "out": str(args.out)}, sort_keys=True))
|
|
610
|
+
return 0
|
|
611
|
+
except ArchiveError as exc:
|
|
612
|
+
print(json.dumps({"error": str(exc), "archive_dir": str(getattr(args, "archive_dir", ""))}, sort_keys=True), file=sys.stderr)
|
|
613
|
+
return 1
|
|
614
|
+
return 2
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
if __name__ == "__main__":
|
|
618
|
+
sys.exit(main())
|