Commit 83523ede628fe98e910a41e1711ae8d94892309b

Authored by yibo
1 parent 905894be

feat(devloop): P0.0 services layer — 7 services + 71 passing unit tests

First concrete code commit for DevLoop. Implements all 7 services that
P0.A tools will depend on, per plan-migration P0.0.

Services (server/services/devloop/, ~900 lines):
- path-resolver.js   foundation — resolveProjectRoot + 12 derived paths;
                     sandbox + linked-new mode support; sensitive path
                     blacklist (with /tmp + os.tmpdir() exemptions);
                     ~/.devloop/linked-projects.json registry
- doc-store.js       PRD/SDD multi-file site IO + cheerio validateHTML +
                     validateUnitsSchema + canonical-JSON hashContract
                     + automatic history/ archival
- unit-registry.js   FSM (M-2: pending→coding→testing→passed/failed/blocked);
                     l2GateOpen; diffUnits (added/changed/removed);
                     reset semantics for L2 + L3
- bug-tracker.js     bugs.ndjson append-only + dedupKey + P-9 transition
                     state machine + list/stats/history aggregation
- timeline-logger.js M-13 ProcessTimeline — append/read/tail;
                     summary truncate to 280; payload oversize → ref form
- changelog-writer.js M-15 — append/read/latest for prd/sdd/units revs
- sse-broker.js      per-pid pub/sub + 25s heartbeat + stream.log replay
                     via Last-Event-ID; emit returns monotonic id
- git-vault.js       spawn-git wrapper — init/commit/tag/log/diff/status/
                     resetWorkingTree/revert/archive; per-pid mutex;
                     nothing-to-commit returns {skipped:true} not throw

Tests (server/tests/devloop/, 71 passing):
- path-resolver.test.js  (17 cases) — sensitive path · sandbox alloc · linked-new
- doc-store.test.js      (12 cases) — validateHTML · validateUnitsSchema · hashContract
- unit-registry.test.js  (15 cases) — FSM transitions · l2GateOpen · diffUnits
- bug-tracker.test.js    (10 cases) — open dedup · transition · list filter · history
- timeline-logger.test.js (9 cases) — append/read/tail · summary truncate · oversize
- git-vault.test.js      (8 cases)  — init idempotent · commit · tag · status · archive

vitest config (`globals: true`) means tests use describe/it/expect globally,
no `require('vitest')` needed (and would fail under vitest 4).

Dep added: cheerio@^1.0.0 (used by docStore.validateHTML; HTML parsing).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
package.json
@@ -65,6 +65,7 @@ @@ -65,6 +65,7 @@
65 "ali-oss": "^6.23.0", 65 "ali-oss": "^6.23.0",
66 "archiver": "^7.0.1", 66 "archiver": "^7.0.1",
67 "better-sqlite3": "^12.8.0", 67 "better-sqlite3": "^12.8.0",
  68 + "cheerio": "^1.0.0",
68 "express-fileupload": "^1.5.2", 69 "express-fileupload": "^1.5.2",
69 "mammoth": "^1.11.0", 70 "mammoth": "^1.11.0",
70 "module-alias": "^2.3.4", 71 "module-alias": "^2.3.4",
pnpm-lock.yaml
@@ -32,6 +32,9 @@ importers: @@ -32,6 +32,9 @@ importers:
32 better-sqlite3: 32 better-sqlite3:
33 specifier: ^12.8.0 33 specifier: ^12.8.0
34 version: 12.8.0 34 version: 12.8.0
  35 + cheerio:
  36 + specifier: ^1.0.0
  37 + version: 1.2.0
35 express-fileupload: 38 express-fileupload:
36 specifier: ^1.5.2 39 specifier: ^1.5.2
37 version: 1.5.2 40 version: 1.5.2
@@ -769,6 +772,9 @@ packages: @@ -769,6 +772,9 @@ packages:
769 '@protobufjs/codegen@2.0.4': 772 '@protobufjs/codegen@2.0.4':
770 resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} 773 resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==}
771 774
  775 + '@protobufjs/codegen@2.0.5':
  776 + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==}
  777 +
772 '@protobufjs/eventemitter@1.1.0': 778 '@protobufjs/eventemitter@1.1.0':
773 resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} 779 resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==}
774 780
@@ -781,6 +787,9 @@ packages: @@ -781,6 +787,9 @@ packages:
781 '@protobufjs/inquire@1.1.0': 787 '@protobufjs/inquire@1.1.0':
782 resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} 788 resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==}
783 789
  790 + '@protobufjs/inquire@1.1.1':
  791 + resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==}
  792 +
784 '@protobufjs/path@1.1.2': 793 '@protobufjs/path@1.1.2':
785 resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} 794 resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
786 795
@@ -790,6 +799,9 @@ packages: @@ -790,6 +799,9 @@ packages:
790 '@protobufjs/utf8@1.1.0': 799 '@protobufjs/utf8@1.1.0':
791 resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} 800 resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
792 801
  802 + '@protobufjs/utf8@1.1.1':
  803 + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==}
  804 +
793 '@rollup/rollup-android-arm-eabi@4.57.1': 805 '@rollup/rollup-android-arm-eabi@4.57.1':
794 resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} 806 resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==}
795 cpu: [arm] 807 cpu: [arm]
@@ -943,15 +955,9 @@ packages: @@ -943,15 +955,9 @@ packages:
943 '@types/estree@1.0.8': 955 '@types/estree@1.0.8':
944 resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 956 resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
945 957
946 - '@types/long@4.0.2':  
947 - resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==}  
948 -  
949 '@types/ms@2.1.0': 958 '@types/ms@2.1.0':
950 resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} 959 resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
951 960
952 - '@types/node@10.17.60':  
953 - resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}  
954 -  
955 '@types/node@25.3.2': 961 '@types/node@25.3.2':
956 resolution: {integrity: sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==} 962 resolution: {integrity: sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==}
957 963
@@ -1042,10 +1048,6 @@ packages: @@ -1042,10 +1048,6 @@ packages:
1042 link-preview-js: 1048 link-preview-js:
1043 optional: true 1049 optional: true
1044 1050
1045 - '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67':  
1046 - resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67}  
1047 - version: 2.0.1  
1048 -  
1049 '@xmldom/xmldom@0.8.11': 1051 '@xmldom/xmldom@0.8.11':
1050 resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} 1052 resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
1051 engines: {node: '>=10.0.0'} 1053 engines: {node: '>=10.0.0'}
@@ -1219,6 +1221,9 @@ packages: @@ -1219,6 +1221,9 @@ packages:
1219 resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} 1221 resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==}
1220 engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} 1222 engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
1221 1223
  1224 + boolbase@1.0.0:
  1225 + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
  1226 +
1222 bowser@1.9.4: 1227 bowser@1.9.4:
1223 resolution: {integrity: sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==} 1228 resolution: {integrity: sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==}
1224 1229
@@ -1275,6 +1280,13 @@ packages: @@ -1275,6 +1280,13 @@ packages:
1275 resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} 1280 resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
1276 engines: {node: '>=10'} 1281 engines: {node: '>=10'}
1277 1282
  1283 + cheerio-select@2.1.0:
  1284 + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
  1285 +
  1286 + cheerio@1.2.0:
  1287 + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==}
  1288 + engines: {node: '>=20.18.1'}
  1289 +
1278 chownr@1.1.4: 1290 chownr@1.1.4:
1279 resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} 1291 resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
1280 1292
@@ -1360,6 +1372,13 @@ packages: @@ -1360,6 +1372,13 @@ packages:
1360 resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} 1372 resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
1361 engines: {node: '>= 8'} 1373 engines: {node: '>= 8'}
1362 1374
  1375 + css-select@5.2.2:
  1376 + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
  1377 +
  1378 + css-what@6.2.2:
  1379 + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
  1380 + engines: {node: '>= 6'}
  1381 +
1363 csstype@3.2.3: 1382 csstype@3.2.3:
1364 resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} 1383 resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
1365 1384
@@ -1445,6 +1464,19 @@ packages: @@ -1445,6 +1464,19 @@ packages:
1445 dom-scroll-into-view@2.0.1: 1464 dom-scroll-into-view@2.0.1:
1446 resolution: {integrity: sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==} 1465 resolution: {integrity: sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==}
1447 1466
  1467 + dom-serializer@2.0.0:
  1468 + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
  1469 +
  1470 + domelementtype@2.3.0:
  1471 + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
  1472 +
  1473 + domhandler@5.0.3:
  1474 + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
  1475 + engines: {node: '>= 4'}
  1476 +
  1477 + domutils@3.2.2:
  1478 + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
  1479 +
1448 dotenv@16.6.1: 1480 dotenv@16.6.1:
1449 resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} 1481 resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
1450 engines: {node: '>=12'} 1482 engines: {node: '>=12'}
@@ -1478,6 +1510,9 @@ packages: @@ -1478,6 +1510,9 @@ packages:
1478 resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} 1510 resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
1479 engines: {node: '>= 0.8'} 1511 engines: {node: '>= 0.8'}
1480 1512
  1513 + encoding-sniffer@0.2.1:
  1514 + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==}
  1515 +
1481 end-of-stream@1.4.5: 1516 end-of-stream@1.4.5:
1482 resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} 1517 resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
1483 1518
@@ -1485,6 +1520,14 @@ packages: @@ -1485,6 +1520,14 @@ packages:
1485 resolution: {integrity: sha512-OclLMSug+k2A0JKuf494im25ANRBVW8qsjmwbgX7lQ8P82H21PQ1PWkoYwb9y5yMBS69BPlwtzdIFClo3+7kOQ==} 1520 resolution: {integrity: sha512-OclLMSug+k2A0JKuf494im25ANRBVW8qsjmwbgX7lQ8P82H21PQ1PWkoYwb9y5yMBS69BPlwtzdIFClo3+7kOQ==}
1486 engines: {node: '>= 0.11.14'} 1521 engines: {node: '>= 0.11.14'}
1487 1522
  1523 + entities@4.5.0:
  1524 + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
  1525 + engines: {node: '>=0.12'}
  1526 +
  1527 + entities@6.0.1:
  1528 + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
  1529 + engines: {node: '>=0.12'}
  1530 +
1488 entities@7.0.1: 1531 entities@7.0.1:
1489 resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} 1532 resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
1490 engines: {node: '>=0.12'} 1533 engines: {node: '>=0.12'}
@@ -1700,6 +1743,9 @@ packages: @@ -1700,6 +1743,9 @@ packages:
1700 hookified@1.15.1: 1743 hookified@1.15.1:
1701 resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} 1744 resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==}
1702 1745
  1746 + htmlparser2@10.1.0:
  1747 + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
  1748 +
1703 http-errors@2.0.1: 1749 http-errors@2.0.1:
1704 resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} 1750 resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
1705 engines: {node: '>= 0.8'} 1751 engines: {node: '>= 0.8'}
@@ -1805,6 +1851,10 @@ packages: @@ -1805,6 +1851,10 @@ packages:
1805 resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} 1851 resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
1806 engines: {node: '>= 0.6.3'} 1852 engines: {node: '>= 0.6.3'}
1807 1853
  1854 + libsignal@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7:
  1855 + resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7}
  1856 + version: 6.0.0
  1857 +
1808 lie@3.3.0: 1858 lie@3.3.0:
1809 resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} 1859 resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
1810 1860
@@ -1848,9 +1898,6 @@ packages: @@ -1848,9 +1898,6 @@ packages:
1848 lodash@4.17.23: 1898 lodash@4.17.23:
1849 resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} 1899 resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
1850 1900
1851 - long@4.0.0:  
1852 - resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==}  
1853 -  
1854 long@5.3.2: 1901 long@5.3.2:
1855 resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} 1902 resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
1856 1903
@@ -2011,6 +2058,9 @@ packages: @@ -2011,6 +2058,9 @@ packages:
2011 resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} 2058 resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
2012 engines: {node: '>=0.10.0'} 2059 engines: {node: '>=0.10.0'}
2013 2060
  2061 + nth-check@2.1.1:
  2062 + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
  2063 +
2014 object-assign@4.1.1: 2064 object-assign@4.1.1:
2015 resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} 2065 resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
2016 engines: {node: '>=0.10.0'} 2066 engines: {node: '>=0.10.0'}
@@ -2072,6 +2122,15 @@ packages: @@ -2072,6 +2122,15 @@ packages:
2072 pako@1.0.11: 2122 pako@1.0.11:
2073 resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} 2123 resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
2074 2124
  2125 + parse5-htmlparser2-tree-adapter@7.1.0:
  2126 + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
  2127 +
  2128 + parse5-parser-stream@7.1.2:
  2129 + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==}
  2130 +
  2131 + parse5@7.3.0:
  2132 + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
  2133 +
2075 parseurl@1.3.3: 2134 parseurl@1.3.3:
2076 resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} 2135 resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
2077 engines: {node: '>= 0.8'} 2136 engines: {node: '>= 0.8'}
@@ -2171,14 +2230,14 @@ packages: @@ -2171,14 +2230,14 @@ packages:
2171 resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} 2230 resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
2172 engines: {node: '>= 0.6.0'} 2231 engines: {node: '>= 0.6.0'}
2173 2232
2174 - protobufjs@6.8.8:  
2175 - resolution: {integrity: sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==}  
2176 - hasBin: true  
2177 -  
2178 protobufjs@7.5.4: 2233 protobufjs@7.5.4:
2179 resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} 2234 resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
2180 engines: {node: '>=12.0.0'} 2235 engines: {node: '>=12.0.0'}
2181 2236
  2237 + protobufjs@7.5.8:
  2238 + resolution: {integrity: sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==}
  2239 + engines: {node: '>=12.0.0'}
  2240 +
2182 proxy-addr@2.0.7: 2241 proxy-addr@2.0.7:
2183 resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} 2242 resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
2184 engines: {node: '>= 0.10'} 2243 engines: {node: '>= 0.10'}
@@ -2577,6 +2636,10 @@ packages: @@ -2577,6 +2636,10 @@ packages:
2577 undici-types@7.18.2: 2636 undici-types@7.18.2:
2578 resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} 2637 resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
2579 2638
  2639 + undici@7.25.0:
  2640 + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
  2641 + engines: {node: '>=20.18.1'}
  2642 +
2580 unescape@1.0.1: 2643 unescape@1.0.1:
2581 resolution: {integrity: sha512-O0+af1Gs50lyH1nUu3ZyYS1cRh01Q/kUKatTOkSs7jukXE6/NebucDVxyiDsA9AQ4JC1V1jUH9EO8JX2nMDgGQ==} 2644 resolution: {integrity: sha512-O0+af1Gs50lyH1nUu3ZyYS1cRh01Q/kUKatTOkSs7jukXE6/NebucDVxyiDsA9AQ4JC1V1jUH9EO8JX2nMDgGQ==}
2582 engines: {node: '>=0.10.0'} 2645 engines: {node: '>=0.10.0'}
@@ -2755,6 +2818,15 @@ packages: @@ -2755,6 +2818,15 @@ packages:
2755 warning@4.0.3: 2818 warning@4.0.3:
2756 resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} 2819 resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
2757 2820
  2821 + whatwg-encoding@3.1.1:
  2822 + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
  2823 + engines: {node: '>=18'}
  2824 + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
  2825 +
  2826 + whatwg-mimetype@4.0.0:
  2827 + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
  2828 + engines: {node: '>=18'}
  2829 +
2758 which-module@2.0.1: 2830 which-module@2.0.1:
2759 resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} 2831 resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
2760 2832
@@ -3261,6 +3333,8 @@ snapshots: @@ -3261,6 +3333,8 @@ snapshots:
3261 3333
3262 '@protobufjs/codegen@2.0.4': {} 3334 '@protobufjs/codegen@2.0.4': {}
3263 3335
  3336 + '@protobufjs/codegen@2.0.5': {}
  3337 +
3264 '@protobufjs/eventemitter@1.1.0': {} 3338 '@protobufjs/eventemitter@1.1.0': {}
3265 3339
3266 '@protobufjs/fetch@1.1.0': 3340 '@protobufjs/fetch@1.1.0':
@@ -3272,12 +3346,16 @@ snapshots: @@ -3272,12 +3346,16 @@ snapshots:
3272 3346
3273 '@protobufjs/inquire@1.1.0': {} 3347 '@protobufjs/inquire@1.1.0': {}
3274 3348
  3349 + '@protobufjs/inquire@1.1.1': {}
  3350 +
3275 '@protobufjs/path@1.1.2': {} 3351 '@protobufjs/path@1.1.2': {}
3276 3352
3277 '@protobufjs/pool@1.1.0': {} 3353 '@protobufjs/pool@1.1.0': {}
3278 3354
3279 '@protobufjs/utf8@1.1.0': {} 3355 '@protobufjs/utf8@1.1.0': {}
3280 3356
  3357 + '@protobufjs/utf8@1.1.1': {}
  3358 +
3281 '@rollup/rollup-android-arm-eabi@4.57.1': 3359 '@rollup/rollup-android-arm-eabi@4.57.1':
3282 optional: true 3360 optional: true
3283 3361
@@ -3384,12 +3462,8 @@ snapshots: @@ -3384,12 +3462,8 @@ snapshots:
3384 3462
3385 '@types/estree@1.0.8': {} 3463 '@types/estree@1.0.8': {}
3386 3464
3387 - '@types/long@4.0.2': {}  
3388 -  
3389 '@types/ms@2.1.0': {} 3465 '@types/ms@2.1.0': {}
3390 3466
3391 - '@types/node@10.17.60': {}  
3392 -  
3393 '@types/node@25.3.2': 3467 '@types/node@25.3.2':
3394 dependencies: 3468 dependencies:
3395 undici-types: 7.18.2 3469 undici-types: 7.18.2
@@ -3501,7 +3575,7 @@ snapshots: @@ -3501,7 +3575,7 @@ snapshots:
3501 '@cacheable/node-cache': 1.7.6 3575 '@cacheable/node-cache': 1.7.6
3502 '@hapi/boom': 9.1.4 3576 '@hapi/boom': 9.1.4
3503 async-mutex: 0.5.0 3577 async-mutex: 0.5.0
3504 - libsignal: '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67' 3578 + libsignal: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7
3505 lru-cache: 11.2.6 3579 lru-cache: 11.2.6
3506 music-metadata: 11.12.1 3580 music-metadata: 11.12.1
3507 p-queue: 9.1.0 3581 p-queue: 9.1.0
@@ -3514,11 +3588,6 @@ snapshots: @@ -3514,11 +3588,6 @@ snapshots:
3514 - supports-color 3588 - supports-color
3515 - utf-8-validate 3589 - utf-8-validate
3516 3590
3517 - '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67':  
3518 - dependencies:  
3519 - curve25519-js: 0.0.4  
3520 - protobufjs: 6.8.8  
3521 -  
3522 '@xmldom/xmldom@0.8.11': {} 3591 '@xmldom/xmldom@0.8.11': {}
3523 3592
3524 abort-controller@3.0.0: 3593 abort-controller@3.0.0:
@@ -3740,6 +3809,8 @@ snapshots: @@ -3740,6 +3809,8 @@ snapshots:
3740 transitivePeerDependencies: 3809 transitivePeerDependencies:
3741 - supports-color 3810 - supports-color
3742 3811
  3812 + boolbase@1.0.0: {}
  3813 +
