diff --git a/assets/sourceos/bin/turtle-arm-generate b/assets/sourceos/bin/turtle-arm-generate new file mode 100755 index 00000000000..b9fad5a9312 --- /dev/null +++ b/assets/sourceos/bin/turtle-arm-generate @@ -0,0 +1,427 @@ +#!/usr/bin/env python3 +# turtle-arm-generate — Architecture Reference Manual (ARM) generator for TurtleTerm. +# Reads ADR-*.md files from a configured directory and synthesises a consolidated +# ARM document at ~/.local/state/sourceos/arm/ARM.md. +# +# Usage: +# turtle-arm-generate generate [--adr-dir ] [--output ] +# turtle-arm-generate show [--section ] +# turtle-arm-generate search <query> +# turtle-arm-generate status +# turtle-arm-generate watch [--interval 60] +# +# SPDX-License-Identifier: MIT +# Author: @mdheller + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import pathlib +import re +import sys +import time +from datetime import datetime, timezone +from typing import NamedTuple + +# ── colour helpers ──────────────────────────────────────────────────────────── + +_TEAL = '\033[38;2;57;197;207m' +_GREEN = '\033[38;2;63;185;80m' +_RED = '\033[38;2;248;81;73m' +_YELLOW = '\033[38;2;210;153;34m' +_DIM = '\033[2m' +_BOLD = '\033[1m' +_RESET = '\033[0m' + + +def _c(s: str, col: str) -> str: + return f'{col}{s}{_RESET}' + + +def _die(msg: str) -> None: + print(_c(f'error: {msg}', _RED), file=sys.stderr) + sys.exit(1) + + +# ── defaults ────────────────────────────────────────────────────────────────── + +_DEFAULT_ADR_DIR = pathlib.Path.home() / 'dev' / 'prophet-platform' / 'adr' +_DEFAULT_ARM_DIR = pathlib.Path.home() / '.local' / 'state' / 'sourceos' / 'arm' +_DEFAULT_OUTPUT = _DEFAULT_ARM_DIR / 'ARM.md' +_INDEX_PATH = _DEFAULT_ARM_DIR / 'index.json' + +# Standard ADR section headings to extract +_KNOWN_SECTIONS = ('context', 'decision', 'consequences', 'status') + + +# ── ADR data model ──────────────────────────────────────────────────────────── + +class AdrRecord(NamedTuple): + adr_id: str # e.g. "ADR-030" + title: str # first # heading text (minus the id prefix) + status: str # e.g. "Accepted", "Proposed" + date: str # from front-matter or empty string + deciders: str # from front-matter or empty string + sections: dict[str, str] # {lowercase_name: content} + raw_body: str # full file text (fallback) + source_path: str # absolute path to the .md file + + +# ── parser ──────────────────────────────────────────────────────────────────── + +def _slug(title: str) -> str: + """GitHub-flavour anchor slug.""" + s = title.lower() + s = re.sub(r'[^\w\s-]', '', s) + s = re.sub(r'[\s_]+', '-', s) + s = re.sub(r'-+', '-', s).strip('-') + return s + + +def _extract_sections(body: str) -> dict[str, str]: + """Split an ADR body into {section_name: content} for known headings.""" + sections: dict[str, str] = {} + # Match ## Heading (case-insensitive) + pattern = re.compile(r'^##\s+(.+)$', re.MULTILINE) + headings = list(pattern.finditer(body)) + for idx, match in enumerate(headings): + heading = match.group(1).strip().lower() + start = match.end() + end = headings[idx + 1].start() if idx + 1 < len(headings) else len(body) + content = body[start:end].strip() + sections[heading] = content + return sections + + +def _parse_adr(path: pathlib.Path) -> AdrRecord: + text = path.read_text(encoding='utf-8', errors='replace') + + # Derive ADR-id from filename ADR-030-some-title.md → ADR-030 + stem = path.stem # "ADR-030-prophet-platform-integration" + id_match = re.match(r'(ADR-\d+)', stem, re.IGNORECASE) + adr_id = id_match.group(1).upper() if id_match else stem.split('-')[0].upper() + + # Extract the first # heading as title + h1_match = re.search(r'^#\s+(.+)$', text, re.MULTILINE) + raw_title = h1_match.group(1).strip() if h1_match else stem + + # Strip "ADR-NNN:" or "ADR-NNN " prefix from title for cleanliness + title = re.sub(r'^ADR-\d+[:\s]*', '', raw_title, flags=re.IGNORECASE).strip() + if not title: + title = raw_title + + sections = _extract_sections(text) + + # Status: look in sections, then front-matter key + status_text = sections.get('status', '') + # First non-blank line of the status section is the status value + status_first_line = next((l.strip() for l in status_text.splitlines() if l.strip()), '') + status = status_first_line if status_first_line else 'Unknown' + + # Date / Deciders — optional front-matter style keys inside the body + date_match = re.search(r'^[*_-]*date[*_-]*[:\s]+(.+)$', text, re.IGNORECASE | re.MULTILINE) + decider_match = re.search(r'^[*_-]*deciders?[*_-]*[:\s]+(.+)$', text, re.IGNORECASE | re.MULTILINE) + date = date_match.group(1).strip() if date_match else '' + deciders = decider_match.group(1).strip() if decider_match else '' + + return AdrRecord( + adr_id=adr_id, + title=title, + status=status, + date=date, + deciders=deciders, + sections=sections, + raw_body=text, + source_path=str(path), + ) + + +def _load_adrs(adr_dir: pathlib.Path) -> list[AdrRecord]: + """Load all ADR-*.md files sorted by ADR-id.""" + if not adr_dir.exists(): + _die(f'ADR directory not found: {adr_dir}') + files = sorted(adr_dir.glob('ADR-*.md'), key=lambda p: p.name.upper()) + if not files: + _die(f'No ADR-*.md files found in {adr_dir}') + records = [] + for f in files: + try: + records.append(_parse_adr(f)) + except Exception as exc: # noqa: BLE001 + print(_c(f'warning: skipping {f.name}: {exc}', _YELLOW), file=sys.stderr) + return records + + +# ── ARM renderer ────────────────────────────────────────────────────────────── + +def _render_adr_section(record: AdrRecord) -> str: + parts: list[str] = [] + + heading_slug = _slug(f'{record.adr_id} {record.title}') + parts.append(f'## {record.adr_id}: {record.title}\n') + parts.append(f'**Status:** {record.status} ') + if record.date: + parts.append(f'\n**Date:** {record.date} ') + if record.deciders: + parts.append(f'\n**Deciders:** {record.deciders} ') + parts.append('\n') + + # Try to render structured sections; fall back to verbatim body + has_any = any(k in record.sections for k in _KNOWN_SECTIONS) + if has_any: + for section_key in ('context', 'decision', 'consequences'): + if section_key in record.sections: + parts.append(f'\n### {section_key.capitalize()}\n') + parts.append(record.sections[section_key]) + parts.append('\n') + # Any extra sections not in the known set + for key, content in record.sections.items(): + if key not in _KNOWN_SECTIONS and content: + parts.append(f'\n### {key.capitalize()}\n') + parts.append(content) + parts.append('\n') + else: + # Include full body (strip the first # heading we already rendered) + body = re.sub(r'^#[^#].*\n', '', record.raw_body, count=1) + parts.append(body.strip()) + parts.append('\n') + + parts.append('\n---\n') + return ''.join(parts) + + +def _render_arm(records: list[AdrRecord], timestamp: str) -> str: + lines: list[str] = [ + '# Architecture Reference Manual\n', + f'> Auto-generated from ADRs. Last updated: {timestamp}\n', + '\n## Table of Contents\n', + ] + for r in records: + anchor = _slug(f'{r.adr_id} {r.title}') + lines.append(f'- [{r.adr_id}: {r.title}](#{anchor})\n') + lines.append('\n---\n\n') + for r in records: + lines.append(_render_adr_section(r)) + lines.append('\n') + return ''.join(lines) + + +# ── index.json ──────────────────────────────────────────────────────────────── + +def _write_index(records: list[AdrRecord], timestamp: str, index_path: pathlib.Path) -> None: + data = { + 'generated_at': timestamp, + 'adr_count': len(records), + 'adrs': [ + { + 'id': r.adr_id, + 'title': r.title, + 'status': r.status, + 'file': r.source_path, + } + for r in records + ], + } + index_path.write_text(json.dumps(data, indent=2), encoding='utf-8') + + +# ── commands ────────────────────────────────────────────────────────────────── + +def _cmd_generate(args: argparse.Namespace) -> None: + adr_dir = pathlib.Path(args.adr_dir).expanduser() + output = pathlib.Path(args.output).expanduser() + + output.parent.mkdir(parents=True, exist_ok=True) + + records = _load_adrs(adr_dir) + timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + arm_text = _render_arm(records, timestamp) + + output.write_text(arm_text, encoding='utf-8') + _write_index(records, timestamp, _INDEX_PATH) + + print( + _c('✔', _GREEN) + f' ARM generated: {output}' + + f' ({len(records)} ADRs, {len(arm_text)} bytes)', + ) + + +def _cmd_show(args: argparse.Namespace) -> None: + output = pathlib.Path(getattr(args, 'output', str(_DEFAULT_OUTPUT))).expanduser() + if not output.exists(): + _die(f'ARM not found at {output}. Run `turtle-arm-generate generate` first.') + text = output.read_text(encoding='utf-8') + + section = getattr(args, 'section', None) + if section: + # Find the section heading (case-insensitive) + pattern = re.compile(rf'^##\s+{re.escape(section)}\b.*$', re.MULTILINE | re.IGNORECASE) + m = pattern.search(text) + if not m: + _die(f'Section not found: {section!r}') + # Print from that heading to the next --- + start = m.start() + end_m = re.search(r'^---$', text[start:], re.MULTILINE) + end = start + end_m.start() if end_m else len(text) + print(text[start:end].strip()) + else: + print(text) + + +def _cmd_search(args: argparse.Namespace) -> None: + output = pathlib.Path(_DEFAULT_OUTPUT).expanduser() + if not output.exists(): + _die(f'ARM not found at {output}. Run `turtle-arm-generate generate` first.') + text = output.read_text(encoding='utf-8') + query = args.query + + matches: list[str] = [] + lines = text.splitlines() + for idx, line in enumerate(lines): + if query.lower() in line.lower(): + context_start = max(0, idx - 2) + context_end = min(len(lines), idx + 3) + snippet = '\n'.join(lines[context_start:context_end]) + matches.append(f'[line {idx + 1}]\n{snippet}') + + if not matches: + print(_c(f'No matches for: {query!r}', _YELLOW)) + return + separator = '\n' + ('─' * 40) + '\n' + print(separator.join(matches)) + + +def _cmd_status(args: argparse.Namespace) -> None: # noqa: ARG001 + output = _DEFAULT_OUTPUT + index = _INDEX_PATH + + arm_exists = output.exists() + index_exists = index.exists() + + if not arm_exists: + print(_c('ARM not yet generated.', _YELLOW)) + print(f' Run: turtle-arm-generate generate') + return + + stat = output.stat() + mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + + adr_count = '?' + generated_at = mtime + if index_exists: + try: + data = json.loads(index.read_text(encoding='utf-8')) + adr_count = data.get('adr_count', '?') + generated_at = data.get('generated_at', mtime) + except Exception: # noqa: BLE001 + pass + + print(_c('ARM status', _TEAL)) + print(f' Path: {output}') + print(f' Generated: {generated_at}') + print(f' ADR count: {adr_count}') + print(f' Size: {stat.st_size} bytes') + print(f' Index: {index} ({"present" if index_exists else "missing"})') + + +def _cmd_watch(args: argparse.Namespace) -> None: + adr_dir = pathlib.Path(args.adr_dir).expanduser() + output = pathlib.Path(args.output).expanduser() + interval = args.interval + + print(_c(f'Watching {adr_dir} every {interval}s — Ctrl-C to stop', _DIM)) + last_digest: str | None = None + + while True: + try: + files = sorted(adr_dir.glob('ADR-*.md')) + digest = hashlib.md5( + b''.join( + (str(f) + str(f.stat().st_mtime)).encode() + for f in files + ) + ).hexdigest() + + if digest != last_digest: + last_digest = digest + ts = datetime.now(timezone.utc).strftime('%H:%M:%S') + print(f'[{ts}] Change detected — regenerating…') + records = _load_adrs(adr_dir) + timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(_render_arm(records, timestamp), encoding='utf-8') + _write_index(records, timestamp, _INDEX_PATH) + print(f'[{ts}] Done — {len(records)} ADRs → {output}') + + time.sleep(interval) + except KeyboardInterrupt: + print('\nWatch stopped.') + break + + +# ── CLI ─────────────────────────────────────────────────────────────────────── + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog='turtle-arm-generate', + description='Architecture Reference Manual (ARM) generator — synthesise ADRs.', + ) + sub = p.add_subparsers(dest='command', required=True) + + # generate + g = sub.add_parser('generate', help='Read ADRs and write ARM.md') + g.add_argument('--adr-dir', default=str(_DEFAULT_ADR_DIR), + help=f'Directory of ADR-*.md files (default: {_DEFAULT_ADR_DIR})') + g.add_argument('--output', default=str(_DEFAULT_OUTPUT), + help=f'Output path for ARM.md (default: {_DEFAULT_OUTPUT})') + + # show + s = sub.add_parser('show', help='Print the ARM or a specific section') + s.add_argument('--section', default=None, metavar='TITLE', + help='Print only the section whose heading matches TITLE') + s.add_argument('--output', default=str(_DEFAULT_OUTPUT), + help=argparse.SUPPRESS) + + # search + sr = sub.add_parser('search', help='Search the ARM for a query string') + sr.add_argument('query', help='String to search for') + + # status + sub.add_parser('status', help='Show ARM status and ADR count') + + # watch + w = sub.add_parser('watch', help='Poll for ADR changes and regenerate automatically') + w.add_argument('--adr-dir', default=str(_DEFAULT_ADR_DIR), + help=f'Directory of ADR-*.md files (default: {_DEFAULT_ADR_DIR})') + w.add_argument('--output', default=str(_DEFAULT_OUTPUT), + help=f'Output path for ARM.md (default: {_DEFAULT_OUTPUT})') + w.add_argument('--interval', type=int, default=60, metavar='SECS', + help='Poll interval in seconds (default: 60)') + + return p + + +_COMMANDS = { + 'generate': _cmd_generate, + 'show': _cmd_show, + 'search': _cmd_search, + 'status': _cmd_status, + 'watch': _cmd_watch, +} + + +def main() -> None: + parser = _build_parser() + args = parser.parse_args() + handler = _COMMANDS.get(args.command) + if handler is None: + parser.print_help() + sys.exit(1) + handler(args) + + +if __name__ == '__main__': + main() diff --git a/assets/sourceos/bin/turtle-doc-feedback b/assets/sourceos/bin/turtle-doc-feedback new file mode 100755 index 00000000000..efa549afa92 --- /dev/null +++ b/assets/sourceos/bin/turtle-doc-feedback @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +# MIT License +# Copyright (c) 2026 @mdheller +# +# turtle-doc-feedback — doc feedback receipts and Noetica-derived FAQ distillation. +# +# Storage: +# ~/.local/state/sourceos/doc-feedback/receipts.jsonl +# ~/.local/state/sourceos/doc-feedback/faq-stubs.jsonl +# +# Usage: +# turtle-doc-feedback submit <doc_ref> <sentiment> [<comment>] +# sentiment: up | down | neutral | question +# turtle-doc-feedback list [<doc_ref>] +# turtle-doc-feedback distill [<doc_ref>] +# turtle-doc-feedback faq [<doc_ref>] +# turtle-doc-feedback status +from __future__ import annotations + +import json +import os +import subprocess +import sys +import uuid +from datetime import datetime, timezone +from pathlib import Path + +# ── Paths ───────────────────────────────────────────────────────────────────── + +_STATE_DIR = Path.home() / ".local" / "state" / "sourceos" / "doc-feedback" +_RECEIPTS = _STATE_DIR / "receipts.jsonl" +_FAQ_STUBS = _STATE_DIR / "faq-stubs.jsonl" + +_VALID_SENTIMENTS = {"up", "down", "neutral", "question"} + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def _ensure_dir() -> None: + _STATE_DIR.mkdir(parents=True, exist_ok=True) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _read_jsonl(path: Path) -> list[dict]: + if not path.exists(): + return [] + records: list[dict] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + records.append(json.loads(line)) + except json.JSONDecodeError: + pass + return records + + +def _append_jsonl(path: Path, record: dict) -> None: + _ensure_dir() + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(record, ensure_ascii=False) + "\n") + + +def _noetica_bin() -> str: + """Locate turtle-noetica-stream on PATH or relative to this script.""" + import shutil + found = shutil.which("turtle-noetica-stream") + if found: + return found + this_dir = Path(__file__).resolve().parent + candidate = this_dir / "turtle-noetica-stream" + if candidate.exists(): + return str(candidate) + return "turtle-noetica-stream" + + +# ── Sub-commands ────────────────────────────────────────────────────────────── + + +def cmd_submit(args: list[str]) -> int: + if len(args) < 2: + print( + "Usage: turtle-doc-feedback submit <doc_ref> <sentiment> [<comment>]\n" + " sentiment: up | down | neutral | question", + file=sys.stderr, + ) + return 1 + + doc_ref = args[0] + sentiment = args[1].lower() + comment = " ".join(args[2:]) if len(args) > 2 else "" + + if sentiment not in _VALID_SENTIMENTS: + print( + f"Invalid sentiment {sentiment!r}. Must be one of: {', '.join(sorted(_VALID_SENTIMENTS))}", + file=sys.stderr, + ) + return 1 + + receipt: dict = { + "ts": _now_iso(), + "doc_ref": doc_ref, + "sentiment": sentiment, + "comment": comment, + "source": "shell", + "session_id": str(uuid.uuid4()), + } + _append_jsonl(_RECEIPTS, receipt) + + print(f"✓ Feedback recorded — {sentiment} on {doc_ref!r}") + if comment: + print(f" Comment: {comment}") + print(f" Receipt: {_RECEIPTS}") + return 0 + + +def cmd_list(args: list[str]) -> int: + filter_ref = args[0] if args else None + records = _read_jsonl(_RECEIPTS) + + if filter_ref: + records = [r for r in records if r.get("doc_ref") == filter_ref] + + if not records: + label = f"for {filter_ref!r}" if filter_ref else "in KB" + print(f"(no feedback receipts {label})") + return 0 + + for r in records: + ts = r.get("ts", "")[:16] + ref = r.get("doc_ref", "?") + sent = r.get("sentiment", "?") + comment = r.get("comment", "") + line = f"{ts} {sent:<9} {ref}" + if comment: + line += f" — {comment[:80]}" + print(line) + + print(f"\n({len(records)} receipt{'s' if len(records) != 1 else ''})") + return 0 + + +def cmd_distill(args: list[str]) -> int: + filter_ref = args[0] if args else None + records = _read_jsonl(_RECEIPTS) + + # Keep only receipts that carry useful feedback content + useful_sentiments = {"question", "neutral", "down"} + records = [r for r in records if r.get("sentiment") in useful_sentiments] + + if filter_ref: + records = [r for r in records if r.get("doc_ref") == filter_ref] + + if not records: + label = f"for {filter_ref!r}" if filter_ref else "" + print(f"No question/neutral/down receipts found{' ' + label if label else ''}. Nothing to distill.") + return 0 + + # Group by doc_ref + groups: dict[str, list[dict]] = {} + for r in records: + ref = r.get("doc_ref", "unknown") + groups.setdefault(ref, []).append(r) + + noetica = _noetica_bin() + distilled = 0 + + for doc_ref, group in groups.items(): + comments = [r.get("comment", "").strip() for r in group if r.get("comment", "").strip()] + if not comments: + # Synthesise a generic question from sentiment counts + comments = [f"User expressed {r.get('sentiment')} sentiment" for r in group] + + comments_block = "\n".join(f"- {c}" for c in comments) + prompt = ( + f"Based on these feedback questions about the document '{doc_ref}':\n" + f"{comments_block}\n\n" + "Generate a concise FAQ entry in this exact format:\n" + "Q: <question>\nA: <answer>" + ) + + env = dict(os.environ) + env["TURTLE_PROMPT"] = prompt + env["TURTLE_MAX_TOKENS"] = "300" + + print(f"Distilling {len(group)} receipt(s) for {doc_ref!r}…", flush=True) + + try: + result = subprocess.run( + ["python3", noetica], + env=env, + capture_output=True, + text=True, + timeout=60, + ) + response = (result.stdout or "").strip() + if not response: + response = result.stderr.strip() or "(no response from Noetica)" + except subprocess.TimeoutExpired: + response = "(Noetica call timed out after 60s)" + except Exception as exc: + response = f"(error calling Noetica: {exc})" + + # Parse Q/A from response; fall back to raw text + question = "" + answer = "" + for line in response.splitlines(): + if line.startswith("Q:") and not question: + question = line[2:].strip() + elif line.startswith("A:") and not answer: + answer = line[2:].strip() + if not question: + question = comments[0][:120] if comments else doc_ref + if not answer: + answer = response[:400] or "(distillation produced no answer)" + + stub: dict = { + "ts": _now_iso(), + "doc_ref": doc_ref, + "question": question, + "answer": answer, + "source_receipts": len(group), + } + _append_jsonl(_FAQ_STUBS, stub) + print(f" → FAQ stub written for {doc_ref!r}") + distilled += 1 + + print(f"\nDistilled {distilled} doc_ref(s). Stubs: {_FAQ_STUBS}") + return 0 + + +def cmd_faq(args: list[str]) -> int: + filter_ref = args[0] if args else None + stubs = _read_jsonl(_FAQ_STUBS) + + if filter_ref: + stubs = [s for s in stubs if s.get("doc_ref") == filter_ref] + + if not stubs: + label = f"for {filter_ref!r}" if filter_ref else "" + print(f"(no FAQ stubs found{' ' + label if label else ''})") + return 0 + + # Deduplicate: keep last stub per (doc_ref, question) + seen: dict[tuple[str, str], dict] = {} + for s in stubs: + key = (s.get("doc_ref", ""), s.get("question", "")) + seen[key] = s + + for stub in seen.values(): + ref = stub.get("doc_ref", "?") + question = stub.get("question", "?") + answer = stub.get("answer", "?") + n = stub.get("source_receipts", 0) + ts = stub.get("ts", "")[:10] + + print(f"── {ref} [{ts} {n} receipt(s)]") + print(f" Q: {question}") + print(f" A: {answer}") + print() + + return 0 + + +def cmd_status(_args: list[str]) -> int: + receipt_count = len(_read_jsonl(_RECEIPTS)) + stub_count = len(_read_jsonl(_FAQ_STUBS)) + + print(f"KB path : {_STATE_DIR}") + print(f"Receipts : {_RECEIPTS} ({receipt_count} record(s))") + print(f"FAQ stubs: {_FAQ_STUBS} ({stub_count} record(s))") + return 0 + + +# ── Entry point ─────────────────────────────────────────────────────────────── + +_COMMANDS = { + "submit": cmd_submit, + "list": cmd_list, + "distill": cmd_distill, + "faq": cmd_faq, + "status": cmd_status, +} + + +def main(argv: list[str]) -> int: + if not argv: + print( + "Usage: turtle-doc-feedback <subcommand> [args]\n" + " submit <doc_ref> <sentiment> [<comment>]\n" + " list [<doc_ref>]\n" + " distill [<doc_ref>]\n" + " faq [<doc_ref>]\n" + " status", + file=sys.stderr, + ) + return 1 + + sub = argv[0] + rest = argv[1:] + + handler = _COMMANDS.get(sub) + if handler is None: + print(f"Unknown subcommand: {sub!r}. Try: {', '.join(_COMMANDS)}", file=sys.stderr) + return 1 + + return handler(rest) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/assets/sourceos/bin/turtle-ticket b/assets/sourceos/bin/turtle-ticket new file mode 100755 index 00000000000..c11e674c6a4 --- /dev/null +++ b/assets/sourceos/bin/turtle-ticket @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +# MIT License +# Copyright (c) 2026 @mdheller +# +# turtle-ticket — sovereign support ticket and case record system. +# +# Stores tickets as JSONL receipts under ~/.local/state/sourceos/tickets/ +# Tickets can be submitted from the terminal, from Matrix via !qes ticket, +# or from any tool that can write to the JSONL store. +# +# Usage: +# turtle-ticket open <title> [--priority <p>] [--repo <repo>] [--body <text>] +# turtle-ticket list [--open | --closed | --all] [--repo <repo>] [--n <n>] +# turtle-ticket show <ticket_id> +# turtle-ticket comment <ticket_id> <text> +# turtle-ticket close <ticket_id> [--resolution <text>] +# turtle-ticket reopen <ticket_id> +# turtle-ticket tag <ticket_id> <tag> +# turtle-ticket search <query> +# turtle-ticket summary [--repo <repo>] # counts by status/priority +# turtle-ticket status # store path + counts + +from __future__ import annotations + +import json +import os +import sys +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ── Storage ─────────────────────────────────────────────────────────────────── + +TICKETS_DIR = Path.home() / ".local" / "state" / "sourceos" / "tickets" +TICKETS_JSONL = TICKETS_DIR / "tickets.jsonl" +EVENTS_JSONL = TICKETS_DIR / "events.jsonl" + +PRIORITIES = {"p0", "p1", "p2", "p3", "low", "medium", "high", "critical"} +STATUSES = {"open", "closed", "in-progress", "blocked", "wont-fix"} + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _short_id() -> str: + return str(uuid.uuid4())[:8] + + +def _ensure_dir() -> None: + TICKETS_DIR.mkdir(parents=True, exist_ok=True) + + +def _read_all() -> list[dict]: + """Read tickets.jsonl, return latest state per ticket (last write wins).""" + if not TICKETS_JSONL.exists(): + return [] + tickets: dict[str, dict] = {} + for line in TICKETS_JSONL.read_text(errors="replace").splitlines(): + line = line.strip() + if not line: + continue + try: + t = json.loads(line) + tickets[t["id"]] = t + except Exception: + continue + return list(tickets.values()) + + +def _write_ticket(ticket: dict) -> None: + _ensure_dir() + with open(TICKETS_JSONL, "a") as f: + f.write(json.dumps(ticket, ensure_ascii=False) + "\n") + + +def _write_event(event: dict) -> None: + _ensure_dir() + with open(EVENTS_JSONL, "a") as f: + f.write(json.dumps(event, ensure_ascii=False) + "\n") + + +def _get(ticket_id: str) -> dict | None: + for t in _read_all(): + if t["id"] == ticket_id or t["id"].startswith(ticket_id): + return t + return None + + +def _fmt_ticket(t: dict, verbose: bool = False) -> str: + prio = t.get("priority", "p2") + repo = t.get("repo", "") + tags = ", ".join(t.get("tags", [])) + lines = [ + f"[{t['id']}] {t['status'].upper():<11} {prio:<4} {t['title']}", + f" opened: {t['created_at'][:16]} repo: {repo or '—'} tags: {tags or '—'}", + ] + if verbose: + if t.get("body"): + lines.append(f" body: {t['body']}") + comments = t.get("comments", []) + for c in comments: + lines.append(f" [{c['ts'][:16]}] {c['author']}: {c['text']}") + if t.get("resolution"): + lines.append(f" resolution: {t['resolution']}") + return "\n".join(lines) + + +def _parse_flags(argv: list[str]) -> tuple[list[str], dict[str, str]]: + flags: dict[str, str] = {} + pos: list[str] = [] + i = 0 + while i < len(argv): + if argv[i].startswith("--") and i + 1 < len(argv) and not argv[i + 1].startswith("--"): + key = argv[i][2:] + flags[key] = argv[i + 1] + i += 2 + elif argv[i].startswith("--") and "=" in argv[i]: + k, _, v = argv[i][2:].partition("=") + flags[k] = v + i += 1 + else: + pos.append(argv[i]) + i += 1 + return pos, flags + +# ── Commands ────────────────────────────────────────────────────────────────── + +def cmd_open(pos: list[str], flags: dict[str, str]) -> None: + if not pos: + print("Usage: turtle-ticket open <title> [--priority p1] [--repo <repo>] [--body <text>]", + file=sys.stderr) + sys.exit(1) + title = " ".join(pos) + priority = flags.get("priority", "p2") + repo = flags.get("repo", os.environ.get("SOURCEOS_REPO", "")) + body = flags.get("body", "") + + ticket: dict[str, Any] = { + "id": _short_id(), + "title": title, + "status": "open", + "priority": priority, + "repo": repo, + "body": body, + "tags": [], + "comments": [], + "created_at": _now(), + "updated_at": _now(), + "resolution": "", + } + _write_ticket(ticket) + _write_event({"ts": _now(), "type": "ticket.opened", "ticket_id": ticket["id"], + "title": title, "priority": priority, "repo": repo}) + print(f"✅ Ticket opened: [{ticket['id']}] {title}") + print(f" priority={priority} repo={repo or '—'}") + + +def cmd_list(pos: list[str], flags: dict[str, str]) -> None: + tickets = _read_all() + status_filter = None + if "--open" in sys.argv: + status_filter = "open" + elif "--closed" in sys.argv: + status_filter = "closed" + elif "status" in flags: + status_filter = flags["status"] + + repo_filter = flags.get("repo", "") + n = int(flags.get("n", "20")) + + if status_filter and status_filter != "all": + tickets = [t for t in tickets if t.get("status") == status_filter] + if repo_filter: + tickets = [t for t in tickets if t.get("repo") == repo_filter] + + tickets.sort(key=lambda t: t.get("updated_at", ""), reverse=True) + tickets = tickets[:n] + + if not tickets: + print("(no tickets)") + return + for t in tickets: + print(_fmt_ticket(t)) + + +def cmd_show(pos: list[str], flags: dict[str, str]) -> None: + if not pos: + print("Usage: turtle-ticket show <ticket_id>", file=sys.stderr) + sys.exit(1) + t = _get(pos[0]) + if not t: + print(f"⚠ Ticket not found: {pos[0]}", file=sys.stderr) + sys.exit(1) + print(_fmt_ticket(t, verbose=True)) + + +def cmd_comment(pos: list[str], flags: dict[str, str]) -> None: + if len(pos) < 2: + print("Usage: turtle-ticket comment <ticket_id> <text>", file=sys.stderr) + sys.exit(1) + t = _get(pos[0]) + if not t: + print(f"⚠ Ticket not found: {pos[0]}", file=sys.stderr) + sys.exit(1) + comment = {"ts": _now(), "author": os.environ.get("USER", "shell"), "text": " ".join(pos[1:])} + t.setdefault("comments", []).append(comment) + t["updated_at"] = _now() + _write_ticket(t) + _write_event({"ts": _now(), "type": "ticket.commented", "ticket_id": t["id"], "text": comment["text"]}) + print(f"💬 Comment added to [{t['id']}]") + + +def cmd_close(pos: list[str], flags: dict[str, str]) -> None: + if not pos: + print("Usage: turtle-ticket close <ticket_id> [--resolution <text>]", file=sys.stderr) + sys.exit(1) + t = _get(pos[0]) + if not t: + print(f"⚠ Ticket not found: {pos[0]}", file=sys.stderr) + sys.exit(1) + t["status"] = "closed" + t["resolution"] = flags.get("resolution", "") + t["updated_at"] = t["closed_at"] = _now() + _write_ticket(t) + _write_event({"ts": _now(), "type": "ticket.closed", "ticket_id": t["id"], + "resolution": t["resolution"]}) + print(f"✅ Ticket closed: [{t['id']}] {t['title']}") + + +def cmd_reopen(pos: list[str], flags: dict[str, str]) -> None: + if not pos: + print("Usage: turtle-ticket reopen <ticket_id>", file=sys.stderr) + sys.exit(1) + t = _get(pos[0]) + if not t: + print(f"⚠ Ticket not found: {pos[0]}", file=sys.stderr) + sys.exit(1) + t["status"] = "open" + t["updated_at"] = _now() + _write_ticket(t) + _write_event({"ts": _now(), "type": "ticket.reopened", "ticket_id": t["id"]}) + print(f"🔄 Ticket reopened: [{t['id']}]") + + +def cmd_tag(pos: list[str], flags: dict[str, str]) -> None: + if len(pos) < 2: + print("Usage: turtle-ticket tag <ticket_id> <tag>", file=sys.stderr) + sys.exit(1) + t = _get(pos[0]) + if not t: + print(f"⚠ Ticket not found: {pos[0]}", file=sys.stderr) + sys.exit(1) + tag = pos[1] + tags = t.setdefault("tags", []) + if tag not in tags: + tags.append(tag) + t["updated_at"] = _now() + _write_ticket(t) + print(f"🏷 Tagged [{t['id']}] with '{tag}'") + + +def cmd_search(pos: list[str], flags: dict[str, str]) -> None: + if not pos: + print("Usage: turtle-ticket search <query>", file=sys.stderr) + sys.exit(1) + query = " ".join(pos).lower() + results = [] + for t in _read_all(): + score = 0 + if query in t.get("title", "").lower(): + score += 3 + if query in t.get("body", "").lower(): + score += 2 + if any(query in tag.lower() for tag in t.get("tags", [])): + score += 2 + if query in t.get("repo", "").lower(): + score += 1 + for c in t.get("comments", []): + if query in c.get("text", "").lower(): + score += 1 + if score > 0: + results.append((score, t)) + + results.sort(key=lambda x: (-x[0], x[1].get("updated_at", "")), reverse=False) + if not results: + print(f"(no tickets matching '{query}')") + return + for _, t in results: + print(_fmt_ticket(t)) + + +def cmd_summary(pos: list[str], flags: dict[str, str]) -> None: + tickets = _read_all() + repo_filter = flags.get("repo", "") + if repo_filter: + tickets = [t for t in tickets if t.get("repo") == repo_filter] + + by_status: dict[str, int] = {} + by_priority: dict[str, int] = {} + for t in tickets: + s = t.get("status", "open") + p = t.get("priority", "p2") + by_status[s] = by_status.get(s, 0) + 1 + by_priority[p] = by_priority.get(p, 0) + 1 + + print(f"Total tickets: {len(tickets)}") + print("By status:") + for s, n in sorted(by_status.items()): + print(f" {s:<12} {n}") + print("By priority:") + for p, n in sorted(by_priority.items()): + print(f" {p:<8} {n}") + + +def cmd_status(pos: list[str], flags: dict[str, str]) -> None: + tickets = _read_all() + open_count = sum(1 for t in tickets if t.get("status") == "open") + closed_count = sum(1 for t in tickets if t.get("status") == "closed") + print(f"tickets store : {TICKETS_JSONL}") + print(f"events store : {EVENTS_JSONL}") + print(f"total tickets : {len(tickets)}") + print(f"open : {open_count}") + print(f"closed : {closed_count}") + + +# ── Main ────────────────────────────────────────────────────────────────────── + +COMMANDS = { + "open": cmd_open, + "list": cmd_list, + "show": cmd_show, + "comment": cmd_comment, + "close": cmd_close, + "reopen": cmd_reopen, + "tag": cmd_tag, + "search": cmd_search, + "summary": cmd_summary, + "status": cmd_status, +} + + +def main() -> None: + argv = sys.argv[1:] + if not argv or argv[0] in ("-h", "--help"): + print(__doc__.strip()) + sys.exit(0) + + cmd = argv[0] + rest = argv[1:] + pos, flags = _parse_flags(rest) + + fn = COMMANDS.get(cmd) + if not fn: + print(f"Unknown command: {cmd}. Run with --help for usage.", file=sys.stderr) + sys.exit(1) + fn(pos, flags) + + +if __name__ == "__main__": + main() diff --git a/assets/sourceos/shell/turtle-shell-init.zsh b/assets/sourceos/shell/turtle-shell-init.zsh index 283c0ab1c19..85808a69fbd 100644 --- a/assets/sourceos/shell/turtle-shell-init.zsh +++ b/assets/sourceos/shell/turtle-shell-init.zsh @@ -2049,6 +2049,49 @@ mxctx() { python3 "$(_turtle_matrix_bridge_bin)" ctx "$@" } # mxstatus — show Matrix bridge config + connectivity mxstatus() { python3 "$(_turtle_matrix_bridge_bin)" status } +# ── Support tickets ──────────────────────────────────────────────────────────── + +_turtle_ticket_bin() { + local _b + for _b in \ + "${TURTLE_SOURCEOS_BIN:-$HOME/.local/share/sourceos/bin}/turtle-ticket" \ + "${${(%):-%x}:h}/turtle-ticket"; do + [[ -x "$_b" ]] && { printf '%s' "$_b"; return; } + done + printf '%s' "$(dirname "${(%):-%x}")/turtle-ticket" +} + +# ticket open <title> [--priority p1] [--repo <repo>] [--body <text>] +# Open a support ticket or case record +ticket() { python3 "$(_turtle_ticket_bin)" "$@" } + +# tko <title> [--priority p1] — quick-open shorthand +tko() { + [[ -z "$1" ]] && { printf 'Usage: tko <title> [--priority p1]\n' >&2; return 1; } + python3 "$(_turtle_ticket_bin)" open "$@" +} + +# tkl [--open|--closed] [--repo <repo>] — list tickets +tkl() { python3 "$(_turtle_ticket_bin)" list "$@" } + +# tks <query> — search tickets +tks() { + [[ -z "$1" ]] && { printf 'Usage: tks <query>\n' >&2; return 1; } + python3 "$(_turtle_ticket_bin)" search "$@" +} + +# tkc <ticket_id> [--resolution <text>] — close ticket +tkc() { + [[ -z "$1" ]] && { printf 'Usage: tkc <ticket_id>\n' >&2; return 1; } + python3 "$(_turtle_ticket_bin)" close "$@" +} + +# tkn <ticket_id> <comment> — add comment/note to ticket +tkn() { + [[ $# -lt 2 ]] && { printf 'Usage: tkn <ticket_id> <comment>\n' >&2; return 1; } + python3 "$(_turtle_ticket_bin)" comment "$@" +} + # Auto-start Matrix bridge daemon at shell init if config is present if [[ -f "${HOME}/.config/sourceos/matrix.yaml" ]] || \ [[ -n "${MATRIX_ACCESS_TOKEN:-}" ]]; then diff --git a/assets/sourceos/turtleterm.lua b/assets/sourceos/turtleterm.lua index 4223f1d8ad3..773f0d70407 100644 --- a/assets/sourceos/turtleterm.lua +++ b/assets/sourceos/turtleterm.lua @@ -1464,6 +1464,13 @@ local PALETTE_COMMANDS = { { label = '💬 Matrix: recent log (mxlog) —', id = 'matrix_log' }, { label = '💬 Matrix: post context (mxctx) —', id = 'matrix_ctx' }, { label = '💬 Matrix: bridge status (mxstatus) —', id = 'matrix_status' }, + -- Support tickets + { label = '🎫 Ticket: open (tko) —', id = 'ticket_open' }, + { label = '🎫 Ticket: list (tkl) —', id = 'ticket_list' }, + { label = '🎫 Ticket: search (tks) —', id = 'ticket_search' }, + { label = '🎫 Ticket: close (tkc) —', id = 'ticket_close' }, + { label = '🎫 Ticket: comment (tkn) —', id = 'ticket_note' }, + { label = '🎫 Ticket: summary —', id = 'ticket_summary' }, } local function turtle_command_palette() @@ -1577,6 +1584,12 @@ local function turtle_command_palette() matrix_log = act.SendString('mxlog\n'), matrix_ctx = act.SendString('mxctx\n'), matrix_status = act.SendString('mxstatus\n'), + ticket_open = act.SendString('tko '), + ticket_list = act.SendString('tkl\n'), + ticket_search = act.SendString('tks '), + ticket_close = act.SendString('tkc '), + ticket_note = act.SendString('tkn '), + ticket_summary = act.SendString('ticket summary\n'), } local a = dispatch[id] if a then w:perform_action(a, p) end