first commit

This commit is contained in:
Nilton Constantino 2026-05-08 16:51:23 +01:00
commit 339ff444f5
No known key found for this signature in database
15 changed files with 1547 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.idea
build
.python-version
.venv

108
README.md Normal file
View File

@ -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/<org>/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 <path>` 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.

22
pyproject.toml Normal file
View File

@ -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"]

View File

@ -0,0 +1,3 @@
"""Deterministic discussion workflow management."""
__version__ = "0.1.0"

View File

@ -0,0 +1,4 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -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

View File

@ -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 <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

View File

@ -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}")

View File

@ -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

259
tests/test_cli.py Normal file
View File

@ -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()