3743 bowser@1.9.4: {} 3814 bowser@1.9.4: {}
3744 3815
3745 brace-expansion@2.0.2: 3816 brace-expansion@2.0.2:
@@ -3797,6 +3868,29 @@ snapshots: @@ -3797,6 +3868,29 @@ snapshots:
3797 ansi-styles: 4.3.0 3868 ansi-styles: 4.3.0
3798 supports-color: 7.2.0 3869 supports-color: 7.2.0
3799 3870
  3871 + cheerio-select@2.1.0:
  3872 + dependencies:
  3873 + boolbase: 1.0.0
  3874 + css-select: 5.2.2
  3875 + css-what: 6.2.2
  3876 + domelementtype: 2.3.0
  3877 + domhandler: 5.0.3
  3878 + domutils: 3.2.2
  3879 +
  3880 + cheerio@1.2.0:
  3881 + dependencies:
  3882 + cheerio-select: 2.1.0
  3883 + dom-serializer: 2.0.0
  3884 + domhandler: 5.0.3
  3885 + domutils: 3.2.2
  3886 + encoding-sniffer: 0.2.1
  3887 + htmlparser2: 10.1.0
  3888 + parse5: 7.3.0
  3889 + parse5-htmlparser2-tree-adapter: 7.1.0
  3890 + parse5-parser-stream: 7.1.2
  3891 + undici: 7.25.0
  3892 + whatwg-mimetype: 4.0.0
  3893 +
3800 chownr@1.1.4: {} 3894 chownr@1.1.4: {}
3801 3895
3802 cliui@6.0.0: 3896 cliui@6.0.0:
@@ -3891,6 +3985,16 @@ snapshots: @@ -3891,6 +3985,16 @@ snapshots:
3891 shebang-command: 2.0.0 3985 shebang-command: 2.0.0
3892 which: 2.0.2 3986 which: 2.0.2
3893 3987
  3988 + css-select@5.2.2:
  3989 + dependencies:
  3990 + boolbase: 1.0.0
  3991 + css-what: 6.2.2
  3992 + domhandler: 5.0.3
  3993 + domutils: 3.2.2
  3994 + nth-check: 2.1.1
  3995 +
  3996 + css-what@6.2.2: {}
  3997 +
3894 csstype@3.2.3: {} 3998 csstype@3.2.3: {}
3895 3999
3896 curve25519-js@0.0.4: {} 4000 curve25519-js@0.0.4: {}
@@ -3943,6 +4047,24 @@ snapshots: @@ -3943,6 +4047,24 @@ snapshots:
3943 4047
3944 dom-scroll-into-view@2.0.1: {} 4048 dom-scroll-into-view@2.0.1: {}
3945 4049
  4050 + dom-serializer@2.0.0:
  4051 + dependencies:
  4052 + domelementtype: 2.3.0
  4053 + domhandler: 5.0.3
  4054 + entities: 4.5.0
  4055 +
  4056 + domelementtype@2.3.0: {}
  4057 +
  4058 + domhandler@5.0.3:
  4059 + dependencies:
  4060 + domelementtype: 2.3.0
  4061 +
  4062 + domutils@3.2.2:
  4063 + dependencies:
  4064 + dom-serializer: 2.0.0
  4065 + domelementtype: 2.3.0
  4066 + domhandler: 5.0.3
  4067 +
3946 dotenv@16.6.1: {} 4068 dotenv@16.6.1: {}
3947 4069
3948 dottie@2.0.7: {} 4070 dottie@2.0.7: {}
@@ -3971,12 +4093,21 @@ snapshots: @@ -3971,12 +4093,21 @@ snapshots:
3971 4093
3972 encodeurl@2.0.0: {} 4094 encodeurl@2.0.0: {}
3973 4095
  4096 + encoding-sniffer@0.2.1:
  4097 + dependencies:
  4098 + iconv-lite: 0.6.3
  4099 + whatwg-encoding: 3.1.1
  4100 +
3974 end-of-stream@1.4.5: 4101 end-of-stream@1.4.5:
3975 dependencies: 4102 dependencies:
3976 once: 1.4.0 4103 once: 1.4.0
3977 4104
3978 end-or-error@1.0.1: {} 4105 end-or-error@1.0.1: {}
3979 4106
  4107 + entities@4.5.0: {}
  4108 +
  4109 + entities@6.0.1: {}
  4110 +
3980 entities@7.0.1: {} 4111 entities@7.0.1: {}
3981 4112
3982 es-define-property@1.0.1: {} 4113 es-define-property@1.0.1: {}
@@ -4254,6 +4385,13 @@ snapshots: @@ -4254,6 +4385,13 @@ snapshots:
4254 4385
4255 hookified@1.15.1: {} 4386 hookified@1.15.1: {}
4256 4387
  4388 + htmlparser2@10.1.0:
  4389 + dependencies:
  4390 + domelementtype: 2.3.0
  4391 + domhandler: 5.0.3
  4392 + domutils: 3.2.2
  4393 + entities: 7.0.1
  4394 +
4257 http-errors@2.0.1: 4395 http-errors@2.0.1:
4258 dependencies: 4396 dependencies:
4259 depd: 2.0.0 4397 depd: 2.0.0
@@ -4365,6 +4503,11 @@ snapshots: @@ -4365,6 +4503,11 @@ snapshots:
4365 dependencies: 4503 dependencies:
4366 readable-stream: 2.3.8 4504 readable-stream: 2.3.8
4367 4505
  4506 + libsignal@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7:
  4507 + dependencies:
  4508 + curve25519-js: 0.0.4
  4509 + protobufjs: 7.5.8
  4510 +
4368 lie@3.3.0: 4511 lie@3.3.0:
4369 dependencies: 4512 dependencies:
4370 immediate: 3.0.6 4513 immediate: 3.0.6
@@ -4397,8 +4540,6 @@ snapshots: @@ -4397,8 +4540,6 @@ snapshots:
4397 4540
4398 lodash@4.17.23: {} 4541 lodash@4.17.23: {}
4399 4542
4400 - long@4.0.0: {}  
4401 -  
4402 long@5.3.2: {} 4543 long@5.3.2: {}
4403 4544
4404 loose-envify@1.4.0: 4545 loose-envify@1.4.0:
@@ -4548,6 +4689,10 @@ snapshots: @@ -4548,6 +4689,10 @@ snapshots:
4548 4689
4549 normalize-path@3.0.0: {} 4690 normalize-path@3.0.0: {}
4550 4691
  4692 + nth-check@2.1.1:
  4693 + dependencies:
  4694 + boolbase: 1.0.0
  4695 +
4551 object-assign@4.1.1: {} 4696 object-assign@4.1.1: {}
4552 4697
4553 object-inspect@1.13.4: {} 4698 object-inspect@1.13.4: {}
@@ -4596,6 +4741,19 @@ snapshots: @@ -4596,6 +4741,19 @@ snapshots:
4596 4741
4597 pako@1.0.11: {} 4742 pako@1.0.11: {}
4598 4743
  4744 + parse5-htmlparser2-tree-adapter@7.1.0:
  4745 + dependencies:
  4746 + domhandler: 5.0.3
  4747 + parse5: 7.3.0
  4748 +
  4749 + parse5-parser-stream@7.1.2:
  4750 + dependencies:
  4751 + parse5: 7.3.0
  4752 +
  4753 + parse5@7.3.0:
  4754 + dependencies:
  4755 + entities: 6.0.1
  4756 +
4599 parseurl@1.3.3: {} 4757 parseurl@1.3.3: {}
4600 4758
4601 path-exists@4.0.0: {} 4759 path-exists@4.0.0: {}
@@ -4697,7 +4855,7 @@ snapshots: @@ -4697,7 +4855,7 @@ snapshots:
4697 4855
4698 process@0.11.10: {} 4856 process@0.11.10: {}
4699 4857
4700 - protobufjs@6.8.8: 4858 + protobufjs@7.5.4:
4701 dependencies: 4859 dependencies:
4702 '@protobufjs/aspromise': 1.1.2 4860 '@protobufjs/aspromise': 1.1.2
4703 '@protobufjs/base64': 1.1.2 4861 '@protobufjs/base64': 1.1.2
@@ -4709,22 +4867,21 @@ snapshots: @@ -4709,22 +4867,21 @@ snapshots:
4709 '@protobufjs/path': 1.1.2 4867 '@protobufjs/path': 1.1.2
4710 '@protobufjs/pool': 1.1.0 4868 '@protobufjs/pool': 1.1.0
4711 '@protobufjs/utf8': 1.1.0 4869 '@protobufjs/utf8': 1.1.0
4712 - '@types/long': 4.0.2  
4713 - '@types/node': 10.17.60  
4714 - long: 4.0.0 4870 + '@types/node': 25.3.2
  4871 + long: 5.3.2
4715 4872
4716 - protobufjs@7.5.4: 4873 + protobufjs@7.5.8:
4717 dependencies: 4874 dependencies:
4718 '@protobufjs/aspromise': 1.1.2 4875 '@protobufjs/aspromise': 1.1.2
4719 '@protobufjs/base64': 1.1.2 4876 '@protobufjs/base64': 1.1.2
4720 - '@protobufjs/codegen': 2.0.4 4877 + '@protobufjs/codegen': 2.0.5
4721 '@protobufjs/eventemitter': 1.1.0 4878 '@protobufjs/eventemitter': 1.1.0
4722 '@protobufjs/fetch': 1.1.0 4879 '@protobufjs/fetch': 1.1.0
4723 '@protobufjs/float': 1.0.2 4880 '@protobufjs/float': 1.0.2
4724 - '@protobufjs/inquire': 1.1.0 4881 + '@protobufjs/inquire': 1.1.1
4725 '@protobufjs/path': 1.1.2 4882 '@protobufjs/path': 1.1.2
4726 '@protobufjs/pool': 1.1.0 4883 '@protobufjs/pool': 1.1.0
4727 - '@protobufjs/utf8': 1.1.0 4884 + '@protobufjs/utf8': 1.1.1
4728 '@types/node': 25.3.2 4885 '@types/node': 25.3.2
4729 long: 5.3.2 4886 long: 5.3.2
4730 4887
@@ -5198,6 +5355,8 @@ snapshots: @@ -5198,6 +5355,8 @@ snapshots:
5198 5355
5199 undici-types@7.18.2: {} 5356 undici-types@7.18.2: {}
5200 5357
  5358 + undici@7.25.0: {}
  5359 +
5201 unescape@1.0.1: 5360 unescape@1.0.1:
5202 dependencies: 5361 dependencies:
5203 extend-shallow: 2.0.1 5362 extend-shallow: 2.0.1
@@ -5327,6 +5486,12 @@ snapshots: @@ -5327,6 +5486,12 @@ snapshots:
5327 dependencies: 5486 dependencies:
5328 loose-envify: 1.4.0 5487 loose-envify: 1.4.0
5329 5488
  5489 + whatwg-encoding@3.1.1:
  5490 + dependencies:
  5491 + iconv-lite: 0.6.3
  5492 +
  5493 + whatwg-mimetype@4.0.0: {}
  5494 +
5330 which-module@2.0.1: {} 5495 which-module@2.0.1: {}
5331 5496
5332 which@2.0.2: 5497 which@2.0.2:
server/services/devloop/bug-tracker.js 0 → 100644
  1 +/**
  2 + * DevLoop · bugTracker
  3 + *
  4 + * bugs.ndjson 的 CRUD + 状态机(P-9)。
  5 + * - bugs.ndjson 是 append-only 流。一条 bug 的「当前态」= 该 id 的<b>最新一行</b>。
  6 + * - dedupKey = "{unit_id}:{test_name}"。open() 时同 key 已存在 open/fixing → 不重复,返旧 id。
  7 + * - transition() 走 P-9 状态机校验,非法转换抛错。
  8 + *
  9 + * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-16
  10 + * docs/prd/newfunction/_spec/sdd-model.md · M-14
  11 + * docs/prd/newfunction/_spec/sdd-process.md · P-9
  12 + */
  13 +
  14 +const fs = require('fs');
  15 +const path = require('path');
  16 +const crypto = require('crypto');
  17 +
  18 +const pathResolver = require('./path-resolver');
  19 +
  20 +// 可选依赖:timelineLogger / sseBroker。LoopController 启动时 attach。
  21 +let timelineLogger = null;
  22 +let sseBroker = null;
  23 +function attachTimelineLogger(t) { timelineLogger = t; }
  24 +function attachSseBroker(b) { sseBroker = b; }
  25 +
  26 +// ────────────────────────────────────────────────────────────────
  27 +// 状态机
  28 +// ────────────────────────────────────────────────────────────────
  29 +
  30 +const VALID_TRANSITIONS = {
  31 + open: ['fixing', 'wontfix', 'duplicate'],
  32 + fixing: ['fixed', 'open'],
  33 + fixed: ['verified', 'open'],
  34 + verified: ['open'],
  35 + wontfix: [],
  36 + duplicate: [],
  37 +};
  38 +
  39 +function canTransition(from, to) {
  40 + if (!VALID_TRANSITIONS[from]) return false;
  41 + return VALID_TRANSITIONS[from].includes(to);
  42 +}
  43 +
  44 +class InvalidBugTransitionError extends Error {
  45 + constructor(from, to, bugId) {
  46 + super(`Invalid bug transition for ${bugId}: ${from} → ${to}`);
  47 + this.name = 'InvalidBugTransitionError';
  48 + this.from = from;
  49 + this.to = to;
  50 + this.bugId = bugId;
  51 + }
  52 +}
  53 +
  54 +// ────────────────────────────────────────────────────────────────
  55 +// 核心
  56 +// ────────────────────────────────────────────────────────────────
  57 +
  58 +function dedupKey(bug) {
  59 + const parts = [bug.unit_id || 'unknown', bug.test_name || bug.title || ''];
  60 + return parts.join(':');
  61 +}
  62 +
  63 +function _genBugId(now = new Date()) {
  64 + const ts = now.toISOString().replace(/[-:T.Z]/g, '').slice(0, 14); // YYYYMMDDHHMMSS
  65 + const tag = crypto.randomBytes(2).toString('hex');
  66 + return `bug-${ts.slice(0,8)}-${ts.slice(8,12)}-${tag}`;
  67 +}
  68 +
  69 +function _now() { return new Date().toISOString(); }
  70 +
  71 +function _logPath(pid) { return pathResolver.resolveBugsLog(pid); }
  72 +
  73 +function _appendLine(pid, obj) {
  74 + const p = _logPath(pid);
  75 + fs.mkdirSync(path.dirname(p), { recursive: true });
  76 + fs.appendFileSync(p, JSON.stringify(obj) + '\n');
  77 +}
  78 +
  79 +/** 流式读全部行(小项目 OK;大项目可优化为 readline 流式) */
  80 +function _readAll(pid) {
  81 + const p = _logPath(pid);
  82 + if (!fs.existsSync(p)) return [];
  83 + const raw = fs.readFileSync(p, 'utf8');
  84 + return raw.split('\n').filter(Boolean).map(l => {
  85 + try { return JSON.parse(l); } catch { return null; }
  86 + }).filter(Boolean);
  87 +}
  88 +
  89 +/**
  90 + * 按 bug id 聚合:最后一行 = 终态。
  91 + * @returns Map<bugId, Bug>
  92 + */
  93 +function _aggregateLatest(pid) {
  94 + const lines = _readAll(pid);
  95 + const map = new Map();
  96 + for (const line of lines) {
  97 + if (!line.id) continue;
  98 + map.set(line.id, line);
  99 + }
  100 + return map;
  101 +}
  102 +
  103 +// ────────────────────────────────────────────────────────────────
  104 +// open / transition
  105 +// ────────────────────────────────────────────────────────────────
  106 +
  107 +/**
  108 + * 开 bug。如 dedupKey 命中已存在 status=open|fixing 的 bug → 不重复,返旧 bug。
  109 + *
  110 + * @param {string} pid
  111 + * @param {object} bug - 必填: source, unit_id?, run_id?, test_name?, category, severity, title, detail, affected_units
  112 + * @returns Bug (含 id)
  113 + */
  114 +function open(pid, bug) {
  115 + // dedup 检查
  116 + const key = dedupKey(bug);
  117 + const all = _aggregateLatest(pid);
  118 + for (const b of all.values()) {
  119 + if ((b.status === 'open' || b.status === 'fixing') &&
  120 + dedupKey(b) === key) {
  121 + return b;
  122 + }
  123 + }
  124 +
  125 + const id = _genBugId();
  126 + const now = _now();
  127 + const full = {
  128 + id,
  129 + discovered_at: now,
  130 + source: bug.source || 'unit_test',
  131 + unit_id: bug.unit_id,
  132 + run_id: bug.run_id,
  133 + test_name: bug.test_name,
  134 + category: bug.category || 'unknown',
  135 + severity: bug.severity || 'medium',
  136 + title: (bug.title || '(no title)').slice(0, 120),
  137 + detail: bug.detail || '',
  138 + affected_units: bug.affected_units || (bug.unit_id ? [bug.unit_id] : []),
  139 + status: 'open',
  140 + status_history: [{
  141 + ts: now, from: '(none)', to: 'open',
  142 + by: bug.by || 'tester',
  143 + note: bug.note,
  144 + }],
  145 + fix_refs: [],
  146 + };
  147 + _appendLine(pid, full);
  148 +
  149 + if (timelineLogger) {
  150 + timelineLogger.append(pid, {
  151 + source: bug.source || 'tester',
  152 + kind: 'bug_opened',
  153 + ref: id,
  154 + summary: full.title,
  155 + });
  156 + }
  157 + if (sseBroker) {
  158 + sseBroker.emit(pid, 'bug.opened', { id, category: full.category, severity: full.severity, title: full.title });
  159 + }
  160 +
  161 + return full;
  162 +}
  163 +
  164 +/**
  165 + * 状态转换。同步发 SSE + timeline。
  166 + */
  167 +function transition(pid, bugId, { to, by, note, fix_refs, duplicate_of } = {}) {
  168 + if (!to) throw new Error('transition requires `to` field');
  169 + const map = _aggregateLatest(pid);
  170 + const b = map.get(bugId);
  171 + if (!b) throw new Error(`bug ${bugId} not found`);
  172 +
  173 + if (!canTransition(b.status, to)) {
  174 + throw new InvalidBugTransitionError(b.status, to, bugId);
  175 + }
  176 +
  177 + const now = _now();
  178 + // 复制一份 + 应用变更
  179 + const next = {
  180 + ...b,
  181 + status: to,
  182 + status_history: [
  183 + ...(b.status_history || []),
  184 + { ts: now, from: b.status, to, by: by || 'system', note },
  185 + ],
  186 + fix_refs: fix_refs ? [...(b.fix_refs || []), ...fix_refs] : (b.fix_refs || []),
  187 + ...(duplicate_of ? { duplicate_of } : {}),
  188 + };
  189 + _appendLine(pid, next);
  190 +
  191 + if (timelineLogger) {
  192 + timelineLogger.append(pid, {
  193 + source: by || 'system',
  194 + kind: to === 'open' ? 'bug_reopened' : 'bug_transitioned',
  195 + ref: bugId,
  196 + summary: `${b.status} → ${to}`,
  197 + });
  198 + }
  199 + if (sseBroker) {
  200 + sseBroker.emit(pid, 'bug.transitioned', { id: bugId, from: b.status, to, by });
  201 + }
  202 +
  203 + return next;
  204 +}
  205 +
  206 +// ────────────────────────────────────────────────────────────────
  207 +// 读
  208 +// ────────────────────────────────────────────────────────────────
  209 +
  210 +/**
  211 + * 列 bug(每个 id 的最新态)。可按 filter 过滤。
  212 + * @param {object} filter - { status?: string[], severity?: string[], unit_id?, since?: ISO date }
  213 + */
  214 +function list(pid, filter = {}) {
  215 + let out = Array.from(_aggregateLatest(pid).values());
  216 + if (filter.status?.length) {
  217 + out = out.filter(b => filter.status.includes(b.status));
  218 + }
  219 + if (filter.severity?.length) {
  220 + out = out.filter(b => filter.severity.includes(b.severity));
  221 + }
  222 + if (filter.unit_id) {
  223 + out = out.filter(b => b.unit_id === filter.unit_id);
  224 + }
  225 + if (filter.since) {
  226 + const t = new Date(filter.since).getTime();
  227 + out = out.filter(b => new Date(b.discovered_at).getTime() >= t);
  228 + }
  229 + return out;
  230 +}
  231 +
  232 +function get(pid, bugId) {
  233 + return _aggregateLatest(pid).get(bugId) || null;
  234 +}
  235 +
  236 +/** 完整 status_history(所有 history 条目,含 open 时的初始条目) */
  237 +function history(pid, bugId) {
  238 + const b = get(pid, bugId);
  239 + if (!b) return [];
  240 + return b.status_history || [];
  241 +}
  242 +
  243 +function stats(pid) {
  244 + const all = list(pid);
  245 + const out = {
  246 + open: 0, fixing: 0, fixed: 0, verified: 0, wontfix: 0, duplicate: 0,
  247 + by_severity: { critical: 0, high: 0, medium: 0, low: 0 },
  248 + total: all.length,
  249 + };
  250 + for (const b of all) {
  251 + if (out[b.status] !== undefined) out[b.status]++;
  252 + if (out.by_severity[b.severity] !== undefined) out.by_severity[b.severity]++;
  253 + }
  254 + return out;
  255 +}
  256 +
  257 +module.exports = {
  258 + // 写
  259 + open,
  260 + transition,
  261 + // 读
  262 + list,
  263 + get,
  264 + history,
  265 + stats,
  266 + // 工具
  267 + dedupKey,
  268 + canTransition,
  269 + VALID_TRANSITIONS,
  270 + InvalidBugTransitionError,
  271 + // 依赖注入
  272 + attachTimelineLogger,
  273 + attachSseBroker,
  274 +};
