9703eb2b6b
Worldtree Conversation API debug TUI. Multi-pane observability dashboard: chat transcript + persona/Vili affect log + tool events + admin events + Bifrost state + tool inventory + (opt-in) raw server log. Design locked at docs/design-brief.md (originated as brokkr-smithy/docs/ratatoskr-design-brief.md). Operator-locked decisions: - Textual application-shell framework (multi-pane dashboard, not REPL). - Separate repo + separate dev team (no Worldtree-source imports). - httpx-sse for SSE consumption (reference Python SSE-resume impl). - Triple version-skew mitigation: spec-pin in pyproject.toml + recorded SSE snapshot tests + conformance smoke. Initial pin: Worldtree v0.19.0 at 55101e909abcd2219833266b6f905c5bc956e0f0. - Persona pane: label-don't-refuse PII posture. - Server-log pane: opt-in via --server-log <path>. - Two-stage Ctrl-C (cancel then exit). - Markdown rendering default-on; --raw opt-out. In the box: - docs/design-brief.md — the locked design with full rationale. - docs/SPEC-PIN.md — Worldtree spec pin + bump procedure. - docs/conversation-api-spec.md + docs/conversation_api.contract.md — vendored Worldtree spec snapshots at the pinned SHA. - pyproject.toml — Python 3.12, hatchling, uv-managed, deps locked. - src/ratatoskr/ — stub package (cli.py raises NotImplementedError). - tests/test_no_worldtree_imports.py — boundary smoke test PASSING. - tests/snapshots/README.md — recording convention for SSE snapshot tests. Not in the box yet: - Gitea remote (operator/infra-ops to register at vh/ratatoskr). - Implementation — the dev team owns this; design brief is the spec. Origin: althing thread 01KS3R34XD3N6HMK91VXESHGW7 (worldtree-dev → brokkr-smithy-dev, 2026-05-20). Volva consulted via thread 01KS3VF6W33N3V5FNMGQ91YNVD.
239 lines
8.3 KiB
Python
239 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
canonical_sync.py — operator-triggered tool to sync pinned canonical
|
|
Corviduo specs from their canonical sources.
|
|
|
|
Reads .corviduo-canonicals.toml at the repo root. For each pin:
|
|
1. Locates canonical at ~/development/<canonical_source>/<canonical_path>
|
|
2. Reads canonical content
|
|
3. Computes SHA-256 (first 16 hex chars)
|
|
4. Writes content to consumer_path (if different from current)
|
|
5. Updates the pin's pinned_sha256_16 + pinned_at in the manifest
|
|
|
|
Reports per-pin: OK | SYNCED | WOULD | ERROR.
|
|
|
|
Manifest shape (.corviduo-canonicals.toml):
|
|
|
|
[[pins]]
|
|
id = "contract-format-v2"
|
|
canonical_source = "corviduo-project-template"
|
|
canonical_path = "docs/contracts/CONTRACT-FORMAT.md"
|
|
consumer_path = "docs/contracts/CONTRACT-FORMAT.md"
|
|
pinned_sha256_16 = "a1b2c3d4e5f67890"
|
|
pinned_at = "2026-05-15T22:00:00+00:00"
|
|
|
|
Optional per-pin: tolerate_drift = true (warn-only in canonical_drift.py
|
|
when --allow-warn is set).
|
|
|
|
Usage:
|
|
python scripts/canonical_sync.py [--manifest PATH] [--dry-run]
|
|
|
|
Exit codes:
|
|
0 — success (any number of files synced)
|
|
2 — manifest missing or invalid
|
|
3 — canonical source path missing for one or more pins
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime
|
|
import hashlib
|
|
import sys
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
DEFAULT_MANIFEST = ".corviduo-canonicals.toml"
|
|
DEV_ROOT = Path.home() / "development"
|
|
|
|
|
|
def sha256_16(data: bytes) -> str:
|
|
"""SHA-256 hash, first 16 hex chars."""
|
|
return hashlib.sha256(data).hexdigest()[:16]
|
|
|
|
|
|
def _replace_value_preserve_format(line: str, new_value: str) -> str:
|
|
"""Replace the quoted value in a `key = "value"` line, preserving leading
|
|
whitespace, key name, and trailing newline. Always emits double quotes."""
|
|
leading_ws = line[: len(line) - len(line.lstrip())]
|
|
trailing_nl = "\n" if line.endswith("\n") else ""
|
|
key_part, _, _ = line.lstrip().partition("=")
|
|
key = key_part.strip()
|
|
return f'{leading_ws}{key} = "{new_value}"{trailing_nl}'
|
|
|
|
|
|
def update_pin_in_manifest_text(
|
|
text: str, pin_id: str, new_sha: str, new_at: str,
|
|
) -> str:
|
|
"""Surgically update one pin's `pinned_sha256_16` + `pinned_at` lines in
|
|
the manifest text, preserving all comments, blank lines, key order, and
|
|
formatting elsewhere.
|
|
|
|
If either line is absent in the block (e.g. first sync of a newly-added
|
|
pin), insert it after the last content line of the block.
|
|
|
|
Raises KeyError if the pin_id isn't found in any `[[pins]]` block.
|
|
"""
|
|
lines = text.splitlines(keepends=True)
|
|
|
|
# Find [[pins]] block boundaries: (start_idx_inclusive, end_idx_exclusive).
|
|
blocks: list[tuple[int, int]] = []
|
|
current_start: int | None = None
|
|
for i, line in enumerate(lines):
|
|
if line.strip() == "[[pins]]":
|
|
if current_start is not None:
|
|
blocks.append((current_start, i))
|
|
current_start = i
|
|
if current_start is not None:
|
|
blocks.append((current_start, len(lines)))
|
|
|
|
# Locate the block whose `id = "<value>"` matches pin_id.
|
|
target_block: tuple[int, int] | None = None
|
|
for start, end in blocks:
|
|
for i in range(start, end):
|
|
stripped = lines[i].strip()
|
|
if stripped.startswith("id ") or stripped.startswith("id="):
|
|
_, _, val = stripped.partition("=")
|
|
val = val.strip().strip('"').strip("'")
|
|
if val == pin_id:
|
|
target_block = (start, end)
|
|
break
|
|
if target_block:
|
|
break
|
|
|
|
if target_block is None:
|
|
raise KeyError(f"pin {pin_id!r} not found in manifest")
|
|
|
|
start, end = target_block
|
|
|
|
# Within the target block: find existing pinned_sha256_16 / pinned_at
|
|
# lines (if any), and the last "content" line (non-blank, non-comment) for
|
|
# insertion fallback.
|
|
sha_idx: int | None = None
|
|
at_idx: int | None = None
|
|
last_content_idx = start # the [[pins]] header itself is content
|
|
for i in range(start, end):
|
|
stripped = lines[i].strip()
|
|
if stripped.startswith("pinned_sha256_16"):
|
|
sha_idx = i
|
|
elif stripped.startswith("pinned_at"):
|
|
at_idx = i
|
|
if stripped and not stripped.startswith("#"):
|
|
last_content_idx = i
|
|
|
|
sha_line = f'pinned_sha256_16 = "{new_sha}"\n'
|
|
at_line = f'pinned_at = "{new_at}"\n'
|
|
|
|
# In-place replacement preserves position + leading whitespace.
|
|
if sha_idx is not None:
|
|
lines[sha_idx] = _replace_value_preserve_format(lines[sha_idx], new_sha)
|
|
if at_idx is not None:
|
|
lines[at_idx] = _replace_value_preserve_format(lines[at_idx], new_at)
|
|
|
|
# Collect inserts for absent lines. When both go at the same index, we
|
|
# want the final output to read pinned_sha256_16 BEFORE pinned_at
|
|
# (conventional order). Since each insert shifts subsequent lines down,
|
|
# insert pinned_at first then pinned_sha256_16 — the second insert
|
|
# pushes pinned_at to idx+1 and lands pinned_sha256_16 at idx.
|
|
inserts: list[tuple[int, str]] = []
|
|
if at_idx is None:
|
|
inserts.append((last_content_idx + 1, at_line))
|
|
if sha_idx is None:
|
|
inserts.append((last_content_idx + 1, sha_line))
|
|
# Reverse-index sort keeps earlier-position inserts from shifting later
|
|
# ones; within a tie the original list order is preserved (Python sort
|
|
# is stable).
|
|
for idx, line in sorted(inserts, key=lambda x: x[0], reverse=True):
|
|
lines.insert(idx, line)
|
|
|
|
return "".join(lines)
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(
|
|
description="Sync pinned canonical Corviduo specs from their sources.",
|
|
)
|
|
ap.add_argument("--manifest", type=Path, default=Path(DEFAULT_MANIFEST))
|
|
ap.add_argument("--dry-run", action="store_true",
|
|
help="Show what would be synced without writing files")
|
|
args = ap.parse_args()
|
|
|
|
if not args.manifest.exists():
|
|
print(f"error: manifest not found at {args.manifest}", file=sys.stderr)
|
|
return 2
|
|
|
|
with args.manifest.open("rb") as f:
|
|
manifest = tomllib.load(f)
|
|
|
|
pins = manifest.get("pins", [])
|
|
if not pins:
|
|
print("warning: no pins in manifest", file=sys.stderr)
|
|
return 0
|
|
|
|
# Read manifest as text upfront for surgical pin-line updates that
|
|
# preserve comments / blank lines / key order in the original file.
|
|
manifest_text = args.manifest.read_text()
|
|
|
|
now = datetime.datetime.now(datetime.timezone.utc).isoformat(
|
|
timespec="seconds",
|
|
)
|
|
any_change = False
|
|
any_error = False
|
|
|
|
for pin in pins:
|
|
pin_id = pin.get("id", "<unknown>")
|
|
source = pin.get("canonical_source")
|
|
canon_rel = pin.get("canonical_path")
|
|
consumer_rel = pin.get("consumer_path")
|
|
if not all((source, canon_rel, consumer_rel)):
|
|
print(f"ERROR {pin_id} (manifest entry incomplete)",
|
|
file=sys.stderr)
|
|
any_error = True
|
|
continue
|
|
|
|
canon_path = DEV_ROOT / source / canon_rel
|
|
if not canon_path.exists():
|
|
print(f"ERROR {pin_id} (canonical missing: {canon_path})",
|
|
file=sys.stderr)
|
|
any_error = True
|
|
continue
|
|
|
|
canon_bytes = canon_path.read_bytes()
|
|
current_sha = sha256_16(canon_bytes)
|
|
pinned_sha = pin.get("pinned_sha256_16", "")
|
|
|
|
consumer_path = Path(consumer_rel)
|
|
consumer_up_to_date = (
|
|
consumer_path.exists()
|
|
and sha256_16(consumer_path.read_bytes()) == current_sha
|
|
)
|
|
|
|
if current_sha == pinned_sha and consumer_up_to_date:
|
|
print(f"OK {pin_id}")
|
|
continue
|
|
|
|
if args.dry_run:
|
|
label = pinned_sha if pinned_sha else "<unpinned>"
|
|
print(f"WOULD {pin_id} ({label} -> {current_sha})")
|
|
continue
|
|
|
|
consumer_path.parent.mkdir(parents=True, exist_ok=True)
|
|
consumer_path.write_bytes(canon_bytes)
|
|
manifest_text = update_pin_in_manifest_text(
|
|
manifest_text, pin_id, current_sha, now,
|
|
)
|
|
label = pinned_sha if pinned_sha else "<unpinned>"
|
|
print(f"SYNCED {pin_id} ({label} -> {current_sha})")
|
|
any_change = True
|
|
|
|
if any_change and not args.dry_run:
|
|
args.manifest.write_text(manifest_text)
|
|
print(f"Manifest updated: {args.manifest}")
|
|
|
|
if any_error:
|
|
return 3
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|