From 339ff444f5fd8ac8fce92b1537a4cef97002bd72 Mon Sep 17 00:00:00 2001 From: Nilton Constantino Date: Fri, 8 May 2026 16:51:23 +0100 Subject: [PATCH] first commit --- .gitignore | 4 + README.md | 108 +++ pyproject.toml | 22 + src/discussion_framework/__init__.py | 3 + src/discussion_framework/__main__.py | 4 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 327 bytes .../__pycache__/cli.cpython-313.pyc | Bin 0 -> 12372 bytes .../__pycache__/commands.cpython-313.pyc | Bin 0 -> 33835 bytes .../__pycache__/model.cpython-313.pyc | Bin 0 -> 6155 bytes .../__pycache__/validation.cpython-313.pyc | Bin 0 -> 4897 bytes src/discussion_framework/cli.py | 179 ++++ src/discussion_framework/commands.py | 780 ++++++++++++++++++ src/discussion_framework/model.py | 94 +++ src/discussion_framework/validation.py | 94 +++ tests/test_cli.py | 259 ++++++ 15 files changed, 1547 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/discussion_framework/__init__.py create mode 100644 src/discussion_framework/__main__.py create mode 100644 src/discussion_framework/__pycache__/__init__.cpython-313.pyc create mode 100644 src/discussion_framework/__pycache__/cli.cpython-313.pyc create mode 100644 src/discussion_framework/__pycache__/commands.cpython-313.pyc create mode 100644 src/discussion_framework/__pycache__/model.cpython-313.pyc create mode 100644 src/discussion_framework/__pycache__/validation.cpython-313.pyc create mode 100644 src/discussion_framework/cli.py create mode 100644 src/discussion_framework/commands.py create mode 100644 src/discussion_framework/model.py create mode 100644 src/discussion_framework/validation.py create mode 100644 tests/test_cli.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6eab65a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea +build +.python-version +.venv diff --git a/README.md b/README.md new file mode 100644 index 0000000..fcab8c6 --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# Discussion Framework + +Deterministic CLI for managing `discussion/` workflow state without spending AI +tokens on mechanical maintenance. + +AI should write and review content. This CLI should manage state. + +## Install + +From this repository: + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -e tools/discussion-framework +``` + +After moving this directory to its own GitHub repository: + +```bash +pip install "git+https://github.com//discussion-framework.git@v0.1.0" +``` + +## Commands + +```bash +discussion init +discussion start pbs-autocomplete-parameter-names "PBS autocomplete parameter names" --tag compiler-pbs --tag lsp +discussion accept-agenda DSC-0037 +discussion add-decision DSC-0037 --title "Canonical PBS parameter names for editor assistance" +discussion accept-decision DSC-0037 +discussion add-plan DSC-0037 --title "Propagate canonical parameter names to stdlib and LSP surfaces" +discussion mark-plan PLN-0078 in_progress +discussion mark-plan PLN-0078 done +discussion status +discussion status --json +discussion validate +discussion validate --json +discussion inspect DSC-0035 +discussion inspect DSC-0035 --json +discussion context DSC-0035 +discussion context DSC-0035 --json +discussion next-actions +discussion backup-index +discussion housekeep DSC-0035 --lesson discussion/lessons/.../LSN-0051-name.md +discussion housekeep DSC-0035 --no-lesson +``` + +Use `--root ` when running outside the repository root. + +When a command is executed from inside the existing `discussion/` directory, the +CLI resolves the repository root to the parent directory instead of creating a +nested `discussion/discussion` tree. + +## Contract + +- `init` creates the canonical `discussion/` layout and `index.ndjson`. +- `start` creates a new discussion record and its first agenda artifact. +- `accept-agenda` marks an agenda as accepted once the discussion is ready for + a decision. +- `add-decision` creates a decision artifact from an accepted agenda and indexes + it under the discussion. +- `accept-decision` marks a decision as accepted once its content is normative. +- `add-plan` creates a plan artifact from an accepted decision and indexes it + under the discussion. +- `mark-plan` moves a plan through `open`, `review`, `accepted`, + `in_progress`, `done`, or `abandoned`. +- `status` prints a compact discussion overview. +- `inspect` shows one discussion and its indexed artifacts. +- `context` prints compact AI-ready context and suggested next actions. +- `next-actions` lists operational next steps across discussions. +- `validate` checks the index, counters, artifact references, and lesson files. +- `backup-index` replaces the old shell backup script by copying + `discussion/index.ndjson` into `discussion/.backups/`. +- `housekeep` marks a discussion `done`, removes workflow artifacts, clears + workflow arrays from the index, and optionally registers an existing lesson. + +The command does not generate agenda, decision, plan, or lesson prose. That work +belongs to humans or AI agents. + +## Codex Integration + +Repository instructions and Codex skills should treat this CLI as the mechanical +state manager. + +Recommended policy: + +- Agents use AI for content: agenda, decision, plan, lesson, specs, and code. +- Agents use `discussion` for state: status, validation, housekeeping, index + updates, and workflow artifact pruning. +- Agents should not manually edit `discussion/index.ndjson` when an equivalent + `discussion` command exists. +- If a command is missing, agents may perform the operation manually and should + record the missing command as a framework improvement. + +Recommended skill behavior: + +- `discussion-status`: run `discussion status` first, then inspect files only + when the user asks for deeper analysis. +- `discussion-context`: run `discussion context DSC-XXXX`; this is the primary + AI handoff surface. +- `discussion-validate`: run `discussion validate`; manual validation is a + fallback only. +- `discussion-housekeep`: AI may decide whether a lesson is needed and write it; + final state mutation must use `discussion housekeep --lesson ...` or + `discussion housekeep --no-lesson`. +- `discussion-framework`: document the split between content generation and + deterministic state operations. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9ba2c74 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "discussion-framework" +version = "0.1.0" +description = "Deterministic CLI for managing discussion-framework workflow state." +readme = "README.md" +requires-python = ">=3.11" +authors = [ + { name = "Prometeu" } +] + +[project.scripts] +discussion = "discussion_framework.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +pythonpath = ["src"] diff --git a/src/discussion_framework/__init__.py b/src/discussion_framework/__init__.py new file mode 100644 index 0000000..70a9a34 --- /dev/null +++ b/src/discussion_framework/__init__.py @@ -0,0 +1,3 @@ +"""Deterministic discussion workflow management.""" + +__version__ = "0.1.0" diff --git a/src/discussion_framework/__main__.py b/src/discussion_framework/__main__.py new file mode 100644 index 0000000..bfdcd0c --- /dev/null +++ b/src/discussion_framework/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/discussion_framework/__pycache__/__init__.cpython-313.pyc b/src/discussion_framework/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4c298b18f925e5687325475c9144e4bcea5f566e GIT binary patch literal 327 zcmYjNu}%Xq3{6^9MU-P>zqLazMXZR44T+^05uwbeg<%7DYpQ;h#~LlpVSQ} zP6gLQ3)y7tTlnaq;>uS{!`XsAU9J|_~iii!Xy|%@gw6}SJ vVlw{V?d7Z4Z$LN*3$jIjg8jtw>#*0RkXTNcs+0wIxI$$#V| zkA7*Lwo}_}Iw3P}CS)cvRX%i{-%LM{nRXuel)Nv^U4$#%#(8z$d+nP{-Um{r|7SWq z_pVkFW&x)&O?NbV?>*<-bI<+VbI$I)s~2W-J_Xl9>c0c~>=gBD+)yr^T+zIkfXbg! zEX8UDsD2HvVQ^hLpyjnt>IQWEG*72!IeZRFvpEC${#-s6Xf&%IF!bl~c~H+|^V!@= zYL=eW@cFEfHQ+jLoYp!_U*n-18p-Glg~AbUBoGb>gV3fgdm}SnYhOR9FYX`mrfg8> zH-T3*vrdA8y2RdAwy-W@rQWUI(@J%T!|aiimFoPxZD?hW5*u>exqI56E^(M`OWB~# z$4IrOkW!b}+sY317_rjeHmF*8JjxbS)zd+o|kery*1fyvFT3MciAx1BT7p`@n3A73`Ffp+tJK0mWX1`mkI-N@sdxNsoX8o08CGB&v2^nOd?kcebodi>j@7We#uW0JU{H zwOuFEegM33hJKlaz?BR}tUH5UHeS1+(Lm7nvn^<}ZJ%ArPhS}gz(~b=+vy(=ql;M> zT?JgOrF?1!jIyM!gY@Y4@~IXTpX&IYC?4M~{{#9IJ@i*u2#oEG*DgNQs^U{8w$E-? zpHe~N|J|qPp_(j=#sQ<_uk2G3AbsO|`BVo%@vSY9d2&Y-Pi^M_UaxJZ{(wHE8~Vd6 zyl!UU<$-#q3U>Ne%HW*@8Q2VGw8LC?dqx2->p86YWXj_AZbuFKs$!c`4wYv0Q9)X* z&n|Ba-DFmOvc=NA-7VSkS>w#cD9c(^1k|3*uuiw|I;EfXaPGi*?G!s{hi5tK!@Q8y zZB6sxJ;ZIbQs?&py(DXWO2*GAa~V0W)G{UO$M2?>4p5Yl0*odzJy}k{dl&3Z6YSo| zp6wnU(Y^&|RXnor>d#oJ)OOBZ?SK)!6uXC*EKL_>UiSUfHSK~x&;Kz3gV`3#^;frh z3j!){pRcZI7X;1_1b#~1Wp~R3KiabLd&D|KSTQ?=z1&J&EfutfDM1U~4c-$fb(gZ! zn3J`-m(@_p;_u`~DNQeXjz@dxm>dy;7RD=PXAa@<)OIYY9;_D8L#wL&N7O9ep7R)L zm*@UjvI5Kw7G8PP5q8{Dy-xJ^?4bXux_-z5>Tj*E|9klY*|C>t5XY&vRwbT^4{Ank zY?ovMph0D~SwJ1l+M7&!1;}-HMW?80YW$|6CztXzfnV9O*2k;x+%{$OSIMIX;aEFl z>#B-WVfD+zCWx7;lKdS_9r7 zZC-h~AiCT4>M6A3e3zrTwCuwY8KpF;-{K788GzfILxC}J??9iY?%j4`L&(G}j`M8CMGi7$!yqBND3nRA~nG5T0&VcF(_+tjA(&Jc4 zGC;7Dy@k0m1IEsSHLzl!EF9t#g#iI{Mgq6FmRD=X1pXtV!(Ll&gWf5`0HwqaYZk;4VbvT>ZBKH-f zyczIvZjR#?AeO-BAP0GROU}iUUErgDKzd!%^M;&_qX5S>pay8h818NWQV%>cWCF7Q zA3t%{InwE*UCh^@-d6zhe~pY<&ZUt70FTN z&&0H3RH6s#%U2qp(X7-xL}zhYr$T39WnI_^zDMyE_2$%~3Bwu63Wd%tK!ACsJqj*|TG&C0i{J=NKC+QQ6 z;|RndKfU1P1x_mH;isecVRIQbc?l<84{!-hD!?rvI!R=CLCVK%xgqVx_4#Nd>J0|( zdAPg2U{rwA?U{xYu^{CWDXdA5%pQpHa1eO-jYFdGoyT7Qq#P0kydLVj1rtnUF+K8H zWx0b6+=0jM#ysAL%06IFd_bbfBQ7@*_K-^Iv88CiXZ`T-mR};s{FDWfQYqb&CKxg` zGF4bFi6lc0ULd{#4~|dyC8M0-Jdl38l37VVa#JVV6L1Rj!{SM%RE{Amz83d2rP7Ej zZNcHLESCpfWO#hAD5c`wL*kyV!z8^NY*Jn*>`7H~h=1_-Q@^AWBD|waq5<>Ul0LOc z`I*(@i$IFUyL29J(D@7aHh^z<&ni!=Fg+8wKQWlVO{|jb$dQ*A4 z#qmC{6~EmHzn@Q0i_|l7Y0_+8Guz|MqWRe3xeYo$NtcOq*^(h{N9{KdZrprcRF*8N z7mMoS7glLhRF8_zF7|KG1xdO>q$`%{(V6{mhY|A5YU!*`X)J=)^5T_NK{??#7Ez z>jQ}sOvhRjy^A`N|rdp5=Y#HUK>Rv4pcI> zcxi(!O43y#UA6S1b=s9_#n9mZ!i?Dr?d}s3Is6T1u7P%%6X57fo0#EFy8-N2U0H^euiNA?@J%oDexs6HMnnYU@ zYVJmTHxX29p2eXJ+M1*pk!F?;uG2>{t^KI&4TKqdhF1Qh8Jz`Kfa-f*SdlEWi-q>( zG1M`G3hk&cuy}rhHl1zxzxE%*JWDA*PLO584Fpo_(gOY1_tVl=jO7c zxkfbCEFVX$myo#znFqIe^L})4lz8)48mo9e>W&boqS;Vw0ivKhSx_q$)Gl8@ZR4n* z78Oh^f}6$xYQV&5ixRg+V+VJv)j@|}}Q9SLLgGgHyK z4evSs)VWM2itE-*^_#O=%4FUQXsLqYhZFBiEL}^O?a!?HAWYV*4I7q{jk103-G6+4 z!&dp;?Bm&u(u()4KfbaV0z!QQCanY^L^*HMR_F!5BfGs zC`-x1dk^nDvy{F&leE>Z+3J@g30w2uS)9MIlr9;TOP0f8bq8XOJ-NN&U+of44WaJK z$Tqxg8G$}TU|y_lLrgoebv(5k+w3RCUPv43Ntm07v5qyXBW`~}ClB|EhkIB1J{$OS zAYr}ypl_qly0ZzWydho^pA#J?P(#;B`)bK*SnRoqUb}{D*PmKOH{aG^Y~Io+*xXE* zo6^`o&q&hMBf5H4+dk|1v`miSTP12Xi1n`h&4@X)~0yh6MM3?Pi*a5 z^?x@1>3qU^Wltj-<84pOV$&(qczPwedgXJMcyQ