server/services/devloop/changelog-writer.js 0 → 100644
  1 +/**
  2 + * DevLoop · changelogWriter
  3 + *
  4 + * 跨 PRD/SDD/units 的版本变更流(M-15)。append-only NDJSON。
  5 + * - 由 DocStore 在 writePRD/writeSDD/writeUnits 成功后调用
  6 + * - 外部 Agent 不直接调
  7 + *
  8 + * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-17
  9 + * docs/prd/newfunction/_spec/sdd-model.md · M-15
  10 + */
  11 +
  12 +const fs = require('fs');
  13 +const path = require('path');
  14 +
  15 +const pathResolver = require('./path-resolver');
  16 +
  17 +function _logPath(pid) { return pathResolver.resolveChangelog(pid); }
  18 +
  19 +const VALID_KINDS = ['prd_rev', 'sdd_rev', 'units_rev', 'integration_spec_rev'];
  20 +
  21 +function append(pid, entry) {
  22 + if (!VALID_KINDS.includes(entry.kind)) {
  23 + throw new Error(`changelogWriter.append: invalid kind "${entry.kind}"`);
  24 + }
  25 + if (typeof entry.rev !== 'number') {
  26 + throw new Error('changelogWriter.append: rev (number) required');
  27 + }
  28 + if (!entry.summary) {
  29 + throw new Error('changelogWriter.append: summary required');
  30 + }
  31 +
  32 + const full = {
  33 + ts: new Date().toISOString(),
  34 + kind: entry.kind,
  35 + rev: entry.rev,
  36 + by: entry.by || 'system',
  37 + trigger: entry.trigger || 'unknown',
  38 + summary: entry.summary,
  39 + diff: entry.diff || {
  40 + sections_added: [], sections_changed: [], sections_removed: [],
  41 + units_added: [], units_changed: [], units_removed: [],
  42 + },
  43 + archive_ref: entry.archive_ref || null,
  44 + };
  45 +
  46 + const line = JSON.stringify(full) + '\n';
  47 + const p = _logPath(pid);
  48 + fs.mkdirSync(path.dirname(p), { recursive: true });
  49 + fs.appendFileSync(p, line);
  50 + return full;
  51 +}
  52 +
  53 +function read(pid, opts = {}) {
  54 + const p = _logPath(pid);
  55 + if (!fs.existsSync(p)) return [];
  56 + const limit = opts.limit || 100;
  57 + const lines = fs.readFileSync(p, 'utf8').split('\n').filter(Boolean);
  58 +
  59 + const entries = [];
  60 + for (let i = lines.length - 1; i >= 0; i--) {
  61 + let e;
  62 + try { e = JSON.parse(lines[i]); } catch { continue; }
  63 + if (opts.kind && e.kind !== opts.kind) continue;
  64 + if (opts.since && new Date(e.ts).getTime() < new Date(opts.since).getTime()) break;
  65 + entries.push(e);
  66 + if (entries.length >= limit) break;
  67 + }
  68 + return entries.reverse();
  69 +}
  70 +
  71 +/** 取某 kind 最近一条 */
  72 +function latest(pid, kind) {
  73 + const recent = read(pid, { kind, limit: 1 });
  74 + return recent[0] || null;
  75 +}
  76 +
  77 +module.exports = { append, read, latest, VALID_KINDS };
server/services/devloop/doc-store.js 0 → 100644
  1 +/**
  2 + * DevLoop · docStore
  3 + *
  4 + * PRD / SDD / units.json / integration/spec.json 的读写、归档、校验。
  5 + * 所有 Agent 不直接 fs.writeFile 这些文件 — 必须走 docStore,确保:
  6 + * 1. cheerio HTML 校验在写盘前跑(不合规拒收 → ValidationError)
  7 + * 2. 旧版自动归档到 history/{kind}-rev-{N}/(或单文件 .json)
  8 + * 3. 写完后自动调 changelogWriter.append(如 changelogWriter 已就位)
  9 + *
  10 + * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-9
  11 + * docs/prd/newfunction/_spec/sdd-model.md · M-3 / M-4 / M-2 / M-6
  12 + *
  13 + * 注意:prd / sdd 现在是<b>多页 HTML 站点目录</b>而非单文件。
  14 + * - writePRD(pid, files) 接受 [{relPath, content}],逐个校验逐个落盘
  15 + * - readPRD(pid, relPath) 单文件读
  16 + * - listPrdFiles(pid) 列文件
  17 + * 单文件写法(writePRDChapter)也保留为常用 helper。
  18 + */
  19 +
  20 +const fs = require('fs');
  21 +const path = require('path');
  22 +const crypto = require('crypto');
  23 +const cheerio = require('cheerio');
  24 +
  25 +const pathResolver = require('./path-resolver');
  26 +
  27 +// ────────────────────────────────────────────────────────────────
  28 +// Validation
  29 +// ────────────────────────────────────────────────────────────────
  30 +
  31 +class ValidationError extends Error {
  32 + constructor(message, details = []) {
  33 + super(message);
  34 + this.name = 'ValidationError';
  35 + this.details = details;
  36 + }
  37 +}
  38 +
  39 +/**
  40 + * 校验单页 PRD/SDD HTML 是否符合规范。
  41 + * - 必含顶级 <article data-doc="prd"|"sdd"> 且 data-rev 数字
  42 + * - 每个 section 必含 <h3> + <p>
  43 + * - PRD 的 data-rev 单调递增(仅检查升序,跳号警告但不报错)
  44 + *
  45 + * 不强求 8 类信息覆盖(那是 PM/Architect 自检层的事,不在 DocStore 阻塞)
  46 + *
  47 + * @param {string} html
  48 + * @param {'prd'|'sdd'} kind
  49 + * @returns {{ ok: boolean, errors: string[] }}
  50 + */
  51 +function validateHTML(html, kind) {
  52 + const errors = [];
  53 + if (typeof html !== 'string' || html.length === 0) {
  54 + return { ok: false, errors: ['empty html'] };
  55 + }
  56 +
  57 + let $;
  58 + try {
  59 + $ = cheerio.load(html, { decodeEntities: false });
  60 + } catch (e) {
  61 + return { ok: false, errors: [`cheerio parse failed: ${e.message}`] };
  62 + }
  63 +
  64 + const article = $(`article[data-doc="${kind}"]`);
  65 + if (article.length === 0) {
  66 + errors.push(`missing <article data-doc="${kind}">`);
  67 + return { ok: false, errors };
  68 + }
  69 + if (article.length > 1) {
  70 + errors.push(`multiple <article data-doc="${kind}"> elements`);
  71 + }
  72 +
  73 + const articleRev = article.first().attr('data-rev');
  74 + if (!articleRev || !/^\d+$/.test(articleRev)) {
  75 + errors.push(`article missing or invalid data-rev: "${articleRev}"`);
  76 + }
  77 +
  78 + const sections = article.first().find('section');
  79 + if (sections.length === 0) {
  80 + errors.push('article has no <section>');
  81 + }
  82 +
  83 + const revs = [];
  84 + sections.each((i, el) => {
  85 + const $sec = $(el);
  86 + if ($sec.find('h3').length === 0) {
  87 + errors.push(`section[${i}] missing <h3>`);
  88 + }
  89 + if ($sec.find('p').length === 0) {
  90 + errors.push(`section[${i}] missing <p>`);
  91 + }
  92 + const rev = $sec.attr('data-rev');
  93 + if (rev && /^\d+$/.test(rev)) revs.push(parseInt(rev, 10));
  94 + });
  95 +
  96 + // section data-rev 单调递增检查(允许相等,因为可能多 section 同 rev)
  97 + for (let i = 1; i < revs.length; i++) {
  98 + if (revs[i] < revs[i - 1]) {
  99 + errors.push(`data-rev not monotonic at section[${i}]: ${revs[i - 1]} → ${revs[i]}`);
  100 + }
  101 + }
  102 +
  103 + return { ok: errors.length === 0, errors };
  104 +}
  105 +
  106 +/**
  107 + * 校验 units.json schema(M-2)+ DAG 无环
  108 + * @returns {{ ok, errors }}
  109 + */
  110 +function validateUnitsSchema(json) {
  111 + const errors = [];
  112 + if (!json || typeof json !== 'object') {
  113 + return { ok: false, errors: ['units json must be object'] };
  114 + }
  115 + for (const k of ['project_id', 'units']) {
  116 + if (!json[k]) errors.push(`missing field: ${k}`);
  117 + }
  118 + if (!Array.isArray(json.units)) {
  119 + errors.push('units must be array');
  120 + return { ok: false, errors };
  121 + }
  122 +
  123 + const idPattern = /^[a-z]+(-[a-z]+)*-(be|fe|mw|db)$/;
  124 + const validStatuses = ['pending', 'coding', 'testing', 'failed', 'passed', 'blocked'];
  125 + const validTypes = ['be', 'fe', 'mw', 'db'];
  126 + const seen = new Set();
  127 +
  128 + for (const u of json.units) {
  129 + if (!u.id) {
  130 + errors.push('unit missing id');
  131 + continue;
  132 + }
  133 + if (!idPattern.test(u.id)) {
  134 + errors.push(`unit id "${u.id}" violates naming pattern`);
  135 + }
  136 + if (seen.has(u.id)) {
  137 + errors.push(`duplicate unit id: ${u.id}`);
  138 + }
  139 + seen.add(u.id);
  140 + if (!validTypes.includes(u.type)) {
  141 + errors.push(`unit ${u.id} invalid type: ${u.type}`);
  142 + }
  143 + if (!validStatuses.includes(u.status)) {
  144 + errors.push(`unit ${u.id} invalid status: ${u.status}`);
  145 + }
  146 + if (!Array.isArray(u.depends_on)) {
  147 + errors.push(`unit ${u.id} depends_on must be array`);
  148 + }
  149 + }
  150 +
  151 + // 引用完整性 + DAG 无环
  152 + const ids = new Set(json.units.map(u => u.id));
  153 + for (const u of json.units) {
  154 + for (const dep of u.depends_on || []) {
  155 + if (!ids.has(dep)) {
  156 + errors.push(`unit ${u.id} depends_on unknown unit: ${dep}`);
  157 + }
  158 + }
  159 + }
  160 + if (errors.length === 0 && hasCycle(json.units)) {
  161 + errors.push('units DAG has cycle');
  162 + }
  163 +
  164 + return { ok: errors.length === 0, errors };
  165 +}
  166 +
  167 +function hasCycle(units) {
  168 + const idx = new Map(units.map((u, i) => [u.id, i]));
  169 + const color = new Array(units.length).fill(0); // 0=white, 1=gray, 2=black
  170 + let cycle = false;
  171 +
  172 + function visit(i) {
  173 + if (cycle) return;
  174 + if (color[i] === 2) return;
  175 + if (color[i] === 1) {
  176 + cycle = true;
  177 + return;
  178 + }
  179 + color[i] = 1;
  180 + for (const dep of units[i].depends_on || []) {
  181 + if (idx.has(dep)) visit(idx.get(dep));
  182 + }
  183 + color[i] = 2;
  184 + }
  185 + for (let i = 0; i < units.length && !cycle; i++) visit(i);
  186 + return cycle;
  187 +}
  188 +
  189 +// ────────────────────────────────────────────────────────────────
  190 +// PRD / SDD multi-file site
  191 +// ────────────────────────────────────────────────────────────────
  192 +
  193 +/** 列项目下 prd/ 站点内所有 html 章节相对路径 */
  194 +function listPrdFiles(pid) {
  195 + return _listSiteHtmls(pathResolver.resolvePrdSite(pid));
  196 +}
  197 +
  198 +function listSddFiles(pid) {
  199 + return _listSiteHtmls(pathResolver.resolveSddSite(pid));
  200 +}
  201 +
  202 +function _listSiteHtmls(siteRoot) {
  203 + const out = [];
  204 + if (!fs.existsSync(siteRoot)) return out;
  205 + function walk(dir, prefix) {
  206 + for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
  207 + const rel = prefix ? `${prefix}/${e.name}` : e.name;
  208 + const full = path.join(dir, e.name);
  209 + if (e.isDirectory()) {
  210 + if (e.name === 'assets') continue; // assets 不计入 chapter 列表
  211 + walk(full, rel);
  212 + } else if (e.name.endsWith('.html')) {
  213 + out.push(rel);
  214 + }
  215 + }
  216 + }
  217 + walk(siteRoot, '');
  218 + return out.sort();
  219 +}
  220 +
  221 +/** 读单个 PRD/SDD 章节 */
  222 +function readPrdChapter(pid, relPath) {
  223 + const file = path.join(pathResolver.resolvePrdSite(pid), relPath);
  224 + if (!fs.existsSync(file)) throw new Error(`PRD chapter not found: ${relPath}`);
  225 + return fs.readFileSync(file, 'utf8');
  226 +}
  227 +
  228 +function readSddChapter(pid, relPath) {
  229 + const file = path.join(pathResolver.resolveSddSite(pid), relPath);
  230 + if (!fs.existsSync(file)) throw new Error(`SDD chapter not found: ${relPath}`);
  231 + return fs.readFileSync(file, 'utf8');
  232 +}
  233 +
  234 +/**
  235 + * 写一个 PRD chapter(HTML)。校验通过 + 旧版(如有)归档 + 落盘。
  236 + * @returns { rev: number, path: string }
  237 + */
  238 +function writePrdChapter(pid, relPath, html, opts = {}) {
  239 + return _writeChapter(pid, 'prd', relPath, html, opts);
  240 +}
  241 +
  242 +function writeSddChapter(pid, relPath, html, opts = {}) {
  243 + return _writeChapter(pid, 'sdd', relPath, html, opts);
  244 +}
  245 +
  246 +function _writeChapter(pid, kind, relPath, html, opts) {
  247 + const validation = validateHTML(html, kind);
  248 + if (!validation.ok && !opts.skipValidation) {
  249 + throw new ValidationError(
  250 + `${kind}/${relPath} validation failed`, validation.errors);
  251 + }
  252 + const siteRoot = kind === 'prd'
  253 + ? pathResolver.resolvePrdSite(pid)
  254 + : pathResolver.resolveSddSite(pid);
  255 + const target = path.join(siteRoot, relPath);
  256 + fs.mkdirSync(path.dirname(target), { recursive: true });
  257 +
  258 + // 归档旧版(如有)
  259 + if (fs.existsSync(target)) {
  260 + archiveFile(pid, kind, relPath, fs.readFileSync(target, 'utf8'));
  261 + }
  262 +
  263 + fs.writeFileSync(target, html);
  264 +
  265 + // 解析 article data-rev 作为新 rev
  266 + let rev = 1;
  267 + try {
  268 + const $ = cheerio.load(html);
  269 + const r = $(`article[data-doc="${kind}"]`).first().attr('data-rev');
  270 + if (r && /^\d+$/.test(r)) rev = parseInt(r, 10);
  271 + } catch { /* ignore */ }
  272 +
  273 + return { rev, path: target };
  274 +}
  275 +
  276 +/** 把旧文件归档到 history/{kind}-rev-{N}/{relPath} */
  277 +function archiveFile(pid, kind, relPath, oldContent) {
  278 + // 取旧版的 rev(从 oldContent 解析 article data-rev)
  279 + let oldRev = 0;
  280 + try {
  281 + const $ = cheerio.load(oldContent);
  282 + const r = $(`article[data-doc="${kind}"]`).first().attr('data-rev');
  283 + if (r && /^\d+$/.test(r)) oldRev = parseInt(r, 10);
  284 + } catch { /* ignore */ }
  285 +
  286 + const archiveDir = path.join(
  287 + pathResolver.resolveHistoryDir(pid),
  288 + `${kind}-rev-${oldRev || 'unknown'}`
  289 + );
  290 + const archiveTarget = path.join(archiveDir, relPath);
  291 + fs.mkdirSync(path.dirname(archiveTarget), { recursive: true });
  292 + fs.writeFileSync(archiveTarget, oldContent);
  293 +}
  294 +
  295 +// ────────────────────────────────────────────────────────────────
  296 +// units.json / integration/spec.json
  297 +// ────────────────────────────────────────────────────────────────
  298 +
  299 +function readUnits(pid) {
  300 + const p = pathResolver.resolveUnitsJson(pid);
  301 + if (!fs.existsSync(p)) return null;
  302 + return JSON.parse(fs.readFileSync(p, 'utf8'));
  303 +}
  304 +
  305 +function writeUnits(pid, json, opts = {}) {
  306 + const validation = validateUnitsSchema(json);
  307 + if (!validation.ok && !opts.skipValidation) {
  308 + throw new ValidationError('units.json validation failed', validation.errors);
  309 + }
  310 + const p = pathResolver.resolveUnitsJson(pid);
  311 +
  312 + // 归档旧版
  313 + if (fs.existsSync(p)) {
  314 + const old = JSON.parse(fs.readFileSync(p, 'utf8'));
  315 + const oldRev = old.sdd_rev || 'unknown';
  316 + const archivePath = path.join(
  317 + pathResolver.resolveHistoryDir(pid),
  318 + `units-rev-${oldRev}.json`
  319 + );
  320 + fs.mkdirSync(path.dirname(archivePath), { recursive: true });
  321 + fs.writeFileSync(archivePath, JSON.stringify(old, null, 2));
  322 + }
  323 +
  324 + fs.mkdirSync(path.dirname(p), { recursive: true });
  325 + fs.writeFileSync(p, JSON.stringify(json, null, 2));
  326 + return { rev: json.sdd_rev || 1, path: p };
  327 +}
  328 +
  329 +function readIntegrationSpec(pid) {
  330 + const p = path.join(pathResolver.resolveIntegrationDir(pid), 'spec.json');
  331 + if (!fs.existsSync(p)) return null;
  332 + return JSON.parse(fs.readFileSync(p, 'utf8'));
  333 +}
  334 +
  335 +function writeIntegrationSpec(pid, json) {
  336 + const p = path.join(pathResolver.resolveIntegrationDir(pid), 'spec.json');
  337 + fs.mkdirSync(path.dirname(p), { recursive: true });
  338 +
  339 + // 校验 involves ⊆ units.json
  340 + const units = readUnits(pid);
  341 + if (units && Array.isArray(json.cases)) {
  342 + const unitIds = new Set(units.units.map(u => u.id));
  343 + const errors = [];
  344 + for (const c of json.cases) {
  345 + for (const u of c.involves || []) {
  346 + if (!unitIds.has(u)) {
  347 + errors.push(`case ${c.id} involves unknown unit: ${u}`);
  348 + }
  349 + }
  350 + }
  351 + if (errors.length > 0) {
  352 + throw new ValidationError('integration spec references unknown units', errors);
  353 + }
  354 + }
  355 +
  356 + fs.writeFileSync(p, JSON.stringify(json, null, 2));
  357 + return { path: p };
  358 +}
  359 +
  360 +// ────────────────────────────────────────────────────────────────
  361 +// Project meta + utilities
  362 +// ────────────────────────────────────────────────────────────────
  363 +
  364 +function readProjectMeta(pid) {
  365 + const p = pathResolver.resolveProjectJson(pid);
  366 + if (!fs.existsSync(p)) return null;
  367 + return JSON.parse(fs.readFileSync(p, 'utf8'));
  368 +}
  369 +
  370 +function writeProjectMeta(pid, meta) {
  371 + const p = pathResolver.resolveProjectJson(pid);
  372 + fs.mkdirSync(path.dirname(p), { recursive: true });
  373 + fs.writeFileSync(p, JSON.stringify(meta, null, 2));
  374 + return { path: p };
  375 +}
  376 +
  377 +/**
  378 + * canonical JSON + sha256 截 16 hex 字符
  379 + * @param {object} contract
  380 + * @returns {string} "sha256:..."
  381 + */
  382 +function hashContract(contract) {
  383 + if (!contract || typeof contract !== 'object') {
  384 + throw new Error('hashContract: contract must be object');
  385 + }
  386 + const body = contract.exports || contract;
  387 + const normalized = canonical(body);
  388 + const full = crypto.createHash('sha256').update(normalized).digest('hex');
  389 + return 'sha256:' + full.slice(0, 16);
  390 +}
  391 +
  392 +function canonical(v) {
  393 + if (v === null || typeof v !== 'object') return JSON.stringify(v);
  394 + if (Array.isArray(v)) return '[' + v.map(canonical).join(',') + ']';
  395 + const keys = Object.keys(v).sort();
  396 + return '{' + keys.map(k => JSON.stringify(k) + ':' + canonical(v[k])).join(',') + '}';
  397 +}
  398 +
  399 +/** 验证某 Unit 的 contract_hash 与重算一致 */
  400 +function verifyContractHash(unit, contract) {
  401 + const recomputed = hashContract(contract);
  402 + return {
  403 + ok: recomputed === unit.contract_hash,
  404 + expected: unit.contract_hash,
  405 + actual: recomputed,
  406 + };
  407 +}
  408 +
  409 +module.exports = {
  410 + // 校验
  411 + validateHTML,
  412 + validateUnitsSchema,
  413 + ValidationError,
  414 +
  415 + // PRD/SDD 多页站点
  416 + listPrdFiles,
  417 + listSddFiles,
  418 + readPrdChapter,
  419 + readSddChapter,
  420 + writePrdChapter,
  421 + writeSddChapter,
  422 +
  423 + // units / integration
  424 + readUnits,
  425 + writeUnits,
  426 + readIntegrationSpec,
  427 + writeIntegrationSpec,
  428 +
  429 + // project meta
  430 + readProjectMeta,
  431 + writeProjectMeta,
  432 +
  433 + // utils
  434 + hashContract,
  435 + verifyContractHash,
  436 + canonical,
  437 + hasCycle,
  438 +};
