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:
2026-02-27 11:08:00 +01:00
co-authored by Claude Opus 4.6
parent 93e050f3bf
commit bc226d8baf
47 changed files with 128 additions and 103 deletions
+1
View File
@@ -0,0 +1 @@
../tooling/db
-4
View File
@@ -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" "$@"
-4
View File
@@ -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 "$@"
-4
View File
@@ -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
-7
View File
@@ -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" "$@"
-309
View File
@@ -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()
-340
View File
@@ -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()
-171
View File
@@ -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()
-8
View File
@@ -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
}
-8
View File
@@ -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" "$@"
-4
View File
@@ -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 "$@"
-538
View File
@@ -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()
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env bash
# Count indexed documents in Qdrant. Whitelistable command.
exec python3 "$(dirname "$0")/qdrant_connector.py" count
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env bash
# Check Qdrant and ollama connectivity. Whitelistable command.
exec python3 "$(dirname "$0")/qdrant_connector.py" health
-4
View File
@@ -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 "$@"
-4
View 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 "$@"
-431
View File
@@ -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()
-741
View File
@@ -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()
-4
View File
@@ -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 "$@"
-4
View File
@@ -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 "$@"
-4
View File
@@ -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 "$@"
-4
View File
@@ -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 "$@"
-222
View File
@@ -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()
-420
View File
@@ -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
View File
@@ -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
-- ---------------------------------------------------------------------------