commit 339ff444f5fd8ac8fce92b1537a4cef97002bd72 Author: Nilton Constantino Date: Fri May 8 16:51:23 2026 +0100 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6eab65a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea +build +.python-version +.venv diff --git a/README.md b/README.md new file mode 100644 index 0000000..fcab8c6 --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# Discussion Framework + +Deterministic CLI for managing `discussion/` workflow state without spending AI +tokens on mechanical maintenance. + +AI should write and review content. This CLI should manage state. + +## Install + +From this repository: + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -e tools/discussion-framework +``` + +After moving this directory to its own GitHub repository: + +```bash +pip install "git+https://github.com//discussion-framework.git@v0.1.0" +``` + +## Commands + +```bash +discussion init +discussion start pbs-autocomplete-parameter-names "PBS autocomplete parameter names" --tag compiler-pbs --tag lsp +discussion accept-agenda DSC-0037 +discussion add-decision DSC-0037 --title "Canonical PBS parameter names for editor assistance" +discussion accept-decision DSC-0037 +discussion add-plan DSC-0037 --title "Propagate canonical parameter names to stdlib and LSP surfaces" +discussion mark-plan PLN-0078 in_progress +discussion mark-plan PLN-0078 done +discussion status +discussion status --json +discussion validate +discussion validate --json +discussion inspect DSC-0035 +discussion inspect DSC-0035 --json +discussion context DSC-0035 +discussion context DSC-0035 --json +discussion next-actions +discussion backup-index +discussion housekeep DSC-0035 --lesson discussion/lessons/.../LSN-0051-name.md +discussion housekeep DSC-0035 --no-lesson +``` + +Use `--root ` when running outside the repository root. + +When a command is executed from inside the existing `discussion/` directory, the +CLI resolves the repository root to the parent directory instead of creating a +nested `discussion/discussion` tree. + +## Contract + +- `init` creates the canonical `discussion/` layout and `index.ndjson`. +- `start` creates a new discussion record and its first agenda artifact. +- `accept-agenda` marks an agenda as accepted once the discussion is ready for + a decision. +- `add-decision` creates a decision artifact from an accepted agenda and indexes + it under the discussion. +- `accept-decision` marks a decision as accepted once its content is normative. +- `add-plan` creates a plan artifact from an accepted decision and indexes it + under the discussion. +- `mark-plan` moves a plan through `open`, `review`, `accepted`, + `in_progress`, `done`, or `abandoned`. +- `status` prints a compact discussion overview. +- `inspect` shows one discussion and its indexed artifacts. +- `context` prints compact AI-ready context and suggested next actions. +- `next-actions` lists operational next steps across discussions. +- `validate` checks the index, counters, artifact references, and lesson files. +- `backup-index` replaces the old shell backup script by copying + `discussion/index.ndjson` into `discussion/.backups/`. +- `housekeep` marks a discussion `done`, removes workflow artifacts, clears + workflow arrays from the index, and optionally registers an existing lesson. + +The command does not generate agenda, decision, plan, or lesson prose. That work +belongs to humans or AI agents. + +## Codex Integration + +Repository instructions and Codex skills should treat this CLI as the mechanical +state manager. + +Recommended policy: + +- Agents use AI for content: agenda, decision, plan, lesson, specs, and code. +- Agents use `discussion` for state: status, validation, housekeeping, index + updates, and workflow artifact pruning. +- Agents should not manually edit `discussion/index.ndjson` when an equivalent + `discussion` command exists. +- If a command is missing, agents may perform the operation manually and should + record the missing command as a framework improvement. + +Recommended skill behavior: + +- `discussion-status`: run `discussion status` first, then inspect files only + when the user asks for deeper analysis. +- `discussion-context`: run `discussion context DSC-XXXX`; this is the primary + AI handoff surface. +- `discussion-validate`: run `discussion validate`; manual validation is a + fallback only. +- `discussion-housekeep`: AI may decide whether a lesson is needed and write it; + final state mutation must use `discussion housekeep --lesson ...` or + `discussion housekeep --no-lesson`. +- `discussion-framework`: document the split between content generation and + deterministic state operations. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9ba2c74 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "discussion-framework" +version = "0.1.0" +description = "Deterministic CLI for managing discussion-framework workflow state." +readme = "README.md" +requires-python = ">=3.11" +authors = [ + { name = "Prometeu" } +] + +[project.scripts] +discussion = "discussion_framework.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +pythonpath = ["src"] diff --git a/src/discussion_framework/__init__.py b/src/discussion_framework/__init__.py new file mode 100644 index 0000000..70a9a34 --- /dev/null +++ b/src/discussion_framework/__init__.py @@ -0,0 +1,3 @@ +"""Deterministic discussion workflow management.""" + +__version__ = "0.1.0" diff --git a/src/discussion_framework/__main__.py b/src/discussion_framework/__main__.py new file mode 100644 index 0000000..bfdcd0c --- /dev/null +++ b/src/discussion_framework/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/discussion_framework/__pycache__/__init__.cpython-313.pyc b/src/discussion_framework/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..4c298b1 Binary files /dev/null and b/src/discussion_framework/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/discussion_framework/__pycache__/cli.cpython-313.pyc b/src/discussion_framework/__pycache__/cli.cpython-313.pyc new file mode 100644 index 0000000..c5aba2a Binary files /dev/null and b/src/discussion_framework/__pycache__/cli.cpython-313.pyc differ diff --git a/src/discussion_framework/__pycache__/commands.cpython-313.pyc b/src/discussion_framework/__pycache__/commands.cpython-313.pyc new file mode 100644 index 0000000..6b5a744 Binary files /dev/null and b/src/discussion_framework/__pycache__/commands.cpython-313.pyc differ diff --git a/src/discussion_framework/__pycache__/model.cpython-313.pyc b/src/discussion_framework/__pycache__/model.cpython-313.pyc new file mode 100644 index 0000000..633243d Binary files /dev/null and b/src/discussion_framework/__pycache__/model.cpython-313.pyc differ diff --git a/src/discussion_framework/__pycache__/validation.cpython-313.pyc b/src/discussion_framework/__pycache__/validation.cpython-313.pyc new file mode 100644 index 0000000..4a46e13 Binary files /dev/null and b/src/discussion_framework/__pycache__/validation.cpython-313.pyc differ diff --git a/src/discussion_framework/cli.py b/src/discussion_framework/cli.py new file mode 100644 index 0000000..343a462 --- /dev/null +++ b/src/discussion_framework/cli.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + +from . import commands +from .model import DiscussionError + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="discussion") + parser.add_argument("--root", default=None, help="repository root containing discussion/") + + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("init", help="create discussion/ layout if missing") + + start = subparsers.add_parser("start", help="create a discussion with an initial agenda") + start.add_argument("ticket", help="stable ticket/slug for the discussion") + start.add_argument("title", help="human-readable discussion title") + start.add_argument("--tag", action="append", default=[], help="tag to add; repeatable") + start.add_argument("--domain-owner", default="", help="explicit domain owner for the agenda") + start.add_argument("--pain", default="", help="initial pain/problem statement") + start.add_argument("--context", default="", help="initial context") + start.add_argument("--question", action="append", default=[], help="open question; repeatable") + start.add_argument("--json", action="store_true", help="print machine-readable JSON") + + accept_agenda = subparsers.add_parser("accept-agenda", help="mark an agenda accepted") + accept_agenda.add_argument("discussion_id") + accept_agenda.add_argument("--agenda", help="agenda id; required when more than one agenda is active") + accept_agenda.add_argument("--json", action="store_true", help="print machine-readable JSON") + + add_decision = subparsers.add_parser("add-decision", help="create a decision artifact from an accepted agenda") + add_decision.add_argument("discussion_id") + add_decision.add_argument("--agenda", help="accepted agenda id; required when ambiguous") + add_decision.add_argument("--title", required=True, help="decision title") + add_decision.add_argument("--json", action="store_true", help="print machine-readable JSON") + + accept_decision = subparsers.add_parser("accept-decision", help="mark a decision accepted") + accept_decision.add_argument("discussion_id") + accept_decision.add_argument("--decision", help="decision id; required when more than one decision is active") + accept_decision.add_argument("--json", action="store_true", help="print machine-readable JSON") + + add_plan = subparsers.add_parser("add-plan", help="create a plan artifact from an accepted decision") + add_plan.add_argument("discussion_id") + add_plan.add_argument("--decision", help="accepted decision id; required when ambiguous") + add_plan.add_argument("--title", required=True, help="plan title") + add_plan.add_argument("--json", action="store_true", help="print machine-readable JSON") + + mark_plan = subparsers.add_parser("mark-plan", help="move a plan through the workflow lifecycle") + mark_plan.add_argument("plan_id") + mark_plan.add_argument("status", choices=["open", "review", "accepted", "in_progress", "done", "abandoned"]) + mark_plan.add_argument("--json", action="store_true", help="print machine-readable JSON") + + status = subparsers.add_parser("status", help="print discussion overview") + status.add_argument("--json", action="store_true", help="print machine-readable JSON") + + validate_parser = subparsers.add_parser("validate", help="validate discussion/index.ndjson and referenced files") + validate_parser.add_argument("--json", action="store_true", help="print machine-readable JSON") + + inspect = subparsers.add_parser("inspect", help="show one discussion and its artifacts") + inspect.add_argument("discussion_id") + inspect.add_argument("--json", action="store_true", help="print machine-readable JSON") + + context = subparsers.add_parser("context", help="print compact AI-ready context for one discussion") + context.add_argument("discussion_id") + context.add_argument("--json", action="store_true", help="print machine-readable JSON") + + next_actions = subparsers.add_parser("next-actions", help="print suggested operational next actions") + next_actions.add_argument("--json", action="store_true", help="print machine-readable JSON") + + subparsers.add_parser("backup-index", help="copy discussion/index.ndjson into discussion/.backups") + + housekeep = subparsers.add_parser("housekeep", help="complete a discussion and prune workflow artifacts") + housekeep.add_argument("discussion_id") + group = housekeep.add_mutually_exclusive_group(required=True) + group.add_argument("--lesson", help="existing lesson path to register") + group.add_argument("--no-lesson", action="store_true", help="complete without registering a lesson") + + args = parser.parse_args(argv) + root = _resolve_root(args.root) + + try: + if args.command == "init": + print(commands.init(root)) + elif args.command == "start": + data = commands.start_agenda( + root, + ticket=args.ticket, + title=args.title, + tags=args.tag, + domain_owner=args.domain_owner, + pain=args.pain, + context=args.context, + questions=args.question, + ) + if args.json: + print(commands.to_json(data)) + else: + print(f"started {data['discussion_id']} with {data['agenda_id']}") + print(f"agenda: {data['agenda_file']}") + elif args.command == "accept-agenda": + data = commands.accept_agenda(root, args.discussion_id, args.agenda) + if args.json: + print(commands.to_json(data)) + else: + print(f"accepted agenda {data['agenda_id']} for {data['discussion_id']}") + elif args.command == "add-decision": + data = commands.add_decision(root, args.discussion_id, args.agenda, args.title) + if args.json: + print(commands.to_json(data)) + else: + print(f"created decision {data['decision_id']} for {data['discussion_id']}") + print(f"decision: {data['decision_file']}") + elif args.command == "accept-decision": + data = commands.accept_decision(root, args.discussion_id, args.decision) + if args.json: + print(commands.to_json(data)) + else: + print(f"accepted decision {data['decision_id']} for {data['discussion_id']}") + elif args.command == "add-plan": + data = commands.add_plan(root, args.discussion_id, args.decision, args.title) + if args.json: + print(commands.to_json(data)) + else: + print(f"created plan {data['plan_id']} for {data['discussion_id']}") + print(f"plan: {data['plan_file']}") + elif args.command == "mark-plan": + data = commands.mark_plan(root, args.plan_id, args.status) + if args.json: + print(commands.to_json(data)) + else: + print(f"marked plan {data['plan_id']} {data['status']} for {data['discussion_id']}") + elif args.command == "status": + print(commands.to_json(commands.status_data(root)) if args.json else commands.status(root)) + elif args.command == "validate": + data = commands.validate_data(root) + if args.json: + print(commands.to_json(data)) + return 0 if data["ok"] else 2 + errors = data["errors"] + if errors: + for error in errors: + print(error, file=sys.stderr) + return 2 + print("validation passed") + elif args.command == "inspect": + print(commands.to_json(commands.inspect_data(root, args.discussion_id)) if args.json else commands.inspect(root, args.discussion_id)) + elif args.command == "context": + print(commands.to_json(commands.context_data(root, args.discussion_id)) if args.json else commands.context(root, args.discussion_id)) + elif args.command == "next-actions": + if args.json: + data = commands.status_data(root) + data["next_actions"] = { + item["id"]: commands.next_actions_for(commands.inspect_data(root, item["id"])) + for item in data["discussions"] + } + print(commands.to_json(data)) + else: + print(commands.next_actions(root)) + elif args.command == "backup-index": + print(commands.backup_index(root)) + elif args.command == "housekeep": + print(commands.housekeep(root, args.discussion_id, args.lesson, args.no_lesson)) + else: + parser.error(f"unknown command: {args.command}") + except DiscussionError as exc: + print(str(exc), file=sys.stderr) + return 2 + return 0 + + +def _resolve_root(root_arg: str | None) -> Path: + root = Path(root_arg or ".").resolve() + if (root / "discussion" / "index.ndjson").exists(): + return root + if root.name == "discussion" and (root / "index.ndjson").exists(): + return root.parent + return root diff --git a/src/discussion_framework/commands.py b/src/discussion_framework/commands.py new file mode 100644 index 0000000..7db8840 --- /dev/null +++ b/src/discussion_framework/commands.py @@ -0,0 +1,780 @@ +from __future__ import annotations + +from datetime import date +from pathlib import Path +import json +import re +import shutil + +from .model import ALL_ARTIFACT_KINDS, DiscussionError, DiscussionIndex, WORKFLOW_KINDS, artifact_path +from .validation import validate, validate_or_raise + + +INITIAL_INDEX = '{"type":"meta","next_id":{"DSC":1,"AGD":1,"DEC":1,"PLN":1,"LSN":1,"CLSN":1}}\n' + + +def init(root: Path) -> str: + discussion_dir = root / "discussion" + for relative in ( + "workflow/agendas", + "workflow/decisions", + "workflow/plans", + "lessons", + ".backups", + ): + (discussion_dir / relative).mkdir(parents=True, exist_ok=True) + + index_path = discussion_dir / "index.ndjson" + if index_path.exists(): + return f"already initialized: {index_path}" + index_path.write_text(INITIAL_INDEX, encoding="utf-8") + return f"initialized: {discussion_dir}" + + +def start_agenda( + root: Path, + *, + ticket: str, + title: str, + tags: list[str], + domain_owner: str, + pain: str, + context: str, + questions: list[str], +) -> dict: + if not ticket.strip(): + raise DiscussionError("ticket is required") + if not title.strip(): + raise DiscussionError("title is required") + + index = DiscussionIndex.load(root) + normalized_ticket = _slugify(ticket) + if not normalized_ticket: + raise DiscussionError("ticket must contain at least one alphanumeric character") + for discussion in index.discussions: + if discussion.get("ticket") == normalized_ticket: + raise DiscussionError(f"discussion already exists for ticket: {normalized_ticket}") + + discussion_id = index.next_id("DSC") + agenda_id = index.next_id("AGD") + slug = _slugify(title) or normalized_ticket + agenda_file = f"{agenda_id}-{slug}.md" + today = date.today().isoformat() + normalized_tags = _normalize_tags(tags) + agenda_path = artifact_path(root, "agendas", agenda_file) + agenda_path.parent.mkdir(parents=True, exist_ok=True) + agenda_path.write_text( + _render_agenda( + agenda_id=agenda_id, + ticket=normalized_ticket, + title=title.strip(), + created=today, + tags=normalized_tags, + domain_owner=domain_owner.strip(), + pain=pain.strip(), + context=context.strip(), + questions=questions, + ), + encoding="utf-8", + ) + + discussion = { + "type": "discussion", + "id": discussion_id, + "status": "open", + "ticket": normalized_ticket, + "title": title.strip(), + "created_at": today, + "updated_at": today, + "tags": normalized_tags, + "agendas": [{"id": agenda_id, "file": agenda_file, "status": "open", "created_at": today, "updated_at": today}], + "decisions": [], + "plans": [], + "lessons": [], + } + index.discussions.insert(0, discussion) + index.ensure_next_id_above("DSC", discussion_id) + index.ensure_next_id_above("AGD", agenda_id) + index.save() + validate_or_raise(root) + return { + "discussion_id": discussion_id, + "agenda_id": agenda_id, + "ticket": normalized_ticket, + "title": title.strip(), + "agenda_file": str(agenda_path), + } + + +def accept_agenda(root: Path, discussion_id: str, agenda_id: str | None) -> dict: + index = DiscussionIndex.load(root) + discussion = index.find_discussion(discussion_id) + agenda = _select_artifact(discussion, "agendas", agenda_id) + if agenda.get("status") not in {"open", "review"}: + raise DiscussionError(f"agenda cannot be accepted from status: {agenda.get('status')}") + today = date.today().isoformat() + _set_artifact_status(root, "agendas", agenda, "accepted") + agenda["status"] = "accepted" + agenda["updated_at"] = today + discussion["updated_at"] = today + index.save() + validate_or_raise(root) + return {"discussion_id": discussion_id, "agenda_id": agenda["id"], "status": "accepted"} + + +def add_decision(root: Path, discussion_id: str, agenda_id: str | None, title: str) -> dict: + if not title.strip(): + raise DiscussionError("title is required") + index = DiscussionIndex.load(root) + discussion = index.find_discussion(discussion_id) + agenda = _select_artifact(discussion, "agendas", agenda_id) + if agenda.get("status") != "accepted": + raise DiscussionError(f"agenda must be accepted before adding a decision: {agenda.get('id')}") + decision_id = index.next_id("DEC") + slug = _slugify(title) or _slugify(discussion.get("ticket", "")) or decision_id.lower() + decision_file = f"{decision_id}-{slug}.md" + today = date.today().isoformat() + decision_path = artifact_path(root, "decisions", decision_file) + decision_path.parent.mkdir(parents=True, exist_ok=True) + decision_path.write_text( + _render_decision( + decision_id=decision_id, + ticket=discussion.get("ticket", ""), + title=title.strip(), + created=today, + tags=discussion.get("tags", []), + agenda_id=agenda["id"], + ), + encoding="utf-8", + ) + entry = { + "id": decision_id, + "file": decision_file, + "status": "open", + "created_at": today, + "updated_at": today, + "ref_agenda": agenda["id"], + } + discussion.setdefault("decisions", []).append(entry) + discussion["status"] = "in_progress" + discussion["updated_at"] = today + index.ensure_next_id_above("DEC", decision_id) + index.save() + validate_or_raise(root) + return {"discussion_id": discussion_id, "decision_id": decision_id, "decision_file": str(decision_path)} + + +def accept_decision(root: Path, discussion_id: str, decision_id: str | None) -> dict: + index = DiscussionIndex.load(root) + discussion = index.find_discussion(discussion_id) + decision = _select_artifact(discussion, "decisions", decision_id) + if decision.get("status") not in {"open", "review"}: + raise DiscussionError(f"decision cannot be accepted from status: {decision.get('status')}") + today = date.today().isoformat() + _set_artifact_status(root, "decisions", decision, "accepted") + decision["status"] = "accepted" + decision["updated_at"] = today + discussion["updated_at"] = today + index.save() + validate_or_raise(root) + return {"discussion_id": discussion_id, "decision_id": decision["id"], "status": "accepted"} + + +def add_plan(root: Path, discussion_id: str, decision_id: str | None, title: str) -> dict: + if not title.strip(): + raise DiscussionError("title is required") + index = DiscussionIndex.load(root) + discussion = index.find_discussion(discussion_id) + decision = _select_artifact(discussion, "decisions", decision_id) + if decision.get("status") != "accepted": + raise DiscussionError(f"decision must be accepted before adding a plan: {decision.get('id')}") + plan_id = index.next_id("PLN") + slug = _slugify(title) or _slugify(discussion.get("ticket", "")) or plan_id.lower() + plan_file = f"{plan_id}-{slug}.md" + today = date.today().isoformat() + plan_path = artifact_path(root, "plans", plan_file) + plan_path.parent.mkdir(parents=True, exist_ok=True) + plan_path.write_text( + _render_plan( + plan_id=plan_id, + ticket=discussion.get("ticket", ""), + title=title.strip(), + created=today, + tags=discussion.get("tags", []), + decision_id=decision["id"], + ), + encoding="utf-8", + ) + entry = { + "id": plan_id, + "file": plan_file, + "status": "open", + "created_at": today, + "updated_at": today, + "ref_decisions": [decision["id"]], + } + discussion.setdefault("plans", []).append(entry) + discussion["status"] = "in_progress" + discussion["updated_at"] = today + index.ensure_next_id_above("PLN", plan_id) + index.save() + validate_or_raise(root) + return {"discussion_id": discussion_id, "plan_id": plan_id, "plan_file": str(plan_path)} + + +def mark_plan(root: Path, plan_id: str, status: str) -> dict: + if status not in {"open", "review", "accepted", "in_progress", "done", "abandoned"}: + raise DiscussionError(f"unsupported plan status: {status}") + index = DiscussionIndex.load(root) + discussion, plan = _find_artifact(index, "plans", plan_id) + current = plan.get("status") + if not _can_transition(current, status): + raise DiscussionError(f"invalid plan transition: {current} -> {status}") + today = date.today().isoformat() + _set_artifact_status(root, "plans", plan, status) + plan["status"] = status + plan["updated_at"] = today + discussion["updated_at"] = today + if status == "in_progress": + discussion["status"] = "in_progress" + index.save() + validate_or_raise(root) + return {"discussion_id": discussion["id"], "plan_id": plan_id, "status": status} + + +def status(root: Path) -> str: + data = status_data(root) + lines = [f"discussions: {data['total']}"] + if data["statuses"]: + lines.append("statuses: " + ", ".join(f"{name}={data['statuses'][name]}" for name in sorted(data["statuses"]))) + lines.append("") + + for discussion in data["discussions"]: + parts = [ + discussion.get("id", "?"), + discussion.get("status", "?"), + discussion.get("ticket", "?"), + ] + artifact_counts = [] + for kind in ("agendas", "decisions", "plans", "lessons"): + count = discussion["artifact_counts"].get(kind, 0) + if count: + artifact_counts.append(f"{kind}={count}") + suffix = f" ({', '.join(artifact_counts)})" if artifact_counts else "" + lines.append(" " + " ".join(parts) + suffix) + return "\n".join(lines) + + +def status_data(root: Path) -> dict: + index = DiscussionIndex.load(root) + counts: dict[str, int] = {} + for discussion in index.discussions: + counts[discussion.get("status", "unknown")] = counts.get(discussion.get("status", "unknown"), 0) + 1 + + discussions = [] + for discussion in index.discussions: + discussions.append( + { + "id": discussion.get("id"), + "status": discussion.get("status"), + "ticket": discussion.get("ticket"), + "title": discussion.get("title"), + "tags": discussion.get("tags", []), + "artifact_counts": {kind: len(discussion.get(kind, [])) for kind in ALL_ARTIFACT_KINDS}, + } + ) + return {"total": len(index.discussions), "statuses": counts, "discussions": discussions} + + +def inspect(root: Path, discussion_id: str) -> str: + data = inspect_data(root, discussion_id) + lines = [ + f"{data['id']} {data['status']} {data['ticket']}", + data.get("title") or "", + "tags: " + ", ".join(data.get("tags", [])), + "", + ] + for kind in ALL_ARTIFACT_KINDS: + artifacts = data["artifacts"][kind] + if not artifacts: + continue + lines.append(f"{kind}:") + for artifact in artifacts: + marker = "exists" if artifact["exists"] else "missing" + lines.append(f" - {artifact['id']} {artifact.get('status', '?')} {marker} {artifact['path']}") + lines.append("") + actions = next_actions_for(data) + if actions: + lines.append("suggested next actions:") + for action in actions: + lines.append(f" - {action}") + return "\n".join(line for line in lines if line is not None).rstrip() + + +def inspect_data(root: Path, discussion_id: str) -> dict: + index = DiscussionIndex.load(root) + discussion = index.find_discussion(discussion_id) + artifacts: dict[str, list[dict]] = {} + for kind in ALL_ARTIFACT_KINDS: + items = [] + for artifact in discussion.get(kind, []): + file_value = artifact.get("file", "") + path = artifact_path(root, kind, file_value) if file_value else root + items.append( + { + "id": artifact.get("id"), + "status": artifact.get("status"), + "file": file_value, + "path": str(path), + "exists": path.exists() if file_value else False, + "ref_agenda": artifact.get("ref_agenda"), + "ref_decisions": artifact.get("ref_decisions", []), + } + ) + artifacts[kind] = items + return { + "id": discussion.get("id"), + "status": discussion.get("status"), + "ticket": discussion.get("ticket"), + "title": discussion.get("title"), + "created_at": discussion.get("created_at"), + "updated_at": discussion.get("updated_at"), + "tags": discussion.get("tags", []), + "artifacts": artifacts, + } + + +def context(root: Path, discussion_id: str) -> str: + data = context_data(root, discussion_id) + actions = next_actions_for(data) + lines = [ + f"Discussion: {data['id']}", + f"Status: {data['status']}", + f"Ticket: {data['ticket']}", + f"Title: {data['title']}", + f"Tags: {', '.join(data.get('tags', []))}", + "", + "Active artifacts:", + ] + any_artifact = False + for kind in ALL_ARTIFACT_KINDS: + for artifact in data["artifacts"][kind]: + any_artifact = True + exists = "present" if artifact["exists"] else "missing" + lines.append(f"- {artifact['id']} ({kind}, {artifact.get('status')}, {exists}): {artifact['file']}") + if not any_artifact: + lines.append("- none") + lines.extend(["", "Suggested next actions:"]) + if actions: + lines.extend(f"- {action}" for action in actions) + else: + lines.append("- none") + return "\n".join(lines) + + +def context_data(root: Path, discussion_id: str) -> dict: + data = inspect_data(root, discussion_id) + data["next_actions"] = next_actions_for(data) + return data + + +def next_actions(root: Path) -> str: + index = DiscussionIndex.load(root) + lines = [] + for discussion in index.discussions: + data = inspect_data(root, discussion["id"]) + actions = next_actions_for(data) + if not actions: + continue + lines.append(f"{discussion['id']} {discussion.get('status')} {discussion.get('ticket')}") + for action in actions: + lines.append(f" - {action}") + return "\n".join(lines) if lines else "no suggested next actions" + + +def next_actions_for(data: dict) -> list[str]: + status = data.get("status") + artifacts = data["artifacts"] + actions: list[str] = [] + if status == "open" and artifacts["agendas"]: + agenda_statuses = {artifact.get("status") for artifact in artifacts["agendas"]} + if "open" in agenda_statuses or "review" in agenda_statuses: + actions.append(f"run: discussion accept-agenda {data['id']} when the agenda is ready") + if "accepted" in agenda_statuses and not artifacts["decisions"]: + actions.append(f"run: discussion add-decision {data['id']} --title ") + if status in {"open", "in_progress"} and artifacts["decisions"] and not artifacts["plans"]: + decision_statuses = {artifact.get("status") for artifact in artifacts["decisions"]} + if "open" in decision_statuses or "review" in decision_statuses: + actions.append(f"run: discussion accept-decision {data['id']} when the decision is accepted") + if "accepted" in decision_statuses: + actions.append(f"run: discussion add-plan {data['id']} --title <title>") + if status in {"open", "in_progress"} and artifacts["plans"]: + plan_statuses = {artifact.get("status") for artifact in artifacts["plans"]} + if plan_statuses <= {"done"}: + actions.append("write a lesson if durable knowledge exists") + actions.append(f"run: discussion housekeep {data['id']} --lesson <lesson-path> or --no-lesson") + else: + actions.append("execute or close active plans before housekeeping") + if status == "done" and any(artifacts[kind] for kind in WORKFLOW_KINDS): + actions.append(f"run: discussion housekeep {data['id']} --no-lesson") + return actions + + +def housekeep(root: Path, discussion_id: str, lesson: str | None, no_lesson: bool) -> str: + if bool(lesson) == bool(no_lesson): + raise DiscussionError("choose exactly one of --lesson or --no-lesson") + + index = DiscussionIndex.load(root) + discussion = index.find_discussion(discussion_id) + + lesson_entry = None + if lesson: + lesson_path = Path(lesson) + if not lesson_path.is_absolute(): + lesson_path = root / lesson_path + lesson_path = lesson_path.resolve() + if not lesson_path.exists(): + raise DiscussionError(f"lesson file not found: {lesson_path}") + lesson_id = _read_frontmatter_field(lesson_path, "id") + if not lesson_id: + lesson_id = lesson_path.name.split("-", 2)[0] + "-" + lesson_path.name.split("-", 2)[1] + relative = lesson_path.relative_to(root.resolve()).as_posix() + today = date.today().isoformat() + lesson_entry = { + "id": lesson_id, + "file": relative, + "status": "done", + "created_at": today, + "updated_at": today, + } + index.ensure_next_id_above("LSN", lesson_id) + + removed: list[Path] = [] + for kind in WORKFLOW_KINDS: + for artifact in discussion.get(kind, []): + file_value = artifact.get("file") + if not file_value: + continue + path = artifact_path(root, kind, file_value) + if path.exists(): + path.unlink() + removed.append(path) + discussion[kind] = [] + + if lesson_entry: + lessons = discussion.setdefault("lessons", []) + lessons[:] = [existing for existing in lessons if existing.get("id") != lesson_entry["id"]] + lessons.append(lesson_entry) + + discussion["status"] = "done" + discussion["updated_at"] = date.today().isoformat() + index.save() + validate_or_raise(root) + + lines = [f"housekept {discussion_id}", f"removed workflow artifacts: {len(removed)}"] + if lesson_entry: + lines.append(f"registered lesson: {lesson_entry['id']}") + return "\n".join(lines) + + +def backup_index(root: Path) -> str: + index = DiscussionIndex.load(root) + backup_dir = index.discussion_dir / ".backups" + backup_dir.mkdir(parents=True, exist_ok=True) + timestamp = date.today().isoformat() + backup_path = backup_dir / f"index.ndjson.{timestamp}.bak" + suffix = 1 + while backup_path.exists(): + backup_path = backup_dir / f"index.ndjson.{timestamp}.{suffix}.bak" + suffix += 1 + shutil.copy2(index.path, backup_path) + return f"backup created: {backup_path}" + + +def validate_data(root: Path) -> dict: + errors = validate(root) + return {"ok": not errors, "errors": errors} + + +def to_json(data: dict) -> str: + return json.dumps(data, indent=2, ensure_ascii=False) + + +def _normalize_tags(tags: list[str]) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + for value in tags: + for part in value.split(","): + tag = _slugify(part) + if tag and tag not in seen: + normalized.append(tag) + seen.add(tag) + return normalized + + +def _slugify(value: str) -> str: + slug = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-") + return re.sub(r"-+", "-", slug) + + +def _render_agenda( + *, + agenda_id: str, + ticket: str, + title: str, + created: str, + tags: list[str], + domain_owner: str, + pain: str, + context: str, + questions: list[str], +) -> str: + tag_text = "[" + ", ".join(tags) + "]" + domain_line = f"Domain owner: `{domain_owner}`\n\n" if domain_owner else "" + pain_text = pain or "TBD." + context_text = context or "TBD." + question_lines = [f"- [ ] {question.strip()}" for question in questions if question.strip()] + if not question_lines: + question_lines = ["- [ ] TBD."] + return f"""--- +id: {agenda_id} +ticket: {ticket} +title: {title} +status: open +created: {created} +resolved: +decision: +tags: {tag_text} +--- + +## Pain + +{domain_line}{pain_text} + +## Context + +{context_text} + +## Open Questions + +{chr(10).join(question_lines)} + +## Options + +### Option A - TBD + +- **Approach:** TBD. +- **Pro:** TBD. +- **Con:** TBD. +- **Maintainability:** TBD. + +### Option B - TBD + +- **Approach:** TBD. +- **Pro:** TBD. +- **Con:** TBD. +- **Maintainability:** TBD. + +## Tradeoffs + +TBD. + +## Recommendation + +TBD. + +## Discussion + +TBD. + +## Resolution + +Ainda em aberto. + +## Next Step + +Preencher a agenda com contexto suficiente para decidir se o tema esta pronto para uma decision. +""" + + +def _render_decision( + *, + decision_id: str, + ticket: str, + title: str, + created: str, + tags: list[str], + agenda_id: str, +) -> str: + tag_text = "[" + ", ".join(tags) + "]" + return f"""--- +id: {decision_id} +ticket: {ticket} +title: {title} +status: open +created: {created} +ref_agenda: {agenda_id} +tags: {tag_text} +--- + +## Context + +TBD. + +## Decision + +TBD. + +## Rationale + +TBD. + +## Implications + +TBD. + +## Propagation Targets + +- specs: TBD. +- plans: TBD. +- code: TBD. +- tests: TBD. +- docs: TBD. + +## References + +- Agenda: {agenda_id} +""" + + +def _render_plan( + *, + plan_id: str, + ticket: str, + title: str, + created: str, + tags: list[str], + decision_id: str, +) -> str: + tag_text = "[" + ", ".join(tags) + "]" + return f"""--- +id: {plan_id} +ticket: {ticket} +title: {title} +status: open +created: {created} +ref_decisions: [{decision_id}] +tags: {tag_text} +--- + +## Briefing + +TBD. + +## Objective + +TBD. + +## Dependencies + +TBD. + +## Scope + +TBD. + +## Non-Goals + +TBD. + +## Execution Method + +TBD. + +## Acceptance Criteria + +TBD. + +## Tests + +TBD. + +## Affected Artifacts + +TBD. +""" + + +def _select_artifact(discussion: dict, kind: str, artifact_id: str | None) -> dict: + artifacts = discussion.get(kind, []) + if not artifacts: + raise DiscussionError(f"{discussion.get('id')}: no {kind}") + if artifact_id: + for artifact in artifacts: + if artifact.get("id") == artifact_id: + return artifact + raise DiscussionError(f"{discussion.get('id')}: {kind} artifact not found: {artifact_id}") + active = [artifact for artifact in artifacts if artifact.get("status") not in {"done", "abandoned"}] + if len(active) == 1: + return active[0] + if len(artifacts) == 1: + return artifacts[0] + raise DiscussionError(f"{discussion.get('id')}: choose one {kind} artifact explicitly") + + +def _find_artifact(index: DiscussionIndex, kind: str, artifact_id: str) -> tuple[dict, dict]: + for discussion in index.discussions: + for artifact in discussion.get(kind, []): + if artifact.get("id") == artifact_id: + return discussion, artifact + raise DiscussionError(f"{kind} artifact not found: {artifact_id}") + + +def _set_artifact_status(root: Path, kind: str, artifact: dict, status: str) -> None: + file_value = artifact.get("file") + if not file_value: + return + path = artifact_path(root, kind, file_value) + if not path.exists(): + return + lines = path.read_text(encoding="utf-8").splitlines() + if not lines or lines[0].strip() != "---": + return + updated = False + for index, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + break + key, sep, _value = line.partition(":") + if sep and key.strip() == "status": + lines[index] = f"status: {status}" + updated = True + break + if updated: + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _can_transition(current: str | None, target: str) -> bool: + if current == target: + return True + allowed = { + "open": {"review", "accepted", "in_progress", "abandoned"}, + "review": {"accepted", "open", "abandoned"}, + "accepted": {"in_progress", "done", "abandoned"}, + "in_progress": {"review", "done", "abandoned"}, + "done": set(), + "abandoned": set(), + } + return target in allowed.get(current or "", set()) + + +def _read_frontmatter_field(path: Path, field: str) -> str | None: + lines = path.read_text(encoding="utf-8").splitlines() + if not lines or lines[0].strip() != "---": + return None + for line in lines[1:]: + if line.strip() == "---": + return None + key, sep, value = line.partition(":") + if sep and key.strip() == field: + return value.strip() + return None diff --git a/src/discussion_framework/model.py b/src/discussion_framework/model.py new file mode 100644 index 0000000..2349637 --- /dev/null +++ b/src/discussion_framework/model.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import json + + +COUNTERS = ("DSC", "AGD", "DEC", "PLN", "LSN", "CLSN") +WORKFLOW_KINDS = ("agendas", "decisions", "plans") +ALL_ARTIFACT_KINDS = (*WORKFLOW_KINDS, "lessons") + + +class DiscussionError(RuntimeError): + pass + + +@dataclass +class DiscussionIndex: + root: Path + meta: dict + discussions: list[dict] + + @property + def discussion_dir(self) -> Path: + return self.root / "discussion" + + @property + def path(self) -> Path: + return self.discussion_dir / "index.ndjson" + + @classmethod + def load(cls, root: Path) -> "DiscussionIndex": + path = root / "discussion" / "index.ndjson" + if not path.exists(): + raise DiscussionError(f"index not found: {path}") + + records = [] + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + if not line.strip(): + continue + try: + records.append(json.loads(line)) + except json.JSONDecodeError as exc: + raise DiscussionError(f"{path}:{line_number}: invalid JSON: {exc}") from exc + + if not records or records[0].get("type") != "meta": + raise DiscussionError("index first record must be type=meta") + + for record in records[1:]: + if record.get("type") != "discussion": + raise DiscussionError(f"index contains non-discussion record after meta: {record!r}") + + return cls(root=root, meta=records[0], discussions=records[1:]) + + def save(self) -> None: + records = [self.meta, *self.discussions] + text = "\n".join(json.dumps(record, separators=(",", ":"), ensure_ascii=False) for record in records) + self.path.write_text(text + "\n", encoding="utf-8") + + def find_discussion(self, discussion_id: str) -> dict: + for discussion in self.discussions: + if discussion.get("id") == discussion_id: + return discussion + raise DiscussionError(f"discussion not found: {discussion_id}") + + def next_id(self, prefix: str) -> str: + try: + value = self.meta["next_id"][prefix] + except KeyError as exc: + raise DiscussionError(f"missing next_id.{prefix}") from exc + return f"{prefix}-{value:04d}" + + def ensure_next_id_above(self, prefix: str, artifact_id: str) -> None: + number = numeric_part(artifact_id) + next_id = self.meta.setdefault("next_id", {}).setdefault(prefix, 1) + if next_id <= number: + self.meta["next_id"][prefix] = number + 1 + + +def numeric_part(identifier: str) -> int: + digits = "".join(ch for ch in identifier if ch.isdigit()) + return int(digits) if digits else 0 + + +def artifact_path(root: Path, kind: str, file_value: str) -> Path: + if kind == "agendas": + return root / "discussion" / "workflow" / "agendas" / file_value + if kind == "decisions": + return root / "discussion" / "workflow" / "decisions" / file_value + if kind == "plans": + return root / "discussion" / "workflow" / "plans" / file_value + if kind == "lessons": + return root / file_value + raise DiscussionError(f"unknown artifact kind: {kind}") diff --git a/src/discussion_framework/validation.py b/src/discussion_framework/validation.py new file mode 100644 index 0000000..6f244b7 --- /dev/null +++ b/src/discussion_framework/validation.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from pathlib import Path + +from .model import ( + ALL_ARTIFACT_KINDS, + COUNTERS, + DiscussionError, + DiscussionIndex, + artifact_path, + numeric_part, +) + + +def validate(root: Path) -> list[str]: + index = DiscussionIndex.load(root) + errors: list[str] = [] + + next_ids = index.meta.get("next_id") + if not isinstance(next_ids, dict): + errors.append("meta.next_id must be an object") + next_ids = {} + + max_ids = {prefix: 0 for prefix in COUNTERS} + seen: set[str] = set() + + for counter in COUNTERS: + value = next_ids.get(counter) + if not isinstance(value, int): + errors.append(f"meta.next_id.{counter} must be an integer") + + for discussion in index.discussions: + discussion_id = discussion.get("id") + if not isinstance(discussion_id, str) or not discussion_id.startswith("DSC-"): + errors.append(f"discussion has invalid id: {discussion_id!r}") + continue + _record_id(errors, seen, discussion_id) + max_ids["DSC"] = max(max_ids["DSC"], numeric_part(discussion_id)) + + if not discussion.get("ticket"): + errors.append(f"{discussion_id}: missing ticket") + if not discussion.get("status"): + errors.append(f"{discussion_id}: missing status") + + for kind in ALL_ARTIFACT_KINDS: + artifacts = discussion.get(kind, []) + if not isinstance(artifacts, list): + errors.append(f"{discussion_id}: {kind} must be an array") + continue + for artifact in artifacts: + artifact_id = artifact.get("id") + file_value = artifact.get("file") + if not isinstance(artifact_id, str): + errors.append(f"{discussion_id}: {kind} artifact missing id") + continue + _record_id(errors, seen, artifact_id) + prefix = artifact_id.split("-", 1)[0] + if prefix in max_ids: + max_ids[prefix] = max(max_ids[prefix], numeric_part(artifact_id)) + else: + errors.append(f"{discussion_id}: unknown artifact id prefix: {artifact_id}") + if not isinstance(file_value, str) or not file_value: + errors.append(f"{discussion_id}: {artifact_id} missing file") + continue + path = artifact_path(root, kind, file_value) + if not path.exists() and _requires_file(discussion, artifact, kind): + errors.append(f"{discussion_id}: {artifact_id} file not found: {path}") + + for prefix, max_id in max_ids.items(): + value = next_ids.get(prefix) + if isinstance(value, int) and value <= max_id: + errors.append(f"meta.next_id.{prefix} must be greater than max {prefix} id {max_id}") + + return errors + + +def validate_or_raise(root: Path) -> None: + errors = validate(root) + if errors: + raise DiscussionError("\n".join(errors)) + + +def _record_id(errors: list[str], seen: set[str], identifier: str) -> None: + if identifier in seen: + errors.append(f"duplicate id: {identifier}") + seen.add(identifier) + + +def _requires_file(discussion: dict, artifact: dict, kind: str) -> bool: + if kind == "lessons": + return True + if discussion.get("status") == "abandoned": + return artifact.get("status") != "abandoned" + return True diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a797378 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +import contextlib +import io +import json +from pathlib import Path +import unittest + +from discussion_framework.cli import main + + +class CliTest(unittest.TestCase): + def test_init_validate_and_status(self) -> None: + with TempDir() as root: + self.assertEqual(main(["--root", str(root), "init"]), 0) + self.assertTrue((root / "discussion" / "index.ndjson").exists()) + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + self.assertEqual(main(["--root", str(root), "validate"]), 0) + self.assertIn("validation passed", stdout.getvalue()) + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + self.assertEqual(main(["--root", str(root), "status"]), 0) + self.assertIn("discussions: 0", stdout.getvalue()) + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + self.assertEqual(main(["--root", str(root), "status", "--json"]), 0) + self.assertEqual(json.loads(stdout.getvalue())["total"], 0) + + def test_start_creates_discussion_and_agenda(self) -> None: + with TempDir() as root: + main(["--root", str(root), "init"]) + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + self.assertEqual( + main( + [ + "--root", + str(root), + "start", + "pbs-autocomplete-parameter-names", + "PBS autocomplete parameter names", + "--tag", + "compiler,pbs", + "--tag", + "lsp", + "--domain-owner", + "compiler/pbs", + "--pain", + "Completions show arg0 and arg1.", + "--context", + "Stdlib methods lack authored parameter names.", + "--question", + "Where should canonical parameter names live?", + ] + ), + 0, + ) + + discussion = root / "discussion" + records = [json.loads(line) for line in (discussion / "index.ndjson").read_text(encoding="utf-8").splitlines()] + meta = records[0] + item = records[1] + agenda_file = discussion / "workflow" / "agendas" / "AGD-0001-pbs-autocomplete-parameter-names.md" + + self.assertIn("started DSC-0001 with AGD-0001", stdout.getvalue()) + self.assertEqual(meta["next_id"]["DSC"], 2) + self.assertEqual(meta["next_id"]["AGD"], 2) + self.assertEqual(item["ticket"], "pbs-autocomplete-parameter-names") + self.assertEqual(item["tags"], ["compiler", "pbs", "lsp"]) + self.assertEqual(item["agendas"][0]["file"], agenda_file.name) + self.assertTrue(agenda_file.exists()) + agenda_text = agenda_file.read_text(encoding="utf-8") + self.assertIn("Domain owner: `compiler/pbs`", agenda_text) + self.assertIn("Completions show arg0 and arg1.", agenda_text) + + def test_root_auto_detects_existing_discussion_directory(self) -> None: + with TempDir() as root: + main(["--root", str(root), "init"]) + cwd = Path.cwd() + try: + import os + + os.chdir(root / "discussion") + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + self.assertEqual(main(["init"]), 0) + self.assertIn("already initialized", stdout.getvalue()) + finally: + os.chdir(cwd) + + self.assertFalse((root / "discussion" / "discussion").exists()) + + def test_semantic_decision_and_plan_commands(self) -> None: + with TempDir() as root: + main(["--root", str(root), "init"]) + self.assertEqual( + main( + [ + "--root", + str(root), + "start", + "demo-flow", + "Demo Flow", + "--tag", + "compiler", + ] + ), + 0, + ) + self.assertEqual(main(["--root", str(root), "accept-agenda", "DSC-0001"]), 0) + self.assertEqual( + main(["--root", str(root), "add-decision", "DSC-0001", "--title", "Demo Decision"]), + 0, + ) + self.assertEqual(main(["--root", str(root), "accept-decision", "DSC-0001"]), 0) + self.assertEqual( + main(["--root", str(root), "add-plan", "DSC-0001", "--title", "Demo Plan"]), + 0, + ) + self.assertEqual(main(["--root", str(root), "mark-plan", "PLN-0001", "in_progress"]), 0) + self.assertEqual(main(["--root", str(root), "mark-plan", "PLN-0001", "done"]), 0) + + discussion = root / "discussion" + records = [json.loads(line) for line in (discussion / "index.ndjson").read_text(encoding="utf-8").splitlines()] + meta = records[0] + item = records[1] + decision = discussion / "workflow" / "decisions" / "DEC-0001-demo-decision.md" + plan = discussion / "workflow" / "plans" / "PLN-0001-demo-plan.md" + + self.assertEqual(meta["next_id"]["DEC"], 2) + self.assertEqual(meta["next_id"]["PLN"], 2) + self.assertEqual(item["status"], "in_progress") + self.assertEqual(item["agendas"][0]["status"], "accepted") + self.assertEqual(item["decisions"][0]["status"], "accepted") + self.assertEqual(item["decisions"][0]["ref_agenda"], "AGD-0001") + self.assertEqual(item["plans"][0]["status"], "done") + self.assertEqual(item["plans"][0]["ref_decisions"], ["DEC-0001"]) + self.assertTrue(decision.exists()) + self.assertTrue(plan.exists()) + self.assertIn("status: accepted", decision.read_text(encoding="utf-8")) + self.assertIn("status: done", plan.read_text(encoding="utf-8")) + + def test_housekeep_with_lesson_removes_workflow_artifacts(self) -> None: + with TempDir() as root: + main(["--root", str(root), "init"]) + discussion = root / "discussion" + (discussion / "workflow" / "agendas" / "AGD-0001-demo.md").write_text("agenda", encoding="utf-8") + (discussion / "workflow" / "decisions" / "DEC-0001-demo.md").write_text("decision", encoding="utf-8") + (discussion / "workflow" / "plans" / "PLN-0001-demo.md").write_text("plan", encoding="utf-8") + lesson = discussion / "lessons" / "DSC-0001-demo" + lesson.mkdir() + lesson_file = lesson / "LSN-0001-demo.md" + lesson_file.write_text("---\nid: LSN-0001\n---\n# Demo\n", encoding="utf-8") + + records = [ + {"type": "meta", "next_id": {"DSC": 2, "AGD": 2, "DEC": 2, "PLN": 2, "LSN": 1, "CLSN": 1}}, + { + "type": "discussion", + "id": "DSC-0001", + "status": "in_progress", + "ticket": "demo", + "title": "Demo", + "created_at": "2026-05-08", + "updated_at": "2026-05-08", + "tags": [], + "agendas": [{"id": "AGD-0001", "file": "AGD-0001-demo.md", "status": "accepted"}], + "decisions": [{"id": "DEC-0001", "file": "DEC-0001-demo.md", "status": "accepted"}], + "plans": [{"id": "PLN-0001", "file": "PLN-0001-demo.md", "status": "done"}], + "lessons": [], + }, + ] + (discussion / "index.ndjson").write_text( + "\n".join(json.dumps(record, separators=(",", ":")) for record in records) + "\n", + encoding="utf-8", + ) + + self.assertEqual( + main(["--root", str(root), "housekeep", "DSC-0001", "--lesson", str(lesson_file)]), + 0, + ) + + updated = (discussion / "index.ndjson").read_text(encoding="utf-8").splitlines() + meta = json.loads(updated[0]) + item = json.loads(updated[1]) + self.assertEqual(meta["next_id"]["LSN"], 2) + self.assertEqual(item["status"], "done") + self.assertEqual(item["agendas"], []) + self.assertEqual(item["decisions"], []) + self.assertEqual(item["plans"], []) + self.assertEqual(item["lessons"][0]["id"], "LSN-0001") + self.assertFalse((discussion / "workflow" / "agendas" / "AGD-0001-demo.md").exists()) + + def test_inspect_context_next_actions_and_backup(self) -> None: + with TempDir() as root: + main(["--root", str(root), "init"]) + discussion = root / "discussion" + agenda = discussion / "workflow" / "agendas" / "AGD-0001-demo.md" + agenda.write_text("agenda", encoding="utf-8") + records = [ + {"type": "meta", "next_id": {"DSC": 2, "AGD": 2, "DEC": 1, "PLN": 1, "LSN": 1, "CLSN": 1}}, + { + "type": "discussion", + "id": "DSC-0001", + "status": "open", + "ticket": "demo", + "title": "Demo", + "created_at": "2026-05-08", + "updated_at": "2026-05-08", + "tags": ["studio"], + "agendas": [{"id": "AGD-0001", "file": "AGD-0001-demo.md", "status": "open"}], + "decisions": [], + "plans": [], + "lessons": [], + }, + ] + (discussion / "index.ndjson").write_text( + "\n".join(json.dumps(record, separators=(",", ":")) for record in records) + "\n", + encoding="utf-8", + ) + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + self.assertEqual(main(["--root", str(root), "inspect", "DSC-0001", "--json"]), 0) + self.assertEqual(json.loads(stdout.getvalue())["artifacts"]["agendas"][0]["id"], "AGD-0001") + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + self.assertEqual(main(["--root", str(root), "context", "DSC-0001"]), 0) + self.assertIn("Suggested next actions", stdout.getvalue()) + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + self.assertEqual(main(["--root", str(root), "next-actions"]), 0) + self.assertIn("accept-agenda", stdout.getvalue()) + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + self.assertEqual(main(["--root", str(root), "backup-index"]), 0) + self.assertTrue(any((discussion / ".backups").iterdir())) + + +class TempDir: + def __enter__(self) -> Path: + import tempfile + + self._tmp = tempfile.TemporaryDirectory() + return Path(self._tmp.name) + + def __exit__(self, exc_type, exc, tb) -> None: + self._tmp.cleanup() + + +if __name__ == "__main__": + unittest.main()