first commit
This commit is contained in:
commit
339ff444f5
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
.idea
|
||||
build
|
||||
.python-version
|
||||
.venv
|
||||
108
README.md
Normal file
108
README.md
Normal 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
22
pyproject.toml
Normal 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"]
|
||||
3
src/discussion_framework/__init__.py
Normal file
3
src/discussion_framework/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""Deterministic discussion workflow management."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
4
src/discussion_framework/__main__.py
Normal file
4
src/discussion_framework/__main__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
BIN
src/discussion_framework/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/discussion_framework/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/discussion_framework/__pycache__/cli.cpython-313.pyc
Normal file
BIN
src/discussion_framework/__pycache__/cli.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/discussion_framework/__pycache__/commands.cpython-313.pyc
Normal file
BIN
src/discussion_framework/__pycache__/commands.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/discussion_framework/__pycache__/model.cpython-313.pyc
Normal file
BIN
src/discussion_framework/__pycache__/model.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/discussion_framework/__pycache__/validation.cpython-313.pyc
Normal file
BIN
src/discussion_framework/__pycache__/validation.cpython-313.pyc
Normal file
Binary file not shown.
179
src/discussion_framework/cli.py
Normal file
179
src/discussion_framework/cli.py
Normal 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
|
||||
780
src/discussion_framework/commands.py
Normal file
780
src/discussion_framework/commands.py
Normal 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
|
||||
94
src/discussion_framework/model.py
Normal file
94
src/discussion_framework/model.py
Normal 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}")
|
||||
94
src/discussion_framework/validation.py
Normal file
94
src/discussion_framework/validation.py
Normal 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
259
tests/test_cli.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user