server/services/devloop/git-vault.js 0 → 100644
  1 +/**
  2 + * DevLoop · gitVault
  3 + *
  4 + * 每项目一个独立 git repo,LoopController 在每次 Agent 成功完成时调
  5 + * gitVault.commit(...) 落盘一个版本。Agent 自身不动 git。
  6 + *
  7 + * - 实现用 child_process.spawn('git', ...);不依赖第三方 npm 包
  8 + * - 所有方法按 pid 串行化(per-pid mutex),避免同项目并发 git 操作打架
  9 + * - commit author 标 Agent 名("PM Agent <pm@devloop.local>")
  10 + * - nothing-to-commit 不抛错,返 { ok: true, skipped: true }
  11 + *
  12 + * 规格:docs/prd/newfunction/_spec/arch-git-versioning.md
  13 + * docs/prd/newfunction/_spec/sdd-control.md · C-18
  14 + * docs/prd/newfunction/_spec/sdd-process.md · P-10
  15 + */
  16 +
  17 +const { spawn } = require('child_process');
  18 +const fs = require('fs');
  19 +const path = require('path');
  20 +
  21 +const pathResolver = require('./path-resolver');
  22 +
  23 +const DEFAULT_GITIGNORE = `# DevLoop project .gitignore
  24 +
  25 +# 高频写的运行态,不需要版本
  26 +agent-state/
  27 +
  28 +# git 本身就是 history
  29 +history/
  30 +
  31 +# 大日志
  32 +stream.log
  33 +units/*/runs/*/stdout.txt
  34 +units/*/runs/*/stderr.txt
  35 +integration/runs/*/process.log
  36 +integration/runs/*/http-trace.ndjson.gz
  37 +
  38 +# Node / 依赖
  39 +node_modules/
  40 +*.log
  41 +.DS_Store
  42 +.cache/
  43 +.tmp/
  44 +.coverage/
  45 +
  46 +# 测试中间文件
  47 +units/*/.vitest-cache/
  48 +`;
  49 +
  50 +// per-pid mutex(简单 Promise chain)
  51 +const mutexes = new Map();
  52 +function _withLock(pid, fn) {
  53 + const prev = mutexes.get(pid) || Promise.resolve();
  54 + const next = prev.then(fn, fn); // 不管前一个成功失败,继续跑下一个
  55 + mutexes.set(pid, next.catch(() => {})); // 防止 unhandled rejection
  56 + return next;
  57 +}
  58 +
  59 +// 运行单条 git 命令;返回 { ok, code, stdout, stderr }
  60 +function _run(cwd, args, opts = {}) {
  61 + return new Promise((resolve) => {
  62 + const child = spawn('git', args, {
  63 + cwd,
  64 + env: { ...process.env, ...(opts.env || {}) },
  65 + stdio: ['ignore', 'pipe', 'pipe'],
  66 + });
  67 + let stdout = '', stderr = '';
  68 + child.stdout.on('data', c => { stdout += c.toString(); });
  69 + child.stderr.on('data', c => { stderr += c.toString(); });
  70 + child.on('close', code => {
  71 + resolve({ ok: code === 0, code, stdout, stderr });
  72 + });
  73 + child.on('error', err => {
  74 + resolve({ ok: false, code: -1, stdout, stderr: err.message });
  75 + });
  76 + });
  77 +}
  78 +
  79 +// ────────────────────────────────────────────────────────────────
  80 +// API
  81 +// ────────────────────────────────────────────────────────────────
  82 +
  83 +/** 在项目根 git init + 写 .gitignore + 首个 commit */
  84 +async function init(pid, opts = {}) {
  85 + return _withLock(pid, async () => {
  86 + const root = pathResolver.resolveProjectRoot(pid);
  87 +
  88 + // 已是 repo 则跳
  89 + if (fs.existsSync(path.join(root, '.git'))) {
  90 + return { ok: true, head: await _head(root), already_exists: true };
  91 + }
  92 +
  93 + // 1. git init
  94 + const r1 = await _run(root, ['init', '-q', '-b', opts.initialBranch || 'main']);
  95 + if (!r1.ok) throw new Error(`git init failed: ${r1.stderr}`);
  96 +
  97 + // 2. 配 author identity(commit 需要)
  98 + const userName = opts.name || 'DevLoop';
  99 + const userEmail = opts.email || 'devloop@lemonboss.local';
  100 + await _run(root, ['config', 'user.name', userName]);
  101 + await _run(root, ['config', 'user.email', userEmail]);
  102 + // 不签名(v1 不强求 GPG)
  103 + await _run(root, ['config', 'commit.gpgsign', 'false']);
  104 +
  105 + // 3. 写 .gitignore
  106 + const gitignorePath = path.join(root, '.gitignore');
  107 + if (!fs.existsSync(gitignorePath)) {
  108 + fs.writeFileSync(gitignorePath, DEFAULT_GITIGNORE);
  109 + }
  110 +
  111 + // 4. 首个 commit
  112 + const r2 = await _run(root, ['add', '.']);
  113 + if (!r2.ok) throw new Error(`git add failed: ${r2.stderr}`);
  114 +
  115 + const initialMsg = opts.initialMessage ||
  116 + `init: project ${pid}` + (opts.summary ? ` · ${opts.summary}` : '');
  117 + const r3 = await _run(root, ['commit', '-q', '-m', initialMsg, '--allow-empty']);
  118 + if (!r3.ok) throw new Error(`initial commit failed: ${r3.stderr}`);
  119 +
  120 + return { ok: true, head: await _head(root) };
  121 + });
  122 +}
  123 +
  124 +/**
  125 + * Commit current changes.
  126 + * @param {string} pid
  127 + * @param {object} spec
  128 + * - kind: 'init'|'prd'|'sdd'|'code'|'triage'|'integ'|'ship'|'chore'
  129 + * - unit_id?: string (code 时填,作为 message 一部分)
  130 + * - run_id?: string (integ/triage 时填)
  131 + * - summary: string (必填)
  132 + * - files?: string[] (限定 add 的路径,默认 git add -A)
  133 + * - author?: { name, email } (commit author)
  134 + * - skipEmpty?: boolean (默认 true,nothing-to-commit 返 skipped)
  135 + * @returns { ok, sha?, skipped? }
  136 + */
  137 +async function commit(pid, spec) {
  138 + return _withLock(pid, async () => {
  139 + const root = pathResolver.resolveProjectRoot(pid);
  140 + if (!fs.existsSync(path.join(root, '.git'))) {
  141 + throw new Error(`not a git repo: ${root} (call init first)`);
  142 + }
  143 + if (!spec.summary) throw new Error('commit: summary required');
  144 +
  145 + // 1. 加 files(默认 -A,但受 .gitignore 控制)
  146 + const addArgs = ['add', '--'];
  147 + if (Array.isArray(spec.files) && spec.files.length) {
  148 + addArgs.push(...spec.files);
  149 + } else {
  150 + // -A:把所有变更(包括删除)加进 index
  151 + const rA = await _run(root, ['add', '-A']);
  152 + if (!rA.ok) throw new Error(`git add -A failed: ${rA.stderr}`);
  153 + }
  154 +
  155 + // 2. 检查是否有可 commit 变化
  156 + const status = await _run(root, ['status', '--porcelain']);
  157 + const dirty = status.stdout.trim().length > 0;
  158 + if (!dirty) {
  159 + if (spec.skipEmpty === false) {
  160 + throw new Error('nothing to commit and skipEmpty=false');
  161 + }
  162 + return { ok: true, skipped: true };
  163 + }
  164 +
  165 + // 3. commit
  166 + const env = {};
  167 + if (spec.author?.name) env.GIT_AUTHOR_NAME = spec.author.name;
  168 + if (spec.author?.email) env.GIT_AUTHOR_EMAIL = spec.author.email;
  169 + if (spec.author?.name) env.GIT_COMMITTER_NAME = spec.author.name;
  170 + if (spec.author?.email) env.GIT_COMMITTER_EMAIL = spec.author.email;
  171 +
  172 + const r = await _run(root, ['commit', '-q', '-m', spec.summary], { env });
  173 + if (!r.ok) {
  174 + // 兜底:检测 "nothing to commit" 文案
  175 + if (/nothing to commit/i.test(r.stdout + r.stderr)) {
  176 + return { ok: true, skipped: true };
  177 + }
  178 + throw new Error(`git commit failed: ${r.stderr || r.stdout}`);
  179 + }
  180 +
  181 + const sha = await _head(root);
  182 + return { ok: true, sha, kind: spec.kind, summary: spec.summary };
  183 + });
  184 +}
  185 +
  186 +async function tag(pid, tagName, opts = {}) {
  187 + return _withLock(pid, async () => {
  188 + const root = pathResolver.resolveProjectRoot(pid);
  189 + const args = ['tag'];
  190 + if (opts.message) {
  191 + args.push('-a', tagName, '-m', opts.message);
  192 + } else {
  193 + args.push(tagName);
  194 + }
  195 + if (opts.sha) args.push(opts.sha);
  196 + const r = await _run(root, args);
  197 + if (!r.ok) throw new Error(`git tag failed: ${r.stderr}`);
  198 + return { ok: true, tag: tagName };
  199 + });
  200 +}
  201 +
  202 +async function log(pid, opts = {}) {
  203 + const root = pathResolver.resolveProjectRoot(pid);
  204 + const args = ['log', '--format=%H%x09%an%x09%ae%x09%at%x09%s'];
  205 + if (opts.limit) args.push(`-n${opts.limit}`);
  206 + if (opts.since) args.push(`--since=${opts.since}`);
  207 + if (opts.grep) args.push(`--grep=${opts.grep}`);
  208 + const r = await _run(root, args);
  209 + if (!r.ok) {
  210 + // 空仓库 (no commits) → 返空
  211 + if (/does not have any commits/i.test(r.stderr)) return [];
  212 + throw new Error(`git log failed: ${r.stderr}`);
  213 + }
  214 + return r.stdout.trim().split('\n').filter(Boolean).map(line => {
  215 + const [sha, author_name, author_email, ts, ...rest] = line.split('\t');
  216 + return {
  217 + sha, author_name, author_email,
  218 + ts: parseInt(ts, 10),
  219 + summary: rest.join('\t'),
  220 + };
  221 + });
  222 +}
  223 +
  224 +async function diff(pid, refA, refB, opts = {}) {
  225 + const root = pathResolver.resolveProjectRoot(pid);
  226 + const args = ['diff', `${refA}..${refB}`];
  227 + if (opts.path) args.push('--', opts.path);
  228 + const r = await _run(root, args);
  229 + if (!r.ok) throw new Error(`git diff failed: ${r.stderr}`);
  230 + return r.stdout;
  231 +}
  232 +
  233 +async function show(pid, sha) {
  234 + const root = pathResolver.resolveProjectRoot(pid);
  235 + const r = await _run(root, ['show', '--format=%H%x09%an%x09%ae%x09%at%x09%s%n', sha]);
  236 + if (!r.ok) throw new Error(`git show failed: ${r.stderr}`);
  237 + return { sha, output: r.stdout };
  238 +}
  239 +
  240 +async function listTags(pid) {
  241 + const root = pathResolver.resolveProjectRoot(pid);
  242 + const r = await _run(root, ['tag', '--list', '--sort=-creatordate']);
  243 + if (!r.ok) throw new Error(`git tag --list failed: ${r.stderr}`);
  244 + return r.stdout.trim().split('\n').filter(Boolean);
  245 +}
  246 +
  247 +async function status(pid) {
  248 + const root = pathResolver.resolveProjectRoot(pid);
  249 + if (!fs.existsSync(path.join(root, '.git'))) {
  250 + return { dirty: false, files: [], no_repo: true };
  251 + }
  252 + const r = await _run(root, ['status', '--porcelain']);
  253 + if (!r.ok) throw new Error(`git status failed: ${r.stderr}`);
  254 + const files = r.stdout.trim().split('\n').filter(Boolean).map(l => l.trim().split(/\s+/, 2));
  255 + return { dirty: files.length > 0, files };
  256 +}
  257 +
  258 +async function resetWorkingTree(pid, ref = 'HEAD') {
  259 + return _withLock(pid, async () => {
  260 + const root = pathResolver.resolveProjectRoot(pid);
  261 + const r1 = await _run(root, ['reset', '--hard', ref]);
  262 + if (!r1.ok) throw new Error(`git reset failed: ${r1.stderr}`);
  263 + const r2 = await _run(root, ['clean', '-fd']);
  264 + if (!r2.ok) throw new Error(`git clean failed: ${r2.stderr}`);
  265 + return { ok: true };
  266 + });
  267 +}
  268 +
  269 +async function revert(pid, sha) {
  270 + return _withLock(pid, async () => {
  271 + const root = pathResolver.resolveProjectRoot(pid);
  272 + const r = await _run(root, ['revert', '--no-edit', sha]);
  273 + if (!r.ok) throw new Error(`git revert failed: ${r.stderr}`);
  274 + return { ok: true, newSha: await _head(root) };
  275 + });
  276 +}
  277 +
  278 +async function archive(pid, ref = 'HEAD', format = 'zip') {
  279 + const root = pathResolver.resolveProjectRoot(pid);
  280 + const outPath = path.join('/tmp', `${pid}-${ref}.${format}`);
  281 + // git archive --format=zip -o /tmp/x.zip HEAD
  282 + const r = await _run(root, ['archive', `--format=${format}`, `-o`, outPath, ref]);
  283 + if (!r.ok) throw new Error(`git archive failed: ${r.stderr}`);
  284 + return { path: outPath };
  285 +}
  286 +
  287 +// 工具
  288 +async function _head(root) {
  289 + const r = await _run(root, ['rev-parse', 'HEAD']);
  290 + if (!r.ok) return null;
  291 + return r.stdout.trim();
  292 +}
  293 +
  294 +function _resetMutexesForTesting() {
  295 + mutexes.clear();
  296 +}
  297 +
  298 +module.exports = {
  299 + init,
  300 + commit,
  301 + tag,
  302 + log,
  303 + diff,
  304 + show,
  305 + listTags,
  306 + status,
  307 + resetWorkingTree,
  308 + revert,
  309 + archive,
  310 + DEFAULT_GITIGNORE,
  311 + _resetMutexesForTesting,
  312 +};
