chore(db): move db/connectors/ to tooling/db/ (#274)
Consolidates all connector scripts under tooling/ per project structure conventions. Symlink at db/connectors → tooling/db/ preserves backwards compatibility (remove after Sprint 22). Updated references in CLAUDE.md, Makefile, DEVOPS.md, all skill files, agent files, rules, schema comments, and Sprint 21 briefings. Python scripts updated with correct SCHEMA_PATH (now relative to WORKTREE_ROOT/db/schema.sql). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Symlink
+1
@@ -0,0 +1 @@
|
||||
../tooling/db
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Batch audio generation from a manifest file. Whitelistable command.
|
||||
# Usage: audio-batch manifest.json [--dry-run] [--only ID,ID,...] [--skip-existing]
|
||||
exec python3 "$(dirname "$0")/audio_batch.py" "$@"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generate audio via Stable Audio Open. Whitelistable command.
|
||||
# Usage: audio-generate "prompt text" [--duration N] [--steps N] [--cfg N] [--output file.wav] [--timeout N]
|
||||
exec python3 "$(dirname "$0")/audio_connector.py" generate "$@"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Check if the Stable Audio Open API is reachable. Whitelistable command.
|
||||
# Usage: audio-health
|
||||
exec python3 "$(dirname "$0")/audio_connector.py" health
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Audio post-processing (ffmpeg wrapper). Whitelistable command.
|
||||
# Usage: audio-post convert input.wav [--output output.ogg]
|
||||
# audio-post normalize input.wav [--lufs -16]
|
||||
# audio-post trim input.wav [--threshold -50]
|
||||
# audio-post pipeline input.wav [--output output.ogg]
|
||||
exec python3 "$(dirname "$0")/audio_post.py" "$@"
|
||||
@@ -1,309 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Batch audio generation from a manifest file.
|
||||
|
||||
Processes multiple assets sequentially: SAO generation or harmonic synthesis,
|
||||
followed by post-processing (trim, normalize, convert to OGG).
|
||||
|
||||
Usage:
|
||||
python3 audio_batch.py manifest.json [--dry-run] [--only ID,ID,...] [--skip-existing]
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import wave
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def load_manifest(path):
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def resolve_paths(manifest, manifest_dir):
|
||||
"""Resolve output_dir and gen_dir relative to the git root."""
|
||||
# Find git root by walking up from manifest_dir
|
||||
# Check for .git as file (worktree) or directory (regular repo)
|
||||
git_root = manifest_dir
|
||||
while git_root != "/":
|
||||
if os.path.exists(os.path.join(git_root, ".git")):
|
||||
break
|
||||
git_root = os.path.dirname(git_root)
|
||||
else:
|
||||
git_root = manifest_dir
|
||||
|
||||
output_dir = os.path.join(git_root, manifest.get("output_dir", "client/assets/audio"))
|
||||
gen_dir = os.path.join(git_root, manifest.get("gen_dir", "client/assets/audio/gen"))
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
os.makedirs(gen_dir, exist_ok=True)
|
||||
return output_dir, gen_dir, git_root
|
||||
|
||||
|
||||
def get_default(manifest, asset, key):
|
||||
"""Get a value from the asset, falling back to manifest defaults."""
|
||||
defaults = manifest.get("defaults", {})
|
||||
return asset.get(key, defaults.get(key))
|
||||
|
||||
|
||||
def run_sao_generate(asset, manifest, gen_dir, output_dir, script_dir):
|
||||
"""Generate audio via Stable Audio Open + post-processing."""
|
||||
filename = asset["filename"]
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
wav_path = os.path.join(gen_dir, base_name + ".wav")
|
||||
ogg_path = os.path.join(output_dir, filename)
|
||||
|
||||
prompt = asset["prompt"]
|
||||
duration = asset.get("duration", 10)
|
||||
steps = get_default(manifest, asset, "steps") or 100
|
||||
cfg = get_default(manifest, asset, "cfg") or 7
|
||||
timeout = get_default(manifest, asset, "timeout") or 600
|
||||
|
||||
# Run audio-generate with --post
|
||||
cmd = [
|
||||
sys.executable, os.path.join(script_dir, "audio_connector.py"),
|
||||
"generate", prompt,
|
||||
"--duration", str(duration),
|
||||
"--steps", str(steps),
|
||||
"--cfg", str(cfg),
|
||||
"--output", wav_path,
|
||||
"--output-ogg", ogg_path,
|
||||
"--timeout", str(timeout),
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 60)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.strip()
|
||||
try:
|
||||
err = json.loads(result.stdout)
|
||||
return {"ok": False, "error": err.get("error", stderr)}
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return {"ok": False, "error": stderr or "generation failed"}
|
||||
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return {"ok": False, "error": f"Unexpected output: {result.stdout[:200]}"}
|
||||
|
||||
|
||||
def synthesize_harmonic(params, wav_path):
|
||||
"""Synthesize audio from harmonic parameters."""
|
||||
sr = 44100
|
||||
duration = params["duration"]
|
||||
fundamental = params["fundamental"]
|
||||
harmonics = params.get("harmonics", [])
|
||||
attack_ms = params.get("attack_ms", 10)
|
||||
sustain_ratio = params.get("sustain_ratio", 0.2)
|
||||
decay = params.get("decay", "exponential")
|
||||
|
||||
n = int(sr * duration)
|
||||
t = np.linspace(0, duration, n, endpoint=False)
|
||||
|
||||
# Fundamental
|
||||
signal = np.sin(2 * np.pi * fundamental * t)
|
||||
|
||||
# Add harmonics
|
||||
for h in harmonics:
|
||||
freq = h["freq"]
|
||||
db = h["db"]
|
||||
amplitude = 10 ** (db / 20)
|
||||
signal = signal + amplitude * np.sin(2 * np.pi * freq * t)
|
||||
|
||||
# Envelope: attack + sustain + decay
|
||||
attack_s = attack_ms / 1000
|
||||
attack_env = np.minimum(t / attack_s, 1.0) if attack_s > 0 else np.ones(n)
|
||||
|
||||
sustain_end = duration * sustain_ratio
|
||||
if decay == "exponential":
|
||||
# Decay rate: reach -60dB by end of duration
|
||||
decay_rate = 6.9 / (duration - sustain_end) if duration > sustain_end else 10
|
||||
decay_env = np.where(t < sustain_end, 1.0, np.exp(-decay_rate * (t - sustain_end)))
|
||||
else:
|
||||
# Linear decay
|
||||
decay_env = np.where(t < sustain_end, 1.0,
|
||||
1.0 - (t - sustain_end) / (duration - sustain_end))
|
||||
|
||||
envelope = attack_env * decay_env
|
||||
signal = signal * envelope
|
||||
|
||||
# Normalize to peak
|
||||
peak = np.max(np.abs(signal))
|
||||
if peak > 0:
|
||||
signal = signal / peak * 0.9
|
||||
|
||||
# Write WAV
|
||||
int_samples = np.clip(signal * 32767, -32767, 32767).astype(np.int16)
|
||||
with wave.open(wav_path, "w") as f:
|
||||
f.setnchannels(1)
|
||||
f.setsampwidth(2)
|
||||
f.setframerate(sr)
|
||||
f.writeframes(int_samples.tobytes())
|
||||
|
||||
return wav_path
|
||||
|
||||
|
||||
def run_synth(asset, manifest, gen_dir, output_dir, script_dir):
|
||||
"""Synthesize audio from harmonic parameters + post-process."""
|
||||
filename = asset["filename"]
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
wav_path = os.path.join(gen_dir, base_name + "_synth.wav")
|
||||
ogg_path = os.path.join(output_dir, filename)
|
||||
|
||||
synth_params = asset.get("synth")
|
||||
if not synth_params:
|
||||
return {"ok": False, "error": "No synth parameters provided"}
|
||||
|
||||
synth_type = synth_params.get("type", "harmonic")
|
||||
if synth_type != "harmonic":
|
||||
return {"ok": False, "error": f"Unknown synth type: {synth_type}"}
|
||||
|
||||
try:
|
||||
synthesize_harmonic(synth_params, wav_path)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"Synthesis failed: {e}"}
|
||||
|
||||
# Post-process: normalize + convert (skip trim for synth — no silence to trim)
|
||||
post_script = os.path.join(script_dir, "audio_post.py")
|
||||
lufs = get_default(manifest, asset, "lufs") or -16
|
||||
quality = get_default(manifest, asset, "quality") or 6
|
||||
|
||||
# Normalize
|
||||
norm_path = os.path.join(gen_dir, base_name + "_norm.wav")
|
||||
cmd = [sys.executable, post_script, "normalize", wav_path, "--output", norm_path,
|
||||
"--lufs", str(lufs)]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
return {"ok": False, "error": f"Normalize failed: {result.stderr.strip()}"}
|
||||
|
||||
# Convert to OGG
|
||||
cmd = [sys.executable, post_script, "convert", norm_path, "--output", ogg_path,
|
||||
"--quality", str(quality)]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
return {"ok": False, "error": f"Convert failed: {result.stderr.strip()}"}
|
||||
|
||||
# Clean up intermediate
|
||||
try:
|
||||
os.remove(norm_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
ogg_size = os.path.getsize(ogg_path)
|
||||
return {
|
||||
"ok": True,
|
||||
"file": wav_path,
|
||||
"ogg_file": ogg_path,
|
||||
"ogg_size_bytes": ogg_size,
|
||||
"synth_params": synth_params,
|
||||
"post_processed": True,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: audio_batch.py manifest.json [--dry-run] [--only ID,ID,...] [--skip-existing]",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
manifest_path = sys.argv[1]
|
||||
dry_run = "--dry-run" in sys.argv
|
||||
skip_existing = "--skip-existing" in sys.argv
|
||||
|
||||
only_ids = None
|
||||
for i, arg in enumerate(sys.argv):
|
||||
if arg == "--only" and i + 1 < len(sys.argv):
|
||||
only_ids = set(sys.argv[i + 1].split(","))
|
||||
|
||||
manifest = load_manifest(manifest_path)
|
||||
manifest_dir = os.path.dirname(os.path.abspath(manifest_path))
|
||||
output_dir, gen_dir, git_root = resolve_paths(manifest, manifest_dir)
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
assets = manifest.get("assets", [])
|
||||
if only_ids:
|
||||
assets = [a for a in assets if a["id"] in only_ids]
|
||||
|
||||
# Health check if any SAO assets
|
||||
sao_assets = [a for a in assets if a.get("method") == "sao"]
|
||||
if sao_assets and not dry_run:
|
||||
print(f"Checking SAO API health...", file=sys.stderr)
|
||||
health_cmd = [sys.executable, os.path.join(script_dir, "audio_connector.py"), "health"]
|
||||
result = subprocess.run(health_cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print(json.dumps({"ok": False, "error": "SAO API health check failed",
|
||||
"details": result.stdout.strip()}))
|
||||
sys.exit(1)
|
||||
print(f" SAO API is up.", file=sys.stderr)
|
||||
|
||||
total = len(assets)
|
||||
results = []
|
||||
success = 0
|
||||
failed = 0
|
||||
skipped = 0
|
||||
|
||||
print(f"Processing {total} assets from {os.path.basename(manifest_path)}...", file=sys.stderr)
|
||||
if dry_run:
|
||||
print(" (dry run — no generation will occur)", file=sys.stderr)
|
||||
|
||||
for i, asset in enumerate(assets, 1):
|
||||
asset_id = asset["id"]
|
||||
filename = asset["filename"]
|
||||
method = asset.get("method", "sao")
|
||||
|
||||
print(f"\n[{i}/{total}] {asset_id}: {filename} ({method})", file=sys.stderr)
|
||||
|
||||
if skip_existing:
|
||||
ogg_path = os.path.join(output_dir, filename)
|
||||
if os.path.exists(ogg_path):
|
||||
print(f" Skipping — already exists", file=sys.stderr)
|
||||
results.append({"id": asset_id, "status": "skipped", "reason": "exists"})
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
print(f" Would generate: {filename}", file=sys.stderr)
|
||||
if method == "sao":
|
||||
print(f" Prompt: {asset.get('prompt', '(none)')[:80]}...", file=sys.stderr)
|
||||
elif method == "synth":
|
||||
synth = asset.get("synth", {})
|
||||
print(f" Synth: {synth.get('fundamental')}Hz, {synth.get('duration')}s",
|
||||
file=sys.stderr)
|
||||
results.append({"id": asset_id, "status": "dry_run"})
|
||||
continue
|
||||
|
||||
if method == "sao":
|
||||
result = run_sao_generate(asset, manifest, gen_dir, output_dir, script_dir)
|
||||
elif method == "synth":
|
||||
result = run_synth(asset, manifest, gen_dir, output_dir, script_dir)
|
||||
else:
|
||||
result = {"ok": False, "error": f"Unknown method: {method}"}
|
||||
|
||||
result["id"] = asset_id
|
||||
if result.get("ok"):
|
||||
success += 1
|
||||
result["status"] = "success"
|
||||
print(f" OK → {result.get('ogg_file', filename)}", file=sys.stderr)
|
||||
else:
|
||||
failed += 1
|
||||
result["status"] = "failed"
|
||||
print(f" FAILED: {result.get('error', 'unknown')}", file=sys.stderr)
|
||||
|
||||
results.append(result)
|
||||
|
||||
# Summary
|
||||
summary = {
|
||||
"ok": failed == 0,
|
||||
"total": total,
|
||||
"success": success,
|
||||
"failed": failed,
|
||||
"skipped": skipped,
|
||||
"results": results,
|
||||
}
|
||||
print(json.dumps(summary, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,340 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stable Audio Open connector — Gradio API wrapper.
|
||||
|
||||
Talks to the Stable Audio Open Gradio app at tower-of-joy:11500.
|
||||
Uses the async Gradio API pattern: POST to submit, SSE stream for results.
|
||||
|
||||
Usage:
|
||||
python3 audio_connector.py generate "prompt text" [--duration 10] [--steps 100] [--cfg 7] [--output file.wav] [--post]
|
||||
python3 audio_connector.py health
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import shutil
|
||||
|
||||
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
|
||||
|
||||
def load_config():
|
||||
with open(CONFIG_PATH) as f:
|
||||
return json.load(f)
|
||||
|
||||
def get_base_url():
|
||||
config = load_config()
|
||||
return config.get("stable_audio_url", "http://tower-of-joy:11500")
|
||||
|
||||
def health():
|
||||
"""Check if the Stable Audio API is reachable."""
|
||||
base = get_base_url()
|
||||
try:
|
||||
req = urllib.request.Request(f"{base}/config", method="GET")
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
version = data.get("version", "unknown")
|
||||
# Extract component info for the generate endpoint
|
||||
api_names = []
|
||||
for dep in data.get("dependencies", []):
|
||||
name = dep.get("api_name", "")
|
||||
if name and not name.startswith("js_"):
|
||||
api_names.append(name)
|
||||
print(json.dumps({
|
||||
"ok": True,
|
||||
"url": base,
|
||||
"gradio_version": version,
|
||||
"api_endpoints": api_names
|
||||
}, indent=2))
|
||||
except Exception as e:
|
||||
print(json.dumps({
|
||||
"ok": False,
|
||||
"url": base,
|
||||
"error": str(e)
|
||||
}, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
def post_process(wav_path, ogg_path=None, lufs=-16, quality=6, threshold=-50):
|
||||
"""Run trim + normalize + convert on a WAV file via audio-post pipeline."""
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
post_script = os.path.join(script_dir, "audio_post.py")
|
||||
if ogg_path is None:
|
||||
ogg_path = os.path.splitext(wav_path)[0] + ".ogg"
|
||||
cmd = [
|
||||
sys.executable, post_script, "pipeline", wav_path,
|
||||
"--output", ogg_path,
|
||||
"--lufs", str(lufs),
|
||||
"--quality", str(quality),
|
||||
"--threshold", str(threshold),
|
||||
]
|
||||
print(f" Post-processing → {os.path.basename(ogg_path)}...", file=sys.stderr)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
return {"ok": False, "error": f"Post-processing failed: {result.stderr.strip()}"}
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return {"ok": True, "output": ogg_path}
|
||||
|
||||
|
||||
def generate(prompt, duration=10.0, steps=100, cfg=7.0, output=None, timeout=600,
|
||||
post=False, output_ogg=None):
|
||||
"""
|
||||
Generate audio from a text prompt.
|
||||
|
||||
Args:
|
||||
prompt: Text description of the audio to generate
|
||||
duration: Duration in seconds (0-47, default 10)
|
||||
steps: Number of diffusion steps (default 100, lower = faster but lower quality)
|
||||
cfg: Classifier-free guidance scale (default 7)
|
||||
output: Output file path (default: auto-named in current directory)
|
||||
timeout: Maximum wait time in seconds (default 600 = 10 minutes)
|
||||
post: If True, run trim+normalize+convert after generation
|
||||
output_ogg: OGG output path when post=True (default: same basename .ogg)
|
||||
"""
|
||||
base = get_base_url()
|
||||
api_url = f"{base}/gradio_api/call/generate_audio"
|
||||
|
||||
if output is None:
|
||||
# Auto-name: sanitize prompt to a filename
|
||||
safe = "".join(c if c.isalnum() or c in "-_ " else "" for c in prompt[:40])
|
||||
safe = safe.strip().replace(" ", "_").lower()
|
||||
output = f"{safe}_{int(duration)}s.wav"
|
||||
|
||||
# Step 1: Submit the generation request
|
||||
payload = json.dumps({"data": [prompt, duration, steps, cfg]})
|
||||
req = urllib.request.Request(
|
||||
api_url,
|
||||
data=payload.encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST"
|
||||
)
|
||||
|
||||
print(f"Submitting generation request...", file=sys.stderr)
|
||||
print(f" Prompt: {prompt}", file=sys.stderr)
|
||||
print(f" Duration: {duration}s, Steps: {steps}, CFG: {cfg}", file=sys.stderr)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
result = json.loads(resp.read())
|
||||
event_id = result.get("event_id")
|
||||
if not event_id:
|
||||
print(json.dumps({"ok": False, "error": "No event_id returned", "response": result}, indent=2))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"ok": False, "error": f"Submit failed: {e}"}, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
print(f" Event ID: {event_id}", file=sys.stderr)
|
||||
print(f" Waiting for generation (timeout: {timeout}s)...", file=sys.stderr)
|
||||
|
||||
# Step 2: Poll the SSE stream for results
|
||||
stream_url = f"{api_url}/{event_id}"
|
||||
start_time = time.time()
|
||||
result_data = None
|
||||
last_status = None
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
req = urllib.request.Request(stream_url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
# Read SSE events
|
||||
current_event = None
|
||||
for line_bytes in resp:
|
||||
line = line_bytes.decode("utf-8").strip()
|
||||
|
||||
if line.startswith("event: "):
|
||||
current_event = line[7:]
|
||||
elif line.startswith("data: ") and current_event:
|
||||
data_str = line[6:]
|
||||
|
||||
if current_event == "heartbeat":
|
||||
elapsed = int(time.time() - start_time)
|
||||
if elapsed % 30 == 0 and elapsed > 0:
|
||||
print(f" Still generating... ({elapsed}s elapsed)", file=sys.stderr)
|
||||
continue
|
||||
|
||||
if current_event == "error":
|
||||
error_msg = data_str
|
||||
try:
|
||||
error_msg = json.loads(data_str)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
print(json.dumps({"ok": False, "error": "Generation failed", "details": error_msg}, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
if current_event == "complete":
|
||||
try:
|
||||
result_data = json.loads(data_str)
|
||||
except json.JSONDecodeError:
|
||||
result_data = data_str
|
||||
break
|
||||
|
||||
if current_event == "progress":
|
||||
try:
|
||||
progress = json.loads(data_str)
|
||||
# Gradio progress events vary; log what we get
|
||||
status = str(progress)[:80]
|
||||
if status != last_status:
|
||||
print(f" Progress: {status}", file=sys.stderr)
|
||||
last_status = status
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
continue
|
||||
|
||||
if result_data is not None:
|
||||
break
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
# Connection dropped — retry after brief pause
|
||||
time.sleep(2)
|
||||
continue
|
||||
except Exception as e:
|
||||
print(json.dumps({"ok": False, "error": f"Stream error: {e}"}, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
if result_data is None:
|
||||
print(json.dumps({"ok": False, "error": f"Generation timed out after {timeout}s"}, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
# Step 3: Download the audio file
|
||||
# Gradio returns file info in the data array
|
||||
elapsed = round(time.time() - start_time, 1)
|
||||
print(f" Generation complete ({elapsed}s)", file=sys.stderr)
|
||||
|
||||
try:
|
||||
# result_data is typically [{"path": "...", "url": "...", ...}] or similar
|
||||
if isinstance(result_data, list) and len(result_data) > 0:
|
||||
audio_info = result_data[0]
|
||||
elif isinstance(result_data, dict) and "data" in result_data:
|
||||
audio_info = result_data["data"][0] if result_data["data"] else None
|
||||
else:
|
||||
audio_info = result_data
|
||||
|
||||
# Extract the file URL
|
||||
file_url = None
|
||||
if isinstance(audio_info, dict):
|
||||
file_url = audio_info.get("url") or audio_info.get("path")
|
||||
elif isinstance(audio_info, str):
|
||||
file_url = audio_info
|
||||
|
||||
if not file_url:
|
||||
print(json.dumps({
|
||||
"ok": False,
|
||||
"error": "Could not extract audio URL from response",
|
||||
"response": result_data
|
||||
}, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
# Handle relative URLs
|
||||
if file_url.startswith("/"):
|
||||
file_url = f"{base}{file_url}"
|
||||
elif not file_url.startswith("http"):
|
||||
file_url = f"{base}/file={file_url}"
|
||||
|
||||
# Download the file
|
||||
print(f" Downloading to {output}...", file=sys.stderr)
|
||||
req = urllib.request.Request(file_url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
with open(output, "wb") as f:
|
||||
shutil.copyfileobj(resp, f)
|
||||
|
||||
file_size = os.path.getsize(output)
|
||||
result_json = {
|
||||
"ok": True,
|
||||
"file": output,
|
||||
"size_bytes": file_size,
|
||||
"duration_requested": duration,
|
||||
"steps": steps,
|
||||
"cfg": cfg,
|
||||
"prompt": prompt,
|
||||
"generation_time_s": elapsed
|
||||
}
|
||||
|
||||
if post:
|
||||
post_result = post_process(output, ogg_path=output_ogg)
|
||||
if not post_result.get("ok"):
|
||||
result_json["post_processed"] = False
|
||||
result_json["post_error"] = post_result.get("error", "unknown")
|
||||
else:
|
||||
result_json["post_processed"] = True
|
||||
result_json["ogg_file"] = post_result.get("output", output_ogg)
|
||||
ogg_size = os.path.getsize(result_json["ogg_file"])
|
||||
result_json["ogg_size_bytes"] = ogg_size
|
||||
|
||||
print(json.dumps(result_json, indent=2))
|
||||
|
||||
except Exception as e:
|
||||
print(json.dumps({
|
||||
"ok": False,
|
||||
"error": f"Download failed: {e}",
|
||||
"response": result_data
|
||||
}, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage:")
|
||||
print(" audio_connector.py health")
|
||||
print(" audio_connector.py generate 'prompt' [--duration N] [--steps N] [--cfg N] [--output file.wav] [--timeout N]")
|
||||
sys.exit(1)
|
||||
|
||||
cmd = sys.argv[1]
|
||||
|
||||
if cmd == "health":
|
||||
health()
|
||||
elif cmd == "generate":
|
||||
if len(sys.argv) < 3:
|
||||
print("Error: prompt required", file=sys.stderr)
|
||||
print("Usage: audio_connector.py generate 'prompt' [--duration N] [--steps N] [--cfg N] [--output file.wav]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
prompt = sys.argv[2]
|
||||
duration = 10.0
|
||||
steps = 100
|
||||
cfg = 7.0
|
||||
output = None
|
||||
timeout = 600
|
||||
post = False
|
||||
output_ogg = None
|
||||
|
||||
# Parse optional args
|
||||
i = 3
|
||||
while i < len(sys.argv):
|
||||
if sys.argv[i] == "--duration" and i + 1 < len(sys.argv):
|
||||
duration = float(sys.argv[i + 1])
|
||||
i += 2
|
||||
elif sys.argv[i] == "--steps" and i + 1 < len(sys.argv):
|
||||
steps = int(sys.argv[i + 1])
|
||||
i += 2
|
||||
elif sys.argv[i] == "--cfg" and i + 1 < len(sys.argv):
|
||||
cfg = float(sys.argv[i + 1])
|
||||
i += 2
|
||||
elif sys.argv[i] == "--output" and i + 1 < len(sys.argv):
|
||||
output = sys.argv[i + 1]
|
||||
i += 2
|
||||
elif sys.argv[i] == "--timeout" and i + 1 < len(sys.argv):
|
||||
timeout = int(sys.argv[i + 1])
|
||||
i += 2
|
||||
elif sys.argv[i] == "--post":
|
||||
post = True
|
||||
i += 1
|
||||
elif sys.argv[i] == "--output-ogg" and i + 1 < len(sys.argv):
|
||||
output_ogg = sys.argv[i + 1]
|
||||
post = True # --output-ogg implies --post
|
||||
i += 2
|
||||
else:
|
||||
print(f"Unknown argument: {sys.argv[i]}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
generate(prompt, duration=duration, steps=steps, cfg=cfg, output=output,
|
||||
timeout=timeout, post=post, output_ogg=output_ogg)
|
||||
else:
|
||||
print(f"Unknown command: {cmd}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,171 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Audio post-processing wrapper around ffmpeg.
|
||||
|
||||
Subcommands:
|
||||
convert — WAV to OGG (libvorbis, quality 6)
|
||||
normalize — LUFS normalize to -16 LUFS (broadcast standard)
|
||||
trim — Remove leading/trailing silence
|
||||
pipeline — trim + normalize + convert (full post-processing chain)
|
||||
|
||||
All operations write to a new file (never overwrites input).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import shutil
|
||||
import json
|
||||
|
||||
|
||||
def check_ffmpeg():
|
||||
if not shutil.which("ffmpeg"):
|
||||
print(json.dumps({"ok": False, "error": "ffmpeg not found in PATH"}))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run_ffmpeg(args, description):
|
||||
"""Run ffmpeg, capture output, return success."""
|
||||
cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error"] + args
|
||||
print(f" {description}", file=sys.stderr)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print(json.dumps({
|
||||
"ok": False,
|
||||
"error": f"ffmpeg failed: {result.stderr.strip()}",
|
||||
"command": " ".join(cmd)
|
||||
}))
|
||||
sys.exit(1)
|
||||
return True
|
||||
|
||||
|
||||
def cmd_convert(args):
|
||||
"""Convert WAV to OGG (libvorbis)."""
|
||||
output = args.output or args.input.rsplit(".", 1)[0] + ".ogg"
|
||||
run_ffmpeg(
|
||||
["-i", args.input, "-c:a", "libvorbis", "-q:a", str(args.quality), output],
|
||||
f"converting {os.path.basename(args.input)} → {os.path.basename(output)}"
|
||||
)
|
||||
size = os.path.getsize(output)
|
||||
print(json.dumps({"ok": True, "output": output, "size_bytes": size}))
|
||||
|
||||
|
||||
def cmd_normalize(args):
|
||||
"""LUFS normalize audio file."""
|
||||
output = args.output or _suffixed(args.input, "_norm")
|
||||
run_ffmpeg(
|
||||
["-i", args.input, "-af",
|
||||
f"loudnorm=I={args.lufs}:LRA=11:TP=-1",
|
||||
output],
|
||||
f"normalizing to {args.lufs} LUFS"
|
||||
)
|
||||
print(json.dumps({"ok": True, "output": output}))
|
||||
|
||||
|
||||
def cmd_trim(args):
|
||||
"""Trim leading/trailing silence."""
|
||||
output = args.output or _suffixed(args.input, "_trimmed")
|
||||
af = (
|
||||
"silenceremove=start_periods=1:start_silence=0.05"
|
||||
f":start_threshold={args.threshold}dB,"
|
||||
"areverse,"
|
||||
"silenceremove=start_periods=1:start_silence=0.05"
|
||||
f":start_threshold={args.threshold}dB,"
|
||||
"areverse"
|
||||
)
|
||||
run_ffmpeg(
|
||||
["-i", args.input, "-af", af, output],
|
||||
f"trimming silence (threshold: {args.threshold}dB)"
|
||||
)
|
||||
print(json.dumps({"ok": True, "output": output}))
|
||||
|
||||
|
||||
def cmd_pipeline(args):
|
||||
"""Full post-processing: trim → normalize → convert to OGG."""
|
||||
base = args.input.rsplit(".", 1)[0]
|
||||
trimmed = base + "_trimmed.wav"
|
||||
normalized = base + "_norm.wav"
|
||||
output = args.output or base + ".ogg"
|
||||
|
||||
# Step 1: trim
|
||||
af_trim = (
|
||||
"silenceremove=start_periods=1:start_silence=0.05"
|
||||
f":start_threshold={args.threshold}dB,"
|
||||
"areverse,"
|
||||
"silenceremove=start_periods=1:start_silence=0.05"
|
||||
f":start_threshold={args.threshold}dB,"
|
||||
"areverse"
|
||||
)
|
||||
run_ffmpeg(
|
||||
["-i", args.input, "-af", af_trim, trimmed],
|
||||
"step 1/3: trimming silence"
|
||||
)
|
||||
|
||||
# Step 2: normalize
|
||||
run_ffmpeg(
|
||||
["-i", trimmed, "-af",
|
||||
f"loudnorm=I={args.lufs}:LRA=11:TP=-1",
|
||||
normalized],
|
||||
f"step 2/3: normalizing to {args.lufs} LUFS"
|
||||
)
|
||||
|
||||
# Step 3: convert
|
||||
run_ffmpeg(
|
||||
["-i", normalized, "-c:a", "libvorbis", "-q:a", str(args.quality), output],
|
||||
f"step 3/3: converting to OGG"
|
||||
)
|
||||
|
||||
# Clean up intermediates
|
||||
os.remove(trimmed)
|
||||
os.remove(normalized)
|
||||
|
||||
size = os.path.getsize(output)
|
||||
print(json.dumps({"ok": True, "output": output, "size_bytes": size}))
|
||||
|
||||
|
||||
def _suffixed(path, suffix):
|
||||
base, ext = os.path.splitext(path)
|
||||
return base + suffix + ext
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Audio post-processing (ffmpeg wrapper)")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# convert
|
||||
p = sub.add_parser("convert", help="WAV → OGG")
|
||||
p.add_argument("input", help="Input WAV file")
|
||||
p.add_argument("--output", "-o", help="Output file (default: same name .ogg)")
|
||||
p.add_argument("--quality", "-q", type=int, default=6, help="Vorbis quality 0-10 (default: 6)")
|
||||
p.set_defaults(func=cmd_convert)
|
||||
|
||||
# normalize
|
||||
p = sub.add_parser("normalize", help="LUFS normalize")
|
||||
p.add_argument("input", help="Input audio file")
|
||||
p.add_argument("--output", "-o", help="Output file")
|
||||
p.add_argument("--lufs", type=float, default=-16, help="Target LUFS (default: -16)")
|
||||
p.set_defaults(func=cmd_normalize)
|
||||
|
||||
# trim
|
||||
p = sub.add_parser("trim", help="Trim silence")
|
||||
p.add_argument("input", help="Input audio file")
|
||||
p.add_argument("--output", "-o", help="Output file")
|
||||
p.add_argument("--threshold", type=int, default=-50, help="Silence threshold in dB (default: -50)")
|
||||
p.set_defaults(func=cmd_trim)
|
||||
|
||||
# pipeline
|
||||
p = sub.add_parser("pipeline", help="Full post-processing: trim + normalize + convert")
|
||||
p.add_argument("input", help="Input WAV file")
|
||||
p.add_argument("--output", "-o", help="Output OGG file")
|
||||
p.add_argument("--quality", "-q", type=int, default=6, help="Vorbis quality 0-10 (default: 6)")
|
||||
p.add_argument("--lufs", type=float, default=-16, help="Target LUFS (default: -16)")
|
||||
p.add_argument("--threshold", type=int, default=-50, help="Silence threshold in dB (default: -50)")
|
||||
p.set_defaults(func=cmd_pipeline)
|
||||
|
||||
args = parser.parse_args()
|
||||
check_ffmpeg()
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"qdrant_url": "http://tower-of-joy:6333",
|
||||
"ollama_url": "http://tower-of-joy:11434",
|
||||
"stable_audio_url": "http://tower-of-joy:11500",
|
||||
"collection": "commonwealth",
|
||||
"embed_model": "nomic-embed-text",
|
||||
"embed_dimensions": 768
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Decision ID management — claim, query, and validate decision IDs.
|
||||
# Usage:
|
||||
# decision next [D|Q|R] Show next available ID
|
||||
# decision claim <D|Q|R> <domain> [title] Claim next ID (reserves in DB)
|
||||
# decision check-dupes Check for duplicate IDs in markdown
|
||||
# decision sync Sync markdown -> DB
|
||||
exec python3 "$(dirname "$0")/decisions_sync.py" "$@"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sync decisions/*.md domain files into the SQLite database. Whitelistable command.
|
||||
# Usage: decisions-sync
|
||||
exec python3 "$(dirname "$0")/decisions_sync.py" sync "$@"
|
||||
@@ -1,538 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Commonwealth Decisions Sync — parse decisions/*.md domain files into SQLite.
|
||||
|
||||
Reads all markdown files from the decisions/ directory, parses decision blocks
|
||||
(D-NNN, Q-NNN, R-NNN), extracts metadata, and upserts into the decisions and
|
||||
decision_refs tables.
|
||||
|
||||
Usage:
|
||||
python3 decisions_sync.py sync Parse and upsert all decisions
|
||||
python3 decisions_sync.py --help Show this help message
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
CONFIG_PATH = SCRIPT_DIR / "config.json"
|
||||
SCHEMA_PATH = SCRIPT_DIR.parent / "schema.sql"
|
||||
WORKTREE_ROOT = SCRIPT_DIR.parent.parent
|
||||
DECISIONS_DIR = WORKTREE_ROOT / "decisions"
|
||||
# Shared database lives in the parent of all worktrees (three levels up from db/connectors/).
|
||||
DB_PATH = (SCRIPT_DIR / ".." / ".." / ".." / "settledreach.db").resolve()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config / DB (same pattern as sqlite_connector.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_config():
|
||||
"""Load config.json and resolve the SQLite database path."""
|
||||
with open(CONFIG_PATH, "r") as f:
|
||||
cfg = json.load(f)
|
||||
cfg["sqlite_db_resolved"] = str(DB_PATH)
|
||||
return cfg
|
||||
|
||||
|
||||
def get_connection(cfg):
|
||||
"""Return an sqlite3 connection with WAL mode and foreign keys enabled."""
|
||||
conn = sqlite3.connect(cfg["sqlite_db_resolved"])
|
||||
conn.execute("PRAGMA journal_mode=WAL;")
|
||||
conn.execute("PRAGMA foreign_keys=ON;")
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Matches headings like: ### D-008: Action pillar design principles
|
||||
HEADING_RE = re.compile(r"^###\s+((?:D|Q|R)-\d{3}):\s+(.+)$")
|
||||
|
||||
# Matches metadata lines like: - **Date:** 2026-02-08
|
||||
DATE_RE = re.compile(r"^\s*-\s+\*\*(?:Date|Rejected):\*\*\s+(\d{4}-\d{2}-\d{2})")
|
||||
STATUS_RE = re.compile(r"^\s*-\s+\*\*Status:\*\*\s+(.+)")
|
||||
ROUND_RE = re.compile(r"Round\s+(\d+)", re.IGNORECASE)
|
||||
|
||||
# Cross-reference patterns in body text
|
||||
REF_RE = re.compile(r"(?:D|Q|R)-\d{3}")
|
||||
|
||||
# Contextual reference patterns (on specific metadata lines)
|
||||
SUPERSEDES_RE = re.compile(r"^\s*-\s+\*\*Supersedes:\*\*", re.IGNORECASE)
|
||||
SUPERSEDED_BY_RE = re.compile(r"^\s*-\s+\*\*Superseded\s+by:\*\*", re.IGNORECASE)
|
||||
RESOLVES_RE = re.compile(r"^\s*-\s+\*\*Resolves:\*\*", re.IGNORECASE)
|
||||
CROSS_REF_RE = re.compile(r"^\s*-\s+\*\*Cross-reference:\*\*", re.IGNORECASE)
|
||||
DEPENDS_RE = re.compile(r"^\s*-\s+\*\*Depends\s+on:\*\*", re.IGNORECASE)
|
||||
|
||||
# Title may include [SUPERSEDED] suffix
|
||||
SUPERSEDED_TITLE_RE = re.compile(r"\s*\[SUPERSEDED\]\s*$", re.IGNORECASE)
|
||||
|
||||
|
||||
def classify_id(decision_id):
|
||||
"""Return the type string for a decision ID prefix."""
|
||||
prefix = decision_id[0]
|
||||
return {"D": "confirmed", "Q": "question", "R": "rejected"}[prefix]
|
||||
|
||||
|
||||
def infer_status(decision_id, title, body_lines):
|
||||
"""Infer the status of a decision from its content."""
|
||||
id_type = classify_id(decision_id)
|
||||
|
||||
# Rejected alternatives are always 'rejected' (maps to our status concept)
|
||||
if id_type == "rejected":
|
||||
return "active"
|
||||
|
||||
# Check for [SUPERSEDED] in title
|
||||
if SUPERSEDED_TITLE_RE.search(title):
|
||||
return "superseded"
|
||||
|
||||
# Check body for "Superseded by:" line
|
||||
for line in body_lines:
|
||||
if SUPERSEDED_BY_RE.match(line):
|
||||
return "superseded"
|
||||
|
||||
# Questions: check if resolved
|
||||
if id_type == "question":
|
||||
for line in body_lines:
|
||||
m = STATUS_RE.match(line)
|
||||
if m:
|
||||
status_text = m.group(1).strip()
|
||||
lower = status_text.lower()
|
||||
# "Partially resolved/scoped" or qualified "X resolved...Remaining" = still open
|
||||
if "partial" in lower or "remaining" in lower:
|
||||
return "open"
|
||||
# Clean "Resolved ->" pattern = fully resolved
|
||||
if lower.startswith("resolved"):
|
||||
return "resolved"
|
||||
# Everything else (not yet discussed, etc.) = open
|
||||
return "open"
|
||||
return "open"
|
||||
|
||||
return "active"
|
||||
|
||||
|
||||
def extract_round(body_lines):
|
||||
"""Try to find a Round number from the decision body."""
|
||||
for line in body_lines:
|
||||
m = ROUND_RE.search(line)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def extract_date(body_lines):
|
||||
"""Extract date from metadata lines."""
|
||||
for line in body_lines:
|
||||
m = DATE_RE.match(line)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def extract_refs(decision_id, body_lines):
|
||||
"""
|
||||
Extract typed references from the body of a decision block.
|
||||
|
||||
Returns a list of (target_id, ref_type, note) tuples.
|
||||
"""
|
||||
refs = []
|
||||
seen = set()
|
||||
|
||||
for line in body_lines:
|
||||
# Determine the ref_type based on the line context
|
||||
if SUPERSEDES_RE.match(line):
|
||||
ref_type = "supersedes"
|
||||
elif SUPERSEDED_BY_RE.match(line):
|
||||
# The *other* decision supersedes *this* one.
|
||||
# We record it as the other decision superseding us,
|
||||
# but from our perspective we store it as a reference.
|
||||
# The canonical direction: source supersedes target.
|
||||
# Here source=other, target=us. We'll record source=us,
|
||||
# target=other with ref_type='references' (since we're
|
||||
# the superseded party; the superseder's block carries
|
||||
# the 'supersedes' ref).
|
||||
ref_type = "references"
|
||||
elif RESOLVES_RE.match(line):
|
||||
ref_type = "resolves"
|
||||
elif DEPENDS_RE.match(line):
|
||||
ref_type = "depends_on"
|
||||
elif CROSS_REF_RE.match(line):
|
||||
ref_type = "references"
|
||||
else:
|
||||
ref_type = "references"
|
||||
|
||||
# Find all decision IDs on this line
|
||||
for target in REF_RE.findall(line):
|
||||
if target == decision_id:
|
||||
continue # skip self-references
|
||||
key = (target, ref_type)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
note = line.strip().lstrip("- ").rstrip()
|
||||
# Truncate note to something reasonable
|
||||
if len(note) > 200:
|
||||
note = note[:197] + "..."
|
||||
refs.append((target, ref_type, note))
|
||||
|
||||
return refs
|
||||
|
||||
|
||||
def parse_file(filepath):
|
||||
"""
|
||||
Parse a single decisions/*.md file into a list of decision dicts.
|
||||
|
||||
Each dict has: id, type, domain, title, status, round, date, file_path,
|
||||
and a refs list of (target_id, ref_type, note).
|
||||
"""
|
||||
domain = filepath.stem # e.g. "architecture" from "architecture.md"
|
||||
rel_path = str(filepath.relative_to(WORKTREE_ROOT))
|
||||
text = filepath.read_text(encoding="utf-8")
|
||||
lines = text.split("\n")
|
||||
|
||||
decisions = []
|
||||
current_id = None
|
||||
current_title = None
|
||||
current_body = []
|
||||
|
||||
def flush():
|
||||
if current_id is None:
|
||||
return
|
||||
clean_title = SUPERSEDED_TITLE_RE.sub("", current_title).strip()
|
||||
decisions.append({
|
||||
"id": current_id,
|
||||
"type": classify_id(current_id),
|
||||
"domain": domain,
|
||||
"title": clean_title,
|
||||
"status": infer_status(current_id, current_title, current_body),
|
||||
"round": extract_round(current_body),
|
||||
"date": extract_date(current_body),
|
||||
"file_path": rel_path,
|
||||
"refs": extract_refs(current_id, current_body),
|
||||
})
|
||||
|
||||
for line in lines:
|
||||
m = HEADING_RE.match(line)
|
||||
if m:
|
||||
flush()
|
||||
current_id = m.group(1)
|
||||
current_title = m.group(2)
|
||||
current_body = []
|
||||
elif current_id is not None:
|
||||
# Stop collecting body at the next --- separator or new ### heading
|
||||
if line.strip() == "---":
|
||||
flush()
|
||||
current_id = None
|
||||
current_title = None
|
||||
current_body = []
|
||||
else:
|
||||
current_body.append(line)
|
||||
|
||||
# Flush final block (file may not end with ---)
|
||||
flush()
|
||||
|
||||
return decisions
|
||||
|
||||
|
||||
def parse_all():
|
||||
"""Parse all decisions/*.md files. Returns (decisions_list, warnings)."""
|
||||
if not DECISIONS_DIR.is_dir():
|
||||
return [], [f"Decisions directory not found: {DECISIONS_DIR}"]
|
||||
|
||||
all_decisions = []
|
||||
warnings = []
|
||||
|
||||
md_files = sorted(DECISIONS_DIR.glob("*.md"))
|
||||
# Skip README.md
|
||||
md_files = [f for f in md_files if f.name.lower() != "readme.md"]
|
||||
|
||||
if not md_files:
|
||||
warnings.append(f"No .md files found in {DECISIONS_DIR}")
|
||||
return all_decisions, warnings
|
||||
|
||||
for filepath in md_files:
|
||||
try:
|
||||
decisions = parse_file(filepath)
|
||||
all_decisions.extend(decisions)
|
||||
except Exception as exc:
|
||||
warnings.append(f"Error parsing {filepath.name}: {exc}")
|
||||
|
||||
return all_decisions, warnings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database sync
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def sync(cfg):
|
||||
"""Parse all decision files and upsert into the database."""
|
||||
decisions, warnings = parse_all()
|
||||
|
||||
if not decisions and warnings:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "No decisions parsed",
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
# Collect all known IDs for reference validation
|
||||
known_ids = {d["id"] for d in decisions}
|
||||
|
||||
conn = get_connection(cfg)
|
||||
try:
|
||||
# Ensure tables exist (idempotent)
|
||||
schema_sql = SCHEMA_PATH.read_text()
|
||||
conn.executescript(schema_sql)
|
||||
|
||||
upserted = 0
|
||||
refs_created = 0
|
||||
broken_refs = []
|
||||
|
||||
# Clear existing refs (we rebuild every sync)
|
||||
conn.execute("DELETE FROM decision_refs")
|
||||
|
||||
# Pass 1: Upsert all decisions (so foreign keys resolve in pass 2)
|
||||
for d in decisions:
|
||||
conn.execute(
|
||||
"""INSERT INTO decisions (id, type, domain, title, status, round, date, file_path, synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
type = excluded.type,
|
||||
domain = excluded.domain,
|
||||
title = excluded.title,
|
||||
status = excluded.status,
|
||||
round = excluded.round,
|
||||
date = excluded.date,
|
||||
file_path = excluded.file_path,
|
||||
synced_at = datetime('now')""",
|
||||
(d["id"], d["type"], d["domain"], d["title"],
|
||||
d["status"], d["round"], d["date"], d["file_path"]),
|
||||
)
|
||||
upserted += 1
|
||||
|
||||
# Pass 2: Insert all references (all targets now exist)
|
||||
for d in decisions:
|
||||
for target_id, ref_type, note in d["refs"]:
|
||||
if target_id not in known_ids:
|
||||
broken_refs.append(
|
||||
f"{d['id']} -> {target_id} ({ref_type}): target not found"
|
||||
)
|
||||
warnings.append(
|
||||
f"Broken reference: {d['id']} -> {target_id} "
|
||||
f"({ref_type}) in {d['file_path']}"
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO decision_refs
|
||||
(source_id, target_id, ref_type, note)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(d["id"], target_id, ref_type, note),
|
||||
)
|
||||
refs_created += 1
|
||||
except sqlite3.IntegrityError:
|
||||
pass # duplicate ref, skip
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"decisions_synced": upserted,
|
||||
"refs_created": refs_created,
|
||||
"broken_refs": len(broken_refs),
|
||||
"warnings": warnings,
|
||||
"summary": (
|
||||
f"Synced {upserted} decisions, "
|
||||
f"{refs_created} refs created, "
|
||||
f"{len(broken_refs)} broken refs, "
|
||||
f"{len(warnings)} warnings"
|
||||
),
|
||||
}
|
||||
|
||||
except sqlite3.Error as exc:
|
||||
conn.rollback()
|
||||
return {"ok": False, "error": str(exc), "warnings": warnings}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ID claiming — database is authority for ID allocation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def next_id(cfg, prefix=None):
|
||||
"""Return the next available ID for a given prefix (D, Q, R) or all."""
|
||||
conn = get_connection(cfg)
|
||||
try:
|
||||
result = {}
|
||||
prefixes = [prefix.upper()] if prefix else ["D", "Q", "R"]
|
||||
for p in prefixes:
|
||||
# Check both DB and markdown files for the highest ID
|
||||
row = conn.execute(
|
||||
"SELECT MAX(CAST(SUBSTR(id, 3) AS INTEGER)) as max_num "
|
||||
"FROM decisions WHERE id LIKE ?",
|
||||
(f"{p}-%",),
|
||||
).fetchone()
|
||||
db_max = row["max_num"] if row and row["max_num"] else 0
|
||||
|
||||
# Also scan markdown files in case they're ahead of the DB
|
||||
md_max = 0
|
||||
for filepath in sorted(DECISIONS_DIR.glob("*.md")):
|
||||
if filepath.name.lower() == "readme.md":
|
||||
continue
|
||||
text = filepath.read_text(encoding="utf-8")
|
||||
for m in re.finditer(rf"^###\s+{p}-(\d{{3}}):", text, re.MULTILINE):
|
||||
num = int(m.group(1))
|
||||
if num > md_max:
|
||||
md_max = num
|
||||
|
||||
highest = max(db_max, md_max)
|
||||
next_num = highest + 1
|
||||
result[p] = f"{p}-{next_num:03d}"
|
||||
|
||||
return {"ok": True, **result}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def claim_id(cfg, prefix, domain, title):
|
||||
"""Claim the next available ID and insert a placeholder into the DB."""
|
||||
if prefix not in ("D", "Q", "R"):
|
||||
return {"ok": False, "error": f"Invalid prefix: {prefix}. Must be D, Q, or R."}
|
||||
|
||||
type_map = {"D": "confirmed", "Q": "question", "R": "rejected"}
|
||||
status_map = {"D": "active", "Q": "open", "R": "active"}
|
||||
|
||||
nxt = next_id(cfg, prefix)
|
||||
if not nxt.get("ok"):
|
||||
return nxt
|
||||
|
||||
new_id = nxt[prefix]
|
||||
conn = get_connection(cfg)
|
||||
try:
|
||||
conn.execute(
|
||||
"""INSERT INTO decisions (id, type, domain, title, status, file_path, synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))""",
|
||||
(new_id, type_map[prefix], domain, title, status_map[prefix],
|
||||
f"decisions/{domain}.md"),
|
||||
)
|
||||
conn.commit()
|
||||
return {"ok": True, "id": new_id, "domain": domain, "title": title}
|
||||
except sqlite3.IntegrityError as exc:
|
||||
conn.rollback()
|
||||
return {"ok": False, "error": f"ID conflict: {exc}"}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def check_dupes(cfg):
|
||||
"""Check for duplicate decision IDs across all markdown files."""
|
||||
# Pre-existing collisions too deeply embedded to renumber (139+ references).
|
||||
# New collisions are prevented by the claim workflow.
|
||||
KNOWN_EXCEPTIONS = {"D-035"}
|
||||
|
||||
id_locations = {} # id -> [(file, line_number)]
|
||||
warnings = []
|
||||
|
||||
for filepath in sorted(DECISIONS_DIR.glob("*.md")):
|
||||
if filepath.name.lower() == "readme.md":
|
||||
continue
|
||||
text = filepath.read_text(encoding="utf-8")
|
||||
for i, line in enumerate(text.split("\n"), 1):
|
||||
m = HEADING_RE.match(line)
|
||||
if m:
|
||||
did = m.group(1)
|
||||
if did not in id_locations:
|
||||
id_locations[did] = []
|
||||
id_locations[did].append((filepath.name, i))
|
||||
|
||||
dupes = {did: locs for did, locs in id_locations.items()
|
||||
if len(locs) > 1 and did not in KNOWN_EXCEPTIONS}
|
||||
|
||||
if dupes:
|
||||
for did, locs in sorted(dupes.items()):
|
||||
loc_str = ", ".join(f"{f}:{ln}" for f, ln in locs)
|
||||
warnings.append(f"DUPLICATE {did}: {loc_str}")
|
||||
|
||||
return {
|
||||
"ok": len(dupes) == 0,
|
||||
"total_ids": len(id_locations),
|
||||
"duplicates": len(dupes),
|
||||
"known_exceptions": list(KNOWN_EXCEPTIONS),
|
||||
"details": warnings,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HELP_TEXT = """\
|
||||
Commonwealth Decisions Sync & ID Management
|
||||
|
||||
Usage:
|
||||
decisions_sync.py sync Parse decisions/*.md and upsert into SQLite
|
||||
decisions_sync.py next [D|Q|R] Show next available ID (all prefixes or one)
|
||||
decisions_sync.py claim <D|Q|R> <domain> [title] Claim next ID and insert placeholder
|
||||
decisions_sync.py check-dupes Check for duplicate IDs across markdown files
|
||||
decisions_sync.py --help Show this help message
|
||||
|
||||
ID claiming workflow:
|
||||
1. Agent calls 'claim D architecture "Per-game save dirs"'
|
||||
2. Gets back D-085 (or whatever is next)
|
||||
3. Agent writes D-085 in the appropriate domain file
|
||||
4. Pre-commit hook runs check-dupes to catch collisions
|
||||
|
||||
Config: {config}
|
||||
Schema: {schema}
|
||||
Source: {decisions}
|
||||
""".format(config=CONFIG_PATH, schema=SCHEMA_PATH, decisions=DECISIONS_DIR)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2 or sys.argv[1] in ("--help", "-h", "help"):
|
||||
print(HELP_TEXT)
|
||||
sys.exit(0)
|
||||
|
||||
cmd = sys.argv[1]
|
||||
|
||||
try:
|
||||
cfg = load_config()
|
||||
except (FileNotFoundError, json.JSONDecodeError) as exc:
|
||||
print(json.dumps({"ok": False, "error": f"Config error: {exc}"}, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
if cmd == "sync":
|
||||
result = sync(cfg)
|
||||
elif cmd == "next":
|
||||
result = next_id(cfg, sys.argv[2] if len(sys.argv) > 2 else None)
|
||||
elif cmd == "claim":
|
||||
if len(sys.argv) < 4:
|
||||
result = {"ok": False, "error": "Usage: claim <D|Q|R> <domain> [title]"}
|
||||
else:
|
||||
prefix = sys.argv[2].upper()
|
||||
domain = sys.argv[3]
|
||||
title = " ".join(sys.argv[4:]) if len(sys.argv) > 4 else "(unclaimed)"
|
||||
result = claim_id(cfg, prefix, domain, title)
|
||||
elif cmd == "check-dupes":
|
||||
result = check_dupes(cfg)
|
||||
else:
|
||||
result = {"ok": False, "error": f"Unknown command: {cmd}. Use --help for usage."}
|
||||
|
||||
print(json.dumps(result, indent=2))
|
||||
sys.exit(0 if result.get("ok") else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Count indexed documents in Qdrant. Whitelistable command.
|
||||
exec python3 "$(dirname "$0")/qdrant_connector.py" count
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Check Qdrant and ollama connectivity. Whitelistable command.
|
||||
exec python3 "$(dirname "$0")/qdrant_connector.py" health
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Index a file into Qdrant. Whitelistable command.
|
||||
# Usage: qdrant-index <filepath>
|
||||
exec python3 "$(dirname "$0")/qdrant_connector.py" index-file "$@"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Search the Qdrant document index. Whitelistable command.
|
||||
# Usage: qdrant-search "query text"
|
||||
exec python3 "$(dirname "$0")/qdrant_connector.py" search "$@"
|
||||
@@ -1,431 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Commonwealth Qdrant + Ollama Connector — mini MCP for vector search.
|
||||
|
||||
Usage:
|
||||
python3 qdrant_connector.py health
|
||||
python3 qdrant_connector.py create-collection
|
||||
python3 qdrant_connector.py search "some query text"
|
||||
python3 qdrant_connector.py index <id> "text to embed" [--metadata key=value ...]
|
||||
python3 qdrant_connector.py index-file <filepath>
|
||||
python3 qdrant_connector.py count
|
||||
python3 qdrant_connector.py --help
|
||||
|
||||
Requires only Python 3 stdlib (no pip dependencies).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths / Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
CONFIG_PATH = SCRIPT_DIR / "config.json"
|
||||
|
||||
|
||||
def load_config():
|
||||
"""Load config.json."""
|
||||
with open(CONFIG_PATH, "r") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP helpers (stdlib only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def http_request(url, method="GET", data=None, headers=None, timeout=30):
|
||||
"""
|
||||
Perform an HTTP request using urllib. Returns (status_code, parsed_json | raw_text).
|
||||
"""
|
||||
hdrs = {"Content-Type": "application/json"}
|
||||
if headers:
|
||||
hdrs.update(headers)
|
||||
|
||||
body = None
|
||||
if data is not None:
|
||||
body = json.dumps(data).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(url, data=body, headers=hdrs, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
try:
|
||||
return resp.status, json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return resp.status, raw
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read().decode("utf-8") if exc.fp else ""
|
||||
try:
|
||||
return exc.code, json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return exc.code, raw
|
||||
except urllib.error.URLError as exc:
|
||||
raise ConnectionError(f"Cannot reach {url}: {exc.reason}") from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Embedding helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def embed_text(cfg, text):
|
||||
"""
|
||||
Call ollama /api/embed to get an embedding vector for the given text.
|
||||
Returns a list of floats.
|
||||
"""
|
||||
url = f"{cfg['ollama_url']}/api/embed"
|
||||
payload = {"model": cfg["embed_model"], "input": text}
|
||||
status, resp = http_request(url, method="POST", data=payload)
|
||||
if status != 200:
|
||||
raise RuntimeError(f"Ollama embed failed (HTTP {status}): {resp}")
|
||||
# ollama returns {"embeddings": [[...]]}
|
||||
embeddings = resp.get("embeddings")
|
||||
if not embeddings or not embeddings[0]:
|
||||
raise RuntimeError(f"Ollama returned empty embeddings: {resp}")
|
||||
return embeddings[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Qdrant helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def qdrant_create_collection(cfg):
|
||||
"""Create (or recreate) the Qdrant collection."""
|
||||
url = f"{cfg['qdrant_url']}/collections/{cfg['collection']}"
|
||||
payload = {
|
||||
"vectors": {
|
||||
"size": cfg["embed_dimensions"],
|
||||
"distance": "Cosine",
|
||||
}
|
||||
}
|
||||
status, resp = http_request(url, method="PUT", data=payload)
|
||||
return status, resp
|
||||
|
||||
|
||||
def qdrant_upsert(cfg, points):
|
||||
"""Upsert a list of points into Qdrant."""
|
||||
url = f"{cfg['qdrant_url']}/collections/{cfg['collection']}/points"
|
||||
payload = {"points": points}
|
||||
status, resp = http_request(url, method="PUT", data=payload)
|
||||
return status, resp
|
||||
|
||||
|
||||
def qdrant_search(cfg, vector, limit=5):
|
||||
"""Search Qdrant by vector."""
|
||||
url = f"{cfg['qdrant_url']}/collections/{cfg['collection']}/points/query"
|
||||
payload = {"query": vector, "limit": limit, "with_payload": True}
|
||||
status, resp = http_request(url, method="POST", data=payload)
|
||||
return status, resp
|
||||
|
||||
|
||||
def qdrant_collection_info(cfg):
|
||||
"""Get collection info (includes point count)."""
|
||||
url = f"{cfg['qdrant_url']}/collections/{cfg['collection']}"
|
||||
status, resp = http_request(url, method="GET")
|
||||
return status, resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chunking helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def chunk_markdown(text, source_file=""):
|
||||
"""
|
||||
Split markdown by headings (# or ##). Returns a list of dicts:
|
||||
{"heading": str, "text": str, "chunk_index": int, "source_file": str}
|
||||
"""
|
||||
# Split on lines that start with one or two hashes
|
||||
pattern = re.compile(r"^(#{1,2})\s+(.+)$", re.MULTILINE)
|
||||
matches = list(pattern.finditer(text))
|
||||
|
||||
chunks = []
|
||||
|
||||
if not matches:
|
||||
# No headings — treat entire file as one chunk
|
||||
stripped = text.strip()
|
||||
if stripped:
|
||||
chunks.append({
|
||||
"heading": Path(source_file).stem if source_file else "untitled",
|
||||
"text": stripped,
|
||||
"chunk_index": 0,
|
||||
"source_file": source_file,
|
||||
})
|
||||
return chunks
|
||||
|
||||
# Text before the first heading
|
||||
preamble = text[: matches[0].start()].strip()
|
||||
if preamble:
|
||||
chunks.append({
|
||||
"heading": "(preamble)",
|
||||
"text": preamble,
|
||||
"chunk_index": 0,
|
||||
"source_file": source_file,
|
||||
})
|
||||
|
||||
for i, match in enumerate(matches):
|
||||
heading = match.group(2).strip()
|
||||
start = match.end()
|
||||
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
||||
body = text[start:end].strip()
|
||||
if body:
|
||||
chunks.append({
|
||||
"heading": heading,
|
||||
"text": body,
|
||||
"chunk_index": len(chunks),
|
||||
"source_file": source_file,
|
||||
})
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def text_to_point_id(text):
|
||||
"""Deterministic integer ID from a string (unsigned 64-bit range for Qdrant)."""
|
||||
h = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
# Qdrant accepts unsigned 64-bit integer IDs
|
||||
return int(h[:16], 16)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_health(cfg):
|
||||
"""Check connectivity to Qdrant and Ollama."""
|
||||
results = {}
|
||||
|
||||
# Qdrant health
|
||||
try:
|
||||
status, resp = http_request(f"{cfg['qdrant_url']}/healthz", method="GET", timeout=5)
|
||||
results["qdrant"] = {"reachable": True, "status": status, "response": resp}
|
||||
except ConnectionError as exc:
|
||||
results["qdrant"] = {"reachable": False, "error": str(exc)}
|
||||
|
||||
# Ollama health
|
||||
try:
|
||||
status, resp = http_request(f"{cfg['ollama_url']}/api/tags", method="GET", timeout=5)
|
||||
results["ollama"] = {"reachable": True, "status": status}
|
||||
# List available models for convenience
|
||||
if isinstance(resp, dict) and "models" in resp:
|
||||
results["ollama"]["models"] = [m.get("name", "?") for m in resp["models"]]
|
||||
except ConnectionError as exc:
|
||||
results["ollama"] = {"reachable": False, "error": str(exc)}
|
||||
|
||||
all_ok = all(v.get("reachable", False) for v in results.values())
|
||||
return {"ok": all_ok, "services": results}
|
||||
|
||||
|
||||
def cmd_create_collection(cfg):
|
||||
"""Create the Qdrant collection."""
|
||||
try:
|
||||
status, resp = qdrant_create_collection(cfg)
|
||||
success = status in (200, 201)
|
||||
return {"ok": success, "status": status, "response": resp}
|
||||
except ConnectionError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
|
||||
def cmd_search(cfg, query_text):
|
||||
"""Embed query text and search Qdrant."""
|
||||
try:
|
||||
vector = embed_text(cfg, query_text)
|
||||
status, resp = qdrant_search(cfg, vector)
|
||||
if status != 200:
|
||||
return {"ok": False, "status": status, "error": resp}
|
||||
|
||||
# Extract the points from the response
|
||||
points = resp.get("result", {}).get("points", resp.get("result", []))
|
||||
results = []
|
||||
if isinstance(points, list):
|
||||
for pt in points:
|
||||
results.append({
|
||||
"id": pt.get("id"),
|
||||
"score": pt.get("score"),
|
||||
"payload": pt.get("payload", {}),
|
||||
})
|
||||
return {"ok": True, "query": query_text, "count": len(results), "results": results}
|
||||
except (ConnectionError, RuntimeError) as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
|
||||
def cmd_index(cfg, point_id_str, text, metadata=None):
|
||||
"""Embed text and upsert a single point."""
|
||||
try:
|
||||
vector = embed_text(cfg, text)
|
||||
|
||||
# Build a numeric ID from the provided string
|
||||
try:
|
||||
point_id = int(point_id_str)
|
||||
except ValueError:
|
||||
point_id = text_to_point_id(point_id_str)
|
||||
|
||||
payload = metadata or {}
|
||||
payload["text"] = text
|
||||
|
||||
point = {"id": point_id, "vector": vector, "payload": payload}
|
||||
status, resp = qdrant_upsert(cfg, [point])
|
||||
success = status in (200, 201)
|
||||
return {"ok": success, "status": status, "point_id": point_id, "response": resp}
|
||||
except (ConnectionError, RuntimeError) as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
|
||||
def cmd_index_file(cfg, filepath):
|
||||
"""Read a markdown file, chunk it, embed each chunk, and upsert all to Qdrant."""
|
||||
fpath = Path(filepath).resolve()
|
||||
if not fpath.exists():
|
||||
return {"ok": False, "error": f"File not found: {fpath}"}
|
||||
|
||||
text = fpath.read_text(encoding="utf-8")
|
||||
source = str(fpath)
|
||||
chunks = chunk_markdown(text, source_file=source)
|
||||
|
||||
if not chunks:
|
||||
return {"ok": False, "error": "No content chunks extracted from file"}
|
||||
|
||||
points = []
|
||||
errors = []
|
||||
for chunk in chunks:
|
||||
chunk_key = f"{source}::{chunk['heading']}::{chunk['chunk_index']}"
|
||||
point_id = text_to_point_id(chunk_key)
|
||||
try:
|
||||
vector = embed_text(cfg, chunk["text"])
|
||||
except (ConnectionError, RuntimeError) as exc:
|
||||
errors.append({"chunk": chunk["heading"], "error": str(exc)})
|
||||
continue
|
||||
|
||||
points.append({
|
||||
"id": point_id,
|
||||
"vector": vector,
|
||||
"payload": {
|
||||
"source_file": chunk["source_file"],
|
||||
"heading": chunk["heading"],
|
||||
"chunk_index": chunk["chunk_index"],
|
||||
"text": chunk["text"],
|
||||
},
|
||||
})
|
||||
|
||||
if not points:
|
||||
return {"ok": False, "error": "All chunks failed to embed", "details": errors}
|
||||
|
||||
try:
|
||||
status, resp = qdrant_upsert(cfg, points)
|
||||
success = status in (200, 201)
|
||||
result = {
|
||||
"ok": success,
|
||||
"status": status,
|
||||
"file": source,
|
||||
"chunks_indexed": len(points),
|
||||
"chunks_failed": len(errors),
|
||||
"response": resp,
|
||||
}
|
||||
if errors:
|
||||
result["errors"] = errors
|
||||
return result
|
||||
except ConnectionError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
|
||||
def cmd_count(cfg):
|
||||
"""Return the point count in the collection."""
|
||||
try:
|
||||
status, resp = qdrant_collection_info(cfg)
|
||||
if status != 200:
|
||||
return {"ok": False, "status": status, "error": resp}
|
||||
# Qdrant returns {"result": {"points_count": N, ...}}
|
||||
result_data = resp.get("result", {})
|
||||
count = result_data.get("points_count", result_data.get("vectors_count", "unknown"))
|
||||
return {"ok": True, "collection": cfg["collection"], "points_count": count}
|
||||
except ConnectionError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HELP_TEXT = """\
|
||||
Commonwealth Qdrant + Ollama Connector
|
||||
|
||||
Usage:
|
||||
qdrant_connector.py health Check Qdrant & Ollama connectivity
|
||||
qdrant_connector.py create-collection Create the vector collection
|
||||
qdrant_connector.py search "<query text>" Embed query and search Qdrant
|
||||
qdrant_connector.py index <id> "<text>" [--metadata k=v ...]
|
||||
Embed text and upsert one point
|
||||
qdrant_connector.py index-file <filepath> Chunk a markdown file and index all chunks
|
||||
qdrant_connector.py count Show point count in collection
|
||||
qdrant_connector.py --help Show this help message
|
||||
|
||||
All output is JSON on stdout. Uses only Python stdlib (no pip install needed).
|
||||
|
||||
Config: {config}
|
||||
""".format(config=CONFIG_PATH)
|
||||
|
||||
|
||||
def parse_metadata(args):
|
||||
"""Parse --metadata key=value pairs from argument list."""
|
||||
metadata = {}
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] == "--metadata" and i + 1 < len(args):
|
||||
i += 1
|
||||
while i < len(args) and "=" in args[i] and not args[i].startswith("--"):
|
||||
key, _, value = args[i].partition("=")
|
||||
metadata[key] = value
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
return metadata
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2 or sys.argv[1] in ("--help", "-h", "help"):
|
||||
print(HELP_TEXT)
|
||||
sys.exit(0)
|
||||
|
||||
cmd = sys.argv[1]
|
||||
|
||||
try:
|
||||
cfg = load_config()
|
||||
except (FileNotFoundError, json.JSONDecodeError) as exc:
|
||||
print(json.dumps({"ok": False, "error": f"Config error: {exc}"}, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
if cmd == "health":
|
||||
result = cmd_health(cfg)
|
||||
elif cmd == "create-collection":
|
||||
result = cmd_create_collection(cfg)
|
||||
elif cmd == "search":
|
||||
if len(sys.argv) < 3:
|
||||
result = {"ok": False, "error": "search requires a query text argument"}
|
||||
else:
|
||||
result = cmd_search(cfg, sys.argv[2])
|
||||
elif cmd == "index":
|
||||
if len(sys.argv) < 4:
|
||||
result = {"ok": False, "error": "index requires <id> and <text> arguments"}
|
||||
else:
|
||||
metadata = parse_metadata(sys.argv[4:])
|
||||
result = cmd_index(cfg, sys.argv[2], sys.argv[3], metadata)
|
||||
elif cmd == "index-file":
|
||||
if len(sys.argv) < 3:
|
||||
result = {"ok": False, "error": "index-file requires a <filepath> argument"}
|
||||
else:
|
||||
result = cmd_index_file(cfg, sys.argv[2])
|
||||
elif cmd == "count":
|
||||
result = cmd_count(cfg)
|
||||
else:
|
||||
result = {"ok": False, "error": f"Unknown command: {cmd}. Use --help for usage."}
|
||||
|
||||
print(json.dumps(result, indent=2))
|
||||
sys.exit(0 if result.get("ok") else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,741 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Sprint CLI — orchestrates sprint lifecycle and context for agents.
|
||||
|
||||
Calls the ticket CLI for data queries (no SQL duplication).
|
||||
Direct DB access only for sprint lifecycle mutations.
|
||||
|
||||
Usage:
|
||||
sprint status [--sprint N] [--team T] Sprint progress and ticket overview
|
||||
sprint sweep [--sprint N] Health check: grouped tickets, issues, team summary (JSON)
|
||||
sprint start [--sprint N] Activate a planned sprint
|
||||
sprint stop [--sprint N] Complete an active sprint
|
||||
sprint start-work [--sprint N] [--team T] Full context dump for starting work
|
||||
sprint prepare [--sprint N] [--team T] Prepare next sprint (candidates + gaps)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
TICKET_CLI = str(SCRIPT_DIR / "ticket")
|
||||
DB_PATH = (SCRIPT_DIR / ".." / ".." / ".." / "settledreach.db").resolve()
|
||||
PROJECT_ROOT = (SCRIPT_DIR / ".." / "..").resolve()
|
||||
|
||||
REMINDER = """---
|
||||
Reminder: Keep ticket status up to date after finishing work.
|
||||
db/connectors/ticket status <id> in_progress (when starting)
|
||||
db/connectors/ticket status <id> done (when finished)"""
|
||||
|
||||
|
||||
def run_ticket(*args):
|
||||
"""Call the ticket CLI and return parsed JSON."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, TICKET_CLI] + list(args),
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return {"ok": False, "error": result.stderr.strip()}
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return {"ok": False, "error": f"Bad ticket output: {result.stdout[:200]}"}
|
||||
|
||||
|
||||
def get_connection():
|
||||
"""Direct DB connection for lifecycle mutations only."""
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.execute("PRAGMA journal_mode=WAL;")
|
||||
conn.execute("PRAGMA foreign_keys=ON;")
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def parse_flags(args, known_flags):
|
||||
"""Parse --flag value pairs from args, return (flags_dict, positional_args)."""
|
||||
flags = {}
|
||||
positional = []
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i].startswith("--") and args[i][2:] in known_flags:
|
||||
key = args[i][2:]
|
||||
if i + 1 < len(args):
|
||||
flags[key] = args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
positional.append(args[i])
|
||||
i += 1
|
||||
else:
|
||||
positional.append(args[i])
|
||||
i += 1
|
||||
return flags, positional
|
||||
|
||||
|
||||
def detect_team(flags):
|
||||
"""Detect team from flags or git branch."""
|
||||
if "team" in flags:
|
||||
return flags["team"]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "branch", "--show-current"],
|
||||
capture_output=True, text=True, cwd=str(PROJECT_ROOT)
|
||||
)
|
||||
branch = result.stdout.strip()
|
||||
if branch and branch != "main":
|
||||
return branch
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_all_sprints():
|
||||
"""Get all sprints via ticket CLI."""
|
||||
data = run_ticket("sprint")
|
||||
if not data.get("ok"):
|
||||
return []
|
||||
return data.get("sprints", [])
|
||||
|
||||
|
||||
def detect_sprint(flags, prefer_status=None):
|
||||
"""Detect sprint from flags or by status preference.
|
||||
|
||||
prefer_status: which status to prefer when auto-detecting.
|
||||
'active' for status/start-work/stop
|
||||
'planning' for start
|
||||
None for prepare (targets next sprint)
|
||||
"""
|
||||
if "sprint" in flags:
|
||||
sprint_id = int(flags["sprint"])
|
||||
sprints = get_all_sprints()
|
||||
for s in sprints:
|
||||
if s["id"] == sprint_id:
|
||||
return s
|
||||
print(f"Error: Sprint {sprint_id} not found.")
|
||||
sys.exit(1)
|
||||
|
||||
sprints = get_all_sprints()
|
||||
if not sprints:
|
||||
print("Error: No sprints found in database.")
|
||||
sys.exit(1)
|
||||
|
||||
if prefer_status:
|
||||
matching = [s for s in sprints if s["status"] == prefer_status]
|
||||
if len(matching) == 1:
|
||||
return matching[0]
|
||||
if len(matching) > 1:
|
||||
ids = ", ".join(str(s["id"]) for s in matching)
|
||||
print(f"Error: Multiple {prefer_status} sprints: {ids}. Use --sprint N to specify.")
|
||||
sys.exit(1)
|
||||
# Fall through: no match for preferred status
|
||||
if prefer_status == "active":
|
||||
# No active sprint
|
||||
print("Error: No active sprint. Use --sprint N to specify.")
|
||||
sys.exit(1)
|
||||
if prefer_status == "planning":
|
||||
print("Error: No sprint in planning status. Use sprint prepare first.")
|
||||
sys.exit(1)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def detect_sprint_for_prepare(flags):
|
||||
"""For prepare: target the next sprint after the most recent one."""
|
||||
if "sprint" in flags:
|
||||
sprint_id = int(flags["sprint"])
|
||||
sprints = get_all_sprints()
|
||||
for s in sprints:
|
||||
if s["id"] == sprint_id:
|
||||
return s
|
||||
# Sprint doesn't exist yet — return a stub
|
||||
return {"id": sprint_id, "status": "new", "name": None}
|
||||
|
||||
sprints = get_all_sprints()
|
||||
# If there's a planning sprint, use it
|
||||
planning = [s for s in sprints if s["status"] == "planning"]
|
||||
if len(planning) == 1:
|
||||
return planning[0]
|
||||
if len(planning) > 1:
|
||||
ids = ", ".join(str(s["id"]) for s in planning)
|
||||
print(f"Error: Multiple planning sprints: {ids}. Use --sprint N to specify.")
|
||||
sys.exit(1)
|
||||
|
||||
# Otherwise target max_id + 1
|
||||
if sprints:
|
||||
next_id = max(s["id"] for s in sprints) + 1
|
||||
return {"id": next_id, "status": "new", "name": None}
|
||||
|
||||
return {"id": 1, "status": "new", "name": None}
|
||||
|
||||
|
||||
def get_tickets_for_sprint(sprint_id, team=None):
|
||||
"""Get tickets for a sprint, optionally filtered by team."""
|
||||
args = ["list", "--sprint", str(sprint_id)]
|
||||
if team:
|
||||
args += ["--team", team]
|
||||
data = run_ticket(*args)
|
||||
if not data.get("ok"):
|
||||
return []
|
||||
return data.get("rows", [])
|
||||
|
||||
|
||||
def get_ticket_deps(ticket_id):
|
||||
"""Get dependencies for a ticket."""
|
||||
data = run_ticket("deps", str(ticket_id))
|
||||
if not data.get("ok"):
|
||||
return {"blocked_by": [], "blocks": []}
|
||||
return data
|
||||
|
||||
|
||||
def get_ticket_detail(ticket_id):
|
||||
"""Get full ticket detail."""
|
||||
data = run_ticket("show", str(ticket_id))
|
||||
if not data.get("ok"):
|
||||
return None
|
||||
return data.get("ticket")
|
||||
|
||||
|
||||
def briefing_path(sprint_id, team):
|
||||
"""Find the briefing file for a sprint/team if it exists."""
|
||||
p = PROJECT_ROOT / "docs" / "sprints" / f"sprint-{sprint_id}" / f"{team}.md"
|
||||
if p.exists():
|
||||
return str(p.relative_to(PROJECT_ROOT))
|
||||
return None
|
||||
|
||||
|
||||
def format_ticket_table(tickets):
|
||||
"""Format tickets as an aligned table."""
|
||||
if not tickets:
|
||||
print(" (none)")
|
||||
return
|
||||
# Header
|
||||
print(f" {'#':<6} {'Title':<50} {'Status':<12} {'Assigned':<10} {'Priority'}")
|
||||
print(f" {'---':<6} {'---':<50} {'---':<12} {'---':<10} {'---'}")
|
||||
for t in tickets:
|
||||
title = t.get("title", "")
|
||||
if len(title) > 48:
|
||||
title = title[:45] + "..."
|
||||
assigned = t.get("assigned_to") or ""
|
||||
print(f" {t['id']:<6} {title:<50} {t['status']:<12} {assigned:<10} {t['priority']}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_status(args):
|
||||
flags, _ = parse_flags(args, ["sprint", "team"])
|
||||
sprint = detect_sprint(flags, prefer_status="active")
|
||||
team = detect_team(flags)
|
||||
|
||||
tickets = get_tickets_for_sprint(sprint["id"], team)
|
||||
|
||||
# Header
|
||||
name = sprint.get("name", f"Sprint {sprint['id']}")
|
||||
print(f"=== {name} ({sprint['status']}) ===")
|
||||
if sprint.get("goal"):
|
||||
print(f"Goal: {sprint['goal']}")
|
||||
parts = []
|
||||
if sprint.get("start_date"):
|
||||
parts.append(f"Started: {sprint['start_date']}")
|
||||
if sprint.get("end_date"):
|
||||
parts.append(f"Ended: {sprint['end_date']}")
|
||||
if team:
|
||||
parts.append(f"Team: {team}")
|
||||
if parts:
|
||||
print(" | ".join(parts))
|
||||
print()
|
||||
|
||||
# Progress
|
||||
total = len(tickets)
|
||||
done = sum(1 for t in tickets if t["status"] == "done")
|
||||
pct = int(done / total * 100) if total > 0 else 0
|
||||
print(f"Progress: {done}/{total} done ({pct}%)")
|
||||
|
||||
# Status breakdown
|
||||
statuses = {}
|
||||
for t in tickets:
|
||||
statuses[t["status"]] = statuses.get(t["status"], 0) + 1
|
||||
status_parts = []
|
||||
for s in ["backlog", "ready", "in_progress", "review", "done", "cancelled"]:
|
||||
if s in statuses:
|
||||
status_parts.append(f"{s}: {statuses[s]}")
|
||||
if status_parts:
|
||||
print(f" {' | '.join(status_parts)}")
|
||||
print()
|
||||
|
||||
# Ticket table
|
||||
print("Tickets:")
|
||||
format_ticket_table(tickets)
|
||||
print()
|
||||
|
||||
# Blocked tickets
|
||||
blocked_lines = []
|
||||
for t in tickets:
|
||||
if t["status"] == "done":
|
||||
continue
|
||||
deps = get_ticket_deps(t["id"])
|
||||
for b in deps.get("blocked_by", []):
|
||||
if b["status"] != "done":
|
||||
blocked_lines.append(f" #{t['id']} blocked by #{b['id']} ({b['status']})")
|
||||
if blocked_lines:
|
||||
print("Blocked:")
|
||||
for line in blocked_lines:
|
||||
print(line)
|
||||
print()
|
||||
|
||||
# Briefing
|
||||
if team:
|
||||
bp = briefing_path(sprint["id"], team)
|
||||
if bp:
|
||||
print(f"Briefing: {bp}")
|
||||
else:
|
||||
# Show all available briefings
|
||||
briefings = []
|
||||
for t_name in ["server", "client", "copy", "audio", "visual", "ci", "joint"]:
|
||||
bp = briefing_path(sprint["id"], t_name)
|
||||
if bp:
|
||||
briefings.append(bp)
|
||||
if briefings:
|
||||
print("Briefings:")
|
||||
for bp in briefings:
|
||||
print(f" {bp}")
|
||||
|
||||
print()
|
||||
print(REMINDER)
|
||||
|
||||
|
||||
def cmd_start(args):
|
||||
flags, _ = parse_flags(args, ["sprint"])
|
||||
sprint = detect_sprint(flags, prefer_status="planning")
|
||||
|
||||
if sprint["status"] != "planning":
|
||||
print(f"Error: Sprint {sprint['id']} is '{sprint['status']}', expected 'planning'.")
|
||||
sys.exit(1)
|
||||
|
||||
# Check ticket count
|
||||
tickets = get_tickets_for_sprint(sprint["id"])
|
||||
if not tickets:
|
||||
print(f"Error: Sprint {sprint['id']} has no tickets. Run sprint prepare first.")
|
||||
sys.exit(1)
|
||||
|
||||
# Activate
|
||||
conn = get_connection()
|
||||
conn.execute(
|
||||
"UPDATE sprints SET status='active', start_date=date('now') WHERE id=?",
|
||||
(sprint["id"],)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Summary
|
||||
teams = {}
|
||||
for t in tickets:
|
||||
team = t.get("team") or "unassigned"
|
||||
teams[team] = teams.get(team, 0) + 1
|
||||
|
||||
name = sprint.get("name", f"Sprint {sprint['id']}")
|
||||
print(f"Started: {name}")
|
||||
print(f"Tickets: {len(tickets)}")
|
||||
for team, count in sorted(teams.items()):
|
||||
print(f" {team}: {count}")
|
||||
print()
|
||||
print(REMINDER)
|
||||
|
||||
|
||||
def cmd_stop(args):
|
||||
flags, _ = parse_flags(args, ["sprint"])
|
||||
sprint = detect_sprint(flags, prefer_status="active")
|
||||
|
||||
if sprint["status"] != "active":
|
||||
print(f"Error: Sprint {sprint['id']} is '{sprint['status']}', expected 'active'.")
|
||||
sys.exit(1)
|
||||
|
||||
tickets = get_tickets_for_sprint(sprint["id"])
|
||||
done = [t for t in tickets if t["status"] == "done"]
|
||||
cancelled = [t for t in tickets if t["status"] == "cancelled"]
|
||||
incomplete = [t for t in tickets if t["status"] not in ("done", "cancelled")]
|
||||
|
||||
# Auto-close: mark picked-up tickets (in_progress, review) as done.
|
||||
# Work merged to main before sprint close means the ticket is done —
|
||||
# agents just forget to update status. Backlog/ready tickets were
|
||||
# never started, so they stay as carry-over candidates.
|
||||
picked_up_statuses = ("in_progress", "review")
|
||||
picked_up = [t for t in incomplete if t["status"] in picked_up_statuses]
|
||||
auto_closed = []
|
||||
if picked_up:
|
||||
conn = get_connection()
|
||||
for t in picked_up:
|
||||
conn.execute("UPDATE tickets SET status='done' WHERE id=?", (t["id"],))
|
||||
auto_closed.append(t)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
# Move auto-closed into done count, remove from incomplete
|
||||
done = done + auto_closed
|
||||
incomplete = [t for t in incomplete if t["status"] not in picked_up_statuses]
|
||||
|
||||
# Complete the sprint
|
||||
conn = get_connection()
|
||||
conn.execute(
|
||||
"UPDATE sprints SET status='completed', end_date=date('now') WHERE id=?",
|
||||
(sprint["id"],)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
name = sprint.get("name", f"Sprint {sprint['id']}")
|
||||
print(f"Completed: {name}")
|
||||
print(f"Done: {len(done)}/{len(tickets)}")
|
||||
if cancelled:
|
||||
print(f"Cancelled: {len(cancelled)}")
|
||||
if auto_closed:
|
||||
print(f"Auto-closed: {len(auto_closed)} tickets marked done on sprint close:")
|
||||
for t in auto_closed:
|
||||
print(f" #{t['id']}: {t['title']} ({t['status']} → done)")
|
||||
print()
|
||||
|
||||
if incomplete:
|
||||
print("Carry-over candidates (never started):")
|
||||
format_ticket_table(incomplete)
|
||||
print()
|
||||
|
||||
print(REMINDER)
|
||||
|
||||
|
||||
def cmd_start_work(args):
|
||||
flags, _ = parse_flags(args, ["sprint", "team"])
|
||||
sprint = detect_sprint(flags, prefer_status="active")
|
||||
team = detect_team(flags)
|
||||
|
||||
if sprint["status"] != "active":
|
||||
print(f"Error: Sprint {sprint['id']} is '{sprint['status']}', expected 'active'.")
|
||||
sys.exit(1)
|
||||
|
||||
tickets = get_tickets_for_sprint(sprint["id"], team)
|
||||
|
||||
# Header
|
||||
name = sprint.get("name", f"Sprint {sprint['id']}")
|
||||
team_label = f" \u2014 {team.title()}" if team else ""
|
||||
print(f"=== {name}{team_label} ===")
|
||||
if sprint.get("goal"):
|
||||
print(f"Goal: {sprint['goal']}")
|
||||
parts = [f"Status: {sprint['status']}"]
|
||||
if sprint.get("start_date"):
|
||||
parts.append(f"Started: {sprint['start_date']}")
|
||||
print(" | ".join(parts))
|
||||
print()
|
||||
|
||||
# Briefing
|
||||
if team:
|
||||
bp = briefing_path(sprint["id"], team)
|
||||
if bp:
|
||||
print(f"Briefing: {bp}")
|
||||
# Also check joint briefing
|
||||
jbp = briefing_path(sprint["id"], "joint")
|
||||
if jbp:
|
||||
print(f"Joint briefing: {jbp}")
|
||||
|
||||
# Collect decision refs
|
||||
decision_refs = set()
|
||||
for t in tickets:
|
||||
detail = get_ticket_detail(t["id"])
|
||||
if detail and detail.get("decision_ref"):
|
||||
decision_refs.add(detail["decision_ref"])
|
||||
if decision_refs:
|
||||
print(f"Decisions: {', '.join(sorted(decision_refs))}")
|
||||
print()
|
||||
|
||||
# Build dependency map
|
||||
blocked_by_map = {} # ticket_id -> [blocker tickets]
|
||||
blocks_map = {} # ticket_id -> [blocked ticket ids]
|
||||
for t in tickets:
|
||||
deps = get_ticket_deps(t["id"])
|
||||
open_blockers = [b for b in deps.get("blocked_by", []) if b["status"] != "done"]
|
||||
if open_blockers:
|
||||
blocked_by_map[t["id"]] = open_blockers
|
||||
blocking = deps.get("blocks", [])
|
||||
if blocking:
|
||||
blocks_map[t["id"]] = blocking
|
||||
|
||||
# Categorize
|
||||
done_tickets = [t for t in tickets if t["status"] == "done"]
|
||||
blocked_tickets = [t for t in tickets if t["status"] != "done" and t["id"] in blocked_by_map]
|
||||
actionable_tickets = [t for t in tickets if t["status"] != "done" and t["id"] not in blocked_by_map]
|
||||
|
||||
# Actionable
|
||||
if actionable_tickets:
|
||||
print("Actionable (not blocked, not done):")
|
||||
for t in actionable_tickets:
|
||||
print(f" #{t['id']}: {t['title']}")
|
||||
# Metadata line
|
||||
meta = [t.get("type", ""), f"P:{t['priority']}", f"S:{t['status']}"]
|
||||
if t.get("assigned_to"):
|
||||
meta.append(f"@{t['assigned_to']}")
|
||||
if t.get("team"):
|
||||
meta.append(f"Team:{t['team']}")
|
||||
detail = get_ticket_detail(t["id"])
|
||||
if detail and detail.get("decision_ref"):
|
||||
meta.append(f"Ref:{detail['decision_ref']}")
|
||||
print(f" {' | '.join(meta)}")
|
||||
if t["id"] in blocks_map:
|
||||
block_ids = ", ".join(f"#{b['id']}" for b in blocks_map[t["id"]])
|
||||
print(f" Blocks: {block_ids}")
|
||||
print()
|
||||
|
||||
# Blocked
|
||||
if blocked_tickets:
|
||||
print("Blocked:")
|
||||
for t in blocked_tickets:
|
||||
blockers = blocked_by_map[t["id"]]
|
||||
blocker_str = ", ".join(f"#{b['id']} ({b['status']})" for b in blockers)
|
||||
print(f" #{t['id']}: {t['title']} \u2190 blocked by {blocker_str}")
|
||||
print()
|
||||
|
||||
# Done
|
||||
if done_tickets:
|
||||
print("Done:")
|
||||
for t in done_tickets:
|
||||
print(f" #{t['id']}: {t['title']} \u2713")
|
||||
print()
|
||||
|
||||
print(REMINDER)
|
||||
|
||||
|
||||
def cmd_sweep(args):
|
||||
"""Health check: grouped tickets, bookkeeping issues, team summary (JSON)."""
|
||||
flags, _ = parse_flags(args, ["sprint"])
|
||||
sprint = detect_sprint(flags, prefer_status="active")
|
||||
|
||||
tickets = get_tickets_for_sprint(sprint["id"])
|
||||
|
||||
# Build dependency map for blocked detection
|
||||
blocked_by_map = {} # ticket_id -> [blocker_id, ...]
|
||||
for t in tickets:
|
||||
if t["status"] == "done":
|
||||
continue
|
||||
deps = get_ticket_deps(t["id"])
|
||||
open_blockers = [b["id"] for b in deps.get("blocked_by", []) if b["status"] != "done"]
|
||||
if open_blockers:
|
||||
blocked_by_map[t["id"]] = open_blockers
|
||||
|
||||
# Group by status
|
||||
by_status = {"done": [], "review": [], "in_progress": [], "blocked": [], "backlog": []}
|
||||
for t in tickets:
|
||||
entry = {
|
||||
"id": t["id"],
|
||||
"title": t["title"],
|
||||
"team": t.get("team") or "unassigned",
|
||||
"assigned_to": t.get("assigned_to"),
|
||||
}
|
||||
if t["status"] == "done":
|
||||
by_status["done"].append(entry)
|
||||
elif t["id"] in blocked_by_map:
|
||||
entry["blocked_by"] = blocked_by_map[t["id"]]
|
||||
by_status["blocked"].append(entry)
|
||||
elif t["status"] == "review":
|
||||
by_status["review"].append(entry)
|
||||
elif t["status"] == "in_progress":
|
||||
by_status["in_progress"].append(entry)
|
||||
else:
|
||||
by_status["backlog"].append(entry)
|
||||
|
||||
# Per-team summary
|
||||
by_team = {}
|
||||
for status, items in by_status.items():
|
||||
for item in items:
|
||||
team = item["team"]
|
||||
if team not in by_team:
|
||||
by_team[team] = {"backlog": 0, "in_progress": 0, "review": 0, "blocked": 0, "done": 0, "total": 0}
|
||||
by_team[team][status] = by_team[team].get(status, 0) + 1
|
||||
by_team[team]["total"] += 1
|
||||
|
||||
# Bookkeeping issues
|
||||
issues = []
|
||||
for t in tickets:
|
||||
if t["status"] in ("in_progress", "review") and not t.get("assigned_to"):
|
||||
issues.append({
|
||||
"type": "unassigned_in_progress",
|
||||
"detail": f"#{t['id']} unassigned {t['status']}",
|
||||
"fix": f"db/connectors/ticket assign {t['id']} <agent>",
|
||||
})
|
||||
if t["status"] == "backlog" and sprint["status"] == "active" and t["id"] not in blocked_by_map:
|
||||
issues.append({
|
||||
"type": "stale_backlog",
|
||||
"detail": f"#{t['id']} stale backlog",
|
||||
"fix": f"db/connectors/ticket status {t['id']} in_progress",
|
||||
})
|
||||
if t["status"] == "done" and t.get("assigned_to"):
|
||||
issues.append({
|
||||
"type": "assigned_but_done",
|
||||
"detail": f"#{t['id']} done, still assigned",
|
||||
"fix": f"db/connectors/ticket unassign {t['id']}",
|
||||
})
|
||||
|
||||
# Progress
|
||||
total = len(tickets)
|
||||
done = len(by_status["done"])
|
||||
pct = int(done / total * 100) if total > 0 else 0
|
||||
|
||||
result = {
|
||||
"sprint": {
|
||||
"id": sprint["id"],
|
||||
"name": sprint.get("name", f"Sprint {sprint['id']}"),
|
||||
"goal": sprint.get("goal", ""),
|
||||
},
|
||||
"progress": {"total": total, "done": done, "pct": pct},
|
||||
"by_status": by_status,
|
||||
"by_team": by_team,
|
||||
"issues": issues,
|
||||
}
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
def cmd_prepare(args):
|
||||
flags, _ = parse_flags(args, ["sprint", "team"])
|
||||
sprint = detect_sprint_for_prepare(flags)
|
||||
team = detect_team(flags)
|
||||
|
||||
# Create sprint record if it doesn't exist
|
||||
if sprint.get("status") == "new":
|
||||
conn = get_connection()
|
||||
conn.execute(
|
||||
"INSERT INTO sprints (id, name, status) VALUES (?, ?, 'planning')",
|
||||
(sprint["id"], f"Sprint {sprint['id']}")
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"Created Sprint {sprint['id']} (planning)")
|
||||
sprint["status"] = "planning"
|
||||
sprint["name"] = f"Sprint {sprint['id']}"
|
||||
elif sprint["status"] not in ("planning", "new"):
|
||||
print(f"Warning: Sprint {sprint['id']} is '{sprint['status']}', not 'planning'.")
|
||||
|
||||
print(f"=== Preparing Sprint {sprint['id']} ===")
|
||||
print()
|
||||
|
||||
# Previous sprint info
|
||||
all_sprints = get_all_sprints()
|
||||
prev_sprints = [s for s in all_sprints if s["id"] < sprint["id"]]
|
||||
if prev_sprints:
|
||||
prev = max(prev_sprints, key=lambda s: s["id"])
|
||||
prev_tickets = get_tickets_for_sprint(prev["id"])
|
||||
prev_done = sum(1 for t in prev_tickets if t["status"] == "done")
|
||||
prev_name = prev.get("name", f"Sprint {prev['id']}")
|
||||
print(f"Previous: {prev_name} ({prev['status']}, {prev_done}/{len(prev_tickets)} done)")
|
||||
print()
|
||||
|
||||
# Carry-over candidates
|
||||
incomplete = [t for t in prev_tickets if t["status"] not in ("done", "cancelled")]
|
||||
if team:
|
||||
incomplete = [t for t in incomplete if team in (t.get("team") or "")]
|
||||
if incomplete:
|
||||
print("Carry-over candidates (incomplete from previous sprint):")
|
||||
format_ticket_table(incomplete)
|
||||
print()
|
||||
|
||||
# Backlog candidates
|
||||
backlog_args = ["list", "--status", "backlog"]
|
||||
if team:
|
||||
backlog_args += ["--team", team]
|
||||
backlog_data = run_ticket(*backlog_args)
|
||||
backlog = backlog_data.get("rows", []) if backlog_data.get("ok") else []
|
||||
# Filter out tickets already assigned to a sprint
|
||||
backlog = [t for t in backlog if not t.get("sprint_id")]
|
||||
|
||||
if backlog:
|
||||
if team:
|
||||
print(f"Backlog candidates ({team}):")
|
||||
format_ticket_table(backlog)
|
||||
else:
|
||||
# Group by team
|
||||
by_team = {}
|
||||
for t in backlog:
|
||||
t_team = t.get("team") or "unassigned"
|
||||
by_team.setdefault(t_team, []).append(t)
|
||||
print("Backlog candidates (unassigned to any sprint):")
|
||||
for t_name in sorted(by_team.keys()):
|
||||
print(f"\n {t_name.title()}:")
|
||||
format_ticket_table(by_team[t_name])
|
||||
print()
|
||||
|
||||
# Decision coverage gaps
|
||||
conn = get_connection()
|
||||
cursor = conn.execute("""
|
||||
SELECT id, title FROM decisions
|
||||
WHERE type='confirmed' AND status='active'
|
||||
AND id NOT IN (SELECT DISTINCT decision_ref FROM tickets WHERE decision_ref IS NOT NULL)
|
||||
ORDER BY id
|
||||
""")
|
||||
orphans = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
if orphans:
|
||||
print("Decision coverage gaps (active decisions without tickets):")
|
||||
for row in orphans:
|
||||
print(f" {row[0]}: {row[1]}")
|
||||
print()
|
||||
|
||||
# Already assigned to this sprint
|
||||
assigned = get_tickets_for_sprint(sprint["id"], team)
|
||||
if assigned:
|
||||
print(f"Already assigned to Sprint {sprint['id']}:")
|
||||
format_ticket_table(assigned)
|
||||
print()
|
||||
|
||||
print(REMINDER)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HELP = """sprint \u2014 sprint lifecycle and context for agents
|
||||
|
||||
Usage:
|
||||
sprint status [--sprint N] [--team T] Sprint progress and ticket overview
|
||||
sprint sweep [--sprint N] Health check: grouped tickets, issues, team summary (JSON)
|
||||
sprint start [--sprint N] Activate a planned sprint
|
||||
sprint stop [--sprint N] Complete an active sprint
|
||||
sprint start-work [--sprint N] [--team T] Full context dump for starting work
|
||||
sprint prepare [--sprint N] [--team T] Prepare next sprint (candidates + gaps)
|
||||
|
||||
Sprint auto-detection:
|
||||
status/start-work/sweep prefer the active sprint
|
||||
start prefer the planning sprint
|
||||
stop prefer the active sprint
|
||||
prepare target next sprint (max id + 1)
|
||||
|
||||
Team auto-detection:
|
||||
If --team is omitted, uses the current git branch name (unless on main).
|
||||
On main with no --team, shows all teams."""
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2 or sys.argv[1] in ("--help", "-h", "help"):
|
||||
print(HELP)
|
||||
sys.exit(0)
|
||||
|
||||
cmd = sys.argv[1]
|
||||
args = sys.argv[2:]
|
||||
|
||||
commands = {
|
||||
"status": cmd_status,
|
||||
"sweep": cmd_sweep,
|
||||
"start": cmd_start,
|
||||
"stop": cmd_stop,
|
||||
"start-work": cmd_start_work,
|
||||
"prepare": cmd_prepare,
|
||||
}
|
||||
|
||||
if cmd not in commands:
|
||||
print(f"Error: Unknown command '{cmd}'. Use --help for usage.")
|
||||
sys.exit(1)
|
||||
|
||||
commands[cmd](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run an INSERT/UPDATE/DELETE on the ticketing database. Whitelistable command.
|
||||
# Usage: sqlite-exec "UPDATE tickets SET status='done' WHERE id=1"
|
||||
exec python3 "$(dirname "$0")/sqlite_connector.py" execute "$@"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Initialise the ticketing database from schema.sql. Whitelistable command.
|
||||
# Usage: sqlite-init
|
||||
exec python3 "$(dirname "$0")/sqlite_connector.py" init "$@"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run a SELECT query on the ticketing database. Whitelistable command.
|
||||
# Usage: sqlite-query "SELECT * FROM tickets WHERE status='in_progress'"
|
||||
exec python3 "$(dirname "$0")/sqlite_connector.py" query "$@"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Seed the ticketing database with initiatives from decisions/ domain files. Whitelistable command.
|
||||
# Usage: sqlite-seed
|
||||
exec python3 "$(dirname "$0")/sqlite_connector.py" seed-decisions "$@"
|
||||
@@ -1,222 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Commonwealth SQLite Connector — mini MCP for ticket management.
|
||||
|
||||
Usage:
|
||||
python3 sqlite_connector.py init
|
||||
python3 sqlite_connector.py query "SELECT * FROM tickets"
|
||||
python3 sqlite_connector.py execute "UPDATE tickets SET status='done' WHERE id=1"
|
||||
python3 sqlite_connector.py seed-decisions
|
||||
python3 sqlite_connector.py --help
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
CONFIG_PATH = SCRIPT_DIR / "config.json"
|
||||
SCHEMA_PATH = SCRIPT_DIR.parent / "schema.sql"
|
||||
# Shared database lives in the parent of all worktrees (three levels up from db/connectors/).
|
||||
DB_PATH = (SCRIPT_DIR / ".." / ".." / ".." / "settledreach.db").resolve()
|
||||
|
||||
|
||||
def load_config():
|
||||
"""Load config.json and resolve the SQLite database path."""
|
||||
with open(CONFIG_PATH, "r") as f:
|
||||
cfg = json.load(f)
|
||||
cfg["sqlite_db_resolved"] = str(DB_PATH)
|
||||
return cfg
|
||||
|
||||
|
||||
def get_connection(cfg):
|
||||
"""Return an sqlite3 connection with WAL mode and foreign keys enabled."""
|
||||
conn = sqlite3.connect(cfg["sqlite_db_resolved"])
|
||||
conn.execute("PRAGMA journal_mode=WAL;")
|
||||
conn.execute("PRAGMA foreign_keys=ON;")
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_init(cfg):
|
||||
"""Initialise the database from schema.sql."""
|
||||
if not SCHEMA_PATH.exists():
|
||||
return {"ok": False, "error": f"Schema file not found: {SCHEMA_PATH}"}
|
||||
|
||||
schema_sql = SCHEMA_PATH.read_text()
|
||||
conn = get_connection(cfg)
|
||||
try:
|
||||
conn.executescript(schema_sql)
|
||||
conn.commit()
|
||||
return {"ok": True, "message": f"Database initialised at {cfg['sqlite_db_resolved']}"}
|
||||
except sqlite3.Error as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def cmd_query(cfg, sql):
|
||||
"""Run a SELECT query and return results as a JSON array of objects."""
|
||||
conn = get_connection(cfg)
|
||||
try:
|
||||
cursor = conn.execute(sql)
|
||||
columns = [desc[0] for desc in cursor.description] if cursor.description else []
|
||||
rows = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||
return {"ok": True, "count": len(rows), "rows": rows}
|
||||
except sqlite3.Error as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def cmd_execute(cfg, sql):
|
||||
"""Run an INSERT/UPDATE/DELETE and return affected row count."""
|
||||
conn = get_connection(cfg)
|
||||
try:
|
||||
cursor = conn.execute(sql)
|
||||
conn.commit()
|
||||
return {
|
||||
"ok": True,
|
||||
"affected_rows": cursor.rowcount,
|
||||
"last_id": cursor.lastrowid,
|
||||
}
|
||||
except sqlite3.Error as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def cmd_seed_decisions(cfg):
|
||||
"""Seed the database with initiatives derived from decisions and open questions."""
|
||||
decisions = [
|
||||
("initiative", "Custom game, not a mod", "backlog", "medium", "D-001"),
|
||||
("initiative", "Commonwealth as first campaign", "backlog", "medium", "D-003"),
|
||||
("initiative", "Single character first-person story generator", "backlog", "medium", "D-005"),
|
||||
("initiative", "Prototype scenario — Institute/Armstrong City/Guardians", "backlog", "medium", "D-006"),
|
||||
("initiative", "Five pillars of game design", "backlog", "medium", "D-007"),
|
||||
("initiative", "Action pillar design principles", "backlog", "medium", "D-008"),
|
||||
("initiative", "Multiplayer — design for it, build single-player first", "backlog", "medium", "D-009"),
|
||||
("initiative", "Multiplayer-ready architectural baseline", "backlog", "medium", "D-010"),
|
||||
("initiative", "Fog of perception non-negotiable", "backlog", "medium", "D-011"),
|
||||
("initiative", "Chunk-based map architecture", "backlog", "medium", "D-012"),
|
||||
("initiative", "Diegetic insert/POI navigation", "backlog", "medium", "D-013"),
|
||||
("initiative", "v0.1 map specification", "backlog", "medium", "D-014"),
|
||||
("initiative", "Camera locked to character", "backlog", "medium", "D-015"),
|
||||
("initiative", "Internal monologue system", "backlog", "medium", "D-016"),
|
||||
("initiative", "Perception modes as character build", "backlog", "medium", "D-017"),
|
||||
("initiative", "Three-range sound model", "backlog", "medium", "D-018"),
|
||||
("initiative", "Top-down with 3D cutscenes", "backlog", "medium", "D-019"),
|
||||
]
|
||||
|
||||
questions = [
|
||||
("story", "Game engine selection", "ready", "critical", "Q-001"),
|
||||
("story", "v0.1 prototype scope", "backlog", "medium", "Q-002"),
|
||||
("story", "Art direction", "backlog", "medium", "Q-003"),
|
||||
("story", "One campaign or separate eras", "backlog", "medium", "Q-004"),
|
||||
("story", "Prototype scale", "backlog", "medium", "Q-005"),
|
||||
("story", "Target platforms", "backlog", "medium", "Q-007"),
|
||||
("story", "Licensing/distribution", "backlog", "medium", "Q-008"),
|
||||
("story", "Time system", "backlog", "medium", "Q-009"),
|
||||
("story", "Storyteller AI design", "backlog", "medium", "Q-010"),
|
||||
("story", "Character selection roster", "backlog", "medium", "Q-011"),
|
||||
]
|
||||
|
||||
conn = get_connection(cfg)
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
try:
|
||||
for ticket_type, title, status, priority, decision_ref in decisions + questions:
|
||||
# Check if a ticket with this decision_ref already exists
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM tickets WHERE decision_ref = ?", (decision_ref,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
skipped += 1
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT INTO tickets (type, title, status, priority, decision_ref) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(ticket_type, title, status, priority, decision_ref),
|
||||
)
|
||||
inserted += 1
|
||||
conn.commit()
|
||||
return {
|
||||
"ok": True,
|
||||
"inserted": inserted,
|
||||
"skipped": skipped,
|
||||
"message": f"Seeded {inserted} tickets ({skipped} already existed)",
|
||||
}
|
||||
except sqlite3.Error as exc:
|
||||
conn.rollback()
|
||||
return {"ok": False, "error": str(exc)}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HELP_TEXT = """\
|
||||
Commonwealth SQLite Connector
|
||||
|
||||
Usage:
|
||||
sqlite_connector.py init Create/update database from schema.sql
|
||||
sqlite_connector.py query "<SQL>" Run a SELECT and return JSON rows
|
||||
sqlite_connector.py execute "<SQL>" Run INSERT/UPDATE/DELETE, return affected rows
|
||||
sqlite_connector.py seed-decisions Seed initiatives from decisions D-001..D-019 and Q-001..Q-011
|
||||
sqlite_connector.py --help Show this help message
|
||||
|
||||
All output is JSON on stdout. Errors also use JSON with {{"ok": false, "error": "..."}}.
|
||||
|
||||
Config: {config}
|
||||
Schema: {schema}
|
||||
""".format(config=CONFIG_PATH, schema=SCHEMA_PATH)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2 or sys.argv[1] in ("--help", "-h", "help"):
|
||||
print(HELP_TEXT)
|
||||
sys.exit(0)
|
||||
|
||||
cmd = sys.argv[1]
|
||||
|
||||
try:
|
||||
cfg = load_config()
|
||||
except (FileNotFoundError, json.JSONDecodeError) as exc:
|
||||
print(json.dumps({"ok": False, "error": f"Config error: {exc}"}, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
if cmd == "init":
|
||||
result = cmd_init(cfg)
|
||||
elif cmd == "query":
|
||||
if len(sys.argv) < 3:
|
||||
result = {"ok": False, "error": "query requires a SQL string argument"}
|
||||
else:
|
||||
result = cmd_query(cfg, sys.argv[2])
|
||||
elif cmd == "execute":
|
||||
if len(sys.argv) < 3:
|
||||
result = {"ok": False, "error": "execute requires a SQL string argument"}
|
||||
else:
|
||||
result = cmd_execute(cfg, sys.argv[2])
|
||||
elif cmd == "seed-decisions":
|
||||
result = cmd_seed_decisions(cfg)
|
||||
else:
|
||||
result = {"ok": False, "error": f"Unknown command: {cmd}. Use --help for usage."}
|
||||
|
||||
print(json.dumps(result, indent=2))
|
||||
sys.exit(0 if result.get("ok") else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,420 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Ticket CLI — ergonomic interface to the project ticketing database.
|
||||
|
||||
Usage:
|
||||
ticket list [--status S] [--priority P] [--epic N] [--sprint N] [--assigned A] [--team T]
|
||||
ticket show <id>
|
||||
ticket done <id> [<id> ...]
|
||||
ticket status <id> <new_status>
|
||||
ticket assign <id> <agent>
|
||||
ticket unassign <id>
|
||||
ticket team <id> <teams>
|
||||
ticket sprint [--active]
|
||||
ticket sprint assign <id> <sprint_id>
|
||||
ticket deps <id>
|
||||
ticket search <keyword>
|
||||
ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T] [--description TEXT]
|
||||
ticket epics [--status S]
|
||||
ticket children <id>
|
||||
ticket count [--status S]
|
||||
|
||||
All output is JSON on stdout.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
CONFIG_PATH = SCRIPT_DIR / "config.json"
|
||||
# Shared database lives in the parent of all worktrees (three levels up from db/connectors/).
|
||||
DB_PATH = (SCRIPT_DIR / ".." / ".." / ".." / "settledreach.db").resolve()
|
||||
|
||||
|
||||
def load_config():
|
||||
with open(CONFIG_PATH, "r") as f:
|
||||
cfg = json.load(f)
|
||||
cfg["sqlite_db_resolved"] = str(DB_PATH)
|
||||
return cfg
|
||||
|
||||
|
||||
def get_connection(cfg):
|
||||
conn = sqlite3.connect(cfg["sqlite_db_resolved"])
|
||||
conn.execute("PRAGMA journal_mode=WAL;")
|
||||
conn.execute("PRAGMA foreign_keys=ON;")
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def query(conn, sql, params=()):
|
||||
cursor = conn.execute(sql, params)
|
||||
columns = [desc[0] for desc in cursor.description] if cursor.description else []
|
||||
return [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||
|
||||
|
||||
def execute(conn, sql, params=()):
|
||||
cursor = conn.execute(sql, params)
|
||||
conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
def out(data):
|
||||
print(json.dumps(data, indent=2))
|
||||
|
||||
|
||||
def parse_flags(args, known_flags):
|
||||
"""Parse --flag value pairs from args, return (flags_dict, positional_args)."""
|
||||
flags = {}
|
||||
positional = []
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i].startswith("--") and args[i][2:] in known_flags:
|
||||
key = args[i][2:]
|
||||
if i + 1 < len(args):
|
||||
flags[key] = args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
positional.append(args[i])
|
||||
i += 1
|
||||
else:
|
||||
positional.append(args[i])
|
||||
i += 1
|
||||
return flags, positional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_list(conn, args):
|
||||
flags, _ = parse_flags(args, ["status", "priority", "epic", "sprint", "assigned", "team"])
|
||||
conditions = []
|
||||
params = []
|
||||
if "status" in flags:
|
||||
conditions.append("t.status = ?")
|
||||
params.append(flags["status"])
|
||||
if "priority" in flags:
|
||||
conditions.append("t.priority = ?")
|
||||
params.append(flags["priority"])
|
||||
if "epic" in flags:
|
||||
conditions.append("t.parent_id = ?")
|
||||
params.append(int(flags["epic"]))
|
||||
if "sprint" in flags:
|
||||
conditions.append("t.sprint_id = ?")
|
||||
params.append(int(flags["sprint"]))
|
||||
if "assigned" in flags:
|
||||
conditions.append("t.assigned_to = ?")
|
||||
params.append(flags["assigned"])
|
||||
if "team" in flags:
|
||||
# Match exact team name within comma-separated list
|
||||
conditions.append("(',' || t.team || ',' LIKE '%,' || ? || ',%')")
|
||||
params.append(flags["team"])
|
||||
where = " AND ".join(conditions) if conditions else "1=1"
|
||||
sql = f"""SELECT t.id, t.type, t.title, t.status, t.priority, t.assigned_to,
|
||||
t.team, t.parent_id, t.sprint_id
|
||||
FROM tickets t WHERE {where}
|
||||
ORDER BY
|
||||
CASE t.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1
|
||||
WHEN 'medium' THEN 2 ELSE 3 END, t.id"""
|
||||
rows = query(conn, sql, tuple(params))
|
||||
out({"ok": True, "count": len(rows), "rows": rows})
|
||||
|
||||
|
||||
def cmd_show(conn, ids, brief=False):
|
||||
tickets = []
|
||||
for ticket_id in ids:
|
||||
rows = query(conn, """SELECT t.*, p.title as parent_title
|
||||
FROM tickets t LEFT JOIN tickets p ON t.parent_id = p.id
|
||||
WHERE t.id = ?""", (ticket_id,))
|
||||
if not rows:
|
||||
tickets.append({"id": ticket_id, "error": f"Ticket #{ticket_id} not found"})
|
||||
continue
|
||||
ticket = rows[0]
|
||||
# Get children
|
||||
children = query(conn, "SELECT id, title, status, priority FROM tickets WHERE parent_id = ? ORDER BY id", (ticket_id,))
|
||||
# Get dependencies (what blocks this)
|
||||
blockers = query(conn, """SELECT t.id, t.title, t.status FROM ticket_deps d
|
||||
JOIN tickets t ON d.blocker_id = t.id
|
||||
WHERE d.blocked_id = ?""", (ticket_id,))
|
||||
# Get dependents (what this blocks)
|
||||
blocks = query(conn, """SELECT t.id, t.title, t.status FROM ticket_deps d
|
||||
JOIN tickets t ON d.blocked_id = t.id
|
||||
WHERE d.blocker_id = ?""", (ticket_id,))
|
||||
ticket["children"] = children
|
||||
ticket["blocked_by"] = blockers
|
||||
ticket["blocks"] = blocks
|
||||
tickets.append(ticket)
|
||||
if brief:
|
||||
_print_brief(tickets)
|
||||
elif len(tickets) == 1:
|
||||
out({"ok": True, "ticket": tickets[0]})
|
||||
else:
|
||||
out({"ok": True, "count": len(tickets), "tickets": tickets})
|
||||
|
||||
|
||||
def _print_brief(tickets):
|
||||
for i, t in enumerate(tickets):
|
||||
if "error" in t:
|
||||
print(f"#{t['id']}: NOT FOUND")
|
||||
continue
|
||||
# Header line
|
||||
print(f"#{t['id']}: {t['title']}")
|
||||
# Metadata line
|
||||
parts = [f"{t['type']}", f"P:{t['priority']}", f"S:{t['status']}"]
|
||||
if t.get("assigned_to"):
|
||||
parts.append(f"@{t['assigned_to']}")
|
||||
if t.get("team"):
|
||||
parts.append(f"Team:{t['team']}")
|
||||
if t.get("parent_id"):
|
||||
parts.append(f"Epic:#{t['parent_id']} ({t.get('parent_title', '?')})")
|
||||
if t.get("sprint_id"):
|
||||
parts.append(f"Sprint:{t['sprint_id']}")
|
||||
if t.get("decision_ref"):
|
||||
parts.append(f"Ref:{t['decision_ref']}")
|
||||
print(f" {' | '.join(parts)}")
|
||||
# Description
|
||||
desc = t.get("description") or ""
|
||||
if desc:
|
||||
# Truncate long descriptions
|
||||
if len(desc) > 200:
|
||||
desc = desc[:197] + "..."
|
||||
print(f" {desc}")
|
||||
# Dependencies
|
||||
if t.get("blocked_by"):
|
||||
blockers = ", ".join(f"#{b['id']} ({b['status']})" for b in t["blocked_by"])
|
||||
print(f" Blocked by: {blockers}")
|
||||
if t.get("blocks"):
|
||||
blocks = ", ".join(f"#{b['id']}" for b in t["blocks"])
|
||||
print(f" Blocks: {blocks}")
|
||||
if i < len(tickets) - 1:
|
||||
print()
|
||||
|
||||
|
||||
def cmd_done(conn, ids):
|
||||
updated = 0
|
||||
for tid in ids:
|
||||
updated += execute(conn, "UPDATE tickets SET status='done', updated_at=datetime('now') WHERE id=?", (int(tid),))
|
||||
out({"ok": True, "updated": updated, "ids": [int(i) for i in ids]})
|
||||
|
||||
|
||||
def cmd_status(conn, ticket_id, new_status):
|
||||
valid = ('backlog', 'ready', 'in_progress', 'review', 'done', 'cancelled')
|
||||
if new_status not in valid:
|
||||
out({"ok": False, "error": f"Invalid status '{new_status}'. Valid: {', '.join(valid)}"})
|
||||
return
|
||||
updated = execute(conn, "UPDATE tickets SET status=?, updated_at=datetime('now') WHERE id=?", (new_status, int(ticket_id)))
|
||||
out({"ok": True, "updated": updated, "id": int(ticket_id), "status": new_status})
|
||||
|
||||
|
||||
def cmd_assign(conn, ticket_id, agent):
|
||||
updated = execute(conn, "UPDATE tickets SET assigned_to=?, updated_at=datetime('now') WHERE id=?", (agent, int(ticket_id)))
|
||||
out({"ok": True, "updated": updated, "id": int(ticket_id), "assigned_to": agent})
|
||||
|
||||
|
||||
def cmd_unassign(conn, ticket_id):
|
||||
updated = execute(conn, "UPDATE tickets SET assigned_to=NULL, updated_at=datetime('now') WHERE id=?", (int(ticket_id),))
|
||||
out({"ok": True, "updated": updated, "id": int(ticket_id), "assigned_to": None})
|
||||
|
||||
|
||||
def cmd_sprint(conn, args):
|
||||
flags, positional = parse_flags(args, ["active"])
|
||||
if positional and positional[0] == "assign" and len(positional) >= 3:
|
||||
ticket_id, sprint_id = int(positional[1]), int(positional[2])
|
||||
updated = execute(conn, "UPDATE tickets SET sprint_id=?, updated_at=datetime('now') WHERE id=?", (sprint_id, ticket_id))
|
||||
out({"ok": True, "updated": updated, "id": ticket_id, "sprint_id": sprint_id})
|
||||
return
|
||||
conditions = []
|
||||
params = []
|
||||
if "active" in flags:
|
||||
conditions.append("s.status = 'active'")
|
||||
where = " AND ".join(conditions) if conditions else "1=1"
|
||||
sprints = query(conn, f"""SELECT s.*, COUNT(t.id) as ticket_count,
|
||||
SUM(CASE WHEN t.status='done' THEN 1 ELSE 0 END) as done_count
|
||||
FROM sprints s LEFT JOIN tickets t ON t.sprint_id = s.id
|
||||
WHERE {where} GROUP BY s.id ORDER BY s.id DESC""", tuple(params))
|
||||
out({"ok": True, "count": len(sprints), "sprints": sprints})
|
||||
|
||||
|
||||
def cmd_deps(conn, ticket_id):
|
||||
blockers = query(conn, """SELECT t.id, t.title, t.status, t.priority FROM ticket_deps d
|
||||
JOIN tickets t ON d.blocker_id = t.id
|
||||
WHERE d.blocked_id = ? ORDER BY t.id""", (int(ticket_id),))
|
||||
blocks = query(conn, """SELECT t.id, t.title, t.status, t.priority FROM ticket_deps d
|
||||
JOIN tickets t ON d.blocked_id = t.id
|
||||
WHERE d.blocker_id = ? ORDER BY t.id""", (int(ticket_id),))
|
||||
out({"ok": True, "id": int(ticket_id), "blocked_by": blockers, "blocks": blocks})
|
||||
|
||||
|
||||
def cmd_search(conn, keyword):
|
||||
rows = query(conn, """SELECT id, type, title, status, priority, assigned_to, team
|
||||
FROM tickets WHERE title LIKE ? OR description LIKE ?
|
||||
ORDER BY id""", (f"%{keyword}%", f"%{keyword}%"))
|
||||
out({"ok": True, "count": len(rows), "rows": rows})
|
||||
|
||||
|
||||
def cmd_create(conn, args):
|
||||
flags, positional = parse_flags(args, ["parent", "priority", "decision", "team", "description"])
|
||||
if len(positional) < 2:
|
||||
out({"ok": False, "error": "Usage: ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T] [--description TEXT]"})
|
||||
return
|
||||
ticket_type = positional[0]
|
||||
title = " ".join(positional[1:])
|
||||
parent_id = int(flags["parent"]) if "parent" in flags else None
|
||||
priority = flags.get("priority", "medium")
|
||||
decision_ref = flags.get("decision")
|
||||
team = flags.get("team")
|
||||
description = flags.get("description")
|
||||
conn.execute(
|
||||
"INSERT INTO tickets (type, title, description, parent_id, priority, decision_ref, team) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(ticket_type, title, description, parent_id, priority, decision_ref, team))
|
||||
conn.commit()
|
||||
last_id = query(conn, "SELECT last_insert_rowid() as id")[0]["id"]
|
||||
out({"ok": True, "id": last_id, "title": title})
|
||||
|
||||
|
||||
def cmd_epics(conn, args):
|
||||
flags, _ = parse_flags(args, ["status"])
|
||||
conditions = ["t.type = 'epic'"]
|
||||
params = []
|
||||
if "status" in flags:
|
||||
conditions.append("t.status = ?")
|
||||
params.append(flags["status"])
|
||||
where = " AND ".join(conditions)
|
||||
rows = query(conn, f"""SELECT t.id, t.title, t.status, t.priority, t.assigned_to, t.team,
|
||||
COUNT(c.id) as child_count,
|
||||
SUM(CASE WHEN c.status='done' THEN 1 ELSE 0 END) as done_count
|
||||
FROM tickets t LEFT JOIN tickets c ON c.parent_id = t.id
|
||||
WHERE {where} GROUP BY t.id
|
||||
ORDER BY CASE t.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1
|
||||
WHEN 'medium' THEN 2 ELSE 3 END, t.id""", tuple(params))
|
||||
out({"ok": True, "count": len(rows), "rows": rows})
|
||||
|
||||
|
||||
def cmd_children(conn, ticket_id):
|
||||
rows = query(conn, """SELECT id, type, title, status, priority, assigned_to, team
|
||||
FROM tickets WHERE parent_id = ? ORDER BY id""", (int(ticket_id),))
|
||||
out({"ok": True, "count": len(rows), "parent_id": int(ticket_id), "rows": rows})
|
||||
|
||||
|
||||
def cmd_team(conn, ticket_id, teams):
|
||||
updated = execute(conn, "UPDATE tickets SET team=?, updated_at=datetime('now') WHERE id=?", (teams, int(ticket_id)))
|
||||
out({"ok": True, "updated": updated, "id": int(ticket_id), "team": teams})
|
||||
|
||||
|
||||
def cmd_count(conn, args):
|
||||
flags, _ = parse_flags(args, ["status"])
|
||||
if "status" in flags:
|
||||
rows = query(conn, "SELECT COUNT(*) as count FROM tickets WHERE status = ?", (flags["status"],))
|
||||
else:
|
||||
rows = query(conn, "SELECT status, COUNT(*) as count FROM tickets GROUP BY status ORDER BY count DESC")
|
||||
out({"ok": True, "rows": rows})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HELP = """ticket — project ticket CLI
|
||||
|
||||
Usage:
|
||||
ticket list [--status S] [--priority P] [--epic N] [--sprint N] [--assigned A] [--team T]
|
||||
ticket show [--brief] <id> [<id>...] Full ticket detail (--brief for summary)
|
||||
ticket done <id> [<id> ...] Mark tickets as done
|
||||
ticket status <id> <new_status> Change ticket status
|
||||
ticket assign <id> <agent> Assign ticket to agent/branch
|
||||
ticket unassign <id> Remove assignment
|
||||
ticket team <id> <teams> Set team(s) (comma-separated, e.g. server,client)
|
||||
ticket sprint [--active] List sprints
|
||||
ticket sprint assign <id> <sprint> Assign ticket to sprint
|
||||
ticket deps <id> Show ticket dependencies
|
||||
ticket search <keyword> Search tickets by title/description
|
||||
ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T] [--description TEXT]
|
||||
ticket epics [--status S] List epics with child counts
|
||||
ticket children <id> List children of a ticket
|
||||
ticket count [--status S] Count tickets by status"""
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2 or sys.argv[1] in ("--help", "-h", "help"):
|
||||
print(HELP)
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
cfg = load_config()
|
||||
except (FileNotFoundError, json.JSONDecodeError) as exc:
|
||||
out({"ok": False, "error": f"Config error: {exc}"})
|
||||
sys.exit(1)
|
||||
|
||||
conn = get_connection(cfg)
|
||||
cmd = sys.argv[1]
|
||||
args = sys.argv[2:]
|
||||
|
||||
try:
|
||||
if cmd == "list":
|
||||
cmd_list(conn, args)
|
||||
elif cmd == "show":
|
||||
brief = "--brief" in args
|
||||
id_args = [a for a in args if a != "--brief"]
|
||||
if not id_args:
|
||||
out({"ok": False, "error": "Usage: ticket show [--brief] <id> [<id> ...]"})
|
||||
else:
|
||||
cmd_show(conn, [int(a) for a in id_args], brief=brief)
|
||||
elif cmd == "done":
|
||||
if not args:
|
||||
out({"ok": False, "error": "Usage: ticket done <id> [<id> ...]"})
|
||||
else:
|
||||
cmd_done(conn, args)
|
||||
elif cmd == "status":
|
||||
if len(args) < 2:
|
||||
out({"ok": False, "error": "Usage: ticket status <id> <new_status>"})
|
||||
else:
|
||||
cmd_status(conn, args[0], args[1])
|
||||
elif cmd == "assign":
|
||||
if len(args) < 2:
|
||||
out({"ok": False, "error": "Usage: ticket assign <id> <agent>"})
|
||||
else:
|
||||
cmd_assign(conn, args[0], args[1])
|
||||
elif cmd == "unassign":
|
||||
if not args:
|
||||
out({"ok": False, "error": "Usage: ticket unassign <id>"})
|
||||
else:
|
||||
cmd_unassign(conn, args[0])
|
||||
elif cmd == "team":
|
||||
if len(args) < 2:
|
||||
out({"ok": False, "error": "Usage: ticket team <id> <teams>"})
|
||||
else:
|
||||
cmd_team(conn, args[0], args[1])
|
||||
elif cmd == "sprint":
|
||||
cmd_sprint(conn, args)
|
||||
elif cmd == "deps":
|
||||
if not args:
|
||||
out({"ok": False, "error": "Usage: ticket deps <id>"})
|
||||
else:
|
||||
cmd_deps(conn, args[0])
|
||||
elif cmd == "search":
|
||||
if not args:
|
||||
out({"ok": False, "error": "Usage: ticket search <keyword>"})
|
||||
else:
|
||||
cmd_search(conn, " ".join(args))
|
||||
elif cmd == "create":
|
||||
cmd_create(conn, args)
|
||||
elif cmd == "epics":
|
||||
cmd_epics(conn, args)
|
||||
elif cmd == "children":
|
||||
if not args:
|
||||
out({"ok": False, "error": "Usage: ticket children <id>"})
|
||||
else:
|
||||
cmd_children(conn, args[0])
|
||||
elif cmd == "count":
|
||||
cmd_count(conn, args)
|
||||
else:
|
||||
out({"ok": False, "error": f"Unknown command: {cmd}. Use --help for usage."})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
-- Commonwealth Project Ticketing Database Schema
|
||||
-- Access via: python3 db/connectors/sqlite_connector.py <command>
|
||||
-- Access via: python3 tooling/db/sqlite_connector.py <command>
|
||||
-- DO NOT use sqlite3 CLI (crashes in Claude Code due to std::bad_alloc bug)
|
||||
|
||||
PRAGMA journal_mode=WAL;
|
||||
@@ -64,7 +64,7 @@ CREATE INDEX IF NOT EXISTS idx_history_ticket ON ticket_history(ticket_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Decision Sync Tables
|
||||
-- Populated by: python3 db/connectors/decisions_sync.py
|
||||
-- Populated by: python3 tooling/db/decisions_sync.py
|
||||
-- Source: decisions/*.md domain files
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user