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