opencode-thrum-pro 0.1.5 → 0.1.7

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 (90) hide show
  1. package/assets/commands/thrum-compact-extended.md +367 -0
  2. package/assets/commands/thrum-compact.md +301 -0
  3. package/assets/commands/thrum-inbox.md +1 -1
  4. package/assets/commands/thrum-restart-extended.md +85 -16
  5. package/assets/commands/thrum-restart.md +106 -14
  6. package/assets/commands/thrum-send.md +4 -1
  7. package/assets/commands/thrum-sleep-extended.md +68 -51
  8. package/assets/commands/thrum-sleep.md +68 -63
  9. package/assets/commands/thrum-update-project.md +1 -1
  10. package/assets/skills/adversarial-critique/SKILL.md +5 -8
  11. package/assets/skills/brainstorm-steward/SKILL.md +65 -11
  12. package/assets/skills/brainstormer-staying-current/SKILL.md +1 -4
  13. package/assets/skills/choosing-subagent-models/SKILL.md +200 -70
  14. package/assets/skills/closing-findings-while-warm/SKILL.md +1 -2
  15. package/assets/skills/configure-roles/SKILL.md +2 -2
  16. package/assets/skills/conversation-only-context/SKILL.md +11 -11
  17. package/assets/skills/conversation-only-context/resources/driver-subagent-pattern.md +1 -1
  18. package/assets/skills/conversation-only-context/resources/remote-environment-scenarios.md +1 -1
  19. package/assets/skills/coordinator-assessing-agent-completion/SKILL.md +98 -6
  20. package/assets/skills/coordinator-branch-split-on-block/SKILL.md +1 -2
  21. package/assets/skills/coordinator-context-monitoring/SKILL.md +29 -19
  22. package/assets/skills/coordinator-deploying-a-node/SKILL.md +124 -0
  23. package/assets/skills/coordinator-dispatching-work/SKILL.md +26 -14
  24. package/assets/skills/coordinator-hotpath-merge-gate/SKILL.md +65 -21
  25. package/assets/skills/coordinator-interacting-with-the-owner/SKILL.md +1 -2
  26. package/assets/skills/coordinator-maintaining-memory/SKILL.md +1 -1
  27. package/assets/skills/coordinator-managing-state-and-lifecycle/SKILL.md +7 -8
  28. package/assets/skills/coordinator-merging-code/SKILL.md +43 -22
  29. package/assets/skills/coordinator-philosophy-merge-gate/SKILL.md +29 -9
  30. package/assets/skills/coordinator-plan-reconcile-gate/SKILL.md +4 -5
  31. package/assets/skills/coordinator-rolling-out-to-nodes/SKILL.md +92 -0
  32. package/assets/skills/coordinator-running-autonomous-feature-development/SKILL.md +3 -3
  33. package/assets/skills/coordinator-running-brainstorm-cycles/SKILL.md +25 -25
  34. package/assets/skills/coordinator-running-review-cycles/SKILL.md +16 -6
  35. package/assets/{commands/thrum-_deviations-protocol.md → skills/deviations-protocol/SKILL.md} +6 -12
  36. package/assets/skills/diagnosing-fleet-and-daemon/SKILL.md +0 -2
  37. package/assets/skills/efficient-multi-agent-research/SKILL.md +3 -3
  38. package/assets/skills/generator-idempotency-protocol/SKILL.md +9 -0
  39. package/assets/skills/implementer-maintaining-memory/SKILL.md +3 -4
  40. package/assets/skills/implementer-orchestrating-execution/SKILL.md +12 -16
  41. package/assets/skills/implementer-receiving-dispatch/SKILL.md +15 -17
  42. package/assets/skills/implementer-receiving-review-feedback/SKILL.md +3 -4
  43. package/assets/skills/implementer-status-and-handoff/SKILL.md +1 -14
  44. package/assets/skills/implementer-tdd-and-quality/SKILL.md +2 -2
  45. package/assets/skills/memory-curate/SKILL.md +1 -1
  46. package/assets/skills/memory-write-discipline/SKILL.md +1 -1
  47. package/assets/skills/orchestrate/SKILL.md +77 -36
  48. package/assets/skills/persistent-watcher-archetype/SKILL.md +172 -25
  49. package/assets/skills/persistent-watcher-archetype/resources/capture_archive.py +618 -0
  50. package/assets/skills/persistent-watcher-archetype/resources/jev_watcher_filter.py +837 -0
  51. package/assets/skills/persistent-watcher-archetype/resources/roster-watch.sh +1140 -0
  52. package/assets/skills/persistent-watcher-archetype/resources/roster-watch_test.sh +291 -0
  53. package/assets/skills/persistent-watcher-archetype/resources/thrum-capture-fallback.sh +2 -2
  54. package/assets/skills/persistent-watcher-archetype/resources/thrum-watch-pane-capture.sh +20 -8
  55. package/assets/skills/prime-agent/SKILL.md +10 -0
  56. package/assets/skills/project-hotpath-gate/SKILL.md +1 -2
  57. package/assets/skills/project-philosophy/SKILL.md +1 -2
  58. package/assets/skills/project-rules-protocol/SKILL.md +12 -0
  59. package/assets/skills/project-setup/SKILL.md +27 -22
  60. package/assets/skills/project-setup/resources/implementation-agent.md +55 -35
  61. package/assets/skills/researcher-answering-queries/SKILL.md +1 -2
  62. package/assets/skills/researcher-investigating/SKILL.md +8 -13
  63. package/assets/skills/researcher-maintaining-memory/SKILL.md +1 -1
  64. package/assets/{commands/thrum-_snapshot-protocol.md → skills/snapshot-protocol/SKILL.md} +27 -21
  65. package/assets/{commands/thrum-_stamp-protocol.md → skills/stamp-protocol/SKILL.md} +14 -8
  66. package/assets/skills/thrum/SKILL.md +14 -7
  67. package/assets/skills/thrum/resources/ANTI_PATTERNS.md +16 -18
  68. package/assets/skills/thrum/resources/CLI_REFERENCE.md +17 -11
  69. package/assets/skills/thrum/resources/LISTENER_PATTERN.md +9 -3
  70. package/assets/skills/thrum/resources/MESSAGING.md +18 -11
  71. package/assets/skills/thrum/resources/WORKTREES.md +1 -1
  72. package/assets/skills/thrum-memory/SKILL.md +1 -1
  73. package/assets/skills/trace-spec-to-plan/resources/sweep-agent-prompt.md +1 -1
  74. package/assets/skills/update-queue/SKILL.md +6 -8
  75. package/assets/skills/using-the-dashboard/SKILL.md +30 -0
  76. package/assets/skills/using-the-queue/SKILL.md +76 -5
  77. package/assets/skills/using-thrum-state/SKILL.md +85 -2
  78. package/assets/skills/verify-against-plan/SKILL.md +122 -7
  79. package/assets/skills/watcher-archetype/SKILL.md +18 -0
  80. package/assets/skills/writing-durable-markdown/SKILL.md +5 -0
  81. package/dist/installer-assets.test.js +41 -0
  82. package/dist/installer.js +4 -4
  83. package/dist/permission-fixture.integration.test.js +4 -4
  84. package/package.json +1 -1
  85. package/assets/commands/thrum-_generator-idempotency-protocol.md +0 -15
  86. package/assets/commands/thrum-_project-rules-protocol.md +0 -18
  87. package/assets/commands/thrum-_verify-before-write-protocol.md +0 -27
  88. package/assets/skills/coordinator-deploying-the-fleet/SKILL.md +0 -396
  89. package/dist/allowlist-sync.test.js +0 -44
  90. /package/dist/{allowlist-sync.test.d.ts → installer-assets.test.d.ts} +0 -0
@@ -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())