server/services/devloop/path-resolver.js 0 → 100644
  1 +/**
  2 + * DevLoop · pathResolver
  3 + *
  4 + * 所有 DevLoop 产物路径的唯一真源。其他服务(docStore / unitRegistry /
  5 + * bugTracker / sseBroker / gitVault)都通过这里查路径,绝不硬编码
  6 + * `data/companies/...`。
  7 + *
  8 + * 支持三种 mode(见 arch-project-root.html §②):
  9 + * - sandbox : 自动生成 root_path = `{dataDir}/companies/{cid}/projects/{pid}/`
  10 + * - linked-new : 用户指定 root_path(绝对路径),注册到 ~/.devloop/linked-projects.json
  11 + * - linked-existing : v2,本文件预留接口但 register 阶段拒收
  12 + *
  13 + * 详尽规格:docs/prd/newfunction/_spec/arch-project-root.md
  14 + */
  15 +
  16 +const fs = require('fs');
  17 +const path = require('path');
  18 +const os = require('os');
  19 +
  20 +const DATA_DIR = path.resolve(__dirname, '../../../data');
  21 +const LINKED_REGISTRY = path.join(os.homedir(), '.devloop', 'linked-projects.json');
  22 +
  23 +// 系统敏感路径黑名单(不允许作为 linked-new 的 target_path)
  24 +// 注意:os.tmpdir() (e.g. /var/folders/... on macOS, /tmp on Linux) 是允许的,
  25 +// 即使前缀匹配 /var;isSensitivePath 内部会先检查 tmpdir 例外。
  26 +const SENSITIVE_PREFIXES = [
  27 + '/etc', '/usr', '/bin', '/sbin', '/boot',
  28 + '/var/log', '/var/lib', '/var/db', '/var/spool', '/var/cache',
  29 + '/System', '/Library/System', '/private/etc', '/private/var/log',
  30 + path.join(os.homedir(), '.ssh'),
  31 + path.join(os.homedir(), '.aws'),
  32 + path.join(os.homedir(), '.gnupg'),
  33 + path.join(os.homedir(), '.config'),
  34 + path.join(os.homedir(), 'Downloads'),
  35 +];
  36 +
  37 +const ALLOWED_EXEMPTIONS = [
  38 + os.tmpdir(), // /var/folders/... (mac) 或 /tmp (linux)
  39 + '/tmp',
  40 +];
  41 +
  42 +// 内存缓存:pid → { root_path, mode, company_id }
  43 +let cache = null;
  44 +
  45 +function loadLinkedRegistry() {
  46 + if (!fs.existsSync(LINKED_REGISTRY)) return {};
  47 + try {
  48 + return JSON.parse(fs.readFileSync(LINKED_REGISTRY, 'utf8'));
  49 + } catch {
  50 + return {};
  51 + }
  52 +}
  53 +
  54 +function saveLinkedRegistry(reg) {
  55 + fs.mkdirSync(path.dirname(LINKED_REGISTRY), { recursive: true });
  56 + fs.writeFileSync(LINKED_REGISTRY, JSON.stringify(reg, null, 2));
  57 +}
  58 +
  59 +/** 全量扫描 + linked 注册表,构造内存索引。LoopController 启动时调一次。 */
  60 +function init() {
  61 + cache = new Map();
  62 +
  63 + // 1. 扫 sandbox 项目(data/companies/*/projects/*/project.json)
  64 + if (fs.existsSync(DATA_DIR)) {
  65 + const companiesDir = path.join(DATA_DIR, 'companies');
  66 + if (fs.existsSync(companiesDir)) {
  67 + for (const cid of fs.readdirSync(companiesDir)) {
  68 + const projectsDir = path.join(companiesDir, cid, 'projects');
  69 + if (!fs.existsSync(projectsDir)) continue;
  70 + for (const pid of fs.readdirSync(projectsDir)) {
  71 + const root = path.join(projectsDir, pid);
  72 + const meta = path.join(root, 'project.json');
  73 + if (fs.existsSync(meta)) {
  74 + cache.set(pid, { root_path: root, mode: 'sandbox', company_id: cid });
  75 + }
  76 + }
  77 + }
  78 + }
  79 + }
  80 +
  81 + // 2. 叠加 linked 项目
  82 + const linked = loadLinkedRegistry();
  83 + for (const [pid, entry] of Object.entries(linked)) {
  84 + cache.set(pid, {
  85 + root_path: entry.root_path,
  86 + mode: entry.mode,
  87 + company_id: entry.company_id,
  88 + });
  89 + }
  90 +
  91 + return { count: cache.size };
  92 +}
  93 +
  94 +function ensureInit() {
  95 + if (cache === null) init();
  96 +}
  97 +
  98 +function resolveProjectRoot(pid) {
  99 + ensureInit();
  100 + const entry = cache.get(pid);
  101 + if (!entry) throw new Error(`Unknown project: ${pid}`);
  102 + return entry.root_path;
  103 +}
  104 +
  105 +function resolveProjectMeta(pid) {
  106 + ensureInit();
  107 + const entry = cache.get(pid);
  108 + if (!entry) throw new Error(`Unknown project: ${pid}`);
  109 + return { ...entry };
  110 +}
  111 +
  112 +function findProjectByPath(abs) {
  113 + ensureInit();
  114 + for (const [pid, entry] of cache.entries()) {
  115 + if (entry.root_path === abs) return { pid, ...entry };
  116 + }
  117 + return null;
  118 +}
  119 +
  120 +// ── 派生路径 ──
  121 +
  122 +function resolveUnitDir(pid, unitId) {
  123 + return path.join(resolveProjectRoot(pid), 'units', unitId);
  124 +}
  125 +
  126 +function resolvePrdSite(pid) {
  127 + return path.join(resolveProjectRoot(pid), 'prd');
  128 +}
  129 +
  130 +function resolveSddSite(pid) {
  131 + return path.join(resolveProjectRoot(pid), 'sdd');
  132 +}
  133 +
  134 +function resolveAgentState(pid, agent) {
  135 + return path.join(resolveProjectRoot(pid), 'agent-state', `${agent}.json`);
  136 +}
  137 +
  138 +function resolveAgentTodolist(pid, agent) {
  139 + return path.join(resolveProjectRoot(pid), 'agent-state', `${agent}-todo.json`);
  140 +}
  141 +
  142 +function resolveAgentSnapshotDir(pid) {
  143 + return path.join(resolveProjectRoot(pid), 'agent-state', 'snapshots');
  144 +}
  145 +
  146 +function resolveTimelineLog(pid) {
  147 + return path.join(resolveProjectRoot(pid), 'timeline.ndjson');
  148 +}
  149 +
  150 +function resolveBugsLog(pid) {
  151 + return path.join(resolveProjectRoot(pid), 'bugs.ndjson');
  152 +}
  153 +
  154 +function resolveChangelog(pid) {
  155 + return path.join(resolveProjectRoot(pid), 'changelog.ndjson');
  156 +}
  157 +
  158 +function resolveStreamLog(pid) {
  159 + return path.join(resolveProjectRoot(pid), 'stream.log');
  160 +}
  161 +
  162 +function resolveHistoryDir(pid) {
  163 + return path.join(resolveProjectRoot(pid), 'history');
  164 +}
  165 +
  166 +function resolveIntegrationDir(pid) {
  167 + return path.join(resolveProjectRoot(pid), 'integration');
  168 +}
  169 +
  170 +function resolveUnitsJson(pid) {
  171 + return path.join(resolveProjectRoot(pid), 'units.json');
  172 +}
  173 +
  174 +function resolveProjectJson(pid) {
  175 + return path.join(resolveProjectRoot(pid), 'project.json');
  176 +}
  177 +
  178 +/** Memory 永远在 company 级(不跟项目根走,详见 arch-project-root §⑧) */
  179 +function resolveMemoryDir(companyId, agent) {
  180 + return path.join(DATA_DIR, 'companies', companyId, 'memory', 'devloop', agent);
  181 +}
  182 +
  183 +function resolveCompanyDir(companyId) {
  184 + return path.join(DATA_DIR, 'companies', companyId);
  185 +}
  186 +
  187 +// ── 创建项目 ──
  188 +
  189 +function generatePid() {
  190 + // p- + 6 位 base36
  191 + const rand = Math.random().toString(36).slice(2, 8).padEnd(6, '0');
  192 + return `p-${rand}`;
  193 +}
  194 +
  195 +function isSensitivePath(abs) {
  196 + const normalized = path.resolve(abs);
  197 + // 1. 明确允许的目录(tmpdir 等)→ 即使前缀匹配也放行
  198 + for (const ex of ALLOWED_EXEMPTIONS) {
  199 + const e = path.resolve(ex);
  200 + if (normalized === e || normalized.startsWith(e + path.sep)) return false;
  201 + }
  202 + // 2. 不允许直接放在根 /
  203 + if (normalized === '/') return true;
  204 + // 3. 检查黑名单前缀
  205 + return SENSITIVE_PREFIXES.some(prefix => {
  206 + const p = path.resolve(prefix);
  207 + return normalized === p || normalized.startsWith(p + path.sep);
  208 + });
  209 +}
  210 +
  211 +/**
  212 + * 在文件系统上分配一个 root_path,但不写任何业务文件。
  213 + * 仅 mkdir + 校验路径合法。
  214 + * 业务文件(project.json / .gitignore / git init)由 LoopController.createProject 完成。
  215 + *
  216 + * @returns { pid, root_path, mode }
  217 + */
  218 +function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) {
  219 + if (!company_id) throw new Error('company_id required');
  220 +
  221 + const pid = generatePid();
  222 +
  223 + let root_path;
  224 + if (mode === 'sandbox') {
  225 + root_path = path.join(DATA_DIR, 'companies', company_id, 'projects', pid);
  226 + fs.mkdirSync(root_path, { recursive: true });
  227 + } else if (mode === 'linked-new') {
  228 + if (!target_path) throw new Error('target_path required for linked-new');
  229 + if (!path.isAbsolute(target_path)) {
  230 + throw new Error('target_path must be absolute');
  231 + }
  232 + if (isSensitivePath(target_path)) {
  233 + const e = new Error(`sensitive_path: ${target_path} is in blacklist`);
  234 + e.code = 'SENSITIVE_PATH';
  235 + throw e;
  236 + }
  237 + // 父目录必须存在
  238 + const parent = path.dirname(target_path);
  239 + if (!fs.existsSync(parent)) {
  240 + throw new Error(`parent dir does not exist: ${parent}`);
  241 + }
  242 + // 必须不存在或为空
  243 + if (fs.existsSync(target_path)) {
  244 + const entries = fs.readdirSync(target_path);
  245 + if (entries.length > 0) {
  246 + throw new Error(`target_path is not empty: ${target_path}`);
  247 + }
  248 + } else {
  249 + fs.mkdirSync(target_path, { recursive: false });
  250 + }
  251 + root_path = path.resolve(target_path);
  252 + // 注册到 linked-projects
  253 + const reg = loadLinkedRegistry();
  254 + reg[pid] = {
  255 + root_path,
  256 + mode: 'linked-new',
  257 + company_id,
  258 + registered_at: new Date().toISOString(),
  259 + };
  260 + saveLinkedRegistry(reg);
  261 + } else if (mode === 'linked-existing') {
  262 + const e = new Error('linked-existing mode is v2; not supported in v1');
  263 + e.code = 'NOT_IMPLEMENTED';
  264 + throw e;
  265 + } else {
  266 + throw new Error(`unknown mode: ${mode}`);
  267 + }
  268 +
  269 + // 立即注入缓存
  270 + ensureInit();
  271 + cache.set(pid, { root_path, mode, company_id });
  272 +
  273 + return { pid, root_path, mode };
  274 +}
  275 +
  276 +function unregisterLinkedProject(pid) {
  277 + const reg = loadLinkedRegistry();
  278 + if (reg[pid]) {
  279 + delete reg[pid];
  280 + saveLinkedRegistry(reg);
  281 + }
  282 + if (cache) cache.delete(pid);
  283 +}
  284 +
  285 +function listProjects() {
  286 + ensureInit();
  287 + return Array.from(cache.entries()).map(([pid, entry]) => ({ pid, ...entry }));
  288 +}
  289 +
  290 +/** 测试用:重置缓存,强制下次重新扫描 */
  291 +function _resetForTesting() {
  292 + cache = null;
  293 +}
  294 +
  295 +module.exports = {
  296 + init,
  297 + resolveProjectRoot,
  298 + resolveProjectMeta,
  299 + findProjectByPath,
  300 + resolveUnitDir,
  301 + resolvePrdSite,
  302 + resolveSddSite,
  303 + resolveAgentState,
  304 + resolveAgentTodolist,
  305 + resolveAgentSnapshotDir,
  306 + resolveTimelineLog,
  307 + resolveBugsLog,
  308 + resolveChangelog,
  309 + resolveStreamLog,
  310 + resolveHistoryDir,
  311 + resolveIntegrationDir,
  312 + resolveUnitsJson,
  313 + resolveProjectJson,
  314 + resolveMemoryDir,
  315 + resolveCompanyDir,
  316 + allocateProjectRoot,
  317 + unregisterLinkedProject,
  318 + listProjects,
  319 + isSensitivePath,
  320 + _resetForTesting,
  321 + // 常量
  322 + DATA_DIR,
  323 + LINKED_REGISTRY,
  324 + SENSITIVE_PREFIXES,
  325 +};