_2Pzq$y!Nx7YY4u-x&X2ie=73_ppiod4u!_jw;0z9nAtqJc?d^F6irH~Y!3 z7t+ICPnhch5 z;i+}g^yWFD=WJR}8b;Y=6IR!bCa+tZ8~d5%k`MYHy@4#wcn{nl;FcO5-+XZHmxZNY z9;khE_E|~AQr$}tw>qlI{=BRlEJbpBAI2{1QWkMEVXnu#Yvajlx8RpO zk>hK%$5%>LBFWRk;_2Z;?Z|pDyFL|4PTgLcx{dDK-=wIwHNDz@!^LIow{r1g6gZ>%v#U*zZ- zihso%U7|O26d+mhxc?(VlIdJyI{&&Md17eo#1Morx;lo&J>u0{i4)!gGr3kW`3vUg zx47#g`q53%V*k!S71_3=w5KGcVN%nY4UcO(^3>7_84yggW}8S7aI*N&TJfPcq+sW| zsb$lGr3w`(;@>z;CyVWC#akoo{wMc8@B3o#=Yxsj@paS0rV01Vqs-Qa7vH&v_BF?^ z{AA+eiTEI@=vp_O+%y3DKmNHI4FC7C%B_Jgta$JPuD z&k8O7wV4B~{}g@<@1uXa&o$RbePKLfgkr6{HwTLKgT2*IB(Ih=Ko6DOF{7)9Vz~aJ_U{6YERD9(Z4;_gOz`sg9rS&y~hH@ zH~K1kufvRQ8uNn(b>FmA2P<{ItIUD=mcKLOgLEDKJM-_L0)J=LKtV1y)vZxJA^Krw z3;(g!CkbmV9J@ zQv}fb>N?@j8uS@nQU*;tj-7JQgOa9&$A77g!HzF;@Z`whF(^`QY6}cIdVF*OXI>%L zn~-f9Y`)=4-GFz+c)v_`R07`pGYmfo1&=@dliOcHMd08DdlO^fBg1=zOJ@>BCTVOC zjSUH7<6_^2!Svuw(XekRBpRBNxou)@TQavp%&}XGXK}FJf9F0jRKg3&;s7KP{7?5m2hEQ{EoKRV;p4E$yurN54B`_z zIM$0q`H-Z8Eg4A7VktOi=2Lt=2m~^Hhe^u!c&6a60M4L2JYH}fK70Wz_~0lQm?TpC z5T@h5(~%PaDQ7Hq)$ literal 0 HcmV?d00001 diff --git a/src/discussion_framework/__pycache__/commands.cpython-313.pyc b/src/discussion_framework/__pycache__/commands.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6b5a744625ff05d7f97ae91f0c4988d7cb1fcd76 GIT binary patch literal 33835 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 literal 0 HcmV?d00001 diff --git a/src/discussion_framework/__pycache__/model.cpython-313.pyc b/src/discussion_framework/__pycache__/model.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..633243d0816e215abab48c6cd1cc2fb650d25a37 GIT binary patch literal 6155 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 F{sM!uSWN%` literal 0 HcmV?d00001 diff --git a/src/discussion_framework/__pycache__/validation.cpython-313.pyc b/src/discussion_framework/__pycache__/validation.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4a46e13b5806a7bc7f477734be8c7611a1da9d68 GIT binary patch literal 4897 zcma)9U2Gf25#A$@KjObcN)#naIji~!ok)}|%SmOYs1w#00~A4!H>Y+Rv_OH*9(g3K zScw-PZ+3TfcD|X}+1dMEuM0u@@ZbNFR4+pRARV(=DgJ2abQvw#3kL!V^x~t(`srFr0O0yw;*CE4Lz); z$sT4Sma!s4-9PLg_><;1N>nXV8vvpQacH!tD8pw&s!Wo=BXW>zKudMjAX5KZj7V)08*c&$&h+(nw=ava3RYJR92tjzY;NY9lTa zgQ+Xw8Gz|MFxU8NdV=L19XXr}?CoyUau1mvQ-{Zt>1~vmWK1mJFKXg#@R$41`*7}9 zE$(GcgU39{9#e*Cg_N^1 z(ABqhbP126Yx6_8&g_aBUp;C#U!u>nO8v{WutC#Ctd+!)OwXP;3SYhhWYr>Vc4k+c z`AjtC>e(3*WoO#H$GRcT2T|T|UK{M--`fs6WFXG}*dByE*w%-v@^?T&lr!3_oAaAi zkpo;H5jAJ(Uo*Rzd-8DhPEm3APeMMbwAo*CZc~oQCsg?MJn6;>@)0YrZCn6sVKc4B z5cwmO>;=@dDrJeor9!eVXG;v4JJ-Lao{+C!LMR9MQl{nRJ@RiYrXM%|3MBsyf$g1K z^Vk?dM*j%(AAPUT91Xa_#(z844Cre46i!j1io?2)!!V$cTVy^Tym`CucTB&*`Qh;<7HqsQ472J@Xl?XG7o z^9m!$Z}J&QU?gFT(XGIu+f^xjMO5>FF=j!6qClQ!DkHjGf$}1!G|p5#omsOl~W-~j|V zE$Xx&rB&U|FD{C*fZ=>0-KqeO6>O-x>sC`>SjD;vmSI(SOM-I9mBL~=iv@6*ZiQ{J z8&+d`WlKp>b(^x7kyK1d6WuN@!H}Z+V9^^n35!aKAkuA;DlRC|7M;e~EG&_X!pkm_ zf=tn!)gVxGd&M%k12%#U6`fW@QPv%4um)h)ZQ#$GsJrSK>)tv|RHUzfY`w{J3J5nz zs3FZd5BOX}uT(`GZPb1C>Yk)NVZPJ!>oHCdu@aM|jGC3xP=|tPRY}gq7GXFm^O@LN zS$suV@vYJL-MP{zw>8RDMQZz=`NX^R%!Jl4x$T+y+!NYywyYf~bZt9hJCS|s z=L+1di+9d{GFTj(C`Kk%9BZ~!-xvOf0sC^vKd|K=FqONoenxxhsMc|8+w)xchgrS~ z^=qO2mGPR+8;N53@XF*)uxnkq6DkFVwt_?DK-*344R4{V;NQTTy_;>gfom%I9IpTBi}qrcd7aLu(7-d}j?j#3JbY=uY4p^lsJ z8}Y)0!kNuOo5JQ<`E8x{F6=iv8T$sb8G=qApk)d09(b&|bc%^`y2#uaW%^ z+)0UvH9Z;M=-+794xYYy^6s-5drq6ZsGUn`9j|@n z;UVTa4?xU$T9)Jg`KL%B|3QW1u0QvBrd#QUUUwv6!}+1^p9cb7ch$EW+984jg%J>zeTMi_0hf=J4+Ly4Ho;(k*GjUhF1kSaS~F zvm&SOKCq&6O=aki=coZ0UP%CXcE3>wk_Q1w)ZoKZwXqbp>)8SiuSR)QKCaXBwYR%8mI9I!b4Akrcr0b zPbXo-a8}+46@;+@A17nv?O^cZQ$PW`0jQmf8mJ74K&x}=^d-0kqkbqdQgb=bLrkSG z@sI8xS8_(WgbgKa3t2(T7)6IsycmbZD0_^uqf(NPThKTcm0%&(`WP9AE2qeL45Z{T z;!iL+I3&E41MqjxN>S8bQO}pC{ae~WS-wT^_aJnXYI*PuWu*>2@K~wHzdSZ7V95GE DliT@{ literal 0 HcmV?d00001 diff --git a/src/discussion_framework/cli.py b/src/discussion_framework/cli.py new file mode 100644 index 0000000..343a462 --- /dev/null +++ b/src/discussion_framework/cli.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + +from . import commands +from .model import DiscussionError + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="discussion") + parser.add_argument("--root", default=None, help="repository root containing discussion/") + + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("init", help="create discussion/ layout if missing") + + start = subparsers.add_parser("start", help="create a discussion with an initial agenda") + start.add_argument("ticket", help="stable ticket/slug for the discussion") + start.add_argument("title", help="human-readable discussion title") + start.add_argument("--tag", action="append", default=[], help="tag to add; repeatable") + start.add_argument("--domain-owner", default="", help="explicit domain owner for the agenda") + start.add_argument("--pain", default="", help="initial pain/problem statement") + start.add_argument("--context", default="", help="initial context") + start.add_argument("--question", action="append", default=[], help="open question; repeatable") + start.add_argument("--json", action="store_true", help="print machine-readable JSON") + + accept_agenda = subparsers.add_parser("accept-agenda", help="mark an agenda accepted") + accept_agenda.add_argument("discussion_id") + accept_agenda.add_argument("--agenda", help="agenda id; required when more than one agenda is active") + accept_agenda.add_argument("--json", action="store_true", help="print machine-readable JSON") + + add_decision = subparsers.add_parser("add-decision", help="create a decision artifact from an accepted agenda") + add_decision.add_argument("discussion_id") + add_decision.add_argument("--agenda", help="accepted agenda id; required when ambiguous") + add_decision.add_argument("--title", required=True, help="decision title") + add_decision.add_argument("--json", action="store_true", help="print machine-readable JSON") + + accept_decision = subparsers.add_parser("accept-decision", help="mark a decision accepted") + accept_decision.add_argument("discussion_id") + accept_decision.add_argument("--decision", help="decision id; required when more than one decision is active") + accept_decision.add_argument("--json", action="store_true", help="print machine-readable JSON") + + add_plan = subparsers.add_parser("add-plan", help="create a plan artifact from an accepted decision") + add_plan.add_argument("discussion_id") + add_plan.add_argument("--decision", help="accepted decision id; required when ambiguous") + add_plan.add_argument("--title", required=True, help="plan title") + add_plan.add_argument("--json", action="store_true", help="print machine-readable JSON") + + mark_plan = subparsers.add_parser("mark-plan", help="move a plan through the workflow lifecycle") + mark_plan.add_argument("plan_id") + mark_plan.add_argument("status", choices=["open", "review", "accepted", "in_progress", "done", "abandoned"]) + mark_plan.add_argument("--json", action="store_true", help="print machine-readable JSON") + + status = subparsers.add_parser("status", help="print discussion overview") + status.add_argument("--json", action="store_true", help="print machine-readable JSON") + + validate_parser = subparsers.add_parser("validate", help="validate discussion/index.ndjson and referenced files") + validate_parser.add_argument("--json", action="store_true", help="print machine-readable JSON") + + inspect = subparsers.add_parser("inspect", help="show one discussion and its artifacts") + inspect.add_argument("discussion_id") + inspect.add_argument("--json", action="store_true", help="print machine-readable JSON") + + context = subparsers.add_parser("context", help="print compact AI-ready context for one discussion") + context.add_argument("discussion_id") + context.add_argument("--json", action="store_true", help="print machine-readable JSON") + + next_actions = subparsers.add_parser("next-actions", help="print suggested operational next actions") + next_actions.add_argument("--json", action="store_true", help="print machine-readable JSON") + + subparsers.add_parser("backup-index", help="copy discussion/index.ndjson into discussion/.backups") + + housekeep = subparsers.add_parser("housekeep", help="complete a discussion and prune workflow artifacts") + housekeep.add_argument("discussion_id") + group = housekeep.add_mutually_exclusive_group(required=True) + group.add_argument("--lesson", help="existing lesson path to register") + group.add_argument("--no-lesson", action="store_true", help="complete without registering a lesson") + + args = parser.parse_args(argv) + root = _resolve_root(args.root) + + try: + if args.command == "init": + print(commands.init(root)) + elif args.command == "start": + data = commands.start_agenda( + root, + ticket=args.ticket, + title=args.title, + tags=args.tag, + domain_owner=args.domain_owner, + pain=args.pain, + context=args.context, + questions=args.question, + ) + if args.json: + print(commands.to_json(data)) + else: + print(f"started {data['discussion_id']} with {data['agenda_id']}") + print(f"agenda: {data['agenda_file']}") + elif args.command == "accept-agenda": + data = commands.accept_agenda(root, args.discussion_id, args.agenda) + if args.json: + print(commands.to_json(data)) + else: + print(f"accepted agenda {data['agenda_id']} for {data['discussion_id']}") + elif args.command == "add-decision": + data = commands.add_decision(root, args.discussion_id, args.agenda, args.title) + if args.json: + print(commands.to_json(data)) + else: + print(f"created decision {data['decision_id']} for {data['discussion_id']}") + print(f"decision: {data['decision_file']}") + elif args.command == "accept-decision": + data = commands.accept_decision(root, args.discussion_id, args.decision) + if args.json: + print(commands.to_json(data)) + else: + print(f"accepted decision {data['decision_id']} for {data['discussion_id']}") + elif args.command == "add-plan": + data = commands.add_plan(root, args.discussion_id, args.decision, args.title) + if args.json: + print(commands.to_json(data)) + else: + print(f"created plan {data['plan_id']} for {data['discussion_id']}") + print(f"plan: {data['plan_file']}") + elif args.command == "mark-plan": + data = commands.mark_plan(root, args.plan_id, args.status) + if args.json: + print(commands.to_json(data)) + else: + print(f"marked plan {data['plan_id']} {data['status']} for {data['discussion_id']}") + elif args.command == "status": + print(commands.to_json(commands.status_data(root)) if args.json else commands.status(root)) + elif args.command == "validate": + data = commands.validate_data(root) + if args.json: + print(commands.to_json(data)) + return 0 if data["ok"] else 2 + errors = data["errors"] + if errors: + for error in errors: + print(error, file=sys.stderr) + return 2 + print("validation passed") + elif args.command == "inspect": + print(commands.to_json(commands.inspect_data(root, args.discussion_id)) if args.json else commands.inspect(root, args.discussion_id)) + elif args.command == "context": + print(commands.to_json(commands.context_data(root, args.discussion_id)) if args.json else commands.context(root, args.discussion_id)) + elif args.command == "next-actions": + if args.json: + data = commands.status_data(root) + data["next_actions"] = { + item["id"]: commands.next_actions_for(commands.inspect_data(root, item["id"])) + for item in data["discussions"] + } + print(commands.to_json(data)) + else: + print(commands.next_actions(root)) + elif args.command == "backup-index": + print(commands.backup_index(root)) + elif args.command == "housekeep": + print(commands.housekeep(root, args.discussion_id, args.lesson, args.no_lesson)) + else: + parser.error(f"unknown command: {args.command}") + except DiscussionError as exc: + print(str(exc), file=sys.stderr) + return 2 + return 0 + + +def _resolve_root(root_arg: str | None) -> Path: + root = Path(root_arg or ".").resolve() + if (root / "discussion" / "index.ndjson").exists(): + return root + if root.name == "discussion" and (root / "index.ndjson").exists(): + return root.parent + return root diff --git a/src/discussion_framework/commands.py b/src/discussion_framework/commands.py new file mode 100644 index 0000000..7db8840 --- /dev/null +++ b/src/discussion_framework/commands.py @@ -0,0 +1,780 @@ +from __future__ import annotations + +from datetime import date +from pathlib import Path +import json +import re +import shutil + +from .model import ALL_ARTIFACT_KINDS, DiscussionError, DiscussionIndex, WORKFLOW_KINDS, artifact_path +from .validation import validate, validate_or_raise + + +INITIAL_INDEX = '{"type":"meta","next_id":{"DSC":1,"AGD":1,"DEC":1,"PLN":1,"LSN":1,"CLSN":1}}\n' + + +def init(root: Path) -> str: + discussion_dir = root / "discussion" + for relative in ( + "workflow/agendas", + "workflow/decisions", + "workflow/plans", + "lessons", + ".backups", + ): + (discussion_dir / relative).mkdir(parents=True, exist_ok=True) + + index_path = discussion_dir / "index.ndjson" + if index_path.exists(): + return f"already initialized: {index_path}" + index_path.write_text(INITIAL_INDEX, encoding="utf-8") + return f"initialized: {discussion_dir}" + + +def start_agenda( + root: Path, + *, + ticket: str, + title: str, + tags: list[str], + domain_owner: str, + pain: str, + context: str, + questions: list[str], +) -> dict: + if not ticket.strip(): + raise DiscussionError("ticket is required") + if not title.strip(): + raise DiscussionError("title is required") + + index = DiscussionIndex.load(root) + normalized_ticket = _slugify(ticket) + if not normalized_ticket: + raise DiscussionError("ticket must contain at least one alphanumeric character") + for discussion in index.discussions: + if discussion.get("ticket") == normalized_ticket: + raise DiscussionError(f"discussion already exists for ticket: {normalized_ticket}") + + discussion_id = index.next_id("DSC") + agenda_id = index.next_id("AGD") + slug = _slugify(title) or normalized_ticket + agenda_file = f"{agenda_id}-{slug}.md" + today = date.today().isoformat() + normalized_tags = _normalize_tags(tags) + agenda_path = artifact_path(root, "agendas", agenda_file) + agenda_path.parent.mkdir(parents=True, exist_ok=True) + agenda_path.write_text( + _render_agenda( + agenda_id=agenda_id, + ticket=normalized_ticket, + title=title.strip(), + created=today, + tags=normalized_tags, + domain_owner=domain_owner.strip(), + pain=pain.strip(), + context=context.strip(), + questions=questions, + ), + encoding="utf-8", + ) + + discussion = { + "type": "discussion", + "id": discussion_id, + "status": "open", + "ticket": normalized_ticket, + "title": title.strip(), + "created_at": today, + "updated_at": today, + "tags": normalized_tags, + "agendas": [{"id": agenda_id, "file": agenda_file, "status": "open", "created_at": today, "updated_at": today}], + "decisions": [], + "plans": [], + "lessons": [], + } + index.discussions.insert(0, discussion) + index.ensure_next_id_above("DSC", discussion_id) + index.ensure_next_id_above("AGD", agenda_id) + index.save() + validate_or_raise(root) + return { + "discussion_id": discussion_id, + "agenda_id": agenda_id, + "ticket": normalized_ticket, + "title": title.strip(), + "agenda_file": str(agenda_path), + } + + +def accept_agenda(root: Path, discussion_id: str, agenda_id: str | None) -> dict: + index = DiscussionIndex.load(root) + discussion = index.find_discussion(discussion_id) + agenda = _select_artifact(discussion, "agendas", agenda_id) + if agenda.get("status") not in {"open", "review"}: + raise DiscussionError(f"agenda cannot be accepted from status: {agenda.get('status')}") + today = date.today().isoformat() + _set_artifact_status(root, "agendas", agenda, "accepted") + agenda["status"] = "accepted" + agenda["updated_at"] = today + discussion["updated_at"] = today + index.save() + validate_or_raise(root) + return {"discussion_id": discussion_id, "agenda_id": agenda["id"], "status": "accepted"} + + +def add_decision(root: Path, discussion_id: str, agenda_id: str | None, title: str) -> dict: + if not title.strip(): + raise DiscussionError("title is required") + index = DiscussionIndex.load(root) + discussion = index.find_discussion(discussion_id) + agenda = _select_artifact(discussion, "agendas", agenda_id) + if agenda.get("status") != "accepted": + raise DiscussionError(f"agenda must be accepted before adding a decision: {agenda.get('id')}") + decision_id = index.next_id("DEC") + slug = _slugify(title) or _slugify(discussion.get("ticket", "")) or decision_id.lower() + decision_file = f"{decision_id}-{slug}.md" + today = date.today().isoformat() + decision_path = artifact_path(root, "decisions", decision_file) + decision_path.parent.mkdir(parents=True, exist_ok=True) + decision_path.write_text( + _render_decision( + decision_id=decision_id, + ticket=discussion.get("ticket", ""), + title=title.strip(), + created=today, + tags=discussion.get("tags", []), + agenda_id=agenda["id"], + ), + encoding="utf-8", + ) + entry = { + "id": decision_id, + "file": decision_file, + "status": "open", + "created_at": today, + "updated_at": today, + "ref_agenda": agenda["id"], + } + discussion.setdefault("decisions", []).append(entry) + discussion["status"] = "in_progress" + discussion["updated_at"] = today + index.ensure_next_id_above("DEC", decision_id) + index.save() + validate_or_raise(root) + return {"discussion_id": discussion_id, "decision_id": decision_id, "decision_file": str(decision_path)} + + +def accept_decision(root: Path, discussion_id: str, decision_id: str | None) -> dict: + index = DiscussionIndex.load(root) + discussion = index.find_discussion(discussion_id) + decision = _select_artifact(discussion, "decisions", decision_id) + if decision.get("status") not in {"open", "review"}: + raise DiscussionError(f"decision cannot be accepted from status: {decision.get('status')}") + today = date.today().isoformat() + _set_artifact_status(root, "decisions", decision, "accepted") + decision["status"] = "accepted" + decision["updated_at"] = today + discussion["updated_at"] = today + index.save() + validate_or_raise(root) + return {"discussion_id": discussion_id, "decision_id": decision["id"], "status": "accepted"} + + +def add_plan(root: Path, discussion_id: str, decision_id: str | None, title: str) -> dict: + if not title.strip(): + raise DiscussionError("title is required") + index = DiscussionIndex.load(root) + discussion = index.find_discussion(discussion_id) + decision = _select_artifact(discussion, "decisions", decision_id) + if decision.get("status") != "accepted": + raise DiscussionError(f"decision must be accepted before adding a plan: {decision.get('id')}") + plan_id = index.next_id("PLN") + slug = _slugify(title) or _slugify(discussion.get("ticket", "")) or plan_id.lower() + plan_file = f"{plan_id}-{slug}.md" + today = date.today().isoformat() + plan_path = artifact_path(root, "plans", plan_file) + plan_path.parent.mkdir(parents=True, exist_ok=True) + plan_path.write_text( + _render_plan( + plan_id=plan_id, + ticket=discussion.get("ticket", ""), + title=title.strip(), + created=today, + tags=discussion.get("tags", []), + decision_id=decision["id"], + ), + encoding="utf-8", + ) + entry = { + "id": plan_id, + "file": plan_file, + "status": "open", + "created_at": today, + "updated_at": today, + "ref_decisions": [decision["id"]], + } + discussion.setdefault("plans", []).append(entry) + discussion["status"] = "in_progress" + discussion["updated_at"] = today + index.ensure_next_id_above("PLN", plan_id) + index.save() + validate_or_raise(root) + return {"discussion_id": discussion_id, "plan_id": plan_id, "plan_file": str(plan_path)} + + +def mark_plan(root: Path, plan_id: str, status: str) -> dict: + if status not in {"open", "review", "accepted", "in_progress", "done", "abandoned"}: + raise DiscussionError(f"unsupported plan status: {status}") + index = DiscussionIndex.load(root) + discussion, plan = _find_artifact(index, "plans", plan_id) + current = plan.get("status") + if not _can_transition(current, status): + raise DiscussionError(f"invalid plan transition: {current} -> {status}") + today = date.today().isoformat() + _set_artifact_status(root, "plans", plan, status) + plan["status"] = status + plan["updated_at"] = today + discussion["updated_at"] = today + if status == "in_progress": + discussion["status"] = "in_progress" + index.save() + validate_or_raise(root) + return {"discussion_id": discussion["id"], "plan_id": plan_id, "status": status} + + +def status(root: Path) -> str: + data = status_data(root) + lines = [f"discussions: {data['total']}"] + if data["statuses"]: + lines.append("statuses: " + ", ".join(f"{name}={data['statuses'][name]}" for name in sorted(data["statuses"]))) + lines.append("") + + for discussion in data["discussions"]: + parts = [ + discussion.get("id", "?"), + discussion.get("status", "?"), + discussion.get("ticket", "?"), + ] + artifact_counts = [] + for kind in ("agendas", "decisions", "plans", "lessons"): + count = discussion["artifact_counts"].get(kind, 0) + if count: + artifact_counts.append(f"{kind}={count}") + suffix = f" ({', '.join(artifact_counts)})" if artifact_counts else "" + lines.append(" " + " ".join(parts) + suffix) + return "\n".join(lines) + + +def status_data(root: Path) -> dict: + index = DiscussionIndex.load(root) + counts: dict[str, int] = {} + for discussion in index.discussions: + counts[discussion.get("status", "unknown")] = counts.get(discussion.get("status", "unknown"), 0) + 1 + + discussions = [] + for discussion in index.discussions: + discussions.append( + { + "id": discussion.get("id"), + "status": discussion.get("status"), + "ticket": discussion.get("ticket"), + "title": discussion.get("title"), + "tags": discussion.get("tags", []), + "artifact_counts": {kind: len(discussion.get(kind, [])) for kind in ALL_ARTIFACT_KINDS}, + } + ) + return {"total": len(index.discussions), "statuses": counts, "discussions": discussions} + + +def inspect(root: Path, discussion_id: str) -> str: + data = inspect_data(root, discussion_id) + lines = [ + f"{data['id']} {data['status']} {data['ticket']}", + data.get("title") or "", + "tags: " + ", ".join(data.get("tags", [])), + "", + ] + for kind in ALL_ARTIFACT_KINDS: + artifacts = data["artifacts"][kind] + if not artifacts: + continue + lines.append(f"{kind}:") + for artifact in artifacts: + marker = "exists" if artifact["exists"] else "missing" + lines.append(f" - {artifact['id']} {artifact.get('status', '?')} {marker} {artifact['path']}") + lines.append("") + actions = next_actions_for(data) + if actions: + lines.append("suggested next actions:") + for action in actions: + lines.append(f" - {action}") + return "\n".join(line for line in lines if line is not None).rstrip() + + +def inspect_data(root: Path, discussion_id: str) -> dict: + index = DiscussionIndex.load(root) + discussion = index.find_discussion(discussion_id) + artifacts: dict[str, list[dict]] = {} + for kind in ALL_ARTIFACT_KINDS: + items = [] + for artifact in discussion.get(kind, []): + file_value = artifact.get("file", "") + path = artifact_path(root, kind, file_value) if file_value else root + items.append( + { + "id": artifact.get("id"), + "status": artifact.get("status"), + "file": file_value, + "path": str(path), + "exists": path.exists() if file_value else False, + "ref_agenda": artifact.get("ref_agenda"), + "ref_decisions": artifact.get("ref_decisions", []), + } + ) + artifacts[kind] = items + return { + "id": discussion.get("id"), + "status": discussion.get("status"), + "ticket": discussion.get("ticket"), + "title": discussion.get("title"), + "created_at": discussion.get("created_at"), + "updated_at": discussion.get("updated_at"), + "tags": discussion.get("tags", []), + "artifacts": artifacts, + } + + +def context(root: Path, discussion_id: str) -> str: + data = context_data(root, discussion_id) + actions = next_actions_for(data) + lines = [ + f"Discussion: {data['id']}", + f"Status: {data['status']}", + f"Ticket: {data['ticket']}", + f"Title: {data['title']}", + f"Tags: {', '.join(data.get('tags', []))}", + "", + "Active artifacts:", + ] + any_artifact = False + for kind in ALL_ARTIFACT_KINDS: + for artifact in data["artifacts"][kind]: + any_artifact = True + exists = "present" if artifact["exists"] else "missing" + lines.append(f"- {artifact['id']} ({kind}, {artifact.get('status')}, {exists}): {artifact['file']}") + if not any_artifact: + lines.append("- none") + lines.extend(["", "Suggested next actions:"]) + if actions: + lines.extend(f"- {action}" for action in actions) + else: + lines.append("- none") + return "\n".join(lines) + + +def context_data(root: Path, discussion_id: str) -> dict: + data = inspect_data(root, discussion_id) + data["next_actions"] = next_actions_for(data) + return data + + +def next_actions(root: Path) -> str: + index = DiscussionIndex.load(root) + lines = [] + for discussion in index.discussions: + data = inspect_data(root, discussion["id"]) + actions = next_actions_for(data) + if not actions: + continue + lines.append(f"{discussion['id']} {discussion.get('status')} {discussion.get('ticket')}") + for action in actions: + lines.append(f" - {action}") + return "\n".join(lines) if lines else "no suggested next actions" + + +def next_actions_for(data: dict) -> list[str]: + status = data.get("status") + artifacts = data["artifacts"] + actions: list[str] = [] + if status == "open" and artifacts["agendas"]: + agenda_statuses = {artifact.get("status") for artifact in artifacts["agendas"]} + if "open" in agenda_statuses or "review" in agenda_statuses: + actions.append(f"run: discussion accept-agenda {data['id']} when the agenda is ready") + if "accepted" in agenda_statuses and not artifacts["decisions"]: + actions.append(f"run: discussion add-decision {data['id']} --title ") + if status in {"open", "in_progress"} and artifacts["decisions"] and not artifacts["plans"]: + decision_statuses = {artifact.get("status") for artifact in artifacts["decisions"]} + if "open" in decision_statuses or "review" in decision_statuses: + actions.append(f"run: discussion accept-decision {data['id']} when the decision is accepted") + if "accepted" in decision_statuses: + actions.append(f"run: discussion add-plan {data['id']} --title <title>") + if status in {"open", "in_progress"} and artifacts["plans"]: + plan_statuses = {artifact.get("status") for artifact in artifacts["plans"]} + if plan_statuses <= {"done"}: + actions.append("write a lesson if durable knowledge exists") + actions.append(f"run: discussion housekeep {data['id']} --lesson <lesson-path> or --no-lesson") + else: + actions.append("execute or close active plans before housekeeping") + if status == "done" and any(artifacts[kind] for kind in WORKFLOW_KINDS): + actions.append(f"run: discussion housekeep {data['id']} --no-lesson") + return actions + + +def housekeep(root: Path, discussion_id: str, lesson: str | None, no_lesson: bool) -> str: + if bool(lesson) == bool(no_lesson): + raise DiscussionError("choose exactly one of --lesson or --no-lesson") + + index = DiscussionIndex.load(root) + discussion = index.find_discussion(discussion_id) + + lesson_entry = None + if lesson: + lesson_path = Path(lesson) + if not lesson_path.is_absolute(): + lesson_path = root / lesson_path + lesson_path = lesson_path.resolve() + if not lesson_path.exists(): + raise DiscussionError(f"lesson file not found: {lesson_path}") + lesson_id = _read_frontmatter_field(lesson_path, "id") + if not lesson_id: + lesson_id = lesson_path.name.split("-", 2)[0] + "-" + lesson_path.name.split("-", 2)[1] + relative = lesson_path.relative_to(root.resolve()).as_posix() + today = date.today().isoformat() + lesson_entry = { + "id": lesson_id, + "file": relative, + "status": "done", + "created_at": today, + "updated_at": today, + } + index.ensure_next_id_above("LSN", lesson_id) + + removed: list[Path] = [] + for kind in WORKFLOW_KINDS: + for artifact in discussion.get(kind, []): + file_value = artifact.get("file") + if not file_value: + continue + path = artifact_path(root, kind, file_value) + if path.exists(): + path.unlink() + removed.append(path) + discussion[kind] = [] + + if lesson_entry: + lessons = discussion.setdefault("lessons", []) + lessons[:] = [existing for existing in lessons if existing.get("id") != lesson_entry["id"]] + lessons.append(lesson_entry) + + discussion["status"] = "done" + discussion["updated_at"] = date.today().isoformat() + index.save() + validate_or_raise(root) + + lines = [f"housekept {discussion_id}", f"removed workflow artifacts: {len(removed)}"] + if lesson_entry: + lines.append(f"registered lesson: {lesson_entry['id']}") + return "\n".join(lines) + + +def backup_index(root: Path) -> str: + index = DiscussionIndex.load(root) + backup_dir = index.discussion_dir / ".backups" + backup_dir.mkdir(parents=True, exist_ok=True) + timestamp = date.today().isoformat() + backup_path = backup_dir / f"index.ndjson.{timestamp}.bak" + suffix = 1 + while backup_path.exists(): + backup_path = backup_dir / f"index.ndjson.{timestamp}.{suffix}.bak" + suffix += 1 + shutil.copy2(index.path, backup_path) + return f"backup created: {backup_path}" + + +def validate_data(root: Path) -> dict: + errors = validate(root) + return {"ok": not errors, "errors": errors} + + +def to_json(data: dict) -> str: + return json.dumps(data, indent=2, ensure_ascii=False) + + +def _normalize_tags(tags: list[str]) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + for value in tags: + for part in value.split(","): + tag = _slugify(part) + if tag and tag not in seen: + normalized.append(tag) + seen.add(tag) + return normalized + + +def _slugify(value: str) -> str: + slug = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-") + return re.sub(r"-+", "-", slug) + + +def _render_agenda( + *, + agenda_id: str, + ticket: str, + title: str, + created: str, + tags: list[str], + domain_owner: str, + pain: str, + context: str, + questions: list[str], +) -> str: + tag_text = "[" + ", ".join(tags) + "]" + domain_line = f"Domain owner: `{domain_owner}`\n\n" if domain_owner else "" + pain_text = pain or "TBD." + context_text = context or "TBD." + question_lines = [f"- [ ] {question.strip()}" for question in questions if question.strip()] + if not question_lines: + question_lines = ["- [ ] TBD."] + return f"""--- +id: {agenda_id} +ticket: {ticket} +title: {title} +status: open +created: {created} +resolved: +decision: +tags: {tag_text} +--- + +## Pain + +{domain_line}{pain_text} + +## Context + +{context_text} + +## Open Questions + +{chr(10).join(question_lines)} + +## Options + +### Option A - TBD + +- **Approach:** TBD. +- **Pro:** TBD. +- **Con:** TBD. +- **Maintainability:** TBD. + +### Option B - TBD + +- **Approach:** TBD. +- **Pro:** TBD. +- **Con:** TBD. +- **Maintainability:** TBD. + +## Tradeoffs + +TBD. + +## Recommendation + +TBD. + +## Discussion + +TBD. + +## Resolution + +Ainda em aberto. + +## Next Step + +Preencher a agenda com contexto suficiente para decidir se o tema esta pronto para uma decision. +""" + + +def _render_decision( + *, + decision_id: str, + ticket: str, + title: str, + created: str, + tags: list[str], + agenda_id: str, +) -> str: + tag_text = "[" + ", ".join(tags) + "]" + return f"""--- +id: {decision_id} +ticket: {ticket} +title: {title} +status: open +created: {created} +ref_agenda: {agenda_id} +tags: {tag_text} +--- + +## Context + +TBD. + +## Decision + +TBD. + +## Rationale + +TBD. + +## Implications + +TBD. + +## Propagation Targets + +- specs: TBD. +- plans: TBD. +- code: TBD. +- tests: TBD. +- docs: TBD. + +## References + +- Agenda: {agenda_id} +""" + + +def _render_plan( + *, + plan_id: str, + ticket: str, + title: str, + created: str, + tags: list[str], + decision_id: str, +) -> str: + tag_text = "[" + ", ".join(tags) + "]" + return f"""--- +id: {plan_id} +ticket: {ticket} +title: {title} +status: open +created: {created} +ref_decisions: [{decision_id}] +tags: {tag_text} +--- + +## Briefing + +TBD. + +## Objective + +TBD. + +## Dependencies + +TBD. + +## Scope + +TBD. + +## Non-Goals + +TBD. + +## Execution Method + +TBD. + +## Acceptance Criteria + +TBD. + +## Tests + +TBD. + +## Affected Artifacts + +TBD. +""" + + +def _select_artifact(discussion: dict, kind: str, artifact_id: str | None) -> dict: + artifacts = discussion.get(kind, []) + if not artifacts: + raise DiscussionError(f"{discussion.get('id')}: no {kind}") + if artifact_id: + for artifact in artifacts: + if artifact.get("id") == artifact_id: + return artifact + raise DiscussionError(f"{discussion.get('id')}: {kind} artifact not found: {artifact_id}") + active = [artifact for artifact in artifacts if artifact.get("status") not in {"done", "abandoned"}] + if len(active) == 1: + return active[0] + if len(artifacts) == 1: + return artifacts[0] + raise DiscussionError(f"{discussion.get('id')}: choose one {kind} artifact explicitly") + + +def _find_artifact(index: DiscussionIndex, kind: str, artifact_id: str) -> tuple[dict, dict]: + for discussion in index.discussions: + for artifact in discussion.get(kind, []): + if artifact.get("id") == artifact_id: + return discussion, artifact + raise DiscussionError(f"{kind} artifact not found: {artifact_id}") + + +def _set_artifact_status(root: Path, kind: str, artifact: dict, status: str) -> None: + file_value = artifact.get("file") + if not file_value: + return + path = artifact_path(root, kind, file_value) + if not path.exists(): + return + lines = path.read_text(encoding="utf-8").splitlines() + if not lines or lines[0].strip() != "---": + return + updated = False + for index, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + break + key, sep, _value = line.partition(":") + if sep and key.strip() == "status": + lines[index] = f"status: {status}" + updated = True + break + if updated: + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _can_transition(current: str | None, target: str) -> bool: + if current == target: + return True + allowed = { + "open": {"review", "accepted", "in_progress", "abandoned"}, + "review": {"accepted", "open", "abandoned"}, + "accepted": {"in_progress", "done", "abandoned"}, + "in_progress": {"review", "done", "abandoned"}, + "done": set(), + "abandoned": set(), + } + return target in allowed.get(current or "", set()) + + +def _read_frontmatter_field(path: Path, field: str) -> str | None: + lines = path.read_text(encoding="utf-8").splitlines() + if not lines or lines[0].strip() != "---": + return None + for line in lines[1:]: + if line.strip() == "---": + return None + key, sep, value = line.partition(":") + if sep and key.strip() == field: + return value.strip() + return None diff --git a/src/discussion_framework/model.py b/src/discussion_framework/model.py new file mode 100644 index 0000000..2349637 --- /dev/null +++ b/src/discussion_framework/model.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import json + + +COUNTERS = ("DSC", "AGD", "DEC", "PLN", "LSN", "CLSN") +WORKFLOW_KINDS = ("agendas", "decisions", "plans") +ALL_ARTIFACT_KINDS = (*WORKFLOW_KINDS, "lessons") + + +class DiscussionError(RuntimeError): + pass + + +@dataclass +class DiscussionIndex: + root: Path + meta: dict + discussions: list[dict] + + @property + def discussion_dir(self) -> Path: + return self.root / "discussion" + + @property + def path(self) -> Path: + return self.discussion_dir / "index.ndjson" + + @classmethod + def load(cls, root: Path) -> "DiscussionIndex": + path = root / "discussion" / "index.ndjson" + if not path.exists(): + raise DiscussionError(f"index not found: {path}") + + records = [] + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + if not line.strip(): + continue + try: + records.append(json.loads(line)) + except json.JSONDecodeError as exc: + raise DiscussionError(f"{path}:{line_number}: invalid JSON: {exc}") from exc + + if not records or records[0].get("type") != "meta": + raise DiscussionError("index first record must be type=meta") + + for record in records[1:]: + if record.get("type") != "discussion": + raise DiscussionError(f"index contains non-discussion record after meta: {record!r}") + + return cls(root=root, meta=records[0], discussions=records[1:]) + + def save(self) -> None: + records = [self.meta, *self.discussions] + text = "\n".join(json.dumps(record, separators=(",", ":"), ensure_ascii=False) for record in records) + self.path.write_text(text + "\n", encoding="utf-8") + + def find_discussion(self, discussion_id: str) -> dict: + for discussion in self.discussions: + if discussion.get("id") == discussion_id: + return discussion + raise DiscussionError(f"discussion not found: {discussion_id}") + + def next_id(self, prefix: str) -> str: + try: + value = self.meta["next_id"][prefix] + except KeyError as exc: + raise DiscussionError(f"missing next_id.{prefix}") from exc + return f"{prefix}-{value:04d}" + + def ensure_next_id_above(self, prefix: str, artifact_id: str) -> None: + number = numeric_part(artifact_id) + next_id = self.meta.setdefault("next_id", {}).setdefault(prefix, 1) + if next_id <= number: + self.meta["next_id"][prefix] = number + 1 + + +def numeric_part(identifier: str) -> int: + digits = "".join(ch for ch in identifier if ch.isdigit()) + return int(digits) if digits else 0 + + +def artifact_path(root: Path, kind: str, file_value: str) -> Path: + if kind == "agendas": + return root / "discussion" / "workflow" / "agendas" / file_value + if kind == "decisions": + return root / "discussion" / "workflow" / "decisions" / file_value + if kind == "plans": + return root / "discussion" / "workflow" / "plans" / file_value + if kind == "lessons": + return root / file_value + raise DiscussionError(f"unknown artifact kind: {kind}") diff --git a/src/discussion_framework/validation.py b/src/discussion_framework/validation.py new file mode 100644 index 0000000..6f244b7 --- /dev/null +++ b/src/discussion_framework/validation.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from pathlib import Path + +from .model import ( + ALL_ARTIFACT_KINDS, + COUNTERS, + DiscussionError, + DiscussionIndex, + artifact_path, + numeric_part, +) + + +def validate(root: Path) -> list[str]: + index = DiscussionIndex.load(root) + errors: list[str] = [] + + next_ids = index.meta.get("next_id") + if not isinstance(next_ids, dict): + errors.append("meta.next_id must be an object") + next_ids = {} + + max_ids = {prefix: 0 for prefix in COUNTERS} + seen: set[str] = set() + + for counter in COUNTERS: + value = next_ids.get(counter) + if not isinstance(value, int): + errors.append(f"meta.next_id.{counter} must be an integer") + + for discussion in index.discussions: + discussion_id = discussion.get("id") + if not isinstance(discussion_id, str) or not discussion_id.startswith("DSC-"): + errors.append(f"discussion has invalid id: {discussion_id!r}") + continue + _record_id(errors, seen, discussion_id) + max_ids["DSC"] = max(max_ids["DSC"], numeric_part(discussion_id)) + + if not discussion.get("ticket"): + errors.append(f"{discussion_id}: missing ticket") + if not discussion.get("status"): + errors.append(f"{discussion_id}: missing status") + + for kind in ALL_ARTIFACT_KINDS: + artifacts = discussion.get(kind, []) + if not isinstance(artifacts, list): + errors.append(f"{discussion_id}: {kind} must be an array") + continue + for artifact in artifacts: + artifact_id = artifact.get("id") + file_value = artifact.get("file") + if not isinstance(artifact_id, str): + errors.append(f"{discussion_id}: {kind} artifact missing id") + continue + _record_id(errors, seen, artifact_id) + prefix = artifact_id.split("-", 1)[0] + if prefix in max_ids: + max_ids[prefix] = max(max_ids[prefix], numeric_part(artifact_id)) + else: + errors.append(f"{discussion_id}: unknown artifact id prefix: {artifact_id}") + if not isinstance(file_value, str) or not file_value: + errors.append(f"{discussion_id}: {artifact_id} missing file") + continue + path = artifact_path(root, kind, file_value) + if not path.exists() and _requires_file(discussion, artifact, kind): + errors.append(f"{discussion_id}: {artifact_id} file not found: {path}") + + for prefix, max_id in max_ids.items(): + value = next_ids.get(prefix) + if isinstance(value, int) and value <= max_id: + errors.append(f"meta.next_id.{prefix} must be greater than max {prefix} id {max_id}") + + return errors + + +def validate_or_raise(root: Path) -> None: + errors = validate(root) + if errors: + raise DiscussionError("\n".join(errors)) + + +def _record_id(errors: list[str], seen: set[str], identifier: str) -> None: + if identifier in seen: + errors.append(f"duplicate id: {identifier}") + seen.add(identifier) + + +def _requires_file(discussion: dict, artifact: dict, kind: str) -> bool: + if kind == "lessons": + return True + if discussion.get("status") == "abandoned": + return artifact.get("status") != "abandoned" + return True diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a797378 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +import contextlib +import io +import json +from pathlib import Path +import unittest + +from discussion_framework.cli import main + + +class CliTest(unittest.TestCase): + def test_init_validate_and_status(self) -> None: + with TempDir() as root: + self.assertEqual(main(["--root", str(root), "init"]), 0) + self.assertTrue((root / "discussion" / "index.ndjson").exists()) + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + self.assertEqual(main(["--root", str(root), "validate"]), 0) + self.assertIn("validation passed", stdout.getvalue()) + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + self.assertEqual(main(["--root", str(root), "status"]), 0) + self.assertIn("discussions: 0", stdout.getvalue()) + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + self.assertEqual(main(["--root", str(root), "status", "--json"]), 0) + self.assertEqual(json.loads(stdout.getvalue())["total"], 0) + + def test_start_creates_discussion_and_agenda(self) -> None: + with TempDir() as root: + main(["--root", str(root), "init"]) + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + self.assertEqual( + main( + [ + "--root", + str(root), + "start", + "pbs-autocomplete-parameter-names", + "PBS autocomplete parameter names", + "--tag", + "compiler,pbs", + "--tag", + "lsp", + "--domain-owner", + "compiler/pbs", + "--pain", + "Completions show arg0 and arg1.", + "--context", + "Stdlib methods lack authored parameter names.", + "--question", + "Where should canonical parameter names live?", + ] + ), + 0, + ) + + discussion = root / "discussion" + records = [json.loads(line) for line in (discussion / "index.ndjson").read_text(encoding="utf-8").splitlines()] + meta = records[0] + item = records[1] + agenda_file = discussion / "workflow" / "agendas" / "AGD-0001-pbs-autocomplete-parameter-names.md" + + self.assertIn("started DSC-0001 with AGD-0001", stdout.getvalue()) + self.assertEqual(meta["next_id"]["DSC"], 2) + self.assertEqual(meta["next_id"]["AGD"], 2) + self.assertEqual(item["ticket"], "pbs-autocomplete-parameter-names") + self.assertEqual(item["tags"], ["compiler", "pbs", "lsp"]) + self.assertEqual(item["agendas"][0]["file"], agenda_file.name) + self.assertTrue(agenda_file.exists()) + agenda_text = agenda_file.read_text(encoding="utf-8") + self.assertIn("Domain owner: `compiler/pbs`", agenda_text) + self.assertIn("Completions show arg0 and arg1.", agenda_text) + + def test_root_auto_detects_existing_discussion_directory(self) -> None: + with TempDir() as root: + main(["--root", str(root), "init"]) + cwd = Path.cwd() + try: + import os + + os.chdir(root / "discussion") + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + self.assertEqual(main(["init"]), 0) + self.assertIn("already initialized", stdout.getvalue()) + finally: + os.chdir(cwd) + + self.assertFalse((root / "discussion" / "discussion").exists()) + + def test_semantic_decision_and_plan_commands(self) -> None: + with TempDir() as root: + main(["--root", str(root), "init"]) + self.assertEqual( + main( + [ + "--root", + str(root), + "start", + "demo-flow", + "Demo Flow", + "--tag", + "compiler", + ] + ), + 0, + ) + self.assertEqual(main(["--root", str(root), "accept-agenda", "DSC-0001"]), 0) + self.assertEqual( + main(["--root", str(root), "add-decision", "DSC-0001", "--title", "Demo Decision"]), + 0, + ) + self.assertEqual(main(["--root", str(root), "accept-decision", "DSC-0001"]), 0) + self.assertEqual( + main(["--root", str(root), "add-plan", "DSC-0001", "--title", "Demo Plan"]), + 0, + ) + self.assertEqual(main(["--root", str(root), "mark-plan", "PLN-0001", "in_progress"]), 0) + self.assertEqual(main(["--root", str(root), "mark-plan", "PLN-0001", "done"]), 0) + + discussion = root / "discussion" + records = [json.loads(line) for line in (discussion / "index.ndjson").read_text(encoding="utf-8").splitlines()] + meta = records[0] + item = records[1] + decision = discussion / "workflow" / "decisions" / "DEC-0001-demo-decision.md" + plan = discussion / "workflow" / "plans" / "PLN-0001-demo-plan.md" + + self.assertEqual(meta["next_id"]["DEC"], 2) + self.assertEqual(meta["next_id"]["PLN"], 2) + self.assertEqual(item["status"], "in_progress") + self.assertEqual(item["agendas"][0]["status"], "accepted") + self.assertEqual(item["decisions"][0]["status"], "accepted") + self.assertEqual(item["decisions"][0]["ref_agenda"], "AGD-0001") + self.assertEqual(item["plans"][0]["status"], "done") + self.assertEqual(item["plans"][0]["ref_decisions"], ["DEC-0001"]) + self.assertTrue(decision.exists()) + self.assertTrue(plan.exists()) + self.assertIn("status: accepted", decision.read_text(encoding="utf-8")) + self.assertIn("status: done", plan.read_text(encoding="utf-8")) + + def test_housekeep_with_lesson_removes_workflow_artifacts(self) -> None: + with TempDir() as root: + main(["--root", str(root), "init"]) + discussion = root / "discussion" + (discussion / "workflow" / "agendas" / "AGD-0001-demo.md").write_text("agenda", encoding="utf-8") + (discussion / "workflow" / "decisions" / "DEC-0001-demo.md").write_text("decision", encoding="utf-8") + (discussion / "workflow" / "plans" / "PLN-0001-demo.md").write_text("plan", encoding="utf-8") + lesson = discussion / "lessons" / "DSC-0001-demo" + lesson.mkdir() + lesson_file = lesson / "LSN-0001-demo.md" + lesson_file.write_text("---\nid: LSN-0001\n---\n# Demo\n", encoding="utf-8") + + records = [ + {"type": "meta", "next_id": {"DSC": 2, "AGD": 2, "DEC": 2, "PLN": 2, "LSN": 1, "CLSN": 1}}, + { + "type": "discussion", + "id": "DSC-0001", + "status": "in_progress", + "ticket": "demo", + "title": "Demo", + "created_at": "2026-05-08", + "updated_at": "2026-05-08", + "tags": [], + "agendas": [{"id": "AGD-0001", "file": "AGD-0001-demo.md", "status": "accepted"}], + "decisions": [{"id": "DEC-0001", "file": "DEC-0001-demo.md", "status": "accepted"}], + "plans": [{"id": "PLN-0001", "file": "PLN-0001-demo.md", "status": "done"}], + "lessons": [], + }, + ] + (discussion / "index.ndjson").write_text( + "\n".join(json.dumps(record, separators=(",", ":")) for record in records) + "\n", + encoding="utf-8", + ) + + self.assertEqual( + main(["--root", str(root), "housekeep", "DSC-0001", "--lesson", str(lesson_file)]), + 0, + ) + + updated = (discussion / "index.ndjson").read_text(encoding="utf-8").splitlines() + meta = json.loads(updated[0]) + item = json.loads(updated[1]) + self.assertEqual(meta["next_id"]["LSN"], 2) + self.assertEqual(item["status"], "done") + self.assertEqual(item["agendas"], []) + self.assertEqual(item["decisions"], []) + self.assertEqual(item["plans"], []) + self.assertEqual(item["lessons"][0]["id"], "LSN-0001") + self.assertFalse((discussion / "workflow" / "agendas" / "AGD-0001-demo.md").exists()) + + def test_inspect_context_next_actions_and_backup(self) -> None: + with TempDir() as root: + main(["--root", str(root), "init"]) + discussion = root / "discussion" + agenda = discussion / "workflow" / "agendas" / "AGD-0001-demo.md" + agenda.write_text("agenda", encoding="utf-8") + records = [ + {"type": "meta", "next_id": {"DSC": 2, "AGD": 2, "DEC": 1, "PLN": 1, "LSN": 1, "CLSN": 1}}, + { + "type": "discussion", + "id": "DSC-0001", + "status": "open", + "ticket": "demo", + "title": "Demo", + "created_at": "2026-05-08", + "updated_at": "2026-05-08", + "tags": ["studio"], + "agendas": [{"id": "AGD-0001", "file": "AGD-0001-demo.md", "status": "open"}], + "decisions": [], + "plans": [], + "lessons": [], + }, + ] + (discussion / "index.ndjson").write_text( + "\n".join(json.dumps(record, separators=(",", ":")) for record in records) + "\n", + encoding="utf-8", + ) + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + self.assertEqual(main(["--root", str(root), "inspect", "DSC-0001", "--json"]), 0) + self.assertEqual(json.loads(stdout.getvalue())["artifacts"]["agendas"][0]["id"], "AGD-0001") + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + self.assertEqual(main(["--root", str(root), "context", "DSC-0001"]), 0) + self.assertIn("Suggested next actions", stdout.getvalue()) + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + self.assertEqual(main(["--root", str(root), "next-actions"]), 0) + self.assertIn("accept-agenda", stdout.getvalue()) + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + self.assertEqual(main(["--root", str(root), "backup-index"]), 0) + self.assertTrue(any((discussion / ".backups").iterdir())) + + +class TempDir: + def __enter__(self) -> Path: + import tempfile + + self._tmp = tempfile.TemporaryDirectory() + return Path(self._tmp.name) + + def __exit__(self, exc_type, exc, tb) -> None: + self._tmp.cleanup() + + +if __name__ == "__main__": + unittest.main()