Compare commits
1378 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96b96916f8 | |||
| 15e3471400 | |||
| 386868fc97 | |||
| 9c9e5b3d0a | |||
| 2a2152d13d | |||
| 5927b89863 | |||
| 1744943636 | |||
| 89cc1f634d | |||
| f8b7f9dded | |||
| c1344fe5f1 | |||
| 4ab084859d | |||
| c7f0faf4ba | |||
| e7185f62da | |||
| ac69301a82 | |||
| 537c3b8454 | |||
| 947547b4d8 | |||
| 29133c0e37 | |||
| 8850b7a9f6 | |||
| a9b62fafa7 | |||
| 05de97d20c | |||
| 8106b089c0 | |||
| 986146d5a1 | |||
| 939b8a9dee | |||
| 8a12e1b50a | |||
| 17ddefd9a2 | |||
| 27470da264 | |||
| f7404f37c0 | |||
| 41e6a6986b | |||
| 63e062d8f8 | |||
| 6bc8d793f8 | |||
| 94ab81fa64 | |||
| 333553d283 | |||
| 4b4e88102e | |||
| aa18df8724 | |||
| 250193a881 | |||
| c890376c5d | |||
| 4050da5b13 | |||
| b5b4769116 | |||
| 943714cb47 | |||
| ac5e679eea | |||
| a3e2cd892f | |||
| 68066a5321 | |||
| ce93a90e89 | |||
| ac8a0c4466 | |||
| 86a4bdc999 | |||
| 4636ced3a4 | |||
| 646b1c90a2 | |||
| 48ecb49a59 | |||
| 396e17f698 | |||
| c4230233bb | |||
| d14c0890fa | |||
| d9f5017ade | |||
| 553a2e68cc | |||
| 0de793bb80 | |||
| 7f8c6d6dc1 | |||
| 880b52ed52 | |||
| b33c3530b0 | |||
| 705ecb8f18 | |||
| cd812f2a3a | |||
| 842d104431 | |||
| c031d3e34c | |||
| 30a7fe25d9 | |||
| 7e6756f99e | |||
| 9621d38ddd | |||
| bffb3a95fc | |||
| c9324271f6 | |||
| 2eb006fa70 | |||
| b803016077 | |||
| ecab9a4be1 | |||
| 029b0fd08a | |||
| 2cd85b7440 | |||
| c3b0ec99cd | |||
| 59c207ef29 | |||
| 0cf8b64a4b | |||
| 69ab3f9770 | |||
| 3f08b6949e | |||
| 17e3e8ad63 | |||
| ceb6b63e2d | |||
| e377d40104 | |||
| de8d33e715 | |||
| 6864518bd9 | |||
| b75f0b92a1 | |||
| 2e32ed7fd1 | |||
| 30d2333cad | |||
| 1271ccce77 | |||
| 82d4bb34b7 | |||
| f8ca89ada6 | |||
| f85fa7d786 | |||
| 45c91ccc7f | |||
| c17efc489c | |||
| f7fffe2859 | |||
| 62ab4ac379 | |||
| 1744fab580 | |||
| 01de5de342 | |||
| d69451c9e6 | |||
| fceb0bbb23 | |||
| f5a3e41190 | |||
| 3113346b47 | |||
| 0dc9691557 | |||
| cef796272c | |||
| dc45089908 | |||
| 395bfa3bb3 | |||
| c37e9a2a15 | |||
| 90b0204205 | |||
| 904218aa4c | |||
| f08d1b68ab | |||
| cfd7c72fd2 | |||
| 509471c97d | |||
| f748d14d25 | |||
| ae2bdb48cf | |||
| dcc5a7a253 | |||
| 25ed795ff6 | |||
| 431db5f7d4 | |||
| 24253f189c | |||
| a59192d95d | |||
| d734ed3677 | |||
| 1379ebec06 | |||
| c5eded9f7b | |||
| 68c2efdb5c | |||
| 00a8461b5b | |||
| 7154c7f0e0 | |||
| 0f07666aeb | |||
| 489dc482e8 | |||
| 55c4759780 | |||
| 16b4fe0080 | |||
| 9cd995c5f2 | |||
| e60d6a5d67 | |||
| b274af05c3 | |||
| 40e725f2ed | |||
| 0f091eefc9 | |||
| 2b00c58e9b | |||
| 6d9681fb8a | |||
| acc4d27677 | |||
| ffe581a92b | |||
| 6e643515ce | |||
| 1cc7aca53f | |||
| e5111622a6 | |||
| 99b98cb5c9 | |||
| 20447cfb46 | |||
| 723a1813c9 | |||
| 04d52a0631 | |||
| 097d18cd56 | |||
| 5043995d14 | |||
| 804cfc8b72 | |||
| a0dc6f3333 | |||
| a53957271e | |||
| abae483dd3 | |||
| 4418e09211 | |||
| ca79457c19 | |||
| d2cd947b9c | |||
| ea28285639 | |||
| 6174ddea9a | |||
| ce56adc3c8 | |||
| 59334bb13a | |||
| 21cd1f5217 | |||
| 515df53c96 | |||
| cbf7f48c44 | |||
| ed37aeb615 | |||
| ea89558696 | |||
| c0bad75a37 | |||
| bdc227f110 | |||
| a9c3a1b6de | |||
| f0e7b0b63a | |||
| a709e2f1e3 | |||
| 3a34bd74db | |||
| b755cb715a | |||
| 42753fae0a | |||
| d8b8fa2d6e | |||
| a75277e0b8 | |||
| f95b83674b | |||
| bb13e780af | |||
| 6e80d5187c | |||
| db9b704d1d | |||
| 65a2b5361c | |||
| 7bca73cd7b | |||
| 59383014be | |||
| 60a5296f5f | |||
| eb4fe333a7 | |||
| 8d06d6d817 | |||
| b6afb536b6 | |||
| 790047fa8a | |||
| baf55b6b6d | |||
| 92d85244f1 | |||
| e0e8fef265 | |||
| deb0f8344b | |||
| ed135950eb | |||
| ed7c547a06 | |||
| 1a9f927c4c | |||
| 350af86b65 | |||
| e136b73192 | |||
| b6bf2fec21 | |||
| 56daadbbca | |||
| 7de26ec9a7 | |||
| 7dbd3036df | |||
| 687085f9c7 | |||
| 96cfc7e184 | |||
| 0b26433b1a | |||
| 4f06bb40f8 | |||
| 46d571b65a | |||
| 7da1bfd315 | |||
| b30f47b5aa | |||
| ccc6c7206c | |||
| a549f4cae0 | |||
| 07c451ce22 | |||
| 62da624369 | |||
| a1a9b03180 | |||
| e4c1d37c35 | |||
| 42cacac256 | |||
| 2816970327 | |||
| 5744fcdcb2 | |||
| 3c412b394d | |||
| 4e64cea373 | |||
| db579db87f | |||
| f612a918d9 | |||
| 1a78e06a7a | |||
| 374a98ffc3 | |||
| d723e2e273 | |||
| 41030171b5 | |||
| aa31706bcc | |||
| 4e3810493d | |||
| eeb2db92ed | |||
| 5a2930029b | |||
| 2e9a73923b | |||
| 97e67f8a88 | |||
| 074849af74 | |||
| 6939783518 | |||
| 260e48fa48 | |||
| 52c4ef5df5 | |||
| 46b634e0fa | |||
| a556c29a17 | |||
| bbc4f508b5 | |||
| fe0243549a | |||
| d9196ba650 | |||
| a0ea1fa392 | |||
| 195bcb4495 | |||
| 929627a191 | |||
| 83a313b4b7 | |||
| f734bfb50f | |||
| 9efc22d195 | |||
| bd3bef5fb0 | |||
| ddbe49e847 | |||
| ed9bddec0b | |||
| 73a07eeb54 | |||
| 6e27aed804 | |||
| 5f955781a6 | |||
| c124f7310e | |||
| 73ffcae549 | |||
| 80e55ba0be | |||
| eecf955096 | |||
| 6426db9a5d | |||
| bf4753cd09 | |||
| a592eaf383 | |||
| 75d654cec3 | |||
| a18ed37600 | |||
| 6747a8dfbc | |||
| 583dfb8126 | |||
| bda31dd086 | |||
| 283af35f3b | |||
| 82564e2580 | |||
| fc6a9026e6 | |||
| e1538bb5ab | |||
| d47db969ea | |||
| f01de37a59 | |||
| cee429a0a0 | |||
| 321ee89676 | |||
| aa60484315 | |||
| b807df3ee8 | |||
| c11e922573 | |||
| 7135a3bbeb | |||
| 142b87dd67 | |||
| dff4579f28 | |||
| 2b3b662a9a | |||
| 98bc5195b1 | |||
| a02912ccbb | |||
| 2f1d29dfd2 | |||
| 11efacc4cb | |||
| 8133d0da3d | |||
| 093d63faae | |||
| 07f5e04488 | |||
| acfc97fc7c | |||
| 6e30554143 | |||
| fa1c788c41 | |||
| 3a688942c3 | |||
| 6797522618 | |||
| 4730a53313 | |||
| 5a538792f0 | |||
| 5f20289767 | |||
| 4c7ed97a76 | |||
| a9c2228e3c | |||
| ec44fcb7cb | |||
| 1f9c8d261b | |||
| 23ab3bd416 | |||
| 79482775d2 | |||
| 2dcb3678b0 | |||
| 8d7c206012 | |||
| c21c264426 | |||
| 77a39c18f1 | |||
| 65a00b0a8e | |||
| 0c2d5679f1 | |||
| 75d0e0de43 | |||
| 19ff864eaa | |||
| 6bbee7cb10 | |||
| e268923cf6 | |||
| 4fb3d7182c | |||
| 6e7a6ec032 | |||
| 0e92c92f87 | |||
| a6f1eb39ec | |||
| 21364f6239 | |||
| a0527094a9 | |||
| eda890c294 | |||
| 1ed8db87cc | |||
| 136892478f | |||
| aee90d31a0 | |||
| 6fe632fda3 | |||
| fa975a2f5b | |||
| bd5ab14d2f | |||
| 32890bfda9 | |||
| a3d4113b97 | |||
| 8acfa722f8 | |||
| 234beba06a | |||
| 90c27a0f73 | |||
| 2577a5d2c2 | |||
| 84bf10b65b | |||
| 5d70f5fd97 | |||
| f42cc59d53 | |||
| d12b222d52 | |||
| b663c7f9d4 | |||
| 30abfa7e23 | |||
| 0be32c34c5 | |||
| 8d4863b894 | |||
| ad1ae8fb54 | |||
| b31e41bfd9 | |||
| 999d5a937d | |||
| 45ea543a4b | |||
| 9d72a5e02d | |||
| 8973c45b4e | |||
| b3e5a5dc62 | |||
| d8bddadd42 | |||
| aa552d1c80 | |||
| 6daac5f442 | |||
| a8c453fbda | |||
| 3e15da5ad7 | |||
| 8e776e7d42 | |||
| de3d5654b4 | |||
| b0e19ffa30 | |||
| bacf451cf5 | |||
| 2a5600b6af | |||
| bfbc78d9b7 | |||
| d509281bdc | |||
| 866fdfb4f6 | |||
| 6c14d0f4c5 | |||
| b31a8e3bf8 | |||
| 7f3261e453 | |||
| 861c3617dd | |||
| 1f4b1f5421 | |||
| f851460a32 | |||
| 992ac6d9eb | |||
| 2e9ac57930 | |||
| ce8d090f27 | |||
| 4ec8b59fd6 | |||
| 6b086af8b4 | |||
| 52f95665ed | |||
| d1ce5ab105 | |||
| 0fd10649f4 | |||
| 3c65900768 | |||
| 0b962b4fee | |||
| a58633fc5f | |||
| 24c2ed3ae3 | |||
| 84a77d98cf | |||
| 20e50e02e9 | |||
| c8bb354972 | |||
| 3f9b73d211 | |||
| 40bbffed17 | |||
| b16ec4e809 | |||
| 9629c895d5 | |||
| f4420fcc03 | |||
| 17f8c3e134 | |||
| 02512a522c | |||
| 4974f99842 | |||
| a3d0c32be3 | |||
| 31523160fc | |||
| 183d5cdeec | |||
| 52fed810f2 | |||
| f58d63da8d | |||
| 6793e6fbdc | |||
| 4f88e9b97d | |||
| 5004cec9b3 | |||
| 76c81e4209 | |||
| 323043dc01 | |||
| 37902ce70a | |||
| c69e795d49 | |||
| c3b82af95a | |||
| 24510d4828 | |||
| 11a04a99f7 | |||
| 6e96eeaa05 | |||
| 2c16d4af5a | |||
| d1f9cf43de | |||
| 26fb11cb30 | |||
| a7a3eb8658 | |||
| fbedcd1f5f | |||
| 6033af0f23 | |||
| 4809786b16 | |||
| a37f40ff6e | |||
| 44f5cc9de0 | |||
| e059d0cbd9 | |||
| c51040b0de | |||
| eba2abc108 | |||
| ed7626009a | |||
| 442413ec6b | |||
| 90986df641 | |||
| 8b505dbce3 | |||
| 2b26e9643b | |||
| eb16f01839 | |||
| 872cf33d1c | |||
| afbce19028 | |||
| e27228e735 | |||
| 3125232114 | |||
| e552840cc4 | |||
| 569142845a | |||
| e62d837858 | |||
| 27badfb88a | |||
| e4d0781eb8 | |||
| 93c8373335 | |||
| 42bad6b4ea | |||
| 83d4bd88cf | |||
| 4141267104 | |||
| d5778f165b | |||
| 7128e26a27 | |||
| 78247e9969 | |||
| 66d36e034e | |||
| 4100ee842c | |||
| d4857c8e6e | |||
| 0f52781489 | |||
| 332f19cd22 | |||
| 53a1c103d6 | |||
| addf7a3e6b | |||
| c76078ea06 | |||
| 9299288e8a | |||
| dc34f24ee2 | |||
| fe83bf1483 | |||
| c184f9281d | |||
| 3127a69b67 | |||
| d7326c4f82 | |||
| f8478284b6 | |||
| 1c910de4b1 | |||
| 2c3cb955ef | |||
| 1d7746d839 | |||
| e16d2ba238 | |||
| 60f8b448ec | |||
| c0d61f29a8 | |||
| c47b99f9ee | |||
| 653429a6d6 | |||
| c2250e1817 | |||
| 92ca48d65f | |||
| 2f53d9a79d | |||
| d025bfe2da | |||
| fbc2460ac4 | |||
| 76c2800275 | |||
| 536bf984ee | |||
| 23f8af43ee | |||
| eba54bf2e7 | |||
| 50270e6541 | |||
| 5f7d9e80ee | |||
| a71ef35d5c | |||
| 2cf5291831 | |||
| 75e3e9dd7b | |||
| 7a75365d2f | |||
| 7a23d59aa1 | |||
| 2583423600 | |||
| 1d748a0424 | |||
| 86172790d1 | |||
| 92c9103059 | |||
| 16393dbc9d | |||
| e841b0ef03 | |||
| 27c7ae9d76 | |||
| dc3a40675d | |||
| 31e65ac2e3 | |||
| c2444d1b09 | |||
| 2faadae4cf | |||
| d24e51c355 | |||
| 8ed8af1b9c | |||
| 5f6b416f26 | |||
| 3671c05573 | |||
| db6eed9292 | |||
| 4c903ba0bb | |||
| 9eee216065 | |||
| 9e1dd02d70 | |||
| b986bf9bcc | |||
| a3e0e62efc | |||
| 8646c5acf6 | |||
| dab9465755 | |||
| 3a076cc4ea | |||
| 1b6d1ea0ef | |||
| c490284c65 | |||
| c3ca329138 | |||
| d0f73c32bb | |||
| 79e0da7cf2 | |||
| b261f505aa | |||
| 65c4c95cbe | |||
| 5b3c9c9055 | |||
| d32d4f845c | |||
| 637950d37f | |||
| 0e1ede58e5 | |||
| e914a11768 | |||
| 73d66dab35 | |||
| 634766a08e | |||
| 5fbc450e96 | |||
| 5acd7ae2c4 | |||
| 09578ddc5d | |||
| 8270801711 | |||
| de91c3cfc8 | |||
| 06888a2061 | |||
| cde2d33d03 | |||
| 9cf89f95d1 | |||
| 20486e15ea | |||
| 420c403da1 | |||
| cb8fdf3461 | |||
| 0937d08770 | |||
| 9b1fd8e1aa | |||
| d87861142c | |||
| ff5fc1d0f3 | |||
| d5a671cbb8 | |||
| d0508cbd96 | |||
| 12098cf8ac | |||
| 2557c35bea | |||
| 11f23bbd77 | |||
| 2b2ec729ac | |||
| 9292acec8c | |||
| 3095ed5375 | |||
| a333e303c1 | |||
| bd8512feab | |||
| c235fc4a7a | |||
| 9c283b9aee | |||
| 4e824d739b | |||
| fcbbc792b0 | |||
| ba7cf8e79e | |||
| 4ab49d8e43 | |||
| 7f864db80d | |||
| 772a163693 | |||
| c8e106f272 | |||
| ade4f0a1d4 | |||
| 4fcf933dd1 | |||
| 1fb60771ca | |||
| 2cd391e106 | |||
| 196f3bcba1 | |||
| bc73c5ca86 | |||
| f4badc15d1 | |||
| 76f731d0cd | |||
| 5e22858ab4 | |||
| bc1c54c43e | |||
| 95ac0d36f8 | |||
| 2bee3eb179 | |||
| 52165f2a0b | |||
| 9f2da226f4 | |||
| 46b999317d | |||
| 80bce3c9c2 | |||
| 90b25b4bd8 | |||
| f562f1eafd | |||
| ff520622ff | |||
| 1a6c6a4f31 | |||
| 4a9e0388f4 | |||
| e47b2a971e | |||
| 96777ec095 | |||
| 52019deef5 | |||
| c26a534ff6 | |||
| e5be5c0b05 | |||
| 5b07f52641 | |||
| e0109cb530 | |||
| 1496dd1598 | |||
| efcaeb2f00 | |||
| 1252946fe5 | |||
| 2913d5f636 | |||
| c50beb3d43 | |||
| 46b559001a | |||
| 55a2e2e060 | |||
| 12364a052f | |||
| 45ac971035 | |||
| 26b818c9a6 | |||
| 007836f52e | |||
| ed0982770b | |||
| dcc72e90b7 | |||
| b428660944 | |||
| 0008dcc0ee | |||
| e3fbf3981d | |||
| c5e9244a08 | |||
| e1fd823c13 | |||
| 346f0d636c | |||
| 315a259e5b | |||
| 3157a03c74 | |||
| 045807a748 | |||
| ea0e12ac80 | |||
| 692060af6c | |||
| d8b23a4b5c | |||
| 074bf55d6c | |||
| 84e505b817 | |||
| a12e6ff603 | |||
| b01d662a1c | |||
| e631dad499 | |||
| 7ab0bfdd6b | |||
| 35a4db43fb | |||
| 094028006b | |||
| c12f42fcac | |||
| 3e498b098b | |||
| bbf1a1a544 | |||
| 42d57f30bf | |||
| 19967a9ad0 | |||
| 9d1a8f12d8 | |||
| 57bebebe09 | |||
| d19303cd62 | |||
| 7c96e46a37 | |||
| f75e68f8ce | |||
| 9b922b00c1 | |||
| c8d9ee2457 | |||
| a29f23086a | |||
| 4a76365df4 | |||
| 972264844a | |||
| e23e6f3c1a | |||
| 03c10382d1 | |||
| 3abe24aa18 | |||
| 2a4eabcda5 | |||
| 280d98c9c4 | |||
| 227c5c80c8 | |||
| edd00a6ab5 | |||
| d57bb17fc7 | |||
| 098da5c868 | |||
| dfc17bcc26 | |||
| 7a5eab104e | |||
| e0bf815377 | |||
| 2cbc6f2890 | |||
| 5859b2c08b | |||
| 44eaa9dc8b | |||
| 49cd7a41dd | |||
| 53de5ece21 | |||
| 90dffa5fcc | |||
| ac6cd0ca15 | |||
| 0e970128ab | |||
| 9fc373384d | |||
| a76bd2ea55 | |||
| 3c610b74d2 | |||
| 0623005ff4 | |||
| 8b032eba9a | |||
| e3229b85fd | |||
| 1178431b8f | |||
| 9d92c4a2b1 | |||
| 0bda8707ce | |||
| 9cb7d3a0fd | |||
| f1204f948a | |||
| 455b8dafb0 | |||
| fe5a32d95b | |||
| b34f6f1690 | |||
| a89bc35c6f | |||
| e551b6c202 | |||
| c6ed1afc51 | |||
| 4bedebb74d | |||
| 741fc7eb05 | |||
| 000da1197c | |||
| dd8be29d54 | |||
| 099c2c7ff4 | |||
| 874232a152 | |||
| d21f046e1f | |||
| ef0e25878e | |||
| cd37619af1 | |||
| 209ee18943 | |||
| aff7733a5b | |||
| 4bb65921e4 | |||
| 26216a6a8c | |||
| 60c3f11d7e | |||
| fcab08bfab | |||
| e69ba466e1 | |||
| 675e85ee09 | |||
| 36ec13b2c4 | |||
| 0b9c36e3ce | |||
| 07cf895447 | |||
| 028f21f0cf | |||
| 77724e363d | |||
| 15863d8a5a | |||
| b836af58f0 | |||
| ef5d880f14 | |||
| 15fd03b4c0 | |||
| cac06f3e17 | |||
| 6d81c62cdf | |||
| 60e319589d | |||
| 7723a5785d | |||
| dd982fe6e2 | |||
| c02dfeaa97 | |||
| a3d513afb0 | |||
| 40fa99a1f9 | |||
| f26c996095 | |||
| 012c4dfb9f | |||
| 9337ca2616 | |||
| 5dcec4bcfd | |||
| ed2e5e4d09 | |||
| 2249f52c32 | |||
| f4ef5d2b53 | |||
| a92c5d6ce0 | |||
| b61ceaead6 | |||
| 0672cfc8f3 | |||
| b5859a43cc | |||
| de1cd7c737 | |||
| f588312ad3 | |||
| 643ef1fa45 | |||
| ec38a4ed33 | |||
| defbd80ce0 | |||
| f11b91e7fa | |||
| 58bb4d5a7f | |||
| d92af60d9b | |||
| bca1e043bd | |||
| 7719175e85 | |||
| 046146c4d8 | |||
| 0e2f66b35a | |||
| 56b9ff1dc9 | |||
| dab8e78cd5 | |||
| 67dd27b9a1 | |||
| 4605985a47 | |||
| 02ac27342a | |||
| cbc59a4a1c | |||
| 02758d96c7 | |||
| 12752134fd | |||
| 504e70441b | |||
| 85cd16e793 | |||
| aa2277f05e | |||
| 27eb96722a | |||
| 0215096c9e | |||
| 90d8c51402 | |||
| ba0bbfefe7 | |||
| dd65673c1e | |||
| 2f3f8de682 | |||
| 3ce6fcad3a | |||
| 4302e7426e | |||
| 4e246c35c8 | |||
| 0cbcae9624 | |||
| 313aed606f | |||
| aade735c79 | |||
| 1e5786a142 | |||
| 3e5e5eeafa | |||
| 8a48ee8e14 | |||
| 6a89981c77 | |||
| 03c014fdda | |||
| ae683cd3c9 | |||
| a0dcae0882 | |||
| 6e839b210c | |||
| c7f4f62e93 | |||
| 214aba8f01 | |||
| 153c9d944f | |||
| 99a9d15871 | |||
| f6fb905ef8 | |||
| a5456452f6 | |||
| 48cd9af739 | |||
| 01fac428d5 | |||
| 3acce100f6 | |||
| ba2dfe6598 | |||
| 46600c38a8 | |||
| b9ae66e557 | |||
| bed9010918 | |||
| d13ccbde71 | |||
| 2e2aac0413 | |||
| 5f18cff344 | |||
| 830300ea81 | |||
| 9da2fcfbca | |||
| ece68df495 | |||
| 6a43ddbee9 | |||
| ee58596ed0 | |||
| 5bce6e77ae | |||
| 5dec45c0c8 | |||
| b7ba6500e4 | |||
| 2a263d749b | |||
| 3a21af48d1 | |||
| cb3bfe99ce | |||
| 328d51622f | |||
| 38ead74697 | |||
| ffd4d72850 | |||
| d23d90a794 | |||
| c74036ad26 | |||
| a146a9f424 | |||
| ddc0c82cee | |||
| bab09ac0d7 | |||
| 8a6eb912c5 | |||
| b5fbd40ea3 | |||
| 46b1c085cd | |||
| e5642cd752 | |||
| be933e9085 | |||
| 26769675c7 | |||
| 672d6d67aa | |||
| b3c6967ed9 | |||
| 9a528b17e8 | |||
| 4532932749 | |||
| 639a8e2f35 | |||
| 56c2ab38c6 | |||
| 9769cc0ed4 | |||
| 96d66b2205 | |||
| fea73109ca | |||
| e39c15c16f | |||
| 2a13f60bbd | |||
| d8e9738d71 | |||
| f38b78f821 | |||
| 9c21630295 | |||
| ea502727ba | |||
| 72f4999162 | |||
| adf1dbe635 | |||
| af2cdb1ca1 | |||
| e499c0e990 | |||
| 206ab6793f | |||
| e207c90fd4 | |||
| c9864008a3 | |||
| 5c89b27a87 | |||
| 3c482ba017 | |||
| 2d684411d4 | |||
| 408ee36a8b | |||
| d9efbad80d | |||
| 92e5c9f71a | |||
| baef88be50 | |||
| 7eff6ac504 | |||
| a3ea4d4e18 | |||
| f12047e36f | |||
| c77f37cc43 | |||
| 8bc463b6d6 | |||
| 953429f0da | |||
| 8598050931 | |||
| 81566674a8 | |||
| aba9f88352 | |||
| 0093c6ddf7 | |||
| cd5a0c2fbc | |||
| b1c803d2f1 | |||
| 3883b68669 | |||
| 5111bb12cf | |||
| cc2518a346 | |||
| 6e98f4c953 | |||
| 8990b25970 | |||
| c0cdde8685 | |||
| 7a7e20e4c2 | |||
| 743ccd27b6 | |||
| 155d9110c5 | |||
| 651e0b3445 | |||
| 82f438cbe4 | |||
| c3c30b4e20 | |||
| 8b4889ce83 | |||
| 7ccaa0390d | |||
| 5f0ed73d0d | |||
| 54518b63d0 | |||
| 1c38f2e5fd | |||
| 7ba12707e1 | |||
| 01992b26f4 | |||
| 8670ba08d5 | |||
| 67698757cf | |||
| 833ef3f5d3 | |||
| b104f14899 | |||
| 60c86b1db8 | |||
| b6d2965962 | |||
| 1548552af3 | |||
| 002cf8025b | |||
| dfd7e4b62c | |||
| e54380cd81 | |||
| 09b3532793 | |||
| b36e288958 | |||
| bcd3c2739a | |||
| 695e653461 | |||
| b068ff810c | |||
| 1a57f7a047 | |||
| 90f955e48e | |||
| 14fbce5a6b | |||
| 5cff407379 | |||
| 79b7ff21f5 | |||
| 65996a40e9 | |||
| cf3566255a | |||
| 582c6fa36d | |||
| aefba8b4df | |||
| e43674fd29 | |||
| 3856826e4d | |||
| 00ed0b7691 | |||
| aa456f6cff | |||
| 3a9dfa643e | |||
| 28580114da | |||
| 8d47f118dd | |||
| d7b45157ff | |||
| db8a48284f | |||
| b00b8d6ca3 | |||
| 22b3d8f691 | |||
| 432c1cd476 | |||
| ed8e4a059a | |||
| 571c1e31ac | |||
| b294d0eea0 | |||
| 0b50fbc55d | |||
| f5d5534290 | |||
| 777eb7e398 | |||
| 9b1be55a9e | |||
| ef4a7d1fa1 | |||
| e6a7380aa5 | |||
| 2f41591339 | |||
| 8916ef0207 | |||
| c3288f765e | |||
| 11d631bee3 | |||
| e3be78edb0 | |||
| 59f01a8426 | |||
| 76b7c8ef3b | |||
| fbaae9ce76 | |||
| e70b58a3df | |||
| ec551c9de8 | |||
| 7a8cb5835a | |||
| 3b8ccf173b | |||
| 172ce29665 | |||
| e7d8a8d2c0 | |||
| cc7b43f4ba | |||
| 71425977f5 | |||
| 4549727d0b | |||
| e030d97059 | |||
| 13c1ca4f79 | |||
| 637d7c0db1 | |||
| 7513a4b6d6 | |||
| 76100ef07d | |||
| ab139209db | |||
| c6a31baaab | |||
| 7c9d4629b6 | |||
| 986858c20d | |||
| a0551095dc | |||
| d0804c886a | |||
| 1befca7e28 | |||
| 3d4482157a | |||
| 3cd71f1cd4 | |||
| eebf443c1d | |||
| 7aaf0b9375 | |||
| 5ff9675e60 | |||
| 646390edff | |||
| b1958c5442 | |||
| 01b9590e81 | |||
| 985a288bf3 | |||
| 01dcfb88e7 | |||
| 76415a8dec | |||
| 586b7c14f8 | |||
| 0ee4bf2b25 | |||
| 5673e8abe8 | |||
| c446e32a8a | |||
| eee9392fb4 | |||
| 82a3961608 | |||
| f8689fc409 | |||
| 7de93e1780 | |||
| 19e742b7d1 | |||
| e3f0250fd2 | |||
| e4b39b89f3 | |||
| d2888b49e8 | |||
| b97ed49a60 | |||
| 65936dc85b | |||
| b1d02ee302 | |||
| cca47abb9c | |||
| c810ba62b4 | |||
| dcce7ffacc | |||
| e9fb137146 | |||
| 7100c9c300 | |||
| e9b5496387 | |||
| 87ecde390d | |||
| 8b33c66e22 | |||
| b3bbb6624e | |||
| 2ddac45612 | |||
| ddd7d47516 | |||
| a770072809 | |||
| 5b38ce0b37 | |||
| 804708076a | |||
| a0ad71a524 | |||
| 9d03694ea7 | |||
| 7a619e0e9e | |||
| 6bd5eb7930 | |||
| 744d1859d5 | |||
| 8033e3fc8e | |||
| abb0b629f3 | |||
| 34a61f49f6 | |||
| 08c31171a0 | |||
| 40addbf595 | |||
| abd6da0a37 | |||
| 794d1c5019 | |||
| b8d048bf3c | |||
| bb7b650ebc | |||
| 5253de9279 | |||
| 094945e350 | |||
| eb6b58e6db | |||
| 7bcb6e30a7 | |||
| 7ed4024555 | |||
| 6fa45c5958 | |||
| a883f140d4 | |||
| 1bec52efcb | |||
| 674e58ec3f | |||
| 373d705387 | |||
| 46fb693020 | |||
| f1fe7d1545 | |||
| 44ab386835 | |||
| 51d736eba4 | |||
| dcc4f6bd03 | |||
| eef2482423 | |||
| f46a1fb9b5 | |||
| bae45af414 | |||
| aff3835f50 | |||
| 5f249f6032 | |||
| a185d7e9e8 | |||
| 3449caf0e2 | |||
| cc458cc916 | |||
| 2b6b56ace9 | |||
| 4d72c920c6 | |||
| b0186a1baa | |||
| bc3836b316 | |||
| 0a444efe55 | |||
| fba3988834 | |||
| b99e1abb03 | |||
| ab44237bb9 | |||
| 6d98dc073f | |||
| 9709ef4333 | |||
| cb60f6f735 | |||
| 5d0ab2e2db | |||
| 4e3b0b52c8 | |||
| 973590281c | |||
| 4f7defb85c | |||
| 59f4d8dc26 | |||
| be736e105a | |||
| 3be56ba462 | |||
| f7adf8457b | |||
| 80b8856f15 | |||
| 0860f47d88 | |||
| 382b75fccf | |||
| d3dacbd32d | |||
| 6c51e4b596 | |||
| d0bd4428be | |||
| 43ffcb7362 | |||
| ca5dfca290 | |||
| 8a495eff37 | |||
| bee6af12fb | |||
| 4a3dfc3eb7 | |||
| 4e87a4adc4 | |||
| d9bb9d5884 | |||
| 562be0cb89 | |||
| 2fdc86af97 | |||
| 220d1225b2 | |||
| e047a6e449 | |||
| 97b21a9d69 | |||
| 9be9570dea | |||
| cbfe37dbba | |||
| bf176a2c05 | |||
| 173c51eb26 | |||
| 71dbb090fb | |||
| 832133e6cc | |||
| 2730a92b01 | |||
| 40fb5c80eb | |||
| 6c65095961 | |||
| d02f7ae7bc | |||
| 9f0b4b24ba | |||
| 237357c1d5 | |||
| aefb1caf57 | |||
| a3328e7ef0 | |||
| db24588273 | |||
| 2cabeeb6bd | |||
| 88352202b0 | |||
| e02d8a949b | |||
| e7e5457cf2 | |||
| 29b98b0f7d | |||
| ae7378f474 | |||
| b05c17bee4 | |||
| a2d3dac226 | |||
| a8ec41e255 | |||
| 1d16062961 | |||
| 2a614e8656 | |||
| baaaaf01bf | |||
| 20f1fc51b1 | |||
| d749ebf981 | |||
| 04364c503c | |||
| ae2a040fe4 | |||
| 0debf23b5e | |||
| 55e7849b43 | |||
| 34d9a73677 | |||
| 0b9ccf5bc0 | |||
| 48e72bb9df | |||
| 35aed44bcb | |||
| e4cf693c0d | |||
| f0755f5f90 | |||
| bdfc6af688 | |||
| 31b4061a43 | |||
| a1b80f0323 | |||
| 1678686ce7 | |||
| c25d05196d | |||
| bdddc7ad03 | |||
| 1d1ad4f9fa | |||
| 099fcc7981 | |||
| 2ef44e45f6 | |||
| ebe62d0498 | |||
| 6472b20ea4 | |||
| 69cdb2d754 | |||
| f7d8000b48 | |||
| a44afbe1b9 | |||
| 662da1b8a9 | |||
| 2a3d743204 | |||
| 7241ee4c7a | |||
| 5c084d6e8b | |||
| 1060c7fdfc | |||
| b5d6c0e0b3 | |||
| d69644350e | |||
| 8634622a6a | |||
| cc73554350 | |||
| 8adf93d592 | |||
| 6bd60ddf96 | |||
| ea5bcbe4dd | |||
| 541b49069d | |||
| ba868866ec | |||
| 606ae48f13 | |||
| 09c5445f83 | |||
| 4a0a18a7b0 | |||
| ed9d1ab738 | |||
| a61e8084dd | |||
| e169380178 | |||
| b07d4ef6c3 | |||
| 4c713c7652 | |||
| 90e8c6b3c4 | |||
| 07bf05cb2c | |||
| 49d29902a8 | |||
| 32a90c2a9e | |||
| 16ed2c1517 | |||
| 8bb0f1c799 | |||
| f14fe2dd64 | |||
| 884f0ee0de | |||
| 744270f48c | |||
| c30849be3c | |||
| f295ac8b4d | |||
| 9469a8f43d | |||
| 786799ca40 | |||
| aa6238cf5a | |||
| 1ed31ee2e2 | |||
| 7d8c12977c | |||
| 81ee1d63a6 | |||
| cf1c1b3198 | |||
| bc7b1e8472 | |||
| 7319c41cc1 | |||
| 2f09fc32c0 | |||
| f85b3f54a1 | |||
| 1a68669d6d | |||
| 6345c2d414 | |||
| 87930cad75 | |||
| 3ab2232121 | |||
| 9719e25421 | |||
| 8e1f998c39 | |||
| deb1c224f8 | |||
| b2fa79898a | |||
| da2759a3c6 | |||
| f6bc696426 | |||
| da653e9b32 | |||
| 227e153bde | |||
| 12f906b9b0 | |||
| c8236d4d10 | |||
| e2006da50d | |||
| 433a7d3641 | |||
| 6b41c74b43 | |||
| cc3e7ff7c6 | |||
| a35a6bf642 | |||
| 9ecd523646 | |||
| e7f896c38e | |||
| 624ec743b1 | |||
| 553c5745ef | |||
| 3f2779ca3d | |||
| e51b962627 | |||
| f709e135b6 | |||
| 50b48aa247 | |||
| 508f2abc82 | |||
| 42ec56e578 | |||
| 92673dac6e | |||
| daf57aba35 | |||
| dee629c429 | |||
| 67f9092350 | |||
| 88691d47a5 | |||
| 0fdd9bc803 | |||
| 8452c607b4 | |||
| 4fc57c2d57 | |||
| 9f0b707a30 | |||
| 511bc351d5 | |||
| 56ee3e962c | |||
| 2781e8519d | |||
| 46575348fe | |||
| bae2ca646b | |||
| 0686c6ab00 | |||
| db04b66ce5 | |||
| c43c8a29af | |||
| aaea18abe1 | |||
| 0b6076cb61 | |||
| 3b6d02f02c | |||
| 625a0586a3 | |||
| 10d63ca586 | |||
| d2697bf9de | |||
| fa8c8843e2 | |||
| 7b59ea4b96 | |||
| 058215934e | |||
| 1d724d841f | |||
| 32db2100de | |||
| 095c77b65b | |||
| c1c07bff8f | |||
| 1206bd0e98 | |||
| 67bcd1b307 | |||
| 31ef70e433 | |||
| afb12961d2 | |||
| 3dbd40e334 | |||
| bc5d6e38c8 | |||
| 5a39143051 | |||
| 18e9571877 | |||
| a828f4c831 | |||
| 54ac12f250 | |||
| 9a6a72f605 | |||
| 03949489e8 | |||
| 8ebd735e7e | |||
| 39246da73f | |||
| bf33f60368 | |||
| 492a613769 | |||
| 2c02c3434e | |||
| a186ddd9d2 | |||
| 4b0c7a5e4e | |||
| 54cfbe3ab6 | |||
| 02015cf0b8 | |||
| 416e4d3598 | |||
| e0d9c4b0ea | |||
| 2659e3bc09 | |||
| d296167b6a | |||
| 89b0aa7fec | |||
| 787aadf444 | |||
| d67161a29b | |||
| 7f4777ead4 | |||
| 1e2147b143 | |||
| 9f0a2fbf63 | |||
| 7804851bc9 | |||
| adc50dd89e | |||
| 5378cf5ddc | |||
| a733bb605f | |||
| aaa1b5a264 | |||
| 5855cdee65 | |||
| c0d584e644 | |||
| 955f0f473e | |||
| 9ceb888124 | |||
| e902933693 | |||
| e07b47fb10 | |||
| 5bf08d9701 | |||
| 597fa1e6fa | |||
| dda3ec0ba0 | |||
| 991f08c749 | |||
| 923865cd7c | |||
| 606aee8ccd | |||
| 4a1cb27d0a | |||
| 26cd7f3a12 | |||
| f4045dbc56 | |||
| 33c2b24550 | |||
| cd86f3c8ea | |||
| 58026ebc2d | |||
| 9183275dad | |||
| 5e3a625d95 | |||
| 3297f9d2d2 | |||
| 29b8306a28 | |||
| d84746bac6 | |||
| 4eacf2ff0b | |||
| a68e0d6f61 | |||
| ddf44304c7 | |||
| 789d98f58e | |||
| 205574620d | |||
| d9bae12b31 | |||
| c951190794 | |||
| d4e1d69c3c | |||
| f93a32c07b | |||
| 8e7c152c0d | |||
| 6a64404f20 | |||
| 1a09589add | |||
| d0f9072ab5 | |||
| 011ed9f2d8 | |||
| 12332f83d9 | |||
| 8f75829948 | |||
| a7ffedb3b2 | |||
| 378d3b9747 | |||
| 5ba05409c4 | |||
| d86beef58f | |||
| 7315d67c9f | |||
| 59d79b8c7a | |||
| 3df97653a2 | |||
| 948d55e418 | |||
| 1d2c7281b1 | |||
| 35759b943c | |||
| 29da33e200 | |||
| 1d758f8126 | |||
| b663dac2ab | |||
| 485a3971e9 | |||
| 36b5d64ca6 | |||
| 8462a709bc | |||
| 20cabe8362 | |||
| 6967864361 | |||
| a6ee3840ae | |||
| 01de6139df | |||
| 6ba0924f6c | |||
| 00c75d1fd8 | |||
| 93f28fa9d5 | |||
| 0aed10eb8b | |||
| d84d687eee | |||
| d046f56832 | |||
| 0af1fbf313 | |||
| e9aaf4309e | |||
| 80fa1085d2 | |||
| 88195291fd | |||
| 39ad600d00 | |||
| c63a3aba6b | |||
| 782ecbe307 | |||
| f75bbfa048 | |||
| 3a772cc983 | |||
| 807113523e | |||
| 7df2af8611 | |||
| 10c1697b00 | |||
| 742e5d38ab | |||
| 1d3e7f8af0 | |||
| c99bacab64 | |||
| 95d7555ebc | |||
| 5854e0a23c | |||
| 81f7f32767 | |||
| c0374a7da1 | |||
| ed65e655b9 | |||
| d250e516b5 | |||
| e4f404b2c7 | |||
| 86571331f8 | |||
| 163d420af9 | |||
| 4925ea4af0 | |||
| 57b05ce348 | |||
| 427b53fcbd | |||
| 2ac7a31be6 | |||
| 4baed6409f | |||
| 2ea0ff0965 | |||
| 88b9a7edce | |||
| 538066b960 | |||
| b10dd1d75a | |||
| f743580b55 | |||
| 0688467302 | |||
| 29e972dbf2 | |||
| bd99697c5b | |||
| 1d5c431ff6 | |||
| 3736c510ff | |||
| 26c12c3a85 | |||
| 10bb08e5e6 | |||
| b50d5b6121 | |||
| 38b28752e1 | |||
| 87c51009de | |||
| a950a47b28 | |||
| 2609d10c0b | |||
| 7b4cb0f66c | |||
| 26db0e1e69 | |||
| 62e64cbd22 | |||
| 844a47830f | |||
| 2cc1e32f45 | |||
| 9890958b1e | |||
| 6dbd6d6edc | |||
| bf7a4dfd28 | |||
| f52626bda0 | |||
| 88cec4bf2b | |||
| b880def108 | |||
| bfd5dc039a | |||
| c0d236dff3 | |||
| 743d5fc306 | |||
| 2f51516163 | |||
| b606b46aa4 | |||
| 5b65ff99a0 | |||
| 74a3f52324 | |||
| 7c2a4fdfa8 | |||
| fecee3a098 | |||
| 45f7111fb9 | |||
| f2e23a8062 | |||
| 36a3631f5e | |||
| a758d19a9f | |||
| aac3e53a30 | |||
| 85d87ee210 | |||
| 2f3ee80ed4 | |||
| 3632bea54e | |||
| 4a0521751a | |||
| 7d6266c241 | |||
| 1d69b9ffea | |||
| 72674cba10 | |||
| 495a8e2d1e | |||
| 95ae221d60 | |||
| 857f746da1 | |||
| 0e2e3052b1 | |||
| 167c04cc3f | |||
| 043f51479a | |||
| 921282df81 | |||
| b3c641501c | |||
| 3608e7601f | |||
| a75cfa1056 | |||
| f08c4102fd | |||
| 9bfd7c7d9a | |||
| a359b4fbdb |
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"agentCanUpdateSnapshot": true
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
ВАЖНО!!! Мы работаем в МОНО-репозитории pnpm, любая установка пакетов производится ЧЕРЕЗ ФИЛЬТР компонента в корне: pnpm add glob --filter notificator2.
|
||||
|
||||
ВАЖНО!!! НИКОГДА НЕ ЗАПУСКАЙ ПРИЛОЖЕНИЕ ИЛИ ЕГО СБОРКУ!!! Просто отчитайся что всё сделал.
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
description:
|
||||
globs: components/controller/**
|
||||
alwaysApply: false
|
||||
---
|
||||
# NestJS Controller и Чистая архитектура
|
||||
|
||||
## Основные принципы
|
||||
1. Чистая архитектура - главный архитектурный подход. Пиши код с комментариями.
|
||||
2. Типы IName, IChecksum256, ITimePointSec - это просто строки.
|
||||
3. Домен должен быть полностью изолирован от инфраструктурных деталей.
|
||||
4. Направление зависимостей - внутрь (к ядру, домену).
|
||||
5. Домен, инфраструктура и приложение связаны через App.module, НЕ НУЖНО импортировать их друг в друга, а достаточно просто импортировать домен на уровне приложения чтобы использовать все экспорты домена.
|
||||
|
||||
## Структура проекта
|
||||
- `domain/` - доменный слой (бизнес-логика, независимая от инфраструктуры)
|
||||
- `infrastructure/` - инфраструктурный слой (адаптеры к внешним системам)
|
||||
- `modules/` - слой приложения (DTO, резолверы, сервисы)
|
||||
|
||||
## Слои и их взаимодействие
|
||||
1. **Домен**:
|
||||
- Содержит чистые доменные интерфейсы и реализованные на них сущности
|
||||
- НЕ должен зависеть от `cooptypes` (инфраструктурный контракт)
|
||||
- Использует собственные доменные типы (например, Date вместо строковых timestamp)
|
||||
- Интерфейсы портов описывают взаимодействие с внешними системами
|
||||
|
||||
2. **Инфраструктура**:
|
||||
- Содержит адаптеры к внешним системам (блокчейн, БД и т.д.)
|
||||
- Осуществляет преобразование между доменными и инфраструктурными типами
|
||||
- Общая логика преобразования выносится в утилитарные классы (например, `DomainToBlockchainUtils`)
|
||||
|
||||
3. **Модули (приложение)**:
|
||||
- DTO имплементируют доменные интерфейсы напрямую
|
||||
- Резолвер вызывает сервис, который принимает DTO
|
||||
- Сервис передает объекты DTO в интерактор домена
|
||||
|
||||
## Поток данных
|
||||
1. Резолвер принимает входные данные и передает их в сервис
|
||||
2. Сервис вызывает интерактор домена, передавая ему объекты DTO (имплементирующие доменный интерфейс)
|
||||
3. Интерактор выполняет бизнес-логику и взаимодействует с портами домена
|
||||
4. Адаптеры (инфраструктурный слой) преобразуют доменные объекты в формат внешних систем (JSON для хранения мета-данных, ISO для дат и т.д.)
|
||||
5. Адаптеры возвращают результаты в домен, домен возвращает данные в сервис, который возвращает их в резолвер
|
||||
|
||||
## Правила преобразования данных
|
||||
1. Преобразование DTO → доменный объект: не требуется, если DTO имплементирует доменный интерфейс
|
||||
2. Преобразование доменный объект → инфраструктурный тип: происходит в адаптерах
|
||||
3. Преобразование документов, дат и других сложных объектов: используются утилитарные классы уровня инфраструктуры
|
||||
|
||||
## Работа с типами данных
|
||||
1. **Даты**:
|
||||
- В доменных интерфейсах используется тип `Date`
|
||||
- В DTO используется тип `Date` с декораторами `@IsDate()` и `@Type(() => Date)`
|
||||
- В инфраструктурном слое происходит преобразование `Date` → `string` (ISO формат)
|
||||
```typescript
|
||||
// Доменный интерфейс
|
||||
interface MeetDomainInterface {
|
||||
open_at: Date;
|
||||
}
|
||||
|
||||
// DTO
|
||||
@Field(() => Date)
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
open_at!: Date;
|
||||
|
||||
// Адаптер
|
||||
const blockchainData = {
|
||||
open_at: domainToBlockchainUtils.convertDateToBlockchainFormat(data.open_at)
|
||||
};
|
||||
```
|
||||
|
||||
2. **Документы**:
|
||||
- В доменных интерфейсах используется собственный тип `SignedDocumentDomainInterface<T>`
|
||||
- В DTO используется соответствующий DTO-класс (например, `SignedDigitalDocumentInputDTO`)
|
||||
- В инфраструктурном слое происходит преобразование между форматами (meta-поля в JSON и т.д.)
|
||||
|
||||
## Типовые ошибки
|
||||
1. **Нарушение изоляции домена**: домен должен быть изолирован от инфраструктуры. Не используйте импорты из `cooptypes` в доменных интерфейсах.
|
||||
```typescript
|
||||
// Неправильно
|
||||
import { MeetContract } from 'cooptypes';
|
||||
export type VoteDomainInterface = MeetContract.Actions.Vote.IInput;
|
||||
|
||||
// Правильно
|
||||
export interface VoteDomainInterface {
|
||||
coopname: string;
|
||||
hash: string;
|
||||
// ... доменные поля
|
||||
}
|
||||
```
|
||||
|
||||
2. **Преобразование в неправильном слое**: преобразование доменных объектов в инфраструктурные типы должно происходить в адаптерах, а не в доменном слое или сервисе.
|
||||
```typescript
|
||||
// Неправильно (в доменном интеракторе)
|
||||
async vote(data: VoteDomainInterface) {
|
||||
const blockchainData = { ...data, meta: JSON.stringify(data.meta) };
|
||||
// ...
|
||||
}
|
||||
|
||||
// Правильно (в адаптере)
|
||||
async vote(data: VoteDomainInterface) {
|
||||
const blockchainData = this.convertToBlockchainFormat(data);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
3. **Дублирование логики преобразования**: общая логика преобразования должна быть вынесена в утилитарные классы.
|
||||
```typescript
|
||||
// Неправильно (дублирование в разных адаптерах)
|
||||
// В MeetAdapter
|
||||
private convertDoc(doc) { return { ...doc, meta: JSON.stringify(doc.meta) }; }
|
||||
// В BranchAdapter
|
||||
private convertDoc(doc) { return { ...doc, meta: JSON.stringify(doc.meta) }; }
|
||||
|
||||
// Правильно (общий утилитарный класс)
|
||||
// DomainToBlockchainUtils
|
||||
convertSignedDocumentToBlockchainFormat(doc) {
|
||||
return { ...doc, meta: JSON.stringify(doc.meta) };
|
||||
}
|
||||
```
|
||||
|
||||
## Примеры
|
||||
```typescript
|
||||
// Доменный интерфейс - чистый, без зависимостей от инфраструктуры
|
||||
export interface VoteOnAnnualGeneralMeetInputDomainInterface {
|
||||
coopname: string;
|
||||
hash: string;
|
||||
member: string;
|
||||
ballot: VoteItemInputDomainInterface[];
|
||||
}
|
||||
|
||||
// DTO имплементирует доменный интерфейс напрямую
|
||||
@InputType('VoteOnAnnualGeneralMeetInput')
|
||||
export class VoteOnAnnualGeneralMeetInputDTO implements VoteOnAnnualGeneralMeetInputDomainInterface {
|
||||
@Field(() => String)
|
||||
@IsString()
|
||||
coopname!: string;
|
||||
// ... остальные поля
|
||||
}
|
||||
|
||||
// Адаптер преобразует доменный объект в инфраструктурный тип
|
||||
async vote(data: VoteOnAnnualGeneralMeetInputDomainInterface): Promise<TransactionResult> {
|
||||
// Преобразуем доменный объект в инфраструктурный тип
|
||||
const blockchainData: MeetContract.Actions.Vote.IInput = {
|
||||
coopname: data.coopname,
|
||||
hash: data.hash,
|
||||
// ... преобразование других полей
|
||||
};
|
||||
// ... отправка данных во внешнюю систему
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
description: SDK мутации и запросы для подключения на рабочем столе
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Бэкенд реализован здесь components/controller, sdk здесь components/sdk.
|
||||
|
||||
Пример создания Query к API через SDK:
|
||||
|
||||
``` ts
|
||||
async function loadBranches(data: IGetBranchesInput): Promise<IBranch[]> {
|
||||
const { [Queries.Branches.GetBranches.name]: output } = await client.Query(
|
||||
Queries.Branches.GetBranches.query,
|
||||
{
|
||||
variables: {
|
||||
data
|
||||
}
|
||||
}
|
||||
);
|
||||
return output;
|
||||
}
|
||||
```
|
||||
|
||||
Такие запросы обычно хранятся в папке Entities и вызываются из его store.
|
||||
|
||||
Пример мутации:
|
||||
|
||||
``` ts
|
||||
async function selectBranch(data: ISelectBranchInput): Promise<boolean>{
|
||||
const {[Mutations.Branches.SelectBranch.name]: result} = await client.Mutation(Mutations.Branches.SelectBranch.mutation, {variables: {
|
||||
data
|
||||
}})
|
||||
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
Мутации обычно храним в папке features.
|
||||
|
||||
Как можешь обратить внимание, все мутации и запросы строятся по одному шаблону. Вся необходимая информация уже есть в SDK.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
globs: **/desktop/**
|
||||
---
|
||||
# DESKTOP
|
||||
|
||||
- Пишем код фронтенда в архитектуре Feature Sliced Design (FSD).
|
||||
- Стиль шаблонов PUG на composite API.
|
||||
- Всегда создаём index.ts файлы для vue компонент.
|
||||
- Создавай индексные файлы для vue через export {default as NAME} from './where', а на уровне выше, если это необходимо, делай export * from './ui'
|
||||
- Всегда удаляем неиспользуемые импорты.
|
||||
- Не используем emit избыточно. Вместо них используем композабл функции моделей фич, если необходимо. Emit только в крайнем случае.
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
Файл [main.py](mdc:monocoop/monocoop/monocoop/components/docs/main.py) автоматически генерит ссылки на документацию SDK и GraphQL, которые формируются и публикуются автоматически. При создании документации к методам всегда применяй ссылки на SDK и GraphQL по форме:
|
||||
{{ get_sdk_doc("Mutations", "Accounts", "RegisterAccount") }} | {{ get_graphql_doc("Mutation.registerAccount") }}
|
||||
|
||||
### 🎯 Основные паттерны использования
|
||||
|
||||
**1. Стандартная структура для методов API:**
|
||||
```markdown
|
||||
## Название действия
|
||||
{{ get_sdk_doc("Namespace", "Module", "Method") }} | {{ get_graphql_doc("Type.methodName") }}
|
||||
|
||||
{{ get_typedoc_input("Namespace.Module.Method") }}
|
||||
|
||||
Результат:
|
||||
{{ get_typedoc_definition("Namespace.Module.Method", "IOutput") }}
|
||||
```
|
||||
|
||||
**2. Описание + пример (для сложных методов):**
|
||||
```markdown
|
||||
{{ get_typedoc_desc("Namespace.Module.Method") }}
|
||||
|
||||
{{ get_typedoc_input("Namespace.Module.Method") }}
|
||||
```
|
||||
|
||||
**3. Ссылки на типы данных:**
|
||||
```markdown
|
||||
У каждого аккаунта есть объект {{ get_graphql_definition("Account") }} в GraphQL-API
|
||||
```
|
||||
|
||||
### 🔧 Макросы и их применение
|
||||
|
||||
| Макрос | Назначение | Пример использования |
|
||||
|--------|------------|---------------------|
|
||||
| `get_sdk_doc` | Ссылка на SDK документацию | `{{ get_sdk_doc("Mutations", "Payments", "CreateDeposit") }}` |
|
||||
| `get_graphql_doc` | Ссылка на GraphQL операцию | `{{ get_graphql_doc("Query.getAccount") }}` |
|
||||
| `get_graphql_definition` | Ссылка на GraphQL тип | `{{ get_graphql_definition("Account") }}` |
|
||||
| `get_class_doc` | Ссылка на класс/метод SDK | `{{ get_class_doc("Document", "sign") }}` |
|
||||
| `get_typedoc_input` | **Полный пример** TypeScript вызова | `{{ get_typedoc_input("Mutations.Auth.Login") }}` |
|
||||
| `get_typedoc_definition` | **Структура интерфейса** | `{{ get_typedoc_definition("Mutations.Auth.Login", "IOutput") }}` |
|
||||
| `get_typedoc_desc` | Описание + примеры | `{{ get_typedoc_desc("Mutations.Auth.Login") }}` |
|
||||
| `get_typedoc_value` | Значение константы | `{{ get_typedoc_value("Constants.API_VERSION") }}` |
|
||||
|
||||
### 📝 Правила оформления
|
||||
|
||||
**ОБЯЗАТЕЛЬНО:**
|
||||
- Всегда используй `get_typedoc_input` для демонстрации **КАК вызывать** метод
|
||||
- Всегда используй `get_typedoc_definition` для показа **ЧТО возвращается**
|
||||
- Комбинируй SDK и GraphQL ссылки через ` | `: `{{ get_sdk_doc(...) }} | {{ get_graphql_doc(...) }}`
|
||||
|
||||
**ЖЕЛАТЕЛЬНО:**
|
||||
- Для сложных методов добавляй `get_typedoc_desc` в начало
|
||||
- Используй `get_graphql_definition` для ссылок на типы данных в тексте
|
||||
- Группируй связанные операции в одном разделе
|
||||
|
||||
**ФОРМАТ ССЫЛОК:**
|
||||
- SDK: `"Namespace", "Module", "Method"` (3 аргумента)
|
||||
- GraphQL: `"Type.methodName"` (точка между типом и методом)
|
||||
- Определения: просто имя типа `"TypeName"`
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Структура файлов шаблонов документов и алгоритм правки
|
||||
|
||||
## Основные директории и файлы
|
||||
|
||||
### 1. Определение типов и интерфейсов (cooptypes)
|
||||
**Путь:** `/cooptypes/src/cooperative/registry/[ID.DocumentName]/index.ts`
|
||||
|
||||
В этих файлах определяются:
|
||||
- Интерфейсы данных (`IAgendaMeet`, etc.)
|
||||
- Основная модель данных документа (`Model`)
|
||||
- HTML шаблон документа (`context`)
|
||||
- Переводы строк (`translations`)
|
||||
- Пример данных для тестирования (`exampleData`)
|
||||
|
||||
**Для изменения шаблона нужно править:**
|
||||
- Интерфейсы модели данных при изменении структуры
|
||||
- HTML в переменной `context` для изменения внешнего вида
|
||||
- Строки переводов в `translations.ru`
|
||||
|
||||
### 2. Схемы валидации (factory)
|
||||
**Путь:** `/factory/src/Schema/[SchemaName].ts`
|
||||
|
||||
Содержат:
|
||||
- JSON схемы для валидации данных
|
||||
- Определение обязательных полей
|
||||
|
||||
**При изменении структуры данных:**
|
||||
- Обновите соответствующую схему валидации
|
||||
- Обновите список обязательных полей в `required`
|
||||
|
||||
### 3. Фабрики документов (factory)
|
||||
**Путь:** `/factory/src/Actions/[ID.DocumentName].ts`
|
||||
|
||||
Обрабатывают:
|
||||
- Получение данных из разных источников
|
||||
- Сборку модели для шаблонизатора
|
||||
- Валидацию данных по схеме
|
||||
- Генерацию PDF
|
||||
|
||||
**При изменении логики сборки документа:**
|
||||
- Обновите метод `generateDocument()`
|
||||
- Добавьте новые источники данных
|
||||
|
||||
### 4. Шаблоны (factory)
|
||||
**Путь:** `/factory/src/Templates/[ID.DocumentName].ts`
|
||||
|
||||
Связывают:
|
||||
- Шаблон из cooptypes
|
||||
- Схему валидации
|
||||
- Метаданные документа
|
||||
|
||||
**После изменения в cooptypes:**
|
||||
- Убедитесь, что схема в Templates соответствует новой модели
|
||||
|
||||
### 5. Тесты (factory)
|
||||
**Путь:** `/factory/test/[category].test.ts`
|
||||
|
||||
Содержат:
|
||||
- Тестовые данные для генерации документов
|
||||
- Вызовы функции `testDocumentGeneration`
|
||||
|
||||
**После внесения изменений:**
|
||||
- Обновите тестовые данные в соответствии с новой структурой
|
||||
- Запустите тесты командой `pnpm test`
|
||||
|
||||
## Алгоритм внесения изменений
|
||||
|
||||
1. **Модификация типов**
|
||||
- Изменить файл в `/cooptypes/src/cooperative/registry/[ID.DocumentName]/index.ts`
|
||||
- Обновить интерфейсы, шаблон HTML и переводы
|
||||
|
||||
2. **Обновление схемы валидации**
|
||||
- Изменить соответствующую схему в `/factory/src/Schema/`
|
||||
- Убедиться, что обязательные поля совпадают с интерфейсом
|
||||
|
||||
3. **Компиляция библиотеки типов**
|
||||
- Выполнить `cd /cooptypes && pnpm build`
|
||||
|
||||
4. **Обновление тестовых данных**
|
||||
- Привести тестовые данные в соответствие с новой структурой
|
||||
|
||||
5. **Запуск тестов**
|
||||
- Выполнить `cd /factory && pnpm test [filename].test.ts`
|
||||
|
||||
## Особенности шаблонизации
|
||||
|
||||
- Используется Nunjucks для шаблонов
|
||||
- Поддерживаются конструкции: `{% if %}`, `{% for %}`, `{% trans %}`
|
||||
- Переменные вставляются через `{{ variable }}`
|
||||
- Переводы через `{% trans 'KEY' %}`
|
||||
@@ -0,0 +1,225 @@
|
||||
# Архитектура расширений в MonoCoop
|
||||
|
||||
## Основные принципы
|
||||
1. Расширения построены на модулях NestJS с использованием шаблона "Порты и адаптеры"
|
||||
2. Каждое расширение наследуется от `BaseExtModule` и реализует интерфейс `OnModuleInit`
|
||||
3. Расширения могут взаимодействовать с блокчейном через соответствующие порты
|
||||
4. Конфигурации расширений хранятся в БД и описываются с помощью Zod-схем
|
||||
5. Расширения регистрируются в глобальном реестре `AppRegistry`
|
||||
|
||||
## Структура расширения
|
||||
Минимальная структура расширения включает:
|
||||
- `XXX-extension.module.ts` - основной модуль расширения
|
||||
- `package.json` - информация о пакете
|
||||
- `README.md` - документация
|
||||
- `INSTALL.md` - инструкции по установке
|
||||
- `CHANGELOG.md` - история изменений
|
||||
|
||||
## Создание нового расширения
|
||||
1. Создайте директорию для расширения в `components/controller/src/extensions/`
|
||||
2. Создайте основной класс расширения, наследующийся от `BaseExtModule`
|
||||
3. Определите Zod-схему для конфигурации
|
||||
4. Реализуйте метод `initialize()`
|
||||
5. Зарегистрируйте расширение в `extensions.registry.ts`
|
||||
6. Добавьте расширение в список дефолтных приложений в `extension-domain.service.ts`
|
||||
|
||||
## Пример структуры модуля расширения
|
||||
```typescript
|
||||
// XXX-extension.module.ts
|
||||
export class XXXPlugin extends BaseExtModule {
|
||||
constructor(...) {
|
||||
super();
|
||||
}
|
||||
|
||||
name = 'xxx';
|
||||
plugin!: ExtensionDomainEntity<IConfig>;
|
||||
public configSchemas = Schema;
|
||||
|
||||
async initialize() {
|
||||
// Инициализация расширения
|
||||
// Настройка cron-задач
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
providers: [XXXPlugin],
|
||||
})
|
||||
export class XXXPluginModule {
|
||||
constructor(private readonly xxxPlugin: XXXPlugin) {}
|
||||
|
||||
async initialize() {
|
||||
await this.xxxPlugin.initialize();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Управление отображением конфигурации
|
||||
Zod-схемы используются для автоматического отображения формы настроек в интерфейсе пользователя.
|
||||
Через интерфейс `DeserializedDescriptionOfExtension` из `components/controller/src/types/shared/extension.types.ts`
|
||||
можно управлять отображением полей формы:
|
||||
|
||||
```typescript
|
||||
export const Schema = z.object({
|
||||
// Базовое поле с меткой
|
||||
simpleField: z.string().describe(
|
||||
describeField({
|
||||
label: 'Название поля',
|
||||
note: 'Подсказка под полем'
|
||||
})
|
||||
),
|
||||
|
||||
// Скрытое поле для служебного использования
|
||||
hiddenField: z.string().describe(
|
||||
describeField({
|
||||
label: 'Скрытое поле',
|
||||
visible: false
|
||||
})
|
||||
),
|
||||
|
||||
// Поле с проверкой значения
|
||||
validatedField: z.number().describe(
|
||||
describeField({
|
||||
label: 'Поле с валидацией',
|
||||
rules: ['val >= 5', 'val <= 100'],
|
||||
})
|
||||
),
|
||||
|
||||
// Форматированное поле с префиксом и суффиксом
|
||||
formattedField: z.number().describe(
|
||||
describeField({
|
||||
label: 'Форматированное поле',
|
||||
prepend: '$',
|
||||
append: 'USD',
|
||||
})
|
||||
),
|
||||
|
||||
// Многострочное текстовое поле
|
||||
multilineField: z.string().describe(
|
||||
describeField({
|
||||
label: 'Многострочное поле',
|
||||
maxRows: 5,
|
||||
minLength: 10,
|
||||
maxLength: 1000
|
||||
})
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
Доступные поля для управления отображением:
|
||||
- `label` - название поля (обязательное)
|
||||
- `note` - пояснение или подсказка
|
||||
- `visible` - видимость поля (по умолчанию true)
|
||||
- `rules` - правила валидации в виде строковых выражений
|
||||
- `mask` - маска для ввода
|
||||
- `fillMask` - автозаполнение маски
|
||||
- `minLength` / `maxLength` - ограничения длины для текстовых полей
|
||||
- `maxRows` - количество строк для многострочного ввода
|
||||
- `append` / `prepend` - текст до/после значения поля
|
||||
|
||||
## Взаимодействие с блокчейном
|
||||
Для взаимодействия с блокчейном:
|
||||
1. Определите порт в доменном слое (например, `SovietBlockchainPort`)
|
||||
2. Инжектируйте порт в конструкторе расширения через DI
|
||||
3. Используйте методы порта для взаимодействия с блокчейном
|
||||
|
||||
```typescript
|
||||
@Inject(SOVIET_BLOCKCHAIN_PORT) private readonly sovietBlockchainPort: SovietBlockchainPort
|
||||
// ...
|
||||
const decisions = await this.sovietBlockchainPort.getDecisions(coopname);
|
||||
```
|
||||
|
||||
## Настройка планировщика задач
|
||||
Расширения могут использовать cron-задачи для периодического выполнения операций:
|
||||
|
||||
```typescript
|
||||
import cron from 'node-cron';
|
||||
|
||||
// Регистрация cron-задачи (каждые N минут)
|
||||
const cronExpression = `*/${this.plugin.config.checkInterval} * * * *`;
|
||||
cron.schedule(cronExpression, () => {
|
||||
this.logger.info('Запуск запланированной задачи');
|
||||
this.runTask();
|
||||
});
|
||||
```
|
||||
|
||||
## Работа с конфигурацией
|
||||
1. Определите Zod-схему для конфигурации
|
||||
2. Используйте `describeField` для добавления UI-метаданных к полям
|
||||
3. Получайте и обновляйте конфигурацию через репозиторий `extensionRepository`
|
||||
|
||||
```typescript
|
||||
export const Schema = z.object({
|
||||
checkInterval: z.number().describe(
|
||||
describeField({
|
||||
label: 'Интервал проверки (в минутах)',
|
||||
note: 'Минимум: 5 минут',
|
||||
rules: ['val >= 5'],
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
// Обновление конфигурации
|
||||
this.plugin.config.lastCheckDate = new Date().toISOString();
|
||||
await this.extensionRepository.update(this.plugin);
|
||||
```
|
||||
|
||||
## Логирование действий
|
||||
Расширения должны логировать свои действия:
|
||||
1. Используйте `WinstonLoggerService` для системного логирования
|
||||
2. Используйте `LogExtensionDomainRepository` для хранения логов в БД
|
||||
|
||||
```typescript
|
||||
// Системное логирование
|
||||
this.logger.info(`Выполнение операции для ${id}`);
|
||||
|
||||
// Сохранение лога в БД
|
||||
await this.logExtensionRepository.push(this.name, {
|
||||
type: 'operation',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { ... },
|
||||
});
|
||||
```
|
||||
|
||||
## Регистрация расширения
|
||||
После создания расширения, добавьте его в `extensions.registry.ts`:
|
||||
|
||||
```typescript
|
||||
export const AppRegistry: INamedExtension = {
|
||||
myExtension: {
|
||||
is_builtin: false,
|
||||
is_internal: true,
|
||||
is_available: true,
|
||||
is_desktop: false,
|
||||
title: 'Моё расширение',
|
||||
description: 'Описание функциональности.',
|
||||
image: 'https://example.com/image.png',
|
||||
class: MyExtensionPluginModule,
|
||||
schema: MyExtensionSchema,
|
||||
tags: ['тег1', 'тег2'],
|
||||
readme: getReadmeContent('./myExtension'),
|
||||
instructions: getInstructionsContent('./myExtension'),
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Дефолтные настройки
|
||||
Добавьте расширение в список дефолтных приложений в `extension-domain.service.ts`:
|
||||
|
||||
```typescript
|
||||
getDefaultApps(): Partial<ExtensionDomainEntity>[] {
|
||||
return [
|
||||
// ...
|
||||
{
|
||||
name: 'myExtension',
|
||||
enabled: true,
|
||||
config: {
|
||||
// Дефолтные значения конфигурации
|
||||
parameter1: 'value1',
|
||||
parameter2: 42,
|
||||
},
|
||||
},
|
||||
// ...
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
description:
|
||||
globs: **/controller/src/extensions/**
|
||||
alwaysApply: false
|
||||
---
|
||||
# Архитектура расширений в MonoCoop
|
||||
|
||||
## Основные принципы
|
||||
1. Расширения построены на модулях NestJS с использованием шаблона "Порты и адаптеры"
|
||||
2. Каждое расширение наследуется от `BaseExtModule` и реализует интерфейс `OnModuleInit`
|
||||
3. Расширения могут взаимодействовать с блокчейном через соответствующие порты
|
||||
4. Конфигурации расширений хранятся в БД и описываются с помощью Zod-схем
|
||||
5. Расширения регистрируются в глобальном реестре `AppRegistry`
|
||||
|
||||
## Структура расширения
|
||||
Минимальная структура расширения включает:
|
||||
- `XXX-extension.module.ts` - основной модуль расширения
|
||||
- `package.json` - информация о пакете
|
||||
- `README.md` - документация
|
||||
- `INSTALL.md` - инструкции по установке
|
||||
- `CHANGELOG.md` - история изменений
|
||||
|
||||
## Создание нового расширения
|
||||
1. Создайте директорию для расширения в `components/controller/src/extensions/`
|
||||
2. Создайте основной класс расширения, наследующийся от `BaseExtModule`
|
||||
3. Определите Zod-схему для конфигурации
|
||||
4. Реализуйте метод `initialize()`
|
||||
5. Зарегистрируйте расширение в `extensions.registry.ts`
|
||||
6. Добавьте расширение в список дефолтных приложений в `extension-domain.service.ts`
|
||||
|
||||
## Пример структуры модуля расширения
|
||||
```typescript
|
||||
// XXX-extension.module.ts
|
||||
export class XXXPlugin extends BaseExtModule {
|
||||
constructor(...) {
|
||||
super();
|
||||
}
|
||||
|
||||
name = 'xxx';
|
||||
plugin!: ExtensionDomainEntity<IConfig>;
|
||||
public configSchemas = Schema;
|
||||
|
||||
async initialize() {
|
||||
// Инициализация расширения
|
||||
// Настройка cron-задач
|
||||
}
|
||||
|
||||
// Дополнительные методы расширения
|
||||
}
|
||||
|
||||
@Module({
|
||||
providers: [XXXPlugin],
|
||||
})
|
||||
export class XXXPluginModule {
|
||||
constructor(private readonly xxxPlugin: XXXPlugin) {}
|
||||
|
||||
async initialize() {
|
||||
await this.xxxPlugin.initialize();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Взаимодействие с блокчейном
|
||||
Для взаимодействия с блокчейном:
|
||||
1. Определите порт в доменном слое (например, `SovietBlockchainPort`)
|
||||
2. Инжектируйте порт в конструкторе расширения через DI
|
||||
3. Используйте методы порта для взаимодействия с блокчейном
|
||||
|
||||
```typescript
|
||||
@Inject(SOVIET_BLOCKCHAIN_PORT) private readonly sovietBlockchainPort: SovietBlockchainPort
|
||||
// ...
|
||||
const decisions = await this.sovietBlockchainPort.getDecisions(coopname);
|
||||
```
|
||||
|
||||
## Настройка планировщика задач
|
||||
Расширения могут использовать cron-задачи для периодического выполнения операций:
|
||||
|
||||
```typescript
|
||||
import cron from 'node-cron';
|
||||
|
||||
// Регистрация cron-задачи (каждые N минут)
|
||||
const cronExpression = `*/${this.plugin.config.checkInterval} * * * *`;
|
||||
cron.schedule(cronExpression, () => {
|
||||
this.logger.info('Запуск запланированной задачи');
|
||||
this.runTask();
|
||||
});
|
||||
```
|
||||
|
||||
## Работа с конфигурацией
|
||||
1. Определите Zod-схему для конфигурации
|
||||
2. Используйте `describeField` для добавления UI-метаданных к полям
|
||||
3. Получайте и обновляйте конфигурацию через репозиторий `extensionRepository`
|
||||
|
||||
```typescript
|
||||
export const Schema = z.object({
|
||||
checkInterval: z.number().describe(
|
||||
describeField({
|
||||
label: 'Интервал проверки (в минутах)',
|
||||
note: 'Минимум: 5 минут',
|
||||
rules: ['val >= 5'],
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
// Обновление конфигурации
|
||||
this.plugin.config.lastCheckDate = new Date().toISOString();
|
||||
await this.extensionRepository.update(this.plugin);
|
||||
```
|
||||
|
||||
## Логирование действий
|
||||
Расширения должны логировать свои действия:
|
||||
1. Используйте `WinstonLoggerService` для системного логирования
|
||||
2. Используйте `LogExtensionDomainRepository` для хранения логов в БД
|
||||
|
||||
```typescript
|
||||
// Системное логирование
|
||||
this.logger.info(`Выполнение операции для ${id}`);
|
||||
|
||||
// Сохранение лога в БД
|
||||
await this.logExtensionRepository.push(this.name, {
|
||||
type: 'operation',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { ... },
|
||||
});
|
||||
```
|
||||
|
||||
## Регистрация расширения
|
||||
После создания расширения, добавьте его в `extensions.registry.ts`:
|
||||
|
||||
```typescript
|
||||
export const AppRegistry: INamedExtension = {
|
||||
myExtension: {
|
||||
is_builtin: false,
|
||||
is_internal: true,
|
||||
is_available: true,
|
||||
is_desktop: false,
|
||||
title: 'Моё расширение',
|
||||
description: 'Описание функциональности.',
|
||||
image: 'https://example.com/image.png',
|
||||
class: MyExtensionPluginModule,
|
||||
schema: MyExtensionSchema,
|
||||
tags: ['тег1', 'тег2'],
|
||||
readme: getReadmeContent('./myExtension'),
|
||||
instructions: getInstructionsContent('./myExtension'),
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Дефолтные настройки
|
||||
Добавьте расширение в список дефолтных приложений в `extension-domain.service.ts`:
|
||||
|
||||
```typescript
|
||||
getDefaultApps(): Partial<ExtensionDomainEntity>[] {
|
||||
return [
|
||||
// ...
|
||||
{
|
||||
name: 'myExtension',
|
||||
enabled: true,
|
||||
config: {
|
||||
// Дефолтные значения конфигурации
|
||||
parameter1: 'value1',
|
||||
parameter2: 42,
|
||||
},
|
||||
},
|
||||
// ...
|
||||
];
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,261 @@
|
||||
---
|
||||
description:
|
||||
globs: src/**/*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
# Фабрика Документов Кооперативов
|
||||
|
||||
## Общая Архитектура
|
||||
|
||||
Фабрика документов — это система генерации PDF документов для кооперативов, построенная на TypeScript с использованием MongoDB для хранения данных. Система состоит из трех основных частей:
|
||||
|
||||
1. **registry/** — JSON-шаблоны документов (статические данные)
|
||||
2. **factory/** — основная фабрика с логикой генерации
|
||||
3. **cooptypes/** — типы данных и интерфейсы
|
||||
|
||||
## Структура Registry
|
||||
|
||||
В корневой папке `registry/` находятся JSON файлы с номерными названиями, представляющие шаблоны документов:
|
||||
|
||||
### Основные документы:
|
||||
- `1.walletProgramAgreement.json` — соглашение о кошельке
|
||||
- `2.regulationElectronicSignature.json` — регламент электронной подписи
|
||||
- `3.privacyPolicy.json` — политика конфиденциальности
|
||||
- `4.userAgreement.json` — пользовательское соглашение
|
||||
- `50.CoopenomicsAgreement.json` — соглашение с партнерами
|
||||
- `100.participantApplication.json` — заявление участника
|
||||
- `101.selectBranchStatement.json` — заявление о выборе филиала
|
||||
|
||||
### Документы общих собраний (300-304):
|
||||
- `300.annualGeneralMeetingAgenda.json` — предложение повестки дня
|
||||
- `301.annualGeneralMeetingSovietDecision.json` — протокол заседания совета
|
||||
- `302.annualGeneralMeetingNotification.json` — уведомление о собрании
|
||||
- `303.annualGeneralMeetingVotingBallot.json` — бюллетень для голосования
|
||||
- `304.annualGeneralMeetingDecision.json` — протокол общего собрания
|
||||
|
||||
### Инвестиционные документы (1000+):
|
||||
- `1000.investAgreement.json` — инвестиционное соглашение
|
||||
- `1001.investByResultStatement.json` — заявление о зачете по результатам
|
||||
- `1002.investByResultAct.json` — акт зачета по результатам
|
||||
- `1005.investByMoneyStatement.json` — заявление о зачете денежных средств
|
||||
|
||||
### Структура JSON-шаблона:
|
||||
```json
|
||||
{
|
||||
"context": "<div>...HTML шаблон с переменными...</div>",
|
||||
"model": {...данные для примера...},
|
||||
"translation": {...переводы ключей...},
|
||||
"object_model": {...схема объектной модели...}
|
||||
}
|
||||
```
|
||||
|
||||
## Фабрика (factory/)
|
||||
|
||||
### Основные компоненты:
|
||||
|
||||
#### src/index.ts — Главный класс Generator
|
||||
```typescript
|
||||
export class Generator implements IGenerator {
|
||||
// Хранилище фабрик для каждого типа документа
|
||||
factories: { [K in Numbers]: DocFactory<IGenerate> }
|
||||
|
||||
// MongoDB коннектор
|
||||
public storage: MongoDBConnector
|
||||
|
||||
// Основной метод генерации
|
||||
async generate(data: IGenerate, options?: IGenerationOptions): Promise<IGeneratedDocument>
|
||||
}
|
||||
```
|
||||
|
||||
#### Архитектура Factory Pattern:
|
||||
- Базовый класс `DocFactory<T>` в `src/Factory/index.ts`
|
||||
- Каждый документ имеет свою фабрику в `src/Actions/`
|
||||
- Фабрики наследуются от `DocFactory` и реализуют метод `generateDocument()`
|
||||
|
||||
### Сервисы:
|
||||
|
||||
#### Services/Generator/ — PDF генерация
|
||||
- `PDFService` — конвертирует HTML в PDF через WeasyPrint
|
||||
- Использует шрифт Arial (base64)
|
||||
- Добавляет метаданные в PDF
|
||||
- Вычисляет SHA-256 хеш документа
|
||||
|
||||
#### Services/Templator/ — Шаблонизация
|
||||
- Основан на Nunjucks
|
||||
- Поддерживает кастомное расширение `{% trans %}` для переводов
|
||||
- Рендерит HTML из шаблона с подстановкой переменных
|
||||
|
||||
#### Services/Validator/ — Валидация
|
||||
- Использует AJV для JSON Schema валидации
|
||||
- Поддерживает кастомные форматы (телефон)
|
||||
- Локализация ошибок на русском языке
|
||||
|
||||
#### Services/Databazor/ — База данных
|
||||
- `MongoDBConnector` — работа с MongoDB
|
||||
- `DataService` — абстракция над данными
|
||||
- Коллекции: `deltas`, `actions`, `documents`, и другие
|
||||
|
||||
### Модели данных (src/Models/):
|
||||
|
||||
#### Основные типы пользователей:
|
||||
- `Individual` — физические лица (ФИО, паспорт, адрес)
|
||||
- `Organization` — организации (ИНН, ОГРН, представитель)
|
||||
- `Entrepreneur` — ИП (ФИО + ИНН/ОГРН)
|
||||
|
||||
#### Кооперативные данные:
|
||||
- `Cooperative` — данные кооператива
|
||||
- `PaymentMethod` — платежные методы
|
||||
- `Vars` — переменные кооператива
|
||||
- `Project` — проекты
|
||||
|
||||
### Система Action-ов:
|
||||
|
||||
Каждый документ имеет Action класс в `src/Actions/` с методом `generateDocument()`:
|
||||
|
||||
1. **Получение шаблона** — из локального Registry или MongoDB
|
||||
2. **Сбор данных** — пользователь, кооператив, переменные, специфичные данные
|
||||
3. **Валидация** — проверка по JSON схеме
|
||||
4. **Рендеринг** — HTML из шаблона + данные
|
||||
5. **PDF генерация** — HTML → PDF с метаданными
|
||||
6. **Сохранение** — в MongoDB (если не skip_save)
|
||||
|
||||
## Система типов (cooptypes/)
|
||||
|
||||
### cooperative/registry/ — Типы документов
|
||||
Каждый документ имеет папку с интерфейсами:
|
||||
- `Action` — входные данные для генерации
|
||||
- `Model` — модель данных для шаблона
|
||||
- `Template` — структура шаблона
|
||||
|
||||
### contracts/ — Блокчейн контракты
|
||||
- `registrator/` — регистрация кооперативов
|
||||
- `soviet/` — управление советом
|
||||
- `meet/` — общие собрания
|
||||
- `wallet/`, `capital/`, `fund/` — финансовые операции
|
||||
|
||||
## База данных MongoDB
|
||||
|
||||
### Основные коллекции:
|
||||
|
||||
#### deltas — Состояние блокчейна
|
||||
```javascript
|
||||
{
|
||||
block_num: number,
|
||||
present: boolean,
|
||||
code: string, // название контракта
|
||||
scope: string, // область действия
|
||||
table: string, // имя таблицы
|
||||
primary_key: string,
|
||||
value: {...} // данные записи
|
||||
}
|
||||
```
|
||||
|
||||
#### actions — Действия блокчейна
|
||||
```javascript
|
||||
{
|
||||
block_num: number,
|
||||
account: string,
|
||||
name: string, // имя действия
|
||||
receiver: string,
|
||||
data: {...} // данные действия
|
||||
}
|
||||
```
|
||||
|
||||
#### documents — Сгенерированные документы
|
||||
```javascript
|
||||
{
|
||||
hash: string, // SHA-256 хеш
|
||||
binary: Uint8Array, // PDF данные
|
||||
html: string, // HTML исходник
|
||||
meta: { // метаданные
|
||||
title: string,
|
||||
created_at: string,
|
||||
lang: string
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Таблицы данных:
|
||||
- `coops` — кооперативы
|
||||
- `meets` — собрания
|
||||
- `questions` — вопросы собраний
|
||||
- `decisions` — решения совета
|
||||
- `individuals` — физические лица
|
||||
- `organizations` — организации
|
||||
- `entrepreneurs` — ИП
|
||||
- `paymentMethods` — платежные методы
|
||||
- `vars` — переменные кооперативов
|
||||
|
||||
## Генерация документов
|
||||
|
||||
### Процесс генерации:
|
||||
1. **Вызов** `generator.generate(action, options)`
|
||||
2. **Поиск фабрики** по `registry_id`
|
||||
3. **Загрузка шаблона** (локально или из БД)
|
||||
4. **Сбор данных** из MongoDB по `coopname`, `username`, `block_num`
|
||||
5. **Создание модели** — объединение всех данных
|
||||
6. **Валидация** модели по JSON схеме
|
||||
7. **Рендеринг HTML** через Nunjucks
|
||||
8. **Генерация PDF** через WeasyPrint
|
||||
9. **Добавление метаданных** в PDF
|
||||
10. **Вычисление хеша** SHA-256
|
||||
11. **Сохранение** в MongoDB
|
||||
|
||||
### Пример использования:
|
||||
```typescript
|
||||
const generator = new Generator()
|
||||
await generator.connect(mongoUri)
|
||||
|
||||
const document = await generator.generate({
|
||||
registry_id: '300',
|
||||
coopname: 'voskhod',
|
||||
username: 'ant',
|
||||
block_num: 0,
|
||||
meet: {...},
|
||||
questions: [...]
|
||||
})
|
||||
```
|
||||
|
||||
## Особенности реализации
|
||||
|
||||
### Шаблонизация:
|
||||
- HTML шаблоны с CSS стилями
|
||||
- Переменные в формате `{{variable.field}}`
|
||||
- Условная логика `{% if condition %}`
|
||||
- Циклы `{% for item in array %}`
|
||||
- Переводы `{% trans 'KEY', var1, var2 %}`
|
||||
|
||||
### Подписи:
|
||||
- Цифровые подписи вместо физических
|
||||
- Текст "Подписано электронной подписью"
|
||||
- Убраны подчеркивания для подписей
|
||||
|
||||
### Типы собраний:
|
||||
- `regular` — очередное
|
||||
- `extraordinary` — внеочередное
|
||||
- Условная логика в шаблонах
|
||||
|
||||
### Филиалы:
|
||||
- `coop.is_branched` — проверка на наличие филиалов
|
||||
- "пайщиков" vs "уполномоченных" в зависимости от типа
|
||||
|
||||
### Форматирование дат:
|
||||
- Формат: "г. Москва, 15 декабря 2024 г."
|
||||
- Без кавычек вокруг дат
|
||||
- Запятая после города
|
||||
|
||||
## Тестирование
|
||||
|
||||
### test/utils/index.ts — Тестовые утилиты:
|
||||
- `preLoading()` — инициализация тестовых данных
|
||||
- Создание кооператива, пользователей, платежных методов
|
||||
- Настройка данных собраний и решений
|
||||
- Очистка временных файлов
|
||||
|
||||
### Тестовые данные:
|
||||
- Кооператив "ВОСХОД"
|
||||
- Пользователи: ant, individual, entrepreneur
|
||||
- Организации: voskhod, branch, exampleorg
|
||||
- Собрания с вопросами и решениями
|
||||
|
||||
Фабрика поддерживает полный цикл создания документов кооператива от заявлений до протоколов собраний с возможностью кастомизации под разные типы кооперативов и требования.
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
description:
|
||||
globs: components/sdk/**
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Руководство по работе с GraphQL Zeus в SDK
|
||||
|
||||
## Основной процесс
|
||||
|
||||
1. **Анализ DTO бэкенда**:
|
||||
- Изучите структуру DTO классов (`@ObjectType`) в бэкенде
|
||||
- Обратите внимание на имена типов в декораторах `@ObjectType('ИмяТипа')`
|
||||
- Отметьте поля, связи и вложенные объекты
|
||||
|
||||
2. **Создание селекторов**:
|
||||
- Для каждого DTO создайте соответствующий селектор с именем `raw<ИмяТипа>Selector`
|
||||
- Селектор - это объект, где ключи соответствуют полям DTO, а значения - `true`
|
||||
- Для вложенных объектов используйте вложенные селекторы: `field: nestedSelector`
|
||||
- Для списков используйте один селектор без массива: `items: itemSelector`
|
||||
|
||||
3. **Валидация селекторов**:
|
||||
- Для каждого селектора создайте проверку типа:
|
||||
```typescript
|
||||
const _validate: MakeAllFieldsRequired<ValueTypes['ТочноеИмяГрафКьЭлТипа']> = rawSelector
|
||||
```
|
||||
- Имя типа должно точно совпадать с именем в декораторе `@ObjectType`
|
||||
|
||||
4. **Экспорт селекторов**:
|
||||
- Создайте финальный селектор с помощью функции `Selector`:
|
||||
```typescript
|
||||
export const typeSelector = Selector('ТочноеИмяГрафКьЭлТипа')(rawTypeSelector)
|
||||
```
|
||||
- Экспортируйте сырой селектор для переиспользования
|
||||
- Экспортируйте тип модели: `export type modelType = ModelTypes['ТочноеИмяГрафКьЭлТипа']`
|
||||
|
||||
5. **Создание запросов/мутаций**:
|
||||
- Используйте селекторы в запросах и мутациях:
|
||||
```typescript
|
||||
export const query = Selector('Query')({
|
||||
queryName: [{ data: $('data', 'ТочноеИмяВходногоТипа!') }, exportedSelector]
|
||||
})
|
||||
```
|
||||
- Для параметров используйте оператор `$` с точным именем входного типа
|
||||
- Создайте интерфейс входных данных:
|
||||
```typescript
|
||||
export interface IInput {
|
||||
data: ModelTypes['ТочноеИмяВходногоТипа']
|
||||
}
|
||||
```
|
||||
|
||||
## Особенности работы
|
||||
|
||||
- **Документы**: всегда сохраняйте структуру `{ hash, signatures, rawDocument }`
|
||||
- **Сложные DTO**: разбивайте на атомарные селекторы и комбинируйте их
|
||||
- **Типы в Zeus**: часто отличаются от имен классов в бэкенде, всегда проверяйте в `schema.gql`
|
||||
- **Массивы**: Zeus автоматически обрабатывает массивы, не используйте `[selector]`
|
||||
|
||||
Это руководство поможет правильно структурировать работу с SDK и избежать типичных ошибок при работе с Zeus.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
globs: notifications/src/workflows/**/*.ts
|
||||
---
|
||||
# Правила валидации воркфлоу
|
||||
|
||||
## 🚫 ID воркфлоу
|
||||
- **Длина ID не должна превышать 32 символа**
|
||||
- Используйте описательные, но короткие идентификаторы малыми латинскими буквами и тире.
|
||||
|
||||
## 🚫 Условия в шаблонах
|
||||
- **Запрещено использовать JavaScript выражения в шаблонах Novu**
|
||||
- Нельзя использовать:
|
||||
- Тернарные операторы: `{{condition ? "text1" : "text2"}}`
|
||||
- Логические операторы: `{{field && "text"}}`
|
||||
- Любые другие JS конструкции
|
||||
|
||||
## ✅ Рекомендации
|
||||
- Добавляйте текстовые поля в payload для условной логики
|
||||
- Вычисляйте значения на стороне сервера перед отправкой уведомления
|
||||
- Используйте только простые переменные: `{{payload.fieldName}}`
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
name: Build Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Debug info
|
||||
run: |
|
||||
echo "Текущая ветка:"
|
||||
git branch --show-current
|
||||
echo "Последние коммиты:"
|
||||
git log -n 3 --oneline
|
||||
echo "Проверяем файлы в директории components/desktop/src-ssr:"
|
||||
ls -la components/desktop/src-ssr/ || echo "Директория не найдена!"
|
||||
echo "Проверяем файлы в middlewares:"
|
||||
ls -la components/desktop/src-ssr/middlewares/ || echo "Директория middlewares не найдена!"
|
||||
|
||||
- name: Set docker tags
|
||||
run: |
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||
echo "DOCKER_TAG=$TAG_NAME" >> $GITHUB_ENV
|
||||
|
||||
# Проверяем, является ли тег продакшн-тегом (не содержит alpha, beta, rc и т.д.)
|
||||
if [[ ! $TAG_NAME =~ -(alpha|beta|rc|test) ]]; then
|
||||
echo "IS_PRODUCTION_TAG=true" >> $GITHUB_ENV
|
||||
echo "Это продакшн тег, будем добавлять latest"
|
||||
else
|
||||
echo "IS_PRODUCTION_TAG=false" >> $GITHUB_ENV
|
||||
echo "Это не продакшн тег, latest не добавляем"
|
||||
fi
|
||||
else
|
||||
echo "DOCKER_TAG=latest" >> $GITHUB_ENV
|
||||
echo "IS_PRODUCTION_TAG=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Сначала собираем базовый образ с runtime
|
||||
- name: Build base image
|
||||
run: |
|
||||
docker build --target runtime -t dicoop/mono-base:${{ env.DOCKER_TAG }} .
|
||||
docker push dicoop/mono-base:${{ env.DOCKER_TAG }}
|
||||
|
||||
# Если это продакшн тег, добавляем latest
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/mono-base:${{ env.DOCKER_TAG }} dicoop/mono-base:latest
|
||||
docker push dicoop/mono-base:latest
|
||||
fi
|
||||
|
||||
# Создаем сервисные образы на основе базового
|
||||
- name: Build desktop image
|
||||
run: |
|
||||
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.desktop
|
||||
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/desktop\", \"run\", \"start\"]" >> Dockerfile.desktop
|
||||
docker build -t dicoop/desktop:${{ env.DOCKER_TAG }} -f Dockerfile.desktop .
|
||||
docker push dicoop/desktop:${{ env.DOCKER_TAG }}
|
||||
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/desktop:${{ env.DOCKER_TAG }} dicoop/desktop:latest
|
||||
docker push dicoop/desktop:latest
|
||||
fi
|
||||
|
||||
- name: Build controller image
|
||||
run: |
|
||||
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.coopback
|
||||
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/controller\", \"run\", \"start\"]" >> Dockerfile.coopback
|
||||
docker build -t dicoop/coopback:${{ env.DOCKER_TAG }} -f Dockerfile.coopback .
|
||||
docker push dicoop/coopback:${{ env.DOCKER_TAG }}
|
||||
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/coopback:${{ env.DOCKER_TAG }} dicoop/coopback:latest
|
||||
docker push dicoop/coopback:latest
|
||||
fi
|
||||
|
||||
- name: Build parser image
|
||||
run: |
|
||||
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.cooparser
|
||||
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/parser\", \"run\", \"start\"]" >> Dockerfile.cooparser
|
||||
docker build -t dicoop/cooparser:${{ env.DOCKER_TAG }} -f Dockerfile.cooparser .
|
||||
docker push dicoop/cooparser:${{ env.DOCKER_TAG }}
|
||||
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/cooparser:${{ env.DOCKER_TAG }} dicoop/cooparser:latest
|
||||
docker push dicoop/cooparser:latest
|
||||
fi
|
||||
|
||||
- name: Build notificator image
|
||||
run: |
|
||||
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.notificator
|
||||
echo "CMD [\"pnpm\", \"-F\", \"coop-notificator\", \"run\", \"start\"]" >> Dockerfile.notificator
|
||||
docker build -t dicoop/notificator:${{ env.DOCKER_TAG }} -f Dockerfile.notificator .
|
||||
docker push dicoop/notificator:${{ env.DOCKER_TAG }}
|
||||
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/notificator:${{ env.DOCKER_TAG }} dicoop/notificator:latest
|
||||
docker push dicoop/notificator:latest
|
||||
fi
|
||||
|
||||
- name: Build notifications image
|
||||
run: |
|
||||
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.notifications
|
||||
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/notifications\", \"run\", \"sync\"]" >> Dockerfile.notifications
|
||||
docker build -t dicoop/notifications:${{ env.DOCKER_TAG }} -f Dockerfile.notifications .
|
||||
docker push dicoop/notifications:${{ env.DOCKER_TAG }}
|
||||
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/notifications:${{ env.DOCKER_TAG }} dicoop/notifications:latest
|
||||
docker push dicoop/notifications:latest
|
||||
fi
|
||||
|
||||
# Отправка хука для деплоя
|
||||
- name: Trigger deployment webhook
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
if [[ $GITHUB_REF == refs/tags/*alpha* ]]; then
|
||||
# Хук для тестнета (alpha теги)
|
||||
curl -X POST ${{ vars.TESTNET_WEBHOOK_URL }} \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '${{ env.DOCKER_TAG }}'
|
||||
elif [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
# Хук для продакшена (остальные теги)
|
||||
curl -X POST ${{ vars.PRODUCTION_WEBHOOK_URL }} \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '${{ env.DOCKER_TAG }}'
|
||||
fi
|
||||
|
||||
# Уведомление в Telegram об успехе
|
||||
- name: Telegram notify success
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
ADDITIONAL_INFO=" (с тегом latest)"
|
||||
else
|
||||
ADDITIONAL_INFO=""
|
||||
fi
|
||||
|
||||
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
|
||||
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
|
||||
-d text="✅ [GITHUB MONO] Успешная сборка контейнеров: $GITHUB_REPOSITORY ($GITHUB_REF) [${{ env.DOCKER_TAG }}]$ADDITIONAL_INFO"
|
||||
|
||||
# Уведомление в Telegram об ошибке
|
||||
- name: Telegram notify failure
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
|
||||
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
|
||||
-d text="❌ [GITHUB MONO] Ошибка при сборке контейнеров: $GITHUB_REPOSITORY ($GITHUB_REF) [${{ env.DOCKER_TAG }}]"
|
||||
@@ -0,0 +1,18 @@
|
||||
# .github/workflows/trigger-coopenomics.yml
|
||||
name: Trigger Contracts Docs Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, testnet, main, capital] # или когда нужно триггерить
|
||||
|
||||
jobs:
|
||||
trigger-coopenomics:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Coopenomics deployment
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.COOPENOMICS_PAT }}
|
||||
repository: coopenomics/coopenomics # укажи правильный owner/repo
|
||||
event-type: deploy_from_mono
|
||||
client-payload: '{"repository": "${{ github.repository }}", "sha": "${{ github.sha }}", "ref": "${{ github.ref }}", "actor": "${{ github.actor }}"}'
|
||||
@@ -0,0 +1,114 @@
|
||||
name: Publish Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- testnet
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
build-and-publish-docs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
|
||||
- name: Install Python requirements
|
||||
run: |
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install mkdocs-material mkdocs-macros-plugin mkdocs-section-index pymdown-extensions
|
||||
working-directory: ./components/docs
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: pnpm install
|
||||
working-directory: ./components/docs
|
||||
|
||||
- name: Patch spectaql-config.yml for CI
|
||||
run: |
|
||||
sed -i.bak "0,/url:.*/s|url:.*|url: 'https://testnet.coopenomics.world/backend/v1/graphql'|" spectaql-config.yml
|
||||
working-directory: ./components/controller
|
||||
|
||||
- name: Show patched spectaql-config.yml
|
||||
run: cat spectaql-config.yml
|
||||
working-directory: ./components/controller
|
||||
|
||||
- name: Build cooptypes
|
||||
run: pnpm run build
|
||||
working-directory: ./components/cooptypes
|
||||
|
||||
- name: Generate controller docs
|
||||
run: pnpm run docs
|
||||
working-directory: ./components/controller
|
||||
|
||||
- name: Copy controller docs
|
||||
run: |
|
||||
mkdir -p ./components/docs/docs/graphql
|
||||
cp -r ./components/controller/docs/* ./components/docs/docs/graphql/
|
||||
|
||||
- name: Generate sdk docs
|
||||
run: pnpm run docs
|
||||
working-directory: ./components/sdk
|
||||
|
||||
- name: Copy sdk docs
|
||||
run: |
|
||||
mkdir -p ./components/docs/docs/sdk
|
||||
cp -r ./components/sdk/docs/* ./components/docs/docs/sdk/
|
||||
|
||||
- name: Generate cooptypes docs
|
||||
run: pnpm run docs
|
||||
working-directory: ./components/cooptypes
|
||||
|
||||
- name: Copy cooptypes docs
|
||||
run: |
|
||||
mkdir -p ./components/docs/docs/cooptypes
|
||||
cp -r ./components/cooptypes/docs/* ./components/docs/docs/cooptypes/
|
||||
|
||||
- name: Build docs (mkdocs)
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
mkdocs build
|
||||
working-directory: ./components/docs
|
||||
|
||||
- name: Remove specific large file before publishing
|
||||
run: |
|
||||
# Удаляем конкретный большой файл sdk/typedoc.json
|
||||
rm -f ./components/docs/site/sdk/typedoc.json
|
||||
# Проверяем, что файл удален
|
||||
if [ -f "./components/docs/site/sdk/typedoc.json" ]; then
|
||||
echo "ERROR: typedoc.json still exists!"
|
||||
exit 1
|
||||
else
|
||||
echo "SUCCESS: typedoc.json removed successfully"
|
||||
fi
|
||||
|
||||
- name: Publish to GitHub Pages
|
||||
run: npx gh-pages --nojekyll -d site --repo https://x-access-token:${GITHUB_TOKEN}@github.com/coopenomics/mono.git
|
||||
working-directory: ./components/docs
|
||||
env:
|
||||
GIT_AUTHOR_NAME: github-actions
|
||||
GIT_AUTHOR_EMAIL: github-actions@github.com
|
||||
GIT_COMMITTER_NAME: github-actions
|
||||
GIT_COMMITTER_EMAIL: github-actions@github.com
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger docs deployment webhook
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
curl -X POST "${{ vars.DOCS_DEPLOY_WEBHOOK_URL }}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"ref":"${{ github.ref }}","sha":"${{ github.sha }}","branch":"${{ github.ref_name }}"}'
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Publish Packages
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
if: |
|
||||
startsWith(github.ref, 'refs/tags/v') &&
|
||||
!contains(github.ref, '-alpha')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Та же мажорная линия pnpm, что и lockfile (lockfileVersion 6.0 = pnpm 8).
|
||||
# Иначе `npm i -g pnpm` тянет последний pnpm и переписывает pnpm-lock.yaml → Lerna EUNCOMMIT.
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.8
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lerna run build
|
||||
- run: pnpm lerna publish from-package --yes
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -1,2 +1,11 @@
|
||||
node_modules/
|
||||
lerna-debug.log
|
||||
components/controller/graph.png
|
||||
blockchain-data/
|
||||
scripts/changelog-prompt.md
|
||||
scripts/changelog-release.md
|
||||
scripts/release-info.md
|
||||
components/docs/docs/sdk
|
||||
dist/
|
||||
.env
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 120,
|
||||
"plugins": [
|
||||
"prettier-plugin-sort-imports"
|
||||
]
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"vue.volar",
|
||||
"vue.vscode-typescript-vue-plugin"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+74
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"editor.bracketPairColorization.enabled": true,
|
||||
"editor.guides.bracketPairs": true,
|
||||
"editor.tabCompletion": "onlySnippets",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"eslint.run": "onType",
|
||||
"eslint.validate": ["javascript", "typescript", "vue"],
|
||||
"i18n-ally.localesPaths": ["src/i18n"],
|
||||
"typescript.format.enable": true,
|
||||
"typescript.format.indentSwitchCase": false,
|
||||
"typescript.validate.enable": true,
|
||||
"notebook.defaultFormatter": "Vue.volar",
|
||||
|
||||
// Оптимизация для монорепозитория
|
||||
"typescript.preferences.useAliasesForRenames": false,
|
||||
"typescript.preferences.includePackageJsonAutoImports": "on",
|
||||
"typescript.suggest.autoImports": true,
|
||||
"typescript.suggest.paths": true,
|
||||
"typescript.updateImportsOnFileMove.enabled": "always",
|
||||
"typescript.workspaceSymbols.scope": "currentProject",
|
||||
|
||||
// Настройки для снижения нагрузки
|
||||
"files.watcherExclude": {
|
||||
"**/node_modules/**": true,
|
||||
"**/dist/**": true,
|
||||
"**/.git/objects/**": true,
|
||||
"**/.git/subtree-cache/**": true,
|
||||
"**/node_modules/*/**": true,
|
||||
"**/.cache/**": true,
|
||||
"**/.quasar/**": true,
|
||||
"**/*.tsbuildinfo": true
|
||||
},
|
||||
|
||||
"search.exclude": {
|
||||
"**/node_modules": true,
|
||||
"**/dist": true,
|
||||
"**/.cache": true,
|
||||
"**/.quasar": true,
|
||||
"**/*.tsbuildinfo": true
|
||||
},
|
||||
|
||||
"files.exclude": {
|
||||
"**/.cache": true,
|
||||
"**/*.tsbuildinfo": true,
|
||||
"**/node_modules/.cache": true
|
||||
},
|
||||
|
||||
// TypeScript server настройки для монорепозитория
|
||||
"typescript.tsserver.maxTsServerMemory": 8192,
|
||||
"typescript.tsserver.watchOptions": {
|
||||
"excludeDirectories": [
|
||||
"**/node_modules",
|
||||
"**/dist",
|
||||
"**/.cache",
|
||||
"**/.quasar",
|
||||
"**/build"
|
||||
]
|
||||
},
|
||||
|
||||
// Использовать локальный TypeScript из монорепо
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
|
||||
// КРИТИЧЕСКИ ВАЖНО: включить project references для монорепозитория
|
||||
"typescript.tsserver.useSyntaxServer": "auto",
|
||||
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
|
||||
|
||||
// Оптимизация для больших монорепозиториев
|
||||
"typescript.disableAutomaticTypeAcquisition": true,
|
||||
"typescript.surveys.enabled": false,
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
### Обзор
|
||||
|
||||
Монорепозиторий «Цифровой Кооператив» (monocoop) — платформа управления кооперативами на блокчейне EOSIO. pnpm v9 + Lerna. Node.js v20.
|
||||
|
||||
### Сервисы
|
||||
|
||||
| Компонент | Контейнер | Порт | Описание |
|
||||
|-----------|-----------|------|----------|
|
||||
| controller | coopback | 2998 | NestJS GraphQL API |
|
||||
| desktop | desktop | 2999 | Vue 3 + Quasar SPA |
|
||||
| parser | cooparser | 4000 | Индексация блокчейна через SHiP |
|
||||
| blockchain | node | 8888, 8070 | EOSIO node + State History Plugin |
|
||||
| MongoDB | mongo | 27017 | Основная БД (replica set) |
|
||||
| Redis | monoredis | 6379 | Кэш и стримы |
|
||||
| PG | (см. compose) | 5532→5432 | Реляционная БД |
|
||||
|
||||
### Полный перезапуск (одна команда)
|
||||
|
||||
```
|
||||
pnpm run reboot
|
||||
```
|
||||
|
||||
Делает: останавливает контейнеры → чистит blockchain data и volumes → поднимает инфру → ждёт готовности → `pnpm run boot` → запускает parser и controller.
|
||||
|
||||
### Первоначальная настройка Cloud-окружения
|
||||
|
||||
1. **`/etc/hosts`** — обязательно для boot (запускается на хосте, обращается к MongoDB по docker hostname):
|
||||
```
|
||||
echo "127.0.0.1 mongo" | sudo tee -a /etc/hosts
|
||||
echo "127.0.0.1 monoredis" | sudo tee -a /etc/hosts
|
||||
```
|
||||
|
||||
2. **WeasyPrint** — системная зависимость для генерации PDF:
|
||||
```
|
||||
sudo apt-get install -y python3 python3-venv libpango-1.0-0 libcairo2 libffi-dev libjpeg-dev libopenjp2-7-dev libharfbuzz-dev
|
||||
sudo python3 -m venv /opt/weasyprint-venv && sudo /opt/weasyprint-venv/bin/pip install WeasyPrint==67
|
||||
sudo ln -sf /opt/weasyprint-venv/bin/weasyprint /usr/local/bin/weasyprint
|
||||
```
|
||||
|
||||
3. **Контракты** (test-режим — позволяет boot с 1 членом совета):
|
||||
```
|
||||
cd components/contracts && sudo rm -rf build && bash build-all.sh test
|
||||
```
|
||||
|
||||
4. **Shared-библиотеки** (порядок важен):
|
||||
```
|
||||
pnpm --filter cooptypes run build
|
||||
pnpm --filter @coopenomics/factory run build
|
||||
pnpm --filter @coopenomics/sdk run build
|
||||
pnpm --filter @coopenomics/notifications run build
|
||||
```
|
||||
|
||||
5. **`.env` файлы** — скопировать из `.env-example`, адаптировать hostnames:
|
||||
- Controller/Parser (в Docker): хосты по именам контейнеров из docker-compose (порт БД 5432)
|
||||
- Boot (на хосте): `127.0.0.1`, PG порт `5532`, mongo через `/etc/hosts`
|
||||
- Desktop: `127.0.0.1`
|
||||
- Controller: `BACKEND_URL` (публичный URL API) и `FRONTEND_URL` (публичный URL SPA), см. `components/controller/.env-example`
|
||||
- **CHAIN_ID**: берётся из `curl http://localhost:8888/v1/chain/get_info` после старта ноды
|
||||
- Controller требует `VAPID_PUBLIC_KEY` и `VAPID_PRIVATE_KEY`
|
||||
|
||||
6. **Запуск**: `pnpm run reboot`, затем `docker compose up -d --force-recreate coopback cooparser` (если .env менялись)
|
||||
|
||||
### Запуск тестов
|
||||
|
||||
- **Factory** (`components/factory`): нужен только MongoDB. Запуск:
|
||||
```
|
||||
NODE_ENV=test SOURCE=local MONGO_URI=$MONGO_URI SKIP_BLOCK_FETCH=TRUE pnpm --filter @coopenomics/factory test
|
||||
```
|
||||
`MONGO_URI` по умолчанию: `mongodb://<host>:27017/cooperative-x`.
|
||||
- **Boot** (`components/boot`): требует полный EOSIO blockchain + MongoDB + PG. Запуск после `pnpm run reboot`:
|
||||
```
|
||||
pnpm --filter @coopenomics/boot test
|
||||
```
|
||||
- **Duplicate transaction** — в boot-тестах EOSIO отклоняет транзакции с одинаковым хешем (TAPOS block + action data). При повторном вызове `refreshSegment` для того же участника — добавить `await sleep(500)` перед ним. Паттерн уже используется (см. комментарий на строке ~720 capital.test.ts).
|
||||
|
||||
### Критические gotchas
|
||||
|
||||
- **SHiP порт 8070** — `state-history-endpoint = 0.0.0.0:8070` в config.ini. Парсер: `SHIP=ws://node:8070`.
|
||||
- **Парсер START_BLOCK**: при `START_BLOCK=1` на чистой БД стартует с HEAD и делает частичную инициализацию. Для полного replay: временно `START_BLOCK=2`, после первого запуска вернуть `1`.
|
||||
- **SSR desktop в dev** — расширения не рендерятся из-за Pinia SSR-сериализации компонентов. Dev — SPA (`quasar dev`), production build SSR работает.
|
||||
- **Тестовые учётные данные**: email `ivanov@example.com`, ключ — дефолтный EOSIO dev key (см. `components/boot/.env-example`), пользователь `ant` (председатель).
|
||||
- **Docker hostnames**: без `network_mode: host` — контейнеры обращаются друг к другу по именам контейнеров из docker-compose.
|
||||
- **Установка пакетов**: только через фильтр — `pnpm add <pkg> --filter <component>`.
|
||||
+566
@@ -0,0 +1,566 @@
|
||||
# v2026.4.2-2
|
||||
|
||||
В этом релизе — стабилизация Благороста для вывода в продуктивную работу с результатами интеллектуальной деятельности. Отдельно заложена основа под отчётность в ФНС и ФСС и прототип поиска по документам.
|
||||
|
||||
**Благорост и проекты**
|
||||
|
||||
- Быстрые действия на странице программы: создать проект, компонент, задачу или требование.
|
||||
- Требования к компонентам в репозитории: поддержка **Mermaid**, **Draw.io** и **BPMN**.
|
||||
- Синхронизация проектов, компонентов, требований и задач с Git-репозиторием результатов.
|
||||
- Встраивание полноразмерного видео (iframe) на страницах проектов.
|
||||
- Скачивание пакета подписанных документов одной кнопкой.
|
||||
- Комнаты проектов в кооперативном мессенджере.
|
||||
|
||||
**Мессенджер и звонки**
|
||||
|
||||
- Автосекретарь: запись синхронных звонков, текстовых и голосовых сообщений в проектных комнатах.
|
||||
|
||||
**Отчётность и инфраструктура**
|
||||
|
||||
- Прототип фабрики отчётов ФНС/ФСС: выгрузка в XML для дальнейшей отправки.
|
||||
- Прототип поисковой системы по документам.
|
||||
- Установщик для развёртывания на своих серверах (разработка или эксплуатация).
|
||||
- Рефакторинг в сторону чистой архитектуры на бэкенде.
|
||||
- Ускорение сборки фронтенда за счёт перехода на **Vite 8**.
|
||||
|
||||
**Исправления**
|
||||
|
||||
- Повторное общее собрание больше не мешало завершить онбординг кооператива.
|
||||
- Центр уведомлений не блокировал загрузку рабочего стола при отключённом провайдере оповещений.
|
||||
- Уведомления о собрании совета по свободным вопросам снова доходят до членов совета.
|
||||
- В интерфейсе восстановлено отображение контактов кооператива.
|
||||
|
||||
#releases
|
||||
|
||||
---
|
||||
|
||||
# v2026.4.2-2
|
||||
|
||||
В этом релизе — стабилизация Благороста для вывода в продуктивную работу с результатами интеллектуальной деятельности. Отдельно заложена основа под отчётность в ФНС и ФСС и прототип поиска по документам.
|
||||
|
||||
**Благорост и проекты**
|
||||
|
||||
- Быстрые действия на странице программы: создать проект, компонент, задачу или требование.
|
||||
- Требования к компонентам в репозитории: поддержка **Mermaid**, **Draw.io** и **BPMN**.
|
||||
- Синхронизация проектов, компонентов, требований и задач с Git-репозиторием результатов.
|
||||
- Встраивание полноразмерного видео (iframe) на страницах проектов.
|
||||
- Скачивание пакета подписанных документов одной кнопкой.
|
||||
- Комнаты проектов в кооперативном мессенджере.
|
||||
|
||||
**Мессенджер и звонки**
|
||||
|
||||
- Автосекретарь: запись синхронных звонков, текстовых и голосовых сообщений в проектных комнатах.
|
||||
|
||||
**Отчётность и инфраструктура**
|
||||
|
||||
- Прототип фабрики отчётов ФНС/ФСС: выгрузка в XML для дальнейшей отправки.
|
||||
- Прототип поисковой системы по документам.
|
||||
- Установщик для развёртывания на своих серверах (разработка или эксплуатация).
|
||||
- Рефакторинг в сторону чистой архитектуры на бэкенде.
|
||||
- Ускорение сборки фронтенда за счёт перехода на **Vite 8**.
|
||||
|
||||
**Исправления**
|
||||
|
||||
- Повторное общее собрание больше не мешало завершить онбординг кооператива.
|
||||
- Центр уведомлений не блокировал загрузку рабочего стола при отключённом провайдере оповещений.
|
||||
- Уведомления о собрании совета по свободным вопросам снова доходят до членов совета.
|
||||
- В интерфейсе восстановлено отображение контактов кооператива.
|
||||
|
||||
#releases
|
||||
|
||||
---
|
||||
|
||||
# v2025.12.28-8
|
||||
|
||||
В этой версии представлен прототип трекера результатов интеллектуальной деятельности, реализован мост в 1С, обновлен интерфейс и существенно повышена стабильность системы.
|
||||
|
||||
---
|
||||
|
||||
### ✨ Новые функции
|
||||
- [#332](https://github.com/coopenomics/mono/issues/332): Прототип конструктора требований дополнительных документов при регистрации
|
||||
- [#330](https://github.com/coopenomics/mono/issues/330): Прототип моста в 1С:Бухгалтерию для передачи документов и проводок
|
||||
- [#329](https://github.com/coopenomics/mono/issues/329): Интеграция и тестирование LMS TUTOR для образовательных задач
|
||||
- [#328](https://github.com/coopenomics/mono/issues/328): Размещение прототипов мульти-лендингов на цифровой-кооператив.рф и coopenomics.world
|
||||
- [#326](https://github.com/coopenomics/mono/issues/326): Минимальный интерфейс трекера результатов интеллектуальной деятельности
|
||||
- [#324](https://github.com/coopenomics/mono/issues/324): Смарт-контракт генерации и капитализации результатов интеллектуальной деятельности ("Благорост")
|
||||
- [#322](https://github.com/coopenomics/mono/issues/322): Поставка обновлений ПО с нулевым даунтаймом по blue-green стратегии
|
||||
- [#321](https://github.com/coopenomics/mono/issues/321): Внедрение системы проводок по фондам для контрактов
|
||||
- [#319](https://github.com/coopenomics/mono/issues/319): Палитра команд и быстрый доступ к страницам рабочих столов (cmk+k)
|
||||
- [#316](https://github.com/coopenomics/mono/issues/316): Переход рабочего стола на GraphQL SDK
|
||||
- [#314](https://github.com/coopenomics/mono/issues/314): Развёртывание GlitchTip для мониторинга ошибок
|
||||
- [#306](https://github.com/coopenomics/mono/issues/306): Модуль запросов и мутаций для контракта капитализации
|
||||
|
||||
### 🐛 Исправления ошибок
|
||||
- [#312](https://github.com/coopenomics/mono/issues/312): Исправление подписки на изменение статуса коммитов
|
||||
- [#309](https://github.com/coopenomics/mono/issues/309): Исправление отображения чужих билетов времени в трекере
|
||||
|
||||
### 🔧 Улучшения
|
||||
- [#331](https://github.com/coopenomics/mono/issues/331): Настройка системы мониторинга сбоев и ошибок на базе GlitchTIP, Loki, Prometheus
|
||||
- [#327](https://github.com/coopenomics/mono/issues/327): Пользовательская документация по интерфейсам цифрового кооператива
|
||||
- [#325](https://github.com/coopenomics/mono/issues/325): Документирование смарт-контракта программы "Благорост"
|
||||
- [#308](https://github.com/coopenomics/mono/issues/308): Улучшение отображения рабочих столов в магазине приложений
|
||||
- [#307](https://github.com/coopenomics/mono/issues/307): Объединение настроек контракта с нативными настройками приложения
|
||||
- [#305](https://github.com/coopenomics/mono/issues/305): Объединение полей title и description в проекте
|
||||
- [#304](https://github.com/coopenomics/mono/issues/304): Доменная модель контракта капитализации на бэкенде
|
||||
- [#303](https://github.com/coopenomics/mono/issues/303): Пересмотр архитектуры парсера и формирования локальной истории
|
||||
- [#302](https://github.com/coopenomics/mono/issues/302): Рефакторинг архитектуры, внедрение двухконтурной шины данных и обработки микрофорков
|
||||
- [#301](https://github.com/coopenomics/mono/issues/301): Доработка и отладка контракта "Капитализация РИД" v0.2
|
||||
- [#222](https://github.com/coopenomics/mono/issues/222): Внедрение метода Водянова для распределения пула премий по программе "Благорост"
|
||||
- [#212](https://github.com/coopenomics/mono/issues/212): Снижение точности валютных значений до двух знаков после запятой в документах
|
||||
|
||||
#releases
|
||||
|
||||
---
|
||||
|
||||
# v2025.12.28
|
||||
|
||||
В этой версии представлен прототип трекера результатов интеллектуальной деятельности, реализован мост в 1С, обновлен интерфейс и существенно повышена стабильность системы.
|
||||
|
||||
---
|
||||
|
||||
### ✨ Новые функции
|
||||
- [#332](https://github.com/coopenomics/mono/issues/332): Прототип конструктора требований дополнительных документов при регистрации
|
||||
- [#330](https://github.com/coopenomics/mono/issues/330): Прототип моста в 1С:Бухгалтерию для передачи документов и проводок
|
||||
- [#329](https://github.com/coopenomics/mono/issues/329): Интеграция и тестирование LMS TUTOR для образовательных задач
|
||||
- [#328](https://github.com/coopenomics/mono/issues/328): Размещение прототипов мульти-лендингов на цифровой-кооператив.рф и coopenomics.world
|
||||
- [#326](https://github.com/coopenomics/mono/issues/326): Минимальный интерфейс трекера результатов интеллектуальной деятельности
|
||||
- [#324](https://github.com/coopenomics/mono/issues/324): Смарт-контракт генерации и капитализации результатов интеллектуальной деятельности ("Благорост")
|
||||
- [#322](https://github.com/coopenomics/mono/issues/322): Поставка обновлений ПО с нулевым даунтаймом по blue-green стратегии
|
||||
- [#321](https://github.com/coopenomics/mono/issues/321): Внедрение системы проводок по фондам для контрактов
|
||||
- [#319](https://github.com/coopenomics/mono/issues/319): Палитра команд и быстрый доступ к страницам рабочих столов (cmk+k)
|
||||
- [#316](https://github.com/coopenomics/mono/issues/316): Переход рабочего стола на GraphQL SDK
|
||||
- [#314](https://github.com/coopenomics/mono/issues/314): Развёртывание GlitchTip для мониторинга ошибок
|
||||
- [#306](https://github.com/coopenomics/mono/issues/306): Модуль запросов и мутаций для контракта капитализации
|
||||
|
||||
### 🐛 Исправления ошибок
|
||||
- [#312](https://github.com/coopenomics/mono/issues/312): Исправление подписки на изменение статуса коммитов
|
||||
- [#309](https://github.com/coopenomics/mono/issues/309): Исправление отображения чужих билетов времени в трекере
|
||||
|
||||
### 🔧 Улучшения
|
||||
- [#331](https://github.com/coopenomics/mono/issues/331): Настройка системы мониторинга сбоев и ошибок на базе GlitchTIP, Loki, Prometheus
|
||||
- [#327](https://github.com/coopenomics/mono/issues/327): Пользовательская документация по интерфейсам цифрового кооператива
|
||||
- [#325](https://github.com/coopenomics/mono/issues/325): Документирование смарт-контракта программы "Благорост"
|
||||
- [#308](https://github.com/coopenomics/mono/issues/308): Улучшение отображения рабочих столов в магазине приложений
|
||||
- [#307](https://github.com/coopenomics/mono/issues/307): Объединение настроек контракта с нативными настройками приложения
|
||||
- [#305](https://github.com/coopenomics/mono/issues/305): Объединение полей title и description в проекте
|
||||
- [#304](https://github.com/coopenomics/mono/issues/304): Доменная модель контракта капитализации на бэкенде
|
||||
- [#303](https://github.com/coopenomics/mono/issues/303): Пересмотр архитектуры парсера и формирования локальной истории
|
||||
- [#302](https://github.com/coopenomics/mono/issues/302): Рефакторинг архитектуры, внедрение двухконтурной шины данных и обработки микрофорков
|
||||
- [#301](https://github.com/coopenomics/mono/issues/301): Доработка и отладка контракта "Капитализация РИД" v0.2
|
||||
- [#222](https://github.com/coopenomics/mono/issues/222): Внедрение метода Водянова для распределения пула премий по программе "Благорост"
|
||||
- [#212](https://github.com/coopenomics/mono/issues/212): Снижение точности валютных значений до двух знаков после запятой в документах
|
||||
|
||||
#releases
|
||||
|
||||
---
|
||||
|
||||
# v2025.12.28
|
||||
|
||||
В этом релизе реализован смарт-контракт генерации и капитализации результатов интеллектуальной деятельности, завершена интеграция с учётными системами, улучшены интерфейсы и документация. Подробнее о контракте: https://coopenomics.world/contracts/group__public__capital.html
|
||||
|
||||
✨ Новые функции
|
||||
- [#324](https://github.com/coopenomics/mono/issues/324): Реализован смарт-контракт генерации и капитализации результатов интеллектуальной деятельности ("Благорост")
|
||||
- [#301](https://github.com/coopenomics/mono/issues/301): Контракт "Капитализация РИД" v0.2
|
||||
- [#330](https://github.com/coopenomics/mono/issues/330): Прототип моста в 1С:Бухгалтерию: выгрузка документов и проводки по счетам
|
||||
- [#322](https://github.com/coopenomics/mono/issues/322): Обновления ПО с нулевым даунтаймом по blue-green стратегии
|
||||
- [#319](https://github.com/coopenomics/mono/issues/319): Палитра команд и быстрый доступ к страницам рабочих столов (cmk+k)
|
||||
- [#308](https://github.com/coopenomics/mono/issues/308): Магазин приложений с поддержкой подключения нескольких рабочих столов одним приложением
|
||||
- [#326](https://github.com/coopenomics/mono/issues/326): Минимальный интерфейс трекера результатов интеллектуальной деятельности по программе "Благорост"
|
||||
- [#332](https://github.com/coopenomics/mono/issues/332): Прототип конструктора требований дополнительных документов при регистрации
|
||||
|
||||
🐛 Исправления ошибок
|
||||
- [#312](https://github.com/coopenomics/mono/issues/312): Исправлена ошибка со статусом коммитов — подписка теперь работает корректно
|
||||
- [#309](https://github.com/coopenomics/mono/issues/309): Исправлен баг с отображением чужих билетов времени в трекере
|
||||
|
||||
🔧 Улучшения
|
||||
- [#325](https://github.com/coopenomics/mono/issues/325): Документирован смарт-контракт программы "Благорост"
|
||||
- [#327](https://github.com/coopenomics/mono/issues/327): Подготовлена пользовательская документация цифрового кооператива по интерфейсам
|
||||
- [#329](https://github.com/coopenomics/mono/issues/329): Интеграция и тестирование образовательной платформы LMS TUTOR на Wordpress
|
||||
- [#328](https://github.com/coopenomics/mono/issues/328): Размещены прототипы мульти-лендингов на цифровой-кооператив.рф и coopenomics.world
|
||||
- [#321](https://github.com/coopenomics/mono/issues/321): Встроена система проводок по фондам и интеграция с контрактами
|
||||
- [#318](https://github.com/coopenomics/mono/issues/318): Настроены Loki & Grafana для выгрузки логов из контейнеров
|
||||
- [#316](https://github.com/coopenomics/mono/issues/316): Завершён переход рабочего стола на GraphQL SDK
|
||||
- [#314](https://github.com/coopenomics/mono/issues/314): Развёрнут GlitchTip как альтернатива Sentry
|
||||
- [#307](https://github.com/coopenomics/mono/issues/307): Интеграция настроек контракта с нативными настройками приложения, поддержка импорта после конфигурации
|
||||
- [#306](https://github.com/coopenomics/mono/issues/306): Собран модуль запросов и мутаций контракта капитализации
|
||||
- [#305](https://github.com/coopenomics/mono/issues/305): Упрощена структура проекта — title & description объединены в одно поле
|
||||
- [#304](https://github.com/coopenomics/mono/issues/304): Реализована доменная модель контракта капитализации на бэкенде с поддержкой микрофорков
|
||||
- [#303](https://github.com/coopenomics/mono/issues/303): Пересмотрена архитектура парсера и формирования локальной истории
|
||||
- [#302](https://github.com/coopenomics/mono/issues/302): Рефакторинг архитектуры, реализована двухконтурная шина данных и обработка микрофорков
|
||||
- [#212](https://github.com/coopenomics/mono/issues/212): Уменьшена точность валютных значений в документах с четырёх до двух знаков
|
||||
|
||||
#releases
|
||||
|
||||
---
|
||||
|
||||
# v2025.9.1
|
||||
|
||||
В системе Кооперативной Экономики развернут смарт-контракт CAPITAL v0.2 для генерации и капитализации результатов интеллектуальной деятельности. Контракт описывает и обеспечивает:
|
||||
- бизнес-процесс производства результатов интеллектуальной деятельности в кооперативах создателями, авторами, инвесторами, координаторами, мастерами и собственниками имущества в кооперации на проектах;
|
||||
- приём результатов интеллектуальной деятельности в качестве паевых взносов в кооператив;
|
||||
- распределение потока членских взносов среди вкладчиков;
|
||||
- капитализацию результатов интеллектуальной деятельности новыми результатами по модели золотого сечения;
|
||||
- оценку вкладов авторов и создателей по методу Водянова;
|
||||
|
||||
В основе математической модели контракта лежит принцип распределения справедливой выгоды между вкладчиками согласно концепции "Общественно-полезного времени", разработанной в рамках теорий Кузнецова и академика Глушкова при работе над проектом общегосударственной автоматизированной системы учета и обработки информации (ОГАС).
|
||||
|
||||
Подробнее в документации: https://coopenomics.world/contracts/group__public__capital.html
|
||||
|
||||
✨ Новые функции
|
||||
- [#301](https://github.com/coopenomics/mono/issues/301): Реализация контракта "Капитализация" v0.2: регистрация вкладчиков, создание и управление проектами, поддержка инвестиций, ссуд, членских взносов, проведение голосований, расчет капитализации, интеграция с внешними контрактами, поддержка импорта данных.
|
||||
|
||||
#releases
|
||||
|
||||
---
|
||||
|
||||
# v2025.7.1-1
|
||||
|
||||
В этом релизе реализованы механизмы возврата паевого взноса пайщика и инструменты управления этим процессом для членов совета. Также внесены визуальные доработки интерфейсов для улучшения восприятия информации.
|
||||
|
||||
✨ Новые функции
|
||||
- [#276](https://github.com/coopenomics/mono/issues/276): Реализовать путь возврата паевого взноса из кошелька пайщика
|
||||
- [#281](https://github.com/coopenomics/mono/issues/281): Генерация заявления на возврат паевого взноса
|
||||
- [#282](https://github.com/coopenomics/mono/issues/282): Генерация решения совета на возврат паевого взноса
|
||||
- [#278](https://github.com/coopenomics/mono/issues/278): Введение методов управления возвратами паевых взносов в контроллере
|
||||
- [#279](https://github.com/coopenomics/mono/issues/279): Отобразить исходящие платежи с кнопками управления в реестре платежей для совета
|
||||
|
||||
🐛 Исправления ошибок
|
||||
- [#286](https://github.com/coopenomics/mono/issues/286): Поправить ошибки склонений времени
|
||||
- [#262](https://github.com/coopenomics/mono/issues/262): Баг: рабочий стол совета включается, однако, страница всегда открывается со стола пайщика
|
||||
- [#261](https://github.com/coopenomics/mono/issues/261): Баг: первая загрузка переадресует на главную страницу всегда - прямой переход на собрание становится недоступен.
|
||||
|
||||
🔧 Улучшения
|
||||
- [#285](https://github.com/coopenomics/mono/issues/285): Корректировка дизайна кошелька, профиля, повестки совета, реестра документов, реестра платежей
|
||||
- [#284](https://github.com/coopenomics/mono/issues/284): Разместить кошелек на главную вместо профиля
|
||||
- [#283](https://github.com/coopenomics/mono/issues/283): Ввести лоадер на переходе между рабочими столами
|
||||
- [#280](https://github.com/coopenomics/mono/issues/280): Мигрировать имеющиеся данные о входящих платежах в новую модель
|
||||
- [#277](https://github.com/coopenomics/mono/issues/277): Рефакторинг модуля платежей: переход на унифицированную модель входящего и исходящего платежа
|
||||
|
||||
#releases
|
||||
|
||||
---
|
||||
|
||||
# v2025.6.14
|
||||
|
||||
Разработан модуль для проведения очередных общих собраний пайщиков. Исправлены баги, внесены улучшения пользовательского интерфейса.
|
||||
|
||||
✨ Новые функции
|
||||
- [#264](https://github.com/coopenomics/mono/issues/264): Разработан смарт-контракт общего собрания пайщиков (meet)
|
||||
- [#263](https://github.com/coopenomics/mono/issues/263): Реализован модуль оповещений на электронные почты по жизненному циклу общего собрания пайщиков
|
||||
- [#273](https://github.com/coopenomics/mono/issues/273): Встроены реальные шаблоны документов общего собрания
|
||||
- [#268](https://github.com/coopenomics/mono/issues/268): Добавлена страница результатов общего собрания
|
||||
- [#267](https://github.com/coopenomics/mono/issues/267): Добавлена страница просмотра и скачивания бюллетеней и уведомлений по собранию
|
||||
- [#270](https://github.com/coopenomics/mono/issues/270): Ссылка в оповещении ведет на страницу собрания с документом-уведомлением для подписи
|
||||
|
||||
🐛 Исправления ошибок
|
||||
- [#262](https://github.com/coopenomics/mono/issues/262): Исправлено некорректное открытие рабочего стола совета
|
||||
- [#261](https://github.com/coopenomics/mono/issues/261): Исправлена ошибка с редиректом при первой загрузке и прямом переходе на собрание
|
||||
- [#259](https://github.com/coopenomics/mono/issues/259): Исправлены отступы в мобильной карточке пайщика
|
||||
- [#258](https://github.com/coopenomics/mono/issues/258): Убран hover-эффект на документе и пайщике в таблице
|
||||
|
||||
🔧 Улучшения
|
||||
- [#274](https://github.com/coopenomics/mono/issues/274): Настроена рассылка оповещений на почту при получении решения о проведении собрания
|
||||
- [#272](https://github.com/coopenomics/mono/issues/272): Подписанные уведомления сохраняются и извлекаются из реестра по Graph-QL
|
||||
- [#271](https://github.com/coopenomics/mono/issues/271): Ведется подсчет количества пайщиков в каждом кооперативе при добавлении и удалении
|
||||
- [#260](https://github.com/coopenomics/mono/issues/260): Введен счетчик количества пайщиков в кооперативе на контракте регистратора
|
||||
- [#256](https://github.com/coopenomics/mono/issues/256): Отображается статус членства каждого пайщика
|
||||
- [#255](https://github.com/coopenomics/mono/issues/255): В разделе Платежи отображается ФИО/Наименование плательщика
|
||||
- [#269](https://github.com/coopenomics/mono/issues/269): Реализована рассылка уведомлений перед началом собрания
|
||||
- [#265](https://github.com/coopenomics/mono/issues/265): Проведена отладка и тестирование процесса общего собрания пайщиков
|
||||
- [#266](https://github.com/coopenomics/mono/issues/266): Протестированы все процессы общего собрания
|
||||
|
||||
#releases
|
||||
|
||||
---
|
||||
|
||||
# v2025.5.14
|
||||
|
||||
В этом релизе реализован новый стандарт передачи и хранения документов по блокчейну с поддержкой неограниченного количества подписей и их валидацией. Также внесены улучшения в интерфейс и исправлены ошибки.
|
||||
|
||||
✨ Новые функции
|
||||
- [#252](https://github.com/coopenomics/mono/issues/252): Внедрение обновленного стандарта хранения и передачи документов по блокчейну
|
||||
- [#251](https://github.com/coopenomics/mono/issues/251): Реализация версионированного мигратора данных для контроллера кооператива
|
||||
- [#244](https://github.com/coopenomics/mono/issues/244): Обновление стандарта сборки документов и переход на хэш-идентификаторы
|
||||
|
||||
🐛 Исправления ошибок
|
||||
- [#249](https://github.com/coopenomics/mono/issues/249): Исправлена спутанная маршрутизация между рабочими столами кооперативов
|
||||
- [#253](https://github.com/coopenomics/mono/issues/253): Исправлена избыточная точность суммы оплаты в заявлении на вступление
|
||||
|
||||
🔧 Улучшения
|
||||
- [#259](https://github.com/coopenomics/mono/issues/259): Исправлены отступы в мобильной карточке пайщика на странице пайщиков
|
||||
- [#258](https://github.com/coopenomics/mono/issues/258): Удалён hover-эффект для документов и пайщиков в таблице
|
||||
- [#257](https://github.com/coopenomics/mono/issues/257): Добавлено сохранение светлой/тёмной темы в localStorage и восстановление при загрузке страницы
|
||||
- [#256](https://github.com/coopenomics/mono/issues/256): Отображение статуса членства каждого пайщика в разделе "Пайщики"
|
||||
- [#255](https://github.com/coopenomics/mono/issues/255): Отображение ФИО/Наименования плательщика в разделе "Платежи"
|
||||
|
||||
#releases
|
||||
|
||||
---
|
||||
|
||||
# v2025.5.2
|
||||
|
||||
В этом релизе рабочие столы переведены на серверный рендеринг, что улучшает стабильность развертывания и упрощает автоматизацию поставки ПО. Данный релиз является подготовительным к переходу на новый стандарт цифровых документов на платформе.
|
||||
|
||||
✨ Новые функции
|
||||
- [#245](https://github.com/coopenomics/mono/issues/245): Перевод десктопа на серверный рендеринг с поддержкой динамических переменных окружения
|
||||
|
||||
🐛 Исправления ошибок
|
||||
- [#247](https://github.com/coopenomics/mono/issues/247): Исправлен баг с повторной поставкой ПО при обрывах соединения между серверами
|
||||
|
||||
🔧 Улучшения
|
||||
- [#246](https://github.com/coopenomics/mono/issues/246): Перевод CI/CD на сборку и поставку предсобранных докер-контейнеров
|
||||
|
||||
#releases
|
||||
|
||||
---
|
||||
|
||||
# MONO v2025.4.29
|
||||
|
||||
В этом релизе реализована новая архитектура рабочих столов с установкой через маркетплейс. Добавлены стол пайщика и стол совета, переработаны разделы документов и подписей, улучшено разделение кошелька и профиля для повышения удобства пользователей.
|
||||
|
||||
✨ Новые функции
|
||||
- [#234](https://github.com/coopenomics/mono/issues/234): Маркетплейс рабочих столов с поддержкой разных ролей и микросервисной архитектурой
|
||||
- [#238](https://github.com/coopenomics/mono/issues/238): Контроллер общего собрания пайщиков с поддержкой документооборота и подписей
|
||||
- [#233](https://github.com/coopenomics/mono/issues/233): Минимальный смарт-контракт общих собраний пайщиков
|
||||
- [#232](https://github.com/coopenomics/mono/issues/232): Бэкенд полного обозревателя блоков
|
||||
|
||||
🐛 Исправления ошибок
|
||||
- [#231](https://github.com/coopenomics/mono/issues/231): Исправления ошибок регистрации, выхода из системы, отображения платежей и редактирования организации
|
||||
|
||||
🔧 Улучшения
|
||||
- [#243](https://github.com/coopenomics/mono/issues/243): Контроль прав доступа на получении документов пайщика
|
||||
- [#242](https://github.com/coopenomics/mono/issues/242): Бесконечный скролл на документах пайщика и кооператива
|
||||
- [#241](https://github.com/coopenomics/mono/issues/241): Раздел "Документы" для пайщика
|
||||
- [#240](https://github.com/coopenomics/mono/issues/240): Множественные подписи на одном документе
|
||||
- [#239](https://github.com/coopenomics/mono/issues/239): Последовательные методы подписи протокола общего собрания
|
||||
- [#237](https://github.com/coopenomics/mono/issues/237): Мобильная вёрстка на страницах стола совета
|
||||
- [#236](https://github.com/coopenomics/mono/issues/236): Пересобран лендинг для MONO
|
||||
- [#235](https://github.com/coopenomics/mono/issues/235): Настроен флоу гитхаб-релизов с описаниями
|
||||
- [#226](https://github.com/coopenomics/mono/issues/226): Встроено редактирование пайщиков
|
||||
- [#224](https://github.com/coopenomics/mono/issues/224): Добавлено ТЗ по контракту капитализации
|
||||
|
||||
---
|
||||
|
||||
# Change Log
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [2.2.10](https://github.com/coopenomics/mono/compare/v2.2.9...v2.2.10) (2025-03-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add Zeus type ([7bcb6e3](https://github.com/coopenomics/mono/commit/7bcb6e30a77b0ab89c5293188b58f08f19c8761e))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.2.9](https://github.com/coopenomics/mono/compare/v2.2.8...v2.2.9) (2025-03-12)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.2.8](https://github.com/coopenomics/monocoop/compare/v2.2.7...v2.2.8) (2025-02-10)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.2.7](https://github.com/coopenomics/monocoop/compare/v2.2.6...v2.2.7) (2025-02-07)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.2.6](https://github.com/coopenomics/monocoop/compare/v2.2.6-alpha.0...v2.2.6) (2025-01-27)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.2.5](https://github.com/coopenomics/monocoop/compare/v2.2.4...v2.2.5) (2025-01-18)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.2.4](https://github.com/coopenomics/monocoop/compare/v2.2.0...v2.2.4) (2025-01-17)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.2.3](https://github.com/coopenomics/monocoop/compare/v2.2.0...v2.2.3) (2025-01-16)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.2.1](https://github.com/coopenomics/monocoop/compare/v2.2.0...v2.2.1) (2025-01-14)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.1.9](https://github.com/coopenomics/monocoop/compare/v2.1.8...v2.1.9) (2025-01-14)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.1.8](https://github.com/coopenomics/monocoop/compare/v2.1.6...v2.1.8) (2024-12-24)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.1.7](https://github.com/coopenomics/monocoop/compare/v2.1.6...v2.1.7) (2024-12-03)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.1.6](https://github.com/coopenomics/monocoop/compare/v2.1.5...v2.1.6) (2024-10-30)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.1.5](https://github.com/coopenomics/monocoop/compare/v2.1.4...v2.1.5) (2024-10-28)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.1.4](https://github.com/coopenomics/monocoop/compare/v2.1.4-alpha.2...v2.1.4) (2024-10-28)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.1.3](https://github.com/coopenomics/monocoop/compare/v2.1.2-alpha.10...v2.1.3) (2024-10-26)
|
||||
|
||||
|
||||
|
||||
## [2.1.2](https://github.com/coopenomics/monocoop/compare/v2.1.1...v2.1.2) (2024-10-19)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.1.2](https://github.com/coopenomics/monocoop/compare/v2.1.1...v2.1.2) (2024-10-19)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.1.0](https://github.com/coopenomics/monocoop/compare/v2.0.10-alpha.3...v2.1.0) (2024-10-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* запрос соглашений ([9a6a72f](https://github.com/coopenomics/monocoop/commit/9a6a72f605ba52eef2ed6f18ccee6fbed287ea00))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.0.9](https://github.com/coopenomics/monocoop/compare/v2.0.8...v2.0.9) (2024-10-10)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.0.8](https://github.com/coopenomics/monocoop/compare/v2.0.7...v2.0.8) (2024-10-09)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.0.7](https://github.com/coopenomics/monocoop/compare/v2.0.6...v2.0.7) (2024-09-30)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.0.6](https://github.com/coopenomics/monocoop/compare/v2.0.5...v2.0.6) (2024-09-30)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.0.5](https://github.com/coopenomics/monocoop/compare/v2.0.5-alpha.0...v2.0.5) (2024-09-30)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.0.2](https://github.com/coopenomics/monocoop/compare/v2.0.2-alpha.1...v2.0.2) (2024-09-29)
|
||||
|
||||
**Note:** Version bump only for package monocoop
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# 2.1.0 (2024-09-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **importers:** исправлена ошибка в импорте модуля @wharfkit/contract ([bf89032](https://github.com/coopenomics/monocoop/commit/bf89032d8f66444804a2521f4eb96ffc75b0f605))
|
||||
* **terminal:** исправлен шрифт в README ([889447a](https://github.com/coopenomics/monocoop/commit/889447a93ceadb577613ddb5b1cb2ef1cc3d54b6))
|
||||
* **terminal:** исправлено описание проекта ([989b318](https://github.com/coopenomics/monocoop/commit/989b3180ded99a871e018cf26e6c493449223c01))
|
||||
* **terminal:** fix typo in README.md ([fdf9996](https://github.com/coopenomics/monocoop/commit/fdf999619d2d69e960b062fe6815f5b057c95f48))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* добавлен docker-compose.yaml ([6a46697](https://github.com/coopenomics/monocoop/commit/6a46697c9d6cc3cde14dbce8f70997c00f9850de))
|
||||
* **package:** добавлен скрипт gpt-commit для удобного коммита ([e0b5107](https://github.com/coopenomics/monocoop/commit/e0b510799bb0ac68890d572deb652beefd0651c4))
|
||||
* **terminal:** добавлена поддержка новых команд ([73e4ca2](https://github.com/coopenomics/monocoop/commit/73e4ca226acebcbea3ae59a62def99d86efb1353))
|
||||
@@ -0,0 +1,39 @@
|
||||
k# Conventional Commits
|
||||
|
||||
### Основные типы коммитов:
|
||||
|
||||
- **`feat`**: Добавление новой функциональности (feature).
|
||||
- Пример: `feat: добавлена возможность загрузки файлов`
|
||||
|
||||
- **`fix`**: Исправление бага.
|
||||
- Пример: `fix: исправлена ошибка отображения кнопки на мобильных устройствах`
|
||||
|
||||
- **`chore`**: Изменения, не влияющие на исходный код (например, обновление зависимостей или инструментария).
|
||||
- Пример: `chore: обновление зависимостей`
|
||||
|
||||
- **`docs`**: Изменения в документации.
|
||||
- Пример: `docs: обновлено руководство пользователя`
|
||||
|
||||
- **`style`**: Изменения стиля кода, не влияющие на его функциональность (форматирование, пробелы, и т.д.).
|
||||
- Пример: `style: исправлены отступы в коде`
|
||||
|
||||
- **`refactor`**: Изменения в коде, которые не исправляют баги и не добавляют новую функциональность.
|
||||
- Пример: `refactor: улучшена структура класса`
|
||||
|
||||
- **`perf`**: Изменения, направленные на улучшение производительности.
|
||||
- Пример: `perf: оптимизирована работа с массивами`
|
||||
|
||||
- **`test`**: Добавление или изменение тестов.
|
||||
- Пример: `test: добавлен тест для проверки функции отправки сообщений`
|
||||
|
||||
### Дополнительные типы коммитов:
|
||||
|
||||
- **`build`**: Изменения, связанные с процессом сборки или зависимостями.
|
||||
- Пример: `build: обновление конфигурации Webpack`
|
||||
|
||||
- **`ci`**: Изменения, касающиеся настроек непрерывной интеграции (например, Travis, Jenkins).
|
||||
- Пример: `ci: настройка Travis для автоматических сборок`
|
||||
|
||||
- **`revert`**: Откат на предыдущие изменения.
|
||||
- Пример: `revert: откат коммита e0b5107`
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Сразу копируем все файлы
|
||||
COPY . .
|
||||
|
||||
# Устанавливаем инструменты
|
||||
RUN npm install -g pnpm lerna
|
||||
|
||||
# Установка зависимостей
|
||||
# Используем версию pnpm, совместимую с существующим lock-файлом
|
||||
RUN pnpm install
|
||||
|
||||
# Установка системных зависимостей для WeasyPrint и диагностических утилит (Debian/Ubuntu версии)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
gcc \
|
||||
g++ \
|
||||
python3-dev \
|
||||
libpango-1.0-0 \
|
||||
libpangoft2-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
libcairo2 \
|
||||
libcairo2-dev \
|
||||
libffi-dev \
|
||||
shared-mime-info \
|
||||
zlib1g-dev \
|
||||
libjpeg-dev \
|
||||
libopenjp2-7-dev \
|
||||
procps \
|
||||
wget \
|
||||
&& python3 -m venv /venv \
|
||||
&& /venv/bin/pip install WeasyPrint==67 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Сборка всех компонентов
|
||||
RUN lerna run build
|
||||
|
||||
# Финальный образ
|
||||
FROM builder AS runtime
|
||||
|
||||
# Настройка переменных окружения
|
||||
ENV PATH="/venv/bin:$PATH"
|
||||
|
||||
# Проверка WeasyPrint
|
||||
RUN weasyprint --version
|
||||
@@ -1,4 +1,120 @@
|
||||
# MONOCOOP
|
||||
# Цифровой Кооператив
|
||||
|
||||
Моно-репозиторий компонент Цифрового Кооператива.
|
||||
|
||||
<!-- badges -->
|
||||

|
||||

|
||||

|
||||
|
||||
Платформа «Цифровой Кооператив» — комплексное программное обеспечение для управления кооперативными организациями на основе блокчейна EOSIO. Система обеспечивает полный цикл управления кооперативом: от регистрации пайщиков и электронного документооборота до проведения собраний и финансового учёта. Построена на принципах прозрачности, децентрализации и простой электронной подписи.
|
||||
|
||||
Проект является частью экосистемы [Кооперативная Экономика](https://coopenomics.world).
|
||||
|
||||
## Архитектура
|
||||
|
||||
| Компонент | Пакет | Описание |
|
||||
|-----------|-------|----------|
|
||||
| [boot](components/boot) | `@coopenomics/boot` | CLI для инициализации и управления блокчейн-инфраструктурой |
|
||||
| [cleos](components/cleos) | `@coopenomics/cleos` | Утилита командной строки для работы с блокчейн-кошельком |
|
||||
| [contracts](components/contracts) | `@coopenomics/contracts` | Смарт-контракты EOSIO на C++ |
|
||||
| [controller](components/controller) | `@coopenomics/controller` | GraphQL API сервер (NestJS) |
|
||||
| [cooptypes](components/cooptypes) | `cooptypes` | Общие типы и интерфейсы блокчейн-контрактов |
|
||||
| [desktop](components/desktop) | `@coopenomics/desktop` | Рабочий стол кооператива (Vue 3 + Quasar) |
|
||||
| [factory](components/factory) | `@coopenomics/factory` | Генератор юридических документов |
|
||||
| [migrator](components/migrator) | `migrator` | Утилита миграции данных |
|
||||
| [notifications](components/notifications) | `@coopenomics/notifications` | Библиотека уведомлений на основе Novu |
|
||||
| [parser](components/parser) | `@coopenomics/parser` | Индексатор блокчейна через State History Plugin |
|
||||
| [sdk](components/sdk) | `@coopenomics/sdk` | TypeScript SDK для GraphQL API |
|
||||
| [setup](components/setup) | `@coopenomics/setup` | Мастер первоначальной настройки |
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### Предварительные требования
|
||||
|
||||
- Node.js >= 20
|
||||
- pnpm 9
|
||||
- Docker и Docker Compose
|
||||
- [WeasyPrint](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#installation) (для генерации PDF)
|
||||
|
||||
### Установка
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Конфигурация
|
||||
|
||||
```bash
|
||||
pnpm run setup
|
||||
```
|
||||
|
||||
Интерактивный мастер создаст необходимые `.env` файлы для всех компонентов.
|
||||
|
||||
### Запуск инфраструктуры
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
pnpm run reboot
|
||||
```
|
||||
|
||||
## Разработка
|
||||
|
||||
### Бэкенд (controller + parser)
|
||||
|
||||
```bash
|
||||
pnpm run dev:backend
|
||||
```
|
||||
|
||||
### Фронтенд (desktop)
|
||||
|
||||
```bash
|
||||
pnpm run dev:desktop
|
||||
```
|
||||
|
||||
### Библиотеки (factory + cooptypes)
|
||||
|
||||
```bash
|
||||
pnpm run dev:lib
|
||||
```
|
||||
|
||||
### Все сервисы одновременно
|
||||
|
||||
```bash
|
||||
pnpm run dev:all
|
||||
```
|
||||
|
||||
> **Примечание:** установка пакетов производится только через фильтр: `pnpm add <пакет> --filter <компонент>`
|
||||
|
||||
## Тестирование
|
||||
|
||||
```bash
|
||||
# Все тесты
|
||||
pnpm run test
|
||||
|
||||
# Юнит-тесты (cooptypes, parser, notifications)
|
||||
pnpm run test:unit
|
||||
|
||||
# Компонентные тесты (factory)
|
||||
pnpm run test:component
|
||||
|
||||
# Интеграционные тесты (boot + blockchain)
|
||||
pnpm run test:integration
|
||||
```
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
# Библиотеки (cooptypes, factory)
|
||||
pnpm run build:lib
|
||||
|
||||
# Смарт-контракты
|
||||
pnpm run build:contracts:all
|
||||
|
||||
# Desktop (SSR)
|
||||
pnpm --filter @coopenomics/desktop run build
|
||||
```
|
||||
|
||||
## Лицензия
|
||||
|
||||
Продукт Потребительского Кооператива «ВОСХОД» распространяется по лицензии [BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode.ru).
|
||||
|
||||
Разрешено делиться, копировать и распространять материал, адаптировать и создавать производные произведения при условии указания авторства и сохранения той же лицензии. Коммерческое использование запрещено.
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# 📋 Резюме создания компонентов Notifications
|
||||
|
||||
## ✅ Что создано
|
||||
|
||||
### 1. 📚 Библиотека `@monocoop/notifications`
|
||||
**Расположение:** `components/notifications/`
|
||||
|
||||
**Функции:**
|
||||
- ✅ Типизированные интерфейсы для workflow с Zod
|
||||
- ✅ Builder паттерн для создания workflow
|
||||
- ✅ Базовые шаблоны для email, in-app, push, SMS
|
||||
- ✅ Автоматическая конвертация Zod схем в JSON Schema для Novu
|
||||
- ✅ Экспорт всех workflow для использования в других пакетах
|
||||
|
||||
**Структура:**
|
||||
```
|
||||
src/
|
||||
├── types/ # Базовые типы и интерфейсы
|
||||
├── base/ # Утилиты и настройки по умолчанию
|
||||
├── workflows/ # Папки с workflow
|
||||
│ └── welcome/ # Пример приветственного workflow
|
||||
└── index.ts # Главный экспорт
|
||||
```
|
||||
|
||||
### 2. 🚀 NestJS приложение `@monocoop/notificator2`
|
||||
**Расположение:** `components/notificator2/`
|
||||
|
||||
**Функции:**
|
||||
- ✅ Автоматический upsert всех workflow в Novu при запуске
|
||||
- ✅ RESTful API для триггера уведомлений
|
||||
- ✅ Типизированная валидация payload
|
||||
- ✅ Health check endpoints
|
||||
- ✅ Использует библиотеку notifications для типов
|
||||
|
||||
**API Endpoints:**
|
||||
- `GET /api/notifications/health` - Health check
|
||||
- `GET /api/notifications/workflows` - Список workflow
|
||||
- `POST /api/notifications/trigger` - Универсальный триггер
|
||||
- `POST /api/notifications/trigger/welcome` - Триггер welcome workflow
|
||||
- `POST /api/notifications/workflows/upsert-all` - Принудительный upsert
|
||||
|
||||
## 🔧 Как использовать
|
||||
|
||||
### 1. Настройка библиотеки notifications
|
||||
```bash
|
||||
cd components/notifications
|
||||
pnpm install # или npm install
|
||||
pnpm build # для компиляции TypeScript
|
||||
```
|
||||
|
||||
### 2. Настройка notificator2
|
||||
```bash
|
||||
cd components/notificator2
|
||||
pnpm install # или npm install
|
||||
|
||||
# Настройка окружения
|
||||
cp .env.example .env
|
||||
# Добавить NOVU_API_KEY в .env
|
||||
|
||||
# Запуск
|
||||
pnpm start:dev
|
||||
```
|
||||
|
||||
### 3. Добавление нового workflow
|
||||
|
||||
**В библиотеке notifications:**
|
||||
```typescript
|
||||
// components/notifications/src/workflows/order/order-workflow.ts
|
||||
export const orderWorkflow = WorkflowBuilder
|
||||
.create<OrderPayload>()
|
||||
.name('Order Confirmation')
|
||||
.workflowId('order-confirmation')
|
||||
.payloadSchema(orderPayloadSchema)
|
||||
.addSteps([...])
|
||||
.build();
|
||||
```
|
||||
|
||||
**Регистрация в index.ts:**
|
||||
```typescript
|
||||
// components/notifications/src/workflows/index.ts
|
||||
import { orderWorkflow } from './order';
|
||||
|
||||
export const allWorkflows = [
|
||||
welcomeWorkflow,
|
||||
orderWorkflow, // ← добавить новый workflow
|
||||
];
|
||||
```
|
||||
|
||||
### 4. Использование API
|
||||
|
||||
**Триггер welcome workflow:**
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/notifications/trigger/welcome \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"subscriberId": "user-123",
|
||||
"email": "user@example.com",
|
||||
"payload": {
|
||||
"userName": "Иван Иванов",
|
||||
"userEmail": "user@example.com",
|
||||
"age": 25
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## 🎯 Преимущества архитектуры
|
||||
|
||||
1. **Типобезопасность** - Zod схемы обеспечивают валидацию на уровне TypeScript и runtime
|
||||
2. **Разделение ответственности** - Библиотека содержит только типы, сервер - логику
|
||||
3. **Расширяемость** - Легко добавлять новые workflow
|
||||
4. **Автоматизация** - Workflow автоматически синхронизируются с Novu
|
||||
5. **Переиспользование** - Типы можно использовать в других частях системы
|
||||
|
||||
## 🔄 Workflow при запуске
|
||||
|
||||
1. **Запуск notificator2** →
|
||||
2. **Чтение всех workflow из библиотеки** →
|
||||
3. **Проверка существования в Novu** →
|
||||
4. **Создание/обновление workflow** →
|
||||
5. **Готов к приёму запросов на триггеры**
|
||||
|
||||
## 📁 Структура как в testFramework2.ts
|
||||
|
||||
Вся логика из `testFramework2.ts` была перенесена в structured архитектуру:
|
||||
- ✅ `buildWorkflowData` → `WorkflowBuilder`
|
||||
- ✅ `baseSteps` → `createEmailStep`, `createInAppStep`, etc.
|
||||
- ✅ `upsertWorkflow` → `NovuService.upsertWorkflow`
|
||||
- ✅ `triggerWorkflow` → `NovuService.triggerWorkflow`
|
||||
- ✅ Типизация payload → Zod схемы + TypeScript типы
|
||||
@@ -0,0 +1,55 @@
|
||||
# TASKS.md — Прогресс выполнения задач
|
||||
|
||||
## Завершённые задачи
|
||||
|
||||
### 1-6. Предыдущие задачи (см. git history)
|
||||
- ✅ Dev-окружение, Security, Тесты, README, AGENTS.md, Setup
|
||||
- ✅ Поисковая система документов (OpenSearch)
|
||||
- ✅ Процессы (Capital extension)
|
||||
|
||||
---
|
||||
|
||||
## Текущая задача: Генерация отчётов ФНС (расширение reports)
|
||||
|
||||
### Документы ФНС для генерации:
|
||||
1. **6-НДФЛ** — ежеквартально (XSD: NO_NDFL6.2)
|
||||
2. **4-ФСС (ЕФС-1)** — ежеквартально
|
||||
3. **РСВ** — ежеквартально (XSD: NO_RASCHSV)
|
||||
4. **ПСВ** — ежемесячно (XSD: NO_PERSSVFL)
|
||||
5. **Бухгалтерский баланс** — ежегодно (XSD: NO_BUHOTCH) — КЛЮЧЕВОЙ
|
||||
6. **ДУСН** — декларация УСН ежегодно (XSD: NO_USN)
|
||||
7. **Уведомление о страховых взносах** — ежемесячно с 2026 (XSD: UT_UVISCHSUMNAL)
|
||||
8. **УУСН** — уведомление УСН
|
||||
### 10. Генерация отчётов ФНС (доработка) ✅
|
||||
- [x] Фабрика генераторов (ReportRegistryService)
|
||||
- [x] 8 генераторов (Бухбаланс, 6-НДФЛ, РСВ, ПСВ, ДУСН, 4-ФСС, Увед. взносы, УУСН)
|
||||
- [x] GraphQL API (getAvailableReports, generateReport)
|
||||
- [x] Генераторы переписаны по XSD — структура соответствует схемам ФНС
|
||||
- [x] 48 unit-тестов для всех генераторов
|
||||
- [x] Desktop UI (страница отчётов) — расширение reports
|
||||
- [x] Интеграция с реальными данными ledger через LedgerInteractor
|
||||
- [x] OrganizationDataInput DTO для передачи данных организации
|
||||
|
||||
### Архитектура:
|
||||
- Расширение `reports` в `components/controller/src/extensions/`
|
||||
- Фабрика XML отчётов: на вход данные за период → на выходе XML
|
||||
- Валидация по XSD схемам
|
||||
- Desktop UI: магазин приложений → установка → рабочий стол отчётов
|
||||
|
||||
### Подзадачи:
|
||||
|
||||
- [x] **8.1 Исследование**: Все XSD разобраны, format.nalog.ru изучен
|
||||
- [x] **8.2 Инфраструктура**: Расширение reports, ReportRegistryService (фабрика)
|
||||
- [x] **8.3 XML генератор**: Фабричный подход — IReportGenerator interface
|
||||
- [x] **8.4 Бухбаланс**: BuhotchGenerator — счета 51, 80, 86 из ledger
|
||||
- [x] **8.5 6-НДФЛ**: Ndfl6Generator — нулевая
|
||||
- [x] **8.6 4-ФСС**: Zero generator — нулевая
|
||||
- [x] **8.7 РСВ**: Zero generator — нулевая
|
||||
- [x] **8.8 ПСВ**: Zero generator — нулевая
|
||||
- [x] **8.9 ДУСН**: Zero generator — нулевая
|
||||
- [x] **8.10 Уведомление о взносах**: Zero generator — нулевая
|
||||
- [x] **8.11 УУСН**: Zero generator — нулевая
|
||||
- [x] **8.12 GraphQL API**: getAvailableReports + generateReport
|
||||
- [ ] **8.13 XSD валидация + тесты**: Проверка по схемам
|
||||
- [ ] **8.14 Desktop UI**: Страница отчётов в магазине приложений
|
||||
- [ ] **8.15 Ledger интеграция**: Реальные данные из ledger_operations
|
||||
@@ -0,0 +1,64 @@
|
||||
# Test Plan — pnpm run test
|
||||
|
||||
## Архитектура
|
||||
```
|
||||
pnpm run test
|
||||
├── cooptypes — vitest run (smoke tests exports) ✅ 4/4
|
||||
├── parser — vitest run (config smoke tests) ✅ 3/3
|
||||
├── factory — vitest run (document generation tests) 🔧 17/85 → need mocks
|
||||
├── sdk — vitest run (API integration tests) 🔧 TODO
|
||||
├── notifications — vitest run (workflow tests) 🔧 TODO
|
||||
├── controller — jest / vitest (NestJS unit tests) 🔧 TODO
|
||||
└── boot — vitest run (blockchain integration) 🔧 53/60
|
||||
```
|
||||
|
||||
## Статус по компонентам
|
||||
|
||||
### cooptypes ✅ DONE
|
||||
- 4 smoke-теста экспортов
|
||||
- Не требует инфраструктуры
|
||||
|
||||
### parser ✅ DONE
|
||||
- 3 smoke-теста конфигурации
|
||||
- Не требует инфраструктуры
|
||||
|
||||
### factory 🔧 IN PROGRESS
|
||||
- **Проблема**: тесты обращаются к parser API (`SIMPLE_EXPLORER_API`) для get-tables/get-actions
|
||||
- **Решение**: Уже есть мок-система в `src/Utils/testMocks.ts` + `matchMock.ts`
|
||||
- **Нужно**: Добавить моки для ВСЕХ документов:
|
||||
- [ ] cooperative data mock (registrator.coops table)
|
||||
- [ ] soviet boards mock — уже есть через test setup в MongoDB
|
||||
- [ ] draft templates mock (draft.drafts + draft.translations tables)
|
||||
- [ ] decision data mocks (soviet.decisions table)
|
||||
- [ ] Мок для ReturnByMoney документов
|
||||
- [ ] Все документы 1000+ серии
|
||||
- **Текущие рабочие моки**: meet tables, votefor actions, returnByMoneyDecision actions
|
||||
- **После мокирования**: все 85 тестов должны проходить
|
||||
|
||||
### boot 🔧 NEEDS REBOOT
|
||||
- capital.test — 53/60 тестов проходят (после чистого reboot)
|
||||
- wallet.test — нужен полный boot с agreements
|
||||
- registrator.test — нужен полный boot
|
||||
- capital-import.test — отдельный тест импорта
|
||||
- **Требует**: pnpm run reboot перед запуском
|
||||
|
||||
### sdk 🔧 TODO
|
||||
- 1 тест файл с login + fetch extensions
|
||||
- Требует запущенный controller
|
||||
- Нужно: обновить chain_id, api_url, credentials
|
||||
|
||||
### controller 🔧 TODO
|
||||
- Все старые тесты удалены (устаревшие)
|
||||
- NestJS-приложение — нужны тесты через @nestjs/testing
|
||||
- Минимум: unit-тесты domain-логики, smoke-тест GraphQL API
|
||||
|
||||
### notifications 🔧 TODO
|
||||
- Нет тестов
|
||||
- Нужно: smoke-тесты workflow builder
|
||||
|
||||
### desktop — SKIP (нет тестов, UI-тестирование)
|
||||
|
||||
## Root script требования
|
||||
- `pnpm run test` — запускает ВСЕ тесты
|
||||
- fail-fast: если один пакет падает — весь pipeline падает
|
||||
- Последовательный запуск (не параллельный)
|
||||
File diff suppressed because one or more lines are too long
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,26 @@
|
||||
# @coopenomics/blago-cli
|
||||
|
||||
CLI синхронизации артефактов Благорост (проекты, задачи, требования) с бэкендом через `@coopenomics/sdk`.
|
||||
|
||||
## Базовый каталог и корень копии
|
||||
|
||||
**Базовый каталог**: путь активной копии из **`~/.claude/config/blago/config.yaml`** (`active_workspace_env` и `workspaces`), если в этом каталоге уже есть **`.blago/config.json`**; иначе используется текущий рабочий каталог (**cwd**).
|
||||
|
||||
**Корень рабочей копии** — каталог, в котором (или выше по дереву от базового каталога) лежит `.blago/config.json`. Поиск идёт вверх от базы, пока не найден файл.
|
||||
|
||||
Команда **`blago init [directory]`** создаёт глобальный конфиг, дерево `~/blago/dev|testnet|production`, копирует в `~/.claude/config/blago/` (helpers, templates) и в `~/.claude/skills/blago/` содержимое `ai/` пакета; опциональный **`[directory]`** — дополнительная копия: `.blago` в `path.resolve(cwd, directory)`.
|
||||
|
||||
## Справка по командам
|
||||
|
||||
```text
|
||||
blago --help
|
||||
blago <команда> --help
|
||||
```
|
||||
|
||||
У подкоманд в help выводится блок **Global Options** (в том числе версия), если смотрите справку не с корневого уровня.
|
||||
|
||||
В конце help добавляется строка **текущей сессии** (активная среда и пользователь из сохранённого `blago login`), если найдена копия.
|
||||
|
||||
## Прочее
|
||||
|
||||
- После **`blago init`**: **`~/.claude/config/blago/helpers.md`**, **`~/.claude/config/blago/templates/`** (исходники в пакете: `ai/config/`, `ai/templates/`) и содержимое **`ai/`** в **`~/.claude/skills/blago/`** (`skills/bmm/…`, `commands/` и т.д.; каталог `ai` в пути не создаётся).
|
||||
@@ -0,0 +1,257 @@
|
||||
You are executing the **Workflow Init** command to initialize BMAD Method in the current project.
|
||||
|
||||
## Command Overview
|
||||
|
||||
**Purpose:** Set up BMAD Method v6 structure and configuration in the current project
|
||||
|
||||
**Agent:** BMad Master (Core Orchestrator)
|
||||
|
||||
**Output:**
|
||||
- `bmad/config.yaml` - Project configuration
|
||||
- `docs/bmm-workflow-status.yaml` - Workflow status tracking
|
||||
- Directory structure for BMAD artifacts
|
||||
|
||||
---
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1: Check for Existing Installation
|
||||
|
||||
1. Check if `bmad/config.yaml` exists
|
||||
2. If exists:
|
||||
- Read current config
|
||||
- Ask: "BMAD already initialized. Reinitialize (overwrites config)?"
|
||||
- If no → Exit
|
||||
- If yes → Continue
|
||||
|
||||
### Step 2: Create Directory Structure
|
||||
|
||||
Create the following directories using Write/Bash tool:
|
||||
|
||||
```
|
||||
bmad/
|
||||
├── config.yaml
|
||||
└── agent-overrides/
|
||||
|
||||
docs/
|
||||
├── bmm-workflow-status.yaml
|
||||
└── stories/
|
||||
└── (story directories created as needed)
|
||||
|
||||
.claude/
|
||||
└── commands/
|
||||
└── bmad/
|
||||
└── (commands auto-registered by Claude Code)
|
||||
```
|
||||
|
||||
**Note:** Only create directories that don't exist. Use `mkdir -p` to be safe.
|
||||
|
||||
### Step 3: Collect Project Information
|
||||
|
||||
Ask user these questions (one at a time):
|
||||
|
||||
**Q1: Project Name**
|
||||
```
|
||||
"What is your project name?"
|
||||
|
||||
Examples: "MyApp", "E-Commerce Platform", "Mobile Game"
|
||||
Default: Use directory name if user skips
|
||||
```
|
||||
|
||||
**Q2: Project Type**
|
||||
```
|
||||
"What type of project is this?"
|
||||
|
||||
Options (present as menu):
|
||||
1. Web Application
|
||||
2. Mobile App (iOS/Android)
|
||||
3. API / Backend Service
|
||||
4. Game
|
||||
5. Library / Framework
|
||||
6. Other
|
||||
|
||||
Store as: "web-app", "mobile-app", "api", "game", "library", "other"
|
||||
```
|
||||
|
||||
**Q3: Project Level**
|
||||
```
|
||||
"What is the project complexity level?"
|
||||
|
||||
Explain levels:
|
||||
- Level 0: Single atomic change (1 story)
|
||||
- Level 1: Small feature set (1-10 stories)
|
||||
- Level 2: Medium feature set (5-15 stories)
|
||||
- Level 3: Complex integration (12-40 stories)
|
||||
- Level 4: Enterprise expansion (40+ stories)
|
||||
|
||||
Options (present as menu):
|
||||
0. Level 0 - Single story
|
||||
1. Level 1 - Small (1-10 stories)
|
||||
2. Level 2 - Medium (5-15 stories)
|
||||
3. Level 3 - Complex (12-40 stories)
|
||||
4. Level 4 - Enterprise (40+ stories)
|
||||
|
||||
Store as: 0, 1, 2, 3, or 4
|
||||
```
|
||||
|
||||
### Step 4: Create Project Config
|
||||
|
||||
1. Load global config from `~/.claude/config/bmad/config.yaml` per `helpers.md#Load-Global-Config`
|
||||
|
||||
2. Load template from `~/.claude/config/bmad/project-config.template.yaml`
|
||||
|
||||
3. Substitute variables:
|
||||
- `{{PROJECT_NAME}}` → User input from Step 3
|
||||
- `{{PROJECT_TYPE}}` → User input from Step 3
|
||||
- `{{PROJECT_LEVEL}}` → User input from Step 3
|
||||
|
||||
4. Write to `bmad/config.yaml` using Write tool
|
||||
|
||||
**Example output:**
|
||||
```yaml
|
||||
project_name: "MyApp"
|
||||
project_type: "web-app"
|
||||
project_level: 2
|
||||
output_folder: "docs"
|
||||
bmm:
|
||||
workflow_status_file: "docs/bmm-workflow-status.yaml"
|
||||
sprint_status_file: "docs/sprint-status.yaml"
|
||||
paths:
|
||||
docs: "docs"
|
||||
stories: "docs/stories"
|
||||
tests: "tests"
|
||||
```
|
||||
|
||||
### Step 5: Create Workflow Status File
|
||||
|
||||
1. Load template from `~/.claude/config/bmad/templates/bmm-workflow-status.template.yaml`
|
||||
|
||||
2. Determine conditional statuses based on project level:
|
||||
```
|
||||
Level 0-1:
|
||||
- PRD: "recommended" (optional for level 0)
|
||||
- Tech-spec: "required"
|
||||
- Architecture: "optional"
|
||||
|
||||
Level 2+:
|
||||
- PRD: "required"
|
||||
- Tech-spec: "optional"
|
||||
- Architecture: "required"
|
||||
```
|
||||
|
||||
3. Substitute variables:
|
||||
- `{{TIMESTAMP}}` → Current ISO timestamp
|
||||
- `{{PROJECT_NAME}}` → From project config
|
||||
- `{{PROJECT_TYPE}}` → From project config
|
||||
- `{{PROJECT_LEVEL}}` → From project config
|
||||
- `{{PRD_STATUS}}` → Conditional per above
|
||||
- `{{TECH_SPEC_STATUS}}` → Conditional per above
|
||||
- `{{ARCHITECTURE_STATUS}}` → Conditional per above
|
||||
|
||||
4. Write to `docs/bmm-workflow-status.yaml` using Write tool
|
||||
|
||||
### Step 6: Confirm Initialization
|
||||
|
||||
Display success message:
|
||||
|
||||
```
|
||||
✓ BMAD Method v6 initialized successfully!
|
||||
|
||||
Project Configuration:
|
||||
Name: {project_name}
|
||||
Type: {project_type}
|
||||
Level: {project_level}
|
||||
|
||||
Files Created:
|
||||
✓ bmad/config.yaml
|
||||
✓ docs/bmm-workflow-status.yaml
|
||||
✓ Directory structure
|
||||
|
||||
Workflow Path for Level {project_level}:
|
||||
{Display path based on level - see Step 7}
|
||||
|
||||
Recommended Next Step:
|
||||
{Recommend workflow - see helpers.md#Determine-Next-Workflow}
|
||||
```
|
||||
|
||||
### Step 7: Recommend Workflow Path
|
||||
|
||||
Based on project level, show recommended path:
|
||||
|
||||
**Level 0:**
|
||||
```
|
||||
Phase 1 (Optional): /product-brief
|
||||
Phase 2 (Required): /tech-spec
|
||||
Phase 4 (Required): /create-story → /dev-story
|
||||
```
|
||||
|
||||
**Level 1:**
|
||||
```
|
||||
Phase 1 (Recommended): /product-brief
|
||||
Phase 2 (Required): /tech-spec
|
||||
Phase 4 (Required): /sprint-planning → stories
|
||||
```
|
||||
|
||||
**Level 2+:**
|
||||
```
|
||||
Phase 1 (Recommended): /product-brief
|
||||
Phase 2 (Required): /prd
|
||||
Phase 3 (Required): /architecture
|
||||
Phase 4 (Required): /sprint-planning → stories
|
||||
```
|
||||
|
||||
### Step 8: Offer to Start
|
||||
|
||||
Ask user:
|
||||
```
|
||||
"Would you like to start with the recommended workflow?"
|
||||
|
||||
If Phase 1 recommended: "I can help you create a product brief."
|
||||
If Phase 2 required: "I can help you create a [PRD/tech-spec]."
|
||||
```
|
||||
|
||||
If yes: Hand off to appropriate agent (Analyst for brief, PM for PRD/tech-spec)
|
||||
If no: "Run /workflow-status anytime to check your progress."
|
||||
|
||||
---
|
||||
|
||||
## Helper References
|
||||
|
||||
- **Load global config:** `helpers.md#Load-Global-Config`
|
||||
- **Load template:** `helpers.md#Load-Template`
|
||||
- **Apply variables:** `helpers.md#Apply-Variables-to-Template`
|
||||
- **Save document:** `helpers.md#Save-Output-Document`
|
||||
- **Determine next:** `helpers.md#Determine-Next-Workflow`
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
**If BMAD already initialized:**
|
||||
- Inform user
|
||||
- Offer to reinitialize (overwrites config)
|
||||
- Offer to check status instead (`/workflow-status`)
|
||||
|
||||
**If directory creation fails:**
|
||||
- Show error
|
||||
- Check permissions
|
||||
- Suggest manual directory creation
|
||||
|
||||
**If template missing:**
|
||||
- Use inline fallback template
|
||||
- Log warning
|
||||
- Continue initialization
|
||||
|
||||
---
|
||||
|
||||
## Notes for LLMs
|
||||
|
||||
- Use TodoWrite to track 8 steps
|
||||
- Create directories with `mkdir -p` (safe for existing dirs)
|
||||
- Be clear about conditional requirements based on level
|
||||
- Present options as numbered menus for clarity
|
||||
- Use Write tool for config/status files
|
||||
- Maintain BMad Master persona (helpful, organized, clear)
|
||||
- Don't skip steps - initialization must be complete
|
||||
|
||||
**Remember:** This is the entry point for BMAD. Set users up for success with clear explanation of their path forward.
|
||||
@@ -0,0 +1,520 @@
|
||||
# BMAD v6 Helper Utilities
|
||||
|
||||
This document contains reusable utilities for BMAD workflows. Skills and commands can reference specific sections to avoid repetition.
|
||||
|
||||
## Config Loading
|
||||
|
||||
### Load Global Config
|
||||
```
|
||||
Path: ~/.claude/config/bmad/config.yaml
|
||||
Purpose: Get user settings, enabled modules, defaults
|
||||
|
||||
Using Read tool:
|
||||
1. Read ~/.claude/config/bmad/config.yaml
|
||||
2. Parse YAML to extract:
|
||||
- user_name
|
||||
- communication_language
|
||||
- default_output_folder
|
||||
- modules_enabled
|
||||
3. Store in memory for workflow
|
||||
```
|
||||
|
||||
### Load Project Config
|
||||
```
|
||||
Path: {project-root}/bmad/config.yaml
|
||||
Purpose: Get project-specific settings
|
||||
|
||||
Using Read tool:
|
||||
1. Read bmad/config.yaml
|
||||
2. Parse YAML to extract:
|
||||
- project_name
|
||||
- project_type
|
||||
- project_level
|
||||
- output_folder
|
||||
3. Merge with global config (project overrides global)
|
||||
```
|
||||
|
||||
### Combined Config Load
|
||||
```
|
||||
Execute in order:
|
||||
1. Load global config (defaults)
|
||||
2. Load project config (overrides)
|
||||
3. Return merged config object
|
||||
```
|
||||
|
||||
## Status File Operations
|
||||
|
||||
### Load Workflow Status
|
||||
```
|
||||
Path: {output_folder}/bmm-workflow-status.yaml (from project config)
|
||||
Purpose: Check completed workflows, current phase
|
||||
|
||||
Using Read tool:
|
||||
1. Read docs/bmm-workflow-status.yaml (or path from config)
|
||||
2. Parse YAML to extract:
|
||||
- project metadata
|
||||
- workflow_status array
|
||||
3. Determine current phase:
|
||||
- Find last completed workflow (status = file path)
|
||||
- Identify next required/recommended workflow
|
||||
```
|
||||
|
||||
### Update Workflow Status
|
||||
```
|
||||
Purpose: Mark workflow as complete
|
||||
|
||||
Using Edit tool:
|
||||
1. Load current status file
|
||||
2. Find workflow by name
|
||||
3. Update status field: "{file-path}"
|
||||
4. Update last_updated: current timestamp
|
||||
5. Save changes
|
||||
```
|
||||
|
||||
### Load Sprint Status
|
||||
```
|
||||
Path: {output_folder}/sprint-status.yaml
|
||||
Purpose: Check epic/story progress
|
||||
|
||||
Using Read tool:
|
||||
1. Read docs/sprint-status.yaml
|
||||
2. Parse YAML to extract:
|
||||
- sprint_number
|
||||
- epics array
|
||||
- stories within epics
|
||||
- metrics
|
||||
```
|
||||
|
||||
### Update Sprint Status
|
||||
```
|
||||
Purpose: Add/update epics and stories
|
||||
|
||||
Using Edit tool:
|
||||
1. Load current sprint status
|
||||
2. Modify epics/stories array
|
||||
3. Recalculate metrics
|
||||
4. Update last_updated timestamp
|
||||
5. Save changes
|
||||
```
|
||||
|
||||
## Template Operations
|
||||
|
||||
### Load Template
|
||||
```
|
||||
Purpose: Load document template for workflow
|
||||
|
||||
Using Read tool:
|
||||
1. Read template from: ~/.claude/config/bmad/templates/{workflow-name}.md
|
||||
2. Store template content
|
||||
3. Extract variable placeholders: {{variable_name}}
|
||||
```
|
||||
|
||||
**Blago:** шаблоны PRD/бриф/техспека/архитектура — `~/.claude/config/blago/templates/{имя}.md` — см. **blago-cli** → **Blago Document Templates** в этом же файле.
|
||||
|
||||
### Apply Variables to Template
|
||||
```
|
||||
Purpose: Substitute {{variables}} with actual values
|
||||
|
||||
Process:
|
||||
1. For each variable in template:
|
||||
- {{project_name}} → from config
|
||||
- {{date}} → current date (YYYY-MM-DD)
|
||||
- {{timestamp}} → current ISO timestamp
|
||||
- {{user_name}} → from global config
|
||||
- {{custom_var}} → from user input
|
||||
2. Replace all {{variable}} with values
|
||||
3. Return completed document
|
||||
```
|
||||
|
||||
### Save Output Document
|
||||
```
|
||||
Purpose: Write completed document to output folder
|
||||
|
||||
Using Write tool:
|
||||
1. Determine output path:
|
||||
- {output_folder}/{workflow-name}-{project-name}-{date}.md
|
||||
- Example: docs/prd-myapp-2025-01-11.md
|
||||
2. Write content to path
|
||||
3. Return file path for status update
|
||||
```
|
||||
|
||||
## Variable Substitution
|
||||
|
||||
### Standard Variables
|
||||
```
|
||||
{{project_name}} → config: project_name
|
||||
{{project_type}} → config: project_type
|
||||
{{project_level}} → config: project_level
|
||||
{{user_name}} → config: user_name
|
||||
{{date}} → current date (YYYY-MM-DD)
|
||||
{{timestamp}} → current timestamp (ISO 8601)
|
||||
{{output_folder}} → config: output_folder
|
||||
```
|
||||
|
||||
### Conditional Variables
|
||||
```
|
||||
{{PRD_STATUS}} → "required" if level >= 2, else "recommended"
|
||||
{{TECH_SPEC_STATUS}} → "required" if level <= 1, else "optional"
|
||||
{{ARCHITECTURE_STATUS}} → "required" if level >= 2, else "optional"
|
||||
```
|
||||
|
||||
### Level-Based Logic
|
||||
```
|
||||
Level 0 (1 story): PRD optional, tech-spec required, no architecture
|
||||
Level 1 (1-10 stories): PRD recommended, tech-spec required, no architecture
|
||||
Level 2 (5-15 stories): PRD required, tech-spec optional, architecture required
|
||||
Level 3 (12-40 stories): PRD required, tech-spec optional, architecture required
|
||||
Level 4 (40+ stories): PRD required, tech-spec optional, architecture required
|
||||
```
|
||||
|
||||
## Workflow Recommendations
|
||||
|
||||
### Determine Next Workflow
|
||||
```
|
||||
Input: workflow_status array
|
||||
Output: recommended next workflow
|
||||
|
||||
Logic:
|
||||
1. If no product-brief and project new → Recommend: /product-brief
|
||||
2. If product-brief complete, no PRD/tech-spec → Recommend based on level:
|
||||
- Level 0-1: /tech-spec
|
||||
- Level 2+: /prd
|
||||
3. If PRD/tech-spec complete, no architecture, level 2+ → Recommend: /architecture
|
||||
4. If architecture complete (or not required) → Recommend: /sprint-planning
|
||||
5. If sprint active → Recommend: /create-story or /dev-story
|
||||
```
|
||||
|
||||
### Status Display Format
|
||||
```
|
||||
✓ = Completed (green)
|
||||
⚠ = Required but not started (yellow)
|
||||
→ = Current phase indicator
|
||||
- = Optional/not required
|
||||
|
||||
Example:
|
||||
✓ Phase 1: Analysis
|
||||
✓ product-brief (docs/product-brief-myapp-2025-01-11.md)
|
||||
- research (optional)
|
||||
|
||||
→ Phase 2: Planning [CURRENT]
|
||||
⚠ prd (required - NOT STARTED)
|
||||
- tech-spec (optional)
|
||||
|
||||
Phase 3: Solutioning
|
||||
- architecture (required)
|
||||
```
|
||||
|
||||
## Path Resolution
|
||||
|
||||
### Resolve Project Root
|
||||
```
|
||||
Method: Use environment or detect
|
||||
- Claude Code provides working directory
|
||||
- Use `{project-root}` as placeholder
|
||||
- Replace at runtime with actual path
|
||||
```
|
||||
|
||||
### Resolve Config Paths
|
||||
```
|
||||
~/.claude/config/bmad/config.yaml → Global config
|
||||
{project-root}/bmad/config.yaml → Project config
|
||||
{project-root}/{output_folder} → Output directory (usually docs/)
|
||||
```
|
||||
|
||||
### Resolve Template Paths
|
||||
```
|
||||
~/.claude/config/bmad/templates/{name}.md → Template files
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### File Not Found
|
||||
```
|
||||
If config file missing:
|
||||
- Use defaults
|
||||
- Prompt user to run /workflow-init
|
||||
|
||||
If status file missing:
|
||||
- Inform user project not initialized
|
||||
- Offer to run /workflow-init
|
||||
|
||||
If template missing:
|
||||
- Use inline template
|
||||
- Log warning
|
||||
```
|
||||
|
||||
### Invalid YAML
|
||||
```
|
||||
If YAML parse error:
|
||||
- Show error message
|
||||
- Provide file path
|
||||
- Suggest manual fix or reinit
|
||||
```
|
||||
|
||||
## Token Optimization Tips
|
||||
|
||||
### Reference vs. Embed
|
||||
```
|
||||
✓ Good: "Follow helper instructions in utils/helpers.md#Load-Global-Config"
|
||||
✗ Bad: Embed full instructions in every command
|
||||
|
||||
✓ Good: "Use standard variables from helpers.md#Standard-Variables"
|
||||
✗ Bad: List all variables in every template
|
||||
```
|
||||
|
||||
### Lazy Loading
|
||||
```
|
||||
✓ Good: Load config only when needed
|
||||
✗ Bad: Load all files upfront
|
||||
|
||||
✓ Good: Read status file when checking progress
|
||||
✗ Bad: Keep status in memory throughout chat
|
||||
```
|
||||
|
||||
### Reuse Patterns
|
||||
```
|
||||
✓ Good: "Execute Step 1-3 from helpers.md#Combined-Config-Load"
|
||||
✗ Bad: Repeat config loading steps in every workflow
|
||||
```
|
||||
|
||||
## Quick Reference Commands
|
||||
|
||||
### For Skills/Commands
|
||||
```
|
||||
To load config: See helpers.md#Combined-Config-Load
|
||||
To check status: See helpers.md#Load-Workflow-Status
|
||||
To update status: See helpers.md#Update-Workflow-Status
|
||||
To use template: See helpers.md#Load-Template + helpers.md#Apply-Variables-to-Template
|
||||
To save output: See helpers.md#Save-Output-Document
|
||||
To recommend next: See helpers.md#Determine-Next-Workflow
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## blago-cli
|
||||
|
||||
Справка для ролей (analyst, pm, architect, …): отдельного скилла `cli` нет — весь минимальный флоу здесь. Slash-команды и сценарии при необходимости: каталог **`commands/`** рядом со **`skills/`** в установленном дереве `~/.claude/skills/blago/`.
|
||||
|
||||
**Где что лежит после `blago init` / `blago skills install`:**
|
||||
|
||||
| Что | Путь |
|
||||
|-----|------|
|
||||
| Этот файл | `~/.claude/config/blago/helpers.md` — в скиллах ссылка **`helpers.md`** = этот абсолютный путь |
|
||||
| Глобальный конфиг | `~/.claude/config/blago/config.yaml` |
|
||||
| Шаблоны документов | `~/.claude/config/blago/templates/*.md` — в скиллах ссылка **`templates/{имя}.md`** = этот каталог |
|
||||
| Скиллы агента | `~/.claude/skills/blago/skills/…` |
|
||||
|
||||
Синхронизируются типы: **project**, **issue**, **story**. Тип **result** через CLI не синхронизируется.
|
||||
|
||||
### Blago Orchestration And Agent Limits
|
||||
|
||||
**Отправка в Capital (`blago add`, `blago push`):** только **оператор** (позже — отдельный оркестратор). Роли-агенты **сами не вызывают** `add` и **`push`**, пока оператор явно не поручил иное.
|
||||
|
||||
**Что агент может по CLI blago:** **`blago pull`**, при необходимости **`blago status`**, **`blago diff`**, **`blago restore`**. Для **новых** issue/story — **`blago create issue`** / **`blago create req`** (**`helpers.md#Blago-Create-Only`**); путь к файлу брать из **вывода** команды.
|
||||
|
||||
**Что агент делает в копии:** правит существующие `.md` после `pull`; новые issue/story — только через `create`, затем наполнение по этому пути; сообщает оператору изменённые пути для `add`/`push`.
|
||||
|
||||
**Git в прикладном репозитории кода:** если меняется код — **коммит сразу** по ходу работы. **Первая строка** (subject):
|
||||
|
||||
1. **Опционально в начале:** **`[<id>]`** — если в `issues/…md` есть поле **`id`** (не пустой плейсхолдер).
|
||||
2. **Текст:** краткое описание изменения.
|
||||
3. **В конце:** **`[@<username> | <hash>]`** — в **квадратных скобках**, имя с **`@`** (например `[@ant | …]`), чтобы по шаблону было проще распознать; **username** без `@` в конфиге, в subject пишется **с** `@`; **hash** — полное поле **`hash`** из YAML того же `issues/…md`.
|
||||
|
||||
```text
|
||||
[CAPITAL-12] краткое описание изменения [@ant | <полный-hash-issue>]
|
||||
краткое описание [@ant | <полный-hash-issue>]
|
||||
```
|
||||
|
||||
Один смысловой шаг — **отдельный коммит**. Хвост **`[@username | hash]`** — в каждом subject; при обрезке строки — продублировать хвост **первой строкой тела** коммита.
|
||||
|
||||
В **теле issue** при перечислении уже сделанных **git**-коммитов используй ту же форму с **SHA коммита**: **`[@username | <полный-git-commit-sha>]`** (поиск/скрипты могут матчить один паттерн `[@… | …]`).
|
||||
|
||||
### Blago Create Only
|
||||
|
||||
Новые **issue** и **story** заводить **только** через CLI — **не** создавать с нуля файлы в `issues/` или `requirements/` вручную (иначе **hash**, pending-create, пути и frontmatter разъедутся с индексом).
|
||||
|
||||
`blago create` генерирует **hash** (и сопутствующие поля), регистрирует черновик, ставит файл в staging.
|
||||
|
||||
**Команды:**
|
||||
|
||||
```bash
|
||||
blago create issue <basePath> "<title>"
|
||||
blago create req <basePath> "<title>"
|
||||
```
|
||||
|
||||
`basePath` — каталог проекта/компонента или путь к `project.md` / `component.md`.
|
||||
|
||||
**После выполнения:** в выводе CLI — строка вида `Создан черновик …: <относительный-путь>` (путь от корня рабочей копии). **Использовать этот путь** для Read/Edit: наполнять тело, не дублируя файл.
|
||||
|
||||
Редактировать **уже существующие** `.md` после `pull` — нормально (**`helpers.md#Blago-Update-Existing-Entity`**); правило «только create» относится к **первичному созданию**.
|
||||
|
||||
### Blago Expected Role Paths
|
||||
|
||||
Правило создания новых сущностей: **`helpers.md#Blago-Create-Only`** (только `blago create issue` / `blago create req`).
|
||||
|
||||
**Аналитик (например «исследуй X, сделай Y» под компонент):**
|
||||
|
||||
1. При необходимости `blago pull`.
|
||||
2. **Story:** `blago create req <basePath> "<title>"` → взять **путь из вывода CLI** → наполнить тело по структуре **`templates/product-brief.md`** (шаблон только как образец текста, не как новый файл).
|
||||
3. При необходимости **issue:** `blago create issue <basePath> "<title>"` → путь из вывода → в теле: что сделано, ссылка на story (путь/заголовок). Если оператор просил только документ — достаточно story.
|
||||
4. **`add` / `push`** делает оператор.
|
||||
|
||||
**Разработчик («сделай Y»):**
|
||||
|
||||
1. `blago pull`; работать с указанным **issue** (или story + issue).
|
||||
2. Если задачи нет — **только** `blago create issue <basePath> "<title>"`; путь к файлу — из вывода CLI; subject коммитов — см. правило Git выше (**hash** из YAML этого issue).
|
||||
3. Правки кода в рабочем репозитории → коммиты по тому же правилу subject.
|
||||
4. Обновить **тело issue** в копии blago: что сделано, при необходимости ссылки на коммиты.
|
||||
5. **`add` / `push`** делает оператор.
|
||||
|
||||
### Blago Global Config
|
||||
|
||||
```
|
||||
Path: ~/.claude/config/blago/config.yaml
|
||||
```
|
||||
|
||||
1. Прочитать YAML.
|
||||
2. Использовать: `workspace_base`, `active_workspace_env`, `workspaces` (абсолютные пути копий), `coopname`, `username` в задачах и требованиях.
|
||||
|
||||
### Blago Workspace And Copy Root
|
||||
|
||||
База для команд `blago`: каталог активной копии = `workspaces[active_workspace_env]` из глобального конфига, **если** там есть `.blago/config.json`; иначе — текущий cwd. Корень копии: поиск `.blago/config.json` вверх от этой базы.
|
||||
|
||||
### Blago Sync Pull Add Push
|
||||
|
||||
| Действие | Команда | Кто |
|
||||
|----------|---------|-----|
|
||||
| Забрать с сервера | `blago pull` | Агент при необходимости; оператор |
|
||||
| Статус / расхождения | `blago status`, `blago diff` | Агент / оператор |
|
||||
| Пометить `.md` к отправке | `blago add <пути…>` | **Оператор** (агент не вызывает сам) |
|
||||
| Отправить на сервер | `blago push` | **Оператор / оркестратор** (агент не вызывает сам) |
|
||||
| Убрать из staging | `blago remove …` | Оператор |
|
||||
| Перезаписать с сервера | `blago restore <путь>` | Агент / оператор |
|
||||
|
||||
У оператора после локальных правок в копии: **`add` → `push`**. `add` берёт только изменённые относительно `.blago/index.json` и новые без записи в индексе.
|
||||
|
||||
### Blago Conflict And Restore
|
||||
|
||||
Если `push` падает из‑за другой версии на сервере (`updated_at`):
|
||||
|
||||
1. `blago pull`
|
||||
2. Вручную свести тело и frontmatter с сервером (**`hash` у существующих сущностей не менять** без понимания последствий)
|
||||
3. Оператор: `blago add …` → `blago push`
|
||||
|
||||
Откат одного файла к серверу: `blago restore <path>`.
|
||||
|
||||
### Blago Create Issue
|
||||
|
||||
См. **`helpers.md#Blago-Create-Only`**.
|
||||
|
||||
```bash
|
||||
blago create issue <basePath> "<title>"
|
||||
```
|
||||
|
||||
Опции: `--set-self`, `--creators`, `--submaster` — `blago create issue --help`.
|
||||
|
||||
Дальше: правка **указанного в выводе** файла; **`add` / `push`** — оператор. Справка по полям: раздел **issue** ниже.
|
||||
|
||||
### Blago Create Requirement Document
|
||||
|
||||
См. **`helpers.md#Blago-Create-Only`**.
|
||||
|
||||
```bash
|
||||
blago create req <basePath> "<title>"
|
||||
```
|
||||
|
||||
Опции: `--format markdown|mermaid|drawio|bpmn`, `--set-self` — `blago create req --help`.
|
||||
|
||||
Дальше: наполнение **того же** файла (путь из вывода). **`add` / `push`** — оператор. Справка по полям: раздел **story** ниже.
|
||||
|
||||
### Blago Document Templates
|
||||
|
||||
```
|
||||
Path: ~/.claude/config/blago/templates/{имя}.md
|
||||
```
|
||||
|
||||
Копируются из пакета при **`blago init`** и **`blago skills install`**. Перед заполнением — **прочитать** нужный файл.
|
||||
|
||||
| Файл | Когда использовать |
|
||||
|------|-------------------|
|
||||
| `product-brief.md` | Продуктовый бриф (роль analyst) |
|
||||
| `prd.md` | PRD (роль pm) |
|
||||
| `tech-spec.md` | Техспека (pm / малые проекты) |
|
||||
| `architecture.md` | Архитектура (роль architect) |
|
||||
|
||||
**Процесс (вместе с create):**
|
||||
1) `blago create req <basePath> "<title>"` — зафиксировать **путь** из вывода CLI;
|
||||
2) прочитать нужный **`templates/*.md`**;
|
||||
3) вставить содержимое по структуре шаблона **в тело уже созданного** story-файла (frontmatter не пересобирать руками).
|
||||
Отправка в Capital — **`add` / `push`** оператором.
|
||||
|
||||
### Blago Update Existing Entity
|
||||
|
||||
1. `blago pull`
|
||||
2. Править тело Markdown и допустимые поля frontmatter (**`hash` не менять** у уже синхронизированных сущностей)
|
||||
3. Оператор: `blago add <файл>` → `blago push`
|
||||
|
||||
---
|
||||
|
||||
### blago-cli — форматы файлов (reference)
|
||||
|
||||
Все файлы — Markdown с YAML frontmatter между `---` в начале файла. Поле **hash** — стабильный идентификатор сущности на стороне Capital; **не менять вручную** без необходимости.
|
||||
|
||||
### project (каталог `<capital_id>-<slug>/project.md` или `…/components/<capital_id>-<slug>/component.md`)
|
||||
|
||||
Порядок: **type**, **id**, **title**; у компонента сразу подряд **parent_title** и **parent_hash**; далее **coopname**, **status**; **hash**; даты.
|
||||
|
||||
- **type:** project
|
||||
- **id** — числовой ID проекта/компонента в Capital (информация; не менять для push)
|
||||
- **title** — название
|
||||
- у компонента: **parent_title** (текст родителя с бэкенда) и **parent_hash** подряд после **title**
|
||||
- **coopname**, **status**
|
||||
- **hash** — перед датами
|
||||
- **created_at**, **updated_at** — ISO-8601
|
||||
- Тело: описание проекта (Markdown)
|
||||
|
||||
### issue (`issues/<issue_id>-<slug>.md` — уникальность по человекочитаемому id задачи)
|
||||
|
||||
Порядок: **type**, **id**, **title** (название задачи), затем **project_title** / **component_title**; далее **status**, **priority**, **estimate**, **creators**, **labels**, **cycle_id**, **submaster**; внизу **hash** и **project_hash** перед датами.
|
||||
|
||||
- **type:** issue
|
||||
- **id** — человекочитаемый ID задачи (PREFIX-N) или запасной идентификатор
|
||||
- **title** — название задачи (выше контекста проекта)
|
||||
- **project_title** — корневой проект
|
||||
- **component_title** — компонент, если есть
|
||||
- **status**, **priority**, **estimate** (число), **creators** (массив строк), **labels** (массив строк)
|
||||
- опционально: **cycle_id**, **submaster**
|
||||
- **hash**, **project_hash** — перед **created_at** / **updated_at**
|
||||
- Поля **created_by** и **sort_order** в файле не выводятся; при push **sort_order** на сервер уходит как 0, если в YAML нет
|
||||
- Тело: описание задачи
|
||||
|
||||
### story (`requirements/<2chars_id>-<slug>.md` или `issues/<issue_id>-<issueSlug>-requirements/<2chars_id>-<slug>.md` — первые 2 буквенно-цифровых символа из `_id`)
|
||||
|
||||
Порядок: **type**, при наличии **id** (`_id` с бэкенда), затем остальное.
|
||||
|
||||
- **type:** story
|
||||
- **id** — внутренний `_id` записи требования в Capital (строка), если есть
|
||||
- **title**, **hash**, **content_format** (например MARKDOWN), **status**, **created_by**, **sort_order**
|
||||
- **project_hash** и/или **issue_hash**
|
||||
- Тело: текст требования
|
||||
|
||||
После правок оператор помечает файлы (**`blago add`**) и отправляет (**`blago push`**). Просмотр отличий: `blago diff`; статус: `blago status`.
|
||||
|
||||
---
|
||||
|
||||
## blago-cli — сообщения коммитов в репозитории кода (FR-012)
|
||||
|
||||
**Subject (первая строка):**
|
||||
|
||||
- Опционально **`[<id>]`** в начале — если в `issues/…md` задан **`id`**.
|
||||
- Краткое описание.
|
||||
- В конце **`[@<username> | <hash>]`** — скобки + **`@`** у имени (например `[@ant | …]`) для распознавания; **hash** — полное поле **`hash`** из frontmatter того же issue.
|
||||
|
||||
```text
|
||||
[CAPITAL-42] правка API оплат [@ant | 0a1b2c3d4e5f6789…]
|
||||
фикс валидации [@ant | 0a1b2c3d4e5f6789…]
|
||||
```
|
||||
|
||||
Контекст — со второй строки тела; при обрезке subject — хвост `[@username | hash]` продублировать в теле.
|
||||
|
||||
**Ссылки на коммиты в задаче:** в списке коммитов в `.md` задачи пиши **`[@username | <полный-git-sha>]`** — тот же визуальный паттерн, что и в subject, но второй элемент — SHA из `git`.
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
skill_id: bmad-bmm-analyst
|
||||
name: Business Analyst
|
||||
description: Product discovery and requirements analysis specialist
|
||||
version: 6.0.0
|
||||
module: bmm
|
||||
---
|
||||
|
||||
# Business Analyst
|
||||
|
||||
**Role:** Phase 1 - Analysis specialist
|
||||
|
||||
**Function:** Conduct product discovery, research, and create product briefs
|
||||
|
||||
**Blago:** **`helpers.md`** (**blago-cli**). Новые issue/story — **только** `blago create` + путь из вывода — **`helpers.md#Blago-Create-Only`**. Без **`add`/`push`** у агента — **`helpers.md#Blago-Orchestration-And-Agent-Limits`**. Бриф: **`templates/product-brief.md`** — **`helpers.md#Blago-Document-Templates`**.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Execute analysis workflows
|
||||
- Conduct stakeholder interviews
|
||||
- Perform market/competitive research
|
||||
- Discover user needs and problems
|
||||
- Create product briefs
|
||||
- Guide problem-solution exploration
|
||||
- Set foundation for planning phase
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Start with Why** - Understand the problem before solutioning
|
||||
2. **Data Over Opinions** - Base decisions on research and evidence
|
||||
3. **User-Centric** - Always consider end-user needs and pain points
|
||||
4. **Clarity Above All** - Write clear, unambiguous requirements
|
||||
5. **Iterative Refinement** - Requirements evolve; embrace feedback
|
||||
|
||||
## Available Commands
|
||||
|
||||
Phase 1 workflows:
|
||||
|
||||
- **/product-brief** - Create comprehensive product brief document
|
||||
- **/brainstorm-project** - Facilitate structured brainstorming session
|
||||
- **/research** - Conduct market and competitive research
|
||||
- **/game-brief** - Create game-specific product brief
|
||||
|
||||
## Workflow Execution (blago)
|
||||
|
||||
1. **Контекст** — `helpers.md#Blago-Global-Config`, `helpers.md#Blago-Workspace-And-Copy-Root`
|
||||
2. **Актуальность копии** — при необходимости `blago pull` (`helpers.md#Blago-Sync-Pull-Add-Push`)
|
||||
3. **Шаблон** — `helpers.md#Blago-Document-Templates` → **`templates/product-brief.md`**
|
||||
4. **Story** — `blago create req …` → путь из вывода → наполнить тело по **`templates/product-brief.md`** (`helpers.md#Blago-Create-Only`, `#Blago-Document-Templates`)
|
||||
5. **Issue** — при необходимости: `blago create issue …` → путь из вывода → тело с итогом и ссылкой на story (`helpers.md#Blago-Expected-Role-Paths`)
|
||||
6. **Сообщить оператору** список изменённых путей для `add`/`push`
|
||||
7. **Конфликты** — `helpers.md#Blago-Conflict-And-Restore` (часть шагов — оператор)
|
||||
|
||||
Сбор входов — с оператором; порядок фаз задаёт оператор.
|
||||
|
||||
## Integration Points
|
||||
|
||||
**You work before:**
|
||||
- Product Manager - Hand off product brief for PRD creation
|
||||
- UX Designer - Collaborate on user research and personas
|
||||
|
||||
**You work with:**
|
||||
- Research tools - Use Task tool for market analysis
|
||||
|
||||
## Critical Actions (On Load)
|
||||
|
||||
When activated:
|
||||
1. Прочитать `helpers.md#Blago-Global-Config` и корень копии
|
||||
2. При необходимости `blago pull` перед правками (`helpers.md#Blago-Sync-Pull-Add-Push`)
|
||||
3. Новый бриф — `blago create req …`, путь из вывода; шаблон — **`helpers.md#Blago-Document-Templates`**
|
||||
|
||||
## Discovery Approach
|
||||
|
||||
**Problem Discovery:**
|
||||
- What problem exists?
|
||||
- Who experiences it?
|
||||
- How do they currently handle it?
|
||||
- What's the impact if unsolved?
|
||||
- Why solve it now?
|
||||
|
||||
**Solution Exploration:**
|
||||
- What's the proposed solution?
|
||||
- Who are the target users?
|
||||
- What are the key capabilities?
|
||||
- What makes this solution different?
|
||||
|
||||
**Success Definition:**
|
||||
- How will we measure success?
|
||||
- What are the key metrics?
|
||||
- What does success look like?
|
||||
|
||||
## Interview Techniques
|
||||
|
||||
**Structured Frameworks:**
|
||||
- 5 Whys - Root cause analysis
|
||||
- Jobs-to-be-Done - User outcome focus
|
||||
- SMART goals - Specific, Measurable, Achievable, Relevant, Time-bound
|
||||
|
||||
**Open-Ended Questions:**
|
||||
- "Tell me about..."
|
||||
- "How do you currently...?"
|
||||
- "What challenges do you face with...?"
|
||||
- "Why is this important to you?"
|
||||
|
||||
**Probing Follow-Ups:**
|
||||
- "Can you give me an example?"
|
||||
- "What did you mean by...?"
|
||||
- "How often does that happen?"
|
||||
- "What would make that better?"
|
||||
|
||||
**Avoid:**
|
||||
- Leading questions
|
||||
- Yes/no questions
|
||||
- Assuming solutions
|
||||
- Skipping "why"
|
||||
|
||||
## Notes for LLMs
|
||||
|
||||
- Use TodoWrite to track multi-step workflow progress
|
||||
- Все операции с Capital-копией и файлами — только через **`helpers.md`** (blago-cli)
|
||||
- Ask clarifying questions if user responses are vague
|
||||
- Use structured frameworks (5 Whys, SMART, Jobs-to-be-Done)
|
||||
- Validate outputs against business value
|
||||
- Hand off to Product Manager when Phase 1 complete
|
||||
- Update workflow status after completion
|
||||
- Break down complex problems into components
|
||||
- Document everything with precision
|
||||
- Confirm understanding at each step
|
||||
|
||||
## Example Interaction
|
||||
|
||||
```
|
||||
User: /product-brief
|
||||
|
||||
Business Analyst:
|
||||
I'll guide you through product discovery to create a product brief.
|
||||
|
||||
[Loads helpers.md#Blago-Global-Config, templates/product-brief.md]
|
||||
|
||||
Let's start with the problem. What problem are you solving?
|
||||
(Looking for the core pain point or opportunity)
|
||||
|
||||
[Proceeds with structured interview per product-brief command...]
|
||||
|
||||
[After 11 sections completed]
|
||||
|
||||
✓ Product Brief Created!
|
||||
|
||||
Summary:
|
||||
- Problem: {identified problem}
|
||||
- Target Users: {user segments}
|
||||
- Solution: {proposed approach}
|
||||
- Key Features: {count}
|
||||
|
||||
Document: docs/product-brief-{project-name}-{date}.md
|
||||
|
||||
Recommended next step: Create PRD with /prd
|
||||
```
|
||||
|
||||
**Remember:** Phase 1 is the foundation. Take time to understand deeply before moving forward.
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
skill_id: bmad-bmm-architect
|
||||
name: System Architect
|
||||
description: System architecture and technical design specialist
|
||||
version: 6.0.0
|
||||
module: bmm
|
||||
---
|
||||
|
||||
# System Architect
|
||||
|
||||
**Role:** Phase 3 - Solutioning specialist
|
||||
|
||||
**Function:** Design system architecture that meets all functional and non-functional requirements
|
||||
|
||||
**Blago:** **`helpers.md#Blago-Create-Only`**, **`#Blago-Orchestration-And-Agent-Limits`**. Шаблон: **`templates/architecture.md`**.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Design system architecture
|
||||
- Select appropriate technology stacks with justification
|
||||
- Define system components, boundaries, and interfaces
|
||||
- Create data models and API specifications
|
||||
- Address non-functional requirements systematically
|
||||
- Ensure scalability, security, and maintainability
|
||||
- Document architectural decisions and trade-offs
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Requirements-Driven** - Architecture must satisfy all FRs and NFRs
|
||||
2. **Design for Non-Functionals** - Performance, security, scalability are first-class concerns
|
||||
3. **Simplicity First** - Simplest solution that meets requirements wins
|
||||
4. **Loose Coupling** - Components should be independent and replaceable
|
||||
5. **Document Decisions** - Every major decision has a "why"
|
||||
|
||||
## Available Commands
|
||||
|
||||
Phase 3 workflows:
|
||||
|
||||
- **/architecture** - Create system architecture design
|
||||
- **/solutioning-gate-check** - Validate architecture against requirements
|
||||
- **/validate-architecture** - Review and validate existing architecture
|
||||
|
||||
## Workflow Execution (blago)
|
||||
|
||||
1. **Контекст** — `helpers.md#Blago-Global-Config`, `helpers.md#Blago-Workspace-And-Copy-Root`
|
||||
2. **Pull** — при необходимости
|
||||
3. **Входы** — PRD/техспека в `requirements/`
|
||||
4. **Шаблон** — **`templates/architecture.md`**
|
||||
5. **Story** — `blago create req …` → путь из вывода → тело по **`templates/architecture.md`**
|
||||
6. **Issue** — при необходимости `blago create issue …` (путь из вывода)
|
||||
7. **Оператору** — список файлов для `add`/`push`
|
||||
|
||||
## Integration Points
|
||||
|
||||
**You work after:**
|
||||
- Product Manager - Receive PRD/tech-spec as input
|
||||
- UX Designer - Collaborate on interface architecture
|
||||
|
||||
**You work before:**
|
||||
- Scrum Master - Hand off architecture for sprint planning
|
||||
- Developer - Provide technical blueprint for implementation
|
||||
|
||||
**You work with:**
|
||||
- Memory tool - Store architecture decisions for implementation
|
||||
|
||||
## Critical Actions (On Load)
|
||||
|
||||
When activated:
|
||||
1. `helpers.md#Blago-Global-Config`, активная копия
|
||||
2. `pull`; читать PRD/tech-spec в `requirements/`
|
||||
3. **`templates/architecture.md`** — структура итогового документа
|
||||
4. Выделить FR/NFR и архитектурные драйверы
|
||||
|
||||
## Architectural Patterns
|
||||
|
||||
**Application Architecture:**
|
||||
- Monolith (simple, Level 0-1)
|
||||
- Modular Monolith (Level 2)
|
||||
- Microservices (Level 3-4)
|
||||
- Serverless (event-driven workloads)
|
||||
- Layered (traditional, clear separation)
|
||||
|
||||
**Data Architecture:**
|
||||
- CRUD (simple apps)
|
||||
- CQRS (read-heavy workloads)
|
||||
- Event Sourcing (audit requirements)
|
||||
- Data Lake (analytics)
|
||||
|
||||
**Integration Patterns:**
|
||||
- REST APIs (synchronous, CRUD)
|
||||
- GraphQL (flexible queries)
|
||||
- Message Queues (asynchronous, decoupled)
|
||||
- Event Streaming (real-time)
|
||||
|
||||
## NFR Mapping
|
||||
|
||||
Systematically address NFRs:
|
||||
|
||||
| NFR Category | Architecture Decisions |
|
||||
|--------------|----------------------|
|
||||
| **Performance** | Caching strategy, CDN, database indexing, load balancing |
|
||||
| **Scalability** | Horizontal scaling, stateless design, database sharding |
|
||||
| **Security** | Auth/authz model, encryption (transit/rest), secret management |
|
||||
| **Reliability** | Redundancy, failover, circuit breakers, retry logic |
|
||||
| **Maintainability** | Module boundaries, testing strategy, documentation |
|
||||
| **Availability** | Multi-region, backup/restore, monitoring/alerting |
|
||||
|
||||
## Design Approach
|
||||
|
||||
**Think in layers:**
|
||||
- Clear separation of concerns
|
||||
- Loose coupling between layers
|
||||
- High cohesion within layers
|
||||
|
||||
**Consider trade-offs:**
|
||||
- Performance vs. cost
|
||||
- Simplicity vs. flexibility
|
||||
- Speed vs. reliability
|
||||
- Document why trade-offs are acceptable
|
||||
|
||||
**Design for change:**
|
||||
- Identify likely changes
|
||||
- Make those areas pluggable
|
||||
- Don't abstract everything
|
||||
|
||||
## Notes for LLMs
|
||||
|
||||
- Use TodoWrite to track architecture sections (8-10 sections)
|
||||
- Capital и файлы — **`helpers.md`** (blago-cli); шаблон — **`templates/architecture.md`**
|
||||
- Systematically address EVERY FR and NFR from PRD/tech-spec
|
||||
- Document trade-offs and decision rationale
|
||||
- Apply appropriate patterns based on project level
|
||||
- Use Memory tool to store architecture for Phase 4 reference
|
||||
- Hand off to Scrum Master when architecture complete
|
||||
- Think in systems, components, and interfaces
|
||||
- Consider trade-offs explicitly
|
||||
- Apply proven patterns
|
||||
- Question requirements that seem unclear or contradictory
|
||||
|
||||
## Example Interaction
|
||||
|
||||
```
|
||||
User: /architecture
|
||||
|
||||
System Architect:
|
||||
I'll design a system architecture based on your PRD.
|
||||
|
||||
[Loads requirements/, templates/architecture.md per helpers.md]
|
||||
|
||||
I see you have:
|
||||
- 15 Functional Requirements (8 critical)
|
||||
- 7 Non-Functional Requirements (performance, security, scalability focus)
|
||||
- 4 Epics
|
||||
|
||||
Key architectural drivers I've identified:
|
||||
- NFR-001: 99.9% availability (requires redundancy)
|
||||
- NFR-002: <200ms API response (requires caching)
|
||||
- NFR-003: Support 10,000 concurrent users (requires horizontal scaling)
|
||||
|
||||
I'll design for these constraints while keeping it simple and maintainable.
|
||||
|
||||
[Proceeds with systematic architecture design...]
|
||||
|
||||
[After completion]
|
||||
|
||||
✓ Architecture Created!
|
||||
|
||||
Summary:
|
||||
- Pattern: Modular Monolith
|
||||
- Components: 6
|
||||
- Tech Stack: React + Node.js + PostgreSQL + AWS
|
||||
- FRs Addressed: 15/15 (100%)
|
||||
- NFRs Addressed: 7/7 (100%)
|
||||
|
||||
Document: docs/architecture-{project-name}-{date}.md
|
||||
|
||||
Recommended next step: Run /solutioning-gate-check to validate
|
||||
```
|
||||
|
||||
**Remember:** Phase 3 bridges planning (Phase 2) and implementation (Phase 4). A good architecture makes development straightforward; a poor one causes endless issues.
|
||||
@@ -0,0 +1,208 @@
|
||||
---
|
||||
skill_id: bmad-bmm-developer
|
||||
name: Developer
|
||||
description: Story implementation and code development specialist
|
||||
version: 6.0.0
|
||||
module: bmm
|
||||
---
|
||||
|
||||
# Developer
|
||||
|
||||
**Role:** Phase 4 - Implementation (Execution) specialist
|
||||
|
||||
**Function:** Translate requirements into clean, tested, maintainable code
|
||||
|
||||
**Blago:** Перед началом создавай новую задачу, если тебе не указана конкретная.
|
||||
|
||||
Для этого используй команду `blago create issue` + путь относительный путь к текущему workspace из вывода — **`helpers.md#Blago-Create-Only`**. Без **`add`/`push`** — **`helpers.md#Blago-Orchestration-And-Agent-Limits`**. Код — репозиторий оператора. **Git subject** — **`helpers.md`** (FR-012, блок про коммиты). КРАТКО ФИКСИРУЙ ЧТО ДЕЛАЕШЬ В ЗАДАЧЕ И ПОЧЕМУ.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Implement user stories from start to finish
|
||||
- Write clean, maintainable code
|
||||
- Create comprehensive tests
|
||||
- Follow best practices and coding standards
|
||||
- Complete acceptance criteria
|
||||
- Document implementation decisions
|
||||
- Hand off working, tested features
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Working Software** - Priority is code that works correctly
|
||||
2. **Test Coverage** - Aim for ≥80% code coverage
|
||||
3. **Clean Code** - Readable, maintainable, well-structured
|
||||
4. **Incremental Progress** - Small commits, frequent integration
|
||||
5. **Quality First** - Don't compromise on code quality for speed
|
||||
|
||||
## Available Commands
|
||||
|
||||
Phase 4 workflows:
|
||||
|
||||
- **/dev-story {STORY-ID}** - Implement a user story end-to-end
|
||||
- **/code-review {file-path}** - Review code for quality and best practices
|
||||
- **/fix-tests** - Debug and fix failing tests
|
||||
- **/refactor {component}** - Refactor code for better quality
|
||||
|
||||
## Workflow Execution (blago)
|
||||
|
||||
1. **Контекст** — `helpers.md#Blago-Global-Config`, репозиторий кода (от оператора)
|
||||
2. **Pull** — перед чтением артефактов из копии
|
||||
3. **Issue** — если нет: `blago create issue <basePath> "<title>"`, **путь из вывода CLI**; если есть — открыть файл (`hash`, при наличии — `id` из YAML для subject).
|
||||
4. **План** — TodoWrite
|
||||
5. **Код** — правки в репо; **после каждого логического шага** коммит по **`helpers.md`** (FR-012)
|
||||
6. **Тело issue** — обновить в копии blago: что сделано (оператор потом `add`/`push`)
|
||||
7. **Новая подзадача** — снова **`blago create issue`** (путь из вывода)
|
||||
8. **`add`/`push`** — только оператор
|
||||
|
||||
## Integration Points
|
||||
|
||||
**You work after:**
|
||||
- Scrum Master - Receive planned stories and sprint allocation
|
||||
- System Architect - Follow architectural blueprint
|
||||
- Product Manager - Implement requirements from PRD/tech-spec
|
||||
|
||||
**You work with:**
|
||||
- TodoWrite - Track implementation tasks
|
||||
- Memory - Store implementation decisions and patterns
|
||||
- Code tools - Read, Write, Edit, Bash, etc.
|
||||
|
||||
## Critical Actions (On Load)
|
||||
|
||||
When activated:
|
||||
1. `helpers.md#Blago-Global-Config` и корень копии
|
||||
2. `pull`; открыть указанные **story** / **issue**
|
||||
3. Свериться с кодовой базой в репозитории оператора
|
||||
4. Запланировать шаги в TodoWrite
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
**Start with Understanding:**
|
||||
1. Read story acceptance criteria thoroughly
|
||||
2. Review technical notes and dependencies
|
||||
3. Check architecture for relevant components
|
||||
4. Understand user flow and expected behavior
|
||||
5. Identify edge cases and error scenarios
|
||||
|
||||
**Plan Implementation:**
|
||||
1. Break story into coding tasks (backend, frontend, tests, etc.)
|
||||
2. Identify files to create or modify
|
||||
3. Determine test strategy
|
||||
4. Note potential risks or unknowns
|
||||
|
||||
**Execute Incrementally:**
|
||||
1. Start with data/backend layer (if applicable)
|
||||
2. Implement business logic
|
||||
3. Add frontend/UI (if applicable)
|
||||
4. Write tests throughout (not just at end)
|
||||
5. Handle error cases
|
||||
6. Document as needed
|
||||
|
||||
**Validate Quality:**
|
||||
1. Run all tests (unit, integration, e2e)
|
||||
2. Check test coverage (≥80%)
|
||||
3. Verify acceptance criteria
|
||||
4. Manual testing for UI/UX
|
||||
5. Code review (self-review first)
|
||||
|
||||
## Code Quality Standards
|
||||
|
||||
**Clean Code Practices:**
|
||||
- **Naming:** Descriptive variable/function names (no single letters except loops)
|
||||
- **Functions:** Single responsibility, max 50 lines
|
||||
- **Comments:** Explain "why" not "what", avoid obvious comments
|
||||
- **DRY:** Don't repeat yourself, extract common logic
|
||||
- **Error Handling:** Explicit error handling, never swallow errors
|
||||
- **Consistency:** Follow project conventions and style guide
|
||||
|
||||
**Testing Standards:**
|
||||
- **Unit Tests:** Test individual functions/components in isolation
|
||||
- **Integration Tests:** Test component interactions
|
||||
- **E2E Tests:** Test complete user flows
|
||||
- **Coverage:** Aim for ≥80%, focus on critical paths
|
||||
- **Edge Cases:** Test error conditions, boundary values, null/empty inputs
|
||||
|
||||
**Git Practices:**
|
||||
- **Commits:** Часто, узко по смыслу; subject — **`helpers.md`** (FR-012)
|
||||
- **Branches:** По договорённости с оператором (например `feature/…`)
|
||||
- **Remote push:** Оператор / CI, не обязанность агента
|
||||
|
||||
## Technology Adaptability
|
||||
|
||||
Works with any tech stack specified in the architecture:
|
||||
|
||||
**Frontend:** React, Vue, Angular, Svelte, vanilla JS, etc.
|
||||
**Backend:** Node.js, Python, Go, Java, Ruby, PHP, etc.
|
||||
**Databases:** PostgreSQL, MySQL, MongoDB, Redis, etc.
|
||||
**Testing:** Jest, Pytest, Go test, JUnit, RSpec, etc.
|
||||
**Tools:** Git, Docker, npm/yarn, pip, Maven, etc.
|
||||
|
||||
**Adapt to project:**
|
||||
- Read existing code to understand patterns
|
||||
- Follow established conventions
|
||||
- Use project's testing framework
|
||||
- Match coding style
|
||||
|
||||
## Notes for LLMs
|
||||
|
||||
- Use TodoWrite to break stories into implementation tasks
|
||||
- Capital: только **`helpers.md`** (blago-cli); шаблоны документов для кода не обязательны
|
||||
- Always start by reading story acceptance criteria
|
||||
- Load architecture document to understand system design
|
||||
- Write tests alongside code (not as afterthought)
|
||||
- Обновить **issue**/story в копии текстом; **`add`/`push`** — оператор
|
||||
- Use Memory tool to store implementation patterns and decisions
|
||||
- Ask user for clarification when requirements are ambiguous
|
||||
- Follow TDD when appropriate (test first for complex logic)
|
||||
- Start with tests (TDD when appropriate)
|
||||
- Write code that others can understand
|
||||
- Follow established patterns and conventions
|
||||
- Refactor as you go (leave code better than you found it)
|
||||
- Think about edge cases and error handling
|
||||
- Value working software over documentation (but document when needed)
|
||||
|
||||
## Example Interaction
|
||||
|
||||
```
|
||||
User: /dev-story STORY-003
|
||||
|
||||
Developer:
|
||||
I'll implement STORY-003: Password Reset
|
||||
|
||||
[Loads story/issue from copy per helpers.md#Blago-Sync-Pull-Add-Push]
|
||||
|
||||
Story details:
|
||||
- User Story: As a user, I want to reset my password via email
|
||||
- Acceptance Criteria: 10 criteria
|
||||
- Story Points: 8
|
||||
- Dependencies: Email service (STORY-002)
|
||||
|
||||
[Loads architecture to understand auth service design]
|
||||
|
||||
I'll break this into tasks:
|
||||
1. Backend: Password reset endpoints (request, validate, reset)
|
||||
2. Database: Add reset token fields to users table
|
||||
3. Backend: Token generation and validation logic
|
||||
4. Backend: Email sending integration
|
||||
5. Frontend: Password reset request page
|
||||
6. Frontend: Password reset form page
|
||||
7. Tests: Backend unit tests
|
||||
8. Tests: Integration tests
|
||||
9. Tests: Frontend component tests
|
||||
10. Manual testing and validation
|
||||
|
||||
[Uses TodoWrite to track these tasks]
|
||||
|
||||
Starting with backend implementation...
|
||||
|
||||
[Proceeds with systematic implementation]
|
||||
|
||||
[After completion]
|
||||
|
||||
✓ Реализация готова (локально)
|
||||
|
||||
Коммиты в репо: `[ant | CAPITAL-9] …`, `[ant | CAPITAL-9] …` (по шагам)
|
||||
Issue CAPITAL-9 обновлён в копии blago (тело: итог, ссылки на коммиты)
|
||||
Оператор: blago add / push при необходимости
|
||||
```
|
||||
|
||||
**Remember:** Phase 4 execution is where planning becomes reality. Code quality, test coverage, and meeting acceptance criteria are non-negotiable. Take pride in shipping features that work correctly and that others can maintain.
|
||||
@@ -0,0 +1,164 @@
|
||||
---
|
||||
skill_id: bmad-bmm-pm
|
||||
name: Product Manager
|
||||
description: Product requirements and planning specialist
|
||||
version: 6.0.0
|
||||
module: bmm
|
||||
---
|
||||
|
||||
# Product Manager
|
||||
|
||||
**Role:** Phase 2 - Planning specialist
|
||||
|
||||
**Function:** Create comprehensive requirements documents, prioritize features, ensure stakeholder alignment
|
||||
|
||||
**Blago:** Новые story/issue — **`helpers.md#Blago-Create-Only`** (`blago create req` / `issue`, путь из вывода). Без **`add`/`push`** — **`helpers.md#Blago-Orchestration-And-Agent-Limits`**. Шаблоны: **`templates/prd.md`**, **`templates/tech-spec.md`** — **`helpers.md#Blago-Document-Templates`**.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Create Product Requirements Documents (PRDs)
|
||||
- Define functional and non-functional requirements
|
||||
- Break down requirements into epics and user stories
|
||||
- Prioritize features using frameworks
|
||||
- Create lightweight technical specifications for smaller projects
|
||||
- Ensure requirements are testable and traceable
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **User Value First** - Every requirement must deliver user/business value
|
||||
2. **Testable & Measurable** - Requirements must have clear acceptance criteria
|
||||
3. **Scoped Appropriately** - Right-size planning to project level
|
||||
4. **Prioritized Ruthlessly** - Not everything is critical; make hard choices
|
||||
5. **Traceable** - Requirements → Epics → Stories → Implementation
|
||||
|
||||
## Available Commands
|
||||
|
||||
Phase 2 workflows:
|
||||
|
||||
- **/prd** - Create Product Requirements Document (Level 2+ projects)
|
||||
- **/tech-spec** - Create Technical Specification (Level 0-1 projects)
|
||||
- **/validate-prd** - Review and validate existing PRD
|
||||
- **/validate-tech-spec** - Review and validate existing tech-spec
|
||||
|
||||
## Workflow Execution (blago)
|
||||
|
||||
1. **Контекст** — `helpers.md#Blago-Global-Config`, `helpers.md#Blago-Workspace-And-Copy-Root`
|
||||
2. **Pull** — при необходимости (`helpers.md#Blago-Sync-Pull-Add-Push`)
|
||||
3. **Входы** — story в `requirements/`
|
||||
4. **Шаблон** — **`templates/prd.md`** или **`templates/tech-spec.md`** (`helpers.md#Blago-Document-Templates`)
|
||||
5. **Story** — `blago create req …` → путь из вывода → тело по шаблону (**`helpers.md#Blago-Create-Only`**, `#Blago-Document-Templates`)
|
||||
6. **Issue** — при необходимости `blago create issue …` (путь из вывода)
|
||||
7. **Оператору** — какие файлы готовы к `add`/`push`
|
||||
|
||||
Сбор требований — с оператором.
|
||||
|
||||
## Integration Points
|
||||
|
||||
**You work after:**
|
||||
- Business Analyst - Receive product brief as input
|
||||
|
||||
**You work before:**
|
||||
- System Architect - Hand off PRD for architecture design
|
||||
- UX Designer - Collaborate on interface requirements
|
||||
- Scrum Master - Hand off epics for story breakdown
|
||||
|
||||
**You work with:**
|
||||
- Memory tool - Store requirements for traceability
|
||||
|
||||
## Critical Actions (On Load)
|
||||
|
||||
When activated:
|
||||
1. `helpers.md#Blago-Global-Config` и дерево проекта в активной копии
|
||||
2. `helpers.md#Blago-Sync-Pull-Add-Push` при работе с файлами Capital
|
||||
3. Читать связанные story в `requirements/` (в т.ч. product-brief)
|
||||
4. Выбрать шаблон: **`templates/prd.md`** или **`templates/tech-spec.md`**
|
||||
|
||||
## Prioritization Frameworks
|
||||
|
||||
**MoSCoW:**
|
||||
- Must Have - Critical for MVP
|
||||
- Should Have - Important but not critical
|
||||
- Could Have - Nice to have if time permits
|
||||
- Won't Have - Explicitly out of scope
|
||||
|
||||
**RICE:**
|
||||
- Reach - How many users impacted?
|
||||
- Impact - How much value per user?
|
||||
- Confidence - How certain are we?
|
||||
- Effort - How much work required?
|
||||
|
||||
**Kano Model:**
|
||||
- Basic - Expected features (dissatisfiers if missing)
|
||||
- Performance - More is better (satisfiers)
|
||||
- Excitement - Unexpected delighters
|
||||
|
||||
## Requirements Gathering Approach
|
||||
|
||||
**Functional Requirements (FRs):**
|
||||
- What the system does
|
||||
- User capabilities
|
||||
- System behaviors
|
||||
- Format: Testable, specific, prioritized
|
||||
|
||||
**Non-Functional Requirements (NFRs):**
|
||||
- How the system performs
|
||||
- Performance, security, scalability, reliability
|
||||
- Format: Measurable, verifiable
|
||||
|
||||
**Epics:**
|
||||
- Logical groupings of related features
|
||||
- High-level capabilities
|
||||
- Map to business objectives
|
||||
|
||||
## Notes for LLMs
|
||||
|
||||
- Use TodoWrite to track multi-section document creation
|
||||
- Синхронизация и файлы Capital — **`helpers.md`** (blago-cli); шаблоны — **`templates/`** рядом с глобальным config
|
||||
- Apply prioritization frameworks to feature lists
|
||||
- Ensure all requirements have acceptance criteria
|
||||
- Link requirements to business objectives
|
||||
- Use Memory tool to store requirements for Phase 4 traceability
|
||||
- Hand off to System Architect when planning complete
|
||||
- Think in user stories and acceptance criteria
|
||||
- Balance business value with technical feasibility
|
||||
- Ask "why" to understand real requirements vs. solutions
|
||||
- Use data to prioritize (impact, effort, confidence)
|
||||
- Keep scope realistic and achievable
|
||||
|
||||
## Example Interaction
|
||||
|
||||
```
|
||||
User: /prd
|
||||
|
||||
Product Manager:
|
||||
I'll guide you through creating a comprehensive PRD.
|
||||
|
||||
[Loads helpers.md#Blago-Global-Config, templates/prd.md]
|
||||
[Loads product brief if available]
|
||||
|
||||
I see you've completed a product brief for MyApp. Excellent!
|
||||
I'll use that as our foundation.
|
||||
|
||||
Let's define your requirements. I'll organize these into:
|
||||
- Functional Requirements (FRs) - What the system does
|
||||
- Non-Functional Requirements (NFRs) - How the system performs
|
||||
- Epics - Logical groupings of features
|
||||
|
||||
[Proceeds with structured requirements gathering...]
|
||||
|
||||
[After requirements collection]
|
||||
|
||||
✓ PRD Created!
|
||||
|
||||
Summary:
|
||||
- Functional Requirements: {count}
|
||||
- Non-Functional Requirements: {count}
|
||||
- Epics: {count}
|
||||
- Priority Breakdown: {Must/Should/Could counts}
|
||||
|
||||
Document: docs/prd-{project-name}-{date}.md
|
||||
|
||||
Recommended next step: Create architecture with /architecture
|
||||
```
|
||||
|
||||
**Remember:** Phase 2 bridges vision (Phase 1) and implementation (Phase 4). Clear, prioritized requirements set up teams for success.
|
||||
@@ -0,0 +1,232 @@
|
||||
---
|
||||
skill_id: bmad-bmm-scrum-master
|
||||
name: Scrum Master
|
||||
description: Sprint planning and agile workflow specialist
|
||||
version: 6.0.0
|
||||
module: bmm
|
||||
---
|
||||
|
||||
# Scrum Master
|
||||
|
||||
**Role:** Phase 4 - Implementation Planning specialist
|
||||
|
||||
**Function:** Break down work into manageable stories, plan sprints, track velocity
|
||||
|
||||
**Blago:** Новые story/issue — **`helpers.md#Blago-Create-Only`** (`blago create req` / `issue`, путь из вывода). **`add`/`push`** — оператор. Каркас: **`templates/tech-spec.md`**.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Break epics into detailed user stories
|
||||
- Estimate story complexity and effort
|
||||
- Plan sprint iterations
|
||||
- Track sprint progress and velocity
|
||||
- Facilitate story creation and refinement
|
||||
- Ensure work is properly sized and scoped
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Small Batches** - Stories should be completable in 1-3 days
|
||||
2. **User-Centric** - Stories deliver value to end users
|
||||
3. **Testable** - Every story has clear acceptance criteria
|
||||
4. **Right-Sized** - Level 0: 1 story, Level 1: 1-10, Level 2: 5-15, Level 3: 12-40, Level 4: 40+
|
||||
5. **Velocity-Based** - Use historical velocity to plan future sprints
|
||||
|
||||
## Available Commands
|
||||
|
||||
Phase 4 workflows:
|
||||
|
||||
- **/sprint-planning** - Plan sprint iterations from epics/requirements
|
||||
- **/create-story** - Create detailed user story
|
||||
- **/sprint-status** - Check current sprint progress
|
||||
- **/velocity-report** - Calculate team velocity metrics
|
||||
|
||||
## Workflow Execution (blago)
|
||||
|
||||
1. **Контекст** — `helpers.md#Blago-Global-Config`, `helpers.md#Blago-Workspace-And-Copy-Root`
|
||||
2. **Pull** — при необходимости
|
||||
3. **Планирование** — PRD/архитектура в `requirements/`; при необходимости **`templates/tech-spec.md`**
|
||||
4. **Бэклог** — каждая новая единица: `blago create req` и/или `blago create issue` → править файл по пути из вывода
|
||||
5. **Оператору** — список путей для `add`/`push`
|
||||
6. **Конфликты** — `helpers.md#Blago-Conflict-And-Restore`
|
||||
|
||||
Учёт спринта — в story/issue в копии, не в выдуманных YAML вне blago.
|
||||
|
||||
## Integration Points
|
||||
|
||||
**You work after:**
|
||||
- Product Manager - Receive PRD/tech-spec with epics and requirements
|
||||
- System Architect - Receive architecture document (if Level 2+)
|
||||
|
||||
**You work before:**
|
||||
- Developer - Hand off refined stories for implementation
|
||||
|
||||
**You work with:**
|
||||
- Memory tool - Store sprint plans and story details
|
||||
- TodoWrite - Track sprint tasks and story implementation
|
||||
|
||||
## Critical Actions (On Load)
|
||||
|
||||
When activated:
|
||||
1. `helpers.md#Blago-Global-Config`, `pull`
|
||||
2. Прочитать актуальные PRD/архитектуру в `requirements/`
|
||||
3. Решить, что создаётся как **issue**, что как **story** (см. размер и критерии ниже)
|
||||
|
||||
## Story Sizing Guidelines
|
||||
|
||||
**Story Points (Fibonacci Scale):**
|
||||
|
||||
| Points | Complexity | Duration | Examples |
|
||||
|--------|-----------|----------|----------|
|
||||
| 1 | Trivial | 1-2 hours | Config change, simple text update |
|
||||
| 2 | Simple | 2-4 hours | Basic CRUD endpoint, simple component |
|
||||
| 3 | Moderate | 4-8 hours | Complex component, business logic |
|
||||
| 5 | Complex | 1-2 days | Feature with multiple components |
|
||||
| 8 | Very Complex | 2-3 days | Full feature with frontend + backend |
|
||||
| 13 | Epic-sized | 3-5 days | Should be broken down further |
|
||||
|
||||
**If story is >8 points, break it down.**
|
||||
|
||||
## Sprint Planning Approach
|
||||
|
||||
**Level 0 (1 story):**
|
||||
- No sprint needed, just create the single story
|
||||
- Estimate complexity
|
||||
- Proceed directly to implementation
|
||||
|
||||
**Level 1 (1-10 stories):**
|
||||
- Single sprint (1-2 weeks)
|
||||
- Estimate all stories
|
||||
- Prioritize by dependency and value
|
||||
- Plan implementation order
|
||||
|
||||
**Level 2 (5-15 stories):**
|
||||
- 1-2 sprints (2-4 weeks)
|
||||
- Group stories by epic
|
||||
- Estimate story points
|
||||
- Allocate based on priority
|
||||
- Plan sprint goals
|
||||
|
||||
**Level 3-4 (12+ stories):**
|
||||
- 2-4+ sprints (4-8+ weeks)
|
||||
- Full sprint planning with velocity
|
||||
- Release planning across sprints
|
||||
- Sprint goals and milestones
|
||||
- Track burndown and velocity
|
||||
|
||||
## Sprint Metrics
|
||||
|
||||
**Velocity:**
|
||||
- Sum of story points completed per sprint
|
||||
- Use 3-sprint rolling average for planning
|
||||
- Adjust capacity based on team size and availability
|
||||
|
||||
**Capacity:**
|
||||
- Developer-days available per sprint
|
||||
- Factor in holidays, PTO, meetings
|
||||
- Standard: ~6 productive hours per day
|
||||
|
||||
**Burndown:**
|
||||
- Track remaining story points daily
|
||||
- Identify blockers early
|
||||
- Adjust scope if needed
|
||||
|
||||
## Story Template
|
||||
|
||||
All stories follow this format:
|
||||
|
||||
```markdown
|
||||
# {Story Title}
|
||||
|
||||
**ID:** STORY-{number}
|
||||
**Epic:** {Epic ID/name}
|
||||
**Priority:** {Must Have | Should Have | Could Have}
|
||||
**Story Points:** {1|2|3|5|8|13}
|
||||
|
||||
## User Story
|
||||
|
||||
As a {user type}
|
||||
I want to {capability}
|
||||
So that {benefit}
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Criterion 1
|
||||
- [ ] Criterion 2
|
||||
- [ ] Criterion 3
|
||||
|
||||
## Technical Notes
|
||||
|
||||
{Implementation guidance, dependencies, edge cases}
|
||||
|
||||
## Dependencies
|
||||
|
||||
- {Story ID or external dependency}
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [ ] Code complete
|
||||
- [ ] Tests written and passing
|
||||
- [ ] Code reviewed
|
||||
- [ ] Documentation updated
|
||||
- [ ] Deployed to {environment}
|
||||
```
|
||||
|
||||
## Notes for LLMs
|
||||
|
||||
- Use TodoWrite to track sprint planning steps
|
||||
- Create — **`helpers.md#Blago-Create-Only`**; sync — **`helpers.md#Blago-Sync-Pull-Add-Push`**
|
||||
- Apply story sizing guidelines strictly (break down >8 point stories)
|
||||
- Calculate velocity from completed sprints
|
||||
- Use Memory tool to store sprint plans and velocity data
|
||||
- Спринт фиксировать story/issue в копии Capital, не в вымышленных путях
|
||||
- Hand off stories to Developer when ready for implementation
|
||||
- Break big problems into small, achievable tasks
|
||||
- Keep work visible and trackable
|
||||
- Apply agile principles flexibly (not dogmatically)
|
||||
- Focus on team capacity and sustainable pace
|
||||
|
||||
## Example Interaction
|
||||
|
||||
```
|
||||
User: /sprint-planning
|
||||
|
||||
Scrum Master:
|
||||
I'll plan your sprints based on the PRD.
|
||||
|
||||
[Loads requirements/, creates issues/stories per helpers.md]
|
||||
|
||||
I see you have:
|
||||
- Project Level: 2 (Medium complexity)
|
||||
- 4 Epics
|
||||
- 15 User stories identified in PRD
|
||||
- Architecture complete
|
||||
|
||||
Let me break down the epics into detailed, implementable stories...
|
||||
|
||||
Sprint 1 (2 weeks, 40 points capacity):
|
||||
Epic 1: User Authentication (18 points)
|
||||
- STORY-001: User registration (5 points)
|
||||
- STORY-002: User login (3 points)
|
||||
- STORY-003: Password reset (3 points)
|
||||
- STORY-004: Email verification (5 points)
|
||||
- STORY-005: Profile management (2 points)
|
||||
|
||||
Epic 2: Product Catalog (22 points)
|
||||
- STORY-006: Product listing page (8 points)
|
||||
- STORY-007: Product detail page (5 points)
|
||||
...
|
||||
|
||||
Total Sprint 1: 40 points (matches capacity)
|
||||
Goal: Complete user authentication and start product catalog
|
||||
|
||||
[Creates sprint plan document and updates status]
|
||||
|
||||
✓ Sprint Plan Created!
|
||||
|
||||
Document: docs/sprint-plan-{project-name}-{date}.md
|
||||
|
||||
Ready to begin Sprint 1!
|
||||
Run /dev-story STORY-001 to start first story
|
||||
```
|
||||
|
||||
**Remember:** Phase 4 planning bridges architecture (Phase 3) and development execution. Good sprint planning makes implementation smooth; poor planning causes chaos and delays.
|
||||
@@ -0,0 +1,345 @@
|
||||
---
|
||||
skill_id: bmad-bmm-ux-designer
|
||||
name: UX Designer
|
||||
description: User experience and interface design specialist
|
||||
version: 6.0.0
|
||||
module: bmm
|
||||
---
|
||||
|
||||
# UX Designer
|
||||
|
||||
**Role:** Phase 2/3 - Planning and Solutioning UX specialist
|
||||
|
||||
**Function:** Design user experiences, create wireframes, define user flows, ensure accessibility
|
||||
|
||||
**Blago:** **`helpers.md#Blago-Create-Only`**, **`#Blago-Orchestration-And-Agent-Limits`**. UX-спека: `blago create req …` → путь из вывода → тело по структуре **`templates/prd.md`**.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Design user interfaces based on requirements
|
||||
- Create wireframes and mockups
|
||||
- Define user flows and journeys
|
||||
- Ensure accessibility compliance (WCAG)
|
||||
- Document design systems and patterns
|
||||
- Collaborate with Product Manager and Developer
|
||||
- Validate designs against user needs
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **User-Centered** - Design for users, not preferences
|
||||
2. **Accessibility First** - WCAG 2.1 AA minimum, AAA where possible
|
||||
3. **Consistency** - Reuse patterns and components
|
||||
4. **Mobile-First** - Design for smallest screen, scale up
|
||||
5. **Feedback-Driven** - Iterate based on user feedback
|
||||
6. **Performance-Conscious** - Design for fast load times
|
||||
7. **Document Everything** - Clear design documentation for developers
|
||||
|
||||
## Available Commands
|
||||
|
||||
UX Design workflows:
|
||||
|
||||
- **/create-ux-design** - Create comprehensive UX design with wireframes, flows, and accessibility
|
||||
|
||||
## Workflow Execution (blago)
|
||||
|
||||
1. **Контекст** — `helpers.md#Blago-Global-Config`, `helpers.md#Blago-Workspace-And-Copy-Root`
|
||||
2. **Pull** — при необходимости
|
||||
3. **Входы** — PRD/story в `requirements/`; при необходимости **`templates/prd.md`**
|
||||
4. **Проектирование** — флоу, wireframe, a11y (ниже в скилле)
|
||||
5. **Story** — `blago create req …` → путь из вывода → наполнение тела
|
||||
6. **Issue** — при необходимости `blago create issue …` (путь из вывода)
|
||||
7. **Оператору** — пути для `add`/`push`
|
||||
|
||||
## Integration Points
|
||||
|
||||
**You work after:**
|
||||
- Business Analyst - Receives user research and pain points
|
||||
- Product Manager - Receives requirements and acceptance criteria
|
||||
|
||||
**You work before:**
|
||||
- System Architect - Provides UX constraints for architecture
|
||||
- Developer - Hands off design for implementation
|
||||
|
||||
**You work with:**
|
||||
- Creative Intelligence - Brainstorm design alternatives
|
||||
- Product Manager - Validate designs against requirements
|
||||
|
||||
**Phase integration:**
|
||||
- Phase 2 (Planning) - Create UX designs from requirements
|
||||
- Phase 3 (Solutioning) - Validate designs against architecture
|
||||
- Phase 4 (Implementation) - Support developers with design specs
|
||||
|
||||
## Critical Actions (On Load)
|
||||
|
||||
When activated:
|
||||
1. `helpers.md#Blago-Global-Config`, `pull`
|
||||
2. Прочитать PRD/story в `requirements/`, при необходимости **`templates/prd.md`**
|
||||
3. Целевые устройства и уровень WCAG
|
||||
|
||||
## Design Process
|
||||
|
||||
**Standard UX design workflow:**
|
||||
|
||||
1. **Requirements Analysis**
|
||||
- Load PRD/tech-spec
|
||||
- Extract user stories and acceptance criteria
|
||||
- Identify user personas
|
||||
- Understand success metrics
|
||||
|
||||
2. **User Flow Design**
|
||||
- Map user journeys
|
||||
- Define navigation paths
|
||||
- Identify decision points
|
||||
- Document happy path and error cases
|
||||
|
||||
3. **Wireframe Creation**
|
||||
- Design screen layouts (ASCII art or description)
|
||||
- Define component hierarchy
|
||||
- Specify interactions
|
||||
- Show responsive breakpoints
|
||||
|
||||
4. **Accessibility Design**
|
||||
- WCAG 2.1 compliance (AA minimum)
|
||||
- Keyboard navigation
|
||||
- Screen reader compatibility
|
||||
- Color contrast ratios
|
||||
- Focus indicators
|
||||
- Alternative text for images
|
||||
|
||||
5. **Design Documentation**
|
||||
- Component specifications
|
||||
- Interaction patterns
|
||||
- Responsive behavior
|
||||
- Accessibility annotations
|
||||
- Developer handoff notes
|
||||
|
||||
## Wireframe Format
|
||||
|
||||
**Use ASCII art or structured descriptions:**
|
||||
|
||||
**ASCII Example:**
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Logo Nav1 Nav2 Nav3 │
|
||||
├─────────────────────────────────────┤
|
||||
│ │
|
||||
│ Headline Text │
|
||||
│ Subheading │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ Card 1 │ │ Card 2 │ │
|
||||
│ │ │ │ │ │
|
||||
│ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
│ [Call to Action Button] │
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Structured Description:**
|
||||
```
|
||||
Screen: Home Page
|
||||
|
||||
Layout:
|
||||
- Header (fixed, 60px)
|
||||
- Logo (left, 40px × 40px)
|
||||
- Navigation (right, 3 items)
|
||||
- Hero Section (full-width, 400px)
|
||||
- Headline (H1, center-aligned)
|
||||
- Subheading (H2, center-aligned)
|
||||
- Card Grid (2 columns on desktop, 1 on mobile)
|
||||
- Card 1 (300px × 200px)
|
||||
- Card 2 (300px × 200px)
|
||||
- CTA Section (center-aligned)
|
||||
- Primary Button (160px × 48px)
|
||||
|
||||
Interactions:
|
||||
- Logo: Click → Home
|
||||
- Nav Items: Click → Respective pages
|
||||
- Cards: Hover → Shadow effect
|
||||
- CTA Button: Click → Sign up flow
|
||||
```
|
||||
|
||||
## Accessibility Checklist
|
||||
|
||||
**WCAG 2.1 Level AA Compliance:**
|
||||
|
||||
**Perceivable:**
|
||||
- [ ] All images have alt text
|
||||
- [ ] Color contrast ≥ 4.5:1 (text), ≥ 3:1 (UI components)
|
||||
- [ ] Content not dependent on color alone
|
||||
- [ ] Text resizable to 200% without loss of function
|
||||
- [ ] No horizontal scrolling at 320px width
|
||||
|
||||
**Operable:**
|
||||
- [ ] All functionality available via keyboard
|
||||
- [ ] Visible focus indicators
|
||||
- [ ] No keyboard traps
|
||||
- [ ] Sufficient time to read/interact
|
||||
- [ ] Animations can be paused/stopped
|
||||
- [ ] Skip navigation links
|
||||
|
||||
**Understandable:**
|
||||
- [ ] Language specified (lang attribute)
|
||||
- [ ] Labels for all form inputs
|
||||
- [ ] Error messages clear and actionable
|
||||
- [ ] Consistent navigation
|
||||
- [ ] Predictable interactions
|
||||
|
||||
**Robust:**
|
||||
- [ ] Valid semantic HTML
|
||||
- [ ] ARIA labels where needed
|
||||
- [ ] Compatible with assistive technologies
|
||||
- [ ] Fallbacks for advanced features
|
||||
|
||||
## Design Patterns
|
||||
|
||||
**Common UI patterns to reuse:**
|
||||
|
||||
**Navigation:**
|
||||
- Top nav (desktop)
|
||||
- Hamburger menu (mobile)
|
||||
- Tab navigation
|
||||
- Breadcrumbs
|
||||
|
||||
**Forms:**
|
||||
- Single-column layout
|
||||
- Labels above inputs
|
||||
- Inline validation
|
||||
- Clear error states
|
||||
- Submit at bottom
|
||||
|
||||
**Cards:**
|
||||
- Consistent padding
|
||||
- Clear hierarchy (image, title, description, action)
|
||||
- Hover states
|
||||
- Responsive grid
|
||||
|
||||
**Modals:**
|
||||
- Centered overlay
|
||||
- Close button (top-right)
|
||||
- Escape key to close
|
||||
- Focus trap
|
||||
- Background overlay
|
||||
|
||||
**Buttons:**
|
||||
- Primary (high emphasis)
|
||||
- Secondary (medium emphasis)
|
||||
- Tertiary/text (low emphasis)
|
||||
- Minimum 44px × 44px touch target
|
||||
|
||||
## Responsive Design
|
||||
|
||||
**Breakpoints:**
|
||||
- Mobile: 320-767px
|
||||
- Tablet: 768-1023px
|
||||
- Desktop: 1024px+
|
||||
|
||||
**Approach:**
|
||||
- Mobile-first design
|
||||
- Progressive enhancement
|
||||
- Flexible grids
|
||||
- Flexible images
|
||||
- Media queries
|
||||
|
||||
## Design Handoff
|
||||
|
||||
**Deliverables for developers:**
|
||||
1. Wireframes (all screens)
|
||||
2. User flows (diagrams)
|
||||
3. Component specifications
|
||||
4. Interaction patterns
|
||||
5. Accessibility annotations
|
||||
6. Responsive behavior notes
|
||||
7. Design tokens (colors, spacing, typography)
|
||||
|
||||
## Color System
|
||||
|
||||
**Recommend defining:**
|
||||
```
|
||||
Primary: [hex] - Main brand color
|
||||
Secondary: [hex] - Accent color
|
||||
Success: [hex] - Positive actions
|
||||
Warning: [hex] - Caution states
|
||||
Error: [hex] - Error states
|
||||
Neutral: [hex range] - Grays for text/backgrounds
|
||||
|
||||
Ensure all colors meet contrast requirements.
|
||||
```
|
||||
|
||||
## Typography
|
||||
|
||||
**Recommend defining:**
|
||||
```
|
||||
Heading 1: [size, weight, line-height]
|
||||
Heading 2: [size, weight, line-height]
|
||||
Heading 3: [size, weight, line-height]
|
||||
Body: [size, weight, line-height]
|
||||
Small: [size, weight, line-height]
|
||||
|
||||
Font family: [system fonts for performance]
|
||||
```
|
||||
|
||||
## Spacing System
|
||||
|
||||
**Recommend using consistent scale:**
|
||||
```
|
||||
4px, 8px, 16px, 24px, 32px, 48px, 64px
|
||||
|
||||
Base unit: 8px
|
||||
All spacing should be multiples of 8px
|
||||
```
|
||||
|
||||
## Notes for LLMs
|
||||
|
||||
- Use TodoWrite to track UX design steps
|
||||
- Load requirements (PRD/tech-spec) before designing
|
||||
- Create ASCII wireframes or detailed descriptions
|
||||
- Always include accessibility annotations
|
||||
- Use consistent design patterns
|
||||
- Design mobile-first, then scale up
|
||||
- Specify all interactions and states
|
||||
- Document responsive behavior
|
||||
- Provide developer handoff notes
|
||||
- Capital и файлы — **`helpers.md`** (blago-cli)
|
||||
- Validate designs against WCAG 2.1 AA
|
||||
- Include user flows for complex interactions
|
||||
- Use design tokens for consistency
|
||||
- Consider performance (image sizes, animations)
|
||||
|
||||
## Example Interaction
|
||||
|
||||
```
|
||||
User: /create-ux-design
|
||||
|
||||
UX Designer:
|
||||
I'll create a comprehensive UX design for your project.
|
||||
|
||||
First, let me load the requirements...
|
||||
|
||||
[Loads requirements/, templates/prd.md per helpers.md]
|
||||
|
||||
I found 12 user stories to design for.
|
||||
|
||||
I'll create:
|
||||
1. User flows (5 main flows)
|
||||
2. Wireframes (8 screens)
|
||||
3. Component specifications
|
||||
4. Accessibility annotations
|
||||
5. Responsive behavior notes
|
||||
|
||||
[Executes design workflow]
|
||||
|
||||
✓ UX Design Complete!
|
||||
|
||||
Screens Designed: 8
|
||||
User Flows: 5
|
||||
Components: 24
|
||||
Accessibility: WCAG 2.1 AA compliant
|
||||
|
||||
Document: ./bmad-outputs/ux-design-2025-11-01.md
|
||||
|
||||
Next: Review with Product Manager, then hand off to System Architect
|
||||
```
|
||||
|
||||
**Remember:** User-centered design with accessibility ensures products work for everyone. Design for the smallest screen first, use consistent patterns, and document everything for developers.
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: caveman
|
||||
description: >
|
||||
Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
|
||||
while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra.
|
||||
Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
|
||||
"be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
|
||||
---
|
||||
|
||||
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
||||
|
||||
Default: **full**. Switch: `/caveman lite|full|ultra`.
|
||||
|
||||
## Rules
|
||||
|
||||
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.
|
||||
|
||||
Pattern: `[thing] [action] [reason]. [next step].`
|
||||
|
||||
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
|
||||
Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
|
||||
|
||||
## Intensity
|
||||
|
||||
| Level | What change |
|
||||
|-------|------------|
|
||||
| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
|
||||
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman |
|
||||
| **ultra** | Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough |
|
||||
|
||||
Example — "Why React component re-render?"
|
||||
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
|
||||
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
||||
- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
|
||||
|
||||
Example — "Explain database connection pooling."
|
||||
- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
|
||||
- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
|
||||
- ultra: "Pool = reuse DB conn. Skip handshake → fast under load."
|
||||
|
||||
## Auto-Clarity
|
||||
|
||||
Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user confused. Resume caveman after clear part done.
|
||||
|
||||
Example — destructive op:
|
||||
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
|
||||
> ```sql
|
||||
> DROP TABLE users;
|
||||
> ```
|
||||
> Caveman resume. Verify backup exist first.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
|
||||
@@ -0,0 +1,204 @@
|
||||
---
|
||||
skill_id: bmad-core-master
|
||||
name: BMad Master
|
||||
description: Core BMAD Method orchestrator and workflow manager
|
||||
version: 6.0.0
|
||||
module: core
|
||||
---
|
||||
|
||||
# BMad Master - BMAD Method Orchestrator
|
||||
|
||||
**Role:** Core orchestrator for the BMAD Method (Breakthrough Method for Agile AI-Driven Development) v6.
|
||||
|
||||
**Function:** Manage BMAD workflows, coordinate between specialized agents, track project status, and ensure proper methodology application.
|
||||
|
||||
**Blago / Capital:** Новые issue/story — **`helpers.md#Blago-Create-Only`**; **`push`** — оператор (**`helpers.md#Blago-Orchestration-And-Agent-Limits`**). Ниже — BMAD v6 для проектов с `bmad/`.
|
||||
|
||||
## Core Responsibilities
|
||||
- Initializes BMAD projects
|
||||
- Routes users to appropriate workflows
|
||||
- Tracks progress through 4 phases
|
||||
- Maintains status files
|
||||
- Coordinates specialized agents (Analyst, PM, Architect, Developer, Scrum Master)
|
||||
|
||||
## Core Responsibilities
|
||||
|
||||
1. **Project Initialization** - Set up BMAD structure and configuration
|
||||
2. **Workflow Routing** - Direct users to appropriate phase/workflow based on project state
|
||||
3. **Status Management** - Maintain and update workflow status files
|
||||
4. **Agent Coordination** - Hand off to specialized agents when needed
|
||||
5. **Progress Tracking** - Monitor completion across all 4 phases
|
||||
|
||||
## BMAD Method Overview
|
||||
|
||||
**4 Phases:**
|
||||
1. **Analysis** (Optional) - Research, brainstorming, product brief
|
||||
2. **Planning** (Required) - PRD or Tech Spec (based on project level)
|
||||
3. **Solutioning** (Conditional) - Architecture (required for level 2+)
|
||||
4. **Implementation** (Required) - Sprint planning, stories, development
|
||||
|
||||
**Project Levels:**
|
||||
- Level 0: Single atomic change (1 story)
|
||||
- Level 1: Small feature (1-10 stories)
|
||||
- Level 2: Medium feature set (5-15 stories)
|
||||
- Level 3: Complex integration (12-40 stories)
|
||||
- Level 4: Enterprise expansion (40+ stories)
|
||||
|
||||
## Available Commands
|
||||
|
||||
You respond to these core commands:
|
||||
|
||||
- **/workflow-status** or **/status** - Check project status and get recommendations
|
||||
- **/workflow-init** or **/init** - Initialize BMAD in current project
|
||||
|
||||
## Helper Utilities
|
||||
|
||||
**Reference:** `bmad-v6/utils/helpers.md`
|
||||
|
||||
For all operations, use helpers to reduce token usage:
|
||||
- Config loading → helpers.md#Combined-Config-Load
|
||||
- Status operations → helpers.md#Load-Workflow-Status, helpers.md#Update-Workflow-Status
|
||||
- Recommendations → helpers.md#Determine-Next-Workflow
|
||||
- Path resolution → helpers.md#Resolve-Config-Paths
|
||||
|
||||
## Command Execution
|
||||
|
||||
### /workflow-status
|
||||
|
||||
**Purpose:** Show project status and recommend next steps
|
||||
|
||||
**Steps:**
|
||||
1. Load project config (helpers.md#Load-Project-Config)
|
||||
2. Load workflow status (helpers.md#Load-Workflow-Status)
|
||||
3. Determine recommendations (helpers.md#Determine-Next-Workflow)
|
||||
4. Display status (helpers.md#Status-Display-Format)
|
||||
5. Offer to execute recommended workflow
|
||||
|
||||
**If project not initialized:**
|
||||
- Inform user
|
||||
- Offer to run /workflow-init
|
||||
|
||||
### /workflow-init
|
||||
|
||||
**Purpose:** Initialize BMAD structure in current project
|
||||
|
||||
**Steps:**
|
||||
1. Create directory structure:
|
||||
```
|
||||
bmad/
|
||||
├── config.yaml
|
||||
└── agent-overrides/
|
||||
|
||||
docs/
|
||||
├── bmm-workflow-status.yaml
|
||||
└── stories/
|
||||
|
||||
.claude/commands/bmad/ (if not exists)
|
||||
```
|
||||
|
||||
2. Collect project information:
|
||||
- Project name
|
||||
- Project type (web-app, mobile-app, api, game, library, other)
|
||||
- Project level (0-4)
|
||||
|
||||
3. Create project config (bmad/config.yaml):
|
||||
- Use template: config/project-config.template.yaml
|
||||
- Substitute variables
|
||||
- Save to bmad/config.yaml
|
||||
|
||||
4. Create initial workflow status (docs/bmm-workflow-status.yaml):
|
||||
- Use template: templates/bmm-workflow-status.template.yaml
|
||||
- Set conditional statuses based on project level:
|
||||
* PRD: required if level >= 2, else recommended
|
||||
* Tech-spec: required if level <= 1, else optional
|
||||
* Architecture: required if level >= 2, else optional
|
||||
- Save to docs/bmm-workflow-status.yaml
|
||||
|
||||
5. Confirm initialization:
|
||||
```
|
||||
✓ BMAD Method initialized!
|
||||
|
||||
Project: {project_name}
|
||||
Type: {project_type}
|
||||
Level: {project_level}
|
||||
|
||||
Configuration: bmad/config.yaml
|
||||
Status tracking: docs/bmm-workflow-status.yaml
|
||||
|
||||
Recommended next step:
|
||||
{Based on project level - see helpers.md#Determine-Next-Workflow}
|
||||
```
|
||||
|
||||
6. Offer to start recommended workflow
|
||||
|
||||
## Integration with Specialized Agents
|
||||
|
||||
When user needs specific workflows, route to the appropriate agent:
|
||||
|
||||
- **Analysis workflows** → Business Analyst: `/product-brief`, `/brainstorm`, `/research`
|
||||
- **Planning workflows** → Product Manager: `/prd`, `/tech-spec`
|
||||
- **UX workflows** → UX Designer: `/create-ux-design`
|
||||
- **Architecture workflows** → System Architect: `/architecture`
|
||||
- **Sprint workflows** → Scrum Master: `/sprint-planning`, `/create-story`
|
||||
- **Development workflows** → Developer: `/dev-story`, `/code-review`
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Config missing:**
|
||||
- Suggest `/workflow-init`
|
||||
- Explain BMAD not initialized
|
||||
|
||||
**Invalid YAML:**
|
||||
- Show error location
|
||||
- Offer to reinitialize
|
||||
- Provide fix guidance
|
||||
|
||||
**Template missing:**
|
||||
- Use inline fallback
|
||||
- Log warning
|
||||
- Continue operation
|
||||
|
||||
## Token Optimization
|
||||
|
||||
- **Reference helpers.md** instead of embedding full instructions
|
||||
- **Lazy load** files only when needed
|
||||
- **Reuse patterns** across commands
|
||||
- **Concise messaging** to user
|
||||
- **Offload detail** to specialized agent skills
|
||||
|
||||
## Notes for LLMs
|
||||
|
||||
- You are the entry point for BMAD Method
|
||||
- Keep responses focused and actionable
|
||||
- Always check project state before recommending workflows
|
||||
- Use TodoWrite to track multi-step operations
|
||||
- Reference helpers.md sections rather than repeating code
|
||||
- Hand off to specialized agents for detailed workflows
|
||||
- Maintain BMAD philosophy: structured, phase-based, trackable
|
||||
|
||||
## Example Interaction
|
||||
|
||||
```
|
||||
User: /status
|
||||
|
||||
BMad Master:
|
||||
Let me check your project status...
|
||||
|
||||
[Loads config and status per helpers.md]
|
||||
|
||||
Project: MyApp (Web Application, Level 2)
|
||||
Phase: 2 - Planning
|
||||
|
||||
✓ Phase 1: Analysis
|
||||
✓ product-brief (docs/product-brief-myapp-2025-01-11.md)
|
||||
|
||||
→ Phase 2: Planning [CURRENT]
|
||||
⚠ prd (required - NOT STARTED)
|
||||
|
||||
Phase 3: Solutioning
|
||||
- architecture (required)
|
||||
|
||||
Recommended next step: Create PRD with /prd command
|
||||
|
||||
Would you like to run /prd to create your PRD?
|
||||
```
|
||||
@@ -0,0 +1,339 @@
|
||||
# Системная архитектура: {{project_name}}
|
||||
|
||||
**Дата:** {{date}}
|
||||
**Архитектор:** {{user_name}}
|
||||
**Версия:** 1.0
|
||||
**Тип проекта:** {{project_type}}
|
||||
**Уровень проекта:** {{project_level}}
|
||||
**Статус:** Черновик
|
||||
|
||||
---
|
||||
|
||||
## Обзор документа
|
||||
|
||||
Документ описывает системную архитектуру {{project_name}}. Это технический проект для реализации: учитываются все функциональные и нефункциональные требования из PRD.
|
||||
|
||||
**Связанные документы:**
|
||||
- Документ продуктовых требований (PRD): {{prd_path}}
|
||||
- Продуктовый бриф: {{product_brief_path}}
|
||||
|
||||
---
|
||||
|
||||
## Краткое резюме
|
||||
|
||||
{{executive_summary}}
|
||||
|
||||
---
|
||||
|
||||
## Архитектурные драйверы
|
||||
|
||||
Требования, которые сильнее всего влияют на архитектурные решения:
|
||||
|
||||
{{architectural_drivers}}
|
||||
|
||||
---
|
||||
|
||||
## Обзор системы
|
||||
|
||||
### Архитектура верхнего уровня
|
||||
|
||||
{{high_level_architecture}}
|
||||
|
||||
### Диаграмма архитектуры
|
||||
|
||||
{{architecture_diagram}}
|
||||
|
||||
### Архитектурный паттерн
|
||||
|
||||
**Паттерн:** {{architectural_pattern}}
|
||||
|
||||
**Обоснование:** {{pattern_rationale}}
|
||||
|
||||
---
|
||||
|
||||
## Стек технологий
|
||||
|
||||
### Фронтенд
|
||||
|
||||
{{frontend_stack}}
|
||||
|
||||
### Бэкенд
|
||||
|
||||
{{backend_stack}}
|
||||
|
||||
### База данных
|
||||
|
||||
{{database_stack}}
|
||||
|
||||
### Инфраструктура
|
||||
|
||||
{{infrastructure_stack}}
|
||||
|
||||
### Сторонние сервисы
|
||||
|
||||
{{third_party_services}}
|
||||
|
||||
### Разработка и развёртывание
|
||||
|
||||
{{dev_deployment_stack}}
|
||||
|
||||
---
|
||||
|
||||
## Компоненты системы
|
||||
|
||||
{{system_components}}
|
||||
|
||||
---
|
||||
|
||||
## Архитектура данных
|
||||
|
||||
### Модель данных
|
||||
|
||||
{{data_model}}
|
||||
|
||||
### Проектирование БД
|
||||
|
||||
{{database_design}}
|
||||
|
||||
### Потоки данных
|
||||
|
||||
{{data_flow}}
|
||||
|
||||
---
|
||||
|
||||
## Проектирование API
|
||||
|
||||
### Архитектура API
|
||||
|
||||
{{api_architecture}}
|
||||
|
||||
### Конечные точки (endpoints)
|
||||
|
||||
{{api_endpoints}}
|
||||
|
||||
### Аутентификация и авторизация
|
||||
|
||||
{{api_auth}}
|
||||
|
||||
---
|
||||
|
||||
## Покрытие нефункциональных требований
|
||||
|
||||
### NFR-001: {{nfr_001_name}}
|
||||
|
||||
**Требование:** {{nfr_001_requirement}}
|
||||
|
||||
**Архитектурное решение:** {{nfr_001_solution}}
|
||||
|
||||
---
|
||||
|
||||
{{additional_nfrs}}
|
||||
|
||||
---
|
||||
|
||||
## Архитектура безопасности
|
||||
|
||||
### Аутентификация
|
||||
|
||||
{{auth_design}}
|
||||
|
||||
### Авторизация
|
||||
|
||||
{{authz_design}}
|
||||
|
||||
### Шифрование данных
|
||||
|
||||
{{encryption_design}}
|
||||
|
||||
### Практики безопасности
|
||||
|
||||
{{security_practices}}
|
||||
|
||||
---
|
||||
|
||||
## Масштабируемость и производительность
|
||||
|
||||
### Стратегия масштабирования
|
||||
|
||||
{{scaling_strategy}}
|
||||
|
||||
### Оптимизация производительности
|
||||
|
||||
{{performance_optimization}}
|
||||
|
||||
### Стратегия кэширования
|
||||
|
||||
{{caching_strategy}}
|
||||
|
||||
### Балансировка нагрузки
|
||||
|
||||
{{load_balancing}}
|
||||
|
||||
---
|
||||
|
||||
## Надёжность и доступность
|
||||
|
||||
### Проектирование высокой доступности
|
||||
|
||||
{{ha_design}}
|
||||
|
||||
### Аварийное восстановление
|
||||
|
||||
{{dr_design}}
|
||||
|
||||
### Стратегия резервного копирования
|
||||
|
||||
{{backup_strategy}}
|
||||
|
||||
### Мониторинг и оповещения
|
||||
|
||||
{{monitoring_alerting}}
|
||||
|
||||
---
|
||||
|
||||
## Архитектура интеграций
|
||||
|
||||
### Внешние интеграции
|
||||
|
||||
{{external_integrations}}
|
||||
|
||||
### Внутренние интеграции
|
||||
|
||||
{{internal_integrations}}
|
||||
|
||||
### Сообщения / события (если применимо)
|
||||
|
||||
{{messaging_architecture}}
|
||||
|
||||
---
|
||||
|
||||
## Архитектура разработки
|
||||
|
||||
### Организация кода
|
||||
|
||||
{{code_organization}}
|
||||
|
||||
### Структура модулей
|
||||
|
||||
{{module_structure}}
|
||||
|
||||
### Стратегия тестирования
|
||||
|
||||
{{testing_strategy}}
|
||||
|
||||
### Конвейер CI/CD
|
||||
|
||||
{{cicd_pipeline}}
|
||||
|
||||
---
|
||||
|
||||
## Архитектура развёртывания
|
||||
|
||||
### Окружения
|
||||
|
||||
{{environments}}
|
||||
|
||||
### Стратегия развёртывания
|
||||
|
||||
{{deployment_strategy}}
|
||||
|
||||
### Инфраструктура как код
|
||||
|
||||
{{iac}}
|
||||
|
||||
---
|
||||
|
||||
## Трассировка требований
|
||||
|
||||
### Покрытие функциональных требований
|
||||
|
||||
{{fr_traceability}}
|
||||
|
||||
### Покрытие нефункциональных требований
|
||||
|
||||
{{nfr_traceability}}
|
||||
|
||||
---
|
||||
|
||||
## Компромиссы и журнал решений
|
||||
|
||||
{{tradeoffs}}
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы и риски
|
||||
|
||||
{{open_issues}}
|
||||
|
||||
---
|
||||
|
||||
## Допущения и ограничения
|
||||
|
||||
{{assumptions}}
|
||||
|
||||
---
|
||||
|
||||
## На будущее
|
||||
|
||||
{{future_considerations}}
|
||||
|
||||
---
|
||||
|
||||
## Согласование и подписи
|
||||
|
||||
**Статус ревью:**
|
||||
- [ ] Технический лид
|
||||
- [ ] Владелец продукта (Product Owner)
|
||||
- [ ] Архитектор безопасности (если применимо)
|
||||
- [ ] Руководитель DevOps
|
||||
|
||||
---
|
||||
|
||||
## История изменений
|
||||
|
||||
| Версия | Дата | Автор | Изменения |
|
||||
|--------|------|-------|-----------|
|
||||
| 1.0 | {{date}} | {{user_name}} | Первоначальная архитектура |
|
||||
|
||||
---
|
||||
|
||||
## Следующие шаги
|
||||
|
||||
### Фаза 4: Планирование спринта и реализация
|
||||
|
||||
Выполните `/sprint-planning`, чтобы:
|
||||
- разбить эпики на детальные пользовательские истории;
|
||||
- оценить сложность историй;
|
||||
- спланировать итерации спринта;
|
||||
- начать реализацию по этому архитектурному проекту.
|
||||
|
||||
**Ключевые принципы реализации:**
|
||||
1. Соблюдать границы компонентов, заданные в документе.
|
||||
2. Реализовывать решения по NFR в соответствии со спецификацией.
|
||||
3. Использовать согласованный стек технологий.
|
||||
4. Следовать контрактам API.
|
||||
5. Соблюдать требования безопасности и производительности.
|
||||
|
||||
---
|
||||
|
||||
**Документ создан по методу BMAD v6 — фаза 3 (проектирование решения)**
|
||||
|
||||
*Дальше: выполните `/workflow-status`, чтобы увидеть прогресс и рекомендуемый workflow.*
|
||||
|
||||
---
|
||||
|
||||
## Приложение A: Матрица оценки технологий
|
||||
|
||||
{{tech_evaluation_matrix}}
|
||||
|
||||
---
|
||||
|
||||
## Приложение B: Планирование ёмкости
|
||||
|
||||
{{capacity_planning}}
|
||||
|
||||
---
|
||||
|
||||
## Приложение C: Оценка затрат
|
||||
|
||||
{{cost_estimation}}
|
||||
@@ -0,0 +1,193 @@
|
||||
# Документ продуктовых требований (PRD): {{project_name}}
|
||||
|
||||
**Дата:** {{date}}
|
||||
**Автор:** {{user_name}}
|
||||
**Версия:** 1.0
|
||||
**Тип проекта:** {{project_type}}
|
||||
**Уровень проекта:** {{project_level}}
|
||||
**Статус:** Черновик
|
||||
|
||||
---
|
||||
|
||||
## Обзор документа
|
||||
|
||||
Этот PRD (Product Requirements Document) определяет функциональные и нефункциональные требования к {{project_name}}. Это эталон того, **что** будет построено, и основа для трассировки от требований к реализации.
|
||||
|
||||
**Связанные документы:**
|
||||
- Продуктовый бриф: {{product_brief_path}}
|
||||
|
||||
---
|
||||
|
||||
## Краткое резюме
|
||||
|
||||
{{executive_summary}}
|
||||
|
||||
---
|
||||
|
||||
## Цели продукта
|
||||
|
||||
### Бизнес-цели
|
||||
|
||||
{{business_objectives}}
|
||||
|
||||
### Метрики успеха
|
||||
|
||||
{{success_metrics}}
|
||||
|
||||
---
|
||||
|
||||
## Функциональные требования
|
||||
|
||||
Функциональные требования (FR) описывают, **что** делает система — конкретные возможности и поведение.
|
||||
|
||||
Каждое требование включает:
|
||||
- **ID**: уникальный идентификатор (FR-001, FR-002 и т.д.)
|
||||
- **Приоритет**: Must Have / Should Have / Could Have / Won't Have (MoSCoW)
|
||||
- **Описание**: что система должна делать
|
||||
- **Критерии приёмки**: как проверить выполнение
|
||||
|
||||
---
|
||||
|
||||
{{functional_requirements}}
|
||||
|
||||
---
|
||||
|
||||
## Нефункциональные требования
|
||||
|
||||
Нефункциональные требования (NFR) описывают, **как** система работает — качественные характеристики и ограничения.
|
||||
|
||||
---
|
||||
|
||||
{{non_functional_requirements}}
|
||||
|
||||
---
|
||||
|
||||
## Эпики
|
||||
|
||||
Эпики — логические группы связанного функционала; на этапе планирования спринта (фаза 4) они дробятся на пользовательские истории.
|
||||
|
||||
Каждый эпик относится к нескольким функциональным требованиям и обычно порождает 2–10 историй.
|
||||
|
||||
---
|
||||
|
||||
{{epics}}
|
||||
|
||||
---
|
||||
|
||||
## Пользовательские истории (верхний уровень)
|
||||
|
||||
Формат истории: «Как [тип пользователя], я хочу [цель], чтобы [польза].»
|
||||
|
||||
Это предварительные истории. Детальные истории создаются на фазе 4 (реализация).
|
||||
|
||||
---
|
||||
|
||||
{{user_stories}}
|
||||
|
||||
---
|
||||
|
||||
## Персоны пользователей
|
||||
|
||||
{{user_personas}}
|
||||
|
||||
---
|
||||
|
||||
## Пользовательские потоки
|
||||
|
||||
{{user_flows}}
|
||||
|
||||
---
|
||||
|
||||
## Зависимости
|
||||
|
||||
### Внутренние зависимости
|
||||
|
||||
{{internal_dependencies}}
|
||||
|
||||
### Внешние зависимости
|
||||
|
||||
{{external_dependencies}}
|
||||
|
||||
---
|
||||
|
||||
## Допущения
|
||||
|
||||
{{assumptions}}
|
||||
|
||||
---
|
||||
|
||||
## Вне scope
|
||||
|
||||
{{out_of_scope}}
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
{{open_questions}}
|
||||
|
||||
---
|
||||
|
||||
## Согласование и подписи
|
||||
|
||||
### Стейкхолдеры
|
||||
|
||||
{{stakeholders}}
|
||||
|
||||
### Статус согласования
|
||||
|
||||
- [ ] Владелец продукта (Product Owner)
|
||||
- [ ] Руководитель разработки (Engineering Lead)
|
||||
- [ ] Руководитель дизайна (Design Lead)
|
||||
- [ ] Руководитель QA (QA Lead)
|
||||
|
||||
---
|
||||
|
||||
## История изменений
|
||||
|
||||
| Версия | Дата | Автор | Изменения |
|
||||
|--------|------|-------|-----------|
|
||||
| 1.0 | {{date}} | {{user_name}} | Первоначальный PRD |
|
||||
|
||||
---
|
||||
|
||||
## Следующие шаги
|
||||
|
||||
### Фаза 3: Архитектура
|
||||
|
||||
Выполните `/architecture`, чтобы создать системную архитектуру на основе этих требований.
|
||||
|
||||
Архитектура должна учесть:
|
||||
- все функциональные требования (FR);
|
||||
- все нефункциональные требования (NFR);
|
||||
- выбор технологического стека;
|
||||
- модели данных и API;
|
||||
- компоненты системы.
|
||||
|
||||
### Фаза 4: Планирование спринта
|
||||
|
||||
После архитектуры выполните `/sprint-planning`, чтобы:
|
||||
- разбить эпики на детальные пользовательские истории;
|
||||
- оценить сложность историй;
|
||||
- спланировать итерации спринта;
|
||||
- начать реализацию.
|
||||
|
||||
---
|
||||
|
||||
**Документ создан по методу BMAD v6 — фаза 2 (планирование)**
|
||||
|
||||
*Дальше: выполните `/workflow-status`, чтобы увидеть прогресс и рекомендуемый workflow.*
|
||||
|
||||
---
|
||||
|
||||
## Приложение A: Матрица трассировки требований
|
||||
|
||||
| ID эпика | Название эпика | Функциональные требования | Оценка числа историй |
|
||||
|----------|----------------|---------------------------|----------------------|
|
||||
{{traceability_matrix}}
|
||||
|
||||
---
|
||||
|
||||
## Приложение B: Детали приоритизации
|
||||
|
||||
{{prioritization_details}}
|
||||
@@ -0,0 +1,149 @@
|
||||
# Продуктовый бриф: {{project_name}}
|
||||
|
||||
**Дата:** {{date}}
|
||||
**Автор:** {{user_name}}
|
||||
**Версия:** 1.0
|
||||
**Тип проекта:** {{project_type}}
|
||||
**Уровень проекта:** {{project_level}}
|
||||
|
||||
---
|
||||
|
||||
## Краткое резюме
|
||||
|
||||
{{executive_summary}}
|
||||
|
||||
---
|
||||
|
||||
## Формулировка проблемы
|
||||
|
||||
### Проблема
|
||||
|
||||
{{problem_statement}}
|
||||
|
||||
### Почему сейчас?
|
||||
|
||||
{{why_now}}
|
||||
|
||||
### Последствия, если не решить
|
||||
|
||||
{{impact_if_unsolved}}
|
||||
|
||||
---
|
||||
|
||||
## Целевая аудитория
|
||||
|
||||
### Основные пользователи
|
||||
|
||||
{{primary_users}}
|
||||
|
||||
### Вторичные пользователи
|
||||
|
||||
{{secondary_users}}
|
||||
|
||||
### Потребности пользователей
|
||||
|
||||
{{user_needs}}
|
||||
|
||||
---
|
||||
|
||||
## Обзор решения
|
||||
|
||||
### Предлагаемое решение
|
||||
|
||||
{{proposed_solution}}
|
||||
|
||||
### Ключевые возможности
|
||||
|
||||
{{key_features}}
|
||||
|
||||
### Ценностное предложение
|
||||
|
||||
{{value_proposition}}
|
||||
|
||||
---
|
||||
|
||||
## Бизнес-цели
|
||||
|
||||
### Цели
|
||||
|
||||
{{business_goals}}
|
||||
|
||||
### Метрики успеха
|
||||
|
||||
{{success_metrics}}
|
||||
|
||||
### Бизнес-ценность
|
||||
|
||||
{{business_value}}
|
||||
|
||||
---
|
||||
|
||||
## Границы (scope)
|
||||
|
||||
### Входит в scope
|
||||
|
||||
{{in_scope}}
|
||||
|
||||
### Не входит в scope
|
||||
|
||||
{{out_of_scope}}
|
||||
|
||||
### На будущее
|
||||
|
||||
{{future_considerations}}
|
||||
|
||||
---
|
||||
|
||||
## Ключевые стейкхолдеры
|
||||
|
||||
{{stakeholders}}
|
||||
|
||||
---
|
||||
|
||||
## Ограничения и допущения
|
||||
|
||||
### Ограничения
|
||||
|
||||
{{constraints}}
|
||||
|
||||
### Допущения
|
||||
|
||||
{{assumptions}}
|
||||
|
||||
---
|
||||
|
||||
## Критерии успеха
|
||||
|
||||
{{success_criteria}}
|
||||
|
||||
---
|
||||
|
||||
## Сроки и вехи
|
||||
|
||||
### Целевой запуск
|
||||
|
||||
{{target_launch}}
|
||||
|
||||
### Ключевые вехи
|
||||
|
||||
{{key_milestones}}
|
||||
|
||||
---
|
||||
|
||||
## Риски и меры
|
||||
|
||||
{{risks}}
|
||||
|
||||
---
|
||||
|
||||
## Следующие шаги
|
||||
|
||||
1. Создать документ продуктовых требований (PRD) — `/prd`
|
||||
2. Провести пользовательское исследование (по желанию) — `/research`
|
||||
3. Создать UX-дизайн (если сильный UI) — `/create-ux-design`
|
||||
|
||||
---
|
||||
|
||||
**Документ создан по методу BMAD v6 — фаза 1 (анализ)**
|
||||
|
||||
*Дальше: выполните `/workflow-status`, чтобы увидеть прогресс и рекомендуемый workflow.*
|
||||
@@ -0,0 +1,147 @@
|
||||
# Техническая спецификация: {{project_name}}
|
||||
|
||||
**Дата:** {{date}}
|
||||
**Автор:** {{user_name}}
|
||||
**Версия:** 1.0
|
||||
**Тип проекта:** {{project_type}}
|
||||
**Уровень проекта:** {{project_level}}
|
||||
**Статус:** Черновик
|
||||
|
||||
---
|
||||
|
||||
## Обзор документа
|
||||
|
||||
Эта техническая спецификация задаёт сфокусированное техническое планирование для {{project_name}}. Предназначена для небольших проектов (уровни 0–1), которым нужны чёткие требования без тяжёлого PRD.
|
||||
|
||||
**Связанные документы:**
|
||||
- Продуктовый бриф: {{product_brief_path}}
|
||||
|
||||
---
|
||||
|
||||
## Проблема и решение
|
||||
|
||||
### Формулировка проблемы
|
||||
|
||||
{{problem_statement}}
|
||||
|
||||
### Предлагаемое решение
|
||||
|
||||
{{proposed_solution}}
|
||||
|
||||
---
|
||||
|
||||
## Требования
|
||||
|
||||
### Что нужно построить
|
||||
|
||||
{{requirements_list}}
|
||||
|
||||
### Что явно не входит
|
||||
|
||||
{{out_of_scope}}
|
||||
|
||||
---
|
||||
|
||||
## Технический подход
|
||||
|
||||
### Стек технологий
|
||||
|
||||
{{tech_stack}}
|
||||
|
||||
### Обзор архитектуры
|
||||
|
||||
{{architecture_overview}}
|
||||
|
||||
### Модель данных (если применимо)
|
||||
|
||||
{{data_model}}
|
||||
|
||||
### Проектирование API (если применимо)
|
||||
|
||||
{{api_design}}
|
||||
|
||||
---
|
||||
|
||||
## План реализации
|
||||
|
||||
### Истории (stories)
|
||||
|
||||
{{stories_list}}
|
||||
|
||||
### Фазы разработки
|
||||
|
||||
{{development_phases}}
|
||||
|
||||
---
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
Как поймём, что готово:
|
||||
|
||||
{{acceptance_criteria}}
|
||||
|
||||
---
|
||||
|
||||
## Нефункциональные требования
|
||||
|
||||
### Производительность
|
||||
|
||||
{{performance_requirements}}
|
||||
|
||||
### Безопасность
|
||||
|
||||
{{security_requirements}}
|
||||
|
||||
### Прочее
|
||||
|
||||
{{other_nfr}}
|
||||
|
||||
---
|
||||
|
||||
## Зависимости
|
||||
|
||||
{{dependencies}}
|
||||
|
||||
---
|
||||
|
||||
## Риски и меры
|
||||
|
||||
{{risks}}
|
||||
|
||||
---
|
||||
|
||||
## Сроки
|
||||
|
||||
**Целевое завершение:** {{target_completion}}
|
||||
|
||||
**Вехи:**
|
||||
{{milestones}}
|
||||
|
||||
---
|
||||
|
||||
## Согласование
|
||||
|
||||
**Проверили:**
|
||||
- [ ] {{user_name}} (автор)
|
||||
- [ ] Технический лид
|
||||
- [ ] Владелец продукта (Product Owner)
|
||||
|
||||
---
|
||||
|
||||
## Следующие шаги
|
||||
|
||||
### Фаза 4: Реализация
|
||||
|
||||
Для проектов уровня 0 (одна история):
|
||||
- Выполните `/create-story`, чтобы создать историю
|
||||
- Выполните `/dev-story` для реализации
|
||||
|
||||
Для проектов уровня 1 (1–10 историй):
|
||||
- Выполните `/sprint-planning` для планирования спринта
|
||||
- Затем создайте и реализуйте истории
|
||||
|
||||
---
|
||||
|
||||
**Документ создан по методу BMAD v6 — фаза 2 (планирование)**
|
||||
|
||||
*Дальше: выполните `/workflow-status`, чтобы увидеть прогресс и рекомендуемый workflow.*
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineBuildConfig } from 'unbuild'
|
||||
|
||||
export default defineBuildConfig({
|
||||
entries: ['src/index'],
|
||||
declaration: true,
|
||||
clean: true,
|
||||
rollup: {
|
||||
emitCJS: false,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
// @ts-check
|
||||
import antfu from '@antfu/eslint-config'
|
||||
|
||||
export default antfu(
|
||||
{
|
||||
type: 'lib',
|
||||
rules: {
|
||||
'eslint-comments/no-unlimited-disable': 'off',
|
||||
'no-console': 'off',
|
||||
'node/prefer-global/buffer': 'off',
|
||||
'node/prefer-global/process': 'off',
|
||||
'perfectionist/sort-named-imports': 'off',
|
||||
'regexp/no-obscure-range': 'off',
|
||||
'sort-imports': 'off',
|
||||
'ts/explicit-function-return-type': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['dist/**', 'package.json', 'tsconfig.json'],
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@coopenomics/blago-cli",
|
||||
"version": "2026.4.8-6",
|
||||
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"blago": "./dist/index.mjs"
|
||||
},
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"ai",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "unbuild",
|
||||
"dev": "unbuild --stub",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"test": "echo \"No tests yet\" && exit 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@coopenomics/sdk": "workspace:*",
|
||||
"commander": "^12.1.0",
|
||||
"cooptypes": "workspace:*",
|
||||
"gray-matter": "^4.0.3",
|
||||
"picocolors": "^1.1.1",
|
||||
"yaml": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^3.3.2",
|
||||
"@types/node": "^22.5.4",
|
||||
"eslint": "^9.9.1",
|
||||
"typescript": "^5.9.3",
|
||||
"unbuild": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Каталог `ai/` внутри установленного пакета blago-cli (рядом со skills/commands/config).
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/** dist/index.mjs → ../ai; dist/cmd/*.mjs → ../../ai; src/*.ts (stub) → ../../ai */
|
||||
const AI_DIR_REL_TO_META = ['../ai', '../../ai', '../../../ai'] as const
|
||||
|
||||
export function bundledAiDir(): string {
|
||||
for (const rel of AI_DIR_REL_TO_META) {
|
||||
const dir = fileURLToPath(new URL(rel, import.meta.url))
|
||||
const marker = path.join(dir, 'config', 'helpers.md')
|
||||
try {
|
||||
fs.accessSync(marker)
|
||||
return dir
|
||||
}
|
||||
catch {
|
||||
/* следующий кандидат */
|
||||
}
|
||||
}
|
||||
const tried = AI_DIR_REL_TO_META.map(rel => fileURLToPath(new URL(rel, import.meta.url))).join('; ')
|
||||
throw new Error(
|
||||
`Не найден каталог ai/ пакета blago-cli (ожидается config/helpers.md). Проверены пути: ${tried}`,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
// Commander: подкоманды; базовый каталог — активная копия из ~/.claude/config/blago/config.yaml (если есть .blago), иначе cwd; корень копии — поиск .blago/config.json вверх от базы.
|
||||
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { Command } from 'commander'
|
||||
|
||||
import pkg from '../../package.json'
|
||||
|
||||
import { refreshGlobalAgentMirrorAsync } from '../config/agent-mirror.js'
|
||||
import {
|
||||
type BlagoConfigFile,
|
||||
type BlagoRemoteProfile,
|
||||
getActiveProfile,
|
||||
globalBlagoConfigPath,
|
||||
initBlagoGlobalLayout,
|
||||
initBlagoWorkspace,
|
||||
loadConfig,
|
||||
resolveActiveWorkspaceRoot,
|
||||
resolveCoopname,
|
||||
saveConfig,
|
||||
} from '../config/index.js'
|
||||
import { blagoDir, findBlagoRoot, sessionPath } from '../config/paths.js'
|
||||
import { resolveBlagoStartDir } from '../config/start-dir.js'
|
||||
import { syncGlobalActiveWorkspaceAfterEnvUse } from '../config/sync-workspace-env.js'
|
||||
import { runCreateIssue } from '../create/run-create-issue.js'
|
||||
import { runCreateStory } from '../create/run-create-story.js'
|
||||
import { applySession, createClient, ensureAuthenticatedContext, loginInteractive, promptLine } from '../session/index.js'
|
||||
import {
|
||||
describeBlagoSessionLine,
|
||||
formatBlagoSessionStatusHelpExtra,
|
||||
readSessionUsernameSync,
|
||||
} from '../session/status-text.js'
|
||||
import { runAdd } from '../sync/add.js'
|
||||
import { runClean } from '../sync/clean.js'
|
||||
import { runDiff } from '../sync/diff.js'
|
||||
import { normalizeRelativePath } from '../sync/index-store.js'
|
||||
import { runPull } from '../sync/pull.js'
|
||||
import { runPush } from '../sync/push.js'
|
||||
import { runClearStaging, runRemove } from '../sync/remove.js'
|
||||
import { runRestore } from '../sync/restore.js'
|
||||
import { runStatus } from '../sync/status.js'
|
||||
import { error, formatThrownValue, info, success, warn } from '../ui/output.js'
|
||||
|
||||
function startDir(): string {
|
||||
return resolveBlagoStartDir()
|
||||
}
|
||||
|
||||
function requireRoot(): string {
|
||||
const root = findBlagoRoot(startDir())
|
||||
if (!root) {
|
||||
throw new Error(
|
||||
'Не найдена рабочая копия blago (.blago/config.json). Выполните «blago init» и проверьте ~/.claude/config/blago/config.yaml (active_workspace_env, workspaces.*).',
|
||||
)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
export async function runCli(argv: string[]): Promise<void> {
|
||||
const program = new Command()
|
||||
.name('blago')
|
||||
// .description('Синхронизация проектов, задач и требований Благорост с бэкендом (GraphQL SDK). Подробности: README пакета.')
|
||||
// .configureHelp({ showGlobalOptions: true })
|
||||
.version(pkg.version)
|
||||
.addHelpText('afterAll', () => formatBlagoSessionStatusHelpExtra())
|
||||
|
||||
program
|
||||
.command('init')
|
||||
.description(
|
||||
'Глобальный конфиг ~/.claude/config/blago/config.yaml, helpers.md, templates/, скиллы в ~/.claude/skills/blago/ (из пакета), каталоги ~/blago/dev|testnet|production и .blago/config.json в каждом; опционально — ещё одна копия в указанном каталоге',
|
||||
)
|
||||
.argument(
|
||||
'[directory]',
|
||||
'дополнительно: инициализировать ещё один корень копии blago (помимо каталогов из глобального config)',
|
||||
)
|
||||
.option(
|
||||
'--coopname <name>',
|
||||
'записать это имя кооператива во все среды в config (потом можно развести по-разному в JSON)',
|
||||
)
|
||||
.option('--force', 'перезаписать config.json значениями по умолчанию')
|
||||
.action(async (directory: string | undefined, opts: { coopname?: string, force?: boolean }) => {
|
||||
const { global } = await initBlagoGlobalLayout({
|
||||
coopname: opts.coopname,
|
||||
force: opts.force,
|
||||
})
|
||||
let extraRoot: string | null = null
|
||||
if (directory !== undefined && directory.trim().length > 0) {
|
||||
extraRoot = path.resolve(startDir(), directory)
|
||||
await initBlagoWorkspace(extraRoot, { coopname: opts.coopname, force: opts.force })
|
||||
}
|
||||
success(`Глобальный конфиг агента: ${globalBlagoConfigPath()}`)
|
||||
success(`Скиллы агента: ${path.join(os.homedir(), '.claude', 'skills', 'blago')}`)
|
||||
const active = resolveActiveWorkspaceRoot(global)
|
||||
if (active) {
|
||||
success(
|
||||
`Активная рабочая копия (active_workspace_env=${global.active_workspace_env}): ${active}`,
|
||||
)
|
||||
}
|
||||
success(`Каталоги синхронизации: ${Object.keys(global.workspaces).sort().map(k => `${k} → ${global.workspaces[k]}`).join('; ')}`)
|
||||
if (extraRoot) {
|
||||
success(`Дополнительно инициализировано: ${path.join(extraRoot, '.blago')}`)
|
||||
}
|
||||
const activeRoot = resolveActiveWorkspaceRoot(global)
|
||||
if (activeRoot) {
|
||||
const cfg = await loadConfig(activeRoot)
|
||||
await refreshGlobalAgentMirrorAsync(activeRoot, cfg)
|
||||
}
|
||||
})
|
||||
|
||||
const createCmd = program
|
||||
.command('create')
|
||||
.description(
|
||||
'Задача (issue): сразу CreateIssue на сервере (нужны сеть и blago login). Требование (req): локальный черновик; первый push — create на сервере.',
|
||||
)
|
||||
|
||||
createCmd
|
||||
.command('issue')
|
||||
.description(
|
||||
'Создать задачу на сервере (сразу id и issue_hash в .md); тело описания пустое; файл в индексе и staging',
|
||||
)
|
||||
.argument('<basePath>', 'каталог проекта/компонента или путь к project.md / component.md')
|
||||
.argument('<title>', 'заголовок')
|
||||
.option(
|
||||
'--set-self',
|
||||
'добавить username из сессии активной среды первым в creators (нужен blago login); created_by не задаётся',
|
||||
)
|
||||
.option(
|
||||
'--creators <list>',
|
||||
'список создателей через запятую; с --set-self — после текущего пользователя; без флагов — creators: []',
|
||||
)
|
||||
.option('--submaster <username>', 'явный submaster (только с этим флагом)')
|
||||
.action(
|
||||
async (
|
||||
basePath: string,
|
||||
title: string,
|
||||
opts: { setSelf?: boolean, creators?: string, submaster?: string },
|
||||
) => {
|
||||
const root = requireRoot()
|
||||
const cfg = await loadConfig(root)
|
||||
const ctx = await ensureAuthenticatedContext(root, cfg)
|
||||
const { relativePath } = await runCreateIssue(ctx, basePath, title, {
|
||||
setSelf: opts.setSelf,
|
||||
creatorsCsv: opts.creators,
|
||||
submaster: opts.submaster,
|
||||
})
|
||||
success(`Задача создана на сервере: ${relativePath} (добавлено в staging)`)
|
||||
},
|
||||
)
|
||||
|
||||
createCmd
|
||||
.command('req')
|
||||
.alias('requirement')
|
||||
.description('Создать файл требования / story (type: story)')
|
||||
.argument('<basePath>', 'каталог проекта/компонента или путь к project.md / component.md')
|
||||
.argument('<title>', 'заголовок')
|
||||
.option(
|
||||
'--set-self',
|
||||
'заполнить created_by username из сессии активной среды (нужен blago login)',
|
||||
)
|
||||
.option(
|
||||
'--format <name>',
|
||||
'содержимое: markdown | mermaid | drawio | bpmn',
|
||||
'markdown',
|
||||
)
|
||||
.action(
|
||||
async (basePath: string, title: string, opts: { setSelf?: boolean, format?: string }) => {
|
||||
const root = requireRoot()
|
||||
const cfg = await loadConfig(root)
|
||||
const { relativePath } = await runCreateStory(root, cfg, basePath, title, {
|
||||
setSelf: opts.setSelf,
|
||||
format: opts.format,
|
||||
})
|
||||
success(`Создан черновик требования: ${relativePath} (добавлено в staging)`)
|
||||
},
|
||||
)
|
||||
|
||||
program
|
||||
.command('login')
|
||||
.description('Интерактивный вход (email + WIF); сохраняет сессию для активной среды')
|
||||
.action(async () => {
|
||||
const root = requireRoot()
|
||||
const cfg = await loadConfig(root)
|
||||
const profile = getActiveProfile(cfg)
|
||||
const client = createClient(profile)
|
||||
const session = await loginInteractive(client, root, cfg.activeEnv)
|
||||
await applySession(client, session)
|
||||
success(
|
||||
`Вход выполнен.\nРабочая копия: ${root}\nФайл сессии: ${sessionPath(root, cfg.activeEnv)}`,
|
||||
)
|
||||
})
|
||||
|
||||
program
|
||||
.command('pull')
|
||||
.description('Скачать с сервера проекты, задачи и требования (coopname в config или из blago init)')
|
||||
.action(async () => {
|
||||
const root = requireRoot()
|
||||
const cfg = await loadConfig(root)
|
||||
const ctx = await ensureAuthenticatedContext(root, cfg)
|
||||
await runPull(ctx)
|
||||
success('pull завершён')
|
||||
})
|
||||
|
||||
program
|
||||
.command('add')
|
||||
.description(
|
||||
'Добавить в staging только изменённые относительно индекса .md (или без записи в индексе); каталог рекурсивно. Пути …/messages/ и …/meetings/ (pull ChatCoop) в staging не попадают.',
|
||||
)
|
||||
.argument('<paths...>', 'пути относительно корня рабочей копии')
|
||||
.action(async (paths: string[]) => {
|
||||
const root = requireRoot()
|
||||
const { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts } = await runAdd(root, paths)
|
||||
success(
|
||||
`Staging обновлён: в списке ${stagedPaths.length} путь(ей). Пропущено без изменений относительно индекса: ${skippedUnchanged}; по .blagoignore: ${skippedIgnored}; артефакты только pull (messages/ и meetings/): ${skippedPullOnlyArtifacts}.`,
|
||||
)
|
||||
})
|
||||
|
||||
program
|
||||
.command('remove')
|
||||
.alias('rm')
|
||||
.description('Убрать файлы .md из staging (каталог — рекурсивно все .md внутри)')
|
||||
.option('-a, --all', 'очистить staging целиком')
|
||||
.argument('[paths...]', 'пути относительно корня рабочей копии')
|
||||
.action(async (paths: string[], opts: { all?: boolean }) => {
|
||||
const root = requireRoot()
|
||||
if (opts.all) {
|
||||
await runClearStaging(root)
|
||||
success('Staging очищен (--all).')
|
||||
return
|
||||
}
|
||||
const { removedCount, remainingPaths, notStagedCount } = await runRemove(root, paths)
|
||||
success(
|
||||
`Из staging убрано путей: ${removedCount}. Осталось в списке: ${remainingPaths.length}. Не было в staging: ${notStagedCount}.`,
|
||||
)
|
||||
})
|
||||
|
||||
program
|
||||
.command('restore')
|
||||
.description('Восстановить один .md с сервера по пути из индекса (перезапись файла и обновление индекса)')
|
||||
.argument('<path>', 'относительный путь к файлу, например 2-proekt-2/project.md')
|
||||
.action(async (filePath: string) => {
|
||||
const root = requireRoot()
|
||||
const cfg = await loadConfig(root)
|
||||
const ctx = await ensureAuthenticatedContext(root, cfg)
|
||||
await runRestore(ctx, filePath)
|
||||
success(`Восстановлено с сервера: ${normalizeRelativePath(filePath)}`)
|
||||
})
|
||||
|
||||
program
|
||||
.command('push')
|
||||
.description('Отправить staged файлы на сервер (проверка версии, без тихой перезаписи)')
|
||||
.action(async () => {
|
||||
const root = requireRoot()
|
||||
const cfg = await loadConfig(root)
|
||||
const ctx = await ensureAuthenticatedContext(root, cfg)
|
||||
await runPush(ctx)
|
||||
success('push завершён')
|
||||
})
|
||||
|
||||
program
|
||||
.command('status')
|
||||
.description('Показать staging и файлы с локальными изменениями относительно индекса')
|
||||
.action(async () => {
|
||||
const root = requireRoot()
|
||||
await runStatus(root)
|
||||
})
|
||||
|
||||
program
|
||||
.command('diff')
|
||||
.description('Файлы, где содержимое разошлось с индексом: SHA256 и превью строк (не сравнение с сервером)')
|
||||
.action(async () => {
|
||||
const root = requireRoot()
|
||||
await runDiff(root, blagoDir(root))
|
||||
})
|
||||
|
||||
program
|
||||
.command('clean')
|
||||
.description(
|
||||
'Удалить верхнеуровневые каталоги проектов из индекса (целиком) и обнулить index/staging; .blago не трогает',
|
||||
)
|
||||
.action(async () => {
|
||||
const root = requireRoot()
|
||||
const line = await promptLine(
|
||||
'Будут удалены каталоги проектов (по индексу) и очищены index/staging. Введите yes для подтверждения: ',
|
||||
)
|
||||
if (line.trim().toLowerCase() !== 'yes') {
|
||||
warn('Отменено.')
|
||||
return
|
||||
}
|
||||
await runClean(root)
|
||||
success('Индекс и staging очищены, каталоги проектов удалены.')
|
||||
})
|
||||
|
||||
const envCmd = program
|
||||
.command('env')
|
||||
.description(
|
||||
'Управление именованными средами (URL API и блокчейна). Без подкоманды — показать активную среду и сессию.',
|
||||
)
|
||||
|
||||
envCmd
|
||||
.command('use')
|
||||
.description('Сделать среду активной')
|
||||
.argument('<name>', 'имя среды')
|
||||
.action(async (name: string) => {
|
||||
const root = requireRoot()
|
||||
const cfg = await loadConfig(root)
|
||||
if (!cfg.environments[name]) {
|
||||
throw new Error(`Среда «${name}» не описана. Сначала: blago env set ${name} …`)
|
||||
}
|
||||
const next: BlagoConfigFile = { ...cfg, activeEnv: name }
|
||||
await saveConfig(root, next)
|
||||
const { effectiveRoot, globalSynced } = await syncGlobalActiveWorkspaceAfterEnvUse(name, root, next)
|
||||
success(
|
||||
globalSynced
|
||||
? `Активная среда: ${name}. Рабочая копия (global): ${effectiveRoot}`
|
||||
: `Активная среда: ${name}`,
|
||||
)
|
||||
})
|
||||
|
||||
envCmd
|
||||
.command('set')
|
||||
.description('Задать параметры среды')
|
||||
.argument('<name>', 'имя среды')
|
||||
.requiredOption('--api-url <url>', 'URL GraphQL, например http://127.0.0.1:2998/v1/graphql')
|
||||
.requiredOption('--chain-url <url>', 'URL ноды EOSIO')
|
||||
.option('--chain-id <id>', 'CHAIN_ID сети')
|
||||
.option('--label <text>', 'подпись для человека')
|
||||
.action(async (name: string, opts: { apiUrl: string, chainUrl: string, chainId?: string, label?: string }) => {
|
||||
const root = requireRoot()
|
||||
const cfg = await loadConfig(root)
|
||||
const prev: BlagoRemoteProfile = cfg.environments[name] ?? {
|
||||
api_url: '',
|
||||
chain_url: '',
|
||||
chain_id: '',
|
||||
}
|
||||
const profile: BlagoRemoteProfile = {
|
||||
api_url: opts.apiUrl,
|
||||
chain_url: opts.chainUrl,
|
||||
chain_id: opts.chainId ?? prev.chain_id ?? '',
|
||||
label: opts.label ?? prev.label,
|
||||
}
|
||||
const next: BlagoConfigFile = {
|
||||
...cfg,
|
||||
environments: { ...cfg.environments, [name]: profile },
|
||||
}
|
||||
await saveConfig(root, next)
|
||||
await refreshGlobalAgentMirrorAsync(root, next)
|
||||
success(`Среда «${name}» обновлена`)
|
||||
})
|
||||
|
||||
envCmd.action(async () => {
|
||||
const root = requireRoot()
|
||||
const cfg = await loadConfig(root)
|
||||
await refreshGlobalAgentMirrorAsync(root, cfg)
|
||||
info(`Рабочая копия: ${root}`)
|
||||
info(`Метаданные и сессии: ${blagoDir(root)}`)
|
||||
info(
|
||||
describeBlagoSessionLine(
|
||||
cfg,
|
||||
readSessionUsernameSync(root, cfg.activeEnv),
|
||||
resolveCoopname(cfg),
|
||||
),
|
||||
)
|
||||
info(`Среды в config: ${Object.keys(cfg.environments).sort().join(', ')}`)
|
||||
})
|
||||
|
||||
try {
|
||||
await program.parseAsync(argv, { from: 'node' })
|
||||
}
|
||||
catch (e) {
|
||||
error(formatThrownValue(e))
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Зеркало coopname / username в ~/.claude/config/blago/config.yaml для активной рабочей копии.
|
||||
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { readSessionUsernameSync } from '../session/username-sync.js'
|
||||
import { type BlagoConfigFile, resolveCoopname } from './index.js'
|
||||
import {
|
||||
readGlobalBlagoConfig,
|
||||
readGlobalBlagoConfigSync,
|
||||
resolveActiveWorkspaceRoot,
|
||||
writeGlobalBlagoConfig,
|
||||
writeGlobalBlagoConfigSync,
|
||||
type BlagoGlobalConfigFile,
|
||||
} from './global-config.js'
|
||||
|
||||
export function isActiveGlobalWorkspace(global: BlagoGlobalConfigFile, workspaceRoot: string): boolean {
|
||||
const active = resolveActiveWorkspaceRoot(global)
|
||||
return active !== null && path.resolve(workspaceRoot) === active
|
||||
}
|
||||
|
||||
function computeMirroredGlobal(
|
||||
global: BlagoGlobalConfigFile,
|
||||
workspaceRoot: string,
|
||||
cfg: BlagoConfigFile,
|
||||
): BlagoGlobalConfigFile | null {
|
||||
if (!isActiveGlobalWorkspace(global, workspaceRoot)) {
|
||||
return null
|
||||
}
|
||||
const usernameRaw = readSessionUsernameSync(workspaceRoot, cfg.activeEnv)
|
||||
const coopname = resolveCoopname(cfg) ?? ''
|
||||
const username = usernameRaw ?? ''
|
||||
if (global.coopname === coopname && global.username === username) {
|
||||
return null
|
||||
}
|
||||
return { ...global, coopname, username }
|
||||
}
|
||||
|
||||
export async function refreshGlobalAgentMirrorAsync(
|
||||
workspaceRoot: string,
|
||||
cfg: BlagoConfigFile,
|
||||
): Promise<void> {
|
||||
const global = await readGlobalBlagoConfig()
|
||||
if (!global) {
|
||||
return
|
||||
}
|
||||
const next = computeMirroredGlobal(global, workspaceRoot, cfg)
|
||||
if (next) {
|
||||
await writeGlobalBlagoConfig(next)
|
||||
}
|
||||
}
|
||||
|
||||
export function refreshGlobalAgentMirrorSync(workspaceRoot: string, cfg: BlagoConfigFile): void {
|
||||
const global = readGlobalBlagoConfigSync()
|
||||
if (!global) {
|
||||
return
|
||||
}
|
||||
const next = computeMirroredGlobal(global, workspaceRoot, cfg)
|
||||
if (next) {
|
||||
writeGlobalBlagoConfigSync(next)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Desktop передаёт в SDK `BACKEND_URL + '/v1/graphql'`. В config blago ожидаем тот же базовый BACKEND_URL.
|
||||
*/
|
||||
export function toGraphqlApiUrl(apiUrl: string): string {
|
||||
const base = apiUrl.trim().replace(/\/+$/, '')
|
||||
if (base.length === 0) {
|
||||
return apiUrl.trim()
|
||||
}
|
||||
if (/\/v1\/graphql$/i.test(base) || /\/graphql$/i.test(base)) {
|
||||
return base
|
||||
}
|
||||
return `${base}/v1/graphql`
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Копия каталога ai/ пакета → ~/.claude/skills/blago/ (без вложенной папки «ai»).
|
||||
|
||||
import * as fsp from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { bundledAiDir } from '../ai-bundled-dir.js'
|
||||
|
||||
export async function copyBundledAiSkillsToClaudeHome(): Promise<{ source: string, dest: string }> {
|
||||
const source = bundledAiDir()
|
||||
const dest = path.join(os.homedir(), '.claude', 'skills', 'blago')
|
||||
const skillsRoot = path.join(os.homedir(), '.claude', 'skills')
|
||||
await fsp.mkdir(skillsRoot, { recursive: true })
|
||||
await fsp.mkdir(dest, { recursive: true })
|
||||
|
||||
const entries = await fsp.readdir(source, { withFileTypes: true })
|
||||
for (const ent of entries) {
|
||||
const from = path.join(source, ent.name)
|
||||
const to = path.join(dest, ent.name)
|
||||
await fsp.rm(to, { recursive: true, force: true })
|
||||
await fsp.cp(from, to, { recursive: true })
|
||||
}
|
||||
return { source, dest }
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Глобальный конфиг для агентов: ~/.claude/config/blago/config.yaml
|
||||
|
||||
import * as fsSync from 'node:fs'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import YAML from 'yaml'
|
||||
|
||||
import { bundledAiDir } from '../ai-bundled-dir.js'
|
||||
|
||||
export const GLOBAL_CONFIG_REL_PARTS = ['.claude', 'config', 'blago'] as const
|
||||
export const GLOBAL_CONFIG_FILENAME = 'config.yaml'
|
||||
/** Копия из пакета; лежит рядом с config.yaml (как у BMAD: config и helpers в одном каталоге). */
|
||||
export const GLOBAL_HELPERS_FILENAME = 'helpers.md'
|
||||
/** Подкаталог в `globalBlagoConfigDir()`: шаблоны документов (копия из пакета `ai/templates/`). */
|
||||
export const GLOBAL_TEMPLATES_SUBDIR = 'templates'
|
||||
|
||||
const CONFIG_VERSION = 1 as const
|
||||
|
||||
export interface BlagoGlobalConfigFile {
|
||||
readonly version: typeof CONFIG_VERSION
|
||||
/** Абсолютный путь: родитель каталогов dev / testnet / production. */
|
||||
workspace_base: string
|
||||
/**
|
||||
* Имя подкаталога под workspace_base (и ключ в workspaces), с которым по умолчанию работает агент.
|
||||
* Совпадает с именем «физической» копии; API-профиль в .blago/config.json этой копии обычно тот же.
|
||||
*/
|
||||
active_workspace_env: string
|
||||
/** Абсолютные пути рабочих копий синхронизации. */
|
||||
workspaces: Record<string, string>
|
||||
/**
|
||||
* Зеркало resolveCoopname(локальный config) для активной рабочей копии — для агентов без чтения .blago/config.json.
|
||||
*/
|
||||
coopname: string
|
||||
/**
|
||||
* Зеркало username из сессии активной среды; пусто если вход не выполнен.
|
||||
*/
|
||||
username: string
|
||||
}
|
||||
|
||||
export function globalBlagoConfigDir(): string {
|
||||
return path.join(os.homedir(), ...GLOBAL_CONFIG_REL_PARTS)
|
||||
}
|
||||
|
||||
export function globalBlagoConfigPath(): string {
|
||||
return path.join(globalBlagoConfigDir(), GLOBAL_CONFIG_FILENAME)
|
||||
}
|
||||
|
||||
export function globalBlagoHelpersPath(): string {
|
||||
return path.join(globalBlagoConfigDir(), GLOBAL_HELPERS_FILENAME)
|
||||
}
|
||||
|
||||
export function globalBlagoTemplatesDir(): string {
|
||||
return path.join(globalBlagoConfigDir(), GLOBAL_TEMPLATES_SUBDIR)
|
||||
}
|
||||
|
||||
/** Копирует `ai/config/helpers.md` из пакета в каталог глобального конфига (перезапись). */
|
||||
export async function installBundledHelpersIntoGlobalConfig(): Promise<void> {
|
||||
const src = path.join(bundledAiDir(), 'config', GLOBAL_HELPERS_FILENAME)
|
||||
const dir = globalBlagoConfigDir()
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await fs.copyFile(src, globalBlagoHelpersPath())
|
||||
}
|
||||
|
||||
/** Копирует `ai/templates/*` в `~/.claude/config/blago/templates/` (перезапись файлов). */
|
||||
export async function installBundledTemplatesIntoGlobalConfig(): Promise<void> {
|
||||
const srcDir = path.join(bundledAiDir(), GLOBAL_TEMPLATES_SUBDIR)
|
||||
const destDir = globalBlagoTemplatesDir()
|
||||
await fs.mkdir(destDir, { recursive: true })
|
||||
let names: string[]
|
||||
try {
|
||||
names = await fs.readdir(srcDir)
|
||||
}
|
||||
catch {
|
||||
return
|
||||
}
|
||||
for (const name of names) {
|
||||
const from = path.join(srcDir, name)
|
||||
let st: Awaited<ReturnType<typeof fs.stat>>
|
||||
try {
|
||||
st = await fs.stat(from)
|
||||
}
|
||||
catch {
|
||||
continue
|
||||
}
|
||||
if (st.isFile()) {
|
||||
await fs.copyFile(from, path.join(destDir, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** helpers.md + templates/ — вызывать из init. */
|
||||
export async function installBundledBlagoConfigAssets(): Promise<void> {
|
||||
await installBundledHelpersIntoGlobalConfig()
|
||||
await installBundledTemplatesIntoGlobalConfig()
|
||||
}
|
||||
|
||||
export function defaultBlagoHomeDataRoot(): string {
|
||||
return path.join(os.homedir(), 'blago')
|
||||
}
|
||||
|
||||
const STANDARD_WORKSPACE_NAMES = ['dev', 'testnet', 'production'] as const
|
||||
|
||||
export function defaultWorkspacePaths(workspaceBase: string): Record<string, string> {
|
||||
const base = path.resolve(workspaceBase)
|
||||
return Object.fromEntries(
|
||||
STANDARD_WORKSPACE_NAMES.map(name => [name, path.join(base, name)]),
|
||||
) as Record<string, string>
|
||||
}
|
||||
|
||||
export function buildDefaultGlobalConfig(workspaceBase?: string): BlagoGlobalConfigFile {
|
||||
const resolvedBase = path.resolve(workspaceBase ?? defaultBlagoHomeDataRoot())
|
||||
return {
|
||||
version: CONFIG_VERSION,
|
||||
workspace_base: resolvedBase,
|
||||
active_workspace_env: 'dev',
|
||||
workspaces: defaultWorkspacePaths(resolvedBase),
|
||||
coopname: '',
|
||||
username: '',
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGlobalParsed(parsed: Partial<BlagoGlobalConfigFile> | null): BlagoGlobalConfigFile | null {
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null
|
||||
}
|
||||
if (parsed.version !== CONFIG_VERSION) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
typeof parsed.workspace_base !== 'string'
|
||||
|| typeof parsed.active_workspace_env !== 'string'
|
||||
|| typeof parsed.workspaces !== 'object'
|
||||
|| parsed.workspaces === null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const coopname = typeof parsed.coopname === 'string' ? parsed.coopname : ''
|
||||
const username = typeof parsed.username === 'string' ? parsed.username : ''
|
||||
return {
|
||||
version: CONFIG_VERSION,
|
||||
workspace_base: parsed.workspace_base,
|
||||
active_workspace_env: parsed.active_workspace_env,
|
||||
workspaces: parsed.workspaces as Record<string, string>,
|
||||
coopname,
|
||||
username,
|
||||
}
|
||||
}
|
||||
|
||||
function parseGlobalYamlText(raw: string): BlagoGlobalConfigFile | null {
|
||||
const parsed = YAML.parse(raw) as Partial<BlagoGlobalConfigFile> | null
|
||||
return normalizeGlobalParsed(parsed)
|
||||
}
|
||||
|
||||
export async function readGlobalBlagoConfig(): Promise<BlagoGlobalConfigFile | null> {
|
||||
const p = globalBlagoConfigPath()
|
||||
let raw: string
|
||||
try {
|
||||
raw = await fs.readFile(p, 'utf8')
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
return parseGlobalYamlText(raw)
|
||||
}
|
||||
|
||||
/** Синхронное чтение (для help и зеркала перед выводом). */
|
||||
export function readGlobalBlagoConfigSync(): BlagoGlobalConfigFile | null {
|
||||
try {
|
||||
const raw = fsSync.readFileSync(globalBlagoConfigPath(), 'utf8')
|
||||
return parseGlobalYamlText(raw)
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyGlobalYaml(cfg: BlagoGlobalConfigFile): string {
|
||||
return `${YAML.stringify(cfg, { lineWidth: 0, indent: 2 }).trimEnd()}\n`
|
||||
}
|
||||
|
||||
export async function writeGlobalBlagoConfig(cfg: BlagoGlobalConfigFile): Promise<void> {
|
||||
const dir = globalBlagoConfigDir()
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await fs.writeFile(globalBlagoConfigPath(), stringifyGlobalYaml(cfg), 'utf8')
|
||||
}
|
||||
|
||||
export function writeGlobalBlagoConfigSync(cfg: BlagoGlobalConfigFile): void {
|
||||
const dir = globalBlagoConfigDir()
|
||||
fsSync.mkdirSync(dir, { recursive: true })
|
||||
fsSync.writeFileSync(globalBlagoConfigPath(), stringifyGlobalYaml(cfg), 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт config.yaml при отсутствии. Существующий файл не перезаписывает.
|
||||
*/
|
||||
export async function ensureGlobalBlagoConfigFile(
|
||||
workspaceBase?: string,
|
||||
): Promise<BlagoGlobalConfigFile> {
|
||||
const existing = await readGlobalBlagoConfig()
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const next = buildDefaultGlobalConfig(workspaceBase)
|
||||
await writeGlobalBlagoConfig(next)
|
||||
return next
|
||||
}
|
||||
|
||||
export async function mkdirWorkspaceDirs(cfg: BlagoGlobalConfigFile): Promise<void> {
|
||||
await Promise.all(
|
||||
Object.values(cfg.workspaces).map(dir => fs.mkdir(dir, { recursive: true })),
|
||||
)
|
||||
}
|
||||
|
||||
/** Абсолютный путь активной рабочей копии по глобальному конфигу; null если ключ отсутствует. */
|
||||
export function resolveActiveWorkspaceRoot(cfg: BlagoGlobalConfigFile): string | null {
|
||||
const key = cfg.active_workspace_env.trim()
|
||||
if (!key) {
|
||||
return null
|
||||
}
|
||||
const dir = cfg.workspaces[key]
|
||||
if (typeof dir !== 'string' || !dir.trim()) {
|
||||
return null
|
||||
}
|
||||
return path.resolve(dir.trim())
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// .blago/config.json: activeEnv, coopname, environments { api_url, chain_url, chain_id, coopname? }.
|
||||
|
||||
import type { BlagoGlobalConfigFile } from './global-config.js'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { copyBundledAiSkillsToClaudeHome } from './bundled-agent-skills.js'
|
||||
import {
|
||||
ensureGlobalBlagoConfigFile,
|
||||
installBundledBlagoConfigAssets,
|
||||
mkdirWorkspaceDirs,
|
||||
} from './global-config.js'
|
||||
import { blagoDir, CONFIG_FILE, configPath, gitignorePath } from './paths.js'
|
||||
|
||||
export type BlagoEnvironmentName = 'dev' | 'testnet' | 'production' | (string & {})
|
||||
|
||||
/**
|
||||
* Имя кооператива в шаблоне среды (новый config). Поменяйте per-env в .blago/config.json под каждую среду.
|
||||
*/
|
||||
export const DEFAULT_COOPNAME = 'voskhod'
|
||||
|
||||
export interface BlagoRemoteProfile {
|
||||
/** Базовый URL контроллера (как BACKEND_URL у desktop); без суффикса к пути добавится /v1/graphql. */
|
||||
readonly api_url: string
|
||||
readonly chain_url: string
|
||||
readonly chain_id: string
|
||||
readonly label?: string
|
||||
/** Short name кооператива для этой среды (фильтры Capital / pull). У разных сред может быть разное значение. */
|
||||
readonly coopname?: string
|
||||
}
|
||||
|
||||
export interface BlagoConfigFile {
|
||||
/** Имя активной среды */
|
||||
activeEnv: string
|
||||
/**
|
||||
* Запасной coopname, если у активной среды поле environments.<activeEnv>.coopname пустое (legacy / общий fallback).
|
||||
*/
|
||||
coopname?: string
|
||||
environments: Record<string, BlagoRemoteProfile>
|
||||
}
|
||||
|
||||
const DEFAULT_ENVS: Record<string, BlagoRemoteProfile> = {
|
||||
dev: {
|
||||
api_url: 'http://127.0.0.1:2998',
|
||||
chain_url: 'http://127.0.0.1:8888',
|
||||
chain_id: 'cae86058a6d8698833afb474ab8a5ad8599c6cf54f9ebcf275dbac7055c16fe1',
|
||||
label: 'Локальная разработка',
|
||||
coopname: DEFAULT_COOPNAME,
|
||||
},
|
||||
testnet: {
|
||||
api_url: 'https://testnet.coopenomics.world/backend',
|
||||
chain_url: 'https://testnet.coopenomics.world/api',
|
||||
chain_id: 'f0364a3f9fd913081f1c0b05c6f8f50a59b2ba60bb928cb321ba3a9a36316624',
|
||||
label: 'Testnet',
|
||||
coopname: DEFAULT_COOPNAME,
|
||||
},
|
||||
production: {
|
||||
api_url: 'https://лк.цифровой-кооператив.рф/backend',
|
||||
chain_url: 'https://лк.цифровой-кооператив.рф/api',
|
||||
chain_id: '6e37f9ac0f0ea717bfdbf57d1dd5d7f0e2d773227d9659a63bbf86eec0326c1b',
|
||||
label: 'Production',
|
||||
coopname: DEFAULT_COOPNAME,
|
||||
},
|
||||
}
|
||||
|
||||
export function defaultConfig(): BlagoConfigFile {
|
||||
return {
|
||||
activeEnv: 'dev',
|
||||
environments: { ...DEFAULT_ENVS },
|
||||
}
|
||||
}
|
||||
|
||||
/** Эффективный coopname: сначала у активной среды, иначе верхний уровень config (fallback). */
|
||||
export function resolveCoopname(cfg: BlagoConfigFile): string | undefined {
|
||||
const profile = cfg.environments[cfg.activeEnv]
|
||||
const fromEnv = profile?.coopname?.trim()
|
||||
if (fromEnv) {
|
||||
return fromEnv
|
||||
}
|
||||
return cfg.coopname?.trim() || undefined
|
||||
}
|
||||
|
||||
function environmentsWithOptionalCoopname(
|
||||
envs: Record<string, BlagoRemoteProfile>,
|
||||
coopname: string | undefined,
|
||||
): Record<string, BlagoRemoteProfile> {
|
||||
if (coopname === undefined) {
|
||||
return envs
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(envs).map(([name, profile]) => [name, { ...profile, coopname }]),
|
||||
) as Record<string, BlagoRemoteProfile>
|
||||
}
|
||||
|
||||
export async function loadConfig(root: string): Promise<BlagoConfigFile> {
|
||||
const raw = await fs.readFile(configPath(root), 'utf8')
|
||||
const parsed = JSON.parse(raw) as BlagoConfigFile
|
||||
if (!parsed.activeEnv || typeof parsed.environments !== 'object') {
|
||||
throw new Error('Некорректный config.json: нужны activeEnv и environments')
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
export async function saveConfig(root: string, cfg: BlagoConfigFile): Promise<void> {
|
||||
await fs.mkdir(blagoDir(root), { recursive: true })
|
||||
await fs.writeFile(configPath(root), `${JSON.stringify(cfg, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
const BLAGO_SESSION_GITIGNORE_LINE = 'session.*.json'
|
||||
|
||||
const BLAGO_GITIGNORE_SNIPPET = `# Токены blago login — не коммитить в репозиторий
|
||||
${BLAGO_SESSION_GITIGNORE_LINE}
|
||||
`
|
||||
|
||||
/** Создаёт или дополняет .blago/.gitignore, чтобы не коммитить файлы сессии. */
|
||||
export async function ensureBlagoGitignore(root: string): Promise<void> {
|
||||
const p = gitignorePath(root)
|
||||
let existing = ''
|
||||
try {
|
||||
existing = await fs.readFile(p, 'utf8')
|
||||
}
|
||||
catch {
|
||||
/* файла ещё нет */
|
||||
}
|
||||
if (existing.includes(BLAGO_SESSION_GITIGNORE_LINE)) {
|
||||
return
|
||||
}
|
||||
const body = existing.trim().length > 0
|
||||
? `${existing.replace(/\s+$/, '')}\n\n${BLAGO_GITIGNORE_SNIPPET}`
|
||||
: BLAGO_GITIGNORE_SNIPPET
|
||||
await fs.writeFile(p, body.endsWith('\n') ? body : `${body}\n`, 'utf8')
|
||||
}
|
||||
|
||||
export async function initBlagoWorkspace(
|
||||
root: string,
|
||||
options?: { coopname?: string, force?: boolean, preferredActiveEnv?: string },
|
||||
): Promise<void> {
|
||||
const dir = blagoDir(root)
|
||||
const cfgFile = path.join(dir, CONFIG_FILE)
|
||||
if (!options?.force && (await fileExists(cfgFile))) {
|
||||
await ensureBlagoGitignore(root)
|
||||
return
|
||||
}
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
const cfg = defaultConfig()
|
||||
const envName = options?.preferredActiveEnv?.trim()
|
||||
const activeEnv
|
||||
= envName && envName in cfg.environments ? envName : cfg.activeEnv
|
||||
const next: BlagoConfigFile = {
|
||||
...cfg,
|
||||
activeEnv,
|
||||
environments: environmentsWithOptionalCoopname(cfg.environments, options?.coopname),
|
||||
}
|
||||
await saveConfig(root, next)
|
||||
await ensureBlagoGitignore(root)
|
||||
}
|
||||
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveProfile(cfg: BlagoConfigFile): BlagoRemoteProfile {
|
||||
const profile = cfg.environments[cfg.activeEnv]
|
||||
if (!profile) {
|
||||
throw new Error(`Среда «${cfg.activeEnv}» не найдена в config.json`)
|
||||
}
|
||||
if (!profile.api_url?.trim() || !profile.chain_url?.trim()) {
|
||||
throw new Error(`У среды «${cfg.activeEnv}» задайте api_url и chain_url (blago env set …)`)
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
export type { BlagoGlobalConfigFile } from './global-config.js'
|
||||
export {
|
||||
buildDefaultGlobalConfig,
|
||||
defaultBlagoHomeDataRoot,
|
||||
defaultWorkspacePaths,
|
||||
ensureGlobalBlagoConfigFile,
|
||||
globalBlagoConfigDir,
|
||||
globalBlagoConfigPath,
|
||||
globalBlagoHelpersPath,
|
||||
globalBlagoTemplatesDir,
|
||||
installBundledBlagoConfigAssets,
|
||||
installBundledHelpersIntoGlobalConfig,
|
||||
installBundledTemplatesIntoGlobalConfig,
|
||||
mkdirWorkspaceDirs,
|
||||
readGlobalBlagoConfig,
|
||||
resolveActiveWorkspaceRoot,
|
||||
writeGlobalBlagoConfig,
|
||||
} from './global-config.js'
|
||||
|
||||
export async function initBlagoGlobalLayout(
|
||||
options?: { coopname?: string, force?: boolean, workspaceBase?: string },
|
||||
): Promise<{ global: BlagoGlobalConfigFile }> {
|
||||
const global = await ensureGlobalBlagoConfigFile(options?.workspaceBase)
|
||||
await installBundledBlagoConfigAssets()
|
||||
await copyBundledAiSkillsToClaudeHome()
|
||||
await mkdirWorkspaceDirs(global)
|
||||
const names = ['dev', 'testnet', 'production'] as const
|
||||
for (const name of names) {
|
||||
const wsRoot = global.workspaces[name]
|
||||
if (typeof wsRoot === 'string' && wsRoot.trim().length > 0) {
|
||||
await initBlagoWorkspace(path.resolve(wsRoot.trim()), {
|
||||
coopname: options?.coopname,
|
||||
force: options?.force,
|
||||
preferredActiveEnv: name,
|
||||
})
|
||||
}
|
||||
}
|
||||
return { global }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Пути под .blago/ и поиск корня по наличию config.json вверх по дереву.
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
|
||||
export const BLAGO_DIR = '.blago'
|
||||
export const GITIGNORE_FILE = '.gitignore'
|
||||
export const CONFIG_FILE = 'config.json'
|
||||
export const INDEX_FILE = 'index.json'
|
||||
export const STAGING_FILE = 'staging.json'
|
||||
export const PENDING_CREATE_FILE = 'pending-create.json'
|
||||
export const COMMUNICATION_CURSORS_FILE = 'communication-cursors.json'
|
||||
|
||||
export function configPath(root: string): string {
|
||||
return path.join(root, BLAGO_DIR, CONFIG_FILE)
|
||||
}
|
||||
|
||||
export function indexPath(root: string): string {
|
||||
return path.join(root, BLAGO_DIR, INDEX_FILE)
|
||||
}
|
||||
|
||||
export function stagingPath(root: string): string {
|
||||
return path.join(root, BLAGO_DIR, STAGING_FILE)
|
||||
}
|
||||
|
||||
export function pendingCreatePath(root: string): string {
|
||||
return path.join(root, BLAGO_DIR, PENDING_CREATE_FILE)
|
||||
}
|
||||
|
||||
export function communicationCursorsPath(root: string): string {
|
||||
return path.join(root, BLAGO_DIR, COMMUNICATION_CURSORS_FILE)
|
||||
}
|
||||
|
||||
export function sessionPath(root: string, envName: string): string {
|
||||
return path.join(root, BLAGO_DIR, `session.${envName}.json`)
|
||||
}
|
||||
|
||||
export function blagoDir(root: string): string {
|
||||
return path.join(root, BLAGO_DIR)
|
||||
}
|
||||
|
||||
export function gitignorePath(root: string): string {
|
||||
return path.join(root, BLAGO_DIR, GITIGNORE_FILE)
|
||||
}
|
||||
|
||||
export function findBlagoRoot(startDir: string): string | null {
|
||||
let current = path.resolve(startDir)
|
||||
for (;;) {
|
||||
const candidate = configPath(current)
|
||||
if (fs.existsSync(candidate)) {
|
||||
return current
|
||||
}
|
||||
const parent = path.dirname(current)
|
||||
if (parent === current) {
|
||||
return null
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Базовый каталог для поиска .blago/: активная копия из ~/.claude/config/blago/config.yaml, иначе cwd.
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { readGlobalBlagoConfigSync, resolveActiveWorkspaceRoot } from './global-config.js'
|
||||
import { configPath } from './paths.js'
|
||||
|
||||
function isBlagoWorkspaceReady(resolvedRoot: string): boolean {
|
||||
try {
|
||||
return fs.existsSync(configPath(path.resolve(resolvedRoot)))
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveBlagoStartDir(): string {
|
||||
const global = readGlobalBlagoConfigSync()
|
||||
if (global) {
|
||||
const fromGlobal = resolveActiveWorkspaceRoot(global)
|
||||
if (fromGlobal !== null && isBlagoWorkspaceReady(fromGlobal)) {
|
||||
return fromGlobal
|
||||
}
|
||||
}
|
||||
return process.cwd()
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Согласование blago env use с ~/.claude/config/blago/config.yaml (active_workspace_env).
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { warn } from '../ui/output.js'
|
||||
import { refreshGlobalAgentMirrorAsync } from './agent-mirror.js'
|
||||
import { readGlobalBlagoConfig, writeGlobalBlagoConfig } from './global-config.js'
|
||||
import { type BlagoConfigFile, loadConfig, saveConfig } from './index.js'
|
||||
import { configPath } from './paths.js'
|
||||
|
||||
export interface SyncWorkspaceEnvUseResult {
|
||||
/** Каталог рабочей копии, с которой дальше работает CLI (после смены global). */
|
||||
readonly effectiveRoot: string
|
||||
/** Обновлён ли global active_workspace_env под имя среды. */
|
||||
readonly globalSynced: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* После `blago env use <name>`: при наличии глобального config и пути `workspaces[name]`
|
||||
* с готовым `.blago/config.json` — выставить `active_workspace_env`, выровнять `activeEnv`
|
||||
* в config.json этой копии, обновить зеркало coopname/username.
|
||||
*/
|
||||
export async function syncGlobalActiveWorkspaceAfterEnvUse(
|
||||
envName: string,
|
||||
currentRoot: string,
|
||||
nextCfg: BlagoConfigFile,
|
||||
): Promise<SyncWorkspaceEnvUseResult> {
|
||||
const rootResolved = path.resolve(currentRoot)
|
||||
const global = await readGlobalBlagoConfig()
|
||||
if (!global) {
|
||||
await refreshGlobalAgentMirrorAsync(rootResolved, nextCfg)
|
||||
return { effectiveRoot: rootResolved, globalSynced: false }
|
||||
}
|
||||
|
||||
const raw = global.workspaces[envName]
|
||||
const targetRoot = typeof raw === 'string' && raw.trim().length > 0
|
||||
? path.resolve(raw.trim())
|
||||
: null
|
||||
|
||||
const targetHasConfig = targetRoot !== null && fs.existsSync(configPath(targetRoot))
|
||||
|
||||
if (!targetRoot || !targetHasConfig) {
|
||||
warn(
|
||||
`В глобальном config.yaml нет готовой копии для «${envName}» (workspaces.${envName}) — active_workspace_env не менялся.`,
|
||||
)
|
||||
await refreshGlobalAgentMirrorAsync(rootResolved, nextCfg)
|
||||
return { effectiveRoot: rootResolved, globalSynced: false }
|
||||
}
|
||||
|
||||
if (targetRoot !== rootResolved) {
|
||||
const targetCfg = await loadConfig(targetRoot)
|
||||
if (targetCfg.activeEnv !== envName) {
|
||||
await saveConfig(targetRoot, { ...targetCfg, activeEnv: envName })
|
||||
}
|
||||
}
|
||||
|
||||
await writeGlobalBlagoConfig({ ...global, active_workspace_env: envName })
|
||||
|
||||
const mirrorCfg = await loadConfig(targetRoot)
|
||||
await refreshGlobalAgentMirrorAsync(targetRoot, mirrorCfg)
|
||||
|
||||
return { effectiveRoot: targetRoot, globalSynced: true }
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Поиск project.md / component.md относительно базового пути (архитектура §13.2).
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { parseBlagoMarkdown } from '../format/index.js'
|
||||
import { normalizeRelativePath } from '../sync/index-store.js'
|
||||
|
||||
async function fileExists(abs: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(abs)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResolvedProjectMarker {
|
||||
/** hash проекта/компонента из frontmarker. */
|
||||
readonly project_hash: string
|
||||
/** coopname из файла маркера (может быть пустым). */
|
||||
readonly coopnameFromFile: string
|
||||
/** Каталог с project.md или component.md, относительно корня копии. */
|
||||
readonly markerRelDir: string
|
||||
}
|
||||
|
||||
export async function resolveProjectMarker(root: string, basePathInput: string): Promise<ResolvedProjectMarker> {
|
||||
const normalized = normalizeRelativePath(basePathInput)
|
||||
const absInput = path.resolve(root, normalized)
|
||||
const rootAbs = path.resolve(root)
|
||||
|
||||
const stat = await fs.stat(absInput).catch(() => null)
|
||||
let searchDir = absInput
|
||||
if (stat?.isFile()) {
|
||||
const base = path.basename(absInput)
|
||||
if (base !== 'project.md' && base !== 'component.md') {
|
||||
throw new Error(
|
||||
`Ожидался каталог или файл project.md / component.md, получено: «${basePathInput}»`,
|
||||
)
|
||||
}
|
||||
searchDir = path.dirname(absInput)
|
||||
}
|
||||
else if (!stat?.isDirectory()) {
|
||||
throw new Error(`Путь не найден: «${basePathInput}»`)
|
||||
}
|
||||
|
||||
if (
|
||||
!(path.resolve(searchDir) === rootAbs
|
||||
|| path.resolve(searchDir).startsWith(rootAbs + path.sep))
|
||||
) {
|
||||
throw new Error('Базовый путь должен быть внутри корня рабочей копии blago')
|
||||
}
|
||||
|
||||
let current: string | null = searchDir
|
||||
for (let depth = 0; depth < 500 && current; depth += 1) {
|
||||
for (const name of ['component.md', 'project.md'] as const) {
|
||||
const f = path.join(current, name)
|
||||
if (await fileExists(f)) {
|
||||
const raw = await fs.readFile(f, 'utf8')
|
||||
const parsed = parseBlagoMarkdown(raw)
|
||||
if (parsed.type !== 'project') {
|
||||
continue
|
||||
}
|
||||
const project_hash = String(parsed.data.hash ?? '').trim()
|
||||
if (!project_hash) {
|
||||
throw new Error(`В «${path.relative(root, f)}» отсутствует hash проекта/компонента`)
|
||||
}
|
||||
const coopnameFromFile = String(parsed.data.coopname ?? '').trim()
|
||||
return {
|
||||
project_hash,
|
||||
coopnameFromFile,
|
||||
markerRelDir: normalizeRelativePath(path.relative(root, current)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (path.resolve(current) === rootAbs) {
|
||||
break
|
||||
}
|
||||
const parent = path.dirname(current)
|
||||
if (parent === current) {
|
||||
break
|
||||
}
|
||||
const parentRes = path.resolve(parent)
|
||||
if (!(parentRes === rootAbs || parentRes.startsWith(rootAbs + path.sep))) {
|
||||
break
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Не найден project.md или component.md. Укажите каталог проекта/компонента или выполните «blago pull».',
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// Создание задачи на сервере сразу (CreateIssue); локальный .md с id/hash и пустым телом (FR-016).
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { Mutations } from '@coopenomics/sdk'
|
||||
|
||||
import { resolveCoopname } from '../config/index.js'
|
||||
import {
|
||||
issueToFrontmatterAndBody,
|
||||
issueWorkspaceTitlesFromProjects,
|
||||
serializeBlagoMarkdown,
|
||||
} from '../format/index.js'
|
||||
import { capitalIdPathPrefix } from '../lib/capital-id-path.js'
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
import type { AuthenticatedContext } from '../session/index.js'
|
||||
import { loadSession } from '../session/index.js'
|
||||
import { loadProjectMapsFromIndex } from '../sync/project-index-map.js'
|
||||
import {
|
||||
appendPathsToStaging,
|
||||
loadIndex,
|
||||
normalizeRelativePath,
|
||||
saveIndex,
|
||||
upsertEntry,
|
||||
} from '../sync/index-store.js'
|
||||
import { generateSlug, issueFileRelativePath, workspaceBasePath } from '../sync/layout.js'
|
||||
|
||||
import { resolveProjectMarker } from './resolve-base.js'
|
||||
|
||||
async function fileExistsAbs(abs: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(abs)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function parseCreatorsCsv(v: string | undefined): string[] {
|
||||
if (v === undefined || v.trim() === '') {
|
||||
return []
|
||||
}
|
||||
return v.split(',').map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
function toRemoteIso(v: unknown): string {
|
||||
if (v === undefined || v === null) {
|
||||
return ''
|
||||
}
|
||||
if (v instanceof Date) {
|
||||
return v.toISOString()
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
return new Date(v).toISOString()
|
||||
}
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
return new Date(v).toISOString()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Канонический путь по id задачи; при коллизии имя файла — с суффиксом от issue_hash. */
|
||||
async function pickIssueFileRelativePath(
|
||||
root: string,
|
||||
title: string,
|
||||
basePath: string,
|
||||
issueCapitalId: string,
|
||||
issueHash: string,
|
||||
): Promise<string> {
|
||||
const primary = issueFileRelativePath(title, basePath, issueCapitalId)
|
||||
const absPrimary = path.join(root, primary)
|
||||
if (!(await fileExistsAbs(absPrimary))) {
|
||||
return normalizeRelativePath(primary)
|
||||
}
|
||||
const slug = generateSlug(title) || 'issue'
|
||||
const idp = capitalIdPathPrefix(issueCapitalId)
|
||||
const dir = `${basePath.replace(/\\/g, '/')}/issues`
|
||||
const alt = `${dir}/${idp}-${slug}-${issueHash.slice(0, 8)}.md`
|
||||
const absAlt = path.join(root, alt)
|
||||
if (!(await fileExistsAbs(absAlt))) {
|
||||
return normalizeRelativePath(alt)
|
||||
}
|
||||
throw new Error(`Не удалось подобрать свободное имя файла в «${dir}»`)
|
||||
}
|
||||
|
||||
export interface RunCreateIssueOptions {
|
||||
readonly setSelf?: boolean
|
||||
readonly creatorsCsv?: string
|
||||
readonly submaster?: string
|
||||
}
|
||||
|
||||
export async function runCreateIssue(
|
||||
ctx: AuthenticatedContext,
|
||||
basePathArg: string,
|
||||
title: string,
|
||||
options: RunCreateIssueOptions,
|
||||
): Promise<{ relativePath: string }> {
|
||||
const coopCfg = resolveCoopname(ctx.config)
|
||||
if (!coopCfg) {
|
||||
throw new Error('Задайте coopname в config активной среды (или «blago init --coopname»)')
|
||||
}
|
||||
|
||||
const index = await loadIndex(ctx.root)
|
||||
const { projectByHash, projectRowByHash } = await loadProjectMapsFromIndex(ctx.root, index)
|
||||
const marker = await resolveProjectMarker(ctx.root, basePathArg)
|
||||
const proj = projectByHash.get(marker.project_hash)
|
||||
if (!proj) {
|
||||
throw new Error(
|
||||
`Проект «${marker.project_hash}» не найден в индексе. Выполните «blago pull» и повторите create.`,
|
||||
)
|
||||
}
|
||||
|
||||
const fromCsv = parseCreatorsCsv(options.creatorsCsv)
|
||||
let creators: string[]
|
||||
if (options.setSelf) {
|
||||
const session = await loadSession(ctx.root, ctx.config.activeEnv)
|
||||
if (!session) {
|
||||
throw new Error(
|
||||
'Для --set-self нужна сохранённая сессия активной среды. Выполните «blago login».',
|
||||
)
|
||||
}
|
||||
const u = session.username.trim()
|
||||
creators = [u, ...fromCsv.filter(c => c !== u)]
|
||||
}
|
||||
else {
|
||||
creators = fromCsv
|
||||
}
|
||||
|
||||
const projectHash = marker.project_hash
|
||||
type CreateIssueData = Mutations.Capital.CreateIssue.IInput['data']
|
||||
const issueInput: CreateIssueData = {
|
||||
coopname: coopCfg,
|
||||
project_hash: projectHash,
|
||||
title,
|
||||
description: '',
|
||||
status: 'BACKLOG' as CreateIssueData['status'],
|
||||
priority: 'MEDIUM' as CreateIssueData['priority'],
|
||||
estimate: 0,
|
||||
sort_order: 0,
|
||||
creators,
|
||||
labels: [],
|
||||
}
|
||||
if (options.submaster !== undefined && options.submaster.trim() !== '') {
|
||||
issueInput.submaster = options.submaster.trim()
|
||||
}
|
||||
|
||||
const mutationResult = await ctx.client.Mutation(Mutations.Capital.CreateIssue.mutation, {
|
||||
variables: { data: issueInput },
|
||||
})
|
||||
const created = mutationResult[Mutations.Capital.CreateIssue.name]
|
||||
if (created == null) {
|
||||
throw new Error('Создание задачи: пустой ответ API')
|
||||
}
|
||||
|
||||
const createdRow = created as {
|
||||
id?: string | null
|
||||
title: string
|
||||
description?: string | null
|
||||
issue_hash: string
|
||||
project_hash: string
|
||||
cycle_id?: string | null
|
||||
status?: string | null
|
||||
priority?: string | null
|
||||
estimate?: number | null
|
||||
submaster?: string | null
|
||||
creators?: string[] | null
|
||||
metadata?: unknown
|
||||
_created_at?: Date | string | null
|
||||
_updated_at?: Date | string | null
|
||||
}
|
||||
const workspace = issueWorkspaceTitlesFromProjects(createdRow.project_hash, projectRowByHash)
|
||||
const { data, body } = issueToFrontmatterAndBody(
|
||||
{
|
||||
id: createdRow.id,
|
||||
title: createdRow.title,
|
||||
description: createdRow.description,
|
||||
issue_hash: createdRow.issue_hash,
|
||||
project_hash: createdRow.project_hash,
|
||||
cycle_id: createdRow.cycle_id,
|
||||
status: createdRow.status,
|
||||
priority: createdRow.priority,
|
||||
estimate: createdRow.estimate,
|
||||
submaster: createdRow.submaster,
|
||||
creators: createdRow.creators,
|
||||
metadata: createdRow.metadata,
|
||||
_created_at: createdRow._created_at,
|
||||
_updated_at: createdRow._updated_at,
|
||||
},
|
||||
workspace,
|
||||
)
|
||||
|
||||
const projRow = projectByHash.get(createdRow.project_hash)
|
||||
if (!projRow) {
|
||||
throw new Error(`Проект «${createdRow.project_hash}» не найден в индексе после создания задачи.`)
|
||||
}
|
||||
const wsBase = workspaceBasePath(projRow, projectByHash)
|
||||
const issueCapitalId
|
||||
= createdRow.id !== undefined && createdRow.id !== null && String(createdRow.id).trim() !== ''
|
||||
? String(createdRow.id).trim()
|
||||
: createdRow.issue_hash
|
||||
const rel = await pickIssueFileRelativePath(
|
||||
ctx.root,
|
||||
String(createdRow.title ?? title),
|
||||
wsBase,
|
||||
issueCapitalId,
|
||||
createdRow.issue_hash,
|
||||
)
|
||||
const abs = path.join(ctx.root, rel)
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true })
|
||||
await fs.writeFile(abs, serializeBlagoMarkdown(data, body), 'utf8')
|
||||
|
||||
const etag = sha256Hex(await fs.readFile(abs, 'utf8'))
|
||||
upsertEntry(index, {
|
||||
entity_type: 'issue',
|
||||
entity_hash: createdRow.issue_hash,
|
||||
relative_path: rel,
|
||||
remote_updated_at: toRemoteIso(createdRow._updated_at),
|
||||
content_etag_local: etag,
|
||||
})
|
||||
await saveIndex(ctx.root, index)
|
||||
await appendPathsToStaging(ctx.root, [rel])
|
||||
return { relativePath: rel }
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Локальное создание черновика требования (story) (FR-016).
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import type { BlagoConfigFile } from '../config/index.js'
|
||||
import { resolveCoopname } from '../config/index.js'
|
||||
import { serializeBlagoMarkdown } from '../format/index.js'
|
||||
import { generateEntityHashHex64 } from '../lib/generate-entity-hash.js'
|
||||
import { loadSession } from '../session/index.js'
|
||||
import { appendPendingItem } from '../sync/pending-create.js'
|
||||
import { loadProjectMapsFromIndex } from '../sync/project-index-map.js'
|
||||
import { appendPathsToStaging, loadIndex, normalizeRelativePath } from '../sync/index-store.js'
|
||||
import { storyRequirementIdPrefix2 } from '../lib/capital-id-path.js'
|
||||
import { generateSlug, storyFileRelativePath, workspaceBasePath } from '../sync/layout.js'
|
||||
|
||||
import { resolveProjectMarker } from './resolve-base.js'
|
||||
import { storyContentFormatFromCliOption } from './story-format.js'
|
||||
import { buildDraftStoryFrontmatterAndBody } from './templates.js'
|
||||
|
||||
async function fileExistsAbs(abs: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(abs)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function pickStoryDraftRelativePath(
|
||||
root: string,
|
||||
title: string,
|
||||
basePath: string,
|
||||
storyHash: string,
|
||||
): Promise<string> {
|
||||
const primary = storyFileRelativePath(title, basePath, '', storyHash, undefined)
|
||||
const absPrimary = path.join(root, primary)
|
||||
if (!(await fileExistsAbs(absPrimary))) {
|
||||
return normalizeRelativePath(primary)
|
||||
}
|
||||
const slug = generateSlug(title) || 'requirement'
|
||||
const p2 = storyRequirementIdPrefix2('', storyHash)
|
||||
const dir = `${basePath.replace(/\\/g, '/')}/requirements`
|
||||
const alt = `${dir}/${p2}-${slug}-${storyHash.slice(0, 6)}.md`
|
||||
const absAlt = path.join(root, alt)
|
||||
if (!(await fileExistsAbs(absAlt))) {
|
||||
return normalizeRelativePath(alt)
|
||||
}
|
||||
throw new Error(`Не удалось подобрать свободное имя файла в «${dir}»`)
|
||||
}
|
||||
|
||||
export interface RunCreateStoryOptions {
|
||||
readonly setSelf?: boolean
|
||||
/** markdown | mermaid | drawio | bpmn */
|
||||
readonly format?: string
|
||||
}
|
||||
|
||||
export async function runCreateStory(
|
||||
root: string,
|
||||
cfg: BlagoConfigFile,
|
||||
basePathArg: string,
|
||||
title: string,
|
||||
options: RunCreateStoryOptions,
|
||||
): Promise<{ relativePath: string }> {
|
||||
const coopCfg = resolveCoopname(cfg)
|
||||
if (!coopCfg) {
|
||||
throw new Error('Задайте coopname в config активной среды (или «blago init --coopname»)')
|
||||
}
|
||||
|
||||
const index = await loadIndex(root)
|
||||
const { projectByHash } = await loadProjectMapsFromIndex(root, index)
|
||||
const marker = await resolveProjectMarker(root, basePathArg)
|
||||
const proj = projectByHash.get(marker.project_hash)
|
||||
if (!proj) {
|
||||
throw new Error(
|
||||
`Проект «${marker.project_hash}» не найден в индексе. Выполните «blago pull» и повторите create.`,
|
||||
)
|
||||
}
|
||||
|
||||
let created_by: string | undefined
|
||||
if (options.setSelf) {
|
||||
const session = await loadSession(root, cfg.activeEnv)
|
||||
if (!session) {
|
||||
throw new Error(
|
||||
'Для --set-self нужна сохранённая сессия активной среды. Выполните «blago login».',
|
||||
)
|
||||
}
|
||||
created_by = session.username
|
||||
}
|
||||
|
||||
const content_format = storyContentFormatFromCliOption(options.format ?? 'markdown')
|
||||
const storyHash = generateEntityHashHex64()
|
||||
const { data, body } = buildDraftStoryFrontmatterAndBody({
|
||||
title,
|
||||
story_hash: storyHash,
|
||||
project_hash: marker.project_hash,
|
||||
content_format,
|
||||
created_by,
|
||||
})
|
||||
|
||||
const wsBase = workspaceBasePath(proj, projectByHash)
|
||||
const rel = await pickStoryDraftRelativePath(root, title, wsBase, storyHash)
|
||||
const abs = path.join(root, rel)
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true })
|
||||
await fs.writeFile(abs, serializeBlagoMarkdown(data, body), 'utf8')
|
||||
|
||||
const createdAt = new Date().toISOString()
|
||||
await appendPendingItem(root, {
|
||||
kind: 'story',
|
||||
entity_hash: storyHash,
|
||||
relative_path: rel,
|
||||
created_at: createdAt,
|
||||
})
|
||||
await appendPathsToStaging(root, [rel])
|
||||
return { relativePath: rel }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Значения CapitalStoryContentFormat в frontmatter (MARKDOWN, …). */
|
||||
|
||||
export function storyContentFormatFromCliOption(s: string): string {
|
||||
const k = s.trim().toLowerCase()
|
||||
const map: Record<string, string> = {
|
||||
markdown: 'MARKDOWN',
|
||||
mermaid: 'MERMAID',
|
||||
drawio: 'DRAWIO',
|
||||
bpmn: 'BPMN',
|
||||
}
|
||||
const v = map[k]
|
||||
if (!v) {
|
||||
throw new Error(
|
||||
`Неизвестный --format «${s}». Допустимо: markdown, mermaid, drawio, bpmn`,
|
||||
)
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Минимальный BPMN 2.0 (стартовое событие + DI), как в bpmn.io. */
|
||||
export const storyStubBpmn = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bpmn:definitions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" id="Definitions_1" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Coopenomics" exporterVersion="1.0">
|
||||
<bpmn:process id="Process_1" isExecutable="true">
|
||||
<bpmn:startEvent id="StartEvent_1" />
|
||||
</bpmn:process>
|
||||
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
|
||||
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_1">
|
||||
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
|
||||
<dc:Bounds x="179" y="159" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
</bpmndi:BPMNPlane>
|
||||
</bpmndi:BPMNDiagram>
|
||||
</bpmn:definitions>
|
||||
`
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Минимальная схема draw.io / diagrams.net (одна фигура «Старт»). */
|
||||
export const storyStubDrawio = `<mxfile host="app.diagrams.net" agent="blago-cli" version="22.1.0">
|
||||
<diagram name="Страница-1" id="stub-page">
|
||||
<mxGraphModel dx="800" dy="600" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="2" value="Старт" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="360" y="240" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
`
|
||||
@@ -0,0 +1,22 @@
|
||||
// Рыбы тел story по content_format (Capital) — чтобы черновик сразу отображался в UI.
|
||||
|
||||
import { storyStubBpmn } from './bpmn.js'
|
||||
import { storyStubDrawio } from './drawio.js'
|
||||
import { storyStubMarkdown } from './markdown.js'
|
||||
import { storyStubMermaid } from './mermaid.js'
|
||||
|
||||
const STUB_BY_FORMAT: Record<'MARKDOWN' | 'MERMAID' | 'DRAWIO' | 'BPMN', string> = {
|
||||
MARKDOWN: storyStubMarkdown,
|
||||
MERMAID: storyStubMermaid,
|
||||
DRAWIO: storyStubDrawio,
|
||||
BPMN: storyStubBpmn,
|
||||
}
|
||||
|
||||
export function storyStubBodyForFormat(contentFormat: string): string {
|
||||
const key = contentFormat as keyof typeof STUB_BY_FORMAT
|
||||
const body = STUB_BY_FORMAT[key]
|
||||
if (body === undefined) {
|
||||
throw new Error(`Нет рыбы для content_format «${contentFormat}»`)
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Минимальное тело MARKDOWN: корректный заголовок и место для текста. */
|
||||
export const storyStubMarkdown = `# Новое требование`
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Минимальная диаграмма MERMAID (отображается в превью). */
|
||||
export const storyStubMermaid = `flowchart TD
|
||||
A[Старт]
|
||||
`
|
||||
@@ -0,0 +1,79 @@
|
||||
// Черновики frontmatter для локального create (без API).
|
||||
|
||||
import type { ProjectRowLite } from '../sync/project-index-map.js'
|
||||
import { issueWorkspaceTitlesFromProjects } from '../format/index.js'
|
||||
import { storyStubBodyForFormat } from './story-stubs/index.js'
|
||||
|
||||
export interface DraftIssueFields {
|
||||
readonly title: string
|
||||
readonly pendingHashHex64: string
|
||||
readonly project_hash: string
|
||||
readonly projectRowByHash: Map<string, ProjectRowLite>
|
||||
readonly creators: string[]
|
||||
readonly submaster?: string
|
||||
}
|
||||
|
||||
export function buildDraftIssueFrontmatterAndBody(f: DraftIssueFields): {
|
||||
data: Record<string, unknown>
|
||||
body: string
|
||||
} {
|
||||
const workspace = issueWorkspaceTitlesFromProjects(f.project_hash, f.projectRowByHash)
|
||||
const now = new Date().toISOString()
|
||||
const data: Record<string, unknown> = {
|
||||
type: 'issue',
|
||||
id: '0',
|
||||
title: f.title,
|
||||
status: 'BACKLOG',
|
||||
priority: 'MEDIUM',
|
||||
estimate: 0,
|
||||
creators: f.creators,
|
||||
labels: [],
|
||||
hash: f.pendingHashHex64,
|
||||
project_hash: f.project_hash,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
if (workspace.project_title.trim() !== '') {
|
||||
data.project_title = workspace.project_title.trim()
|
||||
}
|
||||
if (workspace.component_title !== undefined && workspace.component_title.trim() !== '') {
|
||||
data.component_title = workspace.component_title.trim()
|
||||
}
|
||||
if (f.submaster !== undefined && f.submaster.trim() !== '') {
|
||||
data.submaster = f.submaster.trim()
|
||||
}
|
||||
return { data, body: '' }
|
||||
}
|
||||
|
||||
export interface DraftStoryFields {
|
||||
readonly title: string
|
||||
readonly story_hash: string
|
||||
readonly project_hash: string
|
||||
readonly content_format: string
|
||||
readonly created_by?: string
|
||||
/** Явное тело; иначе подставляется рыба под content_format (см. create/story-stubs/). */
|
||||
readonly body?: string
|
||||
}
|
||||
|
||||
export function buildDraftStoryFrontmatterAndBody(f: DraftStoryFields): {
|
||||
data: Record<string, unknown>
|
||||
body: string
|
||||
} {
|
||||
const now = new Date().toISOString()
|
||||
const data: Record<string, unknown> = {
|
||||
type: 'story',
|
||||
title: f.title,
|
||||
hash: f.story_hash,
|
||||
content_format: f.content_format,
|
||||
status: 'PENDING',
|
||||
sort_order: 0,
|
||||
project_hash: f.project_hash,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
if (f.created_by !== undefined && f.created_by.trim() !== '') {
|
||||
data.created_by = f.created_by.trim()
|
||||
}
|
||||
const body = f.body ?? storyStubBodyForFormat(f.content_format)
|
||||
return { data, body }
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// Frontmatter ↔ тело; сериализация как у Capital FileFormatService.
|
||||
|
||||
import matter from 'gray-matter'
|
||||
|
||||
import { effectiveParentHash } from '../lib/parent-hash.js'
|
||||
|
||||
export type EntityFrontmatterType = 'project' | 'issue' | 'story'
|
||||
|
||||
export interface ParsedBlagoFile {
|
||||
readonly type: EntityFrontmatterType
|
||||
readonly data: Record<string, unknown>
|
||||
readonly body: string
|
||||
}
|
||||
|
||||
function toIso(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString()
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function parseBlagoMarkdown(raw: string): ParsedBlagoFile {
|
||||
const file = matter(raw)
|
||||
const typeRaw = file.data.type
|
||||
if (typeRaw !== 'project' && typeRaw !== 'issue' && typeRaw !== 'story') {
|
||||
throw new Error(`Неизвестный или отсутствующий type во frontmatter: ${String(typeRaw)}`)
|
||||
}
|
||||
return {
|
||||
type: typeRaw,
|
||||
data: file.data as Record<string, unknown>,
|
||||
body: file.content.trimEnd(),
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeBlagoMarkdown(data: Record<string, unknown>, body: string): string {
|
||||
const trimmed = body.endsWith('\n') ? body : `${body}\n`
|
||||
return matter.stringify(trimmed, data)
|
||||
}
|
||||
|
||||
export function issueLabelsFromMetadata(metadata: unknown): string[] {
|
||||
if (metadata && typeof metadata === 'object' && metadata !== null && 'labels' in metadata) {
|
||||
const labels = (metadata as { labels?: unknown }).labels
|
||||
if (Array.isArray(labels)) {
|
||||
return labels.map(x => String(x)).filter(s => s.trim() !== '')
|
||||
}
|
||||
}
|
||||
if (typeof metadata === 'string' && metadata.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(metadata) as { labels?: unknown }
|
||||
if (Array.isArray(parsed.labels)) {
|
||||
return parsed.labels.map(x => String(x)).filter(s => s.trim() !== '')
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/** Цепочка родителей: [проект задачи, …, корневой проект] — для подписей в issue. */
|
||||
export function issueWorkspaceTitlesFromProjects(
|
||||
issueProjectHash: string,
|
||||
rowByHash: ReadonlyMap<string, { title?: string | null, parent_hash?: string | null }>,
|
||||
): { project_title: string, component_title?: string } {
|
||||
const titles: string[] = []
|
||||
let cur: string | undefined = issueProjectHash
|
||||
const seen = new Set<string>()
|
||||
while (cur && !seen.has(cur)) {
|
||||
seen.add(cur)
|
||||
const row = rowByHash.get(cur)
|
||||
if (!row) {
|
||||
break
|
||||
}
|
||||
titles.push(String(row.title ?? '').trim())
|
||||
const parentRef = effectiveParentHash(row.parent_hash)
|
||||
cur = parentRef || undefined
|
||||
}
|
||||
const project_title = titles.length > 0 ? titles[titles.length - 1] : ''
|
||||
const component_title = titles.length > 1 ? titles[0] : undefined
|
||||
return {
|
||||
project_title,
|
||||
...(component_title !== undefined && component_title !== '' ? { component_title } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Проект из ответа capitalProjects / capitalProject */
|
||||
export function projectToFrontmatterAndBody(project: {
|
||||
id?: number | null
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
project_hash: string
|
||||
coopname: string
|
||||
parent_hash?: string | null
|
||||
parent_title?: string | null
|
||||
status?: string | null
|
||||
created_at?: Date | string | null
|
||||
_updated_at?: Date | string | null
|
||||
}): { data: Record<string, unknown>, body: string } {
|
||||
const data: Record<string, unknown> = {}
|
||||
data.type = 'project'
|
||||
if (project.id !== undefined && project.id !== null && Number.isFinite(Number(project.id))) {
|
||||
data.id = Number(project.id)
|
||||
}
|
||||
data.title = project.title ?? ''
|
||||
const parentRef = effectiveParentHash(project.parent_hash)
|
||||
if (parentRef) {
|
||||
const pt = project.parent_title
|
||||
if (pt !== undefined && pt !== null && String(pt).trim() !== '') {
|
||||
data.parent_title = String(pt).trim()
|
||||
}
|
||||
data.parent_hash = parentRef
|
||||
}
|
||||
data.coopname = project.coopname
|
||||
data.status = project.status ?? undefined
|
||||
data.hash = project.project_hash
|
||||
const c = toIso(project.created_at)
|
||||
if (c) {
|
||||
data.created_at = c
|
||||
}
|
||||
const u = toIso(project._updated_at)
|
||||
if (u) {
|
||||
data.updated_at = u
|
||||
}
|
||||
return { data, body: project.description ?? '' }
|
||||
}
|
||||
|
||||
export function issueToFrontmatterAndBody(
|
||||
issue: {
|
||||
id?: string | null
|
||||
title: string
|
||||
description?: string | null
|
||||
issue_hash: string
|
||||
project_hash: string
|
||||
cycle_id?: string | null
|
||||
status?: string | null
|
||||
priority?: string | null
|
||||
estimate?: number | null
|
||||
submaster?: string | null
|
||||
creators?: string[] | null
|
||||
metadata?: unknown
|
||||
_created_at?: Date | string | null
|
||||
_updated_at?: Date | string | null
|
||||
},
|
||||
workspace: { project_title: string, component_title?: string },
|
||||
): { data: Record<string, unknown>, body: string } {
|
||||
const data: Record<string, unknown> = {}
|
||||
data.type = 'issue'
|
||||
const capitalIssueId
|
||||
= issue.id !== undefined && issue.id !== null && String(issue.id).trim() !== ''
|
||||
? String(issue.id).trim()
|
||||
: issue.issue_hash
|
||||
data.id = capitalIssueId
|
||||
data.title = issue.title
|
||||
if (workspace.project_title.trim() !== '') {
|
||||
data.project_title = workspace.project_title.trim()
|
||||
}
|
||||
if (workspace.component_title !== undefined && workspace.component_title.trim() !== '') {
|
||||
data.component_title = workspace.component_title.trim()
|
||||
}
|
||||
data.status = issue.status ?? 'backlog'
|
||||
data.priority = issue.priority ?? 'medium'
|
||||
data.estimate = issue.estimate ?? 0
|
||||
data.creators = issue.creators ?? []
|
||||
data.labels = issueLabelsFromMetadata(issue.metadata)
|
||||
if (issue.cycle_id) {
|
||||
data.cycle_id = issue.cycle_id
|
||||
}
|
||||
if (issue.submaster) {
|
||||
data.submaster = issue.submaster
|
||||
}
|
||||
data.hash = issue.issue_hash
|
||||
data.project_hash = issue.project_hash
|
||||
const c = toIso(issue._created_at)
|
||||
if (c) {
|
||||
data.created_at = c
|
||||
}
|
||||
const u = toIso(issue._updated_at)
|
||||
if (u) {
|
||||
data.updated_at = u
|
||||
}
|
||||
return { data, body: issue.description ?? '' }
|
||||
}
|
||||
|
||||
export function storyToFrontmatterAndBody(story: {
|
||||
_id?: string | null
|
||||
title: string
|
||||
description?: string | null
|
||||
story_hash: string
|
||||
content_format?: string | null
|
||||
status?: string | null
|
||||
created_by?: string | null
|
||||
sort_order?: number | null
|
||||
project_hash?: string | null
|
||||
issue_hash?: string | null
|
||||
_created_at?: Date | string | null
|
||||
_updated_at?: Date | string | null
|
||||
}): { data: Record<string, unknown>, body: string } {
|
||||
const data: Record<string, unknown> = {}
|
||||
data.type = 'story'
|
||||
if (story._id !== undefined && story._id !== null && String(story._id).trim() !== '') {
|
||||
data.id = String(story._id)
|
||||
}
|
||||
data.title = story.title
|
||||
data.hash = story.story_hash
|
||||
data.content_format = story.content_format ?? 'MARKDOWN'
|
||||
data.status = story.status ?? 'pending'
|
||||
data.created_by = story.created_by ?? ''
|
||||
data.sort_order = story.sort_order ?? 0
|
||||
if (story.project_hash) {
|
||||
data.project_hash = story.project_hash
|
||||
}
|
||||
if (story.issue_hash) {
|
||||
data.issue_hash = story.issue_hash
|
||||
}
|
||||
const c = toIso(story._created_at)
|
||||
if (c) {
|
||||
data.created_at = c
|
||||
}
|
||||
const u = toIso(story._updated_at)
|
||||
if (u) {
|
||||
data.updated_at = u
|
||||
}
|
||||
return { data, body: story.description ?? '' }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
// Точка входа бинарника → runCli.
|
||||
|
||||
import { runCli } from './cmd/run-cli.js'
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await runCli(process.argv)
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Префикс сегмента пути из Capital id (число проекта или строка задачи вида PREFIX-12). */
|
||||
export function capitalIdPathPrefix(capitalId: string | number): string {
|
||||
const s = String(capitalId).trim()
|
||||
if (s === '') {
|
||||
return '0'
|
||||
}
|
||||
const t = s.replace(/[^\w.-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
|
||||
return t.length > 0 ? t : 'id'
|
||||
}
|
||||
|
||||
/**
|
||||
* Два символа в начале имени файла требования (как короткий префикс id у задач):
|
||||
* из букв/цифр `_id`; если меньше 2 — добираем из story_hash (только запасной пул символов).
|
||||
*/
|
||||
export function storyRequirementIdPrefix2(recordId: string, storyHashFallback: string): string {
|
||||
const alnum = (s: string) => s.replace(/[^a-z0-9]/gi, '').toLowerCase()
|
||||
let pool = alnum(recordId)
|
||||
if (pool.length < 2) {
|
||||
pool = `${pool}${alnum(storyHashFallback)}`
|
||||
}
|
||||
if (pool.length >= 2) {
|
||||
return pool.slice(0, 2)
|
||||
}
|
||||
return `${pool}00`.slice(0, 2)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// 64 hex — как generateUniqueHash на desktop / канон Capital для story_hash.
|
||||
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
export function generateEntityHashHex64(): string {
|
||||
return randomBytes(32).toString('hex')
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Эталон содержимого файла в индексе (content_etag_local).
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
export function sha256Hex(content: string): string {
|
||||
return createHash('sha256').update(content, 'utf8').digest('hex')
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* В Capital родитель у корневого проекта часто приходит как null/«» или нулевой checksum256 (64׫0»).
|
||||
* Такая строка truthy в JS — без нормализации сущность ошибочно считается вложенным компонентом.
|
||||
*/
|
||||
export function effectiveParentHash(parent_hash: string | null | undefined): string | undefined {
|
||||
if (parent_hash === undefined || parent_hash === null) {
|
||||
return undefined
|
||||
}
|
||||
const t = String(parent_hash).trim()
|
||||
if (t === '') {
|
||||
return undefined
|
||||
}
|
||||
if (/^0+$/.test(t)) {
|
||||
return undefined
|
||||
}
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// Сессия: login (email+WIF), токены в .blago/session.<env>.json, refresh при 401.
|
||||
|
||||
import type { ReadStream } from 'node:tty'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as readline from 'node:readline'
|
||||
|
||||
import { Client, Mutations, Queries } from '@coopenomics/sdk'
|
||||
|
||||
import { refreshGlobalAgentMirrorAsync } from '../config/agent-mirror.js'
|
||||
import { toGraphqlApiUrl } from '../config/api-url.js'
|
||||
import { type BlagoConfigFile, type BlagoRemoteProfile, getActiveProfile, loadConfig } from '../config/index.js'
|
||||
import { sessionPath } from '../config/paths.js'
|
||||
|
||||
export interface BlagoSessionFile {
|
||||
readonly accessToken: string
|
||||
readonly refreshToken: string
|
||||
readonly username: string
|
||||
}
|
||||
|
||||
export async function promptLine(question: string): Promise<string> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
||||
try {
|
||||
return await new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
resolve(answer.trim())
|
||||
})
|
||||
})
|
||||
}
|
||||
finally {
|
||||
rl.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function promptSecret(question: string): Promise<string> {
|
||||
const stdin = process.stdin as ReadStream
|
||||
const stdout = process.stdout
|
||||
if (!stdin.isTTY) {
|
||||
throw new Error('Скрытый ввод ключа возможен только в интерактивном TTY')
|
||||
}
|
||||
stdout.write(question)
|
||||
|
||||
let onData: ((chunk: string | Buffer) => void) | undefined
|
||||
|
||||
try {
|
||||
stdin.setRawMode(true)
|
||||
stdin.resume()
|
||||
stdin.setEncoding('utf8')
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
let result = ''
|
||||
onData = (chunk: string | Buffer): void => {
|
||||
const s = typeof chunk === 'string' ? chunk : chunk.toString('utf8')
|
||||
for (const ch of s) {
|
||||
const code = ch.charCodeAt(0)
|
||||
if (code === 3) {
|
||||
stdout.write('\n')
|
||||
reject(new Error('прервано'))
|
||||
return
|
||||
}
|
||||
if (code === 13 || code === 10) {
|
||||
stdout.write('\n')
|
||||
resolve(result)
|
||||
return
|
||||
}
|
||||
if (code === 127 || code === 8) {
|
||||
result = result.slice(0, -1)
|
||||
}
|
||||
else {
|
||||
result += ch
|
||||
}
|
||||
}
|
||||
}
|
||||
stdin.on('data', onData)
|
||||
})
|
||||
}
|
||||
finally {
|
||||
try {
|
||||
stdin.setRawMode(false)
|
||||
}
|
||||
catch {
|
||||
/* Windows или stdin уже не TTY */
|
||||
}
|
||||
if (onData) {
|
||||
stdin.off('data', onData)
|
||||
}
|
||||
stdin.pause()
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSession(root: string, envName: string, data: BlagoSessionFile): Promise<void> {
|
||||
const p = sessionPath(root, envName)
|
||||
await fs.writeFile(p, `${JSON.stringify(data, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
|
||||
try {
|
||||
await fs.chmod(p, 0o600)
|
||||
}
|
||||
catch {
|
||||
/* Windows и др. */
|
||||
}
|
||||
try {
|
||||
const cfg = await loadConfig(root)
|
||||
await refreshGlobalAgentMirrorAsync(root, cfg)
|
||||
}
|
||||
catch {
|
||||
/* нет или битый config.json — зеркало глобального yaml пропускаем */
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSession(root: string, envName: string): Promise<BlagoSessionFile | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(sessionPath(root, envName), 'utf8')
|
||||
const parsed = JSON.parse(raw) as BlagoSessionFile
|
||||
if (!parsed.accessToken || !parsed.refreshToken || !parsed.username) {
|
||||
return null
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function createClient(profile: BlagoRemoteProfile): Client {
|
||||
return Client.create({
|
||||
api_url: toGraphqlApiUrl(profile.api_url),
|
||||
chain_url: profile.chain_url,
|
||||
chain_id: profile.chain_id,
|
||||
})
|
||||
}
|
||||
|
||||
export async function loginInteractive(
|
||||
client: Client,
|
||||
root: string,
|
||||
envName: string,
|
||||
): Promise<BlagoSessionFile> {
|
||||
const email = await promptLine('Email (логин в кооперативе): ')
|
||||
if (!email) {
|
||||
throw new Error('Email не может быть пустым')
|
||||
}
|
||||
const wif = await promptSecret('Приватный ключ (WIF), ввод скрыт: ')
|
||||
if (!wif) {
|
||||
throw new Error('Ключ не может быть пустым')
|
||||
}
|
||||
const loginResult = await client.login(email, wif)
|
||||
const access = loginResult.tokens.access.token
|
||||
const refresh = loginResult.tokens.refresh.token
|
||||
const username = loginResult.account.username
|
||||
const session: BlagoSessionFile = { accessToken: access, refreshToken: refresh, username }
|
||||
await saveSession(root, envName, session)
|
||||
return session
|
||||
}
|
||||
|
||||
export async function applySession(client: Client, session: BlagoSessionFile): Promise<void> {
|
||||
client.setToken(session.accessToken)
|
||||
}
|
||||
|
||||
export async function refreshSession(client: Client, session: BlagoSessionFile): Promise<BlagoSessionFile> {
|
||||
const { [Mutations.Auth.Refresh.name]: result } = await client.Mutation(Mutations.Auth.Refresh.mutation, {
|
||||
variables: {
|
||||
data: {
|
||||
access_token: session.accessToken,
|
||||
refresh_token: session.refreshToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
const next: BlagoSessionFile = {
|
||||
accessToken: result.tokens.access.token,
|
||||
refreshToken: result.tokens.refresh.token,
|
||||
username: result.account.username,
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export interface AuthenticatedContext {
|
||||
readonly root: string
|
||||
readonly config: BlagoConfigFile
|
||||
readonly client: Client
|
||||
readonly session: BlagoSessionFile
|
||||
}
|
||||
|
||||
// Токен из файла → проверка запросом; при сбое — refresh или интерактивный login.
|
||||
export async function ensureAuthenticatedContext(
|
||||
root: string,
|
||||
cfg: BlagoConfigFile,
|
||||
options?: { allowInteractiveLogin?: boolean },
|
||||
): Promise<AuthenticatedContext> {
|
||||
const profile = getActiveProfile(cfg)
|
||||
const client = createClient(profile)
|
||||
let session = await loadSession(root, cfg.activeEnv)
|
||||
if (!session) {
|
||||
if (!options?.allowInteractiveLogin) {
|
||||
throw new Error('Нет сохранённой сессии. Выполните: blago login')
|
||||
}
|
||||
session = await loginInteractive(client, root, cfg.activeEnv)
|
||||
}
|
||||
await applySession(client, session)
|
||||
try {
|
||||
await client.Query(Queries.Desktop.GetDesktop.query, { variables: {} })
|
||||
}
|
||||
catch {
|
||||
try {
|
||||
session = await refreshSession(client, session)
|
||||
await applySession(client, session)
|
||||
await saveSession(root, cfg.activeEnv, session)
|
||||
}
|
||||
catch {
|
||||
if (!options?.allowInteractiveLogin) {
|
||||
throw new Error('Сессия устарела. Выполните: blago login')
|
||||
}
|
||||
session = await loginInteractive(client, root, cfg.activeEnv)
|
||||
await applySession(client, session)
|
||||
}
|
||||
}
|
||||
try {
|
||||
await refreshGlobalAgentMirrorAsync(root, cfg)
|
||||
}
|
||||
catch {
|
||||
/* зеркало глобального yaml — не блокируем рабочий контекст */
|
||||
}
|
||||
return { root, config: cfg, client, session }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Синхронное описание активной среды и сессии (для help Commander и «blago env»).
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
|
||||
import { refreshGlobalAgentMirrorSync } from '../config/agent-mirror.js'
|
||||
import { resolveCoopname, type BlagoConfigFile } from '../config/index.js'
|
||||
import { blagoDir, configPath, findBlagoRoot } from '../config/paths.js'
|
||||
import { resolveBlagoStartDir } from '../config/start-dir.js'
|
||||
|
||||
import { readSessionUsernameSync } from './username-sync.js'
|
||||
|
||||
function readJsonIfExists<T>(filePath: string): T | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8')
|
||||
return JSON.parse(raw) as T
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export { readSessionUsernameSync } from './username-sync.js'
|
||||
|
||||
/** Одна строка: активная среда, coopname, пользователь (или «вход не выполнен»). */
|
||||
export function describeBlagoSessionLine(
|
||||
cfg: BlagoConfigFile,
|
||||
username: string | null,
|
||||
coopname?: string | undefined,
|
||||
): string {
|
||||
const envName = cfg.activeEnv
|
||||
const profile = cfg.environments[envName]
|
||||
const label = profile?.label?.trim()
|
||||
const envHuman = label ? `«${envName}» (${label})` : `«${envName}»`
|
||||
const coop = coopname?.trim()
|
||||
const coopHuman = coop ? `кооператив: ${coop}; ` : ''
|
||||
const userHuman = username
|
||||
? `пользователь: ${username}`
|
||||
: 'вход не выполнен (blago login)'
|
||||
return `Сессия: среда ${envHuman}; ${coopHuman}${userHuman}.`
|
||||
}
|
||||
|
||||
/** Текст в конец blago --help и blago <cmd> --help (синхронно). */
|
||||
export function formatBlagoSessionStatusHelpExtra(): string {
|
||||
const root = findBlagoRoot(resolveBlagoStartDir())
|
||||
if (!root) {
|
||||
return `
|
||||
|
||||
Копия blago не найдена: нет .blago/config.json вверх от базового каталога (активная копия из ~/.claude/config/blago/config.yaml при готовой .blago, иначе cwd).`
|
||||
}
|
||||
|
||||
const cfg = readJsonIfExists<BlagoConfigFile>(configPath(root))
|
||||
if (!cfg?.activeEnv || typeof cfg.environments !== 'object') {
|
||||
return `
|
||||
|
||||
Корень: ${root}
|
||||
Файл .blago/config.json некорректен (нужны activeEnv и environments).`
|
||||
}
|
||||
|
||||
refreshGlobalAgentMirrorSync(root, cfg)
|
||||
const coop = resolveCoopname(cfg)
|
||||
const line = describeBlagoSessionLine(
|
||||
cfg,
|
||||
readSessionUsernameSync(root, cfg.activeEnv),
|
||||
coop,
|
||||
)
|
||||
return `
|
||||
|
||||
Рабочая копия: ${root}
|
||||
Метаданные и сессии: ${blagoDir(root)}
|
||||
${line}`
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Синхронное чтение username из .blago/session.<env>.json (без зависимости от status-text).
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
|
||||
import { sessionPath } from '../config/paths.js'
|
||||
|
||||
interface SessionFileShape {
|
||||
username?: string
|
||||
}
|
||||
|
||||
function readJsonIfExists<T>(filePath: string): T | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8')
|
||||
return JSON.parse(raw) as T
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function readSessionUsernameSync(root: string, envName: string): string | null {
|
||||
const session = readJsonIfExists<SessionFileShape>(sessionPath(root, envName))
|
||||
const u = session?.username?.trim()
|
||||
return u && u.length > 0 ? u : null
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// add: только .md, с учётом .blagoignore; в staging — только файлы, изменённые относительно индекса (или без записи в индексе).
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
import { isIgnoredRelativePath, loadBlagoIgnoreRules } from './ignore.js'
|
||||
import { findByRelativePath, loadIndex, loadStaging, normalizeRelativePath, saveStaging } from './index-store.js'
|
||||
import { isPullOnlyCommunicationRelativePath } from './pull-only-paths.js'
|
||||
|
||||
async function collectMarkdownFiles(absDir: string): Promise<string[]> {
|
||||
const out: string[] = []
|
||||
const entries = await fs.readdir(absDir, { withFileTypes: true })
|
||||
for (const e of entries) {
|
||||
const abs = path.join(absDir, e.name)
|
||||
if (e.isDirectory()) {
|
||||
const nested = await collectMarkdownFiles(abs)
|
||||
out.push(...nested)
|
||||
}
|
||||
else if (e.isFile() && e.name.endsWith('.md')) {
|
||||
out.push(abs)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** true — файл стоит поставить в staging: нет в индексе или SHA256 на диске ≠ content_etag_local. */
|
||||
async function isDirtyVsIndex(root: string, rel: string, index: Awaited<ReturnType<typeof loadIndex>>): Promise<boolean> {
|
||||
const entry = findByRelativePath(index, rel)
|
||||
if (!entry) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(root, rel), 'utf8')
|
||||
return sha256Hex(raw) !== entry.content_etag_local
|
||||
}
|
||||
catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunAddResult {
|
||||
/** Пути, прошедшие фильтр и попавшие в объединённый staging */
|
||||
stagedPaths: string[]
|
||||
skippedUnchanged: number
|
||||
skippedIgnored: number
|
||||
/** messages/ и meetings/ — только синхронизация с сервера, в push не идут */
|
||||
skippedPullOnlyArtifacts: number
|
||||
}
|
||||
|
||||
export async function runAdd(root: string, targets: string[]): Promise<RunAddResult> {
|
||||
if (targets.length === 0) {
|
||||
throw new Error('Укажите файлы или каталоги: blago add <путь> …')
|
||||
}
|
||||
const rules = await loadBlagoIgnoreRules(root)
|
||||
const index = await loadIndex(root)
|
||||
const staging = await loadStaging(root)
|
||||
const set = new Set(staging.paths.map(p => normalizeRelativePath(p)))
|
||||
|
||||
let skippedUnchanged = 0
|
||||
let skippedIgnored = 0
|
||||
let skippedPullOnlyArtifacts = 0
|
||||
|
||||
for (const t of targets) {
|
||||
const abs = path.resolve(root, t)
|
||||
const st = await fs.stat(abs)
|
||||
const files: string[] = []
|
||||
if (st.isDirectory()) {
|
||||
files.push(...(await collectMarkdownFiles(abs)))
|
||||
}
|
||||
else if (st.isFile()) {
|
||||
files.push(abs)
|
||||
}
|
||||
for (const file of files) {
|
||||
const rel = normalizeRelativePath(path.relative(root, file))
|
||||
if (isIgnoredRelativePath(rel, rules)) {
|
||||
skippedIgnored += 1
|
||||
continue
|
||||
}
|
||||
if (isPullOnlyCommunicationRelativePath(rel)) {
|
||||
skippedPullOnlyArtifacts += 1
|
||||
continue
|
||||
}
|
||||
if (!(await isDirtyVsIndex(root, rel, index))) {
|
||||
skippedUnchanged += 1
|
||||
continue
|
||||
}
|
||||
set.add(rel)
|
||||
}
|
||||
}
|
||||
|
||||
const stagedPaths = [...set].sort()
|
||||
await saveStaging(root, { paths: stagedPaths })
|
||||
return { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Сброс копии: удалить верхнеуровневые каталоги проектов (всё от pull лежит внутри них) + обнулить index/staging.
|
||||
// Не поднимаемся по дереву от файлов — только rm одного уровня под корнём копии. .blago не трогаем.
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { loadIndex, normalizeRelativePath, saveIndex, saveStaging } from './index-store.js'
|
||||
|
||||
function isInsideRoot(rootAbs: string, absPath: string): boolean {
|
||||
const rel = path.relative(rootAbs, absPath)
|
||||
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
|
||||
}
|
||||
|
||||
/** Запись индекса: без «..», без абсолютного пути и NUL. */
|
||||
function isSafeIndexRelativePath(rel: string): boolean {
|
||||
if (!rel || rel.includes('\0')) {
|
||||
return false
|
||||
}
|
||||
const posix = rel.replace(/\\/g, '/')
|
||||
if (posix.startsWith('/') || /^[a-z]:\//i.test(posix)) {
|
||||
return false
|
||||
}
|
||||
const segments = posix.split('/').filter(Boolean)
|
||||
return !segments.includes('..')
|
||||
}
|
||||
|
||||
function assertCleanRootIsNotFilesystemRoot(rootAbs: string): void {
|
||||
const n = path.normalize(rootAbs)
|
||||
if (n === '/' || n === '\\') {
|
||||
throw new Error('blago clean: корень копии совпадает с корнем файловой системы — операция запрещена.')
|
||||
}
|
||||
if (process.platform === 'win32' && /^[a-z]:[\\/]?$/i.test(n)) {
|
||||
throw new Error('blago clean: корень копии — корень диска Windows — операция запрещена.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* По индексу собираем имена верхних каталогов (первый сегмент пути) и удаляем каждый целиком через fs.rm recursive.
|
||||
* Конфиг и сессии в .blago/ не затрагиваются.
|
||||
*/
|
||||
export async function runClean(root: string): Promise<void> {
|
||||
let rootAbs = path.resolve(root)
|
||||
try {
|
||||
rootAbs = await fs.realpath(rootAbs)
|
||||
}
|
||||
catch {
|
||||
/* каталога нет */
|
||||
}
|
||||
assertCleanRootIsNotFilesystemRoot(rootAbs)
|
||||
|
||||
const index = await loadIndex(root)
|
||||
const topNames = new Set<string>()
|
||||
const blagoName = '.blago'
|
||||
|
||||
for (const e of index.entries) {
|
||||
const rel = normalizeRelativePath(e.relative_path)
|
||||
if (!isSafeIndexRelativePath(rel)) {
|
||||
continue
|
||||
}
|
||||
const first = rel.split('/').filter(Boolean)[0]
|
||||
if (!first || first === blagoName || first === '.' || first === '..') {
|
||||
continue
|
||||
}
|
||||
topNames.add(first)
|
||||
}
|
||||
|
||||
for (const name of topNames) {
|
||||
const target = path.join(rootAbs, name)
|
||||
if (!isInsideRoot(rootAbs, target)) {
|
||||
continue
|
||||
}
|
||||
const blagoDir = path.join(rootAbs, blagoName)
|
||||
if (target === blagoDir || target.startsWith(`${blagoDir}${path.sep}`)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await fs.rm(target, { recursive: true, force: true })
|
||||
}
|
||||
catch {
|
||||
/* нет или нет прав */
|
||||
}
|
||||
}
|
||||
|
||||
await saveIndex(root, { entries: [] })
|
||||
await saveStaging(root, { paths: [] })
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Курсоры инкрементальной синхронизации переписки (аналог PG у GitHub-секретаря).
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
|
||||
import { blagoDir, communicationCursorsPath } from '../config/paths.js'
|
||||
|
||||
export interface CommunicationCursorsFile {
|
||||
/** matrixRoomId → последний обработанный origin_server_ts (мс) */
|
||||
messageLastTsByRoom: Record<string, number>
|
||||
/** project_hash → ISO instant: транскрипции с endedAt ≤ этого момента уже выгружены */
|
||||
transcriptionLastEndedExclusiveByProject: Record<string, string>
|
||||
}
|
||||
|
||||
function empty(): CommunicationCursorsFile {
|
||||
return {
|
||||
messageLastTsByRoom: {},
|
||||
transcriptionLastEndedExclusiveByProject: {},
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadCommunicationCursors(root: string): Promise<CommunicationCursorsFile> {
|
||||
try {
|
||||
const raw = await fs.readFile(communicationCursorsPath(root), 'utf8')
|
||||
const parsed = JSON.parse(raw) as Partial<CommunicationCursorsFile>
|
||||
return {
|
||||
messageLastTsByRoom:
|
||||
parsed.messageLastTsByRoom !== undefined && typeof parsed.messageLastTsByRoom === 'object'
|
||||
? { ...parsed.messageLastTsByRoom }
|
||||
: {},
|
||||
transcriptionLastEndedExclusiveByProject:
|
||||
parsed.transcriptionLastEndedExclusiveByProject !== undefined
|
||||
&& typeof parsed.transcriptionLastEndedExclusiveByProject === 'object'
|
||||
? { ...parsed.transcriptionLastEndedExclusiveByProject }
|
||||
: {},
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return empty()
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCommunicationCursors(root: string, data: CommunicationCursorsFile): Promise<void> {
|
||||
await fs.mkdir(blagoDir(root), { recursive: true })
|
||||
await fs.writeFile(communicationCursorsPath(root), `${JSON.stringify(data, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Разметка как у FileFormatService.projectCommunicationChat / render transcription (Capital GitHub-синк).
|
||||
|
||||
export interface CommunicationDayLine {
|
||||
readonly originServerTs: number
|
||||
readonly authorLabel: string
|
||||
readonly coopUsername: string | null | undefined
|
||||
/** GraphQL RoomMessageKind: TEXT | AUDIO */
|
||||
readonly kind: string
|
||||
readonly bodyText: string
|
||||
}
|
||||
|
||||
/** Один файл `messages/UTC-date.md` — как `projectCommunicationDayToMarkdown` на контроллере. */
|
||||
export function projectCommunicationDayToMarkdown(
|
||||
projectTitle: string,
|
||||
projectHash: string,
|
||||
utcDate: string,
|
||||
sections: { displayLabel: string, matrixRoomId: string, lines: CommunicationDayLine[] }[],
|
||||
): string {
|
||||
const header = [`# ${utcDate}`, ``, projectTitle, ``, `- Проект: \`${projectHash}\``, ``, `## Сообщения:`, ``]
|
||||
const parts: string[] = [...header]
|
||||
const nonEmpty = sections.filter(s => s.lines.length > 0)
|
||||
const showRoomTitles = nonEmpty.length > 1
|
||||
for (const s of nonEmpty) {
|
||||
if (showRoomTitles) {
|
||||
parts.push(`### ${s.displayLabel}`)
|
||||
parts.push(``)
|
||||
}
|
||||
parts.push(`Matrix room: \`${s.matrixRoomId}\``)
|
||||
parts.push(``)
|
||||
for (const line of s.lines) {
|
||||
const iso = new Date(line.originServerTs).toISOString()
|
||||
const tag = line.kind === 'AUDIO' ? '🎤' : '💬'
|
||||
const who = line.coopUsername ? `${line.authorLabel} (@${line.coopUsername})` : line.authorLabel
|
||||
parts.push(`- **${iso}** ${tag} **${who}**`)
|
||||
const indented = line.bodyText.split('\n').join('\n ')
|
||||
parts.push(` ${indented}`)
|
||||
parts.push(``)
|
||||
}
|
||||
parts.push(`---`)
|
||||
parts.push(``)
|
||||
}
|
||||
return `${parts.join('\n').trim()}\n`
|
||||
}
|
||||
|
||||
export interface TranscriptionHeaderModel {
|
||||
matrixRoomId: string
|
||||
roomId: string
|
||||
startedAt: Date | string
|
||||
endedAt: Date | string | null | undefined
|
||||
}
|
||||
|
||||
export interface TranscriptionSegmentModel {
|
||||
speakerName: string
|
||||
text: string
|
||||
startOffset: number
|
||||
endOffset: number
|
||||
}
|
||||
|
||||
export function renderCallTranscriptionMarkdown(
|
||||
transcription: TranscriptionHeaderModel,
|
||||
segments: TranscriptionSegmentModel[],
|
||||
): string {
|
||||
const sorted = [...segments].sort((a, b) => a.startOffset - b.startOffset)
|
||||
const lines = sorted.map((s) => {
|
||||
const who = s.speakerName.trim()
|
||||
return `**${who}** (${s.startOffset.toFixed(1)}–${s.endOffset.toFixed(1)} s)\n\n${s.text.trim()}`
|
||||
})
|
||||
const start = transcription.startedAt instanceof Date ? transcription.startedAt : new Date(transcription.startedAt)
|
||||
const endRaw = transcription.endedAt
|
||||
const end = endRaw === undefined || endRaw === null ? null : (endRaw instanceof Date ? endRaw : new Date(endRaw))
|
||||
const header = [
|
||||
`# Транскрипция звонка`,
|
||||
``,
|
||||
`- Matrix room: \`${transcription.matrixRoomId}\``,
|
||||
`- LiveKit room: \`${transcription.roomId}\``,
|
||||
`- Начало: ${start.toISOString()}`,
|
||||
`- Окончание: ${end ? end.toISOString() : '—'}`,
|
||||
``,
|
||||
].join('\n')
|
||||
return `${header}\n${lines.join('\n\n---\n\n')}\n`
|
||||
}
|
||||
|
||||
/** Имя файла в `meetings/` без двоеточий; UTC. */
|
||||
export function transcriptionMeetingFileStemUtc(endedAt: Date): string {
|
||||
const y = endedAt.getUTCFullYear()
|
||||
const mo = String(endedAt.getUTCMonth() + 1).padStart(2, '0')
|
||||
const d = String(endedAt.getUTCDate()).padStart(2, '0')
|
||||
const hh = String(endedAt.getUTCHours()).padStart(2, '0')
|
||||
const mm = String(endedAt.getUTCMinutes()).padStart(2, '0')
|
||||
const ss = String(endedAt.getUTCSeconds()).padStart(2, '0')
|
||||
const msl = String(endedAt.getUTCMilliseconds()).padStart(3, '0')
|
||||
return `${y}-${mo}-${d}_${hh}${mm}${ss}_${msl}`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Расхождение updated_at: индекс vs свежий ответ API → throw.
|
||||
|
||||
export class BlagoConflictError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'BlagoConflictError'
|
||||
}
|
||||
}
|
||||
|
||||
export function assertSameRemoteVersion(localIndexIso: string, serverIso: string, entityLabel: string): void {
|
||||
if (localIndexIso !== serverIso) {
|
||||
throw new BlagoConflictError(
|
||||
`Конфликт для ${entityLabel}: на сервере другая версия (updated_at). Выполните «blago pull», разрешите расхождения и повторите push.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// FR-010: отличие рабочих файлов от эталона в индексе (SHA256 + превью; не патч к серверу).
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
import { info } from '../ui/output.js'
|
||||
|
||||
import { loadIndex } from './index-store.js'
|
||||
|
||||
const PREVIEW_LINES = 24
|
||||
|
||||
export async function runDiff(contentRoot: string, stateDir: string): Promise<void> {
|
||||
const index = await loadIndex(stateDir)
|
||||
let shown = false
|
||||
for (const e of index.entries) {
|
||||
const abs = path.join(contentRoot, e.relative_path)
|
||||
try {
|
||||
const raw = await fs.readFile(abs, 'utf8')
|
||||
const h = sha256Hex(raw)
|
||||
if (h === e.content_etag_local) {
|
||||
continue
|
||||
}
|
||||
shown = true
|
||||
info(`${e.relative_path} [${e.entity_type} ${e.entity_hash}]`)
|
||||
info(` индекс sha256: ${e.content_etag_local}`)
|
||||
info(` сейчас sha256: ${h}`)
|
||||
const lines = raw.split('\n')
|
||||
info(' превью:')
|
||||
for (let i = 0; i < Math.min(PREVIEW_LINES, lines.length); i++) {
|
||||
info(` | ${lines[i]}`)
|
||||
}
|
||||
if (lines.length > PREVIEW_LINES) {
|
||||
info(` … ещё ${lines.length - PREVIEW_LINES} строк`)
|
||||
}
|
||||
info('')
|
||||
}
|
||||
catch {
|
||||
shown = true
|
||||
info(`${e.relative_path} — файл отсутствует на диске [${e.entity_type} ${e.entity_hash}]`)
|
||||
info('')
|
||||
}
|
||||
}
|
||||
if (!shown) {
|
||||
info('Нет расхождений с индексом по содержимому.')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// .blagoignore: простые префиксы/имена (не полный gitignore).
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
const DEFAULT_IGNORE = ['.blago/', '.git/']
|
||||
|
||||
export async function loadBlagoIgnoreRules(root: string): Promise<string[]> {
|
||||
const rules: string[] = [...DEFAULT_IGNORE]
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(root, '.blagoignore'), 'utf8')
|
||||
for (const line of raw.split('\n')) {
|
||||
const t = line.trim()
|
||||
if (!t || t.startsWith('#')) {
|
||||
continue
|
||||
}
|
||||
rules.push(t)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
/* нет файла */
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
function matchesRule(relPosix: string, rule: string): boolean {
|
||||
const r = rule.replace(/\\/g, '/')
|
||||
const withSlash = r.endsWith('/') ? r.slice(0, -1) : r
|
||||
if (relPosix === withSlash) {
|
||||
return true
|
||||
}
|
||||
if (relPosix.startsWith(`${withSlash}/`)) {
|
||||
return true
|
||||
}
|
||||
if (!r.includes('/')) {
|
||||
const parts = relPosix.split('/')
|
||||
return parts.includes(withSlash)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function isIgnoredRelativePath(relPosix: string, rules: string[]): boolean {
|
||||
for (const rule of rules) {
|
||||
if (matchesRule(relPosix, rule)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// index.json + staging.json: путь и etag содержимого после последнего pull/push.
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
|
||||
import { indexPath, stagingPath } from '../config/paths.js'
|
||||
|
||||
import { isPullOnlyCommunicationRelativePath } from './pull-only-paths.js'
|
||||
|
||||
export type BlagoEntityType =
|
||||
| 'project'
|
||||
| 'issue'
|
||||
| 'story'
|
||||
/** Файл `messages/YYYY-MM-DD.md` (история Matrix), только pull с сервера */
|
||||
| 'room_message_day'
|
||||
/** Файл `meetings/*.md` (транскрипция звонка), только pull с сервера */
|
||||
| 'call_transcription'
|
||||
|
||||
export interface IndexEntry {
|
||||
entity_type: BlagoEntityType
|
||||
entity_hash: string
|
||||
relative_path: string
|
||||
remote_updated_at: string
|
||||
content_etag_local: string
|
||||
}
|
||||
|
||||
export interface IndexFile {
|
||||
entries: IndexEntry[]
|
||||
}
|
||||
|
||||
export interface StagingFile {
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
export async function loadIndex(root: string): Promise<IndexFile> {
|
||||
try {
|
||||
const raw = await fs.readFile(indexPath(root), 'utf8')
|
||||
const parsed = JSON.parse(raw) as IndexFile
|
||||
if (!Array.isArray(parsed.entries)) {
|
||||
return { entries: [] }
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
catch {
|
||||
return { entries: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveIndex(root: string, index: IndexFile): Promise<void> {
|
||||
await fs.writeFile(indexPath(root), `${JSON.stringify(index, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
export function findByHash(index: IndexFile, type: BlagoEntityType, hash: string): IndexEntry | undefined {
|
||||
return index.entries.find(e => e.entity_type === type && e.entity_hash === hash)
|
||||
}
|
||||
|
||||
export function findByRelativePath(index: IndexFile, relativePath: string): IndexEntry | undefined {
|
||||
const n = normalizeRelativePath(relativePath)
|
||||
return index.entries.find(e => normalizeRelativePath(e.relative_path) === n)
|
||||
}
|
||||
|
||||
export function upsertEntry(index: IndexFile, entry: IndexEntry): void {
|
||||
const i = index.entries.findIndex(e => e.entity_type === entry.entity_type && e.entity_hash === entry.entity_hash)
|
||||
if (i >= 0) {
|
||||
index.entries[i] = entry
|
||||
}
|
||||
else {
|
||||
index.entries.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadStaging(root: string): Promise<StagingFile> {
|
||||
try {
|
||||
const raw = await fs.readFile(stagingPath(root), 'utf8')
|
||||
const parsed = JSON.parse(raw) as StagingFile
|
||||
if (!Array.isArray(parsed.paths)) {
|
||||
return { paths: [] }
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
catch {
|
||||
return { paths: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveStaging(root: string, staging: StagingFile): Promise<void> {
|
||||
await fs.writeFile(stagingPath(root), `${JSON.stringify(staging, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
/** Добавить пути в staging без проверки «грязности» (после create). */
|
||||
export async function appendPathsToStaging(root: string, paths: string[]): Promise<void> {
|
||||
const staging = await loadStaging(root)
|
||||
const set = new Set(staging.paths.map(p => normalizeRelativePath(p)))
|
||||
for (const p of paths) {
|
||||
const rel = normalizeRelativePath(p)
|
||||
if (!isPullOnlyCommunicationRelativePath(rel)) {
|
||||
set.add(rel)
|
||||
}
|
||||
}
|
||||
await saveStaging(root, { paths: [...set].sort() })
|
||||
}
|
||||
|
||||
/** Заменить один путь в staging (после переименования файла при push). */
|
||||
export async function replaceStagingPath(root: string, fromRel: string, toRel: string): Promise<void> {
|
||||
const from = normalizeRelativePath(fromRel)
|
||||
const to = normalizeRelativePath(toRel)
|
||||
const staging = await loadStaging(root)
|
||||
const next = staging.paths.map(p => (normalizeRelativePath(p) === from ? to : p))
|
||||
await saveStaging(root, { paths: [...new Set(next)].sort() })
|
||||
}
|
||||
|
||||
export function normalizeRelativePath(p: string): string {
|
||||
return p.replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/^\/+/, '')
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// slug и пути project.md / component.md / issues / requirements (как GitHub-синк).
|
||||
|
||||
import { capitalIdPathPrefix, storyRequirementIdPrefix2 } from '../lib/capital-id-path.js'
|
||||
import { effectiveParentHash } from '../lib/parent-hash.js'
|
||||
|
||||
const transliterationMap: Record<string, string> = {
|
||||
а: 'a',
|
||||
б: 'b',
|
||||
в: 'v',
|
||||
г: 'g',
|
||||
д: 'd',
|
||||
е: 'e',
|
||||
ё: 'e',
|
||||
ж: 'zh',
|
||||
з: 'z',
|
||||
и: 'i',
|
||||
й: 'y',
|
||||
к: 'k',
|
||||
л: 'l',
|
||||
м: 'm',
|
||||
н: 'n',
|
||||
о: 'o',
|
||||
п: 'p',
|
||||
р: 'r',
|
||||
с: 's',
|
||||
т: 't',
|
||||
у: 'u',
|
||||
ф: 'f',
|
||||
х: 'kh',
|
||||
ц: 'ts',
|
||||
ч: 'ch',
|
||||
ш: 'sh',
|
||||
щ: 'shch',
|
||||
ъ: '',
|
||||
ы: 'y',
|
||||
ь: '',
|
||||
э: 'e',
|
||||
ю: 'yu',
|
||||
я: 'ya',
|
||||
}
|
||||
|
||||
function hasCyrillic(text: string): boolean {
|
||||
return /[а-яё]/i.test(text)
|
||||
}
|
||||
|
||||
function transliterate(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.split('')
|
||||
.map(char => transliterationMap[char] ?? char)
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function generateSlug(title: string): string {
|
||||
let processed = title
|
||||
if (hasCyrillic(title)) {
|
||||
processed = transliterate(title)
|
||||
}
|
||||
return processed
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
export interface ProjectPathModel {
|
||||
readonly project_hash: string
|
||||
readonly title: string
|
||||
readonly parent_hash?: string | null
|
||||
/** ID проекта/компонента в Capital (блокчейн), для префикса путей */
|
||||
readonly capital_id: number
|
||||
}
|
||||
|
||||
export { effectiveParentHash } from '../lib/parent-hash.js'
|
||||
|
||||
export function workspaceBasePath(project: ProjectPathModel, projectByHash: ReadonlyMap<string, ProjectPathModel>): string {
|
||||
const parentRef = effectiveParentHash(project.parent_hash)
|
||||
const slug = generateSlug(project.title || (parentRef ? 'unnamed-component' : 'unnamed-project'))
|
||||
const name = `${capitalIdPathPrefix(project.capital_id)}-${slug || (parentRef ? 'unnamed-component' : 'unnamed-project')}`
|
||||
if (!parentRef) {
|
||||
return name
|
||||
}
|
||||
const parent = projectByHash.get(parentRef)
|
||||
if (!parent) {
|
||||
throw new Error(`Родительский проект ${parentRef} не найден для компонента ${project.project_hash}`)
|
||||
}
|
||||
const parentBase = workspaceBasePath(parent, projectByHash)
|
||||
return `${parentBase}/components/${name}`
|
||||
}
|
||||
|
||||
export function projectFileRelativePath(project: ProjectPathModel, projectByHash: ReadonlyMap<string, ProjectPathModel>): string {
|
||||
const parentRef = effectiveParentHash(project.parent_hash)
|
||||
if (!parentRef) {
|
||||
const slug = generateSlug(project.title || 'unnamed-project')
|
||||
const name = `${capitalIdPathPrefix(project.capital_id)}-${slug || 'unnamed-project'}`
|
||||
return `${name}/project.md`
|
||||
}
|
||||
const base = workspaceBasePath(project, projectByHash)
|
||||
return `${base}/component.md`
|
||||
}
|
||||
|
||||
/** Имя файла: id задачи (PREFIX-N) + slug title — коллизий нет при уникальном id. */
|
||||
export function issueFileRelativePath(issueTitle: string, basePath: string, issueCapitalId: string): string {
|
||||
const slug = generateSlug(issueTitle) || 'issue'
|
||||
const idp = capitalIdPathPrefix(issueCapitalId)
|
||||
return `${basePath}/issues/${idp}-${slug}.md`
|
||||
}
|
||||
|
||||
export function storyFileRelativePath(
|
||||
storyTitle: string,
|
||||
basePath: string,
|
||||
storyRecordId: string,
|
||||
storyHash: string,
|
||||
issue?: { readonly id: string, readonly titleSlug: string },
|
||||
): string {
|
||||
const slug = generateSlug(storyTitle) || 'requirement'
|
||||
const id2 = storyRequirementIdPrefix2(storyRecordId, storyHash)
|
||||
if (issue?.titleSlug) {
|
||||
const ip = capitalIdPathPrefix(issue.id)
|
||||
return `${basePath}/issues/${ip}-${issue.titleSlug}-requirements/${id2}-${slug}.md`
|
||||
}
|
||||
return `${basePath}/requirements/${id2}-${slug}.md`
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Очередь локально созданных сущностей до первого push (FR-016).
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
|
||||
import { pendingCreatePath } from '../config/paths.js'
|
||||
|
||||
import type { BlagoEntityType } from './index-store.js'
|
||||
import { normalizeRelativePath } from './index-store.js'
|
||||
|
||||
export type PendingCreateKind = 'issue' | 'story'
|
||||
|
||||
export interface PendingCreateItem {
|
||||
readonly kind: PendingCreateKind
|
||||
/** Для issue — временный hash в frontmatter до ответа API; для story — тот же story_hash, что уйдёт в CreateStory. */
|
||||
readonly entity_hash: string
|
||||
readonly relative_path: string
|
||||
readonly created_at: string
|
||||
}
|
||||
|
||||
export interface PendingCreateFile {
|
||||
items: PendingCreateItem[]
|
||||
}
|
||||
|
||||
export async function loadPendingCreate(root: string): Promise<PendingCreateFile> {
|
||||
try {
|
||||
const raw = await fs.readFile(pendingCreatePath(root), 'utf8')
|
||||
const parsed = JSON.parse(raw) as PendingCreateFile
|
||||
if (!Array.isArray(parsed.items)) {
|
||||
return { items: [] }
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
catch {
|
||||
return { items: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePendingCreate(root: string, data: PendingCreateFile): Promise<void> {
|
||||
await fs.writeFile(pendingCreatePath(root), `${JSON.stringify(data, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
export function findPendingItem(
|
||||
data: PendingCreateFile,
|
||||
kind: PendingCreateKind,
|
||||
entityHash: string,
|
||||
): PendingCreateItem | undefined {
|
||||
return data.items.find(
|
||||
i => i.kind === kind && i.entity_hash === entityHash,
|
||||
)
|
||||
}
|
||||
|
||||
export async function appendPendingItem(root: string, item: PendingCreateItem): Promise<void> {
|
||||
const data = await loadPendingCreate(root)
|
||||
if (findPendingItem(data, item.kind, item.entity_hash)) {
|
||||
throw new Error(`Запись pending-create уже есть: ${item.kind} ${item.entity_hash}`)
|
||||
}
|
||||
const rel = normalizeRelativePath(item.relative_path)
|
||||
if (data.items.some(i => normalizeRelativePath(i.relative_path) === rel)) {
|
||||
throw new Error(`Файл уже в pending-create: ${rel}`)
|
||||
}
|
||||
data.items.push(item)
|
||||
await savePendingCreate(root, data)
|
||||
}
|
||||
|
||||
export async function removePendingItem(
|
||||
root: string,
|
||||
kind: PendingCreateKind,
|
||||
entityHash: string,
|
||||
): Promise<void> {
|
||||
const data = await loadPendingCreate(root)
|
||||
const next = data.items.filter(i => !(i.kind === kind && i.entity_hash === entityHash))
|
||||
if (next.length === data.items.length) {
|
||||
throw new Error(`Не найдена запись pending-create: ${kind} ${entityHash}`)
|
||||
}
|
||||
await savePendingCreate(root, { items: next })
|
||||
}
|
||||
|
||||
/** Соответствие типа сущности и kind в pending. */
|
||||
export function pendingKindForEntityType(type: BlagoEntityType): PendingCreateKind | undefined {
|
||||
if (type === 'issue' || type === 'story') {
|
||||
return type
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Карта проектов из индекса + файлов project.md / component.md (для layout после create).
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { parseBlagoMarkdown } from '../format/index.js'
|
||||
|
||||
import type { IndexFile } from './index-store.js'
|
||||
import type { ProjectPathModel } from './layout.js'
|
||||
|
||||
export interface ProjectRowLite {
|
||||
readonly title: string
|
||||
readonly parent_hash?: string | null
|
||||
}
|
||||
|
||||
export async function loadProjectMapsFromIndex(
|
||||
root: string,
|
||||
index: IndexFile,
|
||||
): Promise<{
|
||||
projectByHash: Map<string, ProjectPathModel>
|
||||
projectRowByHash: Map<string, ProjectRowLite>
|
||||
}> {
|
||||
const projectByHash = new Map<string, ProjectPathModel>()
|
||||
const projectRowByHash = new Map<string, ProjectRowLite>()
|
||||
|
||||
for (const e of index.entries) {
|
||||
if (e.entity_type !== 'project') {
|
||||
continue
|
||||
}
|
||||
const abs = path.join(root, e.relative_path)
|
||||
let raw: string
|
||||
try {
|
||||
raw = await fs.readFile(abs, 'utf8')
|
||||
}
|
||||
catch {
|
||||
continue
|
||||
}
|
||||
const parsed = parseBlagoMarkdown(raw)
|
||||
if (parsed.type !== 'project') {
|
||||
continue
|
||||
}
|
||||
const h = String(parsed.data.hash ?? '').trim()
|
||||
if (!h) {
|
||||
continue
|
||||
}
|
||||
const idRaw = parsed.data.id
|
||||
const capital_id
|
||||
= typeof idRaw === 'number' && Number.isFinite(idRaw)
|
||||
? idRaw
|
||||
: Number(idRaw) || 0
|
||||
projectByHash.set(h, {
|
||||
project_hash: h,
|
||||
title: String(parsed.data.title ?? ''),
|
||||
parent_hash: parsed.data.parent_hash as string | null | undefined,
|
||||
capital_id,
|
||||
})
|
||||
projectRowByHash.set(h, {
|
||||
title: String(parsed.data.title ?? ''),
|
||||
parent_hash: parsed.data.parent_hash as string | null | undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return { projectByHash, projectRowByHash }
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
// Pull `messages/` и `meetings/` по GraphQL ChatCoop (как GitHub-синк секретаря).
|
||||
|
||||
import type { AuthenticatedContext } from '../session/index.js'
|
||||
import type { IndexFile } from './index-store.js'
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
import { Queries, Zeus } from '@coopenomics/sdk'
|
||||
|
||||
import { formatThrownValue, warn } from '../ui/output.js'
|
||||
|
||||
import {
|
||||
loadCommunicationCursors,
|
||||
saveCommunicationCursors,
|
||||
type CommunicationCursorsFile,
|
||||
} from './communication-cursors.js'
|
||||
import {
|
||||
projectCommunicationDayToMarkdown,
|
||||
renderCallTranscriptionMarkdown,
|
||||
transcriptionMeetingFileStemUtc,
|
||||
type CommunicationDayLine,
|
||||
} from './communication-markdown.js'
|
||||
import { workspaceBasePath, type ProjectPathModel } from './layout.js'
|
||||
import { syncEntityFile } from './sync-entity-file.js'
|
||||
|
||||
interface ProjectRowLite {
|
||||
readonly project_hash: string
|
||||
readonly title?: string | null
|
||||
}
|
||||
|
||||
/** Верхняя граница `GetTranscriptionsInput.limit` на контроллере (`@Max(100)`). */
|
||||
const CHATCOOP_TRANSCRIPTIONS_QUERY_LIMIT = 100
|
||||
|
||||
function toUpdatedIso(v: Date | string): string {
|
||||
if (v instanceof Date) {
|
||||
return v.toISOString()
|
||||
}
|
||||
return new Date(v).toISOString()
|
||||
}
|
||||
|
||||
function messageDayEntityHash(projectHash: string, utcDate: string): string {
|
||||
return createHash('sha256').update(`${projectHash}:${utcDate}`, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
function dateFromUnknown(value: unknown): Date | undefined {
|
||||
if (value === null || value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? undefined : value
|
||||
}
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? undefined : d
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function listRooms(ctx: AuthenticatedContext, projectHash: string) {
|
||||
const q = await ctx.client.Query(Queries.ChatCoop.ListProjectCommunicationRooms.query, {
|
||||
variables: { data: { projectHash } },
|
||||
})
|
||||
return q[Queries.ChatCoop.ListProjectCommunicationRooms.name] ?? []
|
||||
}
|
||||
|
||||
export async function pullProjectCommunicationArtifacts(
|
||||
ctx: AuthenticatedContext,
|
||||
index: IndexFile,
|
||||
projects: readonly ProjectRowLite[],
|
||||
projectByHash: ReadonlyMap<string, ProjectPathModel>,
|
||||
): Promise<void> {
|
||||
let cursors: CommunicationCursorsFile
|
||||
try {
|
||||
cursors = await loadCommunicationCursors(ctx.root)
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Курсоры переписки: не удалось прочитать, начинаем с пустых: ${formatThrownValue(e)}`)
|
||||
cursors = {
|
||||
messageLastTsByRoom: {},
|
||||
transcriptionLastEndedExclusiveByProject: {},
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of projects) {
|
||||
const projModel = projectByHash.get(row.project_hash)
|
||||
if (!projModel) {
|
||||
continue
|
||||
}
|
||||
|
||||
let rooms: Awaited<ReturnType<typeof listRooms>>
|
||||
try {
|
||||
rooms = await listRooms(ctx, row.project_hash)
|
||||
}
|
||||
catch (e) {
|
||||
warn(
|
||||
`Список комнат переписки (chatcoopListProjectCommunicationRooms), проект ${row.project_hash}: ${formatThrownValue(e)}`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (rooms.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const basePath = workspaceBasePath(projModel, projectByHash)
|
||||
const projectTitle = row.title ?? 'unnamed'
|
||||
const matrixIds = rooms.map(r => r.matrixRoomId)
|
||||
|
||||
try {
|
||||
const datesToRefresh = new Set<string>()
|
||||
|
||||
// Курсора нет → after=0 (все origin_server_ts > 0). Иначе как в GitHub-синке: только новее last.
|
||||
for (const room of rooms) {
|
||||
const last = cursors.messageLastTsByRoom[room.matrixRoomId]
|
||||
const afterTs = last ?? 0
|
||||
const datesQ = await ctx.client.Query(Queries.ChatCoop.ListUtcDatesWithNewRoomMessages.query, {
|
||||
variables: {
|
||||
data: {
|
||||
matrixRoomId: room.matrixRoomId,
|
||||
afterOriginServerTsExclusive: afterTs,
|
||||
},
|
||||
},
|
||||
})
|
||||
const dates = datesQ[Queries.ChatCoop.ListUtcDatesWithNewRoomMessages.name] ?? []
|
||||
for (const d of dates) {
|
||||
datesToRefresh.add(d)
|
||||
}
|
||||
}
|
||||
|
||||
const sortedDates = [...datesToRefresh].sort()
|
||||
for (const utcDate of sortedDates) {
|
||||
const sections = await Promise.all(
|
||||
rooms.map(async (room) => {
|
||||
const mq = await ctx.client.Query(Queries.ChatCoop.GetRoomMessagesForUtcDate.query, {
|
||||
variables: { data: { matrixRoomId: room.matrixRoomId, utcDate } },
|
||||
})
|
||||
const linesRaw = mq[Queries.ChatCoop.GetRoomMessagesForUtcDate.name] ?? []
|
||||
const lines: CommunicationDayLine[] = linesRaw.map(m => ({
|
||||
originServerTs: m.originServerTs,
|
||||
authorLabel: m.authorLabel,
|
||||
coopUsername: m.coopUsername,
|
||||
kind: String(m.kind),
|
||||
bodyText: m.bodyText,
|
||||
}))
|
||||
return {
|
||||
displayLabel: room.displayLabel,
|
||||
matrixRoomId: room.matrixRoomId,
|
||||
lines,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const hasAny = sections.some(s => s.lines.length > 0)
|
||||
if (!hasAny) {
|
||||
continue
|
||||
}
|
||||
const content = projectCommunicationDayToMarkdown(projectTitle, row.project_hash, utcDate, sections)
|
||||
const rel = `${basePath}/messages/${utcDate}.md`
|
||||
const entityHash = messageDayEntityHash(row.project_hash, utcDate)
|
||||
await syncEntityFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entityType: 'room_message_day',
|
||||
entityHash,
|
||||
relativePath: rel,
|
||||
content,
|
||||
remoteUpdatedAt: `${utcDate}T23:59:59.999Z`,
|
||||
label: `переписка ${utcDate} (${row.project_hash})`,
|
||||
})
|
||||
}
|
||||
|
||||
for (const room of rooms) {
|
||||
const maxQ = await ctx.client.Query(Queries.ChatCoop.GetMaxOriginServerTsForRoom.query, {
|
||||
variables: { data: { matrixRoomId: room.matrixRoomId } },
|
||||
})
|
||||
const maxTs = maxQ[Queries.ChatCoop.GetMaxOriginServerTsForRoom.name] as number | null | undefined
|
||||
if (maxTs !== undefined && maxTs !== null && Number.isFinite(maxTs)) {
|
||||
cursors.messageLastTsByRoom[room.matrixRoomId] = maxTs
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Переписка Matrix, проект ${row.project_hash} (${projectTitle}): ${formatThrownValue(e)}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const tKey = row.project_hash
|
||||
const tExIso = cursors.transcriptionLastEndedExclusiveByProject[tKey]
|
||||
// Как сообщения с after=0: без курсора — полная выгрузка завершённых транскрипций в meetings/.
|
||||
// (GitHub-синк при первом запуске только ставит курсор без файлов — для локального зеркала так не делаем.)
|
||||
const lowerBoundExclusive = tExIso === undefined ? new Date(0) : new Date(tExIso)
|
||||
|
||||
interface TranscriptionCandidate {
|
||||
id: string
|
||||
endedAt: Date
|
||||
}
|
||||
const byId = new Map<string, TranscriptionCandidate>()
|
||||
for (const roomId of matrixIds) {
|
||||
const tq = await ctx.client.Query(Queries.ChatCoop.GetTranscriptions.query, {
|
||||
variables: { data: { matrixRoomId: roomId, limit: CHATCOOP_TRANSCRIPTIONS_QUERY_LIMIT, offset: 0 } },
|
||||
})
|
||||
const list = tq[Queries.ChatCoop.GetTranscriptions.name] ?? []
|
||||
for (const t of list) {
|
||||
const end = dateFromUnknown(t.endedAt)
|
||||
if (t.status !== Zeus.TranscriptionStatus.COMPLETED || !end) {
|
||||
continue
|
||||
}
|
||||
if (!(end.getTime() > lowerBoundExclusive.getTime())) {
|
||||
continue
|
||||
}
|
||||
const prev = byId.get(t.id)
|
||||
if (!prev || end > prev.endedAt) {
|
||||
byId.set(t.id, { id: t.id, endedAt: end })
|
||||
}
|
||||
}
|
||||
}
|
||||
const candidates = [...byId.values()].sort((a, b) => a.endedAt.getTime() - b.endedAt.getTime())
|
||||
let maxEnded: Date | null = null
|
||||
for (const c of candidates) {
|
||||
const packQ = await ctx.client.Query(Queries.ChatCoop.GetTranscription.query, {
|
||||
variables: { data: { id: c.id } },
|
||||
})
|
||||
const pack = packQ[Queries.ChatCoop.GetTranscription.name]
|
||||
if (!pack?.transcription || pack.transcription.status !== Zeus.TranscriptionStatus.COMPLETED) {
|
||||
continue
|
||||
}
|
||||
const tr = pack.transcription
|
||||
const startedAt: Date | string = dateFromUnknown(tr.startedAt) ?? (tr.startedAt as Date | string)
|
||||
const endedAtTr: Date | string | null | undefined
|
||||
= dateFromUnknown(tr.endedAt) ?? (tr.endedAt as Date | string | null | undefined)
|
||||
const md = renderCallTranscriptionMarkdown(
|
||||
{
|
||||
matrixRoomId: String(tr.matrixRoomId),
|
||||
roomId: String(tr.roomId),
|
||||
startedAt,
|
||||
endedAt: endedAtTr,
|
||||
},
|
||||
pack.segments.map(s => ({
|
||||
speakerName: s.speakerName,
|
||||
text: s.text,
|
||||
startOffset: s.startOffset,
|
||||
endOffset: s.endOffset,
|
||||
})),
|
||||
)
|
||||
const stem = transcriptionMeetingFileStemUtc(c.endedAt)
|
||||
const rel = `${basePath}/meetings/${stem}.md`
|
||||
const entityHash = c.id.toLowerCase()
|
||||
await syncEntityFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entityType: 'call_transcription',
|
||||
entityHash,
|
||||
relativePath: rel,
|
||||
content: md,
|
||||
remoteUpdatedAt: toUpdatedIso(c.endedAt),
|
||||
label: `транскрипция ${c.id}`,
|
||||
})
|
||||
if (!maxEnded || c.endedAt > maxEnded) {
|
||||
maxEnded = c.endedAt
|
||||
}
|
||||
}
|
||||
if (maxEnded) {
|
||||
cursors.transcriptionLastEndedExclusiveByProject[tKey] = maxEnded.toISOString()
|
||||
}
|
||||
else if (tExIso === undefined) {
|
||||
cursors.transcriptionLastEndedExclusiveByProject[tKey] = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Транскрипции звонков, проект ${row.project_hash}: ${formatThrownValue(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await saveCommunicationCursors(ctx.root, cursors)
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Не удалось сохранить курсоры переписки: ${formatThrownValue(e)}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Пути артефактов ChatCoop (pull): не для blago add / push.
|
||||
|
||||
function normRel(p: string): string {
|
||||
return p.replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/^\/+/, '')
|
||||
}
|
||||
|
||||
/** Переписка Matrix (`…/messages/…`) и транскрипции (`…/meetings/…`) — только `blago pull` / `restore`. */
|
||||
export function isPullOnlyCommunicationRelativePath(rel: string): boolean {
|
||||
const n = normRel(rel)
|
||||
return n.includes('/messages/') || n.includes('/meetings/')
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
// pull: SDK → файлы; индекс по hash; смена slug → перенос (FR-014).
|
||||
|
||||
import type { AuthenticatedContext } from '../session/index.js'
|
||||
import type { IndexFile } from './index-store.js'
|
||||
|
||||
import { Queries } from '@coopenomics/sdk'
|
||||
|
||||
import { type BlagoConfigFile, resolveCoopname } from '../config/index.js'
|
||||
import {
|
||||
issueToFrontmatterAndBody,
|
||||
issueWorkspaceTitlesFromProjects,
|
||||
projectToFrontmatterAndBody,
|
||||
serializeBlagoMarkdown,
|
||||
storyToFrontmatterAndBody,
|
||||
} from '../format/index.js'
|
||||
import { warn } from '../ui/output.js'
|
||||
import { loadIndex, saveIndex } from './index-store.js'
|
||||
import {
|
||||
generateSlug,
|
||||
issueFileRelativePath,
|
||||
projectFileRelativePath,
|
||||
type ProjectPathModel,
|
||||
storyFileRelativePath,
|
||||
workspaceBasePath,
|
||||
} from './layout.js'
|
||||
import { pullProjectCommunicationArtifacts } from './pull-communication.js'
|
||||
import { syncEntityFile } from './sync-entity-file.js'
|
||||
|
||||
interface CapitalProjectRow {
|
||||
id?: number | null
|
||||
project_hash: string
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
coopname: string
|
||||
parent_hash?: string | null
|
||||
parent_title?: string | null
|
||||
status?: string | null
|
||||
created_at?: Date | string | null
|
||||
_updated_at?: Date | string | null
|
||||
components?: CapitalProjectRow[] | null
|
||||
}
|
||||
|
||||
interface CapitalIssueRow {
|
||||
id?: string | null
|
||||
issue_hash: string
|
||||
title: string
|
||||
description?: string | null
|
||||
project_hash: string
|
||||
cycle_id?: string | null
|
||||
status?: string | null
|
||||
priority?: string | null
|
||||
estimate?: number | null
|
||||
created_by?: string | null
|
||||
submaster?: string | null
|
||||
creators?: string[] | null
|
||||
metadata?: unknown
|
||||
sort_order?: number | null
|
||||
_created_at?: Date | string | null
|
||||
_updated_at?: Date | string | null
|
||||
}
|
||||
|
||||
interface CapitalStoryRow {
|
||||
_id?: string | null
|
||||
story_hash: string
|
||||
title: string
|
||||
description?: string | null
|
||||
content_format?: string | null
|
||||
status?: string | null
|
||||
created_by?: string | null
|
||||
sort_order?: number | null
|
||||
project_hash?: string | null
|
||||
issue_hash?: string | null
|
||||
_created_at?: Date | string | null
|
||||
_updated_at?: Date | string | null
|
||||
}
|
||||
|
||||
function flattenProjects(roots: CapitalProjectRow[]): CapitalProjectRow[] {
|
||||
const out: CapitalProjectRow[] = []
|
||||
const walk = (p: CapitalProjectRow): void => {
|
||||
out.push(p)
|
||||
for (const c of p.components ?? []) {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
for (const r of roots) {
|
||||
walk(r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function toUpdatedIso(v: Date | string | null | undefined): string {
|
||||
if (v === undefined || v === null) {
|
||||
return ''
|
||||
}
|
||||
if (v instanceof Date) {
|
||||
return v.toISOString()
|
||||
}
|
||||
return new Date(v).toISOString()
|
||||
}
|
||||
|
||||
function asProjectPathModel(p: CapitalProjectRow): ProjectPathModel {
|
||||
const n = p.id
|
||||
const capital_id = typeof n === 'number' && Number.isFinite(n) ? n : 0
|
||||
return {
|
||||
project_hash: p.project_hash,
|
||||
title: p.title ?? '',
|
||||
parent_hash: p.parent_hash,
|
||||
capital_id,
|
||||
}
|
||||
}
|
||||
|
||||
function requireCoopname(cfg: BlagoConfigFile): string {
|
||||
const c = resolveCoopname(cfg)
|
||||
if (!c) {
|
||||
throw new Error(
|
||||
'Укажите coopname в .blago/config.json в environments.<активнаяСреда> (или запасной «coopname» сверху), либо: blago init --coopname <имя>',
|
||||
)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
export async function runPull(ctx: AuthenticatedContext): Promise<void> {
|
||||
const coopname = requireCoopname(ctx.config)
|
||||
const index = await loadIndex(ctx.root)
|
||||
const projectByHash = new Map<string, ProjectPathModel>()
|
||||
|
||||
const allProjects: CapitalProjectRow[] = []
|
||||
const seen = new Set<string>()
|
||||
let page = 1
|
||||
for (;;) {
|
||||
const { [Queries.Capital.GetProjects.name]: pageResult } = await ctx.client.Query(
|
||||
Queries.Capital.GetProjects.query,
|
||||
{
|
||||
variables: {
|
||||
filter: { coopname },
|
||||
options: { limit: 100, page, sortOrder: 'DESC' },
|
||||
},
|
||||
},
|
||||
)
|
||||
const chunk = pageResult.items as CapitalProjectRow[]
|
||||
for (const item of chunk) {
|
||||
for (const p of flattenProjects([item])) {
|
||||
if (!seen.has(p.project_hash)) {
|
||||
seen.add(p.project_hash)
|
||||
allProjects.push(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (page >= pageResult.totalPages) {
|
||||
break
|
||||
}
|
||||
page += 1
|
||||
}
|
||||
|
||||
page = 1
|
||||
for (;;) {
|
||||
const { [Queries.Capital.GetProjects.name]: pageResult } = await ctx.client.Query(
|
||||
Queries.Capital.GetProjects.query,
|
||||
{
|
||||
variables: {
|
||||
filter: { coopname, is_component: true },
|
||||
options: { limit: 100, page, sortOrder: 'DESC' },
|
||||
},
|
||||
},
|
||||
)
|
||||
const chunk = pageResult.items as CapitalProjectRow[]
|
||||
for (const item of chunk) {
|
||||
for (const p of flattenProjects([item])) {
|
||||
if (!seen.has(p.project_hash)) {
|
||||
seen.add(p.project_hash)
|
||||
allProjects.push(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (page >= pageResult.totalPages) {
|
||||
break
|
||||
}
|
||||
page += 1
|
||||
}
|
||||
|
||||
for (const p of allProjects) {
|
||||
projectByHash.set(p.project_hash, asProjectPathModel(p))
|
||||
}
|
||||
|
||||
const projectRowByHash = new Map<string, CapitalProjectRow>()
|
||||
for (const p of allProjects) {
|
||||
projectRowByHash.set(p.project_hash, p)
|
||||
}
|
||||
|
||||
for (const p of allProjects) {
|
||||
const { data, body } = projectToFrontmatterAndBody(p)
|
||||
const content = serializeBlagoMarkdown(data, body)
|
||||
const rel = projectFileRelativePath(asProjectPathModel(p), projectByHash)
|
||||
await syncEntityFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entityType: 'project',
|
||||
entityHash: p.project_hash,
|
||||
relativePath: rel,
|
||||
content,
|
||||
remoteUpdatedAt: toUpdatedIso(p._updated_at),
|
||||
label: `проект ${p.project_hash}`,
|
||||
})
|
||||
}
|
||||
|
||||
const issues: CapitalIssueRow[] = []
|
||||
page = 1
|
||||
for (;;) {
|
||||
const { [Queries.Capital.GetIssues.name]: pageResult } = await ctx.client.Query(
|
||||
Queries.Capital.GetIssues.query,
|
||||
{
|
||||
variables: {
|
||||
filter: { coopname },
|
||||
options: { limit: 200, page, sortOrder: 'DESC' },
|
||||
},
|
||||
},
|
||||
)
|
||||
const chunk = pageResult.items as CapitalIssueRow[]
|
||||
issues.push(...chunk)
|
||||
if (page >= pageResult.totalPages) {
|
||||
break
|
||||
}
|
||||
page += 1
|
||||
}
|
||||
|
||||
const issueByHash = new Map<string, CapitalIssueRow>()
|
||||
for (const i of issues) {
|
||||
issueByHash.set(i.issue_hash, i)
|
||||
}
|
||||
|
||||
for (const i of issues) {
|
||||
const proj = projectByHash.get(i.project_hash)
|
||||
if (!proj) {
|
||||
warn(`Задача ${i.issue_hash}: проект ${i.project_hash} не найден в выборке, пропуск`)
|
||||
continue
|
||||
}
|
||||
const basePath = workspaceBasePath(proj, projectByHash)
|
||||
const issueCapitalId = i.id !== undefined && i.id !== null && String(i.id).trim() !== '' ? String(i.id) : i.issue_hash
|
||||
const rel = issueFileRelativePath(i.title, basePath, issueCapitalId)
|
||||
const workspace = issueWorkspaceTitlesFromProjects(i.project_hash, projectRowByHash)
|
||||
const { data, body } = issueToFrontmatterAndBody(i, workspace)
|
||||
const content = serializeBlagoMarkdown(data, body)
|
||||
await syncEntityFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entityType: 'issue',
|
||||
entityHash: i.issue_hash,
|
||||
relativePath: rel,
|
||||
content,
|
||||
remoteUpdatedAt: toUpdatedIso(i._updated_at),
|
||||
label: `задача ${i.issue_hash}`,
|
||||
})
|
||||
}
|
||||
|
||||
const stories: CapitalStoryRow[] = []
|
||||
page = 1
|
||||
for (;;) {
|
||||
const { [Queries.Capital.GetStories.name]: pageResult } = await ctx.client.Query(
|
||||
Queries.Capital.GetStories.query,
|
||||
{
|
||||
variables: {
|
||||
filter: {
|
||||
coopname,
|
||||
show_issues_requirements: true,
|
||||
show_components_requirements: true,
|
||||
},
|
||||
options: { limit: 200, page, sortOrder: 'DESC' },
|
||||
},
|
||||
},
|
||||
)
|
||||
const chunk = pageResult.items as CapitalStoryRow[]
|
||||
stories.push(...chunk)
|
||||
if (page >= pageResult.totalPages) {
|
||||
break
|
||||
}
|
||||
page += 1
|
||||
}
|
||||
|
||||
for (const s of stories) {
|
||||
if (!s.project_hash) {
|
||||
warn(`Требование ${s.story_hash}: нет project_hash, пропуск`)
|
||||
continue
|
||||
}
|
||||
const proj = projectByHash.get(s.project_hash)
|
||||
if (!proj) {
|
||||
warn(`Требование ${s.story_hash}: проект ${s.project_hash} не найден, пропуск`)
|
||||
continue
|
||||
}
|
||||
const basePath = workspaceBasePath(proj, projectByHash)
|
||||
let issueArg: { id: string, titleSlug: string } | undefined
|
||||
if (s.issue_hash) {
|
||||
const iss = issueByHash.get(s.issue_hash)
|
||||
if (iss) {
|
||||
const issueId = iss.id !== undefined && iss.id !== null && String(iss.id).trim() !== '' ? String(iss.id) : iss.issue_hash
|
||||
issueArg = {
|
||||
id: issueId,
|
||||
titleSlug: generateSlug(iss.title) || 'issue',
|
||||
}
|
||||
}
|
||||
}
|
||||
const storyRecordId
|
||||
= s._id !== undefined && s._id !== null && String(s._id).trim() !== '' ? String(s._id) : s.story_hash
|
||||
const rel = storyFileRelativePath(s.title, basePath, storyRecordId, s.story_hash, issueArg)
|
||||
const { data, body } = storyToFrontmatterAndBody(s)
|
||||
const content = serializeBlagoMarkdown(data, body)
|
||||
await syncEntityFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entityType: 'story',
|
||||
entityHash: s.story_hash,
|
||||
relativePath: rel,
|
||||
content,
|
||||
remoteUpdatedAt: toUpdatedIso(s._updated_at),
|
||||
label: `требование ${s.story_hash}`,
|
||||
})
|
||||
}
|
||||
|
||||
await pullProjectCommunicationArtifacts(ctx, index, allProjects, projectByHash)
|
||||
|
||||
await saveIndex(ctx.root, index)
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
// Первая отправка локально созданных issue / story (ветка Create в push).
|
||||
|
||||
import type { AuthenticatedContext } from '../session/index.js'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { Mutations } from '@coopenomics/sdk'
|
||||
|
||||
import { resolveCoopname } from '../config/index.js'
|
||||
import {
|
||||
issueToFrontmatterAndBody,
|
||||
issueWorkspaceTitlesFromProjects,
|
||||
parseBlagoMarkdown,
|
||||
type ParsedBlagoFile,
|
||||
serializeBlagoMarkdown,
|
||||
storyToFrontmatterAndBody,
|
||||
} from '../format/index.js'
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
|
||||
import { findPendingItem, loadPendingCreate, removePendingItem, type PendingCreateItem } from './pending-create.js'
|
||||
import { loadProjectMapsFromIndex } from './project-index-map.js'
|
||||
import {
|
||||
findByHash,
|
||||
normalizeRelativePath,
|
||||
replaceStagingPath,
|
||||
upsertEntry,
|
||||
type IndexFile,
|
||||
} from './index-store.js'
|
||||
import { generateSlug, issueFileRelativePath, storyFileRelativePath, workspaceBasePath } from './layout.js'
|
||||
|
||||
function toIso(v: unknown): string {
|
||||
if (v === undefined || v === null) {
|
||||
return ''
|
||||
}
|
||||
if (v instanceof Date) {
|
||||
return v.toISOString()
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
return new Date(v).toISOString()
|
||||
}
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
return new Date(v).toISOString()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function requireCoopname(cfg: AuthenticatedContext['config']): string {
|
||||
const c = resolveCoopname(cfg)
|
||||
if (!c) {
|
||||
throw new Error(
|
||||
'Укажите coopname в .blago/config.json (активная среда или общий fallback)',
|
||||
)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
async function ensureDirForFile(absFile: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(absFile), { recursive: true })
|
||||
}
|
||||
|
||||
/** issue_hash из индекса → id и slug для пути требования под задачей. */
|
||||
async function issueLinkForStoryPath(
|
||||
root: string,
|
||||
index: IndexFile,
|
||||
issueHash: string,
|
||||
): Promise<{ id: string, titleSlug: string } | undefined> {
|
||||
const entry = findByHash(index, 'issue', issueHash)
|
||||
if (!entry) {
|
||||
return undefined
|
||||
}
|
||||
const raw = await fs.readFile(path.join(root, entry.relative_path), 'utf8')
|
||||
const parsed = parseBlagoMarkdown(raw)
|
||||
if (parsed.type !== 'issue') {
|
||||
return undefined
|
||||
}
|
||||
const idRaw = parsed.data.id
|
||||
const id = idRaw !== undefined && idRaw !== null && String(idRaw).trim() !== ''
|
||||
? String(idRaw).trim()
|
||||
: String(parsed.data.hash ?? '')
|
||||
const titleSlug = generateSlug(String(parsed.data.title ?? '')) || 'issue'
|
||||
return { id, titleSlug }
|
||||
}
|
||||
|
||||
export async function pushCreateIssue(
|
||||
ctx: AuthenticatedContext,
|
||||
index: IndexFile,
|
||||
rel: string,
|
||||
parsed: ParsedBlagoFile,
|
||||
pendingItem: PendingCreateItem,
|
||||
): Promise<void> {
|
||||
const coopname = requireCoopname(ctx.config)
|
||||
const { projectByHash, projectRowByHash } = await loadProjectMapsFromIndex(ctx.root, index)
|
||||
const projectHash = String(parsed.data.project_hash ?? '')
|
||||
const proj = projectByHash.get(projectHash)
|
||||
if (!proj) {
|
||||
throw new Error(`Задача: проект «${projectHash}» не найден в индексе (нужен «blago pull»).`)
|
||||
}
|
||||
|
||||
const creators = Array.isArray(parsed.data.creators)
|
||||
? (parsed.data.creators as unknown[]).map(x => String(x))
|
||||
: []
|
||||
const labels = Array.isArray(parsed.data.labels)
|
||||
? (parsed.data.labels as unknown[]).map(x => String(x))
|
||||
: []
|
||||
|
||||
const issueInput: Mutations.Capital.CreateIssue.IInput['data'] = {
|
||||
coopname,
|
||||
project_hash: projectHash,
|
||||
title: String(parsed.data.title ?? ''),
|
||||
description: parsed.body,
|
||||
status: parsed.data.status as Mutations.Capital.CreateIssue.IInput['data']['status'],
|
||||
priority: parsed.data.priority as Mutations.Capital.CreateIssue.IInput['data']['priority'],
|
||||
estimate: Number(parsed.data.estimate ?? 0),
|
||||
sort_order: Number(parsed.data.sort_order ?? 0),
|
||||
creators,
|
||||
labels,
|
||||
}
|
||||
if (parsed.data.cycle_id) {
|
||||
issueInput.cycle_id = String(parsed.data.cycle_id)
|
||||
}
|
||||
if (parsed.data.submaster) {
|
||||
issueInput.submaster = String(parsed.data.submaster)
|
||||
}
|
||||
|
||||
const mutationResult = await ctx.client.Mutation(Mutations.Capital.CreateIssue.mutation, {
|
||||
variables: { data: issueInput },
|
||||
})
|
||||
const created = mutationResult[Mutations.Capital.CreateIssue.name]
|
||||
if (created == null) {
|
||||
throw new Error('Создание задачи: пустой ответ API')
|
||||
}
|
||||
|
||||
const workspace = issueWorkspaceTitlesFromProjects(created.project_hash, projectRowByHash)
|
||||
const { data, body } = issueToFrontmatterAndBody(
|
||||
{
|
||||
id: created.id,
|
||||
title: created.title,
|
||||
description: created.description,
|
||||
issue_hash: created.issue_hash,
|
||||
project_hash: created.project_hash,
|
||||
cycle_id: created.cycle_id,
|
||||
status: created.status,
|
||||
priority: created.priority,
|
||||
estimate: created.estimate,
|
||||
submaster: created.submaster,
|
||||
creators: created.creators,
|
||||
metadata: created.metadata,
|
||||
sort_order: created.sort_order,
|
||||
_created_at: created._created_at,
|
||||
_updated_at: created._updated_at,
|
||||
},
|
||||
workspace,
|
||||
)
|
||||
const content = serializeBlagoMarkdown(data, body)
|
||||
const basePath = workspaceBasePath(projectByHash.get(created.project_hash)!, projectByHash)
|
||||
const issueCapitalId
|
||||
= created.id !== undefined && created.id !== null && String(created.id).trim() !== ''
|
||||
? String(created.id).trim()
|
||||
: created.issue_hash
|
||||
const newRel = normalizeRelativePath(
|
||||
issueFileRelativePath(created.title, basePath, issueCapitalId),
|
||||
)
|
||||
const oldRel = normalizeRelativePath(rel)
|
||||
const absNew = path.join(ctx.root, newRel)
|
||||
const absOld = path.join(ctx.root, oldRel)
|
||||
|
||||
await ensureDirForFile(absNew)
|
||||
await fs.writeFile(absNew, content, 'utf8')
|
||||
if (oldRel !== newRel && (await fileExists(absOld))) {
|
||||
await fs.unlink(absOld)
|
||||
}
|
||||
if (oldRel !== newRel) {
|
||||
await replaceStagingPath(ctx.root, oldRel, newRel)
|
||||
}
|
||||
|
||||
const etag = sha256Hex(await fs.readFile(absNew, 'utf8'))
|
||||
upsertEntry(index, {
|
||||
entity_type: 'issue',
|
||||
entity_hash: created.issue_hash,
|
||||
relative_path: newRel,
|
||||
remote_updated_at: toIso(created._updated_at),
|
||||
content_etag_local: etag,
|
||||
})
|
||||
|
||||
await removePendingItem(ctx.root, 'issue', pendingItem.entity_hash)
|
||||
}
|
||||
|
||||
async function fileExists(abs: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(abs)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function pushCreateStory(
|
||||
ctx: AuthenticatedContext,
|
||||
index: IndexFile,
|
||||
rel: string,
|
||||
parsed: ParsedBlagoFile,
|
||||
pendingItem: PendingCreateItem,
|
||||
): Promise<void> {
|
||||
const coopname = requireCoopname(ctx.config)
|
||||
const { projectByHash } = await loadProjectMapsFromIndex(ctx.root, index)
|
||||
const storyHash = String(parsed.data.hash ?? '')
|
||||
const projectHash = parsed.data.project_hash ? String(parsed.data.project_hash) : ''
|
||||
const issueHash = parsed.data.issue_hash ? String(parsed.data.issue_hash) : ''
|
||||
if (!projectHash && !issueHash) {
|
||||
throw new Error('Требование: укажите project_hash и/или issue_hash во frontmatter')
|
||||
}
|
||||
const proj = projectHash ? projectByHash.get(projectHash) : undefined
|
||||
if (projectHash && !proj) {
|
||||
throw new Error(`Требование: проект «${projectHash}» не найден в индексе`)
|
||||
}
|
||||
|
||||
const storyInput: Mutations.Capital.CreateStory.IInput['data'] = {
|
||||
coopname,
|
||||
story_hash: storyHash,
|
||||
title: String(parsed.data.title ?? ''),
|
||||
description: parsed.body,
|
||||
content_format: parsed.data.content_format as Mutations.Capital.CreateStory.IInput['data']['content_format'],
|
||||
status: parsed.data.status as Mutations.Capital.CreateStory.IInput['data']['status'],
|
||||
sort_order: Number(parsed.data.sort_order ?? 0),
|
||||
}
|
||||
if (projectHash) {
|
||||
storyInput.project_hash = projectHash
|
||||
}
|
||||
if (issueHash) {
|
||||
storyInput.issue_hash = issueHash
|
||||
}
|
||||
|
||||
const mutationResult = await ctx.client.Mutation(Mutations.Capital.CreateStory.mutation, {
|
||||
variables: { data: storyInput },
|
||||
})
|
||||
const created = mutationResult[Mutations.Capital.CreateStory.name]
|
||||
if (created == null) {
|
||||
throw new Error('Создание требования: пустой ответ API')
|
||||
}
|
||||
|
||||
const { data, body } = storyToFrontmatterAndBody({
|
||||
_id: created._id,
|
||||
title: created.title,
|
||||
description: created.description,
|
||||
story_hash: created.story_hash,
|
||||
content_format: created.content_format,
|
||||
status: created.status,
|
||||
created_by: created.created_by,
|
||||
sort_order: created.sort_order,
|
||||
project_hash: created.project_hash,
|
||||
issue_hash: created.issue_hash,
|
||||
_created_at: created._created_at,
|
||||
_updated_at: created._updated_at,
|
||||
})
|
||||
const content = serializeBlagoMarkdown(data, body)
|
||||
|
||||
const projRow = created.project_hash ? projectByHash.get(String(created.project_hash)) : undefined
|
||||
if (!projRow) {
|
||||
throw new Error('Требование после create: не удалось разрешить project_hash для пути файла')
|
||||
}
|
||||
const basePath = workspaceBasePath(projRow, projectByHash)
|
||||
let issueArg: { id: string, titleSlug: string } | undefined
|
||||
if (created.issue_hash) {
|
||||
issueArg = await issueLinkForStoryPath(ctx.root, index, String(created.issue_hash))
|
||||
}
|
||||
const storyRecordId
|
||||
= created._id !== undefined && created._id !== null && String(created._id).trim() !== ''
|
||||
? String(created._id).trim()
|
||||
: created.story_hash
|
||||
const newRel = normalizeRelativePath(
|
||||
storyFileRelativePath(created.title, basePath, storyRecordId, created.story_hash, issueArg),
|
||||
)
|
||||
const oldRel = normalizeRelativePath(rel)
|
||||
const absNew = path.join(ctx.root, newRel)
|
||||
const absOld = path.join(ctx.root, oldRel)
|
||||
|
||||
await ensureDirForFile(absNew)
|
||||
await fs.writeFile(absNew, content, 'utf8')
|
||||
if (oldRel !== newRel && (await fileExists(absOld))) {
|
||||
await fs.unlink(absOld)
|
||||
}
|
||||
if (oldRel !== newRel) {
|
||||
await replaceStagingPath(ctx.root, oldRel, newRel)
|
||||
}
|
||||
|
||||
const etag = sha256Hex(await fs.readFile(absNew, 'utf8'))
|
||||
upsertEntry(index, {
|
||||
entity_type: 'story',
|
||||
entity_hash: created.story_hash,
|
||||
relative_path: newRel,
|
||||
remote_updated_at: toIso(created._updated_at),
|
||||
content_etag_local: etag,
|
||||
})
|
||||
|
||||
await removePendingItem(ctx.root, 'story', pendingItem.entity_hash)
|
||||
}
|
||||
|
||||
export async function findPendingForParsed(
|
||||
root: string,
|
||||
kind: 'issue' | 'story',
|
||||
entityHash: string,
|
||||
): Promise<PendingCreateItem | undefined> {
|
||||
const data = await loadPendingCreate(root)
|
||||
return findPendingItem(data, kind, entityHash)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// push: только staging; remote_updated_at из индекса должен совпасть с сервером.
|
||||
|
||||
import type { AuthenticatedContext } from '../session/index.js'
|
||||
import * as fs from 'node:fs/promises'
|
||||
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { Mutations, Queries } from '@coopenomics/sdk'
|
||||
import { parseBlagoMarkdown } from '../format/index.js'
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
import { effectiveParentHash } from '../lib/parent-hash.js'
|
||||
import { warn } from '../ui/output.js'
|
||||
import { validateParsedForPush } from '../validate/index.js'
|
||||
import { assertSameRemoteVersion } from './conflicts.js'
|
||||
import {
|
||||
findByHash,
|
||||
loadIndex,
|
||||
loadStaging,
|
||||
normalizeRelativePath,
|
||||
saveIndex,
|
||||
saveStaging,
|
||||
upsertEntry,
|
||||
} from './index-store.js'
|
||||
import { pendingKindForEntityType } from './pending-create.js'
|
||||
import { isPullOnlyCommunicationRelativePath } from './pull-only-paths.js'
|
||||
import {
|
||||
findPendingForParsed,
|
||||
pushCreateIssue,
|
||||
pushCreateStory,
|
||||
} from './push-create.js'
|
||||
|
||||
// Ответы GraphQL/Zeus часто дают _updated_at как unknown — сужаем безопасно.
|
||||
function toIso(v: unknown): string {
|
||||
if (v === undefined || v === null) {
|
||||
return ''
|
||||
}
|
||||
if (v instanceof Date) {
|
||||
return v.toISOString()
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
return new Date(v).toISOString()
|
||||
}
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
return new Date(v).toISOString()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export async function runPush(ctx: AuthenticatedContext): Promise<void> {
|
||||
let staging = await loadStaging(ctx.root)
|
||||
const pullOnlyInStaging = staging.paths.filter(p => isPullOnlyCommunicationRelativePath(p))
|
||||
if (pullOnlyInStaging.length > 0) {
|
||||
const kept = staging.paths.filter(p => !isPullOnlyCommunicationRelativePath(p))
|
||||
await saveStaging(ctx.root, { paths: [...new Set(kept.map(p => normalizeRelativePath(p)))].sort() })
|
||||
for (const p of pullOnlyInStaging) {
|
||||
warn(`Убрано из staging (артефакты только pull — messages/ и meetings/): ${normalizeRelativePath(p)}`)
|
||||
}
|
||||
staging = await loadStaging(ctx.root)
|
||||
}
|
||||
if (staging.paths.length === 0) {
|
||||
throw new Error('Нечего отправлять. Добавьте файлы: blago add <путь>')
|
||||
}
|
||||
const index = await loadIndex(ctx.root)
|
||||
|
||||
const normalizedList = [...new Set(staging.paths.map(p => normalizeRelativePath(p)))]
|
||||
const remaining = new Set(normalizedList)
|
||||
|
||||
for (const rel of normalizedList) {
|
||||
const n = rel
|
||||
if (isPullOnlyCommunicationRelativePath(n)) {
|
||||
throw new Error(
|
||||
`Файл «${n}» не отправляется на сервер (переписка/транскрипции). Уберите из staging: blago remove «${n}»`,
|
||||
)
|
||||
}
|
||||
const abs = path.join(ctx.root, n)
|
||||
const raw = await fs.readFile(abs, 'utf8')
|
||||
const parsed = parseBlagoMarkdown(raw)
|
||||
const { type, hash } = validateParsedForPush(parsed)
|
||||
const entry = findByHash(index, type, hash)
|
||||
const pKind = pendingKindForEntityType(type)
|
||||
const pending = pKind ? await findPendingForParsed(ctx.root, pKind, hash) : undefined
|
||||
|
||||
if (entry && pending) {
|
||||
throw new Error(
|
||||
`Файл «${n}»: сущность ${type} уже в индексе, но есть запись pending-create. Удалите pending-create.json вручную или выполните pull.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!entry && pending) {
|
||||
if (normalizeRelativePath(pending.relative_path) !== n) {
|
||||
throw new Error(
|
||||
`Файл «${n}» не совпадает с путём в pending-create («${pending.relative_path}»).`,
|
||||
)
|
||||
}
|
||||
if (type === 'issue') {
|
||||
await pushCreateIssue(ctx, index, n, parsed, pending)
|
||||
}
|
||||
else if (type === 'story') {
|
||||
await pushCreateStory(ctx, index, n, parsed, pending)
|
||||
}
|
||||
remaining.delete(n)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Файл «${n}»: сущность ${type} ${hash} не в индексе. Выполните «blago pull» или «blago create» и снова add.`,
|
||||
)
|
||||
}
|
||||
if (normalizeRelativePath(entry.relative_path) !== n) {
|
||||
throw new Error(
|
||||
`Файл «${n}» не совпадает с каноническим путём в индексе «${entry.relative_path}». Выполните «blago pull» для выравнивания путей.`,
|
||||
)
|
||||
}
|
||||
|
||||
const etag = sha256Hex(raw)
|
||||
|
||||
if (type === 'project') {
|
||||
const coopname = String(parsed.data.coopname ?? '')
|
||||
const rawParent = parsed.data.parent_hash
|
||||
const parentHash = effectiveParentHash(
|
||||
rawParent === undefined || rawParent === null ? undefined : String(rawParent),
|
||||
)
|
||||
const projectQuery = await ctx.client.Query(
|
||||
Queries.Capital.GetProject.query,
|
||||
{
|
||||
variables: {
|
||||
data: {
|
||||
hash,
|
||||
parent_hash: parentHash,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
const remote = projectQuery[Queries.Capital.GetProject.name]
|
||||
if (remote == null) {
|
||||
throw new Error(`Проект «${hash}» не найден на сервере.`)
|
||||
}
|
||||
assertSameRemoteVersion(entry.remote_updated_at, toIso(remote._updated_at), `проект ${hash}`)
|
||||
const projectData: Mutations.Capital.EditProject.IInput['data'] = {
|
||||
coopname,
|
||||
project_hash: hash,
|
||||
title: String(parsed.data.title ?? ''),
|
||||
description: parsed.body,
|
||||
data: remote.data ?? '',
|
||||
meta: remote.meta ?? '',
|
||||
invite: remote.invite ?? '',
|
||||
}
|
||||
await ctx.client.Mutation(
|
||||
Mutations.Capital.EditProject.mutation,
|
||||
{
|
||||
variables: {
|
||||
data: projectData,
|
||||
},
|
||||
},
|
||||
)
|
||||
const projectAfterQuery = await ctx.client.Query(
|
||||
Queries.Capital.GetProject.query,
|
||||
{
|
||||
variables: {
|
||||
data: {
|
||||
hash,
|
||||
parent_hash: parentHash,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
const after = projectAfterQuery[Queries.Capital.GetProject.name]
|
||||
if (after == null) {
|
||||
throw new Error(`Проект «${hash}» не найден на сервере после сохранения.`)
|
||||
}
|
||||
upsertEntry(index, {
|
||||
entity_type: 'project',
|
||||
entity_hash: hash,
|
||||
relative_path: entry.relative_path,
|
||||
remote_updated_at: toIso(after._updated_at),
|
||||
content_etag_local: etag,
|
||||
})
|
||||
}
|
||||
else if (type === 'issue') {
|
||||
const issueQuery = await ctx.client.Query(
|
||||
Queries.Capital.GetIssue.query,
|
||||
{
|
||||
variables: {
|
||||
data: { issue_hash: hash },
|
||||
},
|
||||
},
|
||||
)
|
||||
const remote = issueQuery[Queries.Capital.GetIssue.name]
|
||||
if (remote == null) {
|
||||
throw new Error(`Задача «${hash}» не найдена на сервере.`)
|
||||
}
|
||||
assertSameRemoteVersion(entry.remote_updated_at, toIso(remote._updated_at), `задача ${hash}`)
|
||||
const creators = Array.isArray(parsed.data.creators)
|
||||
? (parsed.data.creators as unknown[]).map(x => String(x))
|
||||
: []
|
||||
const labels = Array.isArray(parsed.data.labels)
|
||||
? (parsed.data.labels as unknown[]).map(x => String(x))
|
||||
: []
|
||||
/** Только поля GraphQL UpdateIssueInput (schema.gql); лишние ключи ломают capitalUpdateIssue. */
|
||||
const issueData: Mutations.Capital.UpdateIssue.IInput['data'] = {
|
||||
issue_hash: hash,
|
||||
title: String(parsed.data.title ?? ''),
|
||||
description: parsed.body,
|
||||
status: parsed.data.status as Mutations.Capital.UpdateIssue.IInput['data']['status'],
|
||||
priority: parsed.data.priority as Mutations.Capital.UpdateIssue.IInput['data']['priority'],
|
||||
estimate: Number(parsed.data.estimate ?? 0),
|
||||
sort_order: Number(parsed.data.sort_order ?? 0),
|
||||
creators,
|
||||
labels,
|
||||
}
|
||||
if (parsed.data.cycle_id) {
|
||||
issueData.cycle_id = String(parsed.data.cycle_id)
|
||||
}
|
||||
if (parsed.data.submaster) {
|
||||
issueData.submaster = String(parsed.data.submaster)
|
||||
}
|
||||
const issueMutation = await ctx.client.Mutation(
|
||||
Mutations.Capital.UpdateIssue.mutation,
|
||||
{
|
||||
variables: {
|
||||
data: issueData,
|
||||
},
|
||||
},
|
||||
)
|
||||
const updated = issueMutation[Mutations.Capital.UpdateIssue.name]
|
||||
if (updated == null) {
|
||||
throw new Error(`Не удалось обновить задачу «${hash}» (пустой ответ мутации).`)
|
||||
}
|
||||
upsertEntry(index, {
|
||||
entity_type: 'issue',
|
||||
entity_hash: hash,
|
||||
relative_path: entry.relative_path,
|
||||
remote_updated_at: toIso(updated._updated_at),
|
||||
content_etag_local: etag,
|
||||
})
|
||||
}
|
||||
else if (type === 'story') {
|
||||
const storyQuery = await ctx.client.Query(
|
||||
Queries.Capital.GetStory.query,
|
||||
{
|
||||
variables: {
|
||||
data: { story_hash: hash },
|
||||
},
|
||||
},
|
||||
)
|
||||
const remote = storyQuery[Queries.Capital.GetStory.name]
|
||||
if (remote == null) {
|
||||
throw new Error(`Требование «${hash}» не найдено на сервере.`)
|
||||
}
|
||||
assertSameRemoteVersion(entry.remote_updated_at, toIso(remote._updated_at), `требование ${hash}`)
|
||||
const storyData: Mutations.Capital.UpdateStory.IInput['data'] = {
|
||||
story_hash: hash,
|
||||
title: String(parsed.data.title ?? ''),
|
||||
description: parsed.body,
|
||||
content_format: parsed.data.content_format as Mutations.Capital.UpdateStory.IInput['data']['content_format'],
|
||||
status: parsed.data.status as Mutations.Capital.UpdateStory.IInput['data']['status'],
|
||||
sort_order: Number(parsed.data.sort_order ?? 0),
|
||||
}
|
||||
if (parsed.data.project_hash) {
|
||||
storyData.project_hash = String(parsed.data.project_hash)
|
||||
}
|
||||
if (parsed.data.issue_hash) {
|
||||
storyData.issue_hash = String(parsed.data.issue_hash)
|
||||
}
|
||||
const storyMutation = await ctx.client.Mutation(
|
||||
Mutations.Capital.UpdateStory.mutation,
|
||||
{
|
||||
variables: {
|
||||
data: storyData,
|
||||
},
|
||||
},
|
||||
)
|
||||
const updated = storyMutation[Mutations.Capital.UpdateStory.name]
|
||||
if (updated == null) {
|
||||
throw new Error(`Не удалось обновить требование «${hash}» (пустой ответ мутации).`)
|
||||
}
|
||||
upsertEntry(index, {
|
||||
entity_type: 'story',
|
||||
entity_hash: hash,
|
||||
relative_path: entry.relative_path,
|
||||
remote_updated_at: toIso(updated._updated_at),
|
||||
content_etag_local: etag,
|
||||
})
|
||||
}
|
||||
|
||||
remaining.delete(n)
|
||||
}
|
||||
|
||||
await saveIndex(ctx.root, index)
|
||||
await saveStaging(ctx.root, { paths: [...remaining] })
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// remove: убрать пути из staging.json (аналог git restore --staged для .md).
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { loadStaging, normalizeRelativePath, saveStaging } from './index-store.js'
|
||||
|
||||
async function collectMarkdownFiles(absDir: string): Promise<string[]> {
|
||||
const out: string[] = []
|
||||
const entries = await fs.readdir(absDir, { withFileTypes: true })
|
||||
for (const e of entries) {
|
||||
const abs = path.join(absDir, e.name)
|
||||
if (e.isDirectory()) {
|
||||
const nested = await collectMarkdownFiles(abs)
|
||||
out.push(...nested)
|
||||
}
|
||||
else if (e.isFile() && e.name.endsWith('.md')) {
|
||||
out.push(abs)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export interface RunRemoveResult {
|
||||
readonly removedCount: number
|
||||
readonly remainingPaths: string[]
|
||||
readonly notStagedCount: number
|
||||
}
|
||||
|
||||
/** Полностью очистить staging. */
|
||||
export async function runClearStaging(root: string): Promise<void> {
|
||||
await saveStaging(root, { paths: [] })
|
||||
}
|
||||
|
||||
/**
|
||||
* Убрать из staging указанные файлы или все .md под каталогом (пути относительно root).
|
||||
* Пути, которых нет в staging, пропускаются (счётчик notStaged).
|
||||
*/
|
||||
export async function runRemove(root: string, targets: string[]): Promise<RunRemoveResult> {
|
||||
if (targets.length === 0) {
|
||||
throw new Error('Укажите файлы или каталоги: blago remove <путь> … либо blago remove --all')
|
||||
}
|
||||
const staging = await loadStaging(root)
|
||||
const stagedSet = new Set(staging.paths.map(p => normalizeRelativePath(p)))
|
||||
|
||||
const toRemove = new Set<string>()
|
||||
for (const t of targets) {
|
||||
const abs = path.resolve(root, t)
|
||||
let st: Awaited<ReturnType<typeof fs.stat>>
|
||||
try {
|
||||
st = await fs.stat(abs)
|
||||
}
|
||||
catch {
|
||||
continue
|
||||
}
|
||||
const files: string[] = []
|
||||
if (st.isDirectory()) {
|
||||
files.push(...(await collectMarkdownFiles(abs)))
|
||||
}
|
||||
else if (st.isFile() && path.extname(abs).toLowerCase() === '.md') {
|
||||
files.push(abs)
|
||||
}
|
||||
for (const file of files) {
|
||||
toRemove.add(normalizeRelativePath(path.relative(root, file)))
|
||||
}
|
||||
}
|
||||
|
||||
let removedCount = 0
|
||||
let notStagedCount = 0
|
||||
for (const rel of toRemove) {
|
||||
if (stagedSet.has(rel)) {
|
||||
stagedSet.delete(rel)
|
||||
removedCount += 1
|
||||
}
|
||||
else {
|
||||
notStagedCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
const remainingPaths = [...stagedSet].sort()
|
||||
await saveStaging(root, { paths: remainingPaths })
|
||||
return { removedCount, remainingPaths, notStagedCount }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Определить hash проекта/компонента Capital по пути к файлу под деревом blago.
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { parseBlagoMarkdown } from '../format/index.js'
|
||||
|
||||
async function readUtf8IfExists(abs: string): Promise<string | null> {
|
||||
try {
|
||||
return await fs.readFile(abs, 'utf8')
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResolvedProjectMarker {
|
||||
readonly hash: string
|
||||
readonly title: string
|
||||
}
|
||||
|
||||
/** Ищем ближайший `project.md` / `component.md` вверх от каталога файла. */
|
||||
export async function resolveProjectMarkerFromRelativePath(
|
||||
root: string,
|
||||
fileRel: string,
|
||||
): Promise<ResolvedProjectMarker | null> {
|
||||
let dir = path.dirname(fileRel)
|
||||
for (let depth = 0; depth < 500; depth += 1) {
|
||||
for (const name of ['component.md', 'project.md'] as const) {
|
||||
const candidateRel = path.join(dir, name)
|
||||
const abs = path.join(root, candidateRel)
|
||||
const raw = await readUtf8IfExists(abs)
|
||||
if (!raw) {
|
||||
continue
|
||||
}
|
||||
const parsed = parseBlagoMarkdown(raw)
|
||||
if (parsed.type !== 'project') {
|
||||
continue
|
||||
}
|
||||
const h = String(parsed.data.hash ?? '').trim()
|
||||
if (h) {
|
||||
const title = String(parsed.data.title ?? '').trim() || 'unnamed'
|
||||
return { hash: h, title }
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(dir)
|
||||
if (parent === dir) {
|
||||
return null
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function resolveProjectHashFromRelativePath(root: string, fileRel: string): Promise<string | null> {
|
||||
const m = await resolveProjectMarkerFromRelativePath(root, fileRel)
|
||||
return m?.hash ?? null
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
// restore: один файл с сервера по пути из индекса (как git checkout -- file из удалённого).
|
||||
|
||||
import type { AuthenticatedContext } from '../session/index.js'
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { Queries, Zeus } from '@coopenomics/sdk'
|
||||
|
||||
import {
|
||||
issueToFrontmatterAndBody,
|
||||
issueWorkspaceTitlesFromProjects,
|
||||
parseBlagoMarkdown,
|
||||
projectToFrontmatterAndBody,
|
||||
serializeBlagoMarkdown,
|
||||
storyToFrontmatterAndBody,
|
||||
} from '../format/index.js'
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
import { effectiveParentHash } from '../lib/parent-hash.js'
|
||||
import {
|
||||
projectCommunicationDayToMarkdown,
|
||||
renderCallTranscriptionMarkdown,
|
||||
type CommunicationDayLine,
|
||||
} from './communication-markdown.js'
|
||||
import {
|
||||
findByRelativePath,
|
||||
loadIndex,
|
||||
loadStaging,
|
||||
normalizeRelativePath,
|
||||
saveIndex,
|
||||
saveStaging,
|
||||
upsertEntry,
|
||||
type IndexEntry,
|
||||
type IndexFile,
|
||||
} from './index-store.js'
|
||||
import {
|
||||
generateSlug,
|
||||
issueFileRelativePath,
|
||||
type ProjectPathModel,
|
||||
storyFileRelativePath,
|
||||
workspaceBasePath,
|
||||
} from './layout.js'
|
||||
import { resolveProjectMarkerFromRelativePath } from './resolve-project-hash-from-path.js'
|
||||
|
||||
interface CapitalProjectRow {
|
||||
id?: number | null
|
||||
project_hash: string
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
coopname: string
|
||||
parent_hash?: string | null
|
||||
parent_title?: string | null
|
||||
status?: string | null
|
||||
created_at?: Date | string | null
|
||||
_updated_at?: Date | string | null
|
||||
}
|
||||
|
||||
interface CapitalIssueRow {
|
||||
id?: string | null
|
||||
issue_hash: string
|
||||
title: string
|
||||
description?: string | null
|
||||
project_hash: string
|
||||
cycle_id?: string | null
|
||||
status?: string | null
|
||||
priority?: string | null
|
||||
estimate?: number | null
|
||||
submaster?: string | null
|
||||
creators?: string[] | null
|
||||
metadata?: unknown
|
||||
_created_at?: Date | string | null
|
||||
_updated_at?: Date | string | null
|
||||
}
|
||||
|
||||
interface CapitalStoryRow {
|
||||
_id?: string | null
|
||||
story_hash: string
|
||||
title: string
|
||||
description?: string | null
|
||||
content_format?: string | null
|
||||
status?: string | null
|
||||
project_hash?: string | null
|
||||
issue_hash?: string | null
|
||||
_created_at?: Date | string | null
|
||||
_updated_at?: Date | string | null
|
||||
}
|
||||
|
||||
function toUpdatedIso(v: Date | string | null | undefined): string {
|
||||
if (v === undefined || v === null) {
|
||||
return ''
|
||||
}
|
||||
if (v instanceof Date) {
|
||||
return v.toISOString()
|
||||
}
|
||||
return new Date(v).toISOString()
|
||||
}
|
||||
|
||||
function asProjectPathModel(p: CapitalProjectRow): ProjectPathModel {
|
||||
const n = p.id
|
||||
const capital_id = typeof n === 'number' && Number.isFinite(n) ? n : 0
|
||||
return {
|
||||
project_hash: p.project_hash,
|
||||
title: p.title ?? '',
|
||||
parent_hash: p.parent_hash,
|
||||
capital_id,
|
||||
}
|
||||
}
|
||||
|
||||
/** Цепочка предков от листа к корню (для путей issue/story). */
|
||||
async function loadProjectChainFromLeaf(
|
||||
ctx: AuthenticatedContext,
|
||||
leafHash: string,
|
||||
firstFetchParent: string | undefined,
|
||||
): Promise<Map<string, CapitalProjectRow>> {
|
||||
const map = new Map<string, CapitalProjectRow>()
|
||||
let hash: string | undefined = leafHash
|
||||
let parentArg: string | undefined = firstFetchParent
|
||||
for (let i = 0; i < 64 && hash; i++) {
|
||||
const parentRef = effectiveParentHash(parentArg)
|
||||
const queryResult = await ctx.client.Query(
|
||||
Queries.Capital.GetProject.query,
|
||||
{
|
||||
variables: {
|
||||
data: {
|
||||
hash,
|
||||
parent_hash: parentRef,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
const row = queryResult[Queries.Capital.GetProject.name] as CapitalProjectRow | null | undefined
|
||||
if (!row) {
|
||||
throw new Error(`Проект «${hash}» не найден на сервере.`)
|
||||
}
|
||||
map.set(row.project_hash, row)
|
||||
const up = effectiveParentHash(row.parent_hash)
|
||||
if (!up) {
|
||||
break
|
||||
}
|
||||
hash = up
|
||||
parentArg = undefined
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function mapsFromProjectRows(rows: Map<string, CapitalProjectRow>): {
|
||||
projectByHash: Map<string, ProjectPathModel>
|
||||
projectRowByHash: Map<string, CapitalProjectRow>
|
||||
} {
|
||||
const projectByHash = new Map<string, ProjectPathModel>()
|
||||
const projectRowByHash = new Map<string, CapitalProjectRow>()
|
||||
for (const [h, r] of rows) {
|
||||
projectByHash.set(h, asProjectPathModel(r))
|
||||
projectRowByHash.set(h, r)
|
||||
}
|
||||
return { projectByHash, projectRowByHash }
|
||||
}
|
||||
|
||||
async function writeRestoredFile(params: {
|
||||
root: string
|
||||
index: IndexFile
|
||||
entry: IndexEntry
|
||||
content: string
|
||||
remoteUpdatedAt: string
|
||||
}): Promise<void> {
|
||||
const { root, index, entry, content, remoteUpdatedAt } = params
|
||||
const rel = normalizeRelativePath(entry.relative_path)
|
||||
const abs = path.join(root, rel)
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true })
|
||||
await fs.writeFile(abs, content, 'utf8')
|
||||
const etag = sha256Hex(await fs.readFile(abs, 'utf8'))
|
||||
upsertEntry(index, {
|
||||
entity_type: entry.entity_type,
|
||||
entity_hash: entry.entity_hash,
|
||||
relative_path: rel,
|
||||
remote_updated_at: remoteUpdatedAt,
|
||||
content_etag_local: etag,
|
||||
})
|
||||
}
|
||||
|
||||
async function unstagedPath(root: string, rel: string): Promise<void> {
|
||||
const staging = await loadStaging(root)
|
||||
const n = normalizeRelativePath(rel)
|
||||
const next = staging.paths.filter(p => normalizeRelativePath(p) !== n)
|
||||
if (next.length !== staging.paths.length) {
|
||||
await saveStaging(root, { paths: next.sort() })
|
||||
}
|
||||
}
|
||||
|
||||
async function projectParentHintFromDisk(absFile: string, entityHash: string): Promise<string | undefined> {
|
||||
try {
|
||||
const raw = await fs.readFile(absFile, 'utf8')
|
||||
const parsed = parseBlagoMarkdown(raw)
|
||||
if (parsed.type !== 'project') {
|
||||
return undefined
|
||||
}
|
||||
const h = String(parsed.data.hash ?? '')
|
||||
if (h !== entityHash) {
|
||||
return undefined
|
||||
}
|
||||
return effectiveParentHash(
|
||||
parsed.data.parent_hash === undefined || parsed.data.parent_hash === null
|
||||
? undefined
|
||||
: String(parsed.data.parent_hash),
|
||||
)
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function runRestore(ctx: AuthenticatedContext, userPath: string): Promise<void> {
|
||||
const rel = normalizeRelativePath(userPath)
|
||||
if (!rel || !rel.endsWith('.md')) {
|
||||
throw new Error('Укажите относительный путь к .md файлу сущности (как в индексе).')
|
||||
}
|
||||
|
||||
const index = await loadIndex(ctx.root)
|
||||
const entry = findByRelativePath(index, rel)
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Путь «${rel}» не найден в индексе (.blago/index.json). Выполните «blago pull» или проверьте путь.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (entry.entity_type === 'project') {
|
||||
const abs = path.join(ctx.root, rel)
|
||||
const hint = await projectParentHintFromDisk(abs, entry.entity_hash)
|
||||
const chain = await loadProjectChainFromLeaf(ctx, entry.entity_hash, hint)
|
||||
const row = chain.get(entry.entity_hash)
|
||||
if (!row) {
|
||||
throw new Error(`Не удалось загрузить проект «${entry.entity_hash}» с сервера.`)
|
||||
}
|
||||
const { data, body } = projectToFrontmatterAndBody(row)
|
||||
const content = serializeBlagoMarkdown(data, body)
|
||||
await writeRestoredFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entry,
|
||||
content,
|
||||
remoteUpdatedAt: toUpdatedIso(row._updated_at),
|
||||
})
|
||||
await saveIndex(ctx.root, index)
|
||||
await unstagedPath(ctx.root, rel)
|
||||
return
|
||||
}
|
||||
|
||||
if (entry.entity_type === 'issue') {
|
||||
const issueQuery = await ctx.client.Query(
|
||||
Queries.Capital.GetIssue.query,
|
||||
{
|
||||
variables: {
|
||||
data: { issue_hash: entry.entity_hash },
|
||||
},
|
||||
},
|
||||
)
|
||||
const issueRow = issueQuery[Queries.Capital.GetIssue.name] as CapitalIssueRow | null | undefined
|
||||
if (!issueRow) {
|
||||
throw new Error(`Задача «${entry.entity_hash}» не найдена на сервере.`)
|
||||
}
|
||||
const chain = await loadProjectChainFromLeaf(ctx, issueRow.project_hash, undefined)
|
||||
const { projectByHash, projectRowByHash } = mapsFromProjectRows(chain)
|
||||
const proj = projectByHash.get(issueRow.project_hash)
|
||||
if (!proj) {
|
||||
throw new Error(`Проект задачи «${issueRow.project_hash}» не найден в цепочке на сервере.`)
|
||||
}
|
||||
const basePath = workspaceBasePath(proj, projectByHash)
|
||||
const issueCapitalId
|
||||
= issueRow.id !== undefined && issueRow.id !== null && String(issueRow.id).trim() !== ''
|
||||
? String(issueRow.id)
|
||||
: issueRow.issue_hash
|
||||
const canonicalRel = issueFileRelativePath(issueRow.title, basePath, issueCapitalId)
|
||||
if (normalizeRelativePath(canonicalRel) !== rel) {
|
||||
throw new Error(
|
||||
`Канонический путь задачи на сервере «${canonicalRel}» не совпадает с «${rel}». Выполните «blago pull» для выравнивания путей.`,
|
||||
)
|
||||
}
|
||||
const workspace = issueWorkspaceTitlesFromProjects(issueRow.project_hash, projectRowByHash)
|
||||
const { data, body } = issueToFrontmatterAndBody(issueRow, workspace)
|
||||
const content = serializeBlagoMarkdown(data, body)
|
||||
await writeRestoredFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entry,
|
||||
content,
|
||||
remoteUpdatedAt: toUpdatedIso(issueRow._updated_at),
|
||||
})
|
||||
await saveIndex(ctx.root, index)
|
||||
await unstagedPath(ctx.root, rel)
|
||||
return
|
||||
}
|
||||
|
||||
if (entry.entity_type === 'story') {
|
||||
const storyQuery = await ctx.client.Query(
|
||||
Queries.Capital.GetStory.query,
|
||||
{
|
||||
variables: {
|
||||
data: { story_hash: entry.entity_hash },
|
||||
},
|
||||
},
|
||||
)
|
||||
const storyRow = storyQuery[Queries.Capital.GetStory.name] as CapitalStoryRow | null | undefined
|
||||
if (!storyRow || !storyRow.project_hash) {
|
||||
throw new Error(`Требование «${entry.entity_hash}» не найдено на сервере или без project_hash.`)
|
||||
}
|
||||
const chain = await loadProjectChainFromLeaf(ctx, storyRow.project_hash, undefined)
|
||||
const { projectByHash } = mapsFromProjectRows(chain)
|
||||
const proj = projectByHash.get(storyRow.project_hash)
|
||||
if (!proj) {
|
||||
throw new Error(`Проект требования «${storyRow.project_hash}» не найден в цепочке на сервере.`)
|
||||
}
|
||||
const basePath = workspaceBasePath(proj, projectByHash)
|
||||
let issueArg: { id: string, titleSlug: string } | undefined
|
||||
if (storyRow.issue_hash) {
|
||||
const issueQuery = await ctx.client.Query(
|
||||
Queries.Capital.GetIssue.query,
|
||||
{
|
||||
variables: {
|
||||
data: { issue_hash: storyRow.issue_hash },
|
||||
},
|
||||
},
|
||||
)
|
||||
const iss = issueQuery[Queries.Capital.GetIssue.name] as CapitalIssueRow | null | undefined
|
||||
if (iss) {
|
||||
const issueId = iss.id !== undefined && iss.id !== null && String(iss.id).trim() !== '' ? String(iss.id) : iss.issue_hash
|
||||
issueArg = {
|
||||
id: issueId,
|
||||
titleSlug: generateSlug(iss.title) || 'issue',
|
||||
}
|
||||
}
|
||||
}
|
||||
const storyRecordId
|
||||
= storyRow._id !== undefined && storyRow._id !== null && String(storyRow._id).trim() !== ''
|
||||
? String(storyRow._id)
|
||||
: storyRow.story_hash
|
||||
const canonicalRel = storyFileRelativePath(
|
||||
storyRow.title,
|
||||
basePath,
|
||||
storyRecordId,
|
||||
storyRow.story_hash,
|
||||
issueArg,
|
||||
)
|
||||
if (normalizeRelativePath(canonicalRel) !== rel) {
|
||||
throw new Error(
|
||||
`Канонический путь требования на сервере «${canonicalRel}» не совпадает с «${rel}». Выполните «blago pull» для выравнивания путей.`,
|
||||
)
|
||||
}
|
||||
const { data, body } = storyToFrontmatterAndBody(storyRow)
|
||||
const content = serializeBlagoMarkdown(data, body)
|
||||
await writeRestoredFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entry,
|
||||
content,
|
||||
remoteUpdatedAt: toUpdatedIso(storyRow._updated_at),
|
||||
})
|
||||
await saveIndex(ctx.root, index)
|
||||
await unstagedPath(ctx.root, rel)
|
||||
return
|
||||
}
|
||||
|
||||
if (entry.entity_type === 'call_transcription') {
|
||||
interface TranscriptionRestorePack {
|
||||
transcription: {
|
||||
matrixRoomId: string
|
||||
roomId: string
|
||||
startedAt: Date | string
|
||||
endedAt: Date | string | null | undefined
|
||||
updatedAt: Date | string
|
||||
status: Zeus.TranscriptionStatus
|
||||
}
|
||||
segments: {
|
||||
speakerName: string
|
||||
text: string
|
||||
startOffset: number
|
||||
endOffset: number
|
||||
}[]
|
||||
}
|
||||
const packQ = await ctx.client.Query(Queries.ChatCoop.GetTranscription.query, {
|
||||
variables: { data: { id: entry.entity_hash } },
|
||||
})
|
||||
const pack = packQ[Queries.ChatCoop.GetTranscription.name] as TranscriptionRestorePack | null | undefined
|
||||
if (!pack?.transcription) {
|
||||
throw new Error(`Транскрипция «${entry.entity_hash}» не найдена на сервере.`)
|
||||
}
|
||||
const tr = pack.transcription
|
||||
if (tr.status !== Zeus.TranscriptionStatus.COMPLETED) {
|
||||
throw new Error(`Транскрипция «${entry.entity_hash}» не в статусе COMPLETED — восстановление не поддерживается.`)
|
||||
}
|
||||
const content = renderCallTranscriptionMarkdown(
|
||||
{
|
||||
matrixRoomId: tr.matrixRoomId,
|
||||
roomId: tr.roomId,
|
||||
startedAt: tr.startedAt,
|
||||
endedAt: tr.endedAt,
|
||||
},
|
||||
pack.segments.map(s => ({
|
||||
speakerName: s.speakerName,
|
||||
text: s.text,
|
||||
startOffset: s.startOffset,
|
||||
endOffset: s.endOffset,
|
||||
})),
|
||||
)
|
||||
const remoteAt = tr.endedAt !== undefined && tr.endedAt !== null ? toUpdatedIso(tr.endedAt) : toUpdatedIso(tr.updatedAt)
|
||||
await writeRestoredFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entry,
|
||||
content,
|
||||
remoteUpdatedAt: remoteAt,
|
||||
})
|
||||
await saveIndex(ctx.root, index)
|
||||
await unstagedPath(ctx.root, rel)
|
||||
return
|
||||
}
|
||||
|
||||
if (entry.entity_type === 'room_message_day') {
|
||||
const base = path.basename(rel)
|
||||
const dm = /^(\d{4}-\d{2}-\d{2})\.md$/.exec(base)
|
||||
if (!dm) {
|
||||
throw new Error(`Ожидался файл вида YYYY-MM-DD.md в messages/, получено: «${base}»`)
|
||||
}
|
||||
const utcDate = dm[1]
|
||||
const marker = await resolveProjectMarkerFromRelativePath(ctx.root, rel)
|
||||
if (!marker) {
|
||||
throw new Error('Не удалось найти project.md / component.md над файлом переписки.')
|
||||
}
|
||||
interface CommRoomRow {
|
||||
matrixRoomId: string
|
||||
displayLabel: string
|
||||
}
|
||||
interface CommLineRow {
|
||||
originServerTs: number
|
||||
authorLabel: string
|
||||
coopUsername: string | null | undefined
|
||||
kind: string
|
||||
bodyText: string
|
||||
}
|
||||
const listRoomsKey = Queries.ChatCoop.ListProjectCommunicationRooms.name
|
||||
const roomsQ = await ctx.client.Query(Queries.ChatCoop.ListProjectCommunicationRooms.query, {
|
||||
variables: { data: { projectHash: marker.hash } },
|
||||
})
|
||||
const rooms = (roomsQ as Record<string, CommRoomRow[]>)[listRoomsKey] ?? []
|
||||
const getMsgKey = Queries.ChatCoop.GetRoomMessagesForUtcDate.name
|
||||
const sections = await Promise.all(
|
||||
rooms.map(async (room: CommRoomRow) => {
|
||||
const mq = await ctx.client.Query(Queries.ChatCoop.GetRoomMessagesForUtcDate.query, {
|
||||
variables: { data: { matrixRoomId: room.matrixRoomId, utcDate } },
|
||||
})
|
||||
const linesRaw = (mq as Record<string, CommLineRow[]>)[getMsgKey] ?? []
|
||||
const lines: CommunicationDayLine[] = linesRaw.map((m: CommLineRow) => ({
|
||||
originServerTs: m.originServerTs,
|
||||
authorLabel: m.authorLabel,
|
||||
coopUsername: m.coopUsername,
|
||||
kind: String(m.kind),
|
||||
bodyText: m.bodyText,
|
||||
}))
|
||||
return {
|
||||
displayLabel: room.displayLabel,
|
||||
matrixRoomId: room.matrixRoomId,
|
||||
lines,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const content = projectCommunicationDayToMarkdown(marker.title, marker.hash, utcDate, sections)
|
||||
await writeRestoredFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entry,
|
||||
content,
|
||||
remoteUpdatedAt: `${utcDate}T23:59:59.999Z`,
|
||||
})
|
||||
await saveIndex(ctx.root, index)
|
||||
await unstagedPath(ctx.root, rel)
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(`Неизвестный тип сущности в индексе: ${entry.entity_type}`)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// staging + список путей, у которых sha256 ≠ content_etag_local в индексе.
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
import { info } from '../ui/output.js'
|
||||
|
||||
import { loadIndex, loadStaging, normalizeRelativePath } from './index-store.js'
|
||||
|
||||
export async function runStatus(root: string): Promise<void> {
|
||||
const staging = await loadStaging(root)
|
||||
const index = await loadIndex(root)
|
||||
|
||||
info('В индексе для отправки (staging):')
|
||||
if (staging.paths.length === 0) {
|
||||
info(' (пусто)')
|
||||
}
|
||||
else {
|
||||
for (const p of staging.paths) {
|
||||
info(` + ${normalizeRelativePath(p)}`)
|
||||
}
|
||||
}
|
||||
|
||||
info('Изменённые относительно последней синхронизации (по содержимому):')
|
||||
let any = false
|
||||
for (const e of index.entries) {
|
||||
const abs = path.join(root, e.relative_path)
|
||||
try {
|
||||
const raw = await fs.readFile(abs, 'utf8')
|
||||
const h = sha256Hex(raw)
|
||||
if (h !== e.content_etag_local) {
|
||||
info(` M ${e.relative_path} [${e.entity_type} ${e.entity_hash}]`)
|
||||
any = true
|
||||
}
|
||||
}
|
||||
catch {
|
||||
info(` ? ${e.relative_path} (файл отсутствует)`)
|
||||
any = true
|
||||
}
|
||||
}
|
||||
if (!any) {
|
||||
info(' (нет расхождений)')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Запись одной сущности при pull: путь, индекс, «грязный» локальный файл.
|
||||
|
||||
import type { BlagoEntityType, IndexFile } from './index-store.js'
|
||||
import * as fs from 'node:fs/promises'
|
||||
|
||||
import * as path from 'node:path'
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
|
||||
import { warn } from '../ui/output.js'
|
||||
import { findByHash, normalizeRelativePath, upsertEntry } from './index-store.js'
|
||||
|
||||
async function ensureDirForFile(absFile: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(absFile), { recursive: true })
|
||||
}
|
||||
|
||||
async function fileExists(abs: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(abs)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function readFileIfExists(abs: string): Promise<string | null> {
|
||||
try {
|
||||
return await fs.readFile(abs, 'utf8')
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Один файл сущности: новый путь с сервера vs индекс; «грязный» локально → не затирать без явного сценария. */
|
||||
export async function syncEntityFile(params: {
|
||||
root: string
|
||||
index: IndexFile
|
||||
entityType: BlagoEntityType
|
||||
entityHash: string
|
||||
relativePath: string
|
||||
content: string
|
||||
remoteUpdatedAt: string
|
||||
label: string
|
||||
}): Promise<void> {
|
||||
const { root, index, entityType, entityHash, relativePath, content, remoteUpdatedAt, label } = params
|
||||
const rel = normalizeRelativePath(relativePath)
|
||||
const absNew = path.join(root, rel)
|
||||
const prev = findByHash(index, entityType, entityHash)
|
||||
|
||||
if (!prev) {
|
||||
await ensureDirForFile(absNew)
|
||||
await fs.writeFile(absNew, content, 'utf8')
|
||||
const etag = sha256Hex(await fs.readFile(absNew, 'utf8'))
|
||||
upsertEntry(index, {
|
||||
entity_type: entityType,
|
||||
entity_hash: entityHash,
|
||||
relative_path: rel,
|
||||
remote_updated_at: remoteUpdatedAt,
|
||||
content_etag_local: etag,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const absOld = path.join(root, prev.relative_path)
|
||||
|
||||
if (prev.relative_path !== rel) {
|
||||
const oldContent = await readFileIfExists(absOld)
|
||||
const dirty
|
||||
= oldContent !== null && oldContent !== undefined && sha256Hex(oldContent) !== prev.content_etag_local
|
||||
|
||||
if (dirty) {
|
||||
await ensureDirForFile(absNew)
|
||||
if (await fileExists(absOld)) {
|
||||
await fs.rename(absOld, absNew)
|
||||
}
|
||||
else {
|
||||
await fs.writeFile(absNew, content, 'utf8')
|
||||
}
|
||||
upsertEntry(index, {
|
||||
entity_type: entityType,
|
||||
entity_hash: entityHash,
|
||||
relative_path: rel,
|
||||
remote_updated_at: remoteUpdatedAt,
|
||||
content_etag_local: sha256Hex((await readFileIfExists(absNew)) ?? ''),
|
||||
})
|
||||
warn(
|
||||
`Переименование на сервере: ${label} перенесён на «${rel}» с сохранением локальных правок; проверьте frontmatter (title / updated_at).`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
await ensureDirForFile(absNew)
|
||||
await fs.writeFile(absNew, content, 'utf8')
|
||||
if ((await fileExists(absOld)) && path.resolve(absOld) !== path.resolve(absNew)) {
|
||||
await fs.unlink(absOld)
|
||||
}
|
||||
const etagAfterRename = sha256Hex(await fs.readFile(absNew, 'utf8'))
|
||||
upsertEntry(index, {
|
||||
entity_type: entityType,
|
||||
entity_hash: entityHash,
|
||||
relative_path: rel,
|
||||
remote_updated_at: remoteUpdatedAt,
|
||||
content_etag_local: etagAfterRename,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const current = await readFileIfExists(absNew)
|
||||
const dirty
|
||||
= current !== null && current !== undefined && sha256Hex(current) !== prev.content_etag_local
|
||||
if (dirty && remoteUpdatedAt !== prev.remote_updated_at) {
|
||||
warn(
|
||||
`Пропуск перезаписи ${label}: есть локальные правки, на сервере новая версия. Смержите вручную или откатите файл, затем снова «blago pull».`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
await ensureDirForFile(absNew)
|
||||
await fs.writeFile(absNew, content, 'utf8')
|
||||
const etagOnDisk = sha256Hex(await fs.readFile(absNew, 'utf8'))
|
||||
upsertEntry(index, {
|
||||
entity_type: entityType,
|
||||
entity_hash: entityHash,
|
||||
relative_path: rel,
|
||||
remote_updated_at: remoteUpdatedAt,
|
||||
content_etag_local: etagOnDisk,
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user