server/services/devloop/sse-broker.js 0 → 100644
  1 +/**
  2 + * DevLoop · sseBroker
  3 + *
  4 + * 每项目一个广播通道,多订阅者。
  5 + * - subscribe(pid, res, opts) 把 HTTP res 注册为订阅者
  6 + * - emit(pid, event, data) 写一行到 stream.log + 广播给所有订阅者
  7 + * - 心跳:每 25s 一条 `:keepalive\n\n` 注释
  8 + * - 断线重连:客户端发 Last-Event-ID header,replay 从 stream.log 读 > 该 id 的行
  9 + *
  10 + * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-11
  11 + * docs/prd/newfunction/_spec/api-sse.md
  12 + * docs/prd/newfunction/_spec/sdd-process.md · P-5
  13 + */
  14 +
  15 +const fs = require('fs');
  16 +const path = require('path');
  17 +const pathResolver = require('./path-resolver');
  18 +
  19 +const HEARTBEAT_MS = 25_000;
  20 +
  21 +// pid → Set<{ res, heartbeatTimer }>
  22 +const subscriptions = new Map();
  23 +
  24 +// 单调递增的事件 id(跨进程不严格保证;崩溃重启从 stream.log 读最大值续)
  25 +let eventCounter = 0;
  26 +
  27 +function _streamLogPath(pid) {
  28 + return pathResolver.resolveStreamLog(pid);
  29 +}
  30 +
  31 +function _initCounterFromLog(pid) {
  32 + // 启动时如果 stream.log 已存在,找最大 id 续号
  33 + const p = _streamLogPath(pid);
  34 + if (!fs.existsSync(p)) return;
  35 + const raw = fs.readFileSync(p, 'utf8');
  36 + const matches = raw.match(/^id: (\d+)$/gm) || [];
  37 + for (const m of matches) {
  38 + const n = parseInt(m.slice(4), 10);
  39 + if (n > eventCounter) eventCounter = n;
  40 + }
  41 +}
  42 +
  43 +function _ensureCounterInit(pid) {
  44 + // 全局单调即可;不严格 per-pid(避免每次启动都全扫)
  45 + if (eventCounter === 0) _initCounterFromLog(pid);
  46 +}
  47 +
  48 +/**
  49 + * @param {string} pid
  50 + * @param {http.ServerResponse} res - 调用方应已 writeHead(200, SSE headers)
  51 + * @param {object} opts - { replayFromOffset?: string | number (Last-Event-ID) }
  52 + */
  53 +function subscribe(pid, res, opts = {}) {
  54 + _ensureCounterInit(pid);
  55 +
  56 + // Replay:从 stream.log 读 > replayFromOffset 的行
  57 + if (opts.replayFromOffset != null) {
  58 + const fromId = parseInt(opts.replayFromOffset, 10) || 0;
  59 + _replayTo(res, pid, fromId);
  60 + }
  61 +
  62 + // 心跳
  63 + const heartbeat = setInterval(() => {
  64 + try { res.write(':keepalive\n\n'); } catch { /* ignore broken pipe */ }
  65 + }, HEARTBEAT_MS);
  66 +
  67 + // 加订阅
  68 + if (!subscriptions.has(pid)) subscriptions.set(pid, new Set());
  69 + const entry = { res, heartbeatTimer: heartbeat };
  70 + subscriptions.get(pid).add(entry);
  71 +
  72 + // 客户端断开
  73 + res.on('close', () => {
  74 + clearInterval(heartbeat);
  75 + subscriptions.get(pid)?.delete(entry);
  76 + if (subscriptions.get(pid)?.size === 0) subscriptions.delete(pid);
  77 + });
  78 +}
  79 +
  80 +function _replayTo(res, pid, fromId) {
  81 + const p = _streamLogPath(pid);
  82 + if (!fs.existsSync(p)) return;
  83 +
  84 + const raw = fs.readFileSync(p, 'utf8');
  85 + // stream.log 格式为 SSE 帧:`id: N\nevent: X\ndata: {...}\n\n`
  86 + const blocks = raw.split('\n\n').filter(Boolean);
  87 + for (const block of blocks) {
  88 + const idMatch = block.match(/^id: (\d+)/m);
  89 + if (!idMatch) continue;
  90 + const id = parseInt(idMatch[1], 10);
  91 + if (id <= fromId) continue;
  92 + try { res.write(block + '\n\n'); } catch { return; }
  93 + }
  94 +}
  95 +
  96 +/**
  97 + * 广播一条事件 + 落盘 stream.log。
  98 + * @param {string} pid
  99 + * @param {string} event - 事件名,如 "unit.state" / "bug.opened"
  100 + * @param {any} data
  101 + */
  102 +function emit(pid, event, data) {
  103 + _ensureCounterInit(pid);
  104 + const id = ++eventCounter;
  105 + const payload = JSON.stringify(data == null ? {} : data);
  106 + const line = `id: ${id}\nevent: ${event}\ndata: ${payload}\n\n`;
  107 +
  108 + // 1. 落盘
  109 + const p = _streamLogPath(pid);
  110 + fs.mkdirSync(path.dirname(p), { recursive: true });
  111 + fs.appendFileSync(p, line);
  112 +
  113 + // 2. 广播
  114 + const subs = subscriptions.get(pid);
  115 + if (subs) {
  116 + for (const entry of subs) {
  117 + try { entry.res.write(line); } catch { /* ignore broken pipe */ }
  118 + }
  119 + }
  120 +
  121 + return { id, event };
  122 +}
  123 +
  124 +/** 项目结束:发 close + 关所有订阅。 */
  125 +function close(pid) {
  126 + const subs = subscriptions.get(pid);
  127 + if (!subs) return;
  128 + for (const entry of subs) {
  129 + try { entry.res.write('event: close\ndata: {}\n\n'); } catch { /* ignore */ }
  130 + try { entry.res.end(); } catch { /* ignore */ }
  131 + clearInterval(entry.heartbeatTimer);
  132 + }
  133 + subscriptions.delete(pid);
  134 +}
  135 +
  136 +/** 测试用 */
  137 +function _resetForTesting() {
  138 + for (const subs of subscriptions.values()) {
  139 + for (const entry of subs) clearInterval(entry.heartbeatTimer);
  140 + }
  141 + subscriptions.clear();
  142 + eventCounter = 0;
  143 +}
  144 +
  145 +function _activeSubscribers(pid) {
  146 + return subscriptions.get(pid)?.size || 0;
  147 +}
  148 +
  149 +module.exports = {
  150 + subscribe,
  151 + emit,
  152 + close,
  153 + HEARTBEAT_MS,
  154 + _resetForTesting,
  155 + _activeSubscribers,
  156 +};
server/services/devloop/timeline-logger.js 0 → 100644
  1 +/**
  2 + * DevLoop · timelineLogger
  3 + *
  4 + * 全流程统一事件日志(M-13 ProcessTimeline)。append-only NDJSON。
  5 + * - 每行一个事件
  6 + * - 每条 ≤ 1KB(summary 自动截 280 字符;payload > 4KB 强制 ref 形式)
  7 + * - 同步原子写(appendFileSync)
  8 + *
  9 + * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-15
  10 + * docs/prd/newfunction/_spec/sdd-model.md · M-13
  11 + */
  12 +
  13 +const fs = require('fs');
  14 +const path = require('path');
  15 +const readline = require('readline');
  16 +
  17 +const pathResolver = require('./path-resolver');
  18 +
  19 +const SUMMARY_MAX = 280;
  20 +const PAYLOAD_MAX_BYTES = 4096;
  21 +
  22 +function _logPath(pid) { return pathResolver.resolveTimelineLog(pid); }
  23 +
  24 +function _truncate(s, n) {
  25 + if (typeof s !== 'string') s = String(s);
  26 + if (s.length <= n) return s;
  27 + return s.slice(0, n - 1) + '…';
  28 +}
  29 +
  30 +/**
  31 + * Append 一条事件。同步写。
  32 + * @param {string} pid
  33 + * @param {object} ev - { source, kind, ref?, summary, payload? }
  34 + */
  35 +function append(pid, ev) {
  36 + if (!ev || !ev.source || !ev.kind || !ev.summary) {
  37 + throw new Error('timelineLogger.append: source/kind/summary required');
  38 + }
  39 +
  40 + const event = {
  41 + ts: Math.floor(Date.now() / 1000),
  42 + source: ev.source,
  43 + kind: ev.kind,
  44 + ref: ev.ref || null,
  45 + summary: _truncate(ev.summary, SUMMARY_MAX),
  46 + };
  47 +
  48 + // payload 超大 → 强制改 ref 形式(不写盘外部文件,仅写入 ref 占位)
  49 + if (ev.payload !== undefined) {
  50 + const json = JSON.stringify(ev.payload);
  51 + if (json.length > PAYLOAD_MAX_BYTES) {
  52 + event.payload = { oversize: true, original_bytes: json.length };
  53 + } else {
  54 + event.payload = ev.payload;
  55 + }
  56 + }
  57 +
  58 + const line = JSON.stringify(event) + '\n';
  59 + const p = _logPath(pid);
  60 + fs.mkdirSync(path.dirname(p), { recursive: true });
  61 + fs.appendFileSync(p, line);
  62 + return event;
  63 +}
  64 +
  65 +/**
  66 + * 读全部 / 过滤事件。
  67 + * @param {object} opts - { limit?, since?: unix ts, source?, kind? }
  68 + */
  69 +function read(pid, opts = {}) {
  70 + const p = _logPath(pid);
  71 + if (!fs.existsSync(p)) return [];
  72 +
  73 + const limit = opts.limit || 100;
  74 + const since = opts.since;
  75 + const filterSource = opts.source;
  76 + const filterKind = opts.kind;
  77 +
  78 + const lines = fs.readFileSync(p, 'utf8').split('\n').filter(Boolean);
  79 + const events = [];
  80 +
  81 + // 倒序读,遇到 limit 停(避免 load 整个 file)
  82 + for (let i = lines.length - 1; i >= 0; i--) {
  83 + let ev;
  84 + try { ev = JSON.parse(lines[i]); } catch { continue; }
  85 + if (since && ev.ts < since) break; // 由于 ts 单调,可以提前 break
  86 + if (filterSource && ev.source !== filterSource) continue;
  87 + if (filterKind && ev.kind !== filterKind) continue;
  88 + events.push(ev);
  89 + if (events.length >= limit) break;
  90 + }
  91 +
  92 + return events.reverse(); // 输出按时间正序
  93 +}
  94 +
  95 +/** 最近 N 条(已按时间正序) */
  96 +function tail(pid, n = 10) {
  97 + return read(pid, { limit: n });
  98 +}
  99 +
  100 +module.exports = { append, read, tail };
server/services/devloop/unit-registry.js 0 → 100644
  1 +/**
  2 + * DevLoop · unitRegistry
  3 + *
  4 + * Unit 状态机 (M-2 + P-1) 的唯一真源。所有 markCoding/markTesting/pass/fail/
  5 + * block/reset 都走 FSM 校验,非法转换抛 InvalidTransitionError。
  6 + *
  7 + * 持久化:通过 docStore 读写 units.json(不直接 fs IO)。
  8 + * 事件:每次合法状态转换发 SSE `unit.state`(若 sseBroker 注入)。
  9 + *
  10 + * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-10
  11 + * docs/prd/newfunction/_spec/sdd-process.md · P-1
  12 + * docs/prd/newfunction/_spec/sdd-model.md · M-2
  13 + */
  14 +
  15 +const docStore = require('./doc-store');
  16 +const pathResolver = require('./path-resolver');
  17 +
  18 +class InvalidTransitionError extends Error {
  19 + constructor(from, to, unitId) {
  20 + super(`Invalid state transition for ${unitId}: ${from} → ${to}`);
  21 + this.name = 'InvalidTransitionError';
  22 + this.from = from;
  23 + this.to = to;
  24 + this.unitId = unitId;
  25 + }
  26 +}
  27 +
  28 +// FSM 合法转换表(来自 sdd-process P-1)
  29 +// from → allowed [to] 集合
  30 +const TRANSITIONS = {
  31 + pending: ['coding'],
  32 + coding: ['testing', 'failed'],
  33 + testing: ['passed', 'failed'],
  34 + failed: ['coding', 'blocked'],
  35 + passed: ['pending'], // L2 reset
  36 + blocked: ['pending'], // 人类干预后
  37 +};
  38 +
  39 +function canTransition(from, to) {
  40 + if (!TRANSITIONS[from]) return false;
  41 + return TRANSITIONS[from].includes(to);
  42 +}
  43 +
  44 +// sseBroker 是可选依赖。LoopController 启动时 attach 进来。
  45 +let sseBroker = null;
  46 +function attachSseBroker(broker) {
  47 + sseBroker = broker;
  48 +}
  49 +
  50 +// ────────────────────────────────────────────────────────────────
  51 +// 读
  52 +// ────────────────────────────────────────────────────────────────
  53 +
  54 +function _loadUnits(pid) {
  55 + const u = docStore.readUnits(pid);
  56 + if (!u) throw new Error(`units.json not found for ${pid}`);
  57 + return u;
  58 +}
  59 +
  60 +function list(pid) {
  61 + return _loadUnits(pid).units;
  62 +}
  63 +
  64 +function get(pid, unitId) {
  65 + const u = list(pid).find(x => x.id === unitId);
  66 + if (!u) throw new Error(`unit ${unitId} not found in project ${pid}`);
  67 + return u;
  68 +}
  69 +
  70 +function pending(pid) { return list(pid).filter(u => u.status === 'pending'); }
  71 +function passed(pid) { return list(pid).filter(u => u.status === 'passed'); }
  72 +function blocked(pid) { return list(pid).filter(u => u.status === 'blocked'); }
  73 +function failed(pid) { return list(pid).filter(u => u.status === 'failed'); }
  74 +function anyBlocked(pid) { return blocked(pid).length > 0; }
  75 +
  76 +/**
  77 + * L2 闸门:全部 unit status=passed 且 contract_hash 与当前 SDD 一致。
  78 + * 这里只检查 status;contract_hash 一致性由 LoopController 调用前先 verifyContractHash
  79 + * (这是更深的检查,需要读每个 unit 的 contract.json,太重不放这里)。
  80 + */
  81 +function l2GateOpen(pid) {
  82 + const units = list(pid);
  83 + if (units.length === 0) return false;
  84 + return units.every(u => u.status === 'passed');
  85 +}
  86 +
  87 +// ────────────────────────────────────────────────────────────────
  88 +// 写(FSM 转换)
  89 +// ────────────────────────────────────────────────────────────────
  90 +
  91 +function _writeBack(pid, unitsJson) {
  92 + docStore.writeUnits(pid, unitsJson, { skipValidation: false });
  93 +}
  94 +
  95 +function _transition(pid, unitId, to, extra = {}) {
  96 + const unitsJson = _loadUnits(pid);
  97 + const unit = unitsJson.units.find(u => u.id === unitId);
  98 + if (!unit) throw new Error(`unit ${unitId} not found in project ${pid}`);
  99 +
  100 + const from = unit.status;
  101 + if (!canTransition(from, to)) {
  102 + throw new InvalidTransitionError(from, to, unitId);
  103 + }
  104 +
  105 + unit.status = to;
  106 + Object.assign(unit, extra);
  107 + _writeBack(pid, unitsJson);
  108 +
  109 + if (sseBroker) {
  110 + sseBroker.emit(pid, 'unit.state', {
  111 + unit_id: unitId,
  112 + from, to,
  113 + retries: unit.retries,
  114 + reason: extra.reason || null,
  115 + });
  116 + }
  117 +
  118 + return unit;
  119 +}
  120 +
  121 +function markCoding(pid, unitId) {
  122 + return _transition(pid, unitId, 'coding');
  123 +}
  124 +
  125 +function markTesting(pid, unitId) {
  126 + return _transition(pid, unitId, 'testing');
  127 +}
  128 +
  129 +function pass(pid, unitId, contractHash) {
  130 + return _transition(pid, unitId, 'passed', { contract_hash: contractHash });
  131 +}
  132 +
  133 +function fail(pid, unitId, reason) {
  134 + // failed 时累加 retries
  135 + const unitsJson = _loadUnits(pid);
  136 + const unit = unitsJson.units.find(u => u.id === unitId);
  137 + if (!unit) throw new Error(`unit ${unitId} not found`);
  138 +
  139 + if (!canTransition(unit.status, 'failed')) {
  140 + throw new InvalidTransitionError(unit.status, 'failed', unitId);
  141 + }
  142 +
  143 + const from = unit.status;
  144 + unit.status = 'failed';
  145 + unit.retries = (unit.retries || 0) + 1;
  146 + unit.last_failure_reason = reason;
  147 + _writeBack(pid, unitsJson);
  148 +
  149 + if (sseBroker) {
  150 + sseBroker.emit(pid, 'unit.state', {
  151 + unit_id: unitId, from, to: 'failed',
  152 + retries: unit.retries, reason,
  153 + });
  154 + }
  155 +
  156 + return unit;
  157 +}
  158 +
  159 +function block(pid, unitId, reason) {
  160 + return _transition(pid, unitId, 'blocked', { block_reason: reason });
  161 +}
  162 +
  163 +/**
  164 + * 重置一批 Unit 到 pending(L2 修复后或 L3 增量后)。
  165 + * 允许 passed → pending 和 blocked → pending;其他状态保留(warning log)。
  166 + */
  167 +function reset(pid, unitIds) {
  168 + const unitsJson = _loadUnits(pid);
  169 + const reset_units = [];
  170 + const skipped = [];
  171 + for (const id of unitIds) {
  172 + const u = unitsJson.units.find(x => x.id === id);
  173 + if (!u) {
  174 + skipped.push({ id, reason: 'not_found' });
  175 + continue;
  176 + }
  177 + const from = u.status;
  178 + if (!canTransition(from, 'pending')) {
  179 + skipped.push({ id, reason: `cannot_reset_from_${from}` });
  180 + continue;
  181 + }
  182 + u.status = 'pending';
  183 + u.retries = 0; // 重置 retry counter
  184 + reset_units.push({ id, from });
  185 + }
  186 + _writeBack(pid, unitsJson);
  187 +
  188 + if (sseBroker && reset_units.length) {
  189 + for (const r of reset_units) {
  190 + sseBroker.emit(pid, 'unit.state', {
  191 + unit_id: r.id, from: r.from, to: 'pending', retries: 0, reason: 'reset',
  192 + });
  193 + }
  194 + }
  195 + return { reset: reset_units, skipped };
  196 +}
  197 +
  198 +// ────────────────────────────────────────────────────────────────
  199 +// L3 增量 diff
  200 +// ────────────────────────────────────────────────────────────────
  201 +
  202 +/**
  203 + * 对比新旧 units.json,返回 added/changed/removed 三类 unit id。
  204 + * - added : 新 list 有,旧 list 无
  205 + * - changed: 双方都有但 contract_hash 不同
  206 + * - removed: 旧 list 有,新 list 无
  207 + */
  208 +function diffUnits(oldUnitsList, newUnitsList) {
  209 + const oldMap = new Map((oldUnitsList || []).map(u => [u.id, u]));
  210 + const newMap = new Map((newUnitsList || []).map(u => [u.id, u]));
  211 +
  212 + const added = [];
  213 + const changed = [];
  214 + const removed = [];
  215 +
  216 + for (const [id, n] of newMap) {
  217 + if (!oldMap.has(id)) {
  218 + added.push(id);
  219 + } else if (oldMap.get(id).contract_hash !== n.contract_hash) {
  220 + changed.push(id);
  221 + }
  222 + }
  223 + for (const [id] of oldMap) {
  224 + if (!newMap.has(id)) removed.push(id);
  225 + }
  226 + return { added, changed, removed };
  227 +}
  228 +
  229 +// ────────────────────────────────────────────────────────────────
  230 +// 初始化 units.json 骨架
  231 +// ────────────────────────────────────────────────────────────────
  232 +
  233 +/**
  234 + * Architect 第一次生成 SDD 时,构造 units.json 骨架。
  235 + * 各 unit 自带默认 retry_budget=5、status=pending、retries=0。
  236 + */
  237 +function buildUnitsJson({ project_id, prd_rev, sdd_rev, units, l2_budget = 3 }) {
  238 + return {
  239 + project_id,
  240 + prd_rev: String(prd_rev),
  241 + sdd_rev: String(sdd_rev),
  242 + l2_rounds: 0,
  243 + l2_budget,
  244 + units: units.map(u => ({
  245 + id: u.id,
  246 + type: u.type,
  247 + status: 'pending',
  248 + depends_on: u.depends_on || [],
  249 + retries: 0,
  250 + retry_budget: u.retry_budget || 5,
  251 + contract_hash: u.contract_hash,
  252 + ...(u.last_run ? { last_run: u.last_run } : {}),
  253 + })),
  254 + };
  255 +}
  256 +
  257 +module.exports = {
  258 + // 读
  259 + list, get,
  260 + pending, passed, blocked, failed, anyBlocked,
  261 + l2GateOpen,
  262 +
  263 + // 写
  264 + markCoding, markTesting, pass, fail, block, reset,
  265 +
  266 + // L3
  267 + diffUnits,
  268 +
  269 + // 工具
  270 + buildUnitsJson,
  271 + canTransition,
  272 + TRANSITIONS,
  273 +
  274 + // 错误
  275 + InvalidTransitionError,
  276 +
  277 + // 依赖注入
  278 + attachSseBroker,
  279 +};
server/tests/devloop/bug-tracker.test.js 0 → 100644
  1 +/**
  2 + * bugTracker 单测
  3 + */
  4 +const fs = require('fs');
  5 +
  6 +const pathResolver = require('../../services/devloop/path-resolver');
  7 +const bugTracker = require('../../services/devloop/bug-tracker');
  8 +
  9 +describe('bugTracker', () => {
  10 + let pid, root;
  11 +
  12 + beforeEach(() => {
  13 + pathResolver._resetForTesting();
  14 + const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' });
  15 + pid = r.pid;
  16 + root = r.root_path;
  17 + });
  18 +
  19 + afterEach(() => {
  20 + fs.rmSync(root, { recursive: true, force: true });
  21 + });
  22 +
  23 + describe('open + dedup', () => {
  24 + it('open new bug returns id', () => {
  25 + const b = bugTracker.open(pid, {
  26 + source: 'unit_test', unit_id: 'auth-be',
  27 + test_name: 'login.invalid-pwd', severity: 'high',
  28 + title: 'expected 401 got 500',
  29 + });
  30 + expect(b.id).toMatch(/^bug-/);
  31 + expect(b.status).toBe('open');
  32 + });
  33 +
  34 + it('same unit + test → dedup returns existing id', () => {
  35 + const b1 = bugTracker.open(pid, {
  36 + unit_id: 'auth-be', test_name: 'login.invalid-pwd', title: 'T1',
  37 + });
  38 + const b2 = bugTracker.open(pid, {
  39 + unit_id: 'auth-be', test_name: 'login.invalid-pwd', title: 'T2',
  40 + });
  41 + expect(b2.id).toBe(b1.id);
  42 + });
  43 + });
  44 +
  45 + describe('transition', () => {
  46 + it('open → fixing → fixed → verified', () => {
  47 + const b = bugTracker.open(pid, {
  48 + unit_id: 'auth-be', test_name: 'x', title: 'T',
  49 + });
  50 + bugTracker.transition(pid, b.id, { to: 'fixing', by: 'coder' });
  51 + expect(bugTracker.get(pid, b.id).status).toBe('fixing');
  52 + bugTracker.transition(pid, b.id, { to: 'fixed', by: 'tester' });
  53 + expect(bugTracker.get(pid, b.id).status).toBe('fixed');
  54 + bugTracker.transition(pid, b.id, { to: 'verified', by: 'integrator' });
  55 + expect(bugTracker.get(pid, b.id).status).toBe('verified');
  56 + });
  57 +
  58 + it('open → verified (skip) throws', () => {
  59 + const b = bugTracker.open(pid, { unit_id: 'x-be', test_name: 'y', title: 'T' });
  60 + expect(() => bugTracker.transition(pid, b.id, { to: 'verified', by: 'system' }))
  61 + .toThrow(bugTracker.InvalidBugTransitionError);
  62 + });
  63 +
  64 + it('verified → open (reopen) ok', () => {
  65 + const b = bugTracker.open(pid, { unit_id: 'x-be', test_name: 'y', title: 'T' });
  66 + bugTracker.transition(pid, b.id, { to: 'fixing', by: 'coder' });
  67 + bugTracker.transition(pid, b.id, { to: 'fixed', by: 'tester' });
  68 + bugTracker.transition(pid, b.id, { to: 'verified', by: 'integrator' });
  69 + bugTracker.transition(pid, b.id, { to: 'open', by: 'system' });
  70 + expect(bugTracker.get(pid, b.id).status).toBe('open');
  71 + });
  72 + });
  73 +
  74 + describe('list + stats', () => {
  75 + it('filter by status', () => {
  76 + const b1 = bugTracker.open(pid, { unit_id: 'a-be', test_name: 't1', title: 'T1' });
  77 + const b2 = bugTracker.open(pid, { unit_id: 'b-be', test_name: 't2', title: 'T2' });
  78 + bugTracker.transition(pid, b2.id, { to: 'fixing', by: 'coder' });
  79 + const open = bugTracker.list(pid, { status: ['open'] });
  80 + const fixing = bugTracker.list(pid, { status: ['fixing'] });
  81 + expect(open.map(b => b.id)).toContain(b1.id);
  82 + expect(fixing.map(b => b.id)).toContain(b2.id);
  83 + });
  84 +
  85 + it('stats counts by status', () => {
  86 + bugTracker.open(pid, { unit_id: 'a-be', test_name: 't1', title: 'T', severity: 'high' });
  87 + bugTracker.open(pid, { unit_id: 'b-be', test_name: 't2', title: 'T', severity: 'low' });
  88 + const s = bugTracker.stats(pid);
  89 + expect(s.open).toBe(2);
  90 + expect(s.by_severity.high).toBe(1);
  91 + expect(s.by_severity.low).toBe(1);
  92 + });
  93 + });
  94 +
  95 + describe('history', () => {
  96 + it('tracks all transitions', () => {
  97 + const b = bugTracker.open(pid, { unit_id: 'a-be', test_name: 't', title: 'T' });
  98 + bugTracker.transition(pid, b.id, { to: 'fixing', by: 'coder' });
  99 + bugTracker.transition(pid, b.id, { to: 'fixed', by: 'tester' });
  100 + const h = bugTracker.history(pid, b.id);
  101 + // 含 open 时的 history (1) + 2 次 transition = 3 条
  102 + expect(h.length).toBeGreaterThanOrEqual(3);
  103 + });
  104 + });
  105 +});
server/tests/devloop/doc-store.test.js 0 → 100644
  1 +/**
  2 + * docStore 单测
  3 + */
  4 +const docStore = require('../../services/devloop/doc-store');
  5 +
  6 +describe('docStore', () => {
  7 + describe('validateHTML', () => {
  8 + it('rejects empty', () => {
  9 + const r = docStore.validateHTML('', 'prd');
  10 + expect(r.ok).toBe(false);
  11 + });
  12 +
  13 + it('rejects missing article', () => {
  14 + const r = docStore.validateHTML('<p>hi</p>', 'prd');
  15 + expect(r.ok).toBe(false);
  16 + expect(r.errors.join(' ')).toContain('article');
  17 + });
  18 +
  19 + it('rejects wrong doc kind', () => {
  20 + const r = docStore.validateHTML('<article data-doc="sdd" data-rev="1"><section><h3>x</h3><p>y</p></section></article>', 'prd');
  21 + expect(r.ok).toBe(false);
  22 + });
  23 +
  24 + it('accepts valid PRD chapter', () => {
  25 + const html = `<article data-doc="prd" data-rev="2">
  26 + <section data-rev="1"><h3>Title</h3><p>body</p></section>
  27 + <section data-rev="2"><h3>Other</h3><p>more</p></section>
  28 + </article>`;
  29 + const r = docStore.validateHTML(html, 'prd');
  30 + expect(r.ok).toBe(true);
  31 + });
  32 +
  33 + it('detects section without h3', () => {
  34 + const html = `<article data-doc="prd" data-rev="1">
  35 + <section><p>only p</p></section>
  36 + </article>`;
  37 + const r = docStore.validateHTML(html, 'prd');
  38 + expect(r.ok).toBe(false);
  39 + expect(r.errors.join(' ')).toContain('h3');
  40 + });
  41 +
  42 + it('detects non-monotonic data-rev', () => {
  43 + const html = `<article data-doc="prd" data-rev="3">
  44 + <section data-rev="2"><h3>x</h3><p>y</p></section>
  45 + <section data-rev="1"><h3>x</h3><p>y</p></section>
  46 + </article>`;
  47 + const r = docStore.validateHTML(html, 'prd');
  48 + expect(r.ok).toBe(false);
  49 + expect(r.errors.join(' ')).toContain('monotonic');
  50 + });
  51 + });
  52 +
  53 + describe('validateUnitsSchema', () => {
  54 + const validUnit = {
  55 + id: 'auth-be', type: 'be', status: 'pending',
  56 + depends_on: [], retries: 0, retry_budget: 5,
  57 + contract_hash: 'sha256:abc',
  58 + };
  59 +
  60 + it('accepts valid units.json', () => {
  61 + const json = {
  62 + project_id: 'p-test', prd_rev: '1', sdd_rev: '1',
  63 + units: [validUnit], l2_rounds: 0, l2_budget: 3,
  64 + };
  65 + const r = docStore.validateUnitsSchema(json);
  66 + expect(r.ok).toBe(true);
  67 + });
  68 +
  69 + it('rejects invalid unit id', () => {
  70 + const json = {
  71 + project_id: 'p', units: [{ ...validUnit, id: 'AuthBE' }],
  72 + };
  73 + const r = docStore.validateUnitsSchema(json);
  74 + expect(r.ok).toBe(false);
  75 + expect(r.errors.join(' ')).toContain('naming');
  76 + });
  77 +
  78 + it('rejects duplicate ids', () => {
  79 + const json = {
  80 + project_id: 'p', units: [validUnit, validUnit],
  81 + };
  82 + const r = docStore.validateUnitsSchema(json);
  83 + expect(r.ok).toBe(false);
  84 + expect(r.errors.join(' ')).toContain('duplicate');
  85 + });
  86 +
  87 + it('rejects unknown depends_on', () => {
  88 + const json = {
  89 + project_id: 'p',
  90 + units: [{ ...validUnit, depends_on: ['ghost-be'] }],
  91 + };
  92 + const r = docStore.validateUnitsSchema(json);
  93 + expect(r.ok).toBe(false);
  94 + expect(r.errors.join(' ')).toContain('unknown unit');
  95 + });
  96 +
  97 + it('detects cycle', () => {
  98 + const a = { id: 'a-be', type: 'be', status: 'pending', depends_on: ['b-be'], retries: 0, retry_budget: 5, contract_hash: 'sha256:1' };
  99 + const b = { id: 'b-be', type: 'be', status: 'pending', depends_on: ['a-be'], retries: 0, retry_budget: 5, contract_hash: 'sha256:2' };
  100 + const json = { project_id: 'p', units: [a, b] };
  101 + const r = docStore.validateUnitsSchema(json);
  102 + expect(r.ok).toBe(false);
  103 + expect(r.errors.join(' ')).toContain('cycle');
  104 + });
  105 + });
  106 +
  107 + describe('hashContract', () => {
  108 + it('deterministic for same content', () => {
  109 + const c = { unit_id: 'x', exports: { http: [{ path: '/x' }] } };
  110 + const h1 = docStore.hashContract(c);
  111 + const h2 = docStore.hashContract(c);
  112 + expect(h1).toBe(h2);
  113 + expect(h1).toMatch(/^sha256:[a-f0-9]{16}$/);
  114 + });
  115 +
  116 + it('different content → different hash', () => {
  117 + const c1 = { unit_id: 'x', exports: { http: [{ path: '/x' }] } };
  118 + const c2 = { unit_id: 'x', exports: { http: [{ path: '/y' }] } };
  119 + expect(docStore.hashContract(c1)).not.toBe(docStore.hashContract(c2));
  120 + });
  121 +
  122 + it('canonical: key ordering does not affect hash', () => {
  123 + const c1 = { exports: { http: [{ a: 1, b: 2 }] } };
  124 + const c2 = { exports: { http: [{ b: 2, a: 1 }] } };
  125 + expect(docStore.hashContract(c1)).toBe(docStore.hashContract(c2));
  126 + });
  127 + });
  128 +});
server/tests/devloop/git-vault.test.js 0 → 100644
  1 +/**
  2 + * gitVault 单测
  3 + *
  4 + * 需要系统装了 git。如果没装则 skip 所有用例。
  5 + */
  6 +const { execSync } = require('child_process');
  7 +const fs = require('fs');
  8 +const path = require('path');
  9 +
  10 +const pathResolver = require('../../services/devloop/path-resolver');
  11 +const gitVault = require('../../services/devloop/git-vault');
  12 +const docStore = require('../../services/devloop/doc-store');
  13 +
  14 +let gitAvailable = false;
  15 +try { execSync('git --version', { stdio: 'ignore' }); gitAvailable = true; } catch { /* skip */ }
  16 +
  17 +(gitAvailable ? describe : describe.skip)('gitVault', () => {
  18 + let pid, root;
  19 +
  20 + beforeEach(() => {
  21 + pathResolver._resetForTesting();
  22 + gitVault._resetMutexesForTesting();
  23 + const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' });
  24 + pid = r.pid;
  25 + root = r.root_path;
  26 + });
  27 +
  28 + afterEach(() => {
  29 + fs.rmSync(root, { recursive: true, force: true });
  30 + });
  31 +
  32 + it('init creates .git + initial commit', async () => {
  33 + const r = await gitVault.init(pid);
  34 + expect(r.ok).toBe(true);
  35 + expect(fs.existsSync(path.join(root, '.git'))).toBe(true);
  36 + expect(fs.existsSync(path.join(root, '.gitignore'))).toBe(true);
  37 +
  38 + const logs = await gitVault.log(pid);
  39 + expect(logs.length).toBeGreaterThanOrEqual(1);
  40 + expect(logs[0].summary).toMatch(/^init: project p-/);
  41 + });
  42 +
  43 + it('init is idempotent', async () => {
  44 + await gitVault.init(pid);
  45 + const r = await gitVault.init(pid);
  46 + expect(r.already_exists).toBe(true);
  47 + });
  48 +
  49 + it('commit with content creates new sha', async () => {
  50 + await gitVault.init(pid);
  51 + // 写一个真实的文件再 commit (PRD/HTML 校验严格,这里直接 raw)
  52 + fs.writeFileSync(path.join(root, 'README.md'), 'hello\n');
  53 + const r = await gitVault.commit(pid, {
  54 + kind: 'chore', summary: 'add README',
  55 + author: { name: 'PM Agent', email: 'pm@devloop.local' },
  56 + });
  57 + expect(r.ok).toBe(true);
  58 + expect(r.skipped).toBeFalsy();
  59 + expect(r.sha).toMatch(/^[a-f0-9]{40}$/);
  60 +
  61 + // author 被记录
  62 + const logs = await gitVault.log(pid, { limit: 1 });
  63 + expect(logs[0].author_name).toBe('PM Agent');
  64 + expect(logs[0].author_email).toBe('pm@devloop.local');
  65 + });
  66 +
  67 + it('commit with nothing-to-commit returns skipped', async () => {
  68 + await gitVault.init(pid);
  69 + const r = await gitVault.commit(pid, {
  70 + kind: 'chore', summary: 'empty commit attempt',
  71 + });
  72 + expect(r.ok).toBe(true);
  73 + expect(r.skipped).toBe(true);
  74 + });
  75 +
  76 + it('tag + listTags', async () => {
  77 + await gitVault.init(pid);
  78 + await gitVault.tag(pid, 'shipped-v1');
  79 + const tags = await gitVault.listTags(pid);
  80 + expect(tags).toContain('shipped-v1');
  81 + });
  82 +
  83 + it('status detects dirty tree', async () => {
  84 + await gitVault.init(pid);
  85 + let s = await gitVault.status(pid);
  86 + expect(s.dirty).toBe(false);
  87 +
  88 + fs.writeFileSync(path.join(root, 'untracked.txt'), 'x');
  89 + s = await gitVault.status(pid);
  90 + expect(s.dirty).toBe(true);
  91 + });
  92 +
  93 + it('resetWorkingTree clears dirty', async () => {
  94 + await gitVault.init(pid);
  95 + fs.writeFileSync(path.join(root, 'dirt.txt'), 'x');
  96 + expect((await gitVault.status(pid)).dirty).toBe(true);
  97 +
  98 + await gitVault.resetWorkingTree(pid);
  99 + expect((await gitVault.status(pid)).dirty).toBe(false);
  100 + });
  101 +
  102 + it('archive produces zip', async () => {
  103 + await gitVault.init(pid);
  104 + fs.writeFileSync(path.join(root, 'kept.txt'), 'x');
  105 + await gitVault.commit(pid, { kind: 'chore', summary: 'add file' });
  106 + const r = await gitVault.archive(pid);
  107 + expect(fs.existsSync(r.path)).toBe(true);
  108 + fs.rmSync(r.path);
  109 + });
  110 +});
server/tests/devloop/path-resolver.test.js 0 → 100644
  1 +/**
  2 + * pathResolver 单测
  3 + */
  4 +const fs = require('fs');
  5 +const path = require('path');
  6 +const os = require('os');
  7 +
  8 +const pathResolver = require('../../services/devloop/path-resolver');
  9 +
  10 +// 用 tmpdir 隔离测试
  11 +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-test-'));
  12 +process.env.HOME = TEST_HOME; // 让 LINKED_REGISTRY 写到 tmp
  13 +
  14 +describe('pathResolver', () => {
  15 + beforeEach(() => {
  16 + pathResolver._resetForTesting();
  17 + });
  18 +
  19 + describe('isSensitivePath', () => {
  20 + it('rejects /etc', () => {
  21 + expect(pathResolver.isSensitivePath('/etc/passwd')).toBe(true);
  22 + });
  23 + it('rejects /usr/local/bin', () => {
  24 + expect(pathResolver.isSensitivePath('/usr/local/bin')).toBe(true);
  25 + });
  26 + it('rejects /etc/passwd absolute', () => {
  27 + expect(pathResolver.isSensitivePath('/etc/passwd')).toBe(true);
  28 + });
  29 + it('rejects /var/log/something', () => {
  30 + expect(pathResolver.isSensitivePath('/var/log/anything')).toBe(true);
  31 + });
  32 + it('accepts ~/code/project', () => {
  33 + expect(pathResolver.isSensitivePath(path.join(os.homedir(), 'code', 'project'))).toBe(false);
  34 + });
  35 + it('accepts /tmp/anything', () => {
  36 + expect(pathResolver.isSensitivePath('/tmp/anything')).toBe(false);
  37 + });
  38 + });
  39 +
  40 + describe('allocateProjectRoot · sandbox', () => {
  41 + it('creates root under data/companies/<cid>/projects/<pid>', () => {
  42 + const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' });
  43 + expect(r.pid).toMatch(/^p-[a-z0-9]{6}$/);
  44 + expect(r.mode).toBe('sandbox');
  45 + expect(r.root_path).toContain('data/companies/c-test/projects/');
  46 + expect(fs.existsSync(r.root_path)).toBe(true);
  47 + // cleanup
  48 + fs.rmSync(r.root_path, { recursive: true, force: true });
  49 + });
  50 + });
  51 +
  52 + describe('allocateProjectRoot · linked-new', () => {
  53 + it('rejects when target_path missing', () => {
  54 + expect(() =>
  55 + pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'linked-new' })
  56 + ).toThrow(/target_path required/);
  57 + });
  58 +
  59 + it('rejects relative target_path', () => {
  60 + expect(() =>
  61 + pathResolver.allocateProjectRoot({
  62 + company_id: 'c-test', mode: 'linked-new', target_path: 'relative/path',
  63 + })
  64 + ).toThrow(/must be absolute/);
  65 + });
  66 +
  67 + it('rejects sensitive path', () => {
  68 + expect(() =>
  69 + pathResolver.allocateProjectRoot({
  70 + company_id: 'c-test', mode: 'linked-new', target_path: '/etc/foo',
  71 + })
  72 + ).toThrow(/sensitive_path/);
  73 + });
  74 +
  75 + it('rejects when parent does not exist', () => {
  76 + expect(() =>
  77 + pathResolver.allocateProjectRoot({
  78 + company_id: 'c-test', mode: 'linked-new',
  79 + target_path: '/tmp/devloop-test-nonexistent-parent/proj',
  80 + })
  81 + ).toThrow(/parent dir does not exist/);
  82 + });
  83 +
  84 + it('rejects when target_path is non-empty dir', () => {
  85 + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-linked-'));
  86 + fs.writeFileSync(path.join(target, 'existing.txt'), 'x');
  87 + expect(() =>
  88 + pathResolver.allocateProjectRoot({
  89 + company_id: 'c-test', mode: 'linked-new', target_path: target,
  90 + })
  91 + ).toThrow(/not empty/);
  92 + fs.rmSync(target, { recursive: true, force: true });
  93 + });
  94 +
  95 + it('accepts empty existing dir', () => {
  96 + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-linked-'));
  97 + const r = pathResolver.allocateProjectRoot({
  98 + company_id: 'c-test', mode: 'linked-new', target_path: target,
  99 + });
  100 + expect(r.mode).toBe('linked-new');
  101 + expect(r.root_path).toBe(target);
  102 + // 检查 linked-registry
  103 + const reg = JSON.parse(fs.readFileSync(pathResolver.LINKED_REGISTRY, 'utf8'));
  104 + expect(reg[r.pid]).toBeDefined();
  105 + expect(reg[r.pid].mode).toBe('linked-new');
  106 +
  107 + fs.rmSync(target, { recursive: true, force: true });
  108 + pathResolver.unregisterLinkedProject(r.pid);
  109 + });
  110 + });
  111 +
  112 + describe('allocateProjectRoot · linked-existing rejected in v1', () => {
  113 + it('throws NOT_IMPLEMENTED', () => {
  114 + expect(() =>
  115 + pathResolver.allocateProjectRoot({
  116 + company_id: 'c-test', mode: 'linked-existing', target_path: '/tmp',
  117 + })
  118 + ).toThrow(/v2/);
  119 + });
  120 + });
  121 +
  122 + describe('resolveProjectRoot + 派生路径', () => {
  123 + it('throws on unknown pid', () => {
  124 + expect(() => pathResolver.resolveProjectRoot('p-nonexistent')).toThrow(/Unknown project/);
  125 + });
  126 +
  127 + it('after allocate, all resolvers work', () => {
  128 + const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' });
  129 + expect(pathResolver.resolveProjectRoot(r.pid)).toBe(r.root_path);
  130 + expect(pathResolver.resolveUnitDir(r.pid, 'auth-be'))
  131 + .toBe(path.join(r.root_path, 'units', 'auth-be'));
  132 + expect(pathResolver.resolvePrdSite(r.pid)).toBe(path.join(r.root_path, 'prd'));
  133 + expect(pathResolver.resolveSddSite(r.pid)).toBe(path.join(r.root_path, 'sdd'));
  134 + expect(pathResolver.resolveTimelineLog(r.pid)).toBe(path.join(r.root_path, 'timeline.ndjson'));
  135 + expect(pathResolver.resolveBugsLog(r.pid)).toBe(path.join(r.root_path, 'bugs.ndjson'));
  136 + expect(pathResolver.resolveAgentState(r.pid, 'pm'))
  137 + .toBe(path.join(r.root_path, 'agent-state', 'pm.json'));
  138 + fs.rmSync(r.root_path, { recursive: true, force: true });
  139 + });
  140 + });
  141 +
  142 + describe('memory dir 在 company 级', () => {
  143 + it('does not depend on pid', () => {
  144 + const m1 = pathResolver.resolveMemoryDir('c-test', 'pm');
  145 + expect(m1).toContain('companies/c-test/memory/devloop/pm');
  146 + expect(m1).not.toContain('projects/');
  147 + });
  148 + });
  149 +});
server/tests/devloop/timeline-logger.test.js 0 → 100644
  1 +/**
  2 + * timelineLogger + changelogWriter 单测
  3 + */
  4 +const fs = require('fs');
  5 +
  6 +const pathResolver = require('../../services/devloop/path-resolver');
  7 +const timelineLogger = require('../../services/devloop/timeline-logger');
  8 +const changelogWriter = require('../../services/devloop/changelog-writer');
  9 +
  10 +describe('timelineLogger', () => {
  11 + let pid, root;
  12 +
  13 + beforeEach(() => {
  14 + pathResolver._resetForTesting();
  15 + const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' });
  16 + pid = r.pid;
  17 + root = r.root_path;
  18 + });
  19 +
  20 + afterEach(() => {
  21 + fs.rmSync(root, { recursive: true, force: true });
  22 + });
  23 +
  24 + it('append writes NDJSON line', () => {
  25 + timelineLogger.append(pid, {
  26 + source: 'system', kind: 'project_created', ref: pid,
  27 + summary: 'test project',
  28 + });
  29 + const events = timelineLogger.read(pid);
  30 + expect(events).toHaveLength(1);
  31 + expect(events[0].kind).toBe('project_created');
  32 + });
  33 +
  34 + it('append rejects missing fields', () => {
  35 + expect(() => timelineLogger.append(pid, {})).toThrow();
  36 + expect(() => timelineLogger.append(pid, { source: 'x' })).toThrow();
  37 + });
  38 +
  39 + it('summary truncated to 280 chars', () => {
  40 + const longSummary = 'x'.repeat(500);
  41 + timelineLogger.append(pid, {
  42 + source: 'system', kind: 'test', summary: longSummary,
  43 + });
  44 + const events = timelineLogger.read(pid);
  45 + expect(events[0].summary.length).toBeLessThanOrEqual(280);
  46 + expect(events[0].summary).toMatch(/…$/);
  47 + });
  48 +
  49 + it('oversized payload reduced to ref marker', () => {
  50 + const big = { huge: 'x'.repeat(5000) };
  51 + timelineLogger.append(pid, {
  52 + source: 'system', kind: 'test', summary: 's', payload: big,
  53 + });
  54 + const events = timelineLogger.read(pid);
  55 + expect(events[0].payload.oversize).toBe(true);
  56 + });
  57 +
  58 + it('tail returns last N', () => {
  59 + for (let i = 0; i < 15; i++) {
  60 + timelineLogger.append(pid, { source: 'system', kind: 'k', summary: `${i}` });
  61 + }
  62 + const last5 = timelineLogger.tail(pid, 5);
  63 + expect(last5).toHaveLength(5);
  64 + // 时间正序,最后一条是最新
  65 + expect(last5[4].summary).toBe('14');
  66 + });
  67 +
  68 + it('filter by kind', () => {
  69 + timelineLogger.append(pid, { source: 'pm', kind: 'prd_drafted', summary: 'a' });
  70 + timelineLogger.append(pid, { source: 'arch', kind: 'sdd_generated', summary: 'b' });
  71 + timelineLogger.append(pid, { source: 'pm', kind: 'prd_drafted', summary: 'c' });
  72 + const r = timelineLogger.read(pid, { kind: 'prd_drafted' });
  73 + expect(r).toHaveLength(2);
  74 + });
  75 +});
  76 +
  77 +describe('changelogWriter', () => {
  78 + let pid, root;
  79 +
  80 + beforeEach(() => {
  81 + pathResolver._resetForTesting();
  82 + const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' });
  83 + pid = r.pid;
  84 + root = r.root_path;
  85 + });
  86 +
  87 + afterEach(() => {
  88 + fs.rmSync(root, { recursive: true, force: true });
  89 + });
  90 +
  91 + it('append writes entry', () => {
  92 + changelogWriter.append(pid, {
  93 + kind: 'prd_rev', rev: 1, by: 'pm-agent',
  94 + trigger: 'init', summary: 'first PRD',
  95 + });
  96 + const all = changelogWriter.read(pid);
  97 + expect(all).toHaveLength(1);
  98 + expect(all[0].kind).toBe('prd_rev');
  99 + expect(all[0].rev).toBe(1);
  100 + });
  101 +
  102 + it('rejects invalid kind', () => {
  103 + expect(() => changelogWriter.append(pid, {
  104 + kind: 'wrong-kind', rev: 1, summary: 's',
  105 + })).toThrow();
  106 + });
  107 +
  108 + it('latest returns most recent of kind', () => {
  109 + changelogWriter.append(pid, { kind: 'prd_rev', rev: 1, summary: 's1' });
  110 + changelogWriter.append(pid, { kind: 'sdd_rev', rev: 1, summary: 's2' });
  111 + changelogWriter.append(pid, { kind: 'prd_rev', rev: 2, summary: 's3' });
  112 + const r = changelogWriter.latest(pid, 'prd_rev');
  113 + expect(r.rev).toBe(2);
  114 + });
  115 +});
server/tests/devloop/unit-registry.test.js 0 → 100644
  1 +/**
  2 + * unitRegistry 单测 — 跑 FSM 转换
  3 + */
  4 +const fs = require('fs');
  5 +const path = require('path');
  6 +const os = require('os');
  7 +
  8 +const pathResolver = require('../../services/devloop/path-resolver');
  9 +const docStore = require('../../services/devloop/doc-store');
  10 +const unitRegistry = require('../../services/devloop/unit-registry');
  11 +
  12 +describe('unitRegistry', () => {
  13 + let pid;
  14 +
  15 + beforeEach(() => {
  16 + pathResolver._resetForTesting();
  17 + const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' });
  18 + pid = r.pid;
  19 + // 准备 units.json
  20 + const units = {
  21 + project_id: pid, prd_rev: '1', sdd_rev: '1', l2_rounds: 0, l2_budget: 3,
  22 + units: [
  23 + {
  24 + id: 'auth-be', type: 'be', status: 'pending', depends_on: [],
  25 + retries: 0, retry_budget: 5, contract_hash: 'sha256:aaaaaaaaaaaaaaaa',
  26 + },
  27 + {
  28 + id: 'auth-fe', type: 'fe', status: 'pending', depends_on: ['auth-be'],
  29 + retries: 0, retry_budget: 5, contract_hash: 'sha256:bbbbbbbbbbbbbbbb',
  30 + },
  31 + ],
  32 + };
  33 + docStore.writeUnits(pid, units);
  34 + });
  35 +
  36 + afterEach(() => {
  37 + const root = pathResolver.resolveProjectRoot(pid);
  38 + fs.rmSync(root, { recursive: true, force: true });
  39 + });
  40 +
  41 + describe('list / pending / passed', () => {
  42 + it('lists all units', () => {
  43 + expect(unitRegistry.list(pid)).toHaveLength(2);
  44 + });
  45 + it('all start pending', () => {
  46 + expect(unitRegistry.pending(pid)).toHaveLength(2);
  47 + expect(unitRegistry.passed(pid)).toHaveLength(0);
  48 + });
  49 + });
  50 +
  51 + describe('canTransition', () => {
  52 + it('pending → coding ok', () => {
  53 + expect(unitRegistry.canTransition('pending', 'coding')).toBe(true);
  54 + });
  55 + it('coding → testing ok', () => {
  56 + expect(unitRegistry.canTransition('coding', 'testing')).toBe(true);
  57 + });
  58 + it('pending → passed FORBIDDEN', () => {
  59 + expect(unitRegistry.canTransition('pending', 'passed')).toBe(false);
  60 + });
  61 + it('passed → pending ok (L2 reset)', () => {
  62 + expect(unitRegistry.canTransition('passed', 'pending')).toBe(true);
  63 + });
  64 + it('blocked → pending ok (人类干预)', () => {
  65 + expect(unitRegistry.canTransition('blocked', 'pending')).toBe(true);
  66 + });
  67 + });
  68 +
  69 + describe('markCoding / markTesting / pass', () => {
  70 + it('full L1 happy path', () => {
  71 + unitRegistry.markCoding(pid, 'auth-be');
  72 + expect(unitRegistry.get(pid, 'auth-be').status).toBe('coding');
  73 +
  74 + unitRegistry.markTesting(pid, 'auth-be');
  75 + expect(unitRegistry.get(pid, 'auth-be').status).toBe('testing');
  76 +
  77 + unitRegistry.pass(pid, 'auth-be', 'sha256:newhash00000000');
  78 + const u = unitRegistry.get(pid, 'auth-be');
  79 + expect(u.status).toBe('passed');
  80 + expect(u.contract_hash).toBe('sha256:newhash00000000');
  81 + });
  82 +
  83 + it('pending → testing throws', () => {
  84 + expect(() => unitRegistry.markTesting(pid, 'auth-be'))
  85 + .toThrow(unitRegistry.InvalidTransitionError);
  86 + });
  87 + });
  88 +
  89 + describe('fail + retries', () => {
  90 + it('coding/testing → failed accumulates retries', () => {
  91 + unitRegistry.markCoding(pid, 'auth-be');
  92 + unitRegistry.markTesting(pid, 'auth-be');
  93 + unitRegistry.fail(pid, 'auth-be', 'test red');
  94 + expect(unitRegistry.get(pid, 'auth-be').retries).toBe(1);
  95 +
  96 + unitRegistry.markCoding(pid, 'auth-be');
  97 + unitRegistry.markTesting(pid, 'auth-be');
  98 + unitRegistry.fail(pid, 'auth-be', 'test red 2');
  99 + expect(unitRegistry.get(pid, 'auth-be').retries).toBe(2);
  100 + });
  101 + });
  102 +
  103 + describe('block + reset', () => {
  104 + it('failed → blocked then reset', () => {
  105 + unitRegistry.markCoding(pid, 'auth-be');
  106 + unitRegistry.markTesting(pid, 'auth-be');
  107 + unitRegistry.fail(pid, 'auth-be', 'red');
  108 + unitRegistry.block(pid, 'auth-be', 'budget exceeded');
  109 + expect(unitRegistry.get(pid, 'auth-be').status).toBe('blocked');
  110 +
  111 + const r = unitRegistry.reset(pid, ['auth-be']);
  112 + expect(r.reset).toHaveLength(1);
  113 + expect(unitRegistry.get(pid, 'auth-be').status).toBe('pending');
  114 + expect(unitRegistry.get(pid, 'auth-be').retries).toBe(0);
  115 + });
  116 +
  117 + it('reset skips coding/testing units with reason', () => {
  118 + unitRegistry.markCoding(pid, 'auth-be');
  119 + const r = unitRegistry.reset(pid, ['auth-be', 'ghost']);
  120 + expect(r.reset).toHaveLength(0);
  121 + expect(r.skipped.find(s => s.id === 'auth-be').reason).toMatch(/coding/);
  122 + expect(r.skipped.find(s => s.id === 'ghost').reason).toBe('not_found');
  123 + });
  124 + });
  125 +
  126 + describe('l2GateOpen', () => {
  127 + it('false when any not passed', () => {
  128 + expect(unitRegistry.l2GateOpen(pid)).toBe(false);
  129 + });
  130 + it('true when all passed', () => {
  131 + for (const id of ['auth-be', 'auth-fe']) {
  132 + unitRegistry.markCoding(pid, id);
  133 + unitRegistry.markTesting(pid, id);
  134 + unitRegistry.pass(pid, id, 'sha256:0000000000000000');
  135 + }
  136 + expect(unitRegistry.l2GateOpen(pid)).toBe(true);
  137 + });
  138 + });
  139 +
  140 + describe('diffUnits', () => {
  141 + it('detects added/changed/removed', () => {
  142 + const oldList = [
  143 + { id: 'a-be', contract_hash: 'sha256:1' },
  144 + { id: 'b-be', contract_hash: 'sha256:2' },
  145 + ];
  146 + const newList = [
  147 + { id: 'a-be', contract_hash: 'sha256:1' }, // same
  148 + { id: 'b-be', contract_hash: 'sha256:CHANGED' }, // changed
  149 + { id: 'c-be', contract_hash: 'sha256:3' }, // added
  150 + ];
  151 + const r = unitRegistry.diffUnits(oldList, newList);
  152 + expect(r.added).toEqual(['c-be']);
  153 + expect(r.changed).toEqual(['b-be']);
  154 + expect(r.removed).toEqual([]);
  155 + });
  156 + });
  